Пишем игровую логику на C#. Часть 2/2
Как и раньше — внизу статьи вы можете найти полный код на ГитХаб и ссылку на бесплатное скачивание.
План работы
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
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 ); }
Добавляем цикл производства
Забирать ресурсы, конечно, приятно, но давать значительно приятнее. Давайде запрограммируем возможность запускать производственные цепочки. Каждый модуль сможет скушать определенное количество сырья и потом выдать готовый материал. Снова начинаем с конфигурации:
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)); } }
Конец
Итак, тесты запустились корректно и мы смогли сделать минимальную версию нашего продукта. Класс Factory получился раздутым, но если вынести настройки в JSON, то и он будет вполне ничего. Используя Json.NET нам необходимо написать что-то вроде этого:
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