Единый принцип ответственности (SRP) и структура классов моей RPG выглядит «странно» - PullRequest
13 голосов
/ 27 января 2010

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

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

Или я тут что-то не так делаю?

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

Ответы [ 5 ]

22 голосов
/ 27 января 2010

Обновление - я переделал свой ответ, потому что после полуночного сна я действительно не чувствовал, что мой предыдущий ответ был очень хорошим.

Чтобы увидеть пример 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? Наверное, нет.

Но давайте посмотрим, что произойдет, когда мы начнем добавлять больше функций - скажем, из типичной 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, устраняя все ненужные зависимости и не связанные функциональные возможности. Если мы хотим что-то изменить в способе расчета урона, очевидно , где искать.

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

7 голосов
/ 27 января 2010

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

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

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

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

2 голосов
/ 27 января 2010

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

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

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

2 голосов
/ 27 января 2010

Я думаю, это зависит от того, может ли поведение ваших персонажей измениться. Например, если вы хотите, чтобы изменились доступные действия (на основе чего-то еще происходящего в 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 вы можете легче создавать свой собственный (ложный) персонаж.

0 голосов
/ 27 января 2010

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

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

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...