单一责任原则(SRP)和我的RPG的类结构看起来“怪异”

时间:2010-01-27 06:04:56

标签: c# design-patterns solid-principles

我正在制作角色扮演游戏,只是为了好玩,并了解有关SOLID原则的更多信息。我关注的第一件事就是SRP。我有一个“角色”类,代表游戏中的角色。它有Name,Health,Mana,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对象。

5 个答案:

答案 0 :(得分: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; }
}

好的,所以这将是一个非常无聊的RPG。但是这个类有意义。这里的所有内容都与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类中移出几行代码真的值得重构吗?可能不是。

但让我们来看看当我们开始添加更多功能时会发生什么 - 比如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课程。 嗯??

更糟糕的是,也许游戏会增加一些新的皱纹,哦,我不知道,背刺奖金,或其他类型的环境奖金。那你到底应该怎样在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的{​​{1}}方法也有点疑问,实际上你可能只想让角色“提交”它攻击某种战斗引擎,但我认为这部分最好留给读者练习。


现在,希望你理解为什么:

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类的部分内容。

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

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

答案 1 :(得分:7)

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

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

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

例如,如果您有ElfCharacterWizardCharacter,您的方法是否需要更改?如果你的方法永远不会改变并且完全是自包含的,那么静态就好了......但即便如此,它也会使测试变得更加困难。

答案 2 :(得分: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,您可以更轻松地创建自己的(模拟)角色。

答案 3 :(得分:2)

我不知道我真的会在第一时间称这种类型的SRP。 “处理foo的一切”通常都表明你在SRP之后(这没关系,它不适合所有类型的设计)。

查看SRP边界的一个好方法是“有什么我可以改变的类,这将使大多数类保持原样吗?”如果是这样,将它们分开。或者,换句话说,如果你触摸一个类中的一个方法,你可能需要触摸所有这些方法。 SRP的一个优点是它可以最大限度地减少您在进行更改时触摸的范围 - 如果另一个文件未被触及,您知道您没有添加错误!

特别是角色类在RPG中成为神级的危险程度很高。避免这种情况的一种可能方法是从不同的方式处理 - 从您的UI开始,并在每一步,从您当前正在编写的类的角度断言您希望存在的接口 已经存在。另外,请查看控制反转原理以及使用IoC时设计如何变化(不一定是IoC容器)。

答案 4 :(得分:0)

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

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

然后你可以拥有一个向导,一个具有特殊属性的向导,以及他攻击的方式不同,比如说战士。

我倾向于不会过于强迫设计原则,而是考虑建模现实世界的对象。