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

68192c36d912459bac96e1cb223b3c98.jpgЭто продолжение предыдущей статьи. Мы шаг за шагом создаем движок, на котором будет работать игровая логика нашей экономической стратегии. Если вы видите это впервые — настоятельно рекомендую начать с Части 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);
}

9aca63b96450455abe5d493a3c1ac5c9.png

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


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

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

public class Bank
{
    private readonly Dictionary resources = new Dictionary();

    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 ConstructionCost;
}
public class ModuleConfig
{
    // ...
    public Dictionary ConstructionCost;
}
public class Factory
{
    // ...
    Type = BuildingType.PowerPlant,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 20 }},
    // ...
    Type = BuildingType.Smeltery,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 20 }},
    // ...
    Type = BuildingType.Roboport,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 20 }},
    // ...
    // ...
    Type = ModuleType.Generator,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Furnace,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Digger,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 10 }},
    // ...
    Type = ModuleType.Miner,
    ConstructionCost = new Dictionary() {{ ResourceType.Metal, 40 }},
    // ...
}

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

public class Pay : Command
{
    public readonly Dictionary Cost;

    public Pay (Dictionary 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.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.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
    );
}

5fa84793f1f74becb7f1c4296c081533.png

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


Забирать ресурсы, конечно, приятно, но давать значительно приятнее. Давайде запрограммируем возможность запускать производственные цепочки. Каждый модуль сможет скушать определенное количество сырья и потом выдать готовый материал. Снова начинаем с конфигурации:
public class ModuleConfig
{
    // ...
    
    public int CycleTime; // сколько времени модуль будет перетравливать сырье
    public Dictionary CycleInput; // сколько сырья
    public Dictionary 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.Energy, 10 }
        },
    }},
    { ModuleType.Furnace  , new ModuleConfig() {
        // ...

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

        CycleTime = 18,
        CycleInput = new Dictionary() {
            { ResourceType.Energy, 2 }
        },
        CycleOutput = new Dictionary() {
            { ResourceType.Ore, 7 }
        }
    }},
    { ModuleType.Miner    , new ModuleConfig() {
        // ...
        
        CycleTime = 32,
        CycleInput = new Dictionary() {
            { ResourceType.Energy, 8 }
        },
        CycleOutput = new Dictionary() {
            { 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.Metal, 10 }
            },

            CycleTime = 4,
            CycleInput = new Dictionary() {
                { ResourceType.Ore, 10 },
                { ResourceType.Energy, 5 }
            },
            CycleOutput = new Dictionary() {
                { 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));

    }
}

c071843a00c04b3bb2ea15d5648b4475.png

Конец


Итак, тесты запустились корректно и мы смогли сделать минимальную версию нашего продукта. Класс Factory получился раздутым, но если вынести настройки в JSON, то и он будет вполне ничего. Используя Json.NET нам необходимо написать что-то вроде этого:
Настройки в JSON
var files = Directory.GetFiles(path + "/Items/Modules", "*.json", SearchOption.AllDirectories);
var modules = new List();

foreach (var file in modules) {
    var content = File.ReadAllText(file);
    modules.Add( JsonConvert.DeserializeObject(content) );
}
{
    "Type": "Generator",
    
    "ConstructionTime": 5,
    "ConstructionCost": {
        "Metal": 10
    },
    
    "CycleTime": 12,
    "CycleInput": {
        "Energy" 6,
        "Ore": 4,
    },
    "CycleOutput": {
        "Energy": 10
    }
}

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

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

Скачать для Windows, Linux, Mac бесплатно и без СМС, а так же поддержать нас можно на странице SpaceLab на GreenLight

Комментарии (0)

© Habrahabr.ru