Об архитектурных неудачах, ошибках планирования и прочих косяках при разработке игры.
Идея
Для меня Bender начался с темы на геймдев.ру форуме о поиске программиста на головоломку. Я перебирал идеи мини-игр, чтобы добавить в свою рпгшку, и эта головоломка показалась мне очень знакомым из-за общих черт с Сокобаном, но я так и не смог нигде найти копию.
Мне понравилось, что гейм-дизайнер сразу записал геймплейное видео с анимациями и музыкой, поэтому я решил, что с таким качественным подходом за игрушку можно взяться. Тем более она не такая уж сложная - всё действие происходит на одном экране, видео демонстрирует весь функционал - по сути это и есть ТЗ. Единственная механика, которая казалась "сложной" - это поворот уровня гаечным ключом, но я подумал, что с небольшим количеством математики я справлюсь
В то время я искал себе в напарники второго программиста, чтобы совместно в свободное время пилить рпгшку, так что ещё одним плюсом этого проекта была возможность привлечь программиста в команду сначала на маленький проект, и если всё сложится, то продолжить работать уже над большим.
Планирование
Разработка заняла почти два года. Хотя в начале я планировал сделать головоломку за несколько свободных выходных.
Можно, конечно, списать итоговую разницу на то, что свободные выходные случались не так уж и часто, но главная проблема была в изначальном отсутствии чёткого планирования. Под этим я подразумеваю не просто список задач в трекере, а оценки времени на реализацию каждой фичи.
Например, задача "окно выбора уровня" - оценка времени - 4 часа. Уже посчитав только сумму времени на выполнение всех подобных задач можно было бы понять, что работая в лучшем случае по 6 часов в неделю игру придётся пилить как минимум несколько месяцев. А после того, как станет понятно, что есть не 6 часов в неделю, а 4, при этом не каждую, а оценки времени на задачи в среднем занижены в 2 раза - то пилить игру уже придётся год.
Так что не следовало пренебрегать более тщательным оформлением списка задач, правильная оценка сроков крайне важный навык разработчика и тренировать его нужно как можно чаще, особенно на длинных дистанциях.
А ещё классно было бы структурировать задачи на месячные спринты - чтобы появились какие-то дедлайны по функционалу.
Наивно думать, что для менеджмента достаточно трекера задач. Известно, что когда недостаточно людей - то художнику приходится быть и аниматором, геймдизайнеру ещё и менеджером, но эти роли нужно не совмещать, а "чередовать", выделяя дни на программирование и дни на менеджмент - в таком случае работа будет сделана не на отвали, чтобы как можно быстрее перейти к "основной роли", а полностью.
Поиск команды
Как я уже писал в начале - у меня было желание найти второго программиста. В начале разработки тем, кто откликался на тему с поиском второго программера я записывал маленькие видео с обзором уже написанного кода, которые к сожалению не сохранились, и давал задачу на небольшой рефакторинг, думая, что это как раз позволит ознакомиться с проектом.
Позже, когда функционал был во многом готов - я давал задачу на реализацию гаечного ключа, который мне самому совсем не хотелось делать из-за того, что лень было разбираться с математикой.
В обоих случаях люди пропадали бесследно.
В чём была проблема? Спустя год кажется, что выбор первой задачи был неудачным. Гаечный ключ вообще не стоило давать, потому что это самая сложная часть проекта, рефакторинг тоже не кажется адекватной первой задачей. Возможно нужно было просить собрать какое-нибудь маленькое окошко типа "Настроек звука".
А может и просто с людьми не повезло. Вряд ли проблема была в ужасном качестве кода - думаю, об этом можно было просто сообщить перед исчезанием :)
С потенциальной целью найти напарника я даже завёл блог о программировании, но спустя небольшое время сделал вывод, если сам не студент или джун, то искать напарника бесполезно, увы. Парного программирования всё равно не выйдет, потому что промежутки свободного времени как правило не пересекаются, а совпасть по скиллам и вовсе фантастика.
Сигналы
Теперь, собственно, о коде. Он ведь и правда ужасен.
В пет-проектах я всегда экспериментирую, в этот раз решил полностью отказаться от связности кода. Я подумал - нафиг синглтоны, нафиг DI, нафиг вообще всё - пусть классы общаются сигналами. Ну а что? Игра маленькая, сигналы позволяют быстро прототипировать, ими можно гибко передавать данные между любыми сущностями - одни плюсы. Опирался я на типизированные сигналы, типа MessageBroker из UniRx, ну или сигналы в Zenject
Тем более у меня был положительный опыт с ними - реализация контроллера квестов через них. Код выбрасывал ивенты каких-то ключевых событий, типа убийства мобов, или крафта предметов, а квест-контроллер подписывался на них и увеличивал внутренние счетчики активных квестов. Таким образом менеджер квестов вместо зависимостей на миллион контроллеров подписывался только на нужные ивенты. Было хорошо.
Но если использовать сигналы для связей между UI и контроллерами, а также между самими контроллерами - сигналы плодятся неимоверно быстро. И непонятно где складировать типы сигналов - какие-от отдельные папки делать для них? Или хранить рядом с контроллерами, которые их "производят"? А может наоборот рядом с вьюшками для которых этих сигналы предназначены? В любом случае быстро становится неприятно открывать файлы с кучами сигналов, чтобы вставить ещё один.
Другая проблема - это чтение кода с сигналами. Когда я писал код перемещения фишки было очевидно понятно, что сначала идёт детект свайпа SwipeSignal, потом определение направления движения MoveSignal, потом собственно начало анимации движения StartMovingSequence, потом конец анимации FinishMovingSequence, и в итоге конец хода NewTurnSignal. Но рефакторя этот код можно запросто забыть, что необходимо выстрелить FinishMovingSequence. А если это движение было начато по подсказке, то ещё и StartHintMove и FinishHintMove нужно куда-то всунуть...
Проблема ещё в том, что эти сигналы сложно задокументировать. Для связи классов можно хотя бы диаграмму нарисовать, а в такой архитектуре вместо связей будут сигналы и самое главное - их последовательность вызова - на диаграмме не отразить.
Кроме того ухудшается стэк вызовов, соответственно усложняется отладка, выдумывать имена сигналов с каждым новым становится всё труднее и труднее... Так что лучше старое доброе прокидывание зависимостей в Инитах или в конструкторах и простые вызовы методов, либо стандартные подписки на ивенты типа public event Action<> OnSomethingHappend.
Стейт-машина
В качестве реализации основного контроллера игры я выбрал стейт-машину. О ней сказано много хорошего, а в геймдеве её часто рекомендуют использовать для стейтов самой игры, а я никогда так её не использовал. Обычно только в UI для пары-тройки стейтов с анимированными переходами между ними. Решил наверстать.
В головоломке исходя из начального видео вроде бы всего несколько стейтов - собственно ожидание хода игрока, анимация хода, и три стейта под каждый инструмент. Итого 5 стейтов.
Классы под стейты я делать не хотел, потому что по сути мне нужна было только переключение между ними, так что в качестве реализации я выбрал Stateless.
В процессе стало понятно, что забыт стейт паузы, а стейт с гаечным ключом разделился на два отдельных. При этом попасть из одного стейта в другой можно несколькими путями, например через отмену хода, о чем я в начале также не подумал.
Результирующий файл со стейт-машиной получился такой. Из него совершенно непонятны как стейты связаны между собой и из какого в какой можно перейти и, главное, как. Ужас.
А всё потому, что стейт-машина это невероятно вязкий паттерн, на который хорошо переписывать уже существующий код, зная что он больше не будет меняться, но вот код, который может расширяться, т.е. добавляться новые стейты и усложняться переходы между ними - писать на стейт машине крайне тяжело. И это относится не только к такой stateless стейт-машине, но и к стейт машине, где за каждый стейт отвечает свой класс.
Либо, если стейт-машину всё-таки нельзя избежать, как, например, в контроллере какого-нибудь персонажа платформера с разными видами прыжков, прицепляний к поверхностях, слайдами и т.п. - нужно делать суперскую документацию. Жаль, что никто до сих пор не написал плагин к райдеру или вижл студии для добавления "рисунков" на поля, а не просто текстовых комментариев.
Тесты
Было, конечно, и хорошее. Весь сложный код - будь это поворот ключом части уровня, или срабатывание нескольких особых объектов типа магнита или вентилятора - был покрыт юнит-тестами.
И хотя во всяких книжках про TDD пишут, что тестировать нужно только публичные методы, но по-моему это возможно применить только в бэкэнде, где есть публичное апи. А в юнити публичные методы зачастую содержат ещё какие-то анимации и прочий не "чистый" функционал, на который необходимо множество моков.
Так что всякие багоёмкие методы контроллеров по трансформации моделей - я выносил в отдельные чистые статические функции, тестировать которые можно без моков - передавая весь стейт уровня через параметр и на выходе получая новый стейт.
Итог
Второй положительный момент - игра всё-таки вышла. Но доволен ли я игрой? Увы, нет.
Потому что сделать игру - это только первый шаг, а второй шаг - её отполировать. Поэтому мы решили отключить звуки, так как нормальные найти за короткое время не получилось, не сделали вторую итерацию по графике, потому что просто устали, потратили лишь немного времени на баланс сложности уровней и не добавили никаких визуальных эффектов и анимаций.
А по-хорошему на эти "мелочи" нужно закладывать столько же времени, как на разработку всей игры. Менеджерский приём умножения временных оценок, названных программистами, на π для получения реалистичного срока разработки тут подходит как нельзя лучше.
Но сам геймплей на наш взгляд удался - хардкорная головоломка, для решения которой нужно реально подумать, а не просто перебрать варианты.
Автор: Морозов Сергей