我的角色扮演游戏的单一职责原则(SRP)和类结构看起来“很奇怪”

发布于 2024-08-18 22:39:45 字数 1089 浏览 6 评论 0原文

我制作角色扮演游戏只是为了好玩并了解有关 SOLID 原则的更多信息。我首先关注的事情之一是 SRP。我有一个代表游戏中角色的“角色”类。它有诸如名称、生命值、法力、能力分数等内容。

现在,通常我也会将方法放在我的角色类中,所以它看起来像这样......

   public class Character
   {
      public string Name { get; set; }
      public int Health { get; set; }
      public int Mana { get; set; }
      public Dictionary<AbilityScoreEnum, int>AbilityScores { get; set; }

      // base attack bonus depends on character level, attribute bonuses, etc
      public static void GetBaseAttackBonus();  
      public static int RollDamage();
      public static TakeDamage(int amount);
   }

但由于 SRP,我决定将所有方法移出进入一个单独的类。我将该类命名为“CharacterActions”,现在方法签名如下所示...

public class CharacterActions
{
    public static void GetBaseAttackBonus(Character character);
    public static int RollDamage(Character character);
    public static TakeDamage(Character character, int amount);
}

请注意,我现在必须包含我在所有CharacterActions 方法中使用的Character 对象。这是利用 SRP 的正确方法吗?这似乎完全违背了 OOP 的封装概念。

或者我在这里做的事情完全错误?

我喜欢这一点的一件事是我的角色类非常清楚它的作用,它只是代表一个角色对象。

I'm making a role playing game just for fun and to learn more about the SOLID principles. One of the first things I'm focusing on is SRP. I have a "Character" class that represents a character in the game. It has things like Name, Health, Mana, AbilityScores, etc.

Now, normally I would also put methods in my Character class so it would look something like this...

   public class Character
   {
      public string Name { get; set; }
      public int Health { get; set; }
      public int Mana { get; set; }
      public Dictionary<AbilityScoreEnum, int>AbilityScores { get; set; }

      // base attack bonus depends on character level, attribute bonuses, etc
      public static void GetBaseAttackBonus();  
      public static int RollDamage();
      public static TakeDamage(int amount);
   }

But because of SRP I've decided to move all the methods out into a separate class. I named that class "CharacterActions" and now the method signatures look like this...

public class CharacterActions
{
    public static void GetBaseAttackBonus(Character character);
    public static int RollDamage(Character character);
    public static TakeDamage(Character character, int amount);
}

Notice that I now have to include the Character object I am using in all my CharacterActions methods. Is this the right way to go about leveraging SRP? It seems to go completely against the OOP concept of encapsulation.

Or am I doing something completely wrong here?

ONE thing I do like about this is that my Character class is very clear on what it does, it simply represents a Character object.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

牵你手 2024-08-25 22:39:45

更新 - 我重做了我的答案,因为睡了半个晚上,我真的觉得我之前的答案不太好。

要查看 SRP 的实际示例,让我们考虑一个非常简单的角色:

public abstract class Character
{
    public virtual void Attack(Character target)
    {
        int damage = Random.Next(1, 20);
        target.TakeDamage(damage);
    }

    public virtual void TakeDamage(int damage)
    {
        HP -= damage;
        if (HP <= 0)
            Die();
    }

    protected virtual void Die()
    {
        // Doesn't matter what this method does right now
    }

    public int HP { get; private set; }
    public int MP { get; private set; }
    protected Random Random { get; private set; }
}

好吧,所以这将是一个非常无聊的 RPG。但这个类有意义。这里的一切都与角色直接相关。每个方法要么是由Character执行的操作,要么是在Character上执行的操作。嘿,游戏很简单!

让我们重点关注攻击部分,并尝试让这部分变得有趣:

public abstract class Character
{
    public const int BaseHitChance = 30;

    public virtual void Attack(Character target)
    {
        int chanceToHit = Dexterity + BaseHitChance;
        int hitTest = Random.Next(100);
        if (hitTest < chanceToHit)
        {
            int damage = Strength * 2 + Weapon.DamageRating;
            target.TakeDamage(damage);
        }
    }

    public int Strength { get; private set; }
    public int Dexterity { get; private set; }
    public Weapon Weapon { get; set; }
}

现在我们已经取得进展了。角色有时会失手,并且伤害/命中会随着等级的增加而增加(假设 STR 也会增加)。非常好,但这仍然很乏味,因为它没有考虑到有关目标的任何信息。让我们看看是否可以解决这个问题:

