О форме и стиле
В первом материале мы обозначили основную проблему, которую будем решать — проблему быстрой разработки качественного кода. И показали, что идеальное решение — это написание универсального фреймворка.
Также мы описали две основные категории разработчиков: те, кто может писать быстро, и те, для кого важнее всего качество. Первые — это обычно начинающие или те, кто не любит много думать (в сущности в этом нет ничего плохого). Вторые — чаще всего увлеченные (одержимые?) педанты. Последние как раз и будут разрабатывать и развивать фреймворк, а начинающие — пользоваться, разрабатывать на нем игры.
Для начинающих данная серия материалов будет объяснением фреймворка, для продвинутых в теме — обоснованием принятых решений. Для тех же, кто хочет перейти из первой категории во вторую, эти тексты могут послужить руководством по рефакторингу (улучшению кода). Ибо только переписывая и улучшая собственный код, и можно стать профессионалом.
Поскольку целевая аудитория выбрана намеренно максимально широкой, то и стиль изложения должен быть как можно более простым и подробным. Таким, чтобы не оказалось ни одного пропущенного звена в объяснении и чтобы он был понятным даже для самых начинающих. Кому текст покажется слишком уж подробным, тот всегда сможет пробежать глазами очевидные для него вещи. А вот если чего-то не хватает, то это уже ничем не компенсируешь.
Также некоторые мысли могут время от времени повторяться, правда, в другой форме. Это сделано отчасти намеренно — как педагогический прием, отчасти — как-то само собой получилось. Мысль сказанная трижды звучит убедительнее, так как если высказать ее только один раз, то фразу можно посчитать случайной или вовсе не обратить внимания. А если и заметить — то потом все равно забыть. Поэтому если увидите повторение — это не баг, это фича.
Но главное в данном цикле не столько текст, сколько исходные коды. Ведь в конечном итоге работа программиста всегда сводится к написанию кода, а всякие пояснения, тех. задания, документация и прочее — лишь сопровождающие моменты. Поэтому и в обучающих статьях опытному разработчику бывает достаточно просматривать только примеры кода, и только при возникновении вопросов читать поясняющий их текст. В данной работе также исходники были первичны. Сначала были написаны все программы, а потом только писался текст. Реально действующая программа всегда важнее текста. Можно все красиво расписать на бумаге, а когда суть дойдет до дела, то все начинает расползаться и разваливаться. А если есть хорошая программа, то написать к ней описание дело техники.
Кстати, об исходниках. Меня в книгах по программированию всегда раздражало, что примеры в них приводятся в явно упрощенно-демонстративном виде. В реальных проектах так не пишут. Различные проверки и условия опускаются ради экономии места или из лени, структура приложения примитивная. В результате книги вроде как учат программированию, но реальной профессиональной разработке, правильной культуре и стилю работы по ним не научишься. Приходится перелопачивать множество литературы, чужих исходников, писать и переписывать множество раз свои проекты, чтобы сформировать тот относительно даже небольшой набор приемов и навыков, которые позволяют назвать работника опытным. Хотя, казалось бы, опиши автор просто свой процесс реальной работы, делая попутно короткие комментарии о том, как правильно, как не правильно и как он пришел к тому или иному решению, и любой читающий будет иметь возможность приобщиться к глубинам профессии.
Но авторы так не делают. Каждый старается выдержать формальный, назидательный тон учебника, а примеры сделать настолько безжизненными, что на них и смотреть-то не хочется. Почему так? Власть ли это традиции, или нежелание создавать себе конкурентов, которые впитает твой с таким трудом накопленный опыт и быстро пойдут дальше, оставив тебя с носом? Или просто неумение писать по-другому в силу своей ограниченности исключительно на технических предметах? Кто знает. Но только вот мы по этому пути не пойдем. Я постараюсь раскрыть собственный ход мыслей при разработке, сохраняя в коде все свои мнения и стилистику, которые обычно называют вкусовщиной. Какие-то мнения для меня важны и кажутся принципиальными, какие-то — не очень. Но мне кажется вредным выбрасывать их только потому, что не являются достаточно универсальными и кому-то могут показаться спорными.
Первые версии программ, правда, и тут сделаны в наиболее упрощенном виде. Но это только, чтобы в них было легче разобраться. Чтобы форма не заслоняла содержание и смысл. Но программы здесь даны в развитии, и по мере усложнения кода они все больше и больше приближается к реальным боевым проектам, какими они должна быть.
Метод
Определившись с целью и формой того, что и как мы будем делать, разберемся с методом работы. То есть тем, как мы эту цель будем достигать.
Построение игрового фреймворка, как и всякой другой сложной системы, происходит в несколько этапов. Сначала накапливается материал для обобщений — разрабатываются простейшие прототипы игр для каждого жанра. Потом из них вычленяется то общее, что есть в играх всех жанров. Третьим шагом берем получившуюся библиотеку и реализуем на ней все существующие игры, обкатывая и совершенствуя обобщенный код. Тем самым мы на третьем этапе как бы возвращаемся к первому — разработке игр, но уже на новом, принципиально новом уровне. Тогда у нас не было фреймворка, а теперь есть.
Выполнение третьего шага будет само по себе являться наглядным доказательством того, что получившийся фреймворк действительно универсальный и годится для серьезных проектов.
Занимаясь прототипами, мы уже на второй или третьей игре начнем замечать общие моменты, которые будут дублироваться от проекта к проекту. Ясно, что в хорошо написанной программе дублирований быть не может. Ведь, если нужно исправить баг и внести изменение, править нужно все копии данного кода, что долго, неудобно и приводит к ошибкам. Поэтому мы примем за первое правило программирования выносить дублирующийся код в отдельное место. Если код повторно используется в рамках класса, он выносится в отдельный метод этого класса, если в рамках проекта — в отдельный класс, а если в разных проектах — в библиотеку. Так, пользуясь еще только первым правилом, у нас уже появляется понятие о библиотеке.
С другой стороны, если использовать один и тот же код повторно из разных мест, то всякое изменение в нем будет влиять на все эти места. В результате мы будем опасаться менять общий код, чтобы ненароком не сломать одну из тех частей приложения, где он применяется.
Поэтому недостаточно просто устранять дублирование, нужно при этом создавать еще такое обобщенное решение, которое бы заранее предусматривало все возможные варианты его использования. Другими словами, чтобы его не нужно было менять, подстраивая под новый случай использования. А вот это уже сложно. Тут нужно порой обладать дарованием пророка. Или оперировать все время настолько простыми сущностями, чтобы их реализации не допускали каких-то сложных вариантов использования, и их можно было бы легко предвидеть наперед.
Отсюда, сформулируем второе правило программирования: всякая сущность должна быть настолько простой, насколько это возможно. (Но не проще, как добавлял в таких случаях Эйнштейн.) Из этого правила напрямую вытекает другой принцип: не создавать функционал заранее, до того, как он действительно нужен.
Если применять этот принцип на разработку игр вообще, то получается, что сначала нужно делать прототип игры и только потом добавлять в него остальной функционал. Прототип — это приложение с минимальным возможным числом функций. Так что если убрать хотя бы одну из них приложение перестанет быть тем, чем оно есть. Например, в гонках — это едущая по дороге машина, в match-3 — шары на поле, которые можно менять местами и т.д.
Оба правила противоречат друг другу. Ведь если мы выносим код в отдельную функцию или библиотеку, то мы тем самым его усложняем. А если мы должны придерживаться простоты, то нам придется избегать обобщений. Но в этом противоречии нет ничего плохого. Оно говорит лишь о том, что два правила связаны и носят взаимно ограничивающий характер. То есть нам всякий раз нужно искать баланс, меру между обобщением (первое правило) и простотой кода (второе правило).
Далее. Одни игры реализовать проще, другие — сложнее. Если мы соберем вместе все основные жанры, сделаем для каждого прототип игры (пусть для начала только мысленно) и расположим их по мере увеличения сложности, то можно обнаружить, что из любых двух соседних игр более сложная отличается лишь какой-то новой функцией или усложнением старой. То есть более простая игра всегда включена в более сложную, в том или ином виде содержится в ней. Точно так же ядро водорода (протон) содержится в ядре лития и во всех остальных ядрах или ядро железа заключено в ядре следующего за ним кобальта. Многие свойства железа и кобальта схожи, но тем не менее 1 дополнительный протон делает его уникальным элементом с отличными от всех прочих свойствами. Так и в играх: добавляется одна незначительная функция, и вот мы уже получили новый жанр. И подобно тому, как новый элемент способен образовывать свой особый набор более или менее устойчивых связей с прочими элементами, так и новый жанр образует собственный набор сочетаний с другими жанрами. (А разветвление жанра на свои разновидности можно сравнить с наличием у элемента изотопов, которые отличаются друг от друга количеством нейтронов, а значит, и массой.) Реальная игра чаще всего как раз и является определенным сочетанием разных жанров (своего рода молекула). Редко когда жанр воплощается в чистом виде.
Таким образом, получается, что самая простая возможная игра в некотором виде содержится во всех прочих более сложных играх. Отсюда, если мы решим создать универсальный игровой фреймворк, нам достаточно создать его для простейшего игрового жанра. Для всех прочих он подойдет автоматически.
Однако, не следует думать, что достаточно реализовать только эту простейшую игру, и фреймворк готов. Это не совсем так. Всегда нужно держать в голове и учитывать остальные жанры при принятии важных архитектурных решений. Это связано с тем, что для простой игры некоторые вещи можно опустить как избыточные. Но то, что в примитивном приложении может показаться ненужными условностями, в более сложном уже может стать насущной необходимостью, помогающей упорядочить сложность и взять ее под контроль.
Например, если мы делаем простую раскраску, то фреймворк, может быть тут и вовсе не нужен — было бы проще все сделать без него. Ведь вся игра может поместиться в одном классе. Но в случае стратегии или того же match-3 без привычной и понятной структуры сложность через какое-то время начинает нас захлестывать, и мы перестаем понимать наш собственный код. Фреймворк тут нам подойдет как нельзя кстати. Но создавать и опробовать фреймворк проще как раз на раскраске, а не на стратегии, так как функционал раскраски не заслоняет собой фреймворк и на него можно сильно не отвлекаться, а сосредоточиться сугубо на общем для всех жанров коде.
Классификация игр
Раз уж без анализа жанров не обойтись, создадим простую классификацию игр. Выстроим их всех в ряд по мере возрастания сложности и найдем простейший. Для этого поищем вначале, какие классификации уже существуют. Из них возьмем список всех основных жанров. Глядя на него, можно обнаружить первую корневую черту, которая делит абсолютно все игры на две категории: пошаговые и реального времени. Первые не зависят напрямую от скорости реагирования пользователя, а вторые — зависят. Ясно, что пошаговые сделать проще. Их можно реализовать иногда даже в командной оболочке (CLI). Тогда как игры реального времени должны без остановки отображать постоянно изменяющееся состояние игры и одновременно и независимо от этого реагировать на действия пользователя. Поэтому поставим пошаговые игры вперед.
Далее, пошаговые игры можно, в свою очередь, разделить на чисто пошаговые и на имеющие элементы игр реального времени, то есть — переходные. К первым относятся, например, квесты, карты, шашки, match-3. Ко вторым — бильярд, чапаев, "червячки" и прочие перестрелки, match-3 shooting и т.д. Во вторых действия выполняются игроками по очереди, как и в первых. Но после того, как кнопка мышки или клавиатуры отпущена, исполнение действия не анимируется, а просчитывается в реальном времени. Их также можно условно назвать пошаговые физические, так как они часто содержат в том или ином виде физический движок для производства симуляций.
Игры реального времени также можно поделить по тому же принципу: на чисто реального времени и переходные — близкие к пошаговым. В первых все действия и отклики на них должны выполняться мгновенно. А для вторых — хоть активность пользователей и происходит в реальном времени, но скорость нажатий на кнопки не является критичной, так как действия выполняются не мгновенно, а постепенно. Так, к первым можно причислить все спортивные игры, большинство стратегий реального времени (RTS), ролевых игр (RPG); ко вторым — бомбермены, лабиринты, динамичные match-3 (Zuma), аркады (арканоид, пинбол), часть стратегий (фермы, Settlers, tower defence и их аналоги, где управление не прямое, а через длящиеся во времени приказы) и RPG (где упор делается больше на диалогах, чем на сражениях).
Итак, основная классификация по мере увеличения сложности будет выглядеть так:
-
Пошаговые.
Чистые (с анимацией действия).
Переходные (с симуляцией действия — с элементами игр реального времени).
-
Реального времени.
Переходные (отложенного действия — с элементами пошаговых).
Чистые (мгновенного действия).
Вот так с помощью многократного деления игр по одному и тому же признаку — на более или менее пошаговые или реального времени, мы создали понятную и удобную классификацию игр по сложности. Пока что такого разделения достаточно.
Очевидно, что самые простые игры — это чисто пошаговые. Их, в свою очередь, можно разделить по организации игрового поля на:
игры на произвольном поле (поиск предметов, квесты, игры на ставки, карточные),
игры на поле в клетку (сапер, крестики-нолики, шахматы, match-3, стратегии).
Поле в клетку нужно создавать программно, а т.н. произвольное поле создается в графическом редакторе дизайнером и потом лишь парсится программой. Поэтому игры на произвольном поле проще в написании, так как ответственность за создание поля выносится из сферы программирования в сферу деятельности дизайнеров. Поэтому для дальнейших поисков простейшего жанра выберем первую подкатегорию, а не вторую.
Дальше будем двигаться по мере убывания сложности. Начнем с игр с картами. В карточных играх нужно добавлять, убирать и перемещать карты (дурак, джокер). Также некоторые из них допускают ставки (покер). То есть карточные уже сложнее игр на ставки, где также нужно совершать все те же действия, но только с фишками (крапс, лото). В квестах правила попроще, хотя в них и присутствуют все те же операции: перемещение предметов в инвентарь и обратно, их появление и исчезновение на поле, изменение состояния объектов (например, надавить на рычаг, открыть дверь, переместить персонажа). Поиск предметов — это упрощенный квест, где предметы можно только помещать в инвентарь, но состояние их не меняется. Поиск отличий — это поиск предметов без инвентаря: предметы на поле просто появляются (или исчезают). Весьма простая игра. Но можно подыскать еще проще. Например, в раскрасках выбирается цвет (обычный RadioButton) и при нажатии на какой-либо элемент поля, этот цвет на него применяется. То есть изменение состояния предмета с выбором, на какое состояние изменить. Изменить объект проще, чем добавить его или удалить.
Но еще проще — это когда в игре объекты просто изменяют собственное состояние без всякого выбора. Так работают dress-up-игры, они же одевалки. Нажал на кнопку вправо, кадр соответствующего элемента увеличился на 1, влево — уменьшился на 1. Что может быть проще — один клик, и единственный параметр объекта изменен. Все объекты независимы друг от друга, потому что их состояние ничего не означает (в отличие от поиска отличий, например). Игра не имеет ни начала, ни конца, ни очков, ни результата. Весь ее смысл заключен в простом переключении кадров. Никакой бизнес-логики и правил. Есть только логика отображения.
Вот такая игра нам и нужна. Она позволяет максимально абстрагироваться от реализации самой игры и сосредоточится на реализации исключительно фреймворка.
Содержание
Не смотря на всю примитивность данного жанра (Dress-Up), при создании реальной игры нам понадобятся все те же функции, что и в любой другой игре. Разделение на меню и игру требует введения концепции экранов, или скринов (screens); при выходе из игры можно показать диалог: "Все изменения пропадут. Вы точно уверены?" — так появляются диалоги; музыка и звуки, загрузка ресурсов, локализация, кнопки и другие UI-компоненты, конфиги — всё это не будет лишним даже в одевалке. Всё это необходимо также и во всякой другой уважающей себя игре. Даже сетевой режим — и тот можно реализовать для dress-up: допустим, мы настраиваем своего персонажа и хотим, чтобы все наши действия видели другие игроки и обменивались при этом своими мнениями в чате.
Разобравшись с классификацией игр (т.е. изучив предметную область) и найдя два самых простых жанра для использования их в качестве примера (одевалка и раскраска), мы можем перейти к написанию кода.
В основу нашей работы мы положим два принципа, рассмотренных выше: избегать дублирования кода и делать настолько просто, насколько это возможно. Благодаря первому правилу у нас будут появляться новые функции, классы, модули и библиотеки. А согласно второму — весь функционал будет вводиться постепенно, шаг за шагом.
Чтобы вся эволюция кода была всегда перед глазами, каждый шаг мы будем помещать в отдельную директорию. Благодаря этому, если нам встретится сложное место в коде, мы всегда можем вернуться к предыдущем понятному нам шагу и проследить все изменения. Более того, мы можем даже запустить в режиме отладки несколько проектов одновременно, что затруднительно, если помещать версии в разные ветки гита или помечать тэгами.
Для каждого большого этапа эволюции мы создадим отдельный проект. Внутри каждого проекта будет прослеживаться собственные этапы развития. Каждый из них будет помещен в отдельный пакет/модуль: v1, v2, v3. В каждом следующем пакете чаще всего будут содержаться только изменения по отношению к предыдущей версии, чтобы не нужно было тратить дополнительное время на поиск отличий в классах. Перед началом следующего проекта все версии предыдущего сливаются в один пакет v0. Эта нулевая версия и будет началом следующего проекта, основой для всех его последующих версий. Она не будет содержать ничего нового, чего бы не было в предыдущем проекте, поэтому особого изучения она не требует (поэтому и 0).
Инструменты
Теперь осталось определиться с выбором инструментария. Данную работу можно провести на любой платформе и на любом языке программирования. Если бы нам нужно было делать только 3D-игры, мы бы просто изучили Unreal Engine или Unity и не парились. Но для простых 2D-игр подобного общепризнанного стандарта не существует. Поэтому мы вынуждены создавать его сами.
Основным критерием при выборе технологий для клиента — это как можно большая универсальность. Чтобы написанный код подходил под максимальное число случаев применения. Чтобы его можно было скомпилировать под как можно большее количество платформ. И только на втором месте идет удобство и скорость разработки.
Поэтому для реализации клиента был выбран язык Haxe + графическая библиотека OpenFL, полностью повторяющая Flash API. Этот выбор обусловлен несколькими решающими факторами:
кроссплатформенность — результат можно компилировать, наверное, на все хоть сколько-нибудь популярные платформы: Web, Mobile, Desktop, Console, Flash;
вся графика и GUI создаются и компонуются в Adobe Animate (бывш. Flash) дизайнерами; в проект GUI подключается в виде скомпилированных swf-файлов, которые будут автоматически скомпилированы библиотекой OpenFL (а точнее Lime) в атласы и подключены к коду; все это происходит незаметно для разработчика, с минимальными с его стороны усилиями;
сходство Haxe с ActionScript 3 и OpenFL с Flash API позволяет легко влиться в работу всем, кто работал на Flash-платформе и хотел бы продолжить пользоваться этой прекрасной технологией (не смотря на все ее недостатки), но по понятным причинам не может;
сходство Haxe c TypeScript и поддержка этого языка в OpenFL позволяет при необходимости без особого труда конвертировать все исходники на TypeScript и перейти на чистую HTML5-разработку.
Другое дело в разработке сервера. Так как мы сами выбираем машину, на которой будет запускаться программа, кроссплатформенность теряет свое значение. Остается только удобство и скорость разработки. По этим и некоторым другим факторам язык Python находится вне конкуренции. Среди них:
высокая скорость и удобство разработки, красивый и лаконичный код, приятный процесс программирования;
относительная легкость в освоении новичками (см. питонтьютор);
один из самых востребованных языков на рынке.
Главный недостаток — низкую скорость исполнения (производительность) в случае необходимости можно будет побороть переводом проекта на типизированный Cython.
План
Определившись с инструментарием, наметим основные этапы работы:
Создание прототипа dress-up-игры.
Создание прототипа раскраски — второй по простоте игры.
Объединение двух игр в одном приложении. Для этого понадобятся скрины для каждой игры и скрин меню.
Скрины, кнопки и другая логика отображения выделяются в отдельные классы и реализуются как компоненты. Для всех них создадим один базовый класс — Component.
Добавим звуки, локализацию, ресурсы. Появляются менеджеры.
Чтобы можно было точечно изменять классы в иерархии вложенностей введем IoC-менеджер. Процесс создания всех компонентов и других объектов становится, таким образом, централизованным, а потому более управляемым.
Добавим диалоги.
Выделим хранение и обработку данных из компонентов в модели.
Если до этого игра была desktop-приложением, то благодаря разделению логики и отображения (выделению моделей из компонентов) можно перенести логику на сервер. Для этого в компоненты через IoC передается другая реализация модели, но с тем же интерфейсом.
Реализуем HTTP-сервер (Python + Flask).
Реализуем Socket-сервер (Python + asyncio).
Выделим и на клиенте, и на сервере отдельные классы для передачи сообщений (transport/server), их форматирования (parser) и преобразования команд в вызовы функций и обратно (protocol).
Добавим возможность загружать в приложение несколько "подприложений" для сеанса одновременной игры (MultiApplication).
Оформим получившийся результат в фреймворк.
Код специфический для одевалки и раскраски выделим в отдельную библиотеку с играми.
Выполнив каждый пункт плана мы получим эволюцию исходного кода фреймворка. Имея перед глазами всю эволюцию программы, мы легко может найти причины выбора того или иного решения, разобраться с любым непонятным нам аспектом. Ведь что, как не история предмета, помогает нам лучше понять сам предмет, то, как он таким стал.
А чтобы вообще не осталось никаких вопросов, на каждый этап было написано небольшое разъяснение. В результате чего и получились данный цикл статей, предисловие к которому вы сейчас читаете. Первый его часть посвящена клиентской части, а вторая — серверной. Так из исходных кодов появилось руководство. Так из практики рождается теория.
Писать код и писать его словесное пояснение — не совсем одно и то же. Когда программируешь, кажутся главными одни вещи. Когда спустя какое-то время осмысливаешь и выражаешь те же вещи словами, общая картина уточняется, и некоторые акценты смещаются. Поэтому ход движения мысли в коде и в тексте может несколько отличаться. Но я решил не подгонять код под текст, а оставил, как есть.
Вместе вся эта работа возможно кому-то послужит неплохим руководством и введением не только в геймдев, но и в профессиональную разработку приложений вообще. Как десктопных, так и сетевых. Начав с самого примитивного примера и очень постепенно его усложняя до конечного продукта, я старался сделать текст максимально доступным для новичков с самым базовым знанием используемых языков программирования и библиотек. Думаю, для большинства будет достаточно пройти руководство по Haxe и OpenFL, чтобы можно было приступить к первому разделу, посвященному клиенту, и питонтьютор — для второго. Для более специфических тем Python и Flask предоставляют хорошую документацию, где все строго по делу и нет ничего лишнего.
Вслед за первыми двумя частями, посвященными разработке клиентского и серверного фреймворков когда-нибудь может быть последует третья — по разработке собственно самих игр. В ней будет рассматриваться эволюция жанров, общая система команд и универсальный клиент-серверный протокол. Отдельный раздел будет посвящен развитию мета-геймплея, включая достижения (achievements), бонусы, систему платежей и промо-акций и т.д. Надеюсь, все вместе это когда-нибудь послужит одним из кирпичиков к построению большой теории разработки игр.