【问题标题】:Single Responsibility Principle(SRP) and class structure of my rpg looks "weird"我的 rpg 的单一职责原则 (SRP) 和类结构看起来很“奇怪”
【发布时间】:2010-01-27 06:04:56
【问题描述】:

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

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

   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 的封装概念。

或者我在这里做错了什么?

我喜欢的一件事是我的 Character 类非常清楚它的作用,它只是表示一个 Character 对象。

【问题讨论】:

  • SRP 是单一职责原则。

标签: c# design-patterns solid-principles


【解决方案1】:

更新 - 我重做了我的答案,因为睡了半夜之后,我真的不觉得我之前的答案很好。

要查看 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 执行的操作,要么是在Character 上执行的操作。嘿,游戏很简单!

让我们专注于Attack 部分,并尝试让这部分变得有趣:

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 负责计算自己对目标的伤害?为什么它甚至有这种能力? 这个类在做什么有一些无形的奇怪,但在这一点上它仍然有点模棱两可。将几行代码移出Character 类真的值得重构吗?应该不会吧。

但让我们看看当我们开始添加更多功能时会发生什么——比如从典型的 1990 年代 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 课程中进行所有这些数字运算是不是有点荒谬?这是一个相当短的方法;在真正的 RPG 中,这种方法可能会扩展到数百行带有豁免和所有其他书呆子方式的行。想象一下,你请来了一个新程序员,他们说:我收到了一个双击武器的请求,它的伤害应该是正常情况下的两倍;我需要在哪里进行更改? 然后你告诉他,检查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 直接调用另一个 CharacterTakeDamage 方法还是有点问题,实际上你可能只是希望角色将其攻击“提交”到某种战斗中引擎,但我认为这部分最好留给读者作为练习。


现在,希望你明白为什么会这样:

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

...基本上没用。它有什么问题?

  • 它没有明确的目的;通用“行动”不是单一责任;
  • 它无法完成 Character 本身无法完成的任何事情;
  • 这完全取决于Character,仅此而已;
  • 可能需要您公开您真正希望私有/受保护的 Character 类的部分内容。

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

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

【讨论】:

  • 你看过这集 dimecasts 吗? dimecasts.net/Casts/CastDetails/88 这就是我的灵感来源。基本上,它们从一个报表类开始,该类具有打印报表和格式化报表的方法。在这一集结束时,他们创建了一个“ReportFormatter”和“ReportPrinter”类。我真的看不出我所做的和他们所做的有什么区别。如果你看过它,你能帮我理解我在做什么有什么不同吗?
  • @mikedev:ReportFormatterReportPrinter 之类的东西背后的想法是,有时当一个单独的操作非常复杂时,您希望将其封装在自己的类中以消除其中的一些复杂性从主要实体。为了适应您的示例,如果计算攻击奖励是一个非常复杂的操作,您可以将其抽象为 AttackBonusCalculator 或类似的东西。然而,简单地将Character 类中的所有 动作移动到通用“动作”类中并不会添加任何特别有用的东西。 HTH。
  • @mikedev:我要补充一点,@Aaronaught 所说的不是从 Character 类中删除 GetBaseAttackBonus,而是从该方法中调用 AttackBonusCalculator
  • 如果你想让你的角色既防守又进攻怎么办?还是撤退?您必须为班级添加更多职责。
  • @Si:SRP 并不意味着类只能有一种方法。 AttackDefend(以及 CastFleeUseItem 以及 what-have-you)都是特定于 Character 的操作,并且只有 Character。我们没有理由不能将它们添加到 Character 类中(嗯,除了它们可能取决于环境的原因,但我修改后的答案足够长,因为没有把它变成关于游戏设计的文章。 ..)
【解决方案2】:

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

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

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

例如,如果您有ElfCharacterWizardCharacter,您的方法是否需要更改?如果您的方法绝对不会改变并且完全自包含,那么也许静态方法没问题……但即便如此,它也会让测试变得更加困难。

【讨论】:

    【解决方案3】:

    我认为这取决于你的角色行为是否可以改变。例如,如果您希望更改可用的操作(基于 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 而不是 Character,因为角色可能具有不同的属性和行为(术士、巫师等),但它们可能都有动作。

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

    【讨论】:

    • 哈,我只是用 IAction 和 ICharacter 编写了一些示例代码
    • 澄清一下,ICharacter 接口中的“Actions”属性是否包含 ICharacter 可以执行的所有可能操作?
    • @mikedev,是的,我要写的是,你的角色不再需要负责决定哪些行动是可能的,或者何时执行。这可能是由于角色的外部因素,例如他们的环境。因此,您可能会考虑另一个(或两个)类,它们负责决定动作并执行它们。
    • 感谢您到目前为止的回答。假设我添加了一个喝健康药水的动作。现在,当我这样做时,我需要首先检查以确保角色的库存中至少有一种健康药水。这样的支票去哪儿了?将它放在“DrinkPotion : IAction”类中是否有意义,还是像您提到的那样将其放在另一个类中更有意义 - 用于决定动作并执行它们?
    • 我真的不认为这种间接级别真的增加了对 SRP 的理解。是的,在某些/许多情况下它可能是一个很好的设计,但 IMO 它只是进一步混淆了整个问题,尤其是这个例子的呈现方式 - 完全不清楚 IAction 如何取决于游戏的状态它引用的唯一类是ICharacter
    【解决方案4】:

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

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

    角色职业在角色扮演游戏中成为神级职业的风险尤其高。避免这种情况的一种可能方法是从不同的方式解决这个问题 - 从您的 UI 开始,在每一步中,只需从您当前正在编写的类的角度断言您希望存在的接口 em> 已经存在。此外,请研究控制反转原则以及使用 IoC(不一定是 IoC 容器)时设计如何变化。

    【讨论】:

      【解决方案5】:

      我在设计类时所采用的方法是面向对象的基础,即对象模拟现实世界的对象。

      让我们来看看角色... 设计一个 Character 类可能非常有趣。 可能是合同 I字符 这意味着任何想要成为角色的东西都应该能够执行 Walk()、Talk()、Attack(),并具有一些属性,例如 Health、Mana。

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

      我倾向于不被设计原则强迫,但也会考虑为现实世界的对象建模。

      【讨论】:

        猜你喜欢
        • 2011-05-07
        • 2011-04-19
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2010-11-26
        相关资源
        最近更新 更多