public void Attack(Character target)
{
    int chanceToHit = CalculateHitChance(target);
    int hitTest = Random.Next(100);
    if (hitTest < chanceToHit)
    {
        int damage = CalculateDamage(target);
        target.TakeDamage(damage);
    }
}

protected int CalculateHitChance(Character target)
{
    return Dexterity + BaseHitChance - target.Evade;
}

protected int CalculateDamage(Character target)
{
    return Strength * 2 + Weapon.DamageRating - target.Armor.ArmorRating -
        (target.Toughness / 2);
}

此时,您的脑海中应该已经浮现出这个问题:为什么角色负责计算自己对目标的伤害?为什么它有这样的能力?这个类正在做的事情有一些无形的奇怪,但在这一点上它仍然有点模棱两可。只是为了将几行代码移出 Character 类,真的值得重构吗?可能不会。

但是让我们看看当我们开始添加更多功能时会发生什么 - 比如说一个典型的 20 世纪 90 年代的 RPG:

protected int CalculateDamage(Character target)
{
    int baseDamage = Strength * 2 + Weapon.DamageRating;
    int armorReduction = target.Armor.ArmorRating;
    int physicalDamage = baseDamage - Math.Min(armorReduction, baseDamage);
    int pierceDamage = (int)(Weapon.PierceDamage / target.Armor.PierceResistance);
    int elementDamage = (int)(Weapon.ElementDamage /
        target.Armor.ElementResistance[Weapon.Element]);
    int netDamage = physicalDamage + pierceDamage + elementDamage;
    if (HP < (MaxHP * 0.1))
        netDamage *= DesperationMultiplier;
    if (Status.Berserk)
        netDamage *= BerserkMultiplier;
    if (Status.Weakened)
        netDamage *= WeakenedMultiplier;
    int randomDamage = Random.Next(netDamage / 2);
    return netDamage + randomDamage;
}

这一切都很好,但在 中进行所有这些数字运算是不是有点荒谬字符类?这是一个相当简短的方法;在真正的角色扮演游戏中,这种方法可能会扩展到数百行,并带有豁免和所有其他形式的书呆子。想象一下,您引入了一位新程序员,他们说:我收到了对双击武器的请求,该武器应该使通常的伤害加倍;我需要在哪里进行更改?然后你告诉他,检查Character类。嗯?

更糟糕的是,也许游戏会添加一些新的东西,比如,哦,我不知道,背刺奖励,或其他类型的环境奖励。那么你到底应该如何在 Character 类中弄清楚这一点呢?你可能最终会向某个单身人士喊叫,比如:

protected int CalculateDamage(Character target)
{
    // ...
    int backstabBonus = Environment.Current.Battle.IsFlanking(this, target);
    // ...
}

讨厌。这太糟糕了。测试和调试这将是一场噩梦。那么我们该怎么办呢?将其从 Character 类中取出。 Character 类应该知道如何做 Character 逻辑上知道如何做的事情,并计算对目标的确切伤害不是其中之一。我们将为它开设一个课程:

public class DamageCalculator
{
    public DamageCalculator()
    {
        this.Battle = new DefaultBattle();
        // Better: use an IoC container to figure this out.
    }

    public DamageCalculator(Battle battle)
    {
        this.Battle = battle;
    }

    public int GetDamage(Character source, Character target)
    {
        // ...
    }

    protected Battle Battle { get; private set; }
}

好多了。这个类只做一件事。它的作用正如罐头上所说的那样。我们已经摆脱了单例依赖,所以这个类现在实际上可以进行测试,并且它感觉更正确,不是吗?现在我们的 Character 可以专注于 Character 操作:

public abstract class Character
{
    public virtual void Attack(Character target)
    {
        HitTest ht = new HitTest();
        if (ht.CanHit(this, target))
        {
            DamageCalculator dc = new DamageCalculator();
            int damage = dc.GetDamage(this, target);
            target.TakeDamage(damage);
        }
    }
}

即使现在,一个 Character 直接调用另一个 Character 仍然有点值得怀疑。 code> 的 TakeDamage 方法,实际上你可能只是希望角色将其攻击“提交”给某种战斗引擎,但我认为这部分最好留作练习读者。


现在,希望您明白为什么:

public class CharacterActions
{
    public static void GetBaseAttackBonus(Character character);
    public static int RollDamage(Character character);
    public static TakeDamage(Character character, int amount);
}

……基本上没有用。有什么问题吗?

  • 它没有明确的目的;通用“操作”不是单一责任;
  • 它无法完成任何 Character 本身无法完成的事情;
  • 它完全取决于角色,而不是其他;
  • 它可能需要您公开您真正想要私有/保护的 Character 类的部分。

