Пишем игровую логику на C#. Часть 2/2

  • Tutorial
Это продолжение предыдущей статьи. Мы шаг за шагом создаем движок, на котором будет работать игровая логика нашей экономической стратегии. Если вы видите это впервые — настоятельно рекомендую начать с Части 1, так как это зависимое продолжение и требует ее контекста.

Как и раньше — внизу статьи вы можете найти полный код на ГитХаб и ссылку на бесплатное скачивание.





План работы


1. Настраиваем проекты
2. Создаем ядро (базовые сооружения)
3. Добавляем и тестируем первые команды — построить строение и модуль
4. Выносим настройки строений и модулей в отдельный файл
5. Добавляем течение времени
6. Добавляем Constructible, строения теперь строятся некоторое время
7. Добавляем ресурсы, для постройки необходимы ресурсы
8. Добавляем цикл производства — модуль потребляет и выдает ресурсы

Добавляем Constructible


Давайте теперь что-то привяжем к течению времени. Пусть постройки и модули строятся не сразу, а несколько ходов (зависимо от конфигурации). Для начала во все настройки добавим пункт ConstructionTime. Если ConstructionTime равно нулю — структуру построить невозможно.

public class BuildingConfig
{
    // ...
    public int ConstructionTime;
}
public class ModuleConfig
{
    // ...
    public int ConstructionTime; 
}

Не забываем добавить настройки в фабрику:

public class Factory
{
    // ...
            Type = BuildingType.PowerPlant,
            ConstructionTime = 8,
    // ...
            Type = BuildingType.Smeltery,
            ConstructionTime = 10,
    // ...
            Type = BuildingType.Roboport,
            ConstructionTime = 12,
            
    // ...
    
            Type = ModuleType.Generator,
            ConstructionTime = 5
    // ...
            Type = ModuleType.Furnace,
            ConstructionTime = 6
    // ...
            Type = ModuleType.Digger,
            ConstructionTime = 7
    // ...
            Type = ModuleType.Miner,
            ConstructionTime = 8
    // ...
}

Теперь создадим класс Progression, которым мы будем реализовывать любые прогрессии, которые текут во времени, например, строительство.

public class Progression
{
    public readonly int Time;

    public int Progress { get; private set; }

    public bool IsFake {
        get { return Time == 0; }
    }

    public bool IsReady {
        get { return IsFake || Progress >= Time; }
    }

    public bool IsRunning
    {
        get { return !IsReady && Progress > 0; }
    }

    public Progression (int time)
    {
        Time = time;
        Progress = 0;
    }

    public void AddProgress ()
    {
        if (!IsReady) Progress++;
    }

    public void Complete ()
    {
        if (!IsReady) Progress = Time;
    }

    public void Reset ()
    {
        Progress = 0;
    }
}

Теперь добавим в наши комнаты и модули возможность постройки.

public class Building
{
    // ...
        
    public readonly Progression Constructible;
    
    // ...

    public Building (BuildingConfig config)
    {
        // ...
        Constructible = new Progression(config.ConstructionTime);
    }

public class Module
{
    // ...
    
    public readonly Progression Constructible;

    public Module (ModuleConfig config)
    {
        // ...
        Constructible = new Progression(config.ConstructionTime);
    }

И запретим постройку модулей в еще не построенной комнате:

public class ModuleConstruct : Command
{
    // ...
    
    protected override bool Run ()
    {
        // ...
        if (!Building.Constructible.IsReady) {
            return false;
        }

Само собой после этого упали тесты, потому мы добавим в тесты CorrectConstruction, IncorrectConstruction, CantConstructInWrongBuilding и ModulesLimits после успешного выполнения команды BuildingConstruct вызов метода Complete (да-да, специально для этого мы его и создали)

room.Building.Constructible.Complete()

А для проверки на невозможность построить в еще не законченной комнате напишем отдельный тест:

[TestMethod]
public void CantConstructInUncompleteBuilding ()
{
    var core = new GameLogic.Core();
    var room = core.Ship.GetRoom(0);

    new BuildingConstruct(
        room,
        core.Factory.ProduceBuilding(BuildingType.PowerPlant)
    )
    .Execute(core);

    Assert.IsFalse(
        new ModuleConstruct(
            room.Building,
            core.Factory.ProduceModule(ModuleType.Generator),
            2
        )
        .Execute(core)
        .IsValid
    );
}

Но теперь давайте сделаем, чтобы комната строилась не только по мановению руки богов мира нашей игры, но и просто со временем. Для этого создадим специальную команду и будем вызывать ее каждый ход:
public class NextTurn : Command
{
    protected override bool Run ()
    {
        new ConstructionProgress().Execute(Core);
        // ..
    }
}

public class ConstructionProgress : Command
{
    protected override bool Run ()
    {
        foreach (var room in Core.Ship.Rooms) {
            BuildingProgress(room.Building);
        }

        return true;
    }

    private void BuildingProgress (Building building)
    {
        building.Constructible.AddProgress();

        foreach (var module in building.Modules) {
            module.Constructible.AddProgress();
        }
    }
}

И сразу покроем тестами, которые покажут, что код работает прекрасно:
[TestMethod]
public void Constructible ()
{
    const int smelteryTime = 10;
    const int furnaceTime = 6;

    var core = new GameLogic.Core();
    var room = core.Ship.GetRoom(0);

    // Smeltery

    new BuildingConstruct(
        room,
        core.Factory.ProduceBuilding(BuildingType.Smeltery)
    )
    .Execute(core);

    Assert.IsFalse( room.Building.Constructible.IsReady );

    new NextTurnCount(smelteryTime - 1).Execute(core);

    Assert.IsFalse(room.Building.Constructible.IsReady);

    new NextTurn().Execute(core);

    Assert.IsTrue(room.Building.Constructible.IsReady);

    // Furnace
    new ModuleConstruct(
        room.Building,
        core.Factory.ProduceModule(ModuleType.Furnace),
        2
    ).Execute(core);

    var module = room.Building.GetModule(2);

    Assert.IsFalse( module.Constructible.IsReady );

    new NextTurnCount(furnaceTime - 1).Execute(core);

    Assert.IsFalse(module.Constructible.IsReady);

    new NextTurn().Execute(core);

    Assert.IsTrue(module.Constructible.IsReady);
}



Добавляем ресурсы


Для того, чтобы что-то создать сначала необходимо что-нибудь разрушить и собрать металлолом. Давайте реализуем ресурсы, чтобы игроку пришлось оплачивать свои постройки. Ресурсов будет три — Энергия, Руда и Металл.

public enum ResourceType
{
    Energy,
    Ore,
    Metal
}

Также создадим Банк, где игрок будет хранить и откуда забирать ресурсы.

public class Bank
{
    private readonly Dictionary<ResourceType, int> resources = new Dictionary<ResourceType, int>();

    public int Get (ResourceType type)
    {
        return resources.ContainsKey(type) ? resources[type] : 0;
    }

    public void Change (ResourceType type, int value)
    {
        var current = Get(type);

        if (current + value < 0) {
            throw new ArgumentOutOfRangeException("Not enought " + type + " in bank");
        }

        resources[type] = current + value;
    }
}

public class Core
{
    // ...
    public readonly Bank Bank = new Bank();
}

Теперь добавляем цену производства в настройки модулей и строений:

public class BuildingConfig
{
    // ...
    public Dictionary<ResourceType, int> ConstructionCost;
}

public class ModuleConfig
{
    // ...
    public Dictionary<ResourceType, int> ConstructionCost;
}

public class Factory
{
    // ...
    Type = BuildingType.PowerPlant,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 20 }},
    // ...
    Type = BuildingType.Smeltery,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 20 }},
    // ...
    Type = BuildingType.Roboport,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 20 }},
    // ...
    // ...
    Type = ModuleType.Generator,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Furnace,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Digger,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Miner,
    ConstructionCost = new Dictionary<ResourceType, int>() {{ ResourceType.Metal, 40 }},
    // ...
}

Теперь добавим команду, которая позволяет платить ресурсы и сразу же попробуем ее в деле (в тестах):

public class Pay : Command
{
    public readonly Dictionary<ResourceType, int> Cost;

    public Pay (Dictionary<ResourceType, int> cost)
    {
        Cost = cost;
    }

    protected override bool Run ()
    {
        // Если хотя бы одного ресурса не хватаем - отменяем всю оплату и возвращаем ошибку
        if (Cost.Any(item => Core.Bank.Get(item.Key) < item.Value)) {
            return false;
        }

        // Если всех хватает - забираем из банка
        foreach (var item in Cost) {
            Core.Bank.Change(item.Key, -item.Value);
        }

        return true;
    }
}

[TestClass]
public class Player
{
    [TestMethod]
    public void Payment ()
    {
        var core = new Core();

        core.Bank.Change(ResourceType.Metal, 100);
        core.Bank.Change(ResourceType.Ore, 150);

        Assert.IsFalse(
            new Pay(new Dictionary<ResourceType, int>{
                { ResourceType.Metal, 100 },
                { ResourceType.Ore, 2000 }
            })
            .Execute(core)
            .IsValid
        );

        Assert.AreEqual(100, core.Bank.Get(ResourceType.Metal));
        Assert.AreEqual(150, core.Bank.Get(ResourceType.Ore));

        Assert.IsTrue(
            new Pay(new Dictionary<ResourceType, int>{
                { ResourceType.Metal, 100 },
                { ResourceType.Ore, 30 }
            })
            .Execute(core)
            .IsValid
        );

        Assert.AreEqual(0, core.Bank.Get(ResourceType.Metal));
        Assert.AreEqual(120, core.Bank.Get(ResourceType.Ore));
    }
}

Оплата работает корректно и начать платить за постройки и модули довольно просто — добавим вызов команды Pay в качестве последней валидации (она должна быть последней, если мы не хотим, чтобы после оплаты другая проверка не дала построить конструкцию):

public class BuildingConstruct : Command
{
    // ...

    protected override bool Run ()
    {
        // ...
        
        if (!new Pay(Building.Config.ConstructionCost).Execute(Core).IsValid) {
            return false;
        }

        Room.Building = Building;
        
        return true;
    }
}

public class ModuleConstruct : Command
{
    // ...

    protected override bool Run ()
    {
        // ...

        if (!new Pay(Module.Config.ConstructionCost).Execute(Core).IsValid) {
            return false;
        }

        Building.SetModule(Position, module);

        return true;
    }
}

К счастью, у нас снова отвалились тесты (к счастью, потому что это значит, что они отлично выполняют свою работу).

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

core.Bank.Change(ResourceType.Metal, 1000);

И пишем тест на постройку с недостачей ресурсов:
[TestMethod]
public void CantBuiltCostly ()
{
    var core = new GameLogic.Core();
    var room = core.Ship.GetRoom(0);

    core.Bank.Change(ResourceType.Metal, 3);
            
    Assert.IsFalse(
        new BuildingConstruct(
            room,
            core.Factory.ProduceBuilding(BuildingType.Smeltery)
        )
        .Execute(core)
        .IsValid
    );
}



Добавляем цикл производства


Забирать ресурсы, конечно, приятно, но давать значительно приятнее. Давайде запрограммируем возможность запускать производственные цепочки. Каждый модуль сможет скушать определенное количество сырья и потом выдать готовый материал. Снова начинаем с конфигурации:

public class ModuleConfig
{
    // ...
    
    public int CycleTime; // сколько времени модуль будет перетравливать сырье
    public Dictionary<ResourceType, int> CycleInput; // сколько сырья
    public Dictionary<ResourceType, int> CycleOutput; // какой выход готовой продукции
}

public class Module
{
    // ...
    public readonly Progression Cycle;

    public Module (ModuleConfig config)
    {
        // ...
        
        Cycle = new Progression(config.CycleTime);
    }
}

public class Factory
{
    // ...

    { ModuleType.Generator, new ModuleConfig() {
        // ...

        CycleTime = 12,
        CycleInput = null, // электростанция ничего не требует, только дает
        CycleOutput = new Dictionary<ResourceType, int>() {
            { ResourceType.Energy, 10 }
        },
    }},
    { ModuleType.Furnace  , new ModuleConfig() {
        // ...

        CycleTime = 16,
        CycleInput = new Dictionary<ResourceType, int>() {
            { ResourceType.Energy, 6 },
            { ResourceType.Ore, 4 },
        },
        CycleOutput = new Dictionary<ResourceType, int>() {
            { ResourceType.Metal, 5 }
        }
    }},
    { ModuleType.Digger   , new ModuleConfig() {
        // ...

        CycleTime = 18,
        CycleInput = new Dictionary<ResourceType, int>() {
            { ResourceType.Energy, 2 }
        },
        CycleOutput = new Dictionary<ResourceType, int>() {
            { ResourceType.Ore, 7 }
        }
    }},
    { ModuleType.Miner    , new ModuleConfig() {
        // ...
        
        CycleTime = 32,
        CycleInput = new Dictionary<ResourceType, int>() {
            { ResourceType.Energy, 8 }
        },
        CycleOutput = new Dictionary<ResourceType, int>() {
            { ResourceType.Ore, 40 }
        }
    }}

Теперь добавим в каждый ход прогресс по производству:

public class NextTurn : Command
{
    protected override bool Run ()
    {
        new CycleProgress().Execute(Core); // Добавьте его в начало, это будет важно в тестах
        // ...
    }
}

public class CycleProgress : Command
{
    protected override bool Run ()
    {
        foreach (var room in Core.Ship.Rooms) {
            BuildingProgress(room.Building);
        }

        return true;
    }

    private void BuildingProgress (Building building)
    {
        if (!building.Constructible.IsReady) return;

        foreach (var module in building.Modules) {
            ModuleProgress(module);
        }
    }

    private void ModuleProgress (Module module)
    {
        if (!module.Constructible.IsReady || module.Cycle.IsFake) {
            return;
        }
        
        // Добавляем прогресс только если модуль уже запущен (ресурсы были заплачены)
        // Или если мы можем запустить его сейчас (заплатить ресурсы)
        if (module.Cycle.IsRunning || TryStartCycle(module)) {
            AddStep(module);
        }
    }

    private void AddStep (Module module)
    {
        module.Cycle.AddProgress();

        // Если после добавления прогресса работа модуля завершена...
        if (module.Cycle.IsReady) {
            // ... отдаем игроку его ресурсы
            CycleOutput(module);
            // ... и обнуляем прогресс, следующий раз ему придется запускаться сначала
            module.Cycle.Reset();
        }
    }

    private bool TryStartCycle (Module module)
    {
        if (module.Config.CycleInput == null) {
            return true;
        }
        
        // Пытаемся заплатить ресурсы и если удается - модуль запущен
        return new Pay(module.Config.CycleInput).Execute(Core).IsValid;
    }

    private void CycleOutput (Module module)
    {
        foreach (var item in module.Config.CycleOutput)
        {
            // Отдаем игроку каждый ресурс, который ему был нужен
            Core.Bank.Change(item.Key, item.Value);
        }
    }
}

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

public class Cycle
{
    [TestMethod]
    public void CheckCycle ()
    {
        var buildingConfig = new BuildingConfig() {
            Type = BuildingType.Smeltery,
            ModulesLimit = 1,
            AvailableModules = new [] { ModuleType.Furnace }
        };

        var moduleConfig = new ModuleConfig() {
            Type = ModuleType.Furnace,

            ConstructionTime = 2,
            ConstructionCost = new Dictionary<ResourceType, int>() {
                { ResourceType.Metal, 10 }
            },

            CycleTime = 4,
            CycleInput = new Dictionary<ResourceType, int>() {
                { ResourceType.Ore, 10 },
                { ResourceType.Energy, 5 }
            },
            CycleOutput = new Dictionary<ResourceType, int>() {
                { ResourceType.Metal, 1 }
            }
        };

        var core = new Core();
        core.Bank.Change(ResourceType.Metal, 10);
        core.Bank.Change(ResourceType.Ore, 80);
        core.Bank.Change(ResourceType.Energy, 10);

        var building = new Building(buildingConfig);
        core.Ship.GetRoom(0).Building = building;

        var module = new Module(moduleConfig);

        Assert.IsTrue(
            new ModuleConstruct(building, module, 0)
                .Execute(core)
                .IsValid
        );

        new NextTurn().Execute(core);
        
        Assert.IsFalse(module.Cycle.IsRunning);

        new NextTurn().Execute(core);

        Assert.IsTrue(module.Constructible.IsReady);
        Assert.IsFalse(module.Cycle.IsRunning);

        new NextTurn().Execute(core);
        Assert.IsTrue(module.Cycle.IsRunning);
        Assert.AreEqual(1, module.Cycle.Progress);

        Assert.AreEqual(70, core.Bank.Get(ResourceType.Ore));
        Assert.AreEqual(5, core.Bank.Get(ResourceType.Energy));
        Assert.AreEqual(0, core.Bank.Get(ResourceType.Metal));

        new NextTurnCount(3).Execute(core);
        Assert.IsFalse(module.Cycle.IsRunning);

        Assert.AreEqual(70, core.Bank.Get(ResourceType.Ore));
        Assert.AreEqual(5, core.Bank.Get(ResourceType.Energy));
        Assert.AreEqual(1, core.Bank.Get(ResourceType.Metal));

        new NextTurn().Execute(core);
        Assert.IsTrue(module.Cycle.IsRunning);

        Assert.AreEqual(60, core.Bank.Get(ResourceType.Ore));
        Assert.AreEqual(0, core.Bank.Get(ResourceType.Energy));
        Assert.AreEqual(1, core.Bank.Get(ResourceType.Metal));

        new NextTurnCount(3).Execute(core);
        Assert.IsFalse(module.Cycle.IsRunning);

        Assert.AreEqual(2, core.Bank.Get(ResourceType.Metal));

        new NextTurn().Execute(core); // Cant launch because of Energy leak
        Assert.IsFalse(module.Cycle.IsRunning);
        Assert.AreEqual(60, core.Bank.Get(ResourceType.Ore));
        Assert.AreEqual(0, core.Bank.Get(ResourceType.Energy));
    }
}



Конец


Итак, тесты запустились корректно и мы смогли сделать минимальную версию нашего продукта. Класс Factory получился раздутым, но если вынести настройки в JSON, то и он будет вполне ничего. Используя Json.NET нам необходимо написать что-то вроде этого:

Настройки в JSON
var files = Directory.GetFiles(path + "/Items/Modules", "*.json", SearchOption.AllDirectories);
var modules = new List<ModuleConfig>();

foreach (var file in modules) {
    var content = File.ReadAllText(file);
    modules.Add( JsonConvert.DeserializeObject<ModuleConfig>(content) );
}


{
    "Type": "Generator",
    
    "ConstructionTime": 5,
    "ConstructionCost": {
        "Metal": 10
    },
    
    "CycleTime": 12,
    "CycleInput": {
        "Energy" 6,
        "Ore": 4,
    },
    "CycleOutput": {
        "Energy": 10
    }
}


Для тех, кто просто любит код — есть отдельный репозиторий на ГитХаб

Кроме этого, если вас интересуют вопросы по разработке SpaceLab — задавайте, отвечу на них в комментариях или в отдельной статье

Скачать для Windows, Linux, Mac бесплатно и без СМС, а так же поддержать нас можно на странице SpaceLab на GreenLight
Метки:
Поделиться публикацией
Комментарии 15
  • 0
    Спасибо за статьи, было интересно почитать. Есть ли в планах статья про продвижение игры, успехи на GreenLight и т.п.?
    • 0
      Спасибо) Пока такого в планах нету, потому что я не привык рассказывать о том, в чем плохо разбираюсь)
      Прогресс на Гринлайт пока неплохой, но каждый голос важен.
    • 0
      В чем удобность команд?
      Читал про StrangeIOC, там можно создавать цепочки из сигналов и команд. Но у вас такого не видел.
      Еще, возможно, когда в команде много программистов, то удобнее, когда каждая операция в своем файле (проще мерж, лучше видно историю изменений).
      Но не нравится мне, что надо создавать класс/файл на каждый чих.
      • +1
        Можете, пожалуйста, поуточнять? Что имеете ввиду под цепочками? У меня команда может вызывать другую команду — чем не цепочка. Для вас система недостаточно сложная? Просто для данной игры более сложная система не нужна. Вот когда делал Генералы — там было сложнее и команды были иерархическими, у команд были дети, сложная система событий, которая позволяла подписаться до команды, в середине команды, после команды, более сложная валидация. Каждому приложению ровно такая сложность, которая необходима, но не больше.

        О да, я забыл, ведь создание файлов — это столь сложная операция. Гораздо удобнее каждый раз рыться в файле на тысячи строк. Поиском? Главное преимущество файлов — значительно легче искать то, что необходимо. Когда у тебя весь необходимый код вмещается на одном экране и другой экран просто не нужен) Один файл на 50-100 строк, файлы структурированы. Можно открыть несколько вкладок. Код, который сейчас не нужен — не путается под ногами. Вот мне необходима функциональность А и Г, а между ними — Б и В. Так я просто не открыл лишнее, а только два файла в разный вкладках — а так метаться постоянно видя лишний код.

        И какие причины НЕ создавать файлы? Сплошные плюсы ведь)
        • –1

          Ну, в двухста файлах в одной папке тоже ничего хорошего. А группировать их не всегда удобно, иногда важно чтобы классы оставались в одном пространстве имен, а после переноса файлов в другую папку IDE будет активно мешать этому.


          Лично я предпочитаю создавать отдельный файл на класс если в этом классе более 7 строк кода.

          • 0
            Что имеете ввиду под цепочками?

            commandBinder.Bind(GameEvent.HIT).To<DestroyEnemyCommand>().To<UpdateScoreCommand>();
            
            commandBinder.Bind(GameEvent.HIT).InSequence()
            	.To<CheckLevelClearedCommand>()
            	.To<EndLevelCommand>()
            	.To<GameOverCommand>();
            
            injectionBinder.Bind<ICommandBinder>().To<SignalCommandBinder>().ToSingleton(); // сигналы - новые, строго типизированные события. 
            

            https://strangeioc.github.io/strangeioc/TheBigStrangeHowTo.html
            Это должно проделываться в Composition Root. Вызов одной команды из другой это явно плохая практика.
            Кстати, в StrangeIOC команды могут быть асинхронными, в этом случае они конечно упрощают жизнь. Хотя я для этого использовал Promise, которые очень популярные в JS.

            файле на тысячи строк

            Я не говорил, что надо вообще все в один файл писать. Но создавать файл для каждой функции, еще и в 1-5 строк… это странно для меня. Хотя конечно можно в одном файле написать несколько классов.
            Но остается проблема с GC. У вас некоторые команды создавались в каждом update, рано или поздно это может внести свою лепту в понижение fps.
            Я не представляю как выглядел мой проект, если бы каждый метод я выносил бы в отдельный класс. Где-то должна быть эта грань.
            • 0
              Не знал, что время редактирования комментария столь ограничено. Немного изменил пример.

              commandBinder.Bind(GameEvent.HIT).To<DestroyEnemyCommand>().To<UpdateScoreCommand>();
              
              commandBinder.Bind(GameEvent.HIT).To<DestroyEnemyCommand>().Once();
              
              commandBinder.Bind(GameEvent.HIT).InSequence()
              	.To<CheckLevelClearedCommand>()
              	.To<EndLevelCommand>()
              	.To<GameOverCommand>();
              
              commandBinder.Bind<ShipDestroyedSignal>().To<ShipDestroyedCommand>(); // сигналы - новые, строго типизированные события.
              
              • 0
                Вызов одной команды из другой это явно плохая практика

                Чем плохая?

                Это должно проделываться в Composition Root.

                Почему?

                Как на меня в данной ситуации это лишнее усложнение, которое не несёт никакого позитива в данном случае.

                commandBinder.Bind<ShipDestroyedSignal>().To<ShipDestroyedCommand>(); // сигналы - новые, строго типизированные события.
                


                Я в игре могу подвесится прям на команды как на события. И тоже строготипизированно. Выглядит как-то так:

                public void On(ShipDestroyedCommand ev) {
                	
                }
                


                Но создавать файл для каждой функции, еще и в 1-5 строк

                Почему для каждой? Есть много классов, где несколько методов. Да и все классы очень быстро растут. Нету смысла сейчас экономить файлы, чтобы потом их все-равно выносить. И как тогда файлы, которые содержат два-три класса называть? Другой вопрос — нужно ли такое разбиение и может неоторые классы должны нести чуть больше ответственности?

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

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

                в StrangeIOC команды могут быть асинхронными, в этом случае они конечно упрощают жизнь

                Чем упрощают? Что для вас «асинхронные» в данном контексте?)

                • 0
                  Почему?

                  При использование Composition Root, все связи видны как на ладоне. Как минимум это должно упростить жизнь новым программистам на проекте.
                  Я сейчас читаю книгу «Dependency Injection in .NET». Там об этом написано. Хотя там написано про инициализацию зависимостей, но думаю к сигналам и командам это тоже относится.

                  Я в игре могу подвесится прям на команды как на события. И тоже строготипизированно. Выглядит как-то так:

                  В StrangeIoC есть два вида событий: старые событий и новые сигналы. Я это имел ввиду.

                  Чем упрощают? Что для вас «асинхронные» в данном контексте?)

                  Команда выполняется в течении нескольких кадров. Проще вызвать другую команду в конце асинхронной команды. Иначе нужен будет или callback или promise.

                  Чтобы просто не создавать классы размером в 1000 строк, можно использовать шаблон стратегия. Хотя я часто пишу статические классы — утилиты или хелперы.
                  Суть команд, мне кажется, больше, чем просто декомпозиция большого класса.
                  • 0

                    Вы упускаете из виду тот факт, что прямые вызовы легко прослеживаются через IDE — а Composition Root в большом проекте иногда нужно еще и найти сначала...

        • +1
          Шикарная статья :) Спасибо автору
          • 0
            вопрос по классу Command, bool Run и bool IsValid
            как обрабатываются различные причины false на клиенте, когда например нужно отобразить разное сообщение пользователю почему та или иная команда не может быть выполнена?
            • 0
              Изначально у меня было что-то вроде такого:

              public enum Status {
              	Success,
              	
              	RoomNotEmpty,
              	NotEnoughItems,
              	CrewMemberIsFreezed
              }
              
              
              public abstract class Command
              {
              	public Core Core { get; private set; }
              	public bool IsValid { get; private set; }
              	public Status Status { get; private set; }
              
              	public Command Execute (Core core)
              	{
              		Core = core;
              		Status = Run();
              		IsValid = (Status == Status.Success);
              		return this;
              	}
              
              	protected abstract Status Run ();
              }
              


              И тогда каждая команда при ошибке возвращает не false, а код ошибки. Как часть сервера работает прекрасно — можно и узнать все возможные ошибки и сериализуется легко.

              protected override Status Run ()
              {
              	if (Room.Building.Type != BuildingType.Empty) {
              		return Status.RoomNotEmpty;
              	}
              	if (Building.Type == BuildingType.Empty) {
              		return Status.CantBuildEmpty;
              	}
              
              	if (!new Pay(Building.Config.ConstructionCost).Execute(Core).IsValid) {
              		return Status.NotEnoughResources;
              	}
              
              	Room.Building = Building;
              	return Status.Success;
              }
              


              Сейчас же я понял, я никогда не проверяю статус, потому что вьюшка не позволяет запустить команду, которая может вернуть false. Правда, статусы самодокументируемые и отладка с ними легче, так что если бы нас в команде было хотя бы двое — обязательно бы их использовали.
              • 0
                вьюшка не позволяет запустить команду, которая может вернуть false

                Это означает, что у вас бизнес-логика дублируется… Нарушение принципа DRY.

                • 0
                  Вы правы.

                  Хотя если строго говорить, то валидация в командах у меня излишняя, а не дублируется (нарушением DRY было бы, если бы убрав эту валидацию, у меня перестало бы работать, а так — функционал игры совершенно не изменится). А сейчас эта валидация просто не нужна для корректной работы игры.

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

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