22 февраля в 14:16

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

Всем привет. В связи с выходом моей игры 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
Павло @TheShock
карма
228,7
рейтинг 1,1
Senior JS Developer
Похожие публикации
Самое читаемое Разработка

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

  • +1

    А при реальной разработке игр (на Unity и вообще) тоже используется такой подход, когда логика пишется отдельным, абстрактным модулем и потом подключается к игровому движку, или это просто пример для статьи?

    • 0
      Ну я использовал именно такой подход. Очень удобно на самом деле — у тебя есть два под-проекта и каждый из них довольно независим.
      • –1
        В случае подобного разделения можно словить очень неприятный момент, а именно — отличие реализации mono в unity и в том, что использовалось при разработке / компиляции / тестировании логики. За этим нужно следить.
    • –2

      Нет. Так работает только какой-нибудь очень далекий от движка объект, например UI или дизайнерские задвики по AI. Вообще желательно всё писать поближе к движку, чтобы потом не оказаться с 1.5 fps. Иначе графику, физику, анимацию предеться очень оптимизировать и упрощать. Вообще даже бывает, что следят за ms, которые тратятся на обработку скриптов. И операции с матрицами и векторами в скрипты стараются не выносить.

      • +5
        Нет. Так работает только...

        А вам не кажется, что это очень проектозависимо?
        • 0
          Мне нравится ваш подход.
          Ничто не мешает, к примеру, биндить физику Unity3d на модель, апдейтить модель, биндить модель к физике Unity3d — я почти так и делал, вполне удобно и на FPS влияние минимальное.
          Но моя модель про анимации ничего не знала, не придумал как подружить их грамотно. Как у SpaceLab с этим дела обстоят? Есть ли необходимость в физике и анимациях на уровне проекта с игровой логикой?
          • 0
            На уровне проекта с игровой логикой — нету. Конечно, от завязки на View не обойтись. Но стараюсь просто создать абстрактный эвент в том месте, где необходимо запустить анимацию. Например, у меня есть какая-то комплексная команда и в определенном случае в опреленном месте этой команды необходимо запустить анимацию. Я делаю в этом месте что-то вроде:
            core.Publish(new CrewLegBrokenEvent( crewMember ));
            


            А в коде вьюшки что-то вроде:
            public void On(CrewLegBrokenEvent ev) {
              LaunchAnimation( ev.crewMember )
            }
            


            Таким образом у меня модель ничего не знает про рендер.
          • –2

            Это не подход вообще это очень низкий ентри левел. Писать код чтобы просто заработало.

            • 0
              Да что вы вцепились в эту строчку? Я ж не пишу все тесты так. Это единственный странный тест, который просто проверяет, что лично я нигде не налажал с подключением тестового проекта к игре. А withoutuniverse говорит о подходе описанном в статье в целом. Или весь подход неверен из-за того, что вам кажется лишней одна строчка?
      • +2
        Вообще, это довольно частый кейс, когда бизнес-логику выносят в отдельный проект. Особенно это актуально для игр с мультиплеерной составляющей, когда надо синхронизировать состояния игры. Одна и та же логика крутится и на клиенте и на сервере. Не тащить же весь юнити проект в сервер, верно?
        • 0
          Почему нет? У нас, к примеру, серверные инстансы тоже на Unity. Таким образом очень много кода переиспользуем. Чисто клиентские фишки завернуты в один define, чисто серверные в другой.
          • 0
            It depends. Если проект — мультиплеерный шутер, например, с активным использованием физики — да, однозначно придётся поднимать инстансы на сервере. Ну или иметь головняк с подключением сторонней физ. библиотеки к юнити (типа Bullet), но такое часто не стоит свеч.
            Из минусов — некоторое усложнение серверной архитектуры, т.к. поднять инстанс game сервера и заставить его корректно общаться с клиентами и другими серверами — это не то же самое, что запустить логику игры из dll в отдельном потоке.
            • 0
              Само собой. Просто вы весьма категорично высказались, вот я и вставил свои 5 копеек.
              Да в целом там в этом плане проблем не было, я имею ввиду с точки зрения написания инстансов. Проблемы больше в плане взаимодействия (сервис дискавери и т.п.), но эти проблемы независимо от реализации будут.
    • 0

      В своем обучающем pet project на unity3d тоже думаю о выносе логики в отдельную сборку, благо четко отделены неймспейсы Core и UnityLogic (по сути — модель и представление, как бы странно в контексте юнити это не звучало) и в Core от юнити только вектора да Assert'ы используются.


      Вот только редактор имеет обыкновение пересоздавать файлы .sln и .csproj, поэтому добавить в решение отдельную сборку довольно сложно (хотя, вроде если сильно исхитриться, то можно), а значит скорее всего придется делать их вообще разными solution'ами, что слегка неудобно — одновременно редактировать и ядро и юнити логику не получится, возможны проблемы с подключением какого-нибудь Vector2 от юнити в этот внешний проект (не проверял, т.ч. может быть тут проблем нет).


      Кроме того, не очень понятно, как не устроить в VCS спам обновлениями какого-нибудь Plugins/Core.dll, да и вручную постоянным копированием перекомпилированного Core заниматься не очень хочется.


      Т.ч. пока активных действий не предпринимаю и Core внутри основного .csproj. Чтобы жить было веселее, встроил известный плагин/хак, позволяющий использовать сахар из C# 6, а для тестов воспользовался Unity Test Tools.


      Но как-то костыльно это все( Все таки хочется выделить ядро в отдельный "чистый" проект, чтобы еще меньше зависеть от движка/удобнее тестировать и т.д. А у юнити проекта создать свои тесты и свои правила.
      Но нужно, по крайней мере, автоматизировать сборку Plugins/Core.dll, причем кроссплатформенно. Хотя, вероятно это очень просто решить банальным output directory в проект юнити.

      • +1
        Вот только редактор имеет обыкновение пересоздавать файлы .sln и .csproj, поэтому добавить в решение отдельную сборку довольно сложно (хотя, вроде если сильно исхитриться, то можно), а значит скорее всего придется делать их вообще разными solution'ами, что слегка неудобно — одновременно редактировать и ядро и юнити логику не получится,

        Я недавно придумал использовать для этого символические ссылки.


        Создаёшь отдельный солюшен (CoreSolution) и проект (CoreProject) в Студии. Внутри CoreProject делаешь папочку Core и весь код хранишь в ней. А в проекте Unity в папку Assets кидаешь символическую ссылку на папку Core. В результате, писать и редактировать код можно и в CoreSolution, и в солюшене Unity, и даже одновременно.


        Косяков такого подхода пока не заметил. Единственная неприятность ― если забыться и создать новый cs-файл в Unity внутри папки Core , то в CoreProject его потом надо будет добавлять вручную через Add -> Existing Item... Этот момент несколько раздражает.


        Для удобной работы со ссылками есть расширение к Проводнику ― Shell Link Extension.


        возможны проблемы с подключением какого-нибудь Vector2 от юнити в этот внешний проект (не проверял, т.ч. может быть тут проблем нет).

        В этом плане всё нормально. UnityEngine.dll можно подключать к сторонним проектам и пользоваться всем его управляемым кодом. Правда, его там не особо много, в основном вектора и Mathf. Если нарвёшься на метод, который реализован в нативном коде, в худшем случае получишь исключение сразу в момент его вызова.

        • +1

          Вот такая конструкция добавит все файлы из поддерева в проект:


          <ItemGroup>
            <Compile Include="Core\**\*.cs" />
          </ItemGroup>

          Необязательно, кстати, симолические ссылки использовать — в проект можно и какой-нибудь ..\..\Core\**\*.cs добавить.

          • 0

            У меня изначальная задумка была не столько для отделения «core-кода» от «не-core-кода», а скорее для того, чтобы вынести код, который я использую в каждом проекте Unity, куда-нибудь в одно место. Чтобы не хранить в десяти Unity-проектах десять копий одних и тех же, но чуть-чуть отличающихся исходников; потому что со временем перестаёшь понимать, где лежит более свежая версия, где более старая, и чем они отличаются.


            Пробовал заводить для такого кода отдельный проект и билдить его dll в папку Assets текущего проекта Unity, но это неудобно. Оказалось проще кидать в Unity-проект линк на общую папку. В неё же можно положить какие-нибудь общие часто используемые ассеты, типа текстур в сеточку, в шахматную клеточку, в сеточку с пронумерованными ячейками для отладки uv-координат у геометрии.


            Тут конечно появляется вероятность сломать что-нибудь в старом проекте, внеся изменения в общий код. Но пока в Unity не появится родная поддержка чего-нибудь типа NuGet, я думаю, это будет наименьшим злом.

            • 0

              Кстати, насколько я знаю, для подобных целей git submodule обычно используют. А чтобы не привязываться к структуре каталогов подмодуля можно объединиит подходы — в каком-нибудь ThirdParty хранить подмодуль и на него делать симлинк (или что-то другое, не знаю точно через что относительные ссылки делаются) в проект. По крайней мере, после клонирования на другую машину не потребуется доп. действий и вообще думать о зависимости.

        • 0

          Однако спасибо за ответы, оригинальный подход. Получается, в итоговый билд все компилируется Unity3d, но разработка и тестирование ядра ведется снаружи.
          Надо будет попробовать, спасибо. Предполагаю, под линухом оба варианта должны работать абсолютно аналогично.

        • 0
          Может быть это вам поможет: Project and Package Manager for Unity3D
      • 0
        Чтобы жить было веселее, встроил известный плагин/хак, позволяющий использовать сахар из C# 6

        Решарпер не так давно начал понимать туплы. Теперь можно жить ещё веселее и потихоньку начинать применять C# 7. Правда всерьёз только на Windows.

  • –1
    Assert.IsInstanceOfType(new Core(), typeof(Core));

    А делать Assert.IsEqual(2*2, 4) вы не пробовали? Или там Assert.IsNotEqual(true, false)?

    • 0
      Пробовал, но цель ведь — проверить, что проект корректно настроен, заимпортить Core. Или вы думаете, что я питаю иллюзии о практической ценности данного теста в долгосрочной перспективе?
    • +3

      Всегда начинаю писать тесты с assert true.


      Это же стандартная практика — проверить, что сам тестировочный фреймворк работает, проект корректно настроен и нужные классы импортируются.

      • 0

        Только для начальной проверки или тест отсается?


        Если первое, то можно и сразу тест на логику писать. если прошел — все работает, все подгружено.
        Если второе, то в каком классе лежит такой тест и один ли он там?
        А если третье, то это тесты самого фреймвека и они должны лежать в проекте с ним и скорее всего они там и так есть.

      • –1
        Assert(true) сработает и на чистом тест-проекте, в которые даже не были добавлены ссылки на тот проект, что будет тестироваться. А проверить на то, что «тестировочный фреймворк работает» — обязанность его разработчиков, как по мне. Не встречались ещё такие, которые бы не работали, но может мне везло.
        Проверки типа Assert.IsInstanceOfType(new Core(), typeof(Core)) не несут никакой ценности, разве что проверить что CLR умеет правильно ассоциировать тип с экземпляром класса (думаю, это бы за 15 лет уже заметили).
        • –1
          Но опять же, если в Юнити такая проверка оправдана и может вернуть false, то это печально
  • +4
    Отличная игра для тех, кому понравилась экономическая часть XCOM: Enemy Unknown, но показалась маленькой, относительно остальной игры.

    Кто-нибудь знает наподобие игры, помимо Fallout Shelter?
  • +2
    Сама задумка интересная, я что-то подобное делал, когда писал игры на LibGDX.
    В вашем случае это возможно, так как легко обходитесь без компонентов движка.

    Есть парочка советов:
    1) Словарь, где ключом является enum — это плохо. Лучше заменить на int, по необходимости этот enum приводить к int, это исключит ненужный боксинг/анбоксинг.
    2) Не знаю, как часто вы используете LINQ, но от таких штук лучше отказаться. Лишние аллокации.
    3) foreach для List лучше не использовать, опять же, лишние аллокации. Если редко вызывается, то ещё ладно, если +- каждый кадр, то плохо.
    • 0
      1) какая разница, enum или int? Элемент в словарь добавляется по его хешкоду, метод GetHashCode() переопределен у каждого наследника System.ValueType (будь то enum, или int), бакеты внутри там тоже дженерики, никакого боксинга никогда не происходит. Если даже я что-то и пропустил, пока читал код Dictionary, то замена одного value-типа на другой точно не даст никакого преимущества.
      2) как ещё вы предлагаете узнать, содержится ли в коллекции интересующий нас элемент, кроме перебора всей коллекции?
      3) как по-другому проходить по элементам коллекции? :) Под «лишними аллокациями» понимается создание енумератора? Да, он весит больше, чем интовый индекс, но не настолько, чтобы вызывать панику и бежать переписывать все циклы на for
      • 0

        ЕМНИП, там какой-то баг в рантайме, который давным-давно исправили в Mono, но никак не сольют в Unity.

        • 0
          Тогда нужно уточнять, что касается только Юнити, а не платформы в целом. А то потом молодежь с толку сбивается, и начинают городить…
      • 0
        1) Пост про Unity же.
        2) Именно for.
        3) Ещё раз, мы говорим про C#, про старую баженую Mono версию. Если енумератор создаётся +- каждый кадр, то это лишние аллокации, лишние нагрузки на gc, зачем?
        • 0

          3) На дворе Unity 5.5 и другой компилятор C#. Он в foreach на списках не аллоцирует.


          К тому же, раз ядро в статье пишется в отдельном проекте вне Unity, то и компилируется оно, скорее всего, тоже вне Unity.

      • +1
        Не забывайте, что в Unity под капотом Mono и половину оптимизаций, включая безбоксовый конверт enum <-> int там нет. Я тоже было хотел высказаться, мол, с хрена ли enum медленнее, даже посмотрел чего там в IL'е творится — все идеально, современному C# компилятору пох, он умеет. Потом решил проверить, чего в Mono, а там реально boxing на boxing'е, пока сам не подлатаешь. Так что совет для Unity/Mono имеет смысл.
        • 0

          Поправка: в Unity под капотом старый Mono. В новом используется реализация из referencesource и такой проблемы там нет.

          • 0
            О, спасибо за инфу. А кто-нибудь встречал, что Мигель планирует делать с Mono дальше, с учетом .Net Core и того, что он сейчас часть MS? Существует какая-то точка, когда это все станет одним целым или хотя бы планы?
            • +1

              Я так понимаю, в среднесрочной перспективе будут существовать три платформы: .Net Framework для Windows, .Net Core для трёх основных платформ и Mono для всего.


              Фишка .Net Framework ― богатство функционала.
              Фишка Mono ― встраиваемость и работа на всём, что движется.
              Фишка .Net Core ― автономность, платформа идёт вместе с приложением.


              В краткосрочной перспективе Mono переходит со своего компилятора на Roslyn, и все три ветви унифицируют свои библиотеки под флагом .Net Standard.


              Примерно так я себе это представляю, но могу ошибаться.

              • +1
                .Net Core уже умеет запускаться на чайнике, по сути, дело только в RID'ах и официальной поддержке (Android уже почти, кстати). Мы, например, пилили поддержку Alpine под x86-64 (хочется тоненький docker-контейнер) и ARM64, а это и так своеобразный дистрибутив (одно совокупление с musl чего стоит, кучи библиотек под него нет и т.д.) + архитектура. В итоге, к работе подключились чуваки из MS и скоро поддержка Alpine всех сортов появится официально.
              • 0
                Вот тут отлично расписано, кто есть кто в .NET'ой перспективе:
                http://www.hanselman.com/blog/WhatNETDevelopersOughtToKnowToStartIn2017.aspx
    • +2
      >> Не знаю, как часто вы используете LINQ, но от таких штук лучше отказаться.
      Не согласен с вами. Каждому случаю свои ограничения.
      Не использование Linq почти всегда раздувает код и снижает читаемость.

      В данном случае код вставлен в команду покупки, которая вызывается один раз за транзакцию. Никакого смысла терять читаемость ради нескольких тактов на аллокацию в таком месте нет.

      Вот если бы он Link вызывал на Update по длинным сложно организованным коллекциям, тогда да. А в таком случае использование Linq абсолютно оправдано.

      Единственное ограничение что на iOS несколько функций Linq не работают. Но всё остальное обсолютно приемлемо если котер понимает что он делает.
  • 0
    Спасибо за статью. Для новичка, желающего начать что-то делать, очень полезная информация. С нетерпением жду продолжения. Так же хотелось бы увидеть как потом связать вашу бизнес-логику с приложением в Unity. Хотя бы просты примеры для особо «одаренных».

    З.Ы. Так же поставил вам лайк в гринлайте за эту статью, надеюсь игру пропустят.
    • +1
      Спасибо) «Так же хотелось бы увидеть как потом связать вашу бизнес-логику с приложением в Unity» — вот это будет еще нескоро. Потому пока напишу тут. В View части при рендеринге я просто использую данные из модели. Выполнение ж каждой команды паблишит событие. Что-то вроде такого:

      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();
              if (isValid) core.Publish(this); /// HERE
              return this;
          }
      
          protected abstract bool Run ();
      }
      


      Потом я подписываюсь на эти события и перерисовываю то, что могло измениться

      Пару примеров из реального кода:

      public void On (MemberWakeup ev)
      {
      	UpdateRowByMember(ev.member);
      }
      
      public void On (MemberReject ev)
      {
      	UpdateRowByMember(ev.member);
      }
      
      public void On (MemberAssign ev)
      {
      	UpdateRowByMember(ev.member);
      }
      


      public void On (ModuleEnable ev)
      {
      	if (ev.module == module) Render();
      }
      
      public void On (ModuleDisable ev)
      {
      	if (ev.module == module) Render();
      }
      


      public void On (ResourceChangeEvent ev)
      {
      	if (activeTreeLeaf != null) {
      		activeTreeLeaf.RenderPanel();
      	}
      }
      


      Главное не сорваться и не начать менять компонент напрямую в методе. Просто то, что должно измениться оповещаем, что оно должно перерисоваться, а оно уже в свою очередь берет освеженные данные из модели и перерисовывается. Если хотите более оптимизированно — просто бьем на методы поменьше и вызываем только тот метод, который может измениться. Например:

      public class ModuleOption
      {
      	public void On (ResourceChangeEvent ev)
      	{
      		UpdateCost();
      	}
      


      Когда открыто окно постройки модуля при изменении цены перерисуются только цены (под перерисовкой я понимаю не реально стирание и новое рисование, а указание новых значений, а уже юнити потом думает — рисовать заново или что там ему делать)
  • 0
    TheShock игра понравилась, проголосовал за неё на greenlight. Но подскажи такой момент: у меня закончились свободные комнаты и я не могу построить помещение, необходимое для исследований, чтобы проапгрейдить корабль. Есть какая-то возможность заменить существующую комнату? А то я в тупике.
    • 0
      Спросил лично
  • 0

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.