CharacterActions 类打破了 Character 封装,并且几乎没有添加自己的内容。另一方面,DamageCalculator 类提供了新的封装,并通过消除所有不必要的依赖项和不相关的功能来帮助恢复原始 Character 类的内聚性。如果我们想改变伤害的计算方式,那么我们应该去哪里寻找是显而易见的

我希望现在能更好地解释这个原理。

Update - I've redone my answer because, after a half-night's sleep, I really didn't feel that my previous answer was very good.

To see an example of the SRP in action, let's consider a very simple character:

public abstract class Character
{
    public virtual void Attack(Character target)
    {
        int damage = Random.Next(1, 20);
        target.TakeDamage(damage);
    }

    public virtual void TakeDamage(int damage)
    {
        HP -= damage;
        if (HP <= 0)
            Die();
    }

    protected virtual void Die()
    {
        // Doesn't matter what this method does right now
    }

    public int HP { get; private set; }
    public int MP { get; private set; }
    protected Random Random { get; private set; }
}

OK, so this would be a pretty boring RPG. But this class makes sense. Everything here is directly related to the Character. Every method is either an action performed by, or performed on the Character. Hey, games are easy!

Let's focus on the Attack part and try to make this halfway interesting:

public abstract class Character
{
    public const int BaseHitChance = 30;

    public virtual void Attack(Character target)
    {
        int chanceToHit = Dexterity + BaseHitChance;
        int hitTest = Random.Next(100);
        if (hitTest < chanceToHit)
        {
            int damage = Strength * 2 + Weapon.DamageRating;
            target.TakeDamage(damage);
        }
    }

    public int Strength { get; private set; }
    public int Dexterity { get; private set; }
    public Weapon Weapon { get; set; }
}

Now we're getting somewhere. The character sometimes misses, and damage/hit go up with level (assuming that STR increases as well). Jolly good, but this is still pretty dull because it doesn't take into account anything about the target. Let's see if we can fix that:

public void Attack(Character target)
{
    int chanceToHit = CalculateHitChance(target);
    int hitTest = Random.Next(100);
    if (hitTest < chanceToHit)
    {
        int damage = CalculateDamage(target);
        target.TakeDamage(damage);
    }
}

protected int CalculateHitChance(Character target)
{
    return Dexterity + BaseHitChance - target.Evade;
}

protected int CalculateDamage(Character target)
{
    return Strength * 2 + Weapon.DamageRating - target.Armor.ArmorRating -
        (target.Toughness / 2);
}

At this point, the question should already be forming in your mind: Why is the Character responsible for calculating its own damage against a target? Why does it even have that ability? There's something intangibly weird about what this class is doing, but at this point it's still sort of ambiguous. Is it really worth refactoring just to move a few lines of code out of the Character class? Probably not.

But let's look at what happens when we start adding more features - say from a typical 1990s-era RPG:

protected int CalculateDamage(Character target)
{
    int baseDamage = Strength * 2 + Weapon.DamageRating;
    int armorReduction = target.Armor.ArmorRating;
    int physicalDamage = baseDamage - Math.Min(armorReduction, baseDamage);
    int pierceDamage = (int)(Weapon.PierceDamage / target.Armor.PierceResistance);
    int elementDamage = (int)(Weapon.ElementDamage /
        target.Armor.ElementResistance[Weapon.Element]);
    int netDamage = physicalDamage + pierceDamage + elementDamage;
    if (HP < (MaxHP * 0.1))
        netDamage *= DesperationMultiplier;
    if (Status.Berserk)
        netDamage *= BerserkMultiplier;
    if (Status.Weakened)
        netDamage *= WeakenedMultiplier;
    int randomDamage = Random.Next(netDamage / 2);
    return netDamage + randomDamage;
}

This is all fine and dandy but isn't it a little ridiculous to be doing all of this number-crunching in the Character class? And this is a fairly short method; in a real RPG this method might extend well into the hundreds of lines with saving throws and all other manner of nerditude. Imagine, you bring in a new programmer, and they say: I got a request for a dual-hit weapon that's supposed to double whatever the damage would normally be; where do I need to make the change? And you tell him, Check the Character class. Huh??

Even worse, maybe the game adds some new wrinkle like, oh I don't know, a backstab bonus, or some other type of environment bonus. Well how the hell are you supposed to figure that out in the Character class? You'll probably end up calling out to some singleton, like:

protected int CalculateDamage(Character target)
{
    // ...
    int backstabBonus = Environment.Current.Battle.IsFlanking(this, target);
    // ...
}

Yuck. This is awful. Testing and debugging this is going to be a nightmare. So what do we do? Take it out of the Character class. The Character class should only know how to do things that a Character would logically know how to do, and calculating the exact damage against a target really isn't one of them. We'll make a class for it:

public class DamageCalculator
{
    public DamageCalculator()
    {
        this.Battle = new DefaultBattle();
        // Better: use an IoC container to figure this out.
    }

    public DamageCalculator(Battle battle)
    {
        this.Battle = battle;
    }

    public int GetDamage(Character source, Character target)
    {
        // ...
    }

    protected Battle Battle { get; private set; }
}

Much better. This class does exactly one thing. It does what it says on the tin. We've gotten rid of the singleton dependency, so this class is actually possible to test now, and it feels a lot more right, doesn't it? And now our Character can concentrate on Character actions:

public abstract class Character
{
    public virtual void Attack(Character target)
    {
        HitTest ht = new HitTest();
        if (ht.CanHit(this, target))
        {
            DamageCalculator dc = new DamageCalculator();
            int damage = dc.GetDamage(this, target);
            target.TakeDamage(damage);
        }
    }
}

Even now it's a little questionable that one Character is directly invoking another Character's TakeDamage method, and in reality you'd probably just want the character to "submit" its attack to some sort of battle engine, but I think that part is best left as an exercise to the reader.


Now, hopefully, you understand why this:

public class CharacterActions
{
    public static void GetBaseAttackBonus(Character character);
    public static int RollDamage(Character character);
    public static TakeDamage(Character character, int amount);
}

...is basically useless. What's wrong with it?

  • It doesn't have a clear purpose; generic "actions" are not a single responsibility;
  • It fails to accomplish anything that a Character can't already do by itself;
  • It depends entirely on the Character and nothing else;
  • It will probably require you to expose parts of the Character class that you really want private/protected.

The CharacterActions class breaks the Character encapsulation and adds little to nothing of its own. The DamageCalculator class, on the other hand, provides a new encapsulation and helps to restore the cohesion of the original Character class by eliminating all of the unnecessary dependencies and unrelated functionality. If we want to change something about the way damage is calculated, it's obvious where to look.

I'm hoping that this explains the principle better now.

百思不得你姐 2024-08-25 22:39:45

SRP 并不意味着类不应该有方法。您所做的是创建一个数据结构而不是多态对象 > 那个。这样做有好处,但在这种情况下可能不是有意或不需要的。

通常可以判断对象是否违反 SRP 的一种方法是查看对象中的方法使用的实例变量。如果有一些方法组使用某些实例变量,但不使用其他实例变量,那么这通常表明您的对象可以根据实例变量组进行拆分。

另外,您可能不希望您的方法是静态的。您可能想要利用多态性——根据调用方法的实例的类型在方法中执行不同操作的能力。

例如,如果您有一个 ElfCharacter 和一个 WizardCharacter,您的方法是否需要更改?如果你的方法绝对不会改变并且完全独立,那么也许静态是可以的......但即便如此,它也会使测试变得更加困难。

SRP doesn't mean that a class shouldn't have methods. What you've done is created a data-structure rather than a polymorphic object that. There are benefits to doing so, but it's probably not intended or needed in this case.

One way you can often tell if an object is violating SRP is to look at the instance variables that the methods within the object use. If there are groups of methods that use certain instance variables, but not others, that's usually a sign that your object can be split up according the the groups of instance variables.

Also, you probably don't want your methods to be static. You'll likely want to leverage polymorphism -- the ability to do something different within your methods based on the type of the instance on which the method was called.

For example, would your methods need to change if you had an ElfCharacter and a WizardCharacter? If your methods will absolutely never change and are completely self contained, then perhaps static is alright... but even then it makes testing much more difficult.

小忆控 2024-08-25 22:39:45

我认为这取决于你的角色行为是否可以改变。例如,如果您希望更改可用的动作(基于 RPG 中发生的其他事情),您可以选择以下内容:

public interface ICharacter
{
    //...
    IEnumerable<IAction> Actions { get; }
}

public interface IAction
{
    ICharacter Character { get; }
    void Execute();
}

public class BaseAttackBonus : IAction
{
    public BaseAttackBonus(ICharacter character)
    {
        Character = character;
    }

    public ICharacter Character { get; private set; }   

    public void Execute()
    {
        // Get base attack bonus for character...
    }
}

这允许您的角色拥有您想要的任意数量的动作(这意味着您可以添加/删除动作而无需更改)字符类),并且每个操作仅负责其自身(但了解该字符)以及具有更复杂要求的操作,从 IAction 继承以添加属性等。您可能希望 Execute 有不同的返回值,并且您可能需要一系列操作,但您已经掌握了要点。

请注意使用 ICharacter 而不是角色,因为角色可能具有不同的属性和行为(术士、巫师等),但他们可能都有动作。

通过分离动作,它还使测试变得更加容易,因为您现在可以测试每个动作,而无需连接完整的角色,并且使用 ICharacter 您可以更轻松地创建自己的(模拟)角色。

I think it depends if your characters behaviour can change. For example, if you wanted the available actions to change (based on something else happening in the RPG) you could go for something like:

public interface ICharacter
{
    //...
    IEnumerable<IAction> Actions { get; }
}

public interface IAction
{
    ICharacter Character { get; }
    void Execute();
}

public class BaseAttackBonus : IAction
{
    public BaseAttackBonus(ICharacter character)
    {
        Character = character;
    }

    public ICharacter Character { get; private set; }   

    public void Execute()
    {
        // Get base attack bonus for character...
    }
}

This allows your character to have as many actions as you want (meaning you can add/remove actions without changing character class), and each action is responsible for only itself (but knows about the character) and for actions which have more complex requirements, inherit from IAction to add properties, etc. You'd probably want a different return value for Execute, and you may want a queue of actions, but you get the drift.

Note use of ICharacter instead of a Character, since characters may have different properties and behaviour (Warlock, Wizard, etc), but they probably all have actions.

By separating out actions, it also makes testing much easier, since you can now test each action without having to wire up a full character, and with ICharacter you can create your own (mock) character more easily.

我们的影子 2024-08-25 22:39:45

我不知道我是否真的会首先将这种类型的类称为 SRP。 “与 foo 相关的所有内容”通常表明您遵循 SRP(这没关系,它并不适合所有类型的设计)。

查看 SRP 边界的一个好方法是“我可以对班级进行任何更改,从而使班级的大部分内容保持不变吗?”如果是这样,请将它们分开。或者,换句话说,如果您接触类中的一个方法,您可能必须接触所有这些方法。 SRP 的优点之一是,它可以最大限度地减少您进行更改时所涉及的范围 - 如果另一个文件未受影响,您就知道您没有向其中添加错误!

在角色扮演游戏中,角色职业尤其有可能成为上帝职业。避免这种情况的一种可能方法是从不同的方式来解决这个问题 - 从您的 UI 开始,在每一步中,简单地断言您希望存在的接口从您当前正在编写的类的角度来看 已经存在了。另外,请研究控制反转原理以及使用 IoC(不一定是 IoC 容器)时设计如何变化。

I don't know that I'd really call that type of class SRP in the first place. "Everything dealing with foo" is usually a sign that you're not following SRP (which is okay, it's not appropriate for all types of designs).

A good way of looking at SRP boundaries is "is there anything I can change about the class that would leave most of the class intact?" If so, separate them out. Or, to put it another way, if you touch one method in a class you should probably have to touch all of them. One of the advantages of SRP is that it minimizes the scope of what you're touching when you make a change - if another file is untouched, you know you didn't add bugs to it!

Character classes in particular are at high degrees of danger of becoming God-classes in RPGs. A possible way of avoiding this would be to approach this from a different way - start with your UI, and at each step, simply assert that the interfaces you wish existed from the perspective of the class you're currently writing existed already. Also, look into the Inversion of Control principle and how design changes when using IoC (not necessarily an IoC container).

追风人 2024-08-25 22:39:45

我在设计类时采用的方法(也是面向对象的基础)是对象对现实世界的对象进行建模。

我们来看看角色...
设计一个角色类可能会非常有趣。
这可能是一份合同
我的角色
这意味着任何想要成为角色的人都应该能够执行Walk()、Talk()、Attack(),并拥有一些属性,例如生命值、法力值。

然后你可以拥有一个巫师,一个具有特殊属性的巫师,他的攻击方式与战士不同。

我倾向于不会太受设计原则的束缚,但也会考虑对现实世界的对象进行建模。

The method that I go about when designing classes and which is what OO is based on is that an object models a real world object.

Let's take a look at Character...
Designing a Character class can be quite interesting.
It might be a contract
ICharacter
This means that anything that wants to be a character should be able to perform Walk(), Talk(), Attack(), Have some Properties such as Health, Mana.

You could then have a Wizard, a Wizard having special properties and the way he attacks is different than, let's say a Fighter.

I tend to not get too forced by Design Principles, but also think about modelling a real world object.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文