Принцип единственной ответственности (SRP) и структура класса моего rpg выглядит "странно",

Я играю в ролевую игру просто для удовольствия и больше узнаю о принципах 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);
}

Обратите внимание, что теперь мне нужно включить объект Character, который я использую во всех моих методах CharacterActions. Правильно ли это подходит для использования SRP? Это, похоже, полностью противоречит концепции инкапсуляции ООП.

Или я делаю что-то совершенно неправильное здесь?

ОДНА вещь, о которой я люблю, это то, что мой класс символов очень ясен в том, что он делает, он просто представляет объект Character.

Ответы

Ответ 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. Эй, игры легкие!

Сфокусируйтесь на части 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? Наверное, нет.

Но посмотрим, что произойдет, когда мы начнем добавлять дополнительные функции - скажем, из типичной RPG эпохи 1990-х:

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);
}

... в основном бесполезно. Что с ним не так?

  • У него нет четкой цели; общие "действия" не являются единственной ответственностью;
  • Он не выполняет ничего, что Character уже не может сделать сам по себе;
  • Все зависит от Character и ничего больше;
  • Вероятно, вам придется выставлять части класса Character, которые вам действительно нужны, чтобы они были конфиденциальными/защищенными.

Класс CharacterActions разбивает инкапсуляцию Character и не добавляет ничего нечего. С другой стороны, класс DamageCalculator обеспечивает новую инкапсуляцию и помогает восстановить сплоченность исходного класса Character, исключив все ненужные зависимости и несвязанные функциональные возможности. Если мы хотим что-то изменить в отношении того, как рассчитывается ущерб, очевидно, где искать.

Я надеюсь, что теперь это лучше объяснит принцип.

Ответ 2

SRP не означает, что класс не должен иметь методов.. Вы создали структуру данных, а не полиморфный объект, который. Для этого есть все основания, но это, вероятно, не предназначено или необходимо в этом случае.

Один из способов, которым вы часто можете сказать, нарушает ли объект SRP, - это посмотреть на переменные экземпляра, которые используют методы внутри объекта. Если существуют группы методов, которые используют определенные переменные экземпляра, но не другие, это обычно знак того, что ваш объект можно разделить по группам переменных экземпляра.

Кроме того, вы, вероятно, не хотите, чтобы ваши методы были статическими. Вероятно, вы захотите использовать полиморфизм - способность делать что-то другое в своих методах на основе типа экземпляра, на который был вызван метод.

Например, ваши методы должны были бы измениться, если бы у вас были ElfCharacter и WizardCharacter? Если ваши методы абсолютно никогда не меняются и полностью самодостаточны, то, возможно, статичность в порядке... но даже тогда это делает тестирование намного сложнее.

Ответ 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 вместо символа, поскольку символы могут иметь разные свойства и поведение (Warlock, Wizard и т.д.), но они, вероятно, все имеют действия.

Отделяя действия, он также упрощает тестирование, так как теперь вы можете протестировать каждое действие без необходимости подключать полный символ, а с помощью ICharacter вы можете легко создать свой собственный (mock) символ.

Ответ 4

Я не знаю, что я бы назвал этот тип класса SRP в первую очередь. "Все, что связано с foo", обычно является признаком того, что вы не следуете за SRP (это нормально, оно не подходит для всех типов конструкций).

Хороший способ взглянуть на границы SRP - "есть ли что-нибудь, что я могу изменить относительно класса, который оставил бы большую часть класса неповрежденным?" Если да, отделите их. Или, другими словами, если вы касаетесь одного метода в классе, вам, вероятно, придется прикоснуться ко всем из них. Одним из преимуществ SRP является то, что он минимизирует объем того, что вы касаетесь, когда вы вносите изменения - если другой файл нетронутый, вы знаете, что не добавляли к нему ошибок!

Символьные классы, в частности, находятся в высокой степени опасности стать богословскими классами в РПГ. Возможным способом избежать этого было бы подойти к этому по-другому - начните с пользовательского интерфейса, и на каждом шаге просто убедитесь, что интерфейсы, которые вы хотите, существовали с точки зрения класса, который вы сейчас пишете, уже существуют. Кроме того, посмотрите на принцип инверсии управления и изменения дизайна при использовании IoC (не обязательно контейнер IoC).

Ответ 5

Метод, который я использую при проектировании классов и на котором основан OO, заключается в том, что объект моделирует объект реального мира.

Посмотрим на характер... Разработка класса персонажа может быть весьма интересной. Это может быть контракт ICharacter Это означает, что все, что хочет быть персонажем, должно иметь возможность выполнять Walk(), Talk(), Attack(), иметь некоторые свойства, такие как Health, Mana.

Тогда у вас может быть Мастер, Мастер, имеющий специальные свойства, и способ, которым он атакует, отличается от того, пусть говорит Истребитель.

Я склонен не слишком заставляться Принципами проектирования, но также думаю о моделировании объекта реального мира.