Подскажите правильную архитектуру для подбора предметов. Unity

Уже 2 дня ломаю голову. Если вкратце делаю игру и у меня реализованы отдельно 2 логики для оружия и здоровья. (старался их сделать универсальными) Концепция такая что нет как такового инвентаря. В оружии есть некоторое количество патронов и всё. После использования выкидывается и ты ищешь другое. Придерживался такой логики. Есть оружие и только оружие знает сколько в нём патронов игрок не должен знать ничего задача игрока воспроизвести выстрел если оружие подобрано и есть патроны. Так вот встала задача добавить типо бонусы подбирая которые в используемом оружии добавляются патроны. Проблема звучит так. Я не знаю как добавить оружию патронов так что подбираемые патроны заранее не знали об оружии (Для всего остального тоже самое). Не хочу нарушать SRP. Прошу дать какой-нибудь совет в какую сторону двигаться. Пробовал применить паттерн Visitor понял что ему тут не место. Также думал об Event Bus, но по-моему там получается такое же нарушение SRP или я мыслю куда-то не туда.


Ответы (2 шт):

Автор решения: Vladimir

Примерно так:

public enum WeaponType { Pistol, Rifle, GrenadeLauncher }

public class Ammo {
    public int Amount { get; set; }
    public WeaponType WeaponType { get; set; }
}

public class Weapon {
    int _ammo;

    public WeaponType WeaponType {get; }

    public Weapon(WeaponType weaponType, int initialAmmo) {
        WeaponType = weaponType;
        _ammo = initialAmmo;
    }
    
    public void AddAmmo(int ammo) => _amount = ammo;
    
    public void Shoot() {
        if (_ammo == 0)
            return;
            
        _ammo--;
    }
}

public class Player {
    Weapon _currentWeapon;
    
    public void OnWeaponPickedUp(Weapon newWeapon) => newWeapon = _currentWeapon;
    
    public void OnAmmoPickedUp(Ammo ammo) {
        if (_currentWeapon.WeaponType != ammo.WeaponType)
            return;
            
        _currentWeapon.AddAmmo(ammo.Amount);
    }
}

Класс Weapon ничего не знает о классе Ammo. Хотя имеет такое право, так как кто использует патроны? Так что сделать public void AddAmmo(Ammo ammo) вполне можно.

Класс Ammo ничего не знает о классе Weapon, и не должен. Если смущает WeaponType, то тогда вопрос, а какое отношение WeaponType имеет к ООП? WeaponType - это данные.

Класс Player знает и о том, и о другом. Так как тоже имеет такое право - кто подбирает оружие? Кто подбирает патроны? Если в вашем случае Player отвечает за что-то еще - ну так сделайте несколько классов, наприме PlayerMovement и PlayerArsenal, или там вообще Player, WeaponPicker и AmmoPicker, и тогда не будет SRP нарушаться.

SRP это не о том, что код в одном классе не должен касаться другого класса. Это о том, что именно мы оставляем в одном классе, а что выносим в другой. То есть о логике и данных, которые должны быть вместе или не должны.


Паттерн Visitor не имеет отношения к проблеме вообще, так как это паттерн преимущественно для работы с legacy-кодом, когда нужно обойти (ну там в цикле, например) формально не связанные друг с другом объекты не особо вдаваясь в детали их реализации.


События, команды, реактивность и разные их реализации созданы для общения объектов друг с другом, да. Но в большинстве случаев их задача - разорвать двустороннюю связь, когда объект A знает об объекте B, но не наоборот, при этом общение должно происходить в две стороны. Тогда один объект вызывает методы другого, а для общения в обратном направлении - подписывается на изменения. Да, есть реализации для общения в две стороны, но зачем это для данного конкретного случая? Какую практическую задачу это должно решить?


И, да, если мы говорим об уменьшении связности кода, то вместо конкретных типов можно использовать интерфейсы:

public class Ammo : IAmmo { ... }

public class Weapon : IWeapon {
    public void AddAmmo(IAmmo ammo) { ... }
}

public class Player : IPlayer {
    public void OnWeaponPickedUp(IWeapon newWeapon)
    public void OnAmmoPickedUp(IAmmo ammo) {
}

В этом случае, с точки зрения ООП, класс Weapon не знает о классе Ammo, а класс Player не знает ни о классе Ammo, ни о классе Weapon. Нужна ли такая абстракция? Для небольшого проекта - обычно нет. Для крупного - ну, если используется внедрение зависимостей, то может быть. Ну и сериализуемые поля Unity не дружат с интерфейсами. Так что если у вас MonoBehaviour'ы, и вы соединяете зависимости через Инспектор, то тогда точно нет.

→ Ссылка
Автор решения: Yaroslav

Имеется:

