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


Всем привет. В связи с выходом моей игры 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. Выносим настройки строений и модулей в отдельный файл


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

  1. Создать конфигурацию для строений и модулей — "class BuildingConfig" и "class ModuleConfig", именно они будут хранить все настройки наших сооружений.
  2. Building и Module при создании должны принимать соответствующие настройки
  3. Сделать фабрику для создания модулей и строений
  4. Добавить настройки для нескольких строений и модулей
  5. Адаптировать существующий код под новые входные данные

// Создаем конфиги
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
-->


К сожалению, не доступен сервер mySQL