Суть игры
Идея пришла внезапно, и пока она не улетела решил записать а потом и воплотить ее. Представьте холодную, темную камеру в некой тюрьме. В ней сидит, неизвестно как и неизвестно кем, прикованный волшебник, которого каждую ночь мучает всякая нечисть. Из последних сил он создает небольшой файрбол и вдыхает в него подобие жизни.
Познакомьтесь, это Кальцифер, ваш аватар в этой игре.
Именно им вы будете уничтожать вся гадость, которая ползет в сторону нашего закованного бедняги.
Ссыль на код
Пояснения насчет кода
Начнем с GameManager. Он всему голова, именно в нем меняется состоянии игры — Initialization->GameLoop->Win или Lose. Одинок и един, ибо синглтон. Так как игра не сетевая, простая, и без сложных переходов, то было принято решение использовать этот паттерн. Здесь же идет обработка попаданий по игроку (см. ниже Известные проблемы), учет хитпоинтов и проверка на выигрыш\проигрыш. Был бы GodObject, да слишком мало у нас классов, поэтому знает не всё обо всех. На этапе инициализации, создается пул объектов отображающих анимацию урона по игроку и смерти врагов. Для отслеживания состояния хитпоинтов, можно подписаться на UpdateHpWizardDelegate или UpdateHpCalciferDelegate. В нашем случае это делает GUIManager для отображения текущего хп на экране.
К этому времени SpawnManager уже составил список точек спавна врагов
a WaveManager загрузил порядок волн создания врагов. Волны можно настроить двумя способами: прописать в коде игры или загрузить с Json файла. Для редактирования этого Json написан кастомный editor:GameDataEditor
Можно прописать точный номер спавна или указать что можно создаться на любом свободном.
Создание волн сделанно с помощью хитрой корутины:
private IEnumerator SpawnWaves()
{
yield return new WaitForSeconds(firstWaveDelay);
while (currentWave < waves.Count)
{
if (!CheckFinishedCurrentWave())
{
var step = waves[currentWave].GetCurrentWaveStep();
if (step != null)
{
yield return new WaitForSeconds(step.delay);
var spawners = step.spawners;
if (spawners != null)
{
foreach (var spawn in spawners)
{
SpawnPoint spawnPoint;
if (spawn.index == Consts.INDEX_RANDOM_SPAWN_POINT)
{
spawnPoint = SpawnManager.S_Instance.GetRandomEmptySpawnPoint();
}
else
{
spawnPoint = SpawnManager.S_Instance.GetSpawnPointByIndex(spawn.index);
}
if (spawnPoint != null)
{
SpawnManager.S_Instance.SpawnEnemy(spawnPoint, spawn.name);
}
else
{
throw new Exception("Not empty spawn point");
}
}
}
Debug.Log(step.text);
}
waves[currentWave].currentStep++;
}
else
{
SelectNextWave();
}
}
}
Вначале проходит firstWaveDelay секунд до начала запуска 1 первой волны. После этого в цикле прогоняют все волны по очереди, вставляя нужную задержку step.delay между шагами волны. Почему в корутине а не например в Update? Да собственно можно и так и эдак, просто тут более наглядно, видно где задержка ( yield return new WaitForSeconds) и не надо городить лишние циклы и проверки.
Давай те глянем что же представляют из себя SpawnPoint. Это MonoBehavior c 2 компонентами: SpawnPoint и CircleCollider2D. В первом, с помощью второго, определяется занят ли спавн каким то врагом. OnDrawGizmos отображает в редакторе Unity расположение спавнов.
void OnDrawGizmos()
{
if (m_IsDirty)
{
Gizmos.color = Color.red;
}
else
{
Gizmos.color = Color.green;
}
Gizmos.DrawSphere(transform.position, 0.3f);
}
Все враги происходят от базового класса BasicEnemy в котором есть несколько виртуальных методов:
- ContactWizard() , для взаимодействия с волшебником
- ContactCalcifer(), для взаимодействия с Кальцифером
- ShowDeathAnimation(), для показа анимации смерти
Их можно переопределить в потомках, для различной реакции на эти события. Например PoisonEnemy ранит при прикосновении Кальцифера, в отличии от остальных врагов.
public override void ContactCalcifer()
{
GameManager.S_Instance.DamageCalcifer(damage);
base.ContactCalcifer();
}
Кстати, врагов и многие другие объекты (много и часто создаваемых на сцене) мы не удаляем с помощью Destroy(this), а отправляем обратно в пул объектов — ObjectPool.Recycle(this). Таким макаром мы неплохо экономим на создании объектов, которое как известно достаточно затратное дело.
Так например анимации заканчиваются вызовом SelfDestroy(), который и возвращает объект анимации обратно в пул.
Движутся же враги с помощью
public void OnEnable()
{
if (obj!=null&&obj.NeedRotateForDirection)
{
Vector3 moveDirection = transform.position - GameManager.S_Instance.wizardTransform.position;
if (moveDirection != Vector3.zero)
{
float angle = Mathf.Atan2(moveDirection.y, moveDirection.x) * Mathf.Rad2Deg-90;
transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);
}
}
}
Move() же является виртуальным методом, который и движет врага к цели. Его можно переопределить в потомках и сделать особенное движение (с рывками, синусоидальное и т.п.)
public virtual void Move()
{
transform.position = Vector3.MoveTowards(transform.position, SpawnManager.S_Instance.TEMP_GOAL.position, speed * Time.deltaTime);
}
На этом как бы всё.
Известные косяки
- Синглтоны
- Отсутствие классов Wizard и Calcifer. Вся логика с их взаимодействием лежит в GameManager
- Магические числа с углами поворота спрайтов. Да понимаю что надо бы все спрайты подвести к одному углу и уже от него плясать. Но времени не хватило.
- Отсутствует запись прогресса
- Кривое объявление префабов врагов в SpawnManager
TODO
- Смена дня\ночи. Днем отдыхаешь, раскидываешь скиллы и т.п. Ночью жгешь вражин.
- Новые типы врагов: стреляющие, ранящие Кальцифера, оставляющие слизь/паутину, телепортирующиеся, размножающиеся
- Прокачка Кальцифера: статы, tower defence, скиллы
- Несколько концовок. Есть пара задумок насчет нескольких вариантов развития событий, в случаи проигрыша на разных этапах/разной прокачки
- Освещение. Стены камеры освещаются Кальцифером, по мере его роста, все сильнее и сильнее.
- Кат сцены.
Анимация огонька взята с powstudios.com
За спрайт волшебника отдельное спасибо милой Kori Tyan
Комментарии (54)
KpoKec
02.12.2017 19:35Почему вместо кватернионов и арктангенса не использовать простр transform.up или transform.right как вектор направления?
vasIvas
02.12.2017 20:01-3Побольше бы таких статей, а то нормальные уже читать надоело, хочется потрещать.
Да и действительно, почему не превратить хабр в откровенный сайт по поиску работы!
Сегодня один написал, завтра другой, после завтра десяток таких статей в день.
С таким кодом я бы взял только забившийся Кальцифер прочищать.Arkebuz Автор
02.12.2017 20:09+1Что не так с кодом?
почитать в комментариях о своих косяках
Это озвучено аж до ката, так что если не сложно тыкните меня носом в косяки.
А насчет работы… Ссылки тут нет, спама тоже, продолжение про эту игру уже составляется. Не только же зубрам писать, новички тоже хотят учиться.vasIvas
02.12.2017 20:18-2Предложение работы и бесплатный рефакторинг хотят новички выкладывая свои наработки о кальцефере. Мне просто ипец как интересно лицезреть подобное на хабре.
Arkebuz Автор
02.12.2017 20:21+1Ясно. Ну тогда вопросов не имею к вам больше. Можете не лицезреть мои посты.
vasIvas
02.12.2017 20:36-2Меня пугает что это увидят такие же как Вы и завтра уже другие посты затеряются среди подобного контента-кальцефера.
sibirier
02.12.2017 23:59+1а на какую платформу вы предлагаете выкладывать свой код под предлогом какой-то цели, чтобы его могли увидеть работодатели в таких отраслях? на какие сайты заходят так много представителей IT компаний, чтобы на них такие статьи были настолько же эффективны (а может и больше)?
да, соглашусь с вами, Хабр не то место, чтобы делать из него свалку мелких статей «смотрите как умею, возьмите меня куда-нибудь». а есть ли этому альтернатива? притом не просто куда можно закидывать код и пояснениями в виде статьи, но куда бы еще и заходили искать этот самый код люди, нуждающиеся в таких программистах?
easty
03.12.2017 12:04Рискую прослыть слоупоком, что такое "кальцифера"?
GoldenStar
03.12.2017 12:47Это такой персонаж (тлеющий уголек жизни — т.е. сердце) из известного в узких детских кругах мультфильма "Ходячий Замок" легендарного японского режиссера. Хотя его мультфильмы вполне и для взрослых. Советую посмотреть всю его фильмографию — вы точно не пожалеете.
TheShock
04.12.2017 11:36Меня пугает что это увидят такие же как Вы и завтра уже другие посты затеряются среди подобного контента-кальцефера.
А мне кажется, что «ищу работу статьи» — скорее положительное явление. Автор старается максимально вложится в материал, а не просто перевести на отъебись или скопипастить новость с другого сайта.vasIvas
04.12.2017 17:33Привет! Написав это сообщение Вам выпал шанс совершенно бесплатно получить от меня ответ! Я решил Вас осчастливить уникальная возможность и Вам даже не придется ждать следующую черную пятницу. Так вот мой дорогой друг! Здесь много Ёби от чувачка-новичка… Буду благодарен за подробное объяснение и наставление! И да, ведется набор в команду для написания продолжения о нападении Сифилёчка в вечернее время на Дрищьград.
Вы реально считаете это положительным контентом? Мне Вас жаль если это так.TheShock
04.12.2017 17:42Наверное, вы хотели донести какую-то мысль. Возможно, в голове у вас она даже казалась довольно внятной. Но сформировать в речь вы ее не смогли
Daniil1979
03.12.2017 12:38Ну а я новичок в разработке на C#, и МНЕ интересно. А вот многочисленные статьи про Docker-контейнеры, которыми хабр-уже-давно-не-торт засран от и до, уже реально заколебали.
Уважаемый Arkebuz, мой Вам совет — создайте свой раздел на samlib.ru, и туда выкладывайте статьи. Потом только киньте ссылку на раздел — добавлю в друзья.
Smeilz1
03.12.2017 15:11Прошу прощения, а за то, а ваш комментарий тут тоже нужно оплатить?
Вы ведь «бесплатно» старались, тратили время на его создание.vasIvas
03.12.2017 17:05Нет, я в него рекламу потом своего портфолио вставлю и ещё пару ссылок на профиля чуваков которые мне его писать помогали. Я вообще хотел написать пост, типа- я крут и вот пару чуваков на которых стоит обратить внимание, но потом подумал что его могут не опубликовать и решил написать бессмысленный коммент от новичка, который никакой смысловой нагрузки не несет.
Но если Вы хотите заплатить, то можете это сделать! Я вообще нуждаюсь в деньгах, поэтому воспользуюсь моментом, и скажу, если кто ещё хочет заплатить то можете и заплатить! Ещё есть старая мебель на балконе могу сфоткать и ссылку приложить, может кому-то нужно. Лучше место чтобы это написать я не знаю. Если у меня не получилось Вас убедить, то можете написать в комментах, как это нужно сделать! Синглтон!
BosonBeard
03.12.2017 16:54+1То есть, я так понимаю, если бы человек убрал упоминание о поиске работы, то вы бы весь этот «хай» не подняли?
Ну в любом случае, Ваша логика чем-то напоминает логику «лубочных» старушек у подъезда, вот «проститутка» прошла, вот «наркоман».
Я так погляжу у Вас в принципе нет публикаций, Вы напишите свой шедевр, и посмотрите, как люди его оценят, таким образом Вы создадите качественный контент и героически разбавите так Вами называемый «контент-кальцифер».
Я думаю если человек делает хоть что-то созидательное, что вроде бы как не разрушает особо работу существующей системы, то не надо сразу пытаться поднять его за это «на вилы».
В конце концов объективно, автор сам по тренировался, кто-то дал дельный совет и порадовался тому что помог человеку, кто-то из любопытна прочитает, кто-то найдет эту статью через поисковую систему и заинтересуется разработкой игр на Unity, да мало ли что еще хорошего принесет эта статья.
Я вот лично благодаря ей и комментариям к ней узнал, что такое кальцифер, не думаю, что это спасет однажды мне жизнь, но и хуже явно не сделает :)
А Вам и вашим единомышленникам я думаю не так сложно, пролистать неинтересную статью, это вопрос долей секунд, учитывая, что превью текста статьи в ленте уложилось в один абзац.
slonopotamus
02.12.2017 23:49> Синглтоны
Есть мнение что с текущей архитектурой Unity это неизбежное зло. Потому что нет никакого контекста с временем жизни «сцена» и простым доступом за O(1). В результате либо вы на каждый чих будете искать нужный объект через FindObjectOfType (что медленно) либо заводите статическую переменную и запоминаете там на него ссылку.
А чтобы добавить жести, у вас нет никаких механизмов задания порядка инициализации этих «важных» объектов. Поэтому либо их приходится создавать программно on-demand (как делаете вы в Singleton.cs) и в результате терять возможность их настроить на сцене (поэтому вам пришлось городить огород вокруг редактирования WaveManager), либо они ставятся на сцену но тогда в процессе инициализации есть риск что с ними начнут пытаться работать до вызова Start/Awake (что у вас потенциально возможно в ObjectPool.cs). И так и так плохо, а сделать хорошо нечем.Arkebuz Автор
03.12.2017 00:09Script Execution Order?
slonopotamus
03.12.2017 00:17В рамках одного геймобжекта — да. Но разве он вам что-то обещает про порядок обработки нескольких геймобжектов?
TheShock
03.12.2017 01:24А иерархия? Сначала инициируются родительские элементы, потом дочерные. Порядок инициализации дочерных между собой не важен. У меня, вроде, все сперва инициируется, а потом запускается игра, потому в порядке выполнения евейков я не уверен, но вроде так они и работают?
Leopotam
03.12.2017 13:51Инициализация происходит не по иерархии, а по порядку создания инстансов в сцене. Т.е. или надо обладать идеальной памятью или использовать что-то иное для инициализации.
KumoKairo
03.12.2017 17:06+1Недавно годный доклад на эту тему опубликовали на youtube. Я сам практикую немного другие вещи (ECS / Entitas), но то что сделали они мне очень понравилось www.youtube.com/watch?v=raQ3iHhE_Kk
Очень советую к просмотру, примеры кода так же можно найти по коментам и в блоге (https://blogs.unity3d.com/2017/11/20/making-cool-stuff-with-scriptableobjects/)
WeslomPo
03.12.2017 08:35Используйте Zenject или любой другой контейнер.
TheShock
03.12.2017 09:22Он не инджектит, если используешь GameObject.Instantiate, правильно?
А следовательно — он предлагает послать всю компонентную систему Юнити. Или помимо компонента, который мы вешаем на объект — он еще предлагает создать компонент-контейнер? Я не совсем понимаю, как с этим работать, можете, пожалуйста, подробнее?
Ну вот допустим есть гипотетическая логика стратегии, там содержатся ресурсы, исследования.
У меня есть кнопка «Построить супер-здание»:
— Она должна быть серой, если здание не исследовано или уже построено в любом месте карты
— Она должна быть синей, если здание исследовано, но не хватает ресурсов
— Она должны быть зеленой, если можно построить здание
— Она должна вспыхнуть (создаться эффект с частичками), если закончилось строительство здания
— На ней должен быть написан текст, согласно локализации.
И таких зданий у нас пять.
Сейчас иерархия компонентов выглядит так:
BuildingButtonPrefab |--- BuildingButtonImage | \--- BuildingTitleText \--- ConstructedParticles
Потом пишем такой моно-бехавиор, присваиваем кнопке и вручную в редакторе перетягиваем все компоненты в соответствующие поля, внутри пишем всякую логику черезжопусинглтон. Обратите внимание, что во вьюшке необходимо данные из четырех моделей — локализация, кошелек, список строений и дерево исследований.
class BuildingButtonsList : MonoBehaviour { public BuildingButtonPrefab BuildingButtonPrefab; public void Start () { foreach (var bType in Game.instance.buildings.list) { Instantiate(BuildingButtonPrefab, transform); } } } class BuildingButtonPrefab : MonoBehaviour { public Button BuildingButtonImage; public Text BuildingTitleText; public Particles ConstructedParticles; private BuildingTypeEnum type; public void SetBuildingType (BuildingTypeEnum type) { this.type = type; text.text = Game.instance.localization.get("Buildings." + type); } public void Start () { Game.instance.onChange += OnSomethingChanged; Game.instance.onConstructed += OnConstructed; } public void OnSomethingChanged() { var g = Game.instance; if (!g.tree.IsResearched(type)) { Button.image = GetGreyImage(); } else if (g.buildings.IsConstructed(type)) { Button.image = GetGreyImage(); } else if (!g.wallet.HasResourcesFor(type)) { Button.image = GetBlueImage(); } else { Button.image = GetGreenImage(); } } public void OnConstructed(BuildingTypeEnum type) { if (type == this.type) { ConstructedParticles.Launch(); } } }
Я вижу тьму проблем в таком подходе, но Юнити прям толкает на то, чтобы писать именно так. Как это правильно сделать при использовании контейнера?WeslomPo
03.12.2017 13:18Стараться не использовать MonoBehaviour :).
Вместо Object.Instantiate — нужно использовать фабрику, и тогда-то зависимости будут прокинуты.
По примеру, больно много уже классов, чтобы быстро пояснить… я только один изменю — BuildingButtonPrefab.
public class BuildingButton : MonoBehaviour { [SerializeField] private Button _button; [SerializeField] private Text _title; [SerializeField] private Particles _particles; public void Listen(Action onClick) { _button.onClick.AddListener(onClick.Invoke); } public void Unlisten() { _button.onClick.RemoveAllListeners(); } public void Initialize(string title) { _title.text = title; } public void Burst() { _particles.Launch(); } public void SetState(ButtonState state) { switch (state) { case ButtonState.Disabled: _button.interactable = false; // _button.image = _gray; break; case ButtonState.Inactive: _button.interactable = false; // _button.image = _blue; break; case ButtonState.Active: _button.interactable = true; // _button.image = _green; break; default: throw new ArgumentOutOfRangeException("state", state, null); } } }
Как видно, тут нет синглтонов, как и нет никакой логики изменения состояния, нет самих состояний. Вообще, я руководствуюсь правилом — если это MonoBehaviour то это Вид и он максимально тупой. Все данные которые нужны, кнопка получает через методы (или поля), сама кнопка так же ничего и не спрашивает ни у кого, у ней есть все нужные ей данные для работы. Ее внутренние состояние можно поменять, в теории, только через публичные методы. Можно выделить интерфейс — и таким образом заблокировать доступ к gameObject
Логика изменения поведения кнопки находится в другом классе, он не-MonoBehaviour и контролирует ее состояние обращаясь к моделям или другим контроллерам.LeonThundeR
03.12.2017 18:25Стараться не использовать MonoBehaviour :).
Как бы это не было смешно, но насколько я знаю, это единственный верный путь, если нужно создать проект с более менее сложной архитектурой в Unity.TheShock
03.12.2017 21:54«Не использовать» или «использовать»??
LeonThundeR
03.12.2017 22:03Вообще, я руководствуюсь правилом — если это MonoBehaviour то это Вид и он максимально тупой.
Т.е. не использовать их для чего-то сложного. А выносить всю логику и т.д. в свои классы не наследуемые от MonoBehaviour и связывать с компонентами порожденным от MonoBehaviour через интерфейсы.
Но это все актуально только для более менее сложных проектов. Совсем простые игры проще, быстрее и в целом выгоднее делать не задумываясь обо всем этом, т.к. может получиться проектирование ради проектирования.
WeslomPo
03.12.2017 22:14+1Не использовать.
Когда изучаешь Unity, автомагически приобретаешь болезнь MonoBehaviour головного мозга xD. Вот ты захотел решить задачу со списком кнопок — и сразу создал «class BuildingButtonsList: MonoBehaviour» — а зачем? Можно же простой класс использовать, а в нужный момент вызвать метод Initialize либо другой аналог Start, из класса контроллера (вопрос целесообразности существования самого такого класса оставим за скобками).
Когда перестаешь при решении задач думать о том как бы это все в недокомпонентную модель вписать — сразу же приобретаешь свободу в решении задач, становится проще применять разные паттерны проектирования на практике.
Может возникнуть опасение, что этот подход не вяжется с Unity — однако это не так, движок достаточно гибкий. Попробуй Zenject изучить, довольно неплохо помогает прокачаться.
TheShock
03.12.2017 22:06По примеру, больно много уже классов, чтобы быстро пояснить… я только один изменю — BuildingButtonPrefab.
Так, ну со вьюшкой понятно. _button, _title, _particles — это все ясно. Но как там иерархией выше?
0. У нас есть что-то вродеBuildingButtonControllerFactory
, которая создаетBuildingButtonController
, которая передает уже все параметры этой нашейBuildingButtonPrefab
?
1. Или ее правильно назватьBuildingButtonView
?
2. А какBuildingButtonController
получает ссылку наBuildingButtonPrefab
? И вот смотрите, мне надо создать список кнопок. Ну допустим, это выглядит в новом подходе так:
foreach (var bType in game.buildings.list) { buttons.Add( buildingButtonControllerFactory.Produce(bType) ); }
3. Мы делаемGameObject.Instantiate(BuildingButtonPrefab)
в контроллереBuildingButtonController
? (простите за каламбур)
4. А какBuildingButtonController
знает в какой трансформ необходимо добавить свой ГеймОбджект?
5. Как вы решаете проблему, что некоторые возможности доступны только наследникуMonoBehaviour
? Я сходу не вспомню, что именно, но допустим, если быInstantiate
можно было бы сделать только внутри наследника — тогда как?
6. Какая у вас иерархия файлов? Вы отдельные компоненты храните в одной папке (их фабрику, контроллер, вьюшку и всякие сервисы). Или у вас есть директория со всеми фабриками, директория со всеми контроллерами, директория со всеми вьюшками?
7. Вы создаете три класса вместо одного на каждую сущность — не усложняется подход на больших проектах?LeonThundeR
03.12.2017 22:15+1Отвечу только по пункту 7. Остальное пусть лучше WeslomPo разжует ))
Как раз таки именно в больших проектах все это и имеет смысл, т.к. относительный объем этого инфраструктурного когда будет очень малым. А преимущества от вынесения бизнес логики в отдельные классы согласно паттерна MVC будут очень значительны.
WeslomPo
03.12.2017 22:280. Этому классу совсем неважно как его создадут и куда поместят, это не его проблемы.
1. Да более подходящее имя.
2. Не важно как :) как удобно. Например из пула объектов.
3. Обычно контроллеры не занимаются такой ерундой (просто получают список всех нужных объектов, либо пул), но можно и так.
4. Это одна из причин почему он этим не занимается.
5. Все что нужно MonoBehaviour — это вид и его проблемы, там же и решаем.
6. Под каждый кусок проекта завожу папку, внутри иерархию из папок Controllers, Views, Enums, Interfaces и т.д., папки исключаю из генерации namespace. Пока так. Но не уверен что это лучшее решение.
7. Распространенное заблуждение :). В крупных проектах долгожителях на первый план выходит поддержка, а не написание. Создавать модули — чуточку сложнее становится, за то поддерживать в десяток раз проще. Плюс разделение логики сильно сокращает время написания. Да я, лично, не люблю прокидывать классы через контейнер (так много всего нужно учесть), но это делается один раз, а потом готовые классы просто дополняются нужным функционалом. Кстати, больше всего ошибок допускается и времени уходит на виды, а контроллеры и модели писать — одно удовольствие, чистая логика.
В целом LeonThundeR прав, если проект мелкий и жить долго не собирается — лучше не морочить голову архитектурой особо — весь запал выжрет — это применимо только если нужно долго поддерживать проект.
kraso4niy
03.12.2017 11:32мне кажется или я попал на форум геймдев.ру
slonopotamus
03.12.2017 17:52Вы так говорите, будто это что-то плохое. Почему бы и не разбавить немного потоки статей про нейросети, блокчейны и вебдев?
Bookvarenko
04.12.2017 12:11Мне вот тут подумалось — а что если запилить клон этой игрушки на Godot 3.0 и сравнить с оригиналом на Unity по простоте реализации?
Arkebuz Автор
05.12.2017 09:44Попробуй, сравним:)
FeNUMe
Бедный Хаул, вот это его жизнь потрепала:)
Arkebuz Автор
Это уже в процессе пришло, изначально в голову был образ Корвина с Амбера.
Deosis
Уж больно имя узнаваемое.
Можно сделать так, чтобы камера переезжала иногда на новое место со сменой декораций и противников.
Arkebuz Автор
Пока по предварительным планам все дело будет происходить в этой камере. А врагов к следующему билду добавлю. Так же думаю можно будет кастомизацию приделать — вместо огонька Мортэ например, дракоша с Мулана или Джинни