  • юнит в роли управляемого персонажа
  • подбираемое оружие, которым персонаж может взять в руки и передавать ему импут атаки
  • подбираемые потроны, которые подбираются, если тип взятого оружия им соответствует

Верно? Не верно! Действующих ответственностей НАМНОГО больше! Кто-то что-то подбирает и кто-то куда-то это всё девает, кто-то как-то этим пользуется, а кто-то занимается визуализацией. Логистика это тоже акторы, а она зачастую состоит из нескольких звеньев, не только в архитектуре кода, но и в жизни.


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

У юнита есть возможность эквиапть что-то в руки и его не волнует что. Главное, что эта хрень под некой абстракцией IHandsTakeable и у него есть набор методов для инпутов и команд. Плевать оружие это или ящик тащишь и плевать откуда это, из инвенторя или с пола.

public interface IHandsTakeable
{
    event Action DropRequired;
    void OnTake (object master);
    void Drop ();
    void StartUse ();
    void StopUse ();
}
public class Unit : MonoBehaviour
{
    public IHandsTakeable HandsItem { get; private set; };

    public void TakeHandsItem (IHandsTakeable item)
    {
        DropHandsItem();
        HandsItem = item;
        HandsItem.OnTake(this);
    }

    public void DropHandsItem ()
    {
        HandsItem?.Drop();
        HandsItem = null;
    }

    public void StartAttack ()
        => HandsItem?.StartUse();

    public void StopAttack ()
        => HandsItem?.StopUse();
}

Про подбор предметов, я писал большой пост. Достаточно написать IDropCollector, чьей единственной ответственностью и будет разбираться с тем, как поступать с конкретно этим типом лута.

public interface IAmmoUser 
{
    string RequiredType { get; }

    void AddAmmo (int amount);
}
public class AmmoCollector : IDropCollector
{
    private readonly Unit _unit;

    public AmmoCollector (Unit unit)
    {
        _unit = unit;
    }

    public bool TryCollect (ICollectobleDrop target)
    {
        if (target is CollectableAmmo ammoTarget &&
            _unit.HandsItem != null &&
            _unit.HandsItem is IAmmoUser au &&
            au.RequiredType == ammoTarget.Type)
        {
            au.AddAmmo(au.Amount);
            return true;
        }
        return false;
    }
}

п.с. RequiredType тут string ключ, может быть "pistol" или "9x18mm", но в инспекторе лучше использовать enum, который всегда можно .ToString(), что бы не прописывать строковые литералы ручками.


Но если прямо совсем по ООПшному, то в оружии не должно быть никакого общего числа патронов, оно должно только стрелять по команде. В нём должен быть делегат (Func<bool> shotCondition), который проверяет, можно ли произвести выстрел или произойдёт файл (проиграть звук пустого магазина). Если shotCondition == null, то оружие будет стрелять сколько хочет, например в руках мобов. В иных случаях, это метод который проверяющий предметы в инвенторе или цифры ещё где. Максимум в оружии может быть число патронов в магазине, определяющее, когда требуется перезарядка. То есть сущьность под IHandsTakeable это не Weapon реализующий этот интерфейс, а обёртка в котором Weapon и число патронов, которое он может использовать.

public class TakeableWeapon : IHandsTakeable, IAmmoUser 
{
    private int _ammo;
    
    public TakeableWeapon (Weapon weapon) 
    {
        Weapon = weapon;
    }

    public Weapon { get; }

    public string RequiredType => Weapon.AmmoType;


    public void OnTake (object master)
    {
        // только у мастера умеющего подбирать лут, будут конечные патроны
        if (master is Component cm)
            SetInfinityAmmo(cm.GetComponent<DropCollector>() == null);
        else
            SetInfinityAmmo(true);
    }


    public void StartUse () => Weapon.StartShoot();

    public void StopUse () => Weapon.StopShoot();

    public void AddAmmo (int amount) => _ammo += amount;

    private bool HaveAmmo () => _ammo > 0;

    private void OnShot () => _ammo--;

    private void SetInfinityAmmo (bool infinity)
    {
        if (infinity)
        {
            Weapon.ShotCondition = null;
            Weapon.Fired -= OnShoot;
        }
        else
        {
            Weapon.ShotCondition = HaveAmmo;
            Weapon.Fired -= OnShoot;
        }
    }
}

То есть класс Weapon должен быть таким, что будь это предмет инвенторя, который стреляет патронами из инвенторя, он бы никак не поменялся, потому что у него та-же самая ответственность.


Все между собой общаются через интерфейсы и никто ни от кого не зависит.

  • В качестве IHandsTakeable, Unit может взаимодействовать с чем угодно, хоть поднять булыжник и швырнуть. За визуализацию кстати отвечает UnitView и в IHandsTakeable могут быть имена необходимых при его использовании анимаций.
  • DropCollector можно дать хоть пылесосу, который имеет свой IDropCollector уничтожающий все предметы без разбору как мусор.
  • TakeableWeapon можно установить хоть на турель или декорацию, никакой привязки к владельцу нет, а после уничтожения, это валыну можнет подобрать персонаж.
→ Ссылка