13

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

现在,通常我也会将方法放在我的 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 对象。

4

5 回答 5

22

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

要查看 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; }
}

好的,所以这将是一个非常无聊的角色扮演游戏。但是这个类是有道理的。这里的一切都与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直接调用另一个Character人的TakeDamage方法也有点可疑,实际上你可能只是希望角色将其攻击“提交”给某种战斗引擎,但我认为这部分最好留作练习给读者。


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

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

……基本没用。它出什么问题了?

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

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

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

于 2010-01-27T06:24:17.017 回答
7

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

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

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

例如,如果您有 anElfCharacter和 a ,您的方法是否需要更改WizardCharacter?如果您的方法绝对不会改变并且完全自包含,那么也许静态方法是可以的……但即便如此,它也会使测试变得更加困难。

于 2010-01-27T06:24:34.223 回答
2

我认为这取决于您的角色行为是否可以改变。例如,如果您希望更改可用的操作(基于 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 您可以更轻松地创建自己的(模拟)角色。

于 2010-01-27T06:31:45.800 回答
2

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

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

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

于 2010-01-27T07:18:16.030 回答
0

我在设计类时采用的方法是 OO 所基于的方法是对象模拟现实世界的对象。

让我们来看看 Character... 设计一个 Character 类可能会很有趣。它可能是一个契约 ICharacter 这意味着任何想要成为角色的东西都应该能够执行 Walk()、Talk()、Attack(),具有一些属性,例如 Health、Mana。

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

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

于 2010-01-27T06:26:17.577 回答