Всем привет. В связи с выходом моей игры SpaceLab на GreenLight я решил начать серию статей о разработке игры на C#/Unity. Она будет основываться на реальном опыте её разработки и немного отличаться от стандартных гайдов для новичков:
Во-первых, я не буду повторять документацию иными словами
Во-вторых, необходимо знание программирования, чтобы понять о чем я пишу
К сожалению, эта статья не сможет вам помочь, если вы хотите создать свою казуальную игру используя лишь мышь.
Зато я шаг за шагом расскажу о создании движка, на котором будет работать игровая логика нашей экономической стратегии.
Для тех, кто любит спойлеры или просто хочет почитать код — в конце есть ссылка не репозиторий, где каждый пункт добавлен отдельным коммитом.
Кого заинтересовало узнать, что за игра — внизу есть видео и ссылка на бесплатное скачивание.
Сразу предупрежу — у меня нету цели идеально применить огромное количество паттернов или описать подход к методологии TTD. В статье я стараюсь писать читабельный, поддерживаемый и безбажный код, как он писался бы в жизни. Возможно, людям имеющим огромный скилл в C# и написании игр данная статья покажется очевидной. Тем не менее, вопрос о том, как писать гейм-логику я слышал довольно часто и эта статья прекрасно подойдет и тем, кому интересно написание сервера и тем, кому интересно написание клиента на Unity.
Краткое описание GD, которого мы хотим достичь
1. Игрок управляет кораблем. В корабле можно выстраивать комнаты, в комнатах можно добавлять в слоты модули.
2. Для постройки чего-либо необходимо потратить ресурсы и подождать время.
Через полгода разработки результат должен выглядеть как-то так)
План работы
1. Настраиваем проекты
2. Создаем ядро — базовые сооружения
3. Добавляем и тестируем первые команды — построить строение и модуль
4. Выносим настройки строений и модулей в отдельный файл
5. Добавляем течение времени
6. Добавляем Constructible, строения теперь строятся некоторое время
7. Добавляем ресурсы, для постройки необходимы ресурсы
8. Добавляем цикл производства — модуль потребляет и выдает ресурсы
Статья получилась очень объемной, потому пришлось разделить ее на две части. В данной части мы сделаем первые пять пунктов, а завтра, во второй части закончим
1. Настраиваем проекты
На первых порах Unity Editor нам не понадобится — мы пишем ГеймЛогику. Открываем VS и создаем два проекта: GаameLogic
и LogicTests
(Unit Tests Project). В первом мы будем писать собственно логику игры на чистом C# не используя namespace Unity, второй будет тестить нашу логику встроенной тест-тулзой. Добавим в GameLogic первый класс Core и напишем первый тест, чтобы проверить нашу связку:
public class Core { public static void Main () {} public Core () {} }
[TestClass] public class Init { [TestMethod] public void TestMethod1 () { Assert.IsInstanceOfType(new Core(), typeof(Core)); } }
2. Создаем ядро — базовые сооружения
Что ж, это указывает, что настроили мы корректно и можно переходить к программированию логики.
Итак, разберемся с нашим гейм-дизайном. У нас есть корабль (Ship), в нем комнаты (Room), в каждую комнату может быть построено строение (Building), а в каждом строении могут быть модули (Module). Конечно, Room и Building можно было бы объединить в одну сущность, но далее такое разделение нам только поможет.
Для всех этих сооружений я создам отдельный namespace Architecture и базовые классы. А так же enum для индексов комнат. Многие вещи, которые мы сейчас делаем — временные и необходимы, чтобы запустить первый тест гейм-логики.
public enum BuildingType { Empty, PowerPlant }
public enum ModuleType { Generator }
public class Core { public static void Main () {} public readonly Ship Ship = new Ship(); public Core () { Ship.CreateEmptyRooms(); } }
public class Ship { // Временно добавим некоторое количество комнат public readonly int RoomsLimit = 10; private readonly List<Room> rooms = new List<Room>(); public IEnumerable<Room> Rooms { get { return rooms; } } public void CreateEmptyRooms () { for (var i = 0; i < RoomsLimit; i++) { rooms.Add(new Room(i)); } } public Room GetRoom (int index) { return rooms[index]; } }
public class Room { public readonly int Index; // каждая комната является пристанищем для строения public Building Building { get; set; } public Room (int index) { Index = index; // и по-умолчанию - это пустое строение Building = new Building(BuildingType.Empty); } }
public class Building { // Ограничим количество модулей, которые можно поставить в строение public readonly int ModulesLimit = 10; public readonly BuildingType Type; // Каждый модуль может иметь свою сообтвенную позицию private readonly Dictionary<int, Module> modules = new Dictionary<int, Module>(); public IEnumerable<Module> Modules { get { return modules.Values; } } public Building (BuildingType type) { Type = type; } public Module GetModule (int position) { return modules.ContainsKey(position) ? modules[position] : null; } public void SetModule (int position, Module module) { if (position < 0 || position >= ModulesLimit) { throw new IndexOutOfRangeException( "Position " + position + " is out of range [0:" + ModulesLimit + "]" ); } modules[position] = module; } }
public class Module { public readonly ModuleType Type; public Module (ModuleType type) { Type = type; } }
3. Добавляем и тестируем первые команды — построить строение и модуль
Теперь мы сможем написать первую «фичу» — постройка строения и постройка модуля в нем. Все подобные действия я буду описывать отдельным классом, который будет наследоваться от класса Command:
public abstract class Command { public Core Core { get; private set; } public bool IsValid { get; private set; } public Command Execute (Core core) { Core = core; IsValid = Run(); return this; } protected abstract bool Run (); }
И хотя сейчас даже такая маленькая структура излишня — чуть позже благодаря ей мы прикрутим необходимые нам события. А существование каждого атомарного действия в отдельной команде позволит нам их комбинировать. Напишем наши первые два действия:
public class BuildingConstruct : Command { public readonly Room Room; public readonly Building Building; public BuildingConstruct (Room room, Building building) { Room = room; Building = building; } protected override bool Run () { // Нельзя строить там, где уже что-то есть if (Room.Building.Type != BuildingType.Empty) { return false; } // Нельзя строить пустую комнату if (Building.Type == BuildingType.Empty) { return false; } Room.Building = Building; return true; } }
public class ModuleConstruct : Command { public readonly Building Building; public readonly Module Module; public readonly int Position; public ModuleConstruct (Building building, Module module, int position) { Building = building; Module = module; Position = position; } protected override bool Run () { if (Building.Type == BuildingType.Empty) { return false; } if (Position < 0 || Position >= Building.ModulesLimit) { return false; } if (Building.GetModule(Position) != null) { return false; } Building.SetModule(Position, Module); return true; } }
Пришло время посмотреть, работает ли наш движок. В тестах создаем ядро, пробуем построить комнату, а в нее пытаемся построить модуль. Кроме этого стоит добавить проверку, что нельзя построить то, чего гейм-логика не должна позволять строить:
[TestClass] public class Architecture { [TestMethod] public void CorrectConstruction () { var core = new Core(); var room = core.Ship.GetRoom(0); Assert.AreEqual(BuildingType.Empty, room.Building.Type); Assert.AreEqual(0, room.Building.Modules.Count()); Assert.IsTrue( new BuildingConstruct( room, new Building(BuildingType.PowerPlant) ) .Execute(core) .IsValid ); Assert.AreEqual(BuildingType.PowerPlant, room.Building.Type); Assert.AreEqual(0, room.Building.Modules.Count()); Assert.IsTrue( new ModuleConstruct( room.Building, new Module(ModuleType.Generator), 2 ) .Execute(core) .IsValid ); Assert.AreEqual(BuildingType.PowerPlant, room.Building.Type); Assert.AreEqual(ModuleType.Generator, room.Building.GetModule(2).Type); Assert.AreEqual(1, room.Building.Modules.Count()); } [TestMethod] public void IncorrectConstruction () { var core = new Core(); var room = core.Ship.GetRoom(0); Assert.IsFalse( new BuildingConstruct( room, new Building(BuildingType.Empty) ) .Execute(core) .IsValid ); Assert.IsFalse( new ModuleConstruct( room.Building, new Module(ModuleType.Generator), 2 ) .Execute(core) .IsValid ); new BuildingConstruct( room, new Building(BuildingType.PowerPlant) ) .Execute(core); Assert.IsFalse( new BuildingConstruct( room, new Building(BuildingType.PowerPlant) ) .Execute(core) .IsValid ); Assert.IsFalse( new ModuleConstruct( room.Building, new Module(ModuleType.Generator), 666 ) .Execute(core) .IsValid ); } }
4. Выносим настройки строений и модулей в отдельный файл
К счастью, наши тесты прекрасно проходятся. Теперь нам необходима возможность линейно расширять количество строений и модулей — для этого необходимо сделать следующее:
- Создать конфигурацию для строений и модулей — "
class BuildingConfig
" и "class ModuleConfig
", именно они будут хранить все настройки наших сооружений. - Building и Module при создании должны принимать соответствующие настройки
- Сделать фабрику для создания модулей и строений
- Добавить настройки для нескольких строений и модулей
- Адаптировать существующий код под новые входные данные
// Создаем конфиги public class BuildingConfig { public BuildingType Type; // Теперь никаких констант public int ModulesLimit; // Каждое строение может иметь только определенные модули public ModuleType[] AvailableModules; }
public class ModuleConfig { public ModuleType Type; }
public class Building { // ... public readonly BuildingConfig Config; // ... // В конструкторе принимаем конфиг, а не индекс public Building (BuildingConfig config) { Type = config.Type; ModulesLimit = config.ModulesLimit; Config = config; } }
public class Module { // ... public readonly ModuleConfig Config; // В конструкторе принимаем конфиг, а не индекс public Module (ModuleConfig config) { // ... Type = config.Type; Config = config; } }
Как можно понять, теперь наш код нерабочий. Для того, чтобы не таскать каждый раз с собой конфиги создадим фабрику, которая будет выпускать наши сооружения зная только их тип. Я знаю, что название пока слишком общее, но мы всегда с легкостью можем его переименовать благодаря IDE, так же, как и разделить на две фабрики:
public class Factory { public Building ProduceBuilding (BuildingType type) { throw new Exception("Not implemented yet"); } public Module ProduceModule (ModuleType type) { throw new Exception("Not implemented yet"); } }
// А также добавим нашу фабрику в ядро: public class Core { // ... public readonly Factory Factory = new Factory(); public Core () { // В аргумент метода передаем фабрику Ship.CreateEmptyRooms(Factory); } }
// Корабль теперь принимает фабрику в качестве аргумента: public class Ship { // ... public void CreateEmptyRooms (Factory factory) { for (var i = 0; i < RoomsLimit; i++) { rooms.Add(new Room(i, factory.ProduceBuilding(BuildingType.Empty))); } }
// А комната - принимает строение по-умолчанию: public class Room { // ... public Room (int index, Building building) { Index = index; Building = building; } }
Сейчас IDE указывает, где мы имеем ошибки — заменим там вызов конструктора на использование фабрики.
// в тестах new Building(Type); // заменяем на core.Factory.ProduceBuilding(Type);
// в тестах new Module(Type); // заменяем на core.Factory.ProduceModule(Type);
И хотя сейчас код корректен — при запуске наших тестов мы словим "Not implemented yet"
. Для этого вернемся к нашей фабрике и реализуем несколько строений и модулей.
public class Factory { private readonly Dictionary<BuildingType, BuildingConfig> buildings = new Dictionary<BuildingType, BuildingConfig>() { { BuildingType.Empty, new BuildingConfig() { Type = BuildingType.Empty }}, { BuildingType.PowerPlant, new BuildingConfig() { Type = BuildingType.PowerPlant, ModulesLimit = 5, AvailableModules = new[]{ ModuleType.Generator } }}, { BuildingType.Smeltery, new BuildingConfig() { Type = BuildingType.Smeltery, ModulesLimit = 4, AvailableModules = new[]{ ModuleType.Furnace } }}, { BuildingType.Roboport, new BuildingConfig() { Type = BuildingType.Roboport, ModulesLimit = 3, AvailableModules = new[]{ ModuleType.Digger, ModuleType.Miner } }} }; private readonly Dictionary<ModuleType, ModuleConfig> modules = new Dictionary<ModuleType, ModuleConfig>() { { ModuleType.Generator, new ModuleConfig() { Type = ModuleType.Generator }}, { ModuleType.Furnace, new ModuleConfig() { Type = ModuleType.Furnace }}, { ModuleType.Digger, new ModuleConfig() { Type = ModuleType.Digger }}, { ModuleType.Miner, new ModuleConfig() { Type = ModuleType.Miner }} }; public Building ProduceBuilding (BuildingType type) { if (!buildings.ContainsKey(type)) { throw new ArgumentException("Unknown building type: " + type); } return new Building(buildings[type]); } public Module ProduceModule (ModuleType type) { if (!modules.ContainsKey(type)) { throw new ArgumentException("Unknown module type: " + type); } return new Module(modules[type]); } }
Я сразу добавил несколько строений и модулей, чтобы можно было покрыть тестами. И сразу скажу — да, хранить все эти настройки в фабрике нету никакого смысла. Они будут лежать отдельно в JSON файлах, по одному на структуру, парсится и передаваться в фабрику. К счастью, у нас движок даже не заметит этого изменения. Ну а пока нам не так критично вынести их в ЖСОНы, как запустить тесты и проверить все ли корректно работает. К счастью, да. Заодно допишем тесты, что нельзя построить модуль не в той комнате, например, Furnace в PowerPlant.
[TestMethod] public void CantConstructInWrongBuilding () { 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.Furnace), 2 ) .Execute(core) .IsValid ); Assert.AreEqual(null, room.Building.GetModule(2)); }
Увы, как вы можете догадаться, никто логику проверки не писал. Добавим условие валидации в команду постройки модуля и после этого успешно пройдем тест:
public class ModuleConstruct : Command { // ... protected override bool Run () { // ... if (!Building.Config.AvailableModules.Contains(Module.Type)) { return false; } // ...
Что ж, теперь все корректно. Заодно добавим тесты на корректную работу лимитов и пойдем дальше.
[TestMethod] public void ModulesLimits () { var core = new GameLogic.Core(); var roomRoboport = core.Ship.GetRoom(0); var roomPowerPlant = core.Ship.GetRoom(1); Assert.IsTrue( new BuildingConstruct( roomRoboport, core.Factory.ProduceBuilding(BuildingType.Roboport) ) .Execute(core) .IsValid ); Assert.IsTrue( new BuildingConstruct( roomPowerPlant, core.Factory.ProduceBuilding(BuildingType.PowerPlant) ) .Execute(core) .IsValid ); Assert.IsFalse( new ModuleConstruct( roomRoboport.Building, core.Factory.ProduceModule(ModuleType.Miner), 3 ) .Execute(core) .IsValid ); Assert.IsTrue( new ModuleConstruct( roomPowerPlant.Building, core.Factory.ProduceModule(ModuleType.Generator), 3 ) .Execute(core) .IsValid ); }
5. Добавляем течение времени
Компьютеры дискретны. И все игры дискретны. Если говорить просто, то представим, что все игры — пошаговые. У большинства игр шаги пропускаются автоматически и 60 раз в секунду. Такие игры называются риалтайм. Я понимаю, что это очень грубо, но для реализации гейм-логики довольно удобно представлять, что ваша игра — пошаговая и мыслить такими категориями. А потом уже на клиенте можно запустить tween между двумя состояниями и юзеру будет красиво и игра будет работать быстро. Для начала введем понятие хода:
public class Turns { public int CurrentTurn { get; private set; } internal void NextTurn () { CurrentTurn++; } }
public class Core { public readonly Turns Turns = new Turns(); }
А также введем команду, которая позволяет переключать хода. Я сразу добавил команду, которая позволяет переключить несколько ходов — будет довольно удобно во время тестирования. В тестах одним выстрелом покроем сразу двух зайцев.
public class NextTurn : Command { protected override bool Run () { // Именно тут будет вся логика хода Core.Turns.NextTurn(); return true; } }
public class NextTurnCount : Command { public const int Max = 32; public readonly int Count; public NextTurnCount (int count) { Count = count; } protected override bool Run () { if (Count < 0 || Count > Max) { return false; } for (var i = 0; i < Count; i++) { var nextTurn = new NextTurn().Execute(Core); if (!nextTurn.IsValid) return false; } return true; } }
[TestClass] public class Turns { [TestMethod] public void NextTurnsCommand () { var core = new Core(); Assert.AreEqual(0, core.Turns.CurrentTurn); Assert.IsTrue( new NextTurnCount(4) .Execute(core) .IsValid ); Assert.AreEqual(4, core.Turns.CurrentTurn); } }
Забегая далеко вперед напишу, как сделать переключалку скоростей в игру, которая позволит нам запускаться с разной скоростью:
public class TimeWarp { public readonly int Speed_Stop = 0; public readonly int Speed_X1 = 1000; public readonly int Speed_X2 = 500; public readonly int Speed_X5 = 200; public readonly Core Core; private int currentSpeed; public int currentTime { get; private set; } public TimeWarp (Core core) { currentSpeed = Speed_Stop; Core = core; } public void SetSpeed (int speed) { currentSpeed = speed; currentTime = Math.Min(speed, currentTime); } public int GetSpeed () { return currentSpeed; } public bool IsStopped () { return currentSpeed == Speed_Stop; } public void AddTime (int ms) { if (IsStopped()) return; currentTime += ms; // Тут можно написать через // while (currentTime >= currentSpeed) NextTurn // Но зачем запускать каждый кадр больше одного хода? // Даже 20 ходов в секунду будет более чем достаточно if (currentTime < currentSpeed) return; currentTime -= currentSpeed; new NextTurn().Execute(Сore); } }
[TestMethod] public void TimeWarp () { var core = new Core(); var time = new TimeWarp(core); Assert.AreEqual(0, core.Turns.CurrentTurn); time.SetSpeed(time.Speed_X5); time.AddTime(50); time.AddTime(50); time.AddTime(50); time.AddTime(50); Assert.AreEqual(1, core.Turns.CurrentTurn); time.AddTime(199); Assert.AreEqual(1, core.Turns.CurrentTurn); time.AddTime(1); Assert.AreEqual(2, core.Turns.CurrentTurn); }
Теперь в Unity достаточно будет подвесится на любой Update и передавать дельта время в наш TimeWarp:
public TimeComponent : MonoBehaviour { public TimeWarp timeWarp; public void Awake () { timeWarp = ...; // } public void Update () { timeWarp.AddTime( Time.deltaTime ); } }
Продолжение следует...
В следующей статье мы закончим создание работоспособной основы для нашего движка, реализовав следующие пункты:
6. Добавляем Constructible, строения теперь строятся некоторое время
7. Добавляем ресурсы, для постройки необходимы ресурсы
8. Добавляем цикл производства — модуль потребляет и выдает ресурсы
Ждите завтра)
Для тех, кто просто любит код — есть отдельный репозиторий на ГитХаб
Кроме этого, если вас интересуют вопросы по разработке SpaceLab — задавайте, отвечу на них в комментариях или в отдельной статье
Скачать для Windows, Linux, Mac бесплатно и без СМС можно со страницы SpaceLab на GreenLight
Автор: TheShock