Как стать автором
Обновить

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

Время на прочтение 11 мин
Количество просмотров 23K
Это продолжение предыдущей статьи. Мы шаг за шагом создаем движок, на котором будет работать игровая логика нашей экономической стратегии. Если вы видите это впервые — настоятельно рекомендую начать с Части 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
Теги:
Хабы:
+26
Комментарии 16
Комментарии Комментарии 16

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн