Как стать автором
Обновить

Как создать игру, если ты ни разу не художник

Время на прочтение 127 мин
Количество просмотров 44K

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

И не надо…

Небольшое вступление


Сразу оговорюсь: нашей целью не является зарабатывание денег — на Хабре полно статей на эту тему. Нет, мы будем делать игру мечты.

Лирическое отступление про игру мечты
Сколько раз я слышал это слово от разработчиков-одиночек и маленьких студий. Куда ни глянь, все начинающие игроделы спешат явить миру реализацию своих мечт и «идеального видения», а потом пишут длинные статьи о своих героических усилиях, рабочем процессе, неизбежных финансовых затруднениях, проблемах с издателями и вообще «игроки-неблагодарные-псы-им-подавай-графончик-и-монетки-и-всё-бесплатно-и-платить-не-хотят-а-игру-пиратят-и-мы-прибылей-лишились-из-за-них-вот».

Люди, не обманывайте себя. Вы делаете не игру мечты, а игру, которая будет хорошо продаваться — это разные вещи. Игрокам (а особенно искушенным) нет дела до вашей мечты и платить за нее они не будут. Хотите прибылей — изучайте тренды, смотрите, что сейчас популярно, делайте что-то уникальное, делайте лучше, необычнее, чем у других, читайте статьи (их много), общайтесь с издателями — в общем, реализовывайте мечты конечных пользователей, не свою.

Если вы еще не сбежали и все же хотите реализовать игру мечты, заранее откажитесь от прибылей. Вообще не продавайте мечту — делитесь ей даром. Дарите людям свою мечту, приобщайте их к ней, и если ваша мечта чего-то стоит, вы получите пусть не деньги, но любовь и признание. Иногда это намного ценнее.

Многие считают, что игры — пустая трата времени и сил, и что серьезные люди не должны вообще на эту тему разговаривать. Но здесь собрались люди несерьезные, поэтому согласимся лишь отчасти — игры действительно отбирают много времени, если в них играть. Однако разработка игр, хоть и занимает во много раз больше времени, способна принести немало пользы. Например, позволяет познакомиться с принципами, подходами и алгоритмами, не встречающимися в разработке неигровых приложений. Или углубить навыки владения инструментами (например, языком программирования), занимаясь чем-то необычным и увлекательным. От себя могу добавить (и многие согласятся), что разработка игр (даже неудачная) — это всегда особый, ни с чем не сравнимый опыт, о котором потом вспоминаешь с трепетом и любовью, чего каждому разработчику хотя бы раз в жизни желаю испытать.

Мы не будем использовать новомодные игровые движки, фреймворки, библиотеки — мы заглянем в самую суть игрового процесса и прочувствуем его изнутри. Откажемся от гибких методологий разработки (задача упрощается необходимостью организовать работу всего одного человека). Мы не будем тратить время и силы на поиски дизайнеров, художников, композиторов и спецов по звуку — мы все сделаем сами, как умеем (но при этом сделаем все по-умному — если вдруг художник у нас появится, нам не составит особых усилий прикрутить модную графику на готовый каркас). В конце концов, мы даже не будем особо изучать инструментарий и выбирать подходящий — сделаем на том, который хорошо знаем и умеем пользовать. Например, на Java, чтоб потом, если нужно, перенести на Андроид (или на кофеварку).

«А!!! Ужас! Кошмар! Как на такую чушь вообще можно время тратить! Проваливай отсюда, я пойду что-то более интересное почитаю!»

Зачем это делать? В смысле, велосипед изобретать? Почему бы не использовать готовый игровой движок? Ответ прост: мы ничего про него не знаем, а игру хотим уже сейчас. Представьте образ мысли среднестатистического программиста: «Хочу делать игру! Там будет мясо, и взрывы, и прокачка, и можно грабить корованы, и сюжет бомбезный, и такого вообще никогда и нигде больше не было! Начну писать прямо сейчас!.. А на чем? Посмотрим, что у нас сейчас популярно… Ага, X, Y и Z. Возьмем Z, на нем сейчас все пишут...». И начинает изучать движок. А идею бросает, потому что на нее уже времени не хватает. Fin. Или ладно, не бросает, но толком не изучив движок, принимается за игру. Хорошо, если потом ему хватит совести никому не показывать свою первую «поделку». Обычно нет (зайдите в любой магазин приложений, посмотрите сами) — ну как же, хочется прибылей, нет сил терпеть. Когда-то создание игр было уделом увлеченных творческих людей. Увы, это время безвозвратно прошло — сейчас в игре главное не душа, а бизнес-модель (по крайней мере, разговоров о ней на порядок больше). У нас же цель простая: мы будем делать игры с душой. Потому абстрагируемся от инструмента (подойдет любой) и сосредоточимся на задаче.

Итак, продолжим.
Не буду вдаваться в подробности собственного горького опыта, но скажу, что одна из основных проблем для программиста при разработке игр — это графика. Рисовать программисты обычно не умеют (хотя бывают исключения), а художники обычно не умеют программировать (хотя бывают исключения). А без графики, согласитесь, редкая игра обходится. Что же делать?

Варианты есть:

1. Нарисовать все самому в простом графическом редакторе

Скриншоты игры «Kill Him All», 2003 год

2. Нарисовать все самому в векторе

Скриншоты игры «Raven», 2001 год


Скриншоты игры «Inferno», 2002 год

3. Попросить брата, который тоже не умеет рисовать (но делает это чуть лучше)

Скриншоты игры «Грёбаный», 2004 год

4. Скачать какую-то программу для 3D-моделирования и натаскать оттуда ассетов

Скриншоты игры «Грёбаный 2. Демо», 2006 год

5. В отчаянии рвать волосы на голове


Скриншоты игры «Грёбаный», 2004 год

6. Нарисовать все самому в псевдографике (ASCII)

Скриншоты игры «Fifa», 2000 год


Скриншоты игры «Sumo», 1998 год

Остановимся подробнее на последнем (отчасти потому что он выглядит не так уныло как остальные). Многие неопытные геймеры считают, что игры без крутой современной графики не способны покорить сердца игроков — их даже играми-то назвать язык не поворачивается. Подобным аргументам молчаливо возражают разработчики таких шедевров, как ADOM, NetHack и Dwarf Fortress. Внешний вид не всегда является решающим фактором, использование же ASCII дает некторые интересные примущества:

  • в процессе разработки программист сосредотачивается на геймплее, игровой механике, сюжетной составляющей и прочем, не отвлекаясь на второстепенные вещи;
  • разработка графической составляющей не отнимает слишком много времени — рабочий прототип (то есть, версия, поиграв в которую можно понять, а стоит ли вообще продолжать) будет готов намного раньше;
  • не нужно осваивать фреймворки и графические движки;
  • ваша графика не устареет за те пять лет, которые вы будете разрабатывать игру;
  • хардкорщики смогут оценить ваш продукт даже на платформах, не имеющих графической среды;
  • если все сделать правильно, то крутую графику можно прикрутить потом, попозже.

Приведенное выше длинное вступление имело целью помочь начинающим игроделам побороть страхи и предрассудки, перестать волноваться и все ж таки попробовать что-нибудь эдакое сотворить. Готовы? Тогда приступим.

Шаг первый. Идея


Как? У вас все еще нет идеи?

Выключайте компьютер, пойдите покушайте, погуляйте, спортом позанимайтесь. Или поспите, на худой конец. Придумать игру это не окна помыть — озарение в процессе не приходит. Обычно идея игры рождается вдруг, неожиданно, когда вы совершенно об этом не думаете. Если такое вдруг произошло, быстрее хватайте карандаш и записывайте, пока идея не улетела. Любой творческий процесс именно так и реализуется.

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

Но мы пойдем простым путем — предположим, что идея у нас уже есть, и мы над ней долго не думали. В качестве первого нашего грандиозного проекта будем делать клон хорошей игры от Obsidian — Pathfinder Adventures.

«Это что еще за бред! Настолки какие-то?»

Как говорится, pourquoi pas? Предрассудки мы, кажись, уже оставили, а потому смело начинаем отшлифовывать идею. Естественно, клонировать игру один к одну мы не будем, но основные механики позаимствуем. К тому же реализация пошаговой настольной кооперативной игры имеет свои преимущества:

  • она пошаговая — это позволяет не заботиться о таймерах, синхронизации, оптимизации, FPS и прочих муторных вещах;
  • она кооперативная, то есть игрок или игроки соревнуются не друг против друга, а против некоего «окружения», играющего по детерминированным правилам — это избавляет от необходимости программировать ИИ (AI) — одного из самых сложных этапов разработки игр;
  • она осмысленная — настолщики вообще люди прихотливые, во что попало играть не будут: им подавай продуманные механики и интересный геймплей — на одной красивой картинке не выедешь (чем-то знакомым отдает, не так ли?);
  • она с сюжетом — многие киберспортсмены не согласятся, но лично для меня игра должна рассказывать интересную историю — как книга, только с использованием своих особых художественных средств.
  • она занятная, что на любителя — описываемые подходы можно будет применить к любой последующей мечте, сколько бы их у вас ни было.

Для не знакомых с правилами, краткое введение:
Pathfinder Adventures — это цифровая версия настольной карточной игры, созданной на основе настольной ролевой игры (а вернее, целой ролевой системы) Pathfinder. Игроки (в количестве от 1 до 6) выбирают себе персонажа и вместе с ним отправляются в приключение, разбитое на некоторое количество сценариев. Каждый персонаж имеет в своем распоряжении карты разных типов (как то: оружие, броня, заклинания, союзники, предметы итп), при помощи которых в каждом сценарии должен найти и жестоко покарать Негодяя — специальную карту с особыми свойствами.

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

Для победы над картами (и для приобретения новых) персонажи должны пройти проверку одной из своих характеристик (стандартные для РПГ сила, ловкость, мудрость итп), кинув кубик, размер которого определяется значением соответствующей характеристики (от d4 до d12), добавив модификаторы (определяемые правилами и уровнем развития персонажа) и играя для усиления эффекта походящие карты из руки. При победе встреченная карта либо убирается из игры (если это враг), или пополняет руку игрока (если это предмет) и ход переходит к другому игроку. При проигрыше персонажу часто наносится урон, заставляющий его сбрасывать карты из руки. Интересная механика состоит в том, что здоровье персонажа определяется количеством карт в его колоде — как только игроку нужно вытащить из колоды карту, а их нет — его персонаж погибает.

Целью же является, пробравшись через карты локаций, найти и победить Негодяя, предварительно перекрыв ему пути к отступлению (подробнее об этом и много другом можно узнать, почитав правила). Сделать же это нужно на время, в чем состоит основная сложность игры. Количество ходов строго ограничено и простым перебором всех имеющихся карт цели не достичь. Потому приходится применять различные ухищрения и умные техники.

По мере выполнения сценариев персонажи будут расти и развиваться, улучшая свои характеристики и приобретая новые полезные навыки. Управление колодой также является очень важным элементом игры, так как исход сценария (особенно на поздних этапах) как правило зависит от правильно подобранных карт (и от кучи везения, но чего вы хотите от игры с кубиками?).

В целом, игра интересная, достойная, заслуживающая внимания и, что важно для нас, достаточно сложная (обратите внимание, я говорю «сложная» не в значении «трудная»), чтобы ее клон было интересно реализовывать.

В нашем случае сделаем одно глобальное концептуальное изменение — откажемся от карт. Вернее, не откажемся вовсе, но заменим карты на кубики, по-прежнему разных размеров и разных цветов (технически, не совсем корректно навывать их «кубики», так как кроме правильного шестигранника присутствуют и другие формы, но называть их «кости» мне непривычно и неприятно, а пользоваться американизмом «дайсы» — и вовсе признак дурного тона, потому оставим как есть). Теперь вместо колод у игроков будут мешочки. И у локаций тоже будут лежать мешочки, из которых игроки в процессе исследования будут вытаскивать произвольные кубики. Цвет кубика будет определять его тип и, соответственно, правила прохождения проверки. Личные характеристики персонажа (сила, ловкость итп), как следствие, упразднятся, но зато появятся новые интересные механики (о чем позже).

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

Шаг второй. Дизайн


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

Поначалу ваш дизайн-документ будет выглядеть как-то так




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

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

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

«Автор, убей себя об стену. Слишком много букв.»

Шаг третий. Моделирование


То есть, все тот же design, только более подробный.
Знаю, многим уже не терпится открыть IDE и начать кодить, но потерпите еще немного. Когда идеи переполняют нашу голову, нам кажется, что стоит лишь прикоснуться к клавиатуре, и руки сами понесутся в заоблачные дали — не успеет кофе вскипеть на плите, как рабочая версия приложения уже будет готова… отправиться в мусор. Чтобы много раз не переписывать одно и то же (а особенно чтобы не убеждаться через три часа разработки, что макет нерабочий и нужно начинать заново), предлагаю для начала хорошенько продумать (и задокументировать) основную структуру приложения.

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

Начнем, пожалуй, с диаграммы «вариантов использования» (use-case). Изобразим на ней способы взаимодействия нашего пользователя (игрока) с будущей системой:

Варианты использования


«Э… это что вообще?»

Шучу-шучу… и, пожалуй, на этом прекращаю шутить — дело-то серьезное (мечта, как-никак). На диаграмме вариантов использования необходимо отобразить возможности, которые система предоставляет пользователю. В подробностях. Но так уж исторически сложилось, что именно данный тип диаграмм получается у меня хуже всего — терпения не хватает, судя по всему. И не надо на меня так смотреть — мы не в ВУЗе диплом защищаем, а получаем удовольствие от рабочего процесса. И для данного процесса не так важны варианты использования. Гораздо важнее грамотно разбить приложение на независимые модули, то есть реализовать игру таким образом, чтобы особенности визуального интерфейса не влияли на игровые механики, и чтобы графическую составляющую при желании можно было легко изменить.

Этот момент можно детализировать на следующей диаграмме компонентов (components):

Компоненты системы


Здесь мы уже выделили конкретные подсистемы, входящие в состав нашего приложения и, как будет показано дальше, все они будут разрабатываться независимо друг от друга.

Также, на этом же этапе прикинем, как будет выглядеть основной игровой цикл (вернее, его наиболее интересная часть — та самая, которая реализует прохождение персонажами сценария). Для этого нам подойдет диаграмма деятельности (activity):

Если стоите, присядьте


Ну и напоследок неплохо бы представить в общем виде последовательность (sequence) взаимодействия конечного пользователя с игровым движком, посредством системы ввода-вывода.

Колбаски


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

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

Шаг четвертый. Выбор инструментов


Как уже было условлено, разрабатывать будем кроссплатформенное приложение, работающее как на десктопах под управлением различных операционных систем, так и на мобильных устройствах. В качестве языка программирования выберем Java, а еще лучше Kotlin, так как последний более нов и свеж, и еще не успел искупаться в волнах негодования, с головой захлестнувших его предшественника (заодно подучим, если кто еще не владеет). JVM, как вы знаете, доступен везде и всюду (на трех миллиардах устройств, хе-хе), будем поддерживать и Windows, и UNIX, и даже на удаленном сервере через SSH-подключение можно будет играть (кому это может понадобиться — неизвестно, но возможность такую предоставим). На Андроид тоже перенесем, когда разбогатеем и наймем художника, но об этом позже.

Библиотеки (без них никуда не деться) будем выбирать соответственно нашему требованию кроссплатформенности. В качестве системы сборки будем использовать Maven. Или Gradle. Или все ж таки Maven, начнем с него. Сразу советую настроить систему контроля версий (любую, какая больше нравится), чтобы легче было через много лет с ностальгическими чувствами вспоминать, как было здорово когда-то. IDE тоже выбирайте привычную, любимую и удобную.

Собственно, больше нам ничего и не нужно. Можно приступать к разработке.

Шаг пятый. Создание и настройка проекта


Если вы используете IDE, то создать проект — дело тривиальное. Нужно только выбрать для нашего будущего шедевра какое-то звучное имя (например, Dice), не забыть включить поддержку Maven в настройках, и в файле pom.xml прописать необходимые идентификаторы:

<modelVersion>4.0.0</modelVersion>
<groupId>my.company</groupId>
<artifactId>dice</artifactId>
<version>1.0</version>
<packaging>jar</packaging>

Также добавим поддержку Kotlin, по умолчанию отсутствующую:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib</artifactId>
    <version>${kotlin.version}</version>
</dependency>

и некоторые настройки, на которых не станем подробно останавливаться:

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <kotlin.version>1.3.20</kotlin.version>
    <kotlin.compiler.incremental>true</kotlin.compiler.incremental>
</properties>

Немного информации касательно гибридных проектов
Если в своем проекте вы планируете одновременно использовать и Java, и Kotlin то кроме папки src/main/kotlin у вас также будет присутствовать папка src/main/java. Разработчики языка Kotlin утверждают, что исходные файлы из первой папки (*.kt) должны компилироваться раньше, чем исходные файлы из второй (*.java) и потому настоятельно рекомендуют изменить настройки стандартных целей Maven:

<build>
    <plugins>

        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <version>${kotlin.version}</version>
            <executions>
                <execution>
                    <id>compile</id>
                    <phase>process-sources</phase>
                    <goals>
                        <goal>compile</goal>
                    </goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/main/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
                <execution>
                    <id>test-compile</id>
                    <goals>
                        <goal>test-compile</goal>
                    </goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/test/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.1</version>
            <executions>
                <!-- Replacing default-compile -->
                <execution>
                    <id>default-compile</id>
                    <phase>none</phase>
                </execution>
                <!-- Replacing default-testCompile -->
                <execution>
                    <id>default-testCompile</id>
                    <phase>none</phase>
                </execution>
                <execution>
                    <id>java-compile</id>
                    <phase>compile</phase>
                    <goals>
                        <goal>compile</goal>
                    </goals>
                </execution>
                <execution>
                    <id>java-test-compile</id>
                    <phase>test-compile</phase>
                    <goals>
                        <goal>testCompile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

    </plugins>
</build>

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

Создадим сразу три пакета (чего мелочиться-то?):

  • model — для классов, описывающих объекты игрового мира;
  • game — для классов, реализующих игровой процесс;
  • ui — для классов, отвечающих за взаимодействие с пользователем.

Последний будет содержать лишь интерфейсы, к методам которых мы будем обращаться для ввода и вывода данных. Конкретные реализации будем хранить вообще в отдельном проекте, но об этом позже. Пока же, чтобы сильно не распыляться, эти классы будем складывать здесь же, рядышком.

Не пытайтесь сразу делать идеально: продумывать до мелочей названия пакетов, интерфейсов, классов и методов; досконально прописывать взаимодействие объектов между собой — все это будет меняться, и не один десяток раз. По мере развития проекта многие вещи будут казаться вам некрасивыми, громоздкими, неэффективными и тому подобное — смело меняйте их, благо рефакторинг в современных IDE — весьма дешевая операция.

Создадим также класс c функцией main и мы готовы к великим свершениям. Для запуска можно использовать саму IDE, но как вы в дальнейшем убедитесь, для наших целей этот способ не подходит (стандартная консоль IDE не способна как следет отобразить наши графические изыскания), потому настроим запуск извне, про помощи batch (или shell в системах UNIX) файла. Но перед этим, сделаем кое-какие дополнительные настройки.

После выполнения операции mvn package мы получим на выходе JAR-архив со всеми скомилированными классами. Во-первых, по умолчанию в состав этого архива не входят зависимоти, необходимые для работы проекта (пока что их у нас нет, но в будущем обязательно появятся). Во-вторых, в файле-манифесте архива не прописан путь к главному классу, содержащему метод main, поэтому запустить проект командой java -jar dice-1.0.jar у нас не выйдет. Исправим это, добавив дополнительные настройки в pom.xml:

<build>
    <plugins>

        <plugin>
            <artifactId>maven-assembly-plugin</artifactId>
            <version>2.6</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <archive>
                    <manifest>
                        <mainClass>my.company.dice.MainKt</mainClass>
                    </manifest>
                </archive>
            </configuration>
        </plugin>

    </plugins>
</build>

Обратите внимание на название главного класса. Для функций Kotlin, содержащихся вне классов (как, например, функции main) при компиляции все равно создаются классы (потому как JVM ничего другого не знает и знать не желает). В качестве имени этого класса используется имя файла с добавкой Kt. То есть, если главный класс вы назвали Main, то скомпилирован он будет в файл MainKt.class. Именно этот последний мы и должны указывать в манифесте jar-файла.

Теперь при сборке проекта мы будем получать на выходе два jar-файла: dice-1.0.jar и dice-1.0-jar-with-dependencies.jar. Нас интересует второй. Напишем для него скрипт запуска.

dice.bat (для Windows)

@ECHO OFF

rem Compiling
call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package
if errorlevel 1 echo Project compilation failed! & pause & goto :EOF

rem Running
java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
pause

dice.sh (для UNIX)

#!/bin/sh

# Compiling
mvn -f "path_to_project/Dice/pom.xml" package
if [[ "$?" -ne 0 ]] ; then
  echo 'Project compilation failed!'; exit $rc
fi

# Running
java -jar path_to_project/Dice/target/dice-1.0-jar-with-dependencies.jar

Обратите внимание, при неудачной компиляции мы вынуждены прервать выполнение скрипта. Иначе будет запущена не последний арфив, а файл, оставшийся от предыдущей успешной сборки (иногда мы и разницу-то не обнаружим). Часто разработчики используют команду mvn clean package для удаления всех скомпилированных ранее файлов, но в этом случае весь процесс компиляции всегда будет начинаться с самого начала (даже если исходный код не менялся), что займет уйму времени. А ждать мы не можем — нам игру нужно делать.

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

Шаг шестой. Основные объекты


Постепенно начнем наполнять пакет model необходимыми для игрового процесса классами.

Диаграмма классов


Кубики — наше все, добавим их в первую очередь. Каждый кубик (экземпляр класса Die) характеризуется типом (цветом) и размером. Для типов кубика заведем отдельное перечисление (Die.Type), размер отметим целым числом от 4 до 12. Также реализуем метод roll(), который будет выдавать произвольное, равномерно распределенное число из доступного кубику диапазона (от 1 до значения размера включительно).

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

class Die(val type: Type, val size: Int) : Comparable<Die> {

    enum class Type {
        PHYSICAL, //Blue
        SOMATIC, //Green
        MENTAL, //Purple
        VERBAL, //Yellow
        DIVINE, //Cyan
        WOUND, //Gray
        ENEMY, //Red
        VILLAIN, //Orange
        OBSTACLE, //Brown
        ALLY //White
    }

    fun roll() = (1.. size).random()

    override fun toString() = "d$size"

    override fun compareTo(other: Die): Int {
        return compareValuesBy(this, other, Die::type, { -it.size })
    }
}

Чтобы не пылились, кубики хранятся в сумочках (экземплярах класса Bag). О том, что творится внутри сумки, можно лишь догадываться, потому нет смысла использовать упорядоченную коллекцию. Вроде бы. Наборы (sets) хорошо реализуют нужную нам идею, но не подходят по двум причинам. Во-первых, при их использовании придется реализовывать методы equals() и hashCode(), причем непонятно каким образом, так как сравнивать типы и размеры кубиков неверно — в нашем наборе может храниться любое количество идентичных кубиков. Во-вторых, вытягивая кубик из сумки, мы ожидаем получить не просто что-то недетерминированное, но случайное, каждый раз разное. Потому советую все же использовать упорядоченную коллекцию (список) и перемешивать ее каждый раз при добавлении нового элемента (в методе put()) или непосредственно перед выдачей (в методе draw()).

Метод examine() подойдет для случаев, когда уставший от неопределенности игрок в сердцах вытряхнет содержимое сумки на стол (обратите внимание на сортировку), а метод clear() — если вытряхнутые кубики больше в сумку не вернутся.

open class Bag {

    protected val dice = LinkedList<Die>()
    val size
        get() = dice.size

    fun put(vararg dice: Die) {
        dice.forEach(this.dice::addLast)
        this.dice.shuffle()
    }

    fun draw(): Die = dice.pollFirst()
    fun clear() = dice.clear()
    fun examine() = dice.sorted().toList()
}

Помимо сумок с кубиками, нужны также кучи с кубиками (экземпляры класса Pile). От первых вторые отличаются тем, что их содержимое видно игрокам, а потому при необходимости достать из кучи кубик, игрок может выбрать конкретный интересующий экземпляр. Эту идею реализуем методом removeDie().

class Pile : Bag() {
    fun removeDie(die: Die) = dice.remove(die)
}

Теперь перейдем к нашим главным действующим лицам — героям. То бишь, персонажам, которых отныне будем называть героями (есть весомая причина не называть свой класс именем Character в Java). Герои бывают разных типов (сиречь классов, хотя слово class лучше тоже не использовать), но для нашего рабочего прототипа возьмем лишь два: Brawler (то есть, Fighter с упором на стойкость и силу) и Hunter (он же Ranger/Thief, с упором на ловкость и скрытность). Класс героя определяет его характеристики, умения и начальный набор кубиков, но как будет позже видно, строгой привязки к классам герои иметь не будут, а потому их персональные настройки можно будет с легкостью менять в одном-единственном месте.

Добавим герою необходимые свойства в соответствии с дизайн-документом: имя, любимый тип кубика, лимиты кубиков, навыки изученные и неизученные, руку, сумку и кучу для сброса. Обратите внимание на особенности реализации свойств-коллекций. Во всем цивилизованном мире считается дурным тоном предоставлять наружу доступ (при помощи getter'а) к коллекциям, хранящимся внутри объекта — недобросовестные программисты смогут без ведома класса менять содержимое этих коллекций. Один из способов борьбы с этим — реализовывать отдельные методы для добавления и удаления элементов, получения их количества и доступа по индексу. Можно и getter реализовать, но при этом возвращать не саму коллекцию, а ее неизменяемую копию — для небольшого количества элементов не особо страшно именно так и поступить.

data class Hero(val type: Type) {

    enum class Type {
        BRAWLER
        HUNTER
    }

    var name = ""
    var isAlive = true
    var favoredDieType: Die.Type = Die.Type.ALLY
    val hand = Hand(0)
    val bag: Bag = Bag()
    val discardPile: Pile = Pile()

    private val diceLimits = mutableListOf<DiceLimit>()
    private val skills = mutableListOf<Skill>()
    private val dormantSkills = mutableListOf<Skill>()

    fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit)
    fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits)
    fun addSkill(skill: Skill) = skills.add(skill)
    fun getSkills(): List<Skill> = Collections.unmodifiableList(skills)
    fun addDormantSkill(skill: Skill) = dormantSkills.add(skill)
    fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills)

    fun increaseDiceLimit(type: Die.Type) {
        diceLimits.find { it.type == type }?.let {
            when {
                it.current < it.maximal -> it.current++
                else -> throw IllegalArgumentException("Already at maximum")
            }
        } ?: throw IllegalArgumentException("Incorrect type specified")
    }

    fun hideDieFromHand(die: Die) {
        bag.put(die)
        hand.removeDie(die)
    }

    fun discardDieFromHand(die: Die) {
        discardPile.put(die)
        hand.removeDie(die)
    }

    fun hasSkill(type: Skill.Type) = skills.any { it.type == type }

    fun improveSkill(type: Skill.Type) {
        dormantSkills
                .find { it.type == type }
                ?.let {
                    skills.add(it)
                    dormantSkills.remove(it)
                }
        skills
                .find { it.type == type }
                ?.let {
                    when {
                        it.level < it.maxLevel -> it.level += 1
                        else -> throw IllegalStateException("Skill already maxed out")
                    }
                } ?: throw IllegalArgumentException("Skill not found")
    }
}

Рука героя (кубики, которыми он располагает в данный момент), описывается отдельным объектом (класс Hand). Дизайн-решение хранить кубики-союзники отдельно от основной руки было одним из первых, пришедших на ум. Поначалу оно казалось супер-крутой фичей, но впоследствии породило огромое количество проблем и неудобств. Тем не менее, легких путей мы не ищем, а потому списки dice и allies — к нашим услугам, со всеми нужными для добавления, получения и удаления методами (некоторые из них умно определяют, к которому из двух списков обращаться). При удалении кубика из руки все последующие кубики будут сдвигаться к началу списка, заполняя пробелы — в дальнейшем это сильно облегчит перебор (не нужно обрабатывать ситуации с null).

class Hand(var capacity: Int) {

    private val dice = LinkedList<Die>()
    private val allies = LinkedList<Die>()

    val dieCount
        get() = dice.size
    val allyDieCount
        get() = allies.size

    fun dieAt(index: Int) = when {
        (index in 0 until dieCount) -> dice[index]
        else -> null
    }

    fun allyDieAt(index: Int) = when {
        (index in 0 until allyDieCount) -> allies[index]
        else -> null
    }

    fun addDie(die: Die) = when {
        die.type == Die.Type.ALLY -> allies.addLast(die)
        else -> dice.addLast(die)
    }

    fun removeDie(die: Die) = when {
        die.type == Die.Type.ALLY -> allies.remove(die)
        else -> dice.remove(die)
    }

    fun findDieOfType(type: Die.Type): Die? = when (type) {
        Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null
        else -> dice.firstOrNull { it.type == type }
    }

    fun examine(): List<Die> = (dice + allies).sorted()
}

Коллекция объектов класса DiceLimit задает ограничения по количеству кубиков каждого типа, которое герой может иметь в начале сценария. Говорить тут особо нечего, определяем начально, максимальное и текущее значения для каждого типа.

class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)

А вот с навыками дело обстоит интереснее. Каждый из них придется индивидуально реализовывать (о чем позже), но мы рассмотрим всего два: Hit и Shoot (по одному для каждого класса соответственно). Навыки можно развивать («прокачивать») с начального до максимального уровня, что зачастую влияет на модификаторы, которые добавляются к броскам кубиков. Отразим это в свойствах level, maxLevel, modifier1 и modifier2.

class Skill(val type: Type) {

    enum class Type {
        //Brawler
        HIT,
        //Hunter
        SHOOT,
    }

    var level = 1
    var maxLevel = 3
    var isActive = true
    var modifier1 = 0
    var modifier2 = 0
}

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

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

«Чего-то мне поплохело. Пойду покурю, что ли...»

А мы продолжим.
Героев и их способности описали, пора перейти к противоборствуюшим силам — великим и ужасным Игровым Механикам. А вернее объектам, с которыми нашим героям предстоит взаимодействовать.

Очередная диаграмма классов


Противостоять нашим доблестным протагонистам будут кубики и карты трех видов: злодеи (класс Villain), враги (класс Enemy) и преграды (класс Obstacle), объединенные под общим термином «угрозы» (Threat — абстрактный «запертый» класс, список его возможных наследников строго ограничен). Каждая угроза имеет набор отличительных особенностей (Trait), описывающих особые правила поведения при встрече с такой угрозой и вносящие разнообразие в игровой процесс.

sealed class Threat {
    var name: String = ""
    var description: String = ""
    private val traits = mutableListOf<Trait>()

    fun addTrait(trait: Trait) = traits.add(trait)
    fun getTraits(): List<Trait> = traits
}

class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat()

class Villain : Threat()

class Enemy : Threat()

enum class Trait {
    MODIFIER_PLUS_ONE, //Add +1 modifier
    MODIFIER_PLUS_TWO, //Add +2 modifier
}

Обратите внимание, список объектов класса Trait определен как изменяемый (MutableList), но наружу отдается в виде неизменяемого интерфейса List. Хоть в Kotlin это и будет работать, подход однако небезопасный, поскольку ничего не мешает преобразовать полученный список к изменяемому интерфейсу и произвести различные модификации — особенно просто это сделать, если обращаться к классу из кода на Java (где интерфейс List — изменяемый). Наиболее параноидальный способ защитить свою коллекцию — сделать что-то вроде этого:

fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)

но мы не станем настолько скрупулезно подходить к вопросу (вы, однако, предупреждены).

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

Карты угроз (а если вы внимательно читали дизайн-документ, то помните, что это карты) объединяются в колоды, представленные классом Deck:

class Deck<E: Threat> {

    private val cards = LinkedList<E>()
    val size
        get() = cards.size

    fun addToTop(card: E) = cards.addFirst(card)
    fun addToBottom(card: E) = cards.addLast(card)
    fun revealTop(): E = cards.first
    fun drawFromTop(): E = cards.removeFirst()
    fun shuffle() = cards.shuffle()
    fun clear() = cards.clear()
    fun examine() = cards.toList()
}

Здесь нет ничего необычного, кроме того что класс параметризованный и содержит в своем составе упорядоченный список (а вернее двухстороннюю очередь), который можно перемешивать соответствующим методом. Колоды врагов и препятствий понадобятся нам буквально через секунду, когда мы перейдем к рассмотрению…

… класса Location, каждый экземпляр которого описывает уникальную местность, которую в рамках сценария придется посетить нашим героям.

class Location {
    var name: String = ""
    var description: String = ""

    var isOpen = true
    var closingDifficulty = 0
    lateinit var bag: Bag
    var villain: Villain? = null
    lateinit var enemies: Deck<Enemy>
    lateinit var obstacles: Deck<Obstacle>

    private val specialRules = mutableListOf<SpecialRule>()
    fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule)
    fun getSpecialRules() = specialRules
}

Каждая местность имеет название, описание, сложность закрытия и признак «открытая/закрытая». Где-то здесь может таиться злодей (а может и не таиться, ввиду чего свойство villain может принимать значение null). В каждой местности есть сумка с кубиками и колоды карт с угрозами. Также местность может обладать своими уникальными игровыми особенностями (SpecialRule), которые, подобно свойствам угроз, вносят разнообразие в игровой процесс. Как видите, мы закладываем базис под будущую функциональность, даже если не планируем в ближайшее время ее реализовывать (для чего, по сути, и нужен этап моделирования).

Напоследок осталось реализовать сценарии (класс Scenario):

class Scenario {

    var name = ""
    var description = ""
    var level = 0
    var initialTimer = 0

    private val allySkills = mutableListOf<AllySkill>()
    private val specialRules = mutableListOf<SpecialRule>()

    fun addAllySkill(skill: AllySkill) = allySkills.add(skill)
    fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills)
    fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule)
    fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules)
}

Каждый сценарий характеризуется уровнем и начальным значением таймера. Аналогично виденному ранее задаются особые правила (specialRules) и навыки союзников (упустим из рассмотрения). Можно подумать, что сценарий также должен содержать список местностей (объектов класса Location) и по логике вещей это действительно так. Но как станет видно позже, такую связь мы нигде не будем использовать и никакого технического примущества она на дает.

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

«Ну вооот...»

Шаг седьмой. Шаблоны и генераторы


Представим на секундочку, в чем будет состоять процесс генерации какого-либо из рассмотренных ранее объектов, например локации (местности). Нам необходимо создать экземпляр класса Location, инициализировать его поля значениями, и так для каждой местности, которую мы захотим использовать в игре. Но постойте: у каждой локации должна быть сумка, которую тоже необходимо сгенерировать. А сумках есть кубики — это тоже экземпляры соответствующего класса (Die). Это я еще не говорю про врагов и препятствия — их вообще нужно в колоды собрать. А злодея не сама местность определяет, но особенности сценария, расположенного на уровень выше. Ну, вы поняли. Исходный код для вышеперечисленного может иметь такой вид:

val location = Location().apply {
    name = "Some location"
    description = "Some description"
    isOpen = true
    closingDifficulty = 4
    bag = Bag().apply {
        put(Die(Die.Type.PHYSICAL, 4))
        put(Die(Die.Type.SOMATIC, 4))
        put(Die(Die.Type.MENTAL, 4))
        put(Die(Die.Type.ENEMY, 6))
        put(Die(Die.Type.OBSTACLE, 6))
        put(Die(Die.Type.VILLAIN, 6))
    }
    villain = Villain().apply {
        name = "Some villain"
        description = "Some description"
        addTrait(Trait.MODIFIER_PLUS_ONE)
    }
    enemies = Deck<Enemy>().apply {
        addToTop(Enemy().apply {
            name = "Some enemy"
            description = "Some description"
        })
        addToTop(Enemy().apply {
            name = "Other enemy"
            description = "Some description"
        })
        shuffle()
    }
    obstacles = Deck<Obstacle>().apply {
        addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply {
            name = "Some obstacle"
            description = "Some Description"
        })
    }
}

Это еще спасибо языку Kotlin и конструкции apply{} — в Java код был бы в два раза более громоздким. Причем местностей, как мы сказали, будет много, а кроме них есть еще сценарии, приключения и герои с их навыками и характеристиками — в общем, есть, чем заняться гейм-дизайнеру.

Вот только гейм-дизайнер код писать не будет, да и нам неудобно при малейшем изменении игрового мира заново компилировать проект. Тут любой грамотный программист возразит, что описания объектов от кода классов нужно отделить — в идеале, чтобы экземпляры последних генерировались динамически на основе первых по мере необходимости, аналогично тому как на заводе по чертежу изготавливают деталь. Реализуем такие чертежи и мы, только назовем их шаблонами (templates) и представим экземплярами специального класса. Имея такие шаблоны, специальный программный код (генератор) будет создавать конечные объекты из описанной ранее модели.

Процесс генерации объекта из шаблона


Таким образом, для каждого класса наших объектов необходимо задать две новых сущности: интерфейс-шаблон и класс-генератор. А поскольку объектов поднакопилось приличное количество, то и сущностей тоже окажется количество… неприличное:

Диаграмма классов


Просьба дышать глубже, слушать внимательно и не отвлекаться. Во-первых, на диаграмме представлены не все объекты игрового мира, а лишь основные, без которых на первых порах не обойтись. Во вторых, дабы не перегружать схему излишними деталями, некоторые связи, уже упомянутые ранее на других диаграммах, были опущены.

Начнем с чего-нибудь простого — генерации кубиков. «Как? — скажете вы. — Разве нам мало конструктора? Да-да, вот того самого, с типом и размером». Нет, отвечу, недостаточно. Ведь во многих случаях (читайте правила) кубики необходимо генерировать произвольным образом в произвольном количестве (например: «от одного до трех кубиков либо синего, либо зеленого цвета»). Да еще размер подбирать в завимости от уровня сложности сценария. Поэтому введем специальный интерфейс DieTypeFilter.

interface DieTypeFilter {
    fun test(type: Die.Type): Boolean
}

Различные реализации этого интерефейса будут проверять, соответствует ли тип кубика различным наборам правил (любым, какие только в голову прийдут). Например, соответствует ли тип строго заданному значению («синий») или диапазону значений («синий, желтый или зеленый»); или, наоборот, соответствует любому типу кроме заданного («лишь бы не белый ни в коем случае» — все, что угодно, только не это). Даже если заранее и непонятно, какие конкретно реализации нужны, не беда — их можно добавить позже, система от этого не сломается (полиморфизм, помните?).

class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (this.type == type)
}

class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (this.type != type)
}

class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (type in types)
}

class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (type !in types)
}

Размер кубика тоже будет задаваться произвольным образом, но об этом позже. А пока напишем генератор кубиков (DieGenerator), который, в отличие от конструктора класса Die, будет принимать не явный тип и размер кубика, а фильтр и уровень сложности.

private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8)
private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10)
private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12)
private val DISTRIBUTIONS = arrayOf(
        intArrayOf(4),
        DISTRIBUTION_LEVEL1,
        DISTRIBUTION_LEVEL2,
        DISTRIBUTION_LEVEL3
)

fun getMaxLevel() = DISTRIBUTIONS.size - 1

fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level))

private fun generateDieType(filter: DieTypeFilter): Die.Type {
    var type: Die.Type
    do {
        type = Die.Type.values().random()
    } while (!filter.test(type))
    return type
}

private fun generateDieSize(level: Int) =
        DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random()
        

В Java эти методы были бы статическими, но поскольку мы имеем дело с Kotlin, класс, как таковой, нам не нужен, что справедливо и для прочих рассматриваемых ниже генераторов (тем не менее, на логическом уровне мы все же будем пользоваться понятием класса).

Два приватных метода генерируют отдельно тип и размер кубика — про каждый можно сказать что-то интересное. Метод generateDieType() можно загнать в бесконечный цикл, передав на вход фильтр с

override fun test(filter: DieTypeFilter) = false

(у сценаристов есть стойкое убеждение, что из логических нестыковок и сюжетных дыр можно выкрутиться, если сами персонажи в ходе повествования укажут на них зрителям). Метод generateDieSize(), производит генерацию псевдослучайного размера на основе распределения, заданного в виде массива (по одному на каждый уровень). Когда в старости я разбогатею и куплю себе пакет разноцветных игральных кубиков, я не смогу сыграть в Dice, потому как не буду знать способа случайным образом собрать из них сумку (кроме как попросить соседа, а самому в это время отвернуться). Это не колода карт, которую можно перетасовать рубашкой вверх, тут требуются специальные механизмы и приспособления. Если у кого-то есть идеи (и ему хватило терпения дочитать до этого места), пожалуйста, поделитесь в коментариях.

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

class BagTemplate {

    class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter)

    val plans = mutableListOf<Plan>()

    fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) {
        plans.add(Plan(minQuantity, maxQuantity, filter))
    }
}

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

private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> {
    val count = (plan.minQuantity..plan.maxQuantity).shuffled().last()
    return (1..count).map { generateDie(plan.filter, level) }.toTypedArray()
}

fun generateBag(template: BagTemplate, level: Int): Bag {
    return template.plans.asSequence()
            .map { realizePlan(it, level) }
            .fold(Bag()) { b, d -> b.put(*d); b }
    }
}

Если вы так же, как и я, устали от всей этой функциональщины, крепитесь — дальше будет только хуже. Но зато, в отличие от многих невнятных туториалов на просторах интернета, у нас есть возможность изучить использование разных хитрых методов применительно к реальной, понятной предметной области.

Сами по себе сумки на поле валяться не будут — нужно раздать их героям и локациям. Начнем с последних.

interface LocationTemplate {

    val name: String

    val description: String

    val bagTemplate: BagTemplate

    val basicClosingDifficulty: Int

    val enemyCardsCount: Int

    val obstacleCardsCount: Int

    val enemyCardPool: Collection<EnemyTemplate>

    val obstacleCardPool: Collection<ObstacleTemplate>

    val specialRules: List<SpecialRule>
}

В языке Kotlin вместо методов getЧтоТо() можно использоваить свойства интерфейсов — так гораздо лаконичнее. С шаблоном сумки мы уже знакомы, рассмотрим оставшиеся методы. Свойство basicClosingDifficulty будет задавать базовую сложность проверки на закрытие местности. Слово «базовую» означает здесь лишь то, что конечная сложность будет зависеть от уровня сценария и на данном этапе неясна. Кроме этого, нам нужно определить шаблоны для врагов и препятствий (и злодеев заодно). При этом из описанного в шаблоне разнообразия врагов и препятствий будут использоваться не все, а лишь ограниченное количество (для повышения реиграбельности). Обратите внимание, что специальные правила (SpecialRule) местности реализуются простым перечислением (enum class), а потому отдельного шаблона не требуют.

interface EnemyTemplate {

    val name: String

    val description: String

    val traits: List<Trait>
}

interface ObstacleTemplate {

    val name: String

    val description: String

    val tier: Int

    val dieTypes: Array<Die.Type>

    val traits: List<Trait>
}

interface VillainTemplate {

    val name: String

    val description: String

    val traits: List<Trait>
}

И пусть генератор создает не только отдельные объекты, но и целые колоды с ними.

fun generateVillain(template: VillainTemplate) = Villain().apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateEnemy(template: EnemyTemplate) = Enemy().apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> {
    val deck = types
            .map { generateEnemy(it) }
            .shuffled()
            .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d }
    limit?.let {
        while (deck.size > it) deck.drawFromTop()
    }
    return deck
}

fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> {
    val deck = templates
            .map { generateObstacle(it) }
            .shuffled()
            .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d }
    limit?.let {
        while (deck.size > it) deck.drawFromTop()
    }
    return deck
}

Если в колоде окажется больше карт, чем нам нужно (параметр limit), мы их оттуда уберем. Умея генерировать сумки с кубиками и колоды карт, мы наконец-то можем и местности создавать:

fun generateLocation(template: LocationTemplate, level: Int) = Location().apply {
    name = template.name
    description = template.description
    bag = generateBag(template.bagTemplate, level)
    closingDifficulty = template.basicClosingDifficulty + level * 2
    enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount)
    obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount)
    template.specialRules.forEach { addSpecialRule(it) }
}

Местность, которую мы явно задавали в коде в начале главы, теперь примет совершенно другой вид:

class SomeLocationTemplate: LocationTemplate {
    override val name = "Some location"
    override val description = "Some description"
    override val bagTemplate = BagTemplate().apply {
        addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL))
        addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC))
        addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL))
        addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE))
    }
    override val basicClosingDifficulty = 2
    override val enemyCardsCount = 2
    override val obstacleCardsCount = 1
    override val enemyCardPool = listOf(
            SomeEnemyTemplate(),
            OtherEnemyTemplate()
    )
    override val obstacleCardPool = listOf(
            SomeObstacleTemplate()
    )
    override val specialRules = emptyList<SpecialRule>()
}

class SomeEnemyTemplate: EnemyTemplate {
    override val name = "Some enemy"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
}

class OtherEnemyTemplate: EnemyTemplate {
    override val name = "Other enemy"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
}

class SomeObstacleTemplate: ObstacleTemplate {
    override val name = "Some obstacle"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
    override val tier = 1
    override val dieTypes = arrayOf(
            Die.Type.PHYSICAL,
            Die.Type.VERBAL
    )
}

val location = generateLocation(SomeLocationTemplate(), 1)

Генерация сценариев будет происходить аналогичным образом.

interface ScenarioTemplate {

    val name: String

    val description: String

    val initialTimer: Int

    val staticLocations: List<LocationTemplate>

    val dynamicLocationsPool: List<LocationTemplate>

    val villains: List<VillainTemplate>

    val specialRules: List<SpecialRule>

    fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2
}

В соответствии с правилами, количество динамически генерируемых локаций зависит от количества героев. В интерфейсе задана стандартная функция расчета, которую при желании можно переопределить в конкретных реализациях. В связи с этим требованием, генератор сценариев будет также генерировать и местности для этих сценариев — там же злодеи будут случайным образом распределены по местностям.

fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply {
    name =template.name
    description = template.description
    this.level = level
    initialTimer = template.initialTimer
    template.specialRules.forEach { addSpecialRule(it) }
}

fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> {
    val locations = template.staticLocations.map { generateLocation(it, level) } +
            template.dynamicLocationsPool
                    .map { generateLocation(it, level) }
                    .shuffled()
                    .take(template.calculateDynamicLocationsCount(numberOfHeroes))
    val villains = template.villains
            .map(::generateVillain)
            .shuffled()
    locations.forEachIndexed { index, location ->
        if (index < villains.size) {
            location.villain = villains[index]
            location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level))
        }
    }
    return locations
}

Многие внимательные читатели возразят, что шаблоны нужно хранить не в исходном коде классов, а в каких-нибудь текстовых файлах (скриптах), чтобы создавать и поддерживать их могли даже люди, далекие от программирования. Соглашусь, снимаю шляпу, но голову пеплом не посыпаю — ибо одно другому не мешает. Если хотите, достаточно определить специальную реализацию шаблона, значения свойств которой будут загружаться из внешнего файла. Процесс генерации от этого ни на йоту не изменится.

Ну вот, кажись ничего не забыли… Ах да, герои — их ведь тоже нужно генерировать, а значит им тоже нужны свои шаблоны. Вот такие, например:

interface HeroTemplate {

    val type: Hero.Type

    val initialHandCapacity: Int

    val favoredDieType: Die.Type

    val initialDice: Collection<Die>

    val initialSkills: List<SkillTemplate>

    val dormantSkills: List<SkillTemplate>

    fun getDiceCount(type: Die.Type): Pair<Int, Int>?
}

И сразу же мы замечаем две странности. Во-первых, мы не используем шаблоны для генерации сумок и кубиков в них. Почему? Да потому что для каждого типа (класса) героев список начальных кубиков строго определен — нет смысла усложнять процесс их создания. Во-вторых, getDiceCount() — что это вообще за муть такая??? Успокойтесь, это те самые DiceLimit, задающие ограничения по кубикам. А шаблон для них выбран в столь причудливом виде, чтобы нагляднее записывались конкретные значения. Убедитесь сами из примера:

class BrawlerHeroTemplate : HeroTemplate {
    
    override val type = Hero.Type.BRAWLER
    override val favoredDieType = PHYSICAL
    override val initialHandCapacity = 4

    override val initialDice = listOf(
            Die(PHYSICAL, 6),
            Die(PHYSICAL, 6),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(SOMATIC, 6),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(MENTAL, 4),
            Die(VERBAL, 4),
            Die(VERBAL, 4)
    )

    override fun getDiceCount(type: Die.Type) = when (type) {
        PHYSICAL -> 8 to 12
        SOMATIC -> 4 to 7
        MENTAL -> 1 to 2
        VERBAL -> 2 to 4
        else -> null
    }

    override val initialSkills = listOf(
            HitSkillTemplate()
    )

    override val dormantSkills = listOf<SkillTemplate>()
}


class HunterHeroTemplate : HeroTemplate {

    override val type = Hero.Type.HUNTER
    override val favoredDieType = SOMATIC
    override val initialHandCapacity = 5

    override val initialDice = listOf(
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(SOMATIC, 6),
            Die(SOMATIC, 6),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(MENTAL, 6),
            Die(MENTAL, 4),
            Die(MENTAL, 4),
            Die(MENTAL, 4),
            Die(VERBAL, 4)
    )

    override fun getDiceCount(type: Die.Type) = when (type) {
        PHYSICAL -> 3 to 5
        SOMATIC -> 7 to 11
        MENTAL -> 4 to 7
        VERBAL -> 1 to 2
        else -> null
    }

    override val initialSkills = listOf(
            ShootSkillTemplate()
    )

    override val dormantSkills = listOf<SkillTemplate>()
}

Но прежде чем писать генератор, определим шаблон для навыков.

interface SkillTemplate {

    val type: Skill.Type

    val maxLevel: Int

    val modifier1: Int

    val modifier2: Int

    val isActive
        get() = true
}

class HitSkillTemplate : SkillTemplate {
    override val type = Skill.Type.HIT
    override val maxLevel = 3
    override val modifier1 = +1
    override val modifier2 = +3
}

class ShootSkillTemplate : SkillTemplate {
    override val type = Skill.Type.SHOOT
    override val maxLevel = 3
    override val modifier1 = +0
    override val modifier2 = +2
}

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

fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill {
    val skill = Skill(template.type)
    skill.isActive = template.isActive
    skill.level = initialLevel
    skill.maxLevel = template.maxLevel
    skill.modifier1 = template.modifier1
    skill.modifier2 = template.modifier2
    return skill
}

fun generateHero(type: Hero.Type, name: String = ""): Hero {
    val template = when (type) {
        BRAWLER -> BrawlerHeroTemplate()
        HUNTER -> HunterHeroTemplate()
    }
    val hero = Hero(type)
    hero.name = name
    hero.isAlive = true
    hero.favoredDieType = template.favoredDieType
    hero.hand.capacity = template.initialHandCapacity
    template.initialDice.forEach { hero.bag.put(it) }

    for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) {
        l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) }
    }
    template.initialSkills
            .map { generateSkill(it) }
            .forEach { hero.addSkill(it) }
    template.dormantSkills
            .map { generateSkill(it, 0) }
            .forEach { hero.addDormantSkill(it) }
    return hero
}

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

Шаг восьмой. Игровой цикл


Наконец-то мы подобрались к самому интересному — реализации игрового цикла. Говоря по-простому, начали «делать игру». Многие начинающие разработчики частенько именно с этого этапа и начинают, не считая игроделанием все остальное. Особенно всякие бессмысленные схемки рисовать, пффф… Но мы не станем торопиться (до утра еще далеко), а потому еще немного моделирования. Да, опять.

Диаграмма деятельности


Как видите, приведенный фрагмент игрового цикла на порядок меньше чем то, что мы приводили выше. Мы рассмотрим лишь процесс передачи хода, исследования местности (причем опишем встречу только с двумя типами кубиков) и сброса кубиков в конце хода. А еще завершение сценария проигрышем (да, победить в нашей игре пока не получится) — а как вы хотели? Таймер будет уменьшаться каждый ход, и по его завершении что-то нужно делать. Например, вывести сообщение и завершить игру — все, как в правилах написано. Еще игру нужно завершать при смерти героев, но наносить вред им никто не будет, потому оставим. Для победы же нужно закрыть все местности, что сложно даже в случае, если она всего одна. Потому оставим и этот момент. Нет смысла слишком распыляться — нам важно понять суть, а остальное доделать уже позже, в свободное время (вернее мне — доделать, а вам — пойти писать игру своей мечты).

Итак, первым делом необходимо определиться с тем, какие объекты нам нужны.

Герои. Сценарий. Локации.
Выше мы уже рассмотрели процесс их создания — не будем повторяться. Отметим только шаблон местности, который будем использовать в нашем маленьком примере.

class TestLocationTemplate : LocationTemplate {

    override val name = "Test"
    override val description = "Some Description"

    override val basicClosingDifficulty = 0
    override val enemyCardsCount = 0
    override val obstacleCardsCount = 0

    override val bagTemplate = BagTemplate().apply {
        addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL))
        addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC))
        addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL))
        addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL))
        addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE))
    }

    override val enemyCardPool = emptyList<EnemyTemplate>()

    override val obstacleCardPool = emptyList<ObstacleTemplate>()

    override val specialRules = emptyList<SpecialRule>()
}

Как видите, в сумке лежат только «позитивные» кубики — синие, зеленые, фиолетовые, желтые и голубые. Врагов и препятствий в местности нет, злодеи и раны не водятся. Каких-то особых правил тоже нет — их реализация весьма второстепенна.

Куча для удержанных кубиков.
Или deterrent pile. Поскольку мы положили голубые кубики в сумку местности, их можно будет использовать в проверках и после использования удерживать в специальной куче. Для этого пригодится экземпляр класса Pile.

Модификаторы.
То есть, числовые значения, которые необходимо добавлять или вычитать из результата броска кубика. Можно реализовать либо глобальный модификатор, либо отдельный модификор для кажого кубика. Мы выберем второй вариант (так нагляднее), потому создадим простой класс DiePair.

class DiePair(val die: Die, var modifier: Int = 0)

Расположение героев в местности.
По-хорошему, этот момент нужно отслеживать при помощи специальной структуры. Например, карты вида Map<Location, List<Hero>>, где каждая местность будет содержать список героев, находящихся в ней в данный момент (а также метод для обратного — определения местности, в которой конкретный герой находится). Если вы решитесь идти этим путем, то не забудьте добавить в класс Location реализации методов equals() и hashCode() — надеюсь, не нужно объяснять зачем. Мы же не станем тратить на это время, так как местность всего одна и герои из нее никуда не уходят.

Проверка руки героя.
В процессе игры героям постоянно приходится проходить проверки (о которых ниже), то есть брать кубики из руки, бросать их (добавлять модификаторы), агрегировать результаты, если кубиков несколько (суммировать, брать максимальный/минимальный, средний итп), сравнивать их с броском другого кубика (того, который вынут из сумки местности) и в зависимости от результата выполнять последующие действия. Но прежде всего необходимо понять, способен ли герой в принципе пройти проверку, то есть, есть ли у него в руке нужные кубики. Для этого предусмотрим простой интерфейс HandFilter.

interface HandFilter {
    fun test(hand: Hand): Boolean
}

Реализации интерфейса принимают на вход руку героя (объект класса Hand) и возвращают true или false в зависимости от результатов проверки. Для нашего фрагмента игры понадобится единственная реализация: если встречен синий, зеленый, фиолетовый или желтый кубик, нужно определить, есть ли в руке героя кубик такого же цвета.

class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter {

    override fun test(hand: Hand) =
            (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types }
                    || (Die.Type.ALLY in types && hand.allyDieCount > 0)
}

Да, опять функциональщина.

Активные/выбранные позиции.
Теперь, когда мы убедились, что рука героя подходит для выполнения проверки, необходимо, чтобы игрок выбрал из руки тот кубик (или кубики), при помощи которого он эту проверку будет проходить. Во-первых, нужно выделить (подсветить) подходящие позиции (в которых есть кубики нужного типа). Во-вторых, нужно как-то отмечать выбранные кубики. Для обоих этих требований подойдет класс HandMask, который, по сути, содержит набор целых чисел (номеров выбранных позиций) и методы для их добавления и удаления.

class HandMask {

    private val positions = mutableSetOf<Int>()
    private val allyPositions = mutableSetOf<Int>()
    val positionCount
        get() = positions.size
    val allyPositionCount
        get() = allyPositions.size

    fun addPosition(position: Int) = positions.add(position)

    fun removePosition(position: Int) = positions.remove(position)

    fun addAllyPosition(position: Int) = allyPositions.add(position)

    fun removeAllyPosition(position: Int) = allyPositions.remove(position)

    fun checkPosition(position: Int) = position in positions

    fun checkAllyPosition(position: Int) = position in allyPositions

    fun switchPosition(position: Int) {
        if (!removePosition(position)) {
            addPosition(position)
        }
    }

    fun switchAllyPosition(position: Int) {
        if (!removeAllyPosition(position)) {
            addAllyPosition(position)
        }
    }

    fun clear() {
        positions.clear()
        allyPositions.clear()
    }

}

Я уже говорил, как я страдаю от «гениальной» идеи хранить белые кубики в отдельной руке? Из-за этой глупости приходится управляться с двумя наборами и дублировать каждый из представленных методов. Если у кого-то есть идеи, как упростить реализацию этого требования (например, использовать один набор, но у белых кубиков индексы начинаются с сотни — или еще что-то в той же степени невразумительное) — делитесь ими в коментариях.

Кстати, похожий класс нужно реализовать для выбора кубиков из кучи (PileMask), но эта функциональность находится за пределами рассматриваемого примера.

Выбор кубиков из руки.
Но мало «подсветить» допустимые позиции, важно менять эту «подсветку» в процессе выбора кубиков. То есть, если от игрока требуется взять из руки только один кубик, то при выборе этого кубика, все остальные позиции должны стать недоступными. Более того, на каждом этапе необходимо контролировать выполнение игроком цели — то есть, понимать, достаточно ли выбранных кубиков для прохождения той или иной проверки. Такая сложная задача требует непростой экземпляр непростого класса.

abstract class HandMaskRule(val hand: Hand) {

    abstract fun checkMask(mask: HandMask): Boolean

    abstract fun isPositionActive(mask: HandMask, position: Int): Boolean

    abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean

    fun getCheckedDice(mask: HandMask): List<Die> {
        return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt))
                .plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt))
                .filterNotNull()
    }

}

Довольно сложная логика, я пойму и прощу вас, если этот класс окажется для вас непонятным. И все же попытаюсь объяснить. Реализации этого класса всегда хранят ссылку на руку (объект Hand), с которой будут иметь дело. Каждый из методов принимает на вход маску (HandMask), отражающую текущее состояние выбора (какие позиции выбраны игроком, а какие нет). Метод checkMask() сообщает, достаточно ли выбранных кубиков для прохождения проверки. Метод isPositionActive() говорит, нужно ли подсвечивать конкретную позицию — можно ли добавить к проверке находящийся в этой позиции кубик (или убрать кубик, который уже выбран). Метод isAllyPositionActive() — то же самое для белых кубик (да, знаю, я идиот). Ну и вспомогательный метод getCheckedDice() попросту возвращает список всех кубиков из руки, которые соответствуют маске — это нужно для того чтобы всех их разом взять, бросить на стол и наслаждаться веселым стуком, с коим они разлетаются в разные стороны.

Реализаций этого абстрактного класса нам понадобится две (сюрприз, сюрприз!). Первая контролирует процесс прохождения проверки при приобретении нового кубика конкретного типа (не белого). Как вы помните, к такой проверке можно добавлять любое количество голубых кубиков.

class StatDieAcquireHandMaskRule(hand: Hand,
                                 private val requiredType: Die.Type)
    : HandMaskRule(hand) {

    /**
     * Define how many dice of specified type are currently checked
     */
    private fun checkedDieCount(mask: HandMask) =
            (0 until hand.dieCount)
                    .filter(mask::checkPosition)
                    .mapNotNull(hand::dieAt)
                    .count { it.type === requiredType }

    override fun checkMask(mask: HandMask) =
            (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1)


    override fun isPositionActive(mask: HandMask, position: Int) =
            with(hand.dieAt(position)) {
                when {
                    mask.checkPosition(position) -> true
                    this == null -> false
                    this.type === Die.Type.DIVINE -> true
                    this.type === requiredType && checkedDieCount(mask) < 1 -> true
                    else -> false
                }
            }

    override fun isAllyPositionActive(mask: HandMask, position: Int) = false

}

Вторая реализация сложнее. Она управляет процессом сброса кубиков в конце хода. При этом возможны два варианта. Если количество кубиков в руке превышает ее максимально допустимый размер (capacity), мы должны сбросить все лишние кубики плюс любое количество дополнительных кубиков (если хотим). Если же размер не превышается, то можно ничего не сбрасывать (а можно и сбросить, по желанию). Серые кубики ни в одном из случаев сбрасывать нельзя.

class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) {

    private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0
    private val maxDiceToDiscard = hand.dieCount - hand.woundCount

    override fun checkMask(mask: HandMask) =
            (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) &&
                    (mask.allyPositionCount in 0..hand.allyDieCount)

    override fun isPositionActive(mask: HandMask, position: Int) = when {
        mask.checkPosition(position) -> true
        hand.dieAt(position) == null -> false
        hand.dieAt(position)!!.type == Die.Type.WOUND -> false
        mask.positionCount < maxDiceToDiscard -> true
        else -> false
    }

    override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null

}

Нежданчик: в классе Hand вдруг появилось свойство woundCount, которого раньше не было. Его реализацию можете написать сами, это несложно. Заодно попрактикуетесь.

Прохождение проверок.
Наконец добрались до них. Когда кубики взяты из руки, пришла пора их бросать. Для каждого кубика необходимо учитывать: его размер, его модификаторы, результат его броска. Хотя из сумки местности одновременно можно вынимать лишь один кубик, против него можно выставлять несколько кубиков, аггрегируя результаты их бросков. Вообще, давайте абстрагируемся от кубиков и представим войска на поле боя. С одной стороны у нас враг — он всего лишь один, но он силен и свиреп. С другой стороны равный ему по силе соперник, но с поддержкой. Исход битвы решится в одной короткой стычке, победитель может быть лишь один…

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

class DieBattleCheck(val method: Method, opponent: DiePair? = null) {

    enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN }

    private inner class Wrap(val pair: DiePair, var roll: Int)

    private infix fun DiePair.with(roll: Int) = Wrap(this, roll)

    private val opponent: Wrap? = opponent?.with(0)
    private val heroics = ArrayList<Wrap>()
    var isRolled = false
    var result: Int? = null
    val heroPairCount
        get() = heroics.size

    fun getOpponentPair() = opponent?.pair

    fun getOpponentResult() = when {
        isRolled -> opponent?.roll ?: 0
        else -> throw IllegalStateException("Not rolled yet")
    }

    fun addHeroPair(pair: DiePair) {
        if (method == Method.SUM && heroics.size > 0) {
            pair.modifier = 0
        }
        heroics.add(pair with 0)
    }

    fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier))

    fun clearHeroPairs() = heroics.clear()

    fun getHeroPairAt(index: Int) = heroics[index].pair

    fun getHeroResultAt(index: Int) = when {
        isRolled -> when {
            (index in 0 until heroics.size) -> heroics[index].roll
            else -> 0
        }
        else -> throw IllegalStateException("Not rolled yet")
    }

    fun roll() {
        fun roll(wrap: Wrap) {
            wrap.roll = wrap.pair.die.roll()
        }
        isRolled = true
        opponent?.let { roll(it) }
        heroics.forEach { roll(it) }
    }

    fun calculateResult() {
        if (!isRolled) {
            throw IllegalStateException("Not rolled yet")
        }
        val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0
        val stats = heroics.map { it.roll + it.pair.modifier }
        val heroResult = when (method) {
            DieBattleCheck.Method.SUM -> stats.sum()
            DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt()
            DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt()
            DieBattleCheck.Method.MAX -> stats.max() ?: 0
            DieBattleCheck.Method.MIN -> stats.min() ?: 0
        }
        result = heroResult - opponentResult
    }

}

Поскольку каждый кубик может иметь модификатор, хранить данные будем в объектах DiePair. Вроде бы. На самом деле, нет, так как помимо кубика и модификатора нужно хранить еще и результат его броска (помните, сам кубик хоть и генерирует это значение, но не хранит его среди своих свойств). Поэтому обернем каждую пару в обертку (Wrap). Обратите внимание на инфиксный метод with, хе-хе.

В конструкторе класса задается метод аггрегации (экземпляр внутреннего перечисления Method) и оппонент (которого может и не быть). Список кубиков героя формируется при помощи соответствующих методов. Также предусмотрена куча методов для получения пар, участвующих в проверке, и результатов их бросков (если они есть).

Метод roll() вызывает одноименной метод каждого кубика, сохраняет промежуточные результаты и отмечает факт своего выполнения флагом isRolled. Обратите внимание, что финальный результат броска не вычисляется сразу — для этого есть специальный метод calculateResult(), результатом выполнения которого является запись конечного значения в свойство result. Зачем это нужно? Для драматического эффекта. Метод roll() будет запускаться несколько раз, каждый раз на гранях кубиков будут отображаться разные значения (прямо как в реальной жизни). И только когда кубики успокоятся на столе, мы узнаем нашу судьбу финальный результат (разность значений кубиков героя и кубика-оппонента). Для снятия напряжения скажу, что результат 0 будет считаться успешным прохождением проверки.

Состояние игрового движка.
Со сложными объектами разобрались, теперь вещи попроще. Не будет большим открытием сказать, что нам необходимо контролировать текущий «прогресс» игрового движка, этап или фазу (phase), в которой он находится. Для этого пригодится специальное перечисление.

enum class GamePhase {
    SCENARIO_START,
    HERO_TURN_START,
    HERO_TURN_END,
    LOCATION_BEFORE_EXPLORATION,
    LOCATION_ENCOUNTER_STAT,
    LOCATION_ENCOUNTER_DIVINE,
    LOCATION_AFTER_EXPLORATION,
    GAME_LOSS
}

На самом деле фаз больше, но мы отобрали только те, которые используются в нашем примере. Для смены фазы игрового движка будем использовать методы changePhaseX(), где X — значение из приведенного выше перечисления. В этих методах все внутренние переменные движка будут приводиться к адекватным для начала соответствующей фазы значениям, но об этом позже.

Сообщения.
Хранить состояние игрового движка недостаточно. Важно еще и пользователю как-то о нем сообщать — иначе как последний узнает, что вообще у него на экране происходит? Именно поэтому нам нужно еще одно перечисление.

enum class StatusMessage {
    EMPTY,
    CHOOSE_DICE_PERFORM_CHECK,
    END_OF_TURN_DISCARD_EXTRA,
    END_OF_TURN_DISCARD_OPTIONAL,
    CHOOSE_ACTION_BEFORE_EXPLORATION,
    CHOOSE_ACTION_AFTER_EXPLORATION,
    ENCOUNTER_PHYSICAL,
    ENCOUNTER_SOMATIC,
    ENCOUNTER_MENTAL,
    ENCOUNTER_VERBAL,
    ENCOUNTER_DIVINE,
    DIE_ACQUIRE_SUCCESS,
    DIE_ACQUIRE_FAILURE,
    GAME_LOSS_OUT_OF_TIME    
}

Как видите, все возможные состояния из нашего примера описываются значениями этого перечисления. Для каждого из них предусмотрена текстовая строка, которая и будет отображаться на экране (кроме EMPTY — это специальное значение), но мы узнаем об этом чуть позже.

Действия.
Для коммуникации между пользователем и игровым движком простых сообщений недостаточно. Важно еще сообщить первому о тех действиях, которые он в данный момент может совершать (исследовать, передать кубики, завершить ход — вот это все добро). Для этого разработаем специальный класс.

class Action(
        val type: Type,
        var isEnabled: Boolean = true,
        val data: Int = 0
) {

    enum class Type {
        NONE, //Blank type
        CONFIRM, //Confirm some action
        CANCEL, //Cancel action

        HAND_POSITION, //Some position in hand
        HAND_ALLY_POSITION, //Some ally position in hand
        EXPLORE_LOCATION, //Explore current location
        FINISH_TURN, //Finish current turn
        ACQUIRE, //Acquire (DIVINE) die
        FORFEIT, //Remove die from game
        HIDE, //Put die into bag
        DISCARD, //Put die to discard pile
    }
}

Внутреннее перечисление Type описывает тип совершаемого действия. Поле isEnabled нужно для того, чтобы отображать действия в неактивном состоянии. То есть, сообщать, что это действие обычно доступно, но в данный момент по какой-то причине не может быть выполнено (такое отображение гораздо более информативно, чем когда действие не отображается вовсе). Свойство data (необходимо для некоторых типов действий) хранит специальное значение, сообщающее какие-то дополнительные детали (например, индекс выбранной пользователем позиции или номер выбранного пункта из списка).

Клас Action является главным «интерфейсом» между игровым движком и системами ввода-вывода (о которых ниже). Поскольку действий зачастую несколько (иначе зачем тогда выбор?), они будут объединяться в группы (списки). Вместо использования стандартных коллекций, напишем свою, расширенную.

class ActionList : Iterable<Action> {

    private val actions = mutableListOf<Action>()
    val size
        get() = actions.size

    fun add(action: Action): ActionList {
        actions.add(action)
        return this
    }

    fun add(type: Action.Type, enabled: Boolean = true): ActionList {
        add(Action(type, enabled))
        return this
    }

    fun addAll(actions: ActionList): ActionList {
        actions.forEach { add(it) }
        return this
    }

    fun remove(type: Action.Type): ActionList {
        actions.removeIf { it.type == type }
        return this
    }

    operator fun get(index: Int) = actions[index]

    operator fun get(type: Action.Type) = actions.find { it.type == type }

    override fun iterator(): Iterator<Action> = ActionListIterator()

    private inner class ActionListIterator : Iterator<Action> {
        private var position = -1
        override fun hasNext() = (actions.size > position + 1)
        override fun next() = actions[++position]
    }

    companion object {
        val EMPTY
            get() = ActionList()
    }

}

Класс содержит много разных методов для добавления и удаления действий из списка (которые можно объединять в цепочки), а также получения как по индексу, так и по типу (обратите внимание на «перегрузку» get() — к нашему списку применим оператор квадратных скобок). Реализация интерфейса Iterator позволяет проделывать с нашим классом all sorts of crazy shit различные потоковые манипуляции (функциональщина, ага). Также предусмотрено значение EMPTY для быстрого создания пустого списка.

Экраны.
Напоследок еще одно перечисление, которое описывает различные типы отображаемого в данный момент контента… Вы смотрите на меня и хлопаете глазами, я знаю. Когда я начал придумывать, как бы попонятнее описать этот класс, я ударился головой об стол, потому ничего толком сообразить не смог. Сами поймете, я надеюсь.

enum class GameScreen {
    HERO_TURN_START,
    LOCATION_INTERIOR,
    GAME_LOSS
}

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

«Отображалка» и «вводилка».
И вот мы наконец подошли к самому важному моменту — взаимодейтствию игрового движка с пользователем (игроком). Если столь длинное вступление вас еще не утомило, то вы наверняка помните, что мы договорились функционально отделить две эти части друг от друга. Поэтому вместо конкретной реализации системы ввода-вывода мы предоставим лишь интерфейс. Точнее, два.

Первый интерфейс, GameRenderer, предназначен для отображения картинки на экране. Напоминаю, мы абстрагируемся от размеров экрана, от конкретных графических библиотек итп. Мы просто отсылаем команду: «отрисуй-ка мне вот это» — и те из вас, кто понял наш невнятный разговор об экранах, уже догадался, что для каждого из таких экранов в рамках интерфейса предусмотрен свой собственный метод.

interface GameRenderer {

    fun drawHeroTurnStart(hero: Hero)

    fun drawLocationInteriorScreen(
            location: Location,
            heroesAtLocation: List<Hero>,
            timer: Int,
            currentHero: Hero,
            battleCheck: DieBattleCheck?,
            encounteredDie: DiePair?,
            pickedDice: HandMask,
            activePositions: HandMask,
            statusMessage: StatusMessage,
            actions: ActionList
    )

    fun drawGameLoss(message: StatusMessage)
}

Думаю, дополнительтных объяснений здесь не нужно — предназначение всех передаваемых объектов подробно рассмотрено выше.

Для пользовательского ввода реализуем другой интерфейс — GameInteractor (да, скрипты проверки орфографии отныне всегда будут подчеркивать это слово, хотя казалось бы...). Его методы будут запрашивать у игрока требуемые команды для различных ситуаций: выбрать действие из списка предложенных, выбрать элемент из списка, выбрать кубики с руки, просто хоть что-то нажать итп. Следует сразу отметить, что ввод происходит синхронно (игра-то у нас пошаговая), то есть выполнение игрового цикла приостанавливается до тех пор, пока пользователь не ответит на запрос.

interface GameInteractor{

    fun anyInput()

    fun pickAction(list: ActionList): Action

    fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action
}

Про последний метод чуть подробнее. Как видно из названия, от предлагает пользователю выбрать кубики из руки, предоставляя объект HandMask — номера активных позиций. Выполнение метода будет продолжаться до тех пор пока какая-то их них не будет выбрана — в этом случае метод вернет действие типа HAND_POSITION (или HAND_ALLY_POSITION, мда) с номером выбранной позиции в поле data. Кроме того, возможно выбрать другое действие (например, CONFIRM или CANCEL) из объекта ActionList. Реализации методов ввода должны различать ситуации когда поле isEnabled выставлено в false и игнорировать ввод пользователем таких действий.

Класс игрового движка.
Все необходимое для работы добро мы рассмотрели, пришло время и движок реализовать. Создадим класс Game со следующим наполнением:

Извините, такое нельзя показывать впечатлительным людям
class Game(
            private val renderer: GameRenderer,
            private val interactor: GameInteractor,
            private val scenario: Scenario,
            private val locations: List<Location>,
            private val heroes: List<Hero>) {

    private var timer = 0
    private var currentHeroIndex = -1
    private lateinit var currentHero: Hero
    private lateinit var currentLocation: Location
    private val deterrentPile = Pile()

    private var encounteredDie: DiePair? = null
    private var battleCheck: DieBattleCheck? = null

    private val activeHandPositions = HandMask()
    private val pickedHandPositions = HandMask()

    private var phase: GamePhase = GamePhase.SCENARIO_START

    private var screen = GameScreen.SCENARIO_INTRO
    private var statusMessage = StatusMessage.EMPTY
    private var actions: ActionList = ActionList.EMPTY

    fun start() {
        if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!")
        if (locations.isEmpty()) throw IllegalStateException("Location list is empty!")

        heroes.forEach { it.isAlive = true }
        timer = scenario.initialTimer

        //Draw initial hand for each hero
        heroes.forEach(::drawInitialHand)

        //First hero turn
        currentHeroIndex = -1
        changePhaseHeroTurnStart()

        processCycle()
    }

    private fun drawInitialHand(hero: Hero) {
        val hand = hero.hand
        val favoredDie = hero.bag.drawOfType(hero.favoredDieType)
        hand.addDie(favoredDie!!)
        refillHeroHand(hero, false)
    }

    private fun refillHeroHand(hero: Hero, redrawScreen: Boolean = true) {
        val hand = hero.hand
        while (hand.dieCount < hand.capacity && hero.bag.size > 0) {
            val die = hero.bag.draw()
            hand.addDie(die)
            if (redrawScreen) {
                Audio.playSound(Sound.DIE_DRAW)
                drawScreen()
                Thread.sleep(500)
            }
        }
    }

    private fun changePhaseHeroTurnEnd() {
        battleCheck = null
        encounteredDie = null
        phase = GamePhase.HERO_TURN_END
        //Discard extra dice (or optional dice)
        val hand = currentHero.hand
        pickedHandPositions.clear()
        activeHandPositions.clear()
        val allowCancel =
                if (hand.dieCount > hand.capacity) {
                    statusMessage = StatusMessage.END_OF_TURN_DISCARD_EXTRA
                    false
                } else {
                    statusMessage = StatusMessage.END_OF_TURN_DISCARD_OPTIONAL
                    true
                }
        val result = pickDiceFromHand(DiscardExtraDiceHandMaskRule(hand), allowCancel)
        statusMessage = StatusMessage.EMPTY
        actions = ActionList.EMPTY
        if (result) {
            val discardDice = collectPickedDice(hand)
            val discardAllyDice = collectPickedAllyDice(hand)
            pickedHandPositions.clear()
            (discardDice + discardAllyDice).forEach { die ->
                Audio.playSound(Sound.DIE_DISCARD)
                currentHero.discardDieFromHand(die)
                drawScreen()
                Thread.sleep(500)
            }
        }
        pickedHandPositions.clear()
        //Replenish hand
        refillHeroHand(currentHero)
        changePhaseHeroTurnStart()
    }

    private fun changePhaseHeroTurnStart() {
        phase = GamePhase.HERO_TURN_START
        screen = GameScreen.HERO_TURN_START
        //Tick timer
        timer--
        if (timer < 0) {
            changePhaseGameLost(StatusMessage.GAME_LOSS_OUT_OF_TIME)
            return
        }
        //Pick next hero
        do {
            currentHeroIndex = ++currentHeroIndex % heroes.size
            currentHero = heroes[currentHeroIndex]
        } while (!currentHero.isAlive)
        currentLocation = locations[0]
        //Setup
        Audio.playMusic(Music.SCENARIO_MUSIC_1)
        Audio.playSound(Sound.TURN_START)
    }

    private fun changePhaseLocationBeforeExploration() {
        phase = GamePhase.LOCATION_BEFORE_EXPLORATION
        screen = GameScreen.LOCATION_INTERIOR
        encounteredDie = null
        battleCheck = null
        pickedHandPositions.clear()
        activeHandPositions.clear()
        statusMessage = StatusMessage.CHOOSE_ACTION_BEFORE_EXPLORATION
        actions = ActionList()
        actions.add(Action.Type.EXPLORE_LOCATION, checkLocationCanBeExplored(currentLocation))
        actions.add(Action.Type.FINISH_TURN)
    }

    private fun changePhaseLocationEncounterStatDie() {
        Audio.playSound(Sound.ENCOUNTER_STAT)
        phase = GamePhase.LOCATION_ENCOUNTER_STAT
        screen = GameScreen.LOCATION_INTERIOR
        battleCheck = null
        pickedHandPositions.clear()
        activeHandPositions.clear()
        statusMessage = when (encounteredDie!!.die.type) {
            Die.Type.PHYSICAL -> StatusMessage.ENCOUNTER_PHYSICAL
            Die.Type.SOMATIC -> StatusMessage.ENCOUNTER_SOMATIC
            Die.Type.MENTAL -> StatusMessage.ENCOUNTER_MENTAL
            Die.Type.VERBAL -> StatusMessage.ENCOUNTER_VERBAL
            else -> throw AssertionError("Should not happen")
        }
        val canAttemptCheck = checkHeroCanAttemptStatCheck(currentHero, encounteredDie!!.die.type)
        actions = ActionList()
        actions.add(Action.Type.HIDE, canAttemptCheck)
        actions.add(Action.Type.DISCARD, canAttemptCheck)
        actions.add(Action.Type.FORFEIT)
    }

    private fun changePhaseLocationEncounterDivineDie() {
        Audio.playSound(Sound.ENCOUNTER_DIVINE)
        phase = GamePhase.LOCATION_ENCOUNTER_DIVINE
        screen = GameScreen.LOCATION_INTERIOR
        battleCheck = null
        pickedHandPositions.clear()
        activeHandPositions.clear()
        statusMessage = StatusMessage.ENCOUNTER_DIVINE
        actions = ActionList()
        actions.add(Action.Type.ACQUIRE, checkHeroCanAcquireDie(currentHero, Die.Type.DIVINE))
        actions.add(Action.Type.FORFEIT)
    }

    private fun changePhaseLocationAfterExploration() {
        phase = GamePhase.LOCATION_AFTER_EXPLORATION
        screen = GameScreen.LOCATION_INTERIOR
        encounteredDie = null
        battleCheck = null
        pickedHandPositions.clear()
        activeHandPositions.clear()
        statusMessage = StatusMessage.CHOOSE_ACTION_AFTER_EXPLORATION
        actions = ActionList()
        actions.add(Action.Type.FINISH_TURN)
    }

    private fun changePhaseGameLost(message: StatusMessage) {
        Audio.stopMusic()
        Audio.playSound(Sound.GAME_LOSS)
        phase = GamePhase.GAME_LOSS
        screen = GameScreen.GAME_LOSS
        statusMessage = message
    }

    private fun pickDiceFromHand(rule: HandMaskRule, allowCancel: Boolean = true, onEachLoop: (() -> Unit)? = null): Boolean {
        //Preparations
        pickedHandPositions.clear()
        actions = ActionList().add(Action.Type.CONFIRM, false)
        if (allowCancel) {
            actions.add(Action.Type.CANCEL)
        }
        val hand = rule.hand
        while (true) {
            //Recurring action
            onEachLoop?.invoke()
            //Define success condition
            val canProceed = rule.checkMask(pickedHandPositions)
            actions[Action.Type.CONFIRM]?.isEnabled = canProceed
            //Prepare active hand commands
            activeHandPositions.clear()
            (0 until hand.dieCount)
                    .filter { rule.isPositionActive(pickedHandPositions, it) }
                    .forEach { activeHandPositions.addPosition(it) }
            (0 until hand.allyDieCount)
                    .filter { rule.isAllyPositionActive(pickedHandPositions, it) }
                    .forEach { activeHandPositions.addAllyPosition(it) }
            //Draw current phase
            drawScreen()
            //Process interaction result
            val result = interactor.pickDiceFromHand(activeHandPositions, actions)
            when (result.type) {
                Action.Type.CONFIRM -> if (canProceed) {
                    activeHandPositions.clear()
                    return true
                }
                Action.Type.CANCEL -> if (allowCancel) {
                    activeHandPositions.clear()
                    pickedHandPositions.clear()
                    return false
                }
                Action.Type.HAND_POSITION -> {
                    Audio.playSound(Sound.DIE_PICK)
                    pickedHandPositions.switchPosition(result.data)
                }
                Action.Type.HAND_ALLY_POSITION -> {
                    Audio.playSound(Sound.DIE_PICK)
                    pickedHandPositions.switchAllyPosition(result.data)
                }
                else -> throw AssertionError("Should not happen")
            }
        }
    }

    private fun collectPickedDice(hand: Hand) =
            (0 until hand.dieCount)
                    .filter(pickedHandPositions::checkPosition)
                    .mapNotNull(hand::dieAt)

    private fun collectPickedAllyDice(hand: Hand) =
            (0 until hand.allyDieCount)
                    .filter(pickedHandPositions::checkAllyPosition)
                    .mapNotNull(hand::allyDieAt)

    private fun performStatDieAcquireCheck(shouldDiscard: Boolean): Boolean {
        //Prepare check
        battleCheck = DieBattleCheck(DieBattleCheck.Method.SUM, encounteredDie)
        pickedHandPositions.clear()
        statusMessage = StatusMessage.CHOOSE_DICE_PERFORM_CHECK
        val hand = currentHero.hand
        //Try to pick dice from performer's hand
        if (!pickDiceFromHand(StatDieAcquireHandMaskRule(currentHero.hand, encounteredDie!!.die.type), true) {
                    battleCheck!!.clearHeroPairs()
                    (collectPickedDice(hand) + collectPickedAllyDice(hand))
                            .map { DiePair(it, if (shouldDiscard) 1 else 0) }
                            .forEach(battleCheck!!::addHeroPair)
                }) {
            battleCheck = null
            pickedHandPositions.clear()
            return false
        }
        //Remove dice from hand
        collectPickedDice(hand).forEach { hand.removeDie(it) }
        collectPickedAllyDice(hand).forEach { hand.removeDie(it) }
        pickedHandPositions.clear()
        //Perform check
        Audio.playSound(Sound.BATTLE_CHECK_ROLL)
        for (i in 0..7) {
            battleCheck!!.roll()
            drawScreen()
            Thread.sleep(100)
        }
        battleCheck!!.calculateResult()
        val result = battleCheck?.result ?: -1
        val success = result >= 0
        //Process dice which participated in the check
        (0 until battleCheck!!.heroPairCount)
                .map(battleCheck!!::getHeroPairAt)
                .map(DiePair::die)
                .forEach { d ->
                    if (d.type === Die.Type.DIVINE) {
                        currentHero.hand.removeDie(d)
                        deterrentPile.put(d)
                    } else {
                        if (shouldDiscard) {
                            currentHero.discardDieFromHand(d)
                        } else {
                            currentHero.hideDieFromHand(d)
                        }
                    }
                }
        //Show message to user
        Audio.playSound(if (success) Sound.BATTLE_CHECK_SUCCESS else Sound.BATTLE_CHECK_FAILURE)
        statusMessage = if (success) StatusMessage.DIE_ACQUIRE_SUCCESS else StatusMessage.DIE_ACQUIRE_FAILURE
        actions = ActionList.EMPTY
        drawScreen()
        interactor.anyInput()
        //Clean up
        battleCheck = null
        //Resolve consequences of the check
        if (success) {
            Audio.playSound(Sound.DIE_DRAW)
            currentHero.hand.addDie(encounteredDie!!.die)
        }
        return true
    }

    private fun processCycle() {
        while (true) {
            drawScreen()
            when (phase) {
                GamePhase.HERO_TURN_START -> {
                    interactor.anyInput()
                    changePhaseLocationBeforeExploration()
                }
                GamePhase.GAME_LOSS -> {
                    interactor.anyInput()
                    return
                }
                GamePhase.LOCATION_BEFORE_EXPLORATION ->
                    when (interactor.pickAction(actions).type) {
                        Action.Type.EXPLORE_LOCATION -> {
                            val die = currentLocation.bag.draw()
                            encounteredDie = DiePair(die, 0)
                            when (die.type) {
                                Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL -> changePhaseLocationEncounterStatDie()
                                Die.Type.DIVINE -> changePhaseLocationEncounterDivineDie()
                                else -> TODO("Others")
                            }
                        }
                        Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd()
                        else -> throw AssertionError("Should not happen")
                    }
                GamePhase.LOCATION_ENCOUNTER_STAT -> {
                    val type = interactor.pickAction(actions).type
                    when (type) {
                        Action.Type.DISCARD, Action.Type.HIDE -> {
                            performStatDieAcquireCheck(type === Action.Type.DISCARD)
                            changePhaseLocationAfterExploration()
                        }
                        Action.Type.FORFEIT -> {
                            Audio.playSound(Sound.DIE_REMOVE)
                            changePhaseLocationAfterExploration()
                        }
                        else -> throw AssertionError("Should not happen")
                    }
                }
                GamePhase.LOCATION_ENCOUNTER_DIVINE ->
                    when (interactor.pickAction(actions).type) {
                        Action.Type.ACQUIRE -> {
                            Audio.playSound(Sound.DIE_DRAW)
                            currentHero.hand.addDie(encounteredDie!!.die)
                            changePhaseLocationAfterExploration()
                        }
                        Action.Type.FORFEIT -> {
                            Audio.playSound(Sound.DIE_REMOVE)
                            changePhaseLocationAfterExploration()
                        }
                        else -> throw AssertionError("Should not happen")
                    }
                GamePhase.LOCATION_AFTER_EXPLORATION ->
                    when (interactor.pickAction(actions).type) {
                        Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd()
                        else -> throw AssertionError("Should not happen")
                    }
                else -> throw AssertionError("Should not happen")
            }
        }
    }

    private fun drawScreen() {
        when (screen) {
            GameScreen.HERO_TURN_START -> renderer.drawHeroTurnStart(currentHero)
            GameScreen.LOCATION_INTERIOR -> renderer.drawLocationInteriorScreen(currentLocation, heroes, timer, currentHero, battleCheck, encounteredDie, null, pickedHandPositions, activeHandPositions, statusMessage, actions)
            GameScreen.GAME_LOSS -> renderer.drawGameLoss(statusMessage)
        }

    }

    private fun checkLocationCanBeExplored(location: Location) = location.isOpen && location.bag.size > 0

    private fun checkHeroCanAttemptStatCheck(hero: Hero, type: Die.Type): Boolean {
        return hero.isAlive && SingleDieHandFilter(type).test(hero.hand)
    }

    private fun checkHeroCanAcquireDie(hero: Hero, type: Die.Type): Boolean {
        if (!hero.isAlive) {
            return false
        }
        return when (type) {
            Die.Type.ALLY -> hero.hand.allyDieCount < MAX_HAND_ALLY_SIZE
            else -> hero.hand.dieCount < MAX_HAND_SIZE
        }
    }

}

Метод start() — точка входа в игру. Здесь инициализируются переменные, взвешиваются герои, руки наполняются кубиками, а репортеры светят камерами со всех сторон. Главный цикл будет запущен с минуты на минуту, после чего его уже не остановить. Метод drawInitialHand() говорит сам за себя (мы, кажется, не рассмотрели код метода drawOfType() класса Bag, но пройдя столь длинный путь вместе, этот код вы и сами напишете без труда). Метод refillHeroHand() имеет два варианта (в зависимости от значения аргумента redrawScreen): быстрый и тихий (когда нужно наполнить руки всех героев в начале игры), и громкий с кучей пафоса, когда в конце хода нужно демонстративно доставать кубики из сумки, доводя руку до нужного размера.

Куча методов с названиями, начинающимися с changePhase, — как мы уже сказали, они служат для смены и текущей игровой фазы и занимаются присвоением соответствующих значений игровых переменных. Здесь же формируется список actions, куда добавляются характерные для данной фазы действия.

Служебный метод pickDiceFromHand() в обобщенном виде занимается выбором кубиков из руки. Сюда передается объект знакомого класса HandMaskRule, задающего правила выбора. Тут же указывается возможность отказаться от выбора (allowCancel), а также функция onEachLoop, код которой необходимо вызывать при каждом изменении списка выбранных кубиков (обычно это перерисовка экрана). Выбранные этим методом кубики можно собрать из руки при помощи методов collectPickedDice() и collectPickedAllyDice().

Еще один служебный метод performStatDieAcquireCheck() полностью реализует прохождение героем проверки на приобретение нового кубика. Центральную роль в этом методе играет объект DieBattleCheck. Процесс начинается с выбора кубиков методом pickDiceFromHand() (на каждом шаге происходит обновление списка «участников» DieBattleCheck). Выбранные кубики удаляются из руки, после чего присходит «бросок» — каждый кубик обновляет свое значение (восемь раз подряд), после чего подсчитывается и отображается результат. При успешном броске новый кубик попадает в руку героя. Участвовашие в проверке кубики либо удерживаются (если они голубые), либо сбрасываются (если shouldDiscard = true), либо прячутся обратно в сумку (если shouldDiscard = false).

Основной метод processCycle() содержит бесконечный цикл (попрошу без обмороков), в котором сначала отрисовывается экран, затем у пользователя запрашивается ввод, затем происходит обработка этого ввода — со всеми вытекающими последствиями. Метод drawScreen() вызывает нужный метод интерфейса GameRenderer (в зависимости от текущего значения screen), передавая ему требуемые объекты на вход.

Также класс содержит несколько вспомогательных методов: checkLocationCanBeExplored(), checkHeroCanAttemptStatCheck() и checkHeroCanAcquireDie(). Их названия говорят сами за себя, потому не будем подробно на них останавливаться. А еще есть вызовы методов класса Audio, подчеркнутые красной волнистой линией. Закомментируйте их до поры до времени — их предназначение мы рассмотрим позже.

Кому вообще ничего не понятно, вот диаграммка (для наглядности, так сказать):


Вот и все, игра готова (хе-хе). Остались сущие мелочи, о них ниже.

Шаг девятый. Вывод изображения на экран


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

Экран 1. Идентификатор хода игрока


Экран 2. Информация о местности и текущем герое


Экран 3. Сообщение о проигрыше сценария


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

К нам на помощь спешат Чип и Дейл коды ANSI. Отправляя на вывод причудливые последовательности символов, можно добиться не менее причудливых эффектов: менять цвет текста/фона, способ начертания символов, положение курсора на экране и многое другое. Разумеется, в чистом виде мы их вводить не будем — спрячем реализацию за методами класса. Да и сам класс мы с нуля писать не будем — к счастью, умные люди сделали это за нас. Нам же остается скачать и подключить к проекту какую-то легковесную библиотеку, например, Jansi:

<dependency>
    <groupId>org.fusesource.jansi</groupId>
    <artifactId>jansi</artifactId>
    <version>1.17.1</version>
    <scope>compile</scope>
</dependency>

И можно начинать творить. Данная библиотека предоставляет нам объект класса Ansi (получается в результате статического вызова Ansi.ansi()) с кучей удобных методов, которые можно объединять в цепочки. Работает по принципу StringBuilder'а — сначала формируем объект, затем отправляем его на печать. Из полезных методов нам пригодятся:

  • a() — для вывода символов;
  • cursor() — для перемещения курсора по экрану;
  • eraseLine() — как-бы говорит сам за себя;
  • eraseScreen() — аналогично;
  • fg(), bg(), fgBright(), bgBright() — очень неудобные методы для работы с цветами текста и фона — мы сделаем свои, более приятные;
  • reset() — для сброса установленных настроек цветов, мерцания итп.

Создадим класс ConsoleRenderer со служебными методами, которые могут пригодиться нам в работе. Первая версия будет иметь приблизительно такой вид:

abstract class ConsoleRenderer() {

    protected lateinit var ansi: Ansi

    init {
        AnsiConsole.systemInstall()
        clearScreen()
        resetAnsi()
    }

    private fun resetAnsi() {
        ansi = Ansi.ansi()
    }

    fun clearScreen() {
        print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1))
    }

    protected fun render() {
        print(ansi.toString())
        resetAnsi()
    }
}

Метод resetAnsi() создает новый (пустой) объект Ansi, который будет наполняться нужными командами (перемещения, вывода итп). По завершении наполнения, сформированный объект отправляется на печать методом render(), а переменная инициализируется новым объектом. Пока что ничего сложного, верно? А раз так, то начнем наполнять этот класс другими полезными методами.

Начнем с размеров. Стандартная консоль большинства терминалов имеет размер 80х24. Отметим этот факт двумя константами CONSOLE_WIDTH и CONSOLE_HEIGHT. Мы не будем привязываться к конкретным значениям и постараемся сделать дизайн максимально резиновым (как в вебе). Нумерация координат начинается с единицы, первая координата — строка, вторая — столбец. Зная все это, напишем служебный метод drawHorizontalLine() для заполнения указанной строки указанным символом.

protected fun drawHorizontalLine(offsetY: Int, filler: Char) {
    ansi.cursor(offsetY, 1)
    (1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
    //for (i in 1..CONSOLE_WIDTH) { ansi.a(filler) }
}

Еще раз напоминаю, что вызов команд a() или cursor() не приводит ни к какому мгновенному эффекту, а лишь добавляет в объект Ansi соответствующие последовательности команд. Только когда эти последовательности будут отправлены на печать, мы увидим их на экране.

Между использованием классического цикла for и функционального подхода с ClosedRange и forEach{} нет никакой принципиальной разницы — каждый разработчик сам решает, что ему удобнее. Однако я и дальше буду дурить вам головы функциональщиной, просто потому что я обезьяна, которая любит все новое и блестящее скобки не переносятся на новую строку и код выглядит компактнее.

Реализуем еще один служебный метод drawBlankLine(), делающий то же самое, что и drawHorizontalLine(offsetY, ' '), только с расширением. Иногда нам понадобится сделать строку пустой не полностью, а оставить в начале и конце вертикальную черту (рамочку, ага). Код будет выглядеть как-то так:

protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) {
    ansi.cursor(offsetY, 1)
    if (drawBorders) {
        ansi.a('│')
        (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
        ansi.a('│')
    } else {
        ansi.eraseLine(Ansi.Erase.ALL)
    }
}

Как, вы никогда не рисовали рамочки из псевдографики? Символы можно вставлять прямо в исходный код. Зажимаете клавишу Alt и набираете код символа на цифровой клавиатуре. Затем отпускаете. Нужные нам ASCII-коды в любой кодировке одинаковые, вот минимальный джентельменский набор:


А дальше как в майнкрафте — возможности ограничены лишь пределами вашего воображения. И размером экрана.

protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) {
    val center = (CONSOLE_WIDTH - text.length) / 2
    ansi.cursor(offsetY, 1)
    ansi.a(if (drawBorders) '│' else ' ')
    (2 until center).forEach { ansi.a(' ') }
    ansi.color(color).a(text).reset()
    (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') }
    ansi.a(if (drawBorders) '│' else ' ')
}

Поговорим немножко о цветах. Класс Ansi содержит константы Color для восьми основных цветов (черный, синий, зеленый, голубой, красный, фиолетовый, желтый, серый), которые нужно передавать на вход методов fg()/bg() для темного варианта или fgBright()/bgBright() — для светлого, что делать жутко неудобно, так как для идентификации цвета таким способом нам недостаточно одного значения — нужно как-минимум два (цвет и яркость). Поэтому мы создадим свой список констант и свои методы-расширения (а еще карты-привязки цветов к типам кубиков и классам героев):

protected enum class Color {
    BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY,
    DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE
}

 protected fun Ansi.color(color: Color?): Ansi = when (color) {
    Color.BLACK -> fgBlack()
    Color.DARK_BLUE -> fgBlue()
    Color.DARK_GREEN -> fgGreen()
    Color.DARK_CYAN -> fgCyan()
    Color.DARK_RED -> fgRed()
    Color.DARK_MAGENTA -> fgMagenta()
    Color.DARK_YELLOW -> fgYellow()
    Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE)
    Color.DARK_GRAY -> fgBrightBlack()
    Color.LIGHT_BLUE -> fgBrightBlue()
    Color.LIGHT_GREEN -> fgBrightGreen()
    Color.LIGHT_CYAN -> fgBrightCyan()
    Color.LIGHT_RED -> fgBrightRed()
    Color.LIGHT_MAGENTA -> fgBrightMagenta()
    Color.LIGHT_YELLOW -> fgBrightYellow()
    Color.WHITE -> fgBright(Ansi.Color.WHITE)
    else -> this
}

protected fun Ansi.background(color: Color?): Ansi = when (color) {
    Color.BLACK -> ansi.bg(Ansi.Color.BLACK)
    Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE)
    Color.DARK_GREEN -> ansi.bgGreen()
    Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN)
    Color.DARK_RED -> ansi.bgRed()
    Color.DARK_MAGENTA -> ansi.bgMagenta()
    Color.DARK_YELLOW -> ansi.bgYellow()
    Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE)
    Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK)
    Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE)
    Color.LIGHT_GREEN -> ansi.bgBrightGreen()
    Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN)
    Color.LIGHT_RED -> ansi.bgBrightRed()
    Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA)
    Color.LIGHT_YELLOW -> ansi.bgBrightYellow()
    Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE)
    else -> this
}

protected val dieColors = mapOf(
        Die.Type.PHYSICAL to Color.LIGHT_BLUE,
        Die.Type.SOMATIC to Color.LIGHT_GREEN,
        Die.Type.MENTAL to Color.LIGHT_MAGENTA,
        Die.Type.VERBAL to Color.LIGHT_YELLOW,
        Die.Type.DIVINE to Color.LIGHT_CYAN,
        Die.Type.WOUND to Color.DARK_GRAY,
        Die.Type.ENEMY to Color.DARK_RED,
        Die.Type.VILLAIN to Color.LIGHT_RED,
        Die.Type.OBSTACLE to Color.DARK_YELLOW,
        Die.Type.ALLY to Color.WHITE
)

protected val heroColors = mapOf(
        Hero.Type.BRAWLER to Color.LIGHT_BLUE,
        Hero.Type.HUNTER to Color.LIGHT_GREEN
)

Теперь каждый из 16-ти доступных цветов однозначно идентифицируется единственной константой. Напишем еще пару служебных методов, но перед этим разберемся вот еще с чем:

Где хранить константы для текстовых строк?

«Строковые константы нужно выносить в отдельные файлы, чтобы они хранились все в одном месте — так их легче поддерживать. А еще это важно для локализации...»

Строковые константы нужно выносить в отдельные файлы… ну да. Вынесем и мы. Стандартным механизмом Java для работы с такого рода ресурсами являются объекты java.util.ResourceBundle, работающие с файлами .properties. Вот с такого файла и начнем:

# Game status messages
choose_dice_perform_check=Choose dice to perform check:
end_of_turn_discard_extra=END OF TURN: Discard extra dice:
end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed:
choose_action_before_exploration=Choose your action:
choose_action_after_exploration=Already explored this turn. Choose what to do now:
encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die.
encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die.
encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die.
encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die.
encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed):
die_acquire_success=You have acquired the die!
die_acquire_failure=You have failed to acquire the die.
game_loss_out_of_time=You ran out of time

# Die types
physical=PHYSICAL
somatic=SOMATIC
mental=MENTAL
verbal=VERBAL
divine=DIVINE
ally=ALLY
wound=WOUND
enemy=ENEMY
villain=VILLAIN
obstacle=OBSTACLE

# Hero types and descriptions
brawler=Brawler
hunter=Hunter

# Various labels
avg=avg
bag=Bag
bag_size=Bag size
class=Class
closed=Closed
discard=Discard
empty=Empty
encountered=Encountered
fail=Fail
hand=Hand
heros_turn=%s's turn
max=max
min=min
perform_check=Perform check:
pile=Pile
received_new_die=Received new die
result=Result
success=Success
sum=sum
time=Time
total=Total

# Action names and descriptions
action_confirm_key=ENTER
action_confirm_name=Confirm
action_cancel_key=ESC
action_cancel_name=Cancel
action_explore_location_key=E
action_explore_location_name=xplore
action_finish_turn_key=F
action_finish_turn_name=inish
action_hide_key=H
action_hide_name=ide
action_discard_key=D
action_discard_name=iscard
action_acquire_key=A
action_acquire_name=cquire
action_leave_key=L
action_leave_name=eave
action_forfeit_key=F
action_forfeit_name=orfeit

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

Абстрагируемся, однако, от конкретного формата (в Андроиде, например, строки хранятся по-другому) и опишем интерфейс для загрузки строковых констант.

interface StringLoader {

    fun loadString(key: String): String
}

На вход передается ключ, на выходе получаем конкретную строку. Реализация так же незамысловата, как и сам интерфейс (предположим, что файл лежит по пути src/main/resources/text/strings.properties).

class PropertiesStringLoader() : StringLoader {
    private val properties = ResourceBundle.getBundle("text.strings")

    override fun loadString(key: String) = properties.getString(key) ?: ""
}

Теперь не составит труда реализовать метод drawStatusMessage() для отображения на экране текущего состояния игрового движка (StatusMessage) и метод drawActionList() для отображения списка доступных действий (ActionList). А также других служебных методов, какие только душа пожелает.

Тут много кода, часть его мы уже видели... так что вот вам спойлер
abstract class ConsoleRenderer(private val strings: StringLoader) {

    protected lateinit var ansi: Ansi

    init {
        AnsiConsole.systemInstall()
        clearScreen()
        resetAnsi()
    }

    protected fun loadString(key: String) = strings.loadString(key)

    private fun resetAnsi() {
        ansi = Ansi.ansi()
    }

    fun clearScreen() {
        print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1))
    }

    protected fun render() {
        ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH)
        System.out.print(ansi.toString())
        resetAnsi()
    }

    protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) {
        var currentX = offsetX
        cursor(offsetY, currentX)
        val text = number.toString()
        text.forEach {
            when (it) {
                '0' -> {
                    cursor(offsetY, currentX)
                    a(" ███  ")
                    cursor(offsetY + 1, currentX)
                    a("█   █ ")
                    cursor(offsetY + 2, currentX)
                    a("█   █ ")
                    cursor(offsetY + 3, currentX)
                    a("█   █ ")
                    cursor(offsetY + 4, currentX)
                    a(" ███  ")
                }
                '1' -> {
                    cursor(offsetY, currentX)
                    a("  █   ")
                    cursor(offsetY + 1, currentX)
                    a(" ██   ")
                    cursor(offsetY + 2, currentX)
                    a("█ █   ")
                    cursor(offsetY + 3, currentX)
                    a("  █   ")
                    cursor(offsetY + 4, currentX)
                    a("█████ ")
                }
                '2' -> {
                    cursor(offsetY, currentX)
                    a(" ███  ")
                    cursor(offsetY + 1, currentX)
                    a("█   █ ")
                    cursor(offsetY + 2, currentX)
                    a("   █  ")
                    cursor(offsetY + 3, currentX)
                    a("  █   ")
                    cursor(offsetY + 4, currentX)
                    a("█████ ")
                }
                '3' -> {
                    cursor(offsetY, currentX)
                    a("████  ")
                    cursor(offsetY + 1, currentX)
                    a("    █ ")
                    cursor(offsetY + 2, currentX)
                    a("  ██  ")
                    cursor(offsetY + 3, currentX)
                    a("    █ ")
                    cursor(offsetY + 4, currentX)
                    a("████  ")
                }
                '4' -> {
                    cursor(offsetY, currentX)
                    a("   █  ")
                    cursor(offsetY + 1, currentX)
                    a("  ██  ")
                    cursor(offsetY + 2, currentX)
                    a(" █ █  ")
                    cursor(offsetY + 3, currentX)
                    a("█████ ")
                    cursor(offsetY + 4, currentX)
                    a("   █  ")
                }
                '5' -> {
                    cursor(offsetY, currentX)
                    a("█████ ")
                    cursor(offsetY + 1, currentX)
                    a("█     ")
                    cursor(offsetY + 2, currentX)
                    a("████  ")
                    cursor(offsetY + 3, currentX)
                    a("    █ ")
                    cursor(offsetY + 4, currentX)
                    a("████  ")
                }
                '6' -> {
                    cursor(offsetY, currentX)
                    a(" ███  ")
                    cursor(offsetY + 1, currentX)
                    a("█     ")
                    cursor(offsetY + 2, currentX)
                    a("████  ")
                    cursor(offsetY + 3, currentX)
                    a("█   █ ")
                    cursor(offsetY + 4, currentX)
                    a(" ███  ")
                }
                '7' -> {
                    cursor(offsetY, currentX)
                    a("█████ ")
                    cursor(offsetY + 1, currentX)
                    a("   █  ")
                    cursor(offsetY + 2, currentX)
                    a("  █   ")
                    cursor(offsetY + 3, currentX)
                    a("  █   ")
                    cursor(offsetY + 4, currentX)
                    a("  █   ")
                }
                '8' -> {
                    cursor(offsetY, currentX)
                    a(" ███  ")
                    cursor(offsetY + 1, currentX)
                    a("█   █ ")
                    cursor(offsetY + 2, currentX)
                    a(" ███  ")
                    cursor(offsetY + 3, currentX)
                    a("█   █ ")
                    cursor(offsetY + 4, currentX)
                    a(" ███  ")
                }
                '9' -> {
                    cursor(offsetY, currentX)
                    a(" ███  ")
                    cursor(offsetY + 1, currentX)
                    a("█   █ ")
                    cursor(offsetY + 2, currentX)
                    a(" ████ ")
                    cursor(offsetY + 3, currentX)
                    a("    █ ")
                    cursor(offsetY + 4, currentX)
                    a(" ███  ")
                }
            }
            currentX += 6
        }
    }

    protected fun drawHorizontalLine(offsetY: Int, filler: Char) {
        ansi.cursor(offsetY, 1)
        (1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
    }

    protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) {
        ansi.cursor(offsetY, 1)
        if (drawBorders) {
            ansi.a('│')
            (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
        } else {
            ansi.eraseLine(Ansi.Erase.ALL)
        }
    }

    protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) {
        val center = (CONSOLE_WIDTH - text.length) / 2
        ansi.cursor(offsetY, 1)
        ansi.a(if (drawBorders) '│' else ' ')
        (2 until center).forEach { ansi.a(' ') }
        ansi.color(color).a(text).reset()
        (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') }
        ansi.a(if (drawBorders) '│' else ' ')
    }

    protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) {
        //Setup
        val messageText = loadString(message.toString().toLowerCase())
        var currentX = 1
        val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0

        //Left border
        ansi.cursor(offsetY, 1)
        if (drawBorders) {
            ansi.a('│')
            currentX++
        }
        ansi.a(' ')
        currentX++

        //Text
        ansi.a(messageText)
        currentX += messageText.length

        //Right border
        (currentX..rightBorder).forEach { ansi.a(' ') }
        if (drawBorders) {
            ansi.a('│')
        }
    }

    protected fun drawActionList(offsetY: Int, actions: ActionList, drawBorders: Boolean = true) {
        val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0
        var currentX = 1

        //Left border
        ansi.cursor(offsetY, 1)
        if (drawBorders) {
            ansi.a('│')
            currentX++
        }
        ansi.a(' ')
        currentX++

        //List of actions
        actions.forEach { action ->
            val key = loadString("action_${action.toString().toLowerCase()}_key")
            val name = loadString("action_${action.toString().toLowerCase()}_name")
            val length = key.length + 2 + name.length
            if (currentX + length >= rightBorder) {
                (currentX..rightBorder).forEach { ansi.a(' ') }
                if (drawBorders) {
                    ansi.a('│')
                }
                ansi.cursor(offsetY + 1, 1)
                currentX = 1
                if (drawBorders) {
                    ansi.a('│')
                    currentX++
                }
                ansi.a(' ')
                currentX++
            }
            if (action.isEnabled) {
                ansi.color(Color.LIGHT_YELLOW)
            }
            ansi.a('(').a(key).a(')').reset()
            ansi.a(name)
            ansi.a("  ")
            currentX += length + 2
        }

        //Right border
        (currentX..rightBorder).forEach { ansi.a(' ') }
        if (drawBorders) {
            ansi.a('│')
        }
    }

    protected enum class Color {
        BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY,
        DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE
    }

    protected fun Ansi.color(color: Color?): Ansi = when (color) {
        Color.BLACK -> fgBlack()
        Color.DARK_BLUE -> fgBlue()
        Color.DARK_GREEN -> fgGreen()
        Color.DARK_CYAN -> fgCyan()
        Color.DARK_RED -> fgRed()
        Color.DARK_MAGENTA -> fgMagenta()
        Color.DARK_YELLOW -> fgYellow()
        Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE)
        Color.DARK_GRAY -> fgBrightBlack()
        Color.LIGHT_BLUE -> fgBrightBlue()
        Color.LIGHT_GREEN -> fgBrightGreen()
        Color.LIGHT_CYAN -> fgBrightCyan()
        Color.LIGHT_RED -> fgBrightRed()
        Color.LIGHT_MAGENTA -> fgBrightMagenta()
        Color.LIGHT_YELLOW -> fgBrightYellow()
        Color.WHITE -> fgBright(Ansi.Color.WHITE)
        else -> this
    }

    protected fun Ansi.background(color: Color?): Ansi = when (color) {
        Color.BLACK -> ansi.bg(Ansi.Color.BLACK)
        Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE)
        Color.DARK_GREEN -> ansi.bgGreen()
        Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN)
        Color.DARK_RED -> ansi.bgRed()
        Color.DARK_MAGENTA -> ansi.bgMagenta()
        Color.DARK_YELLOW -> ansi.bgYellow()
        Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE)
        Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK)
        Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE)
        Color.LIGHT_GREEN -> ansi.bgBrightGreen()
        Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN)
        Color.LIGHT_RED -> ansi.bgBrightRed()
        Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA)
        Color.LIGHT_YELLOW -> ansi.bgBrightYellow()
        Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE)
        else -> this
    }

    protected val dieColors = mapOf(
            Die.Type.PHYSICAL to Color.LIGHT_BLUE,
            Die.Type.SOMATIC to Color.LIGHT_GREEN,
            Die.Type.MENTAL to Color.LIGHT_MAGENTA,
            Die.Type.VERBAL to Color.LIGHT_YELLOW,
            Die.Type.DIVINE to Color.LIGHT_CYAN,
            Die.Type.WOUND to Color.DARK_GRAY,
            Die.Type.ENEMY to Color.DARK_RED,
            Die.Type.VILLAIN to Color.LIGHT_RED,
            Die.Type.OBSTACLE to Color.DARK_YELLOW,
            Die.Type.ALLY to Color.WHITE
    )

    protected val heroColors = mapOf(
            Hero.Type.BRAWLER to Color.LIGHT_BLUE,
            Hero.Type.HUNTER to Color.LIGHT_GREEN
    )

    protected open fun shortcut(index: Int) = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index]
}

Для чего мы все это делали, спросите вы? Да для того, чтобы унаследовать от этого замечательного класса нашу реализацию интерфейса GameRenderer.

Диаграмма классов


Вот так будет выглядеть реализация первого, самого простого метода:

override fun drawGameLoss(message: StatusMessage) {
    val centerY = CONSOLE_HEIGHT / 2
    (1 until centerY).forEach { drawBlankLine(it, false) }
    val data = loadString(message.toString().toLowerCase()).toUpperCase()
    drawCenteredCaption(centerY, data, LIGHT_RED, false)
    (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
    render()
}

Ничего сверхъестественного, просто одна текстовая строка (data), нарисованная красным цветом в центре экрана (drawCenteredCaption()). Остальной код заполняет пустыми строками оставшуюся часть экрана. Возможно, кто-то спросит, зачем это нужно — есть ведь метод clearScreen(), достаточно вызвать его в начале метода, очистить экран, а потом отрисовать нужный текст. Увы, это ленивый подход, использовать который мы не станем. Причина очень проста: при таком подходе некоторые позиции на экране отрисовываются по два раза, что приводит к заметному мерцанию, особенно когда экран последовательно отрисовывается несколько раз подряд (во время анимаций). Поэтому нашей задачей является не просто отрисовать нужные символы в нужных местах, но и заполнить весь остальной экран пустыми символами (чтобы на нем не оставались артефакты от прочей отрисовки). А эта задача уже не так проста.

Следующий метод следует этому принципу:

override fun drawHeroTurnStart(hero: Hero) {
    val centerY = (CONSOLE_HEIGHT - 5) / 2
    (1 until centerY).forEach { drawBlankLine(it, false) }
    ansi.color(heroColors[hero.type])
    drawHorizontalLine(centerY, '─')
    drawHorizontalLine(centerY + 4, '─')
    ansi.reset()
    ansi.cursor(centerY + 1, 1).eraseLine()
    ansi.cursor(centerY + 3, 1).eraseLine()
    ansi.cursor(centerY + 2, 1)
    val text = String.format(loadString("heros_turn"), hero.name.toUpperCase())
    val index = text.indexOf(hero.name.toUpperCase())
    val center = (CONSOLE_WIDTH - text.length) / 2
    ansi.cursor(centerY + 2, center)
    ansi.eraseLine(Ansi.Erase.BACKWARD)
    ansi.a(text.substring(0, index))
    ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset()
    ansi.a(text.substring(index + hero.name.length))
    ansi.eraseLine(Ansi.Erase.FORWARD)
    (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
    render()
}

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

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

1. Встреча с вынутым из сумки кубиком


2. Выбор кубиков для прохождения проверки


3. Отображение результатов проверки


Поэтому вот мой вам большой совет: не пихайте весь код в один метод. Разбейте реализацию на несколько методов (даже если каждый из них будет вызываться только один раз). Ну и о «резине» не забывайте.

Если в глазах начнет рябить, поморгайте пару секунд - должно помочь
class ConsoleGameRenderer(loader: StringLoader)
    : ConsoleRenderer(loader), GameRenderer {

    private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) {
        val closedString = loadString("closed").toLowerCase()
        val timeString = loadString("time")
        val locationName = location.name.toString().toUpperCase()
        val separatorX1 = locationName.length + if (location.isOpen) {
            6 + if (location.bag.size >= 10) 2 else 1
        } else {
            closedString.length + 7
        }
        val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0
        //Top border
        ansi.cursor(1, 1)
        ansi.a('┌')
        (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┬' else '─') }
        ansi.a('┐')
        //Center row
        ansi.cursor(2, 1)
        ansi.a("│ ")
        if (location.isOpen) {
            ansi.color(WHITE).a(locationName).reset()
            ansi.a(": ").a(location.bag.size)
        } else {
            ansi.a(locationName).reset()
            ansi.color(DARK_GRAY).a(" (").a(closedString).a(')').reset()
        }
        ansi.a(" │")
        var currentX = separatorX1 + 2
        heroesAtLocation.forEach { hero ->
            ansi.a(' ')
            ansi.color(heroColors[hero.type])
            ansi.a(if (hero === currentHero) '☻' else '').reset()
            currentX += 2
        }
        (currentX..separatorX2).forEach { ansi.a(' ') }
        ansi.a("│ ").a(timeString).a(": ")
        when {
            timer <= 5 -> ansi.color(LIGHT_RED)
            timer <= 15 -> ansi.color(LIGHT_YELLOW)
            else -> ansi.color(LIGHT_GREEN)
        }
        ansi.bold().a(timer).reset().a(" │")
        //Bottom border
        ansi.cursor(3, 1)
        ansi.a('├')
        (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┴' else '─') }
        ansi.a('┤')
    }

    private fun drawLocationHeroPanel(offsetY: Int, hero: Hero) {
        val bagString = loadString("bag").toUpperCase()
        val discardString = loadString("discard").toUpperCase()
        val separatorX1 = hero.name.length + 4
        val separatorX3 = CONSOLE_WIDTH - discardString.length - 6 - if (hero.discardPile.size >= 10) 1 else 0
        val separatorX2 = separatorX3 - bagString.length - 6 - if (hero.bag.size >= 10) 1 else 0
        //Top border
        ansi.cursor(offsetY, 1)
        ansi.a('├')
        (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┬' else '─') }
        ansi.a('┤')
        //Center row
        ansi.cursor(offsetY + 1, 1)
        ansi.a("│ ")
        ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset()
        ansi.a(" │")
        val currentX = separatorX1 + 1
        (currentX until separatorX2).forEach { ansi.a(' ') }
        ansi.a("│ ").a(bagString).a(": ")
        when {
            hero.bag.size <= hero.hand.capacity -> ansi.color(LIGHT_RED)
            else -> ansi.color(LIGHT_YELLOW)
        }
        ansi.a(hero.bag.size).reset()
        ansi.a(" │ ").a(discardString).a(": ")
        ansi.a(hero.discardPile.size)
        ansi.a(" │")
        //Bottom border
        ansi.cursor(offsetY + 2, 1)
        ansi.a('├')
        (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┴' else '─') }
        ansi.a('┤')
    }

    private fun drawDieSize(die: Die, checked: Boolean = false) {
        when {
            checked -> ansi.background(dieColors[die.type]).color(BLACK)
            else -> ansi.color(dieColors[die.type])
        }
        ansi.a(die.toString()).reset()
    }

    private fun drawDieFrameSmall(offsetX: Int, offsetY: Int, longDieSize: Boolean) {
        //Top border
        ansi.cursor(offsetY, offsetX)
        ansi.a('╔')
        (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') }
        ansi.a('╗')
        //Left border
        ansi.cursor(offsetY + 1, offsetX)
        ansi.a("║ ")
        //Bottom border
        ansi.cursor(offsetY + 2, offsetX)
        ansi.a("╚")
        (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') }
        ansi.a('╝')
        //Right border
        ansi.cursor(offsetY + 1, offsetX + if (longDieSize) 6 else 5)
        ansi.a('║')
    }

    private fun drawDieSmall(offsetX: Int, offsetY: Int, pair: DiePair, rollResult: Int? = null) {
        ansi.color(dieColors[pair.die.type])
        val longDieSize = pair.die.size >= 10
        drawDieFrameSmall(offsetX, offsetY, longDieSize)
        //Roll result or die size
        ansi.cursor(offsetY + 1, offsetX + 1)
        if (rollResult != null) {
            ansi.a(String.format(" %2d %s", rollResult, if (longDieSize) " " else ""))
        } else {
            ansi.a(' ').a(pair.die.toString()).a(' ')
        }
        //Draw modifier
        ansi.cursor(offsetY + 3, offsetX)
        val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier)
        val frameLength = 4 + if (longDieSize) 3 else 2
        var spaces = (frameLength - modString.length) / 2
        (0 until spaces).forEach { ansi.a(' ') }
        ansi.a(modString)
        spaces = frameLength - spaces - modString.length
        (0 until spaces).forEach { ansi.a(' ') }
        ansi.reset()
    }

    private fun drawDieFrameBig(offsetX: Int, offsetY: Int, longDieSize: Boolean) {
        //Top border
        ansi.cursor(offsetY, offsetX)
        ansi.a('╔')
        (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") }
        ansi.a("═╗")
        //Left border
        (1..5).forEach {
            ansi.cursor(offsetY + it, offsetX)
            ansi.a('║')
        }
        //Bottom border
        ansi.cursor(offsetY + 6, offsetX)
        ansi.a('╚')
        (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") }
        ansi.a("═╝")
        //Right border
        val currentX = offsetX + if (longDieSize) 20 else 14
        (1..5).forEach {
            ansi.cursor(offsetY + it, currentX)
            ansi.a('║')
        }
    }

    private fun drawDieSizeBig(offsetX: Int, offsetY: Int, pair: DiePair) {
        ansi.color(dieColors[pair.die.type])
        val longDieSize = pair.die.size >= 10
        drawDieFrameBig(offsetX, offsetY, longDieSize)
        //Die size
        ansi.cursor(offsetY + 1, offsetX + 1)
        ansi.a(" ████  ")
        ansi.cursor(offsetY + 2, offsetX + 1)
        ansi.a(" █   █ ")
        ansi.cursor(offsetY + 3, offsetX + 1)
        ansi.a(" █   █ ")
        ansi.cursor(offsetY + 4, offsetX + 1)
        ansi.a(" █   █ ")
        ansi.cursor(offsetY + 5, offsetX + 1)
        ansi.a(" ████  ")
        drawBigNumber(offsetX + 8, offsetY + 1, pair.die.size)
        //Draw modifier
        ansi.cursor(offsetY + 7, offsetX)
        val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier)
        val frameLength = 4 + 6 * if (longDieSize) 3 else 2
        var spaces = (frameLength - modString.length) / 2
        (0 until spaces).forEach { ansi.a(' ') }
        ansi.a(modString)
        spaces = frameLength - spaces - modString.length - 1
        (0 until spaces).forEach { ansi.a(' ') }
        ansi.reset()
    }

    private fun drawBattleCheck(offsetY: Int, battleCheck: DieBattleCheck) {
        val performCheck = loadString("perform_check")
        var currentX = 4
        var currentY = offsetY
        //Top message
        ansi.cursor(offsetY, 1)
        ansi.a("│  ").a(performCheck)
        (performCheck.length + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
        ansi.a('│')
        //Left border
        (1..4).forEach {
            ansi.cursor(offsetY + it, 1)
            ansi.a("│  ")
        }
        //Opponent
        var opponentWidth = 0
        var vsWidth = 0
        (battleCheck.getOpponentPair())?.let {
            //Die
            if (battleCheck.isRolled) {
                drawDieSmall(4, offsetY + 1, it, battleCheck.getOpponentResult())
            } else {
                drawDieSmall(4, offsetY + 1, it)
            }
            opponentWidth = 4 + if (it.die.size >= 10) 3 else 2
            currentX += opponentWidth
            //VS
            ansi.cursor(currentY + 1, currentX)
            ansi.a("    ")
            ansi.cursor(currentY + 2, currentX)
            ansi.color(LIGHT_YELLOW).a(" VS ").reset()
            ansi.cursor(currentY + 3, currentX)
            ansi.a("    ")
            ansi.cursor(currentY + 4, currentX)
            ansi.a("    ")
            vsWidth = 4
            currentX += vsWidth
        }
        //Clear below
        for (row in currentY + 5..currentY + 8) {
            ansi.cursor(row, 1)
            ansi.a('│')
            (2 until currentX).forEach { ansi.a(' ') }
        }
        //Dice
        for (index in 0 until battleCheck.heroPairCount) {
            if (index > 0) {
                ansi.cursor(currentY + 1, currentX)
                ansi.a("   ")
                ansi.cursor(currentY + 2, currentX)
                ansi.a(if (battleCheck.method == DieBattleCheck.Method.SUM) " + " else " / ").reset()
                ansi.cursor(currentY + 3, currentX)
                ansi.a("   ")
                ansi.cursor(currentY + 4, currentX)
                ansi.a("   ")
                currentX += 3
            }
            val pair = battleCheck.getHeroPairAt(index)
            val width = 4 + if (pair.die.size >= 10) 3 else 2
            if (currentX + width + 3 > CONSOLE_WIDTH) { //Out of space
                for (row in currentY + 1..currentY + 4) {
                    ansi.cursor(row, currentX)
                    (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') }
                    ansi.a('│')
                }
                currentY += 4
                currentX = 4 + vsWidth + opponentWidth
            }
            if (battleCheck.isRolled) {
                drawDieSmall(currentX, currentY + 1, pair, battleCheck.getHeroResultAt(index))
            } else {
                drawDieSmall(currentX, currentY + 1, pair)
            }
            currentX += width
        }
        //Clear the rest
        (currentY + 1..currentY + 4).forEach { row ->
            ansi.cursor(row, currentX)
            (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
        }
        if (currentY == offsetY) { //Still on the first line
            currentX = 4 + vsWidth + opponentWidth
            (currentY + 5..currentY + 8).forEach { row ->
                ansi.cursor(row, currentX)
                (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') }
                ansi.a('│')
            }
        }
        //Draw result
        (battleCheck.result)?.let { r ->
            val frameTopY = offsetY + 5
            val result = String.format("%+d", r)
            val message = loadString(if (r >= 0) "success" else "fail").toUpperCase()
            val color = if (r >= 0) DARK_GREEN else DARK_RED
            //Frame
            ansi.color(color)
            drawHorizontalLine(frameTopY, '▒')
            drawHorizontalLine(frameTopY + 3, '▒')
            ansi.cursor(frameTopY + 1, 1).a("▒▒")
            ansi.cursor(frameTopY + 1, CONSOLE_WIDTH - 1).a("▒▒")
            ansi.cursor(frameTopY + 2, 1).a("▒▒")
            ansi.cursor(frameTopY + 2, CONSOLE_WIDTH - 1).a("▒▒")
            ansi.reset()
            //Top message
            val resultString = loadString("result")
            var center = (CONSOLE_WIDTH - result.length - resultString.length - 2) / 2
            ansi.cursor(frameTopY + 1, 3)
            (3 until center).forEach { ansi.a(' ') }
            ansi.a(resultString).a(": ")
            ansi.color(color).a(result).reset()
            (center + result.length + resultString.length + 2 until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') }
            //Bottom message
            center = (CONSOLE_WIDTH - message.length) / 2
            ansi.cursor(frameTopY + 2, 3)
            (3 until center).forEach { ansi.a(' ') }
            ansi.color(color).a(message).reset()
            (center + message.length until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') }
        }
    }

    private fun drawExplorationResult(offsetY: Int, pair: DiePair) {
        val encountered = loadString("encountered")
        ansi.cursor(offsetY, 1)
        ansi.a("│  ").a(encountered).a(':')
        (encountered.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
        ansi.a('│')
        val dieFrameWidth = 3 + 6 * if (pair.die.size >= 10) 3 else 2
        for (row in 1..8) {
            ansi.cursor(offsetY + row, 1)
            ansi.a("│  ")
            ansi.cursor(offsetY + row, dieFrameWidth + 4)
            (dieFrameWidth + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
        }
        drawDieSizeBig(4, offsetY + 1, pair)
    }

    private fun drawHand(offsetY: Int, hand: Hand, checkedDice: HandMask, activePositions: HandMask) {
        val handString = loadString("hand").toUpperCase()
        val alliesString = loadString("allies").toUpperCase()
        val capacity = hand.capacity
        val size = hand.dieCount
        val slots = max(size, capacity)
        val alliesSize = hand.allyDieCount
        var currentY = offsetY
        var currentX = 1

        //Hand title
        ansi.cursor(currentY, currentX)
        ansi.a("│   ").a(handString)

        //Left border
        currentY += 1
        currentX = 1
        ansi.cursor(currentY, currentX)
        ansi.a("│ ╔")
        ansi.cursor(currentY + 1, currentX)
        ansi.a("│ ║")
        ansi.cursor(currentY + 2, currentX)
        ansi.a("│ ╚")
        ansi.cursor(currentY + 3, currentX)
        ansi.a("│  ")
        currentX += 3

        //Main hand
        for (i in 0 until min(slots, MAX_HAND_SIZE)) {
            val die = hand.dieAt(i)
            val longDieName = die != null && die.size >= 10

            //Top border
            ansi.cursor(currentY, currentX)
            if (i < capacity) {
                ansi.a("════").a(if (longDieName) "═" else "")
            } else {
                ansi.a("────").a(if (longDieName) "─" else "")
            }
            ansi.a(if (i < capacity - 1) '╤' else if (i == capacity - 1) '╗' else if (i < size - 1) '┬' else '┐')

            //Center row
            ansi.cursor(currentY + 1, currentX)
            ansi.a(' ')
            if (die != null) {
                drawDieSize(die, checkedDice.checkPosition(i))
            } else {
                ansi.a("  ")
            }
            ansi.a(' ')
            ansi.a(if (i < capacity - 1) '│' else if (i == capacity - 1) '║' else '│')

            //Bottom border
            ansi.cursor(currentY + 2, currentX)
            if (i < capacity) {
                ansi.a("════").a(if (longDieName) '═' else "")
            } else {
                ansi.a("────").a(if (longDieName) '─' else "")
            }
            ansi.a(if (i < capacity - 1) '╧' else if (i == capacity - 1) '╝' else if (i < size - 1) '┴' else '┘')

            //Die number
            ansi.cursor(currentY + 3, currentX)
            if (activePositions.checkPosition(i)) {
                ansi.color(LIGHT_YELLOW)
            }
            ansi.a(String.format(" (%s) %s", shortcut(i), if (longDieName) " " else ""))
            ansi.reset()

            currentX += 5 + if (longDieName) 1 else 0
        }

        //Ally subhand
        if (alliesSize > 0) {
            currentY = offsetY

            //Ally title
            ansi.cursor(currentY, handString.length + 5)
            (handString.length + 5 until currentX).forEach { ansi.a(' ') }
            ansi.a("     ").a(alliesString)
            (currentX + alliesString.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')

            //Left border
            currentY += 1
            ansi.cursor(currentY, currentX)
            ansi.a("   ┌")
            ansi.cursor(currentY + 1, currentX)
            ansi.a("   │")
            ansi.cursor(currentY + 2, currentX)
            ansi.a("   └")
            ansi.cursor(currentY + 3, currentX)
            ansi.a("    ")
            currentX += 4

            //Ally slots
            for (i in 0 until min(alliesSize, MAX_HAND_ALLY_SIZE)) {
                val allyDie = hand.allyDieAt(i)!!
                val longDieName = allyDie.size >= 10

                //Top border
                ansi.cursor(currentY, currentX)
                ansi.a("────").a(if (longDieName) "─" else "")
                ansi.a(if (i < alliesSize - 1) '┬' else '┐')

                //Center row
                ansi.cursor(currentY + 1, currentX)
                ansi.a(' ')
                drawDieSize(allyDie, checkedDice.checkAllyPosition(i))
                ansi.a(" │")

                //Bottom border
                ansi.cursor(currentY + 2, currentX)
                ansi.a("────").a(if (longDieName) "─" else "")
                ansi.a(if (i < alliesSize - 1) '┴' else '┘')

                //Die number
                ansi.cursor(currentY + 3, currentX)
                if (activePositions.checkAllyPosition(i)) {
                    ansi.color(LIGHT_YELLOW)
                }
                ansi.a(String.format(" (%s) %s", shortcut(i + 10), if (longDieName) " " else "")).reset()

                currentX += 5 + if (longDieName) 1 else 0
            }
        } else {
            ansi.cursor(offsetY, 9)
            (9 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
            ansi.cursor(offsetY + 4, currentX)
            (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
        }

        //Clear the end of the line
        (0..3).forEach { row ->
            ansi.cursor(currentY + row, currentX)
            (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') }
            ansi.a('│')
        }
    }

    override fun drawHeroTurnStart(hero: Hero) {
        val centerY = (CONSOLE_HEIGHT - 5) / 2
        (1 until centerY).forEach { drawBlankLine(it, false) }
        ansi.color(heroColors[hero.type])
        drawHorizontalLine(centerY, '─')
        drawHorizontalLine(centerY + 4, '─')
        ansi.reset()
        ansi.cursor(centerY + 1, 1).eraseLine()
        ansi.cursor(centerY + 3, 1).eraseLine()
        ansi.cursor(centerY + 2, 1)
        val text = String.format(loadString("heros_turn"), hero.name.toUpperCase())
        val index = text.indexOf(hero.name.toUpperCase())
        val center = (CONSOLE_WIDTH - text.length) / 2
        ansi.cursor(centerY + 2, center)
        ansi.eraseLine(Ansi.Erase.BACKWARD)
        ansi.a(text.substring(0, index))
        ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset()
        ansi.a(text.substring(index + hero.name.length))
        ansi.eraseLine(Ansi.Erase.FORWARD)
        (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
        render()
    }

    override fun drawLocationInteriorScreen(
            location: Location,
            heroesAtLocation: List<Hero>,
            timer: Int,
            currentHero: Hero,
            battleCheck: DieBattleCheck?,
            encounteredDie: DiePair?,
            pickedDice: HandMask,
            activePositions: HandMask,
            statusMessage: StatusMessage,
            actions: ActionList) {

        //Top panel
        drawLocationTopPanel(location, heroesAtLocation, currentHero, timer)

        //Encounter info
        when {
            battleCheck != null -> drawBattleCheck(4, battleCheck)
            encounteredDie != null -> drawExplorationResult(4, encounteredDie)
            else -> (4..12).forEach { drawBlankLine(it) }
        }

        //Fill blank space
        val bottomHalfTop = CONSOLE_HEIGHT - 11
        (13 until bottomHalfTop).forEach { drawBlankLine(it) }

        //Hero-specific info
        drawLocationHeroPanel(bottomHalfTop, currentHero)
        drawHand(bottomHalfTop + 3, currentHero.hand, pickedDice, activePositions)

        //Separator
        ansi.cursor(bottomHalfTop + 8, 1)
        ansi.a('├')
        (2 until CONSOLE_WIDTH).forEach { ansi.a('─') }
        ansi.a('┤')

        //Status and actions
        drawStatusMessage(bottomHalfTop + 9, statusMessage)
        drawActionList(bottomHalfTop + 10, actions)

        //Bottom border
        ansi.cursor(CONSOLE_HEIGHT, 1)
        ansi.a('└')
        (2 until CONSOLE_WIDTH).forEach { ansi.a('─') }
        ansi.a('┘')

        //Finalize
        render()
    }

    override fun drawGameLoss(message: StatusMessage) {
        val centerY = CONSOLE_HEIGHT / 2
        (1 until centerY).forEach { drawBlankLine(it, false) }
        val data = loadString(message.toString().toLowerCase()).toUpperCase()
        drawCenteredCaption(centerY, data, LIGHT_RED, false)
        (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
        render()
    }

}

Существует одна маленькая проблемка, связанная с проверкой работы всего этого кода. Поскольку встроенная консоль IDE не поддерживает управляющие последовательности ANSI, то и запускать приложение придется во внешнем терминале (скрипт для запуска мы уже написали ранее). Кроме того, с поддержкой ANSI не все в порядке в Windows — насколько мне известно, только с 10-й версии стандартный cmd.exe может порадовать нас качественным отображением (и то, с некоторыми проблемами, на которых не станем акцентировать внимание). Да и PowerShell не сразу научился распознавать последовательности (несмотря на имеющийся спрос). Если же вам не повезло, не расстраивайтесь — всегда есть альтернативные решения (вот это, например). А мы двигаемся дальше.

Шаг десятый. Пользовательский ввод


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

Насколько вы помните, перед нами стоит необходимость реализовать методы класса GameInteractor. Их всего три, но они требуют особого внимания. Во-первых, синхронность. Работа игрового движка должна приостанавливаться до тех пор пока игрок не нажмет на клавишу. Во-вторых, обработка нажатий. К сожалению, возможностей стандартных классов Reader, Scanner, Console недостаточно для распознавания этих самых нажатий: мы не требуем от пользователя жать ENTER после ввода каждой команды. Нам нужно что-то вроде KeyListener'а, но он крепко привязан к фреймворку Swing, а наше приложение консольное — без всей этой графической мишуры.

Что же делать? Искать библиотеки, разумеется, и в этот раз их работа будет всецело опираться на нативный код. Что значит «прощай, кроссплатформенность»… Или нет? Увы, мне еще предстоит найти библиотеку, которая в легковесном, независимом от платформы виде реализует простую функциональность. А пока что обратим внимание на монстра jLine, реализующего комбайн по построению продвинутых пользовательских интерфейсов (в консоли). Да, он имеет нативную реализацию, да, он поддерживает как Windows, так и Linux/UNIX (путем предоставления соответствующих библиотек). И да, большая часть его функциональности нам триста лет не нужна. Нужна лишь мелкая, плохо документированная возможность, работу которой мы сейчас разберем.

<dependency>
    <groupId>jline</groupId>
    <artifactId>jline</artifactId>
    <version>2.14.6</version>
    <scope>compile</scope>
</dependency>

Обратите внимание, нам понадобится не третяя, последняя версия, а вторая, где есть класс ConsoleReader с методом readCharacter(). Как понятно из названия, данный метод возвращает код нажатого на клавиатуре символа (при этом работает синхронно, что нам и нужно). Остальное — дело техники: составить таблицу соответствий между символами и типами действий (Action.Type) и по нажатию на одно возвращать другое.

«А известно ли тебе, что не все клавиши на клавиатуре можно представить одним символом? Многие клавиши используют escape-последовательности из двух, трех, четырех разных символов. Как быть с ними?»

Следует отметить, что задача ввода усложняется, если мы захотим распознавать «несимвольные клавиши»: стрелки, F-ки, Home, Insert, PgUp/Dn, End, Delete, num-pad и прочие. Но мы не хотим, потому продолжим. Создадим класс ConsoleInteractor с необходимыми нам служебными методами.

abstract class ConsoleInteractor {

    private val reader = ConsoleReader()

    private val mapper = mapOf(
            CONFIRM to 13.toChar(),
            CANCEL to 27.toChar(),

            EXPLORE_LOCATION to 'e',
            FINISH_TURN to 'f',
            ACQUIRE to 'a',
            LEAVE to 'l',
            FORFEIT to 'f',
            HIDE to 'h',
            DISCARD to 'd',
    )

    protected fun read() = reader.readCharacter().toChar()

    protected open fun getIndexForKey(key: Char) = 
            "1234567890abcdefghijklmnopqrstuvw".indexOf(key)

}

Задаем карту mapper и метод read(). Кроме того предусмотрим метод getIndexForKey(), использующийся в ситуациях, когда нам необходимо выбрать элемент из списка или кубики из руки. Осталось унаследовать от этого класса нашу реализацию интерфейса GameInteractor.

Диаграмма классов


И, собственно, код:

class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor {

    override fun anyInput() {
        read()
    }

    override fun pickAction(list: ActionList): Action {
        while (true) {
            val key = read()
            list
                    .filter(Action::isEnabled)
                    .find { mapper[it.type] == key }
                    ?.let { return it }
        }
    }

    override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList)
            : Action {

        while (true) {
            val key = read()
            actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it }
            when (key) {
                in '1'..'9' -> {
                    val index = key - '1'
                    if (activePositions.checkPosition(index)) {
                        return Action(HAND_POSITION, data = index)
                    }
                }
                '0' -> {
                    if (activePositions.checkPosition(9)) {
                        return Action(HAND_POSITION, data = 9)
                    }
                }
                in 'a'..'f' -> {
                    val allyIndex = key - 'a'
                    if (activePositions.checkAllyPosition(allyIndex)) {
                        return Action(HAND_ALLY_POSITION, data = allyIndex)
                    }
                }
            }
        }
    }

}

Реализации наших методов достаточно вежливы и воспитанны, чтобы не возвращать наружу разную неадекватную ерунду. Они сами проверяют, чтобы выбранное действие было активным, а выбранная позиция руки входила в набор допустимых. И нам всем желаю быть такими же вежливыми с окружающими нас людьми.

Шаг одиннадцатый. Звуки и музычка


А то как же без них-то? Если вы хоть раз играли в игры с выключенным звуком (например, с планшетом под одеялом, пока никто из домашних не видит), вы возможно осознавали, как многого вы лишаетесь. Это как будто играть только в половину игры. Многие игры невозможно вообразить без звукового сопровождения, для многих это и вовсе неотъемлемое требование, хотя бывают и обратные ситуации (например, когда звуков нет в принципе, или они настолько убогие, что лучше бы без них). Сделать дело хорошо на самом деле не так просто, как кажется на первый взгляд (недаром в больших студиях этим занимаются высококвалифицированные специалисты), но как бы там ни было, в большинстве случаев иметь в своей игре аудиальную составляющую (хоть какую-то) гораздо лучше, чем не иметь ее вовсе. В крайнем случае, качество звука можно улучшить позже, когда время и настроение позволит.

Ввиду специфики жанра, наша игра не будет характеризоваться шедевральными звуковыми эффектами — если вы играли в цифровые адаптации настольных игр, то понимаете о чем я. Звуки отталкивают своей однообразостью, скоро приедаются и через некоторое время игра без них уже не кажется серьезной потерей. Проблема усугубляется тем, что отсутствуют эффективные способы борьбы с этим явлением. Замените игровые звуки на совершенно другие, и со временем опостылеют и они. В хороших играх звуки дополняют игровой процесс, раскрывают атмосферу происходящего действа, делают ее живой — этого сложно добиться в случае, если атмосфера — всего лишь стол с кучей пыльных мешков, а весь игровой процесс состоит в кидании кубиков. Тем не менее, именно это мы и станем озвучивать: шелчок тут, бросок здесь, шелест и шуршание под громкие крики — как будто мы не картинку на экране наблюдаем, а действительно взаимодействуем с реальными физическими объектами. Озвучивать их нужно полноценно, но ненавязчиво — на протяжении сценария вы будете слышать одно и то же сотню раз, поэтому звуки не должны выходить на первый план — лишь мягко оттенять игровой процесс. Как грамотно этого добиться? Понятия не имею, я не спец по звуку. Могу лишь посоветовать как можно больше играть в свою игру, замечая и шлифуя бросающиеся в глаза недостатки (этот совет, кстати, не только к звукам относится).

С теорией, кажись, разобрались, теперь пора и к практике перейти. А перед этим нужно задаться вопросом: а где, собственно, брать игровые файлы? Самый простой и верный способ — записать их самому в уродливом качестве, используя старенький микрофон или вообще телефоном пользуясь. В интернете полно роликов о том, как откручивая ботву ананаса или ломая лед сапогом можно добиться эффекта дробящихся костей и хрустящего позвоночника. Если вы не чужды эстетики сюрреализма, можете воспользоваться собственным голосом или кухонной утварью в качестве музыкального инструмента (есть примеры — и даже удачные — где такое делалось). А можете пойти на freesound.org, где сотня других людей давным-давно сделала это за вас. Только на лицензию обращайте внимание: многие авторы очень трепетно относятся к аудиозаписям своего громкого кашля или брошенной на пол монетки — вы ни в коем случае не хотите бессовестно воспользоваться плодами их трудов, не заплатив оригинальному создателю или не упомянув его творческий псевдоним (иногда весьма причудливый) в комментариях.

Натаскайте файликов, какие понравятся, и сложите их куда-нибудь в classpath. Для их идентификации будем использовать перечисление, где каждый экземпляр соответствует одному звуковому эффекту.

enum class Sound {

    TURN_START, //Hero starts the turn
    BATTLE_CHECK_ROLL, //Perform check, type
    BATTLE_CHECK_SUCCESS, //Check was successful
    BATTLE_CHECK_FAILURE, //Check failed
    DIE_DRAW, //Draw die from bag
    DIE_HIDE, //Remove die to bag
    DIE_DISCARD, //Remove die to pile
    DIE_REMOVE, //Remove die entirely
    DIE_PICK, //Check/uncheck the die
    TRAVEL, //Move hero to another location
    ENCOUNTER_STAT, //Hero encounters STAT die
    ENCOUNTER_DIVINE, //Hero encounters DIVINE die
    ENCOUNTER_ALLY, //Hero encounters ALLY die
    ENCOUNTER_WOUND, //Hero encounters WOUND die
    ENCOUNTER_OBSTACLE, //Hero encounters OBSTACLE die
    ENCOUNTER_ENEMY, //Hero encounters ENEMY die
    ENCOUNTER_VILLAIN, //Hero encounters VILLAIN die
    DEFEAT_OBSTACLE, //Hero defeats OBSTACLE die
    DEFEAT_ENEMY, //Hero defeats ENEMY die
    DEFEAT_VILLAIN, //Hero defeats VILLAIN die
    TAKE_DAMAGE, //Hero takes damage
    HERO_DEATH, //Hero death
    CLOSE_LOCATION,  //Location closed
    GAME_VICTORY, //Scenario completed
    GAME_LOSS, //Scenario failed
    ERROR, //When something unexpected happens  
}

Поскольку способ воспроизведения звуков будет разниться в зависимости от аппаратной платформы, акбстрагируемсчя от конкретной реализации при помощи интерфейса. Например, вот такого:

interface SoundPlayer {

    fun play(sound: Sound)
}

Подобно ранее рассмотренным интерфейсам GameRenderer и GameInteractor, его реализацию также необходимо передавать на вход экземпляру класса Game. Для начала, реализация может быть такой:

class MuteSoundPlayer : SoundPlayer {

    override fun play(sound: Sound) {
        //Do nothing
    }
}

Впоследствии мы рассмотрим более интересные реализации, а пока поговорим о музыке.
Подобно звуковым эффектам, она играет огромную роль в создании атмосферы игры, и точно также прекрасную игру можно загубить неподходящей музыкой. Как и звуки, музыка должна быть ненавязчивой, не выходить на первый план (кроме случаев, когда это необходимо для художественного эффекта) и адекватно соответствовать творящемуся на экране действу (не надейтесь, что кто-то всерьез проникнется судьбой попавшего в засаду и безжалостно убитого главного героя, если сцена его трагической гибели будет сопровождаться веселой музычкой из детской песенки). Добиться этого весьма непросто, специально обученные люди занимаются такими вопросами (мы с ними незнакомы), но и мы, как начинающие гении игростроя, тоже чего-то можем. Например, зайти куда-нибудь на freemusicarchive.org или soundcloud.com (или даже YouTube) и найти что-нибудь по душе. Для настолок хорошим выбором будет ambient — тихая плавная музыка без выраженной мелодии, хорошо подходящая для создания фона. Вдвойне обращайте внимание на лицензию: даже бесплатную музыку порой пишут талантливые композиторы, заслуживающие если и не денежного вознаграждения, то по крайней мере всеобщего признания.

Создадим еще одно перечисление:

enum class Music {
    SCENARIO_MUSIC_1,
    SCENARIO_MUSIC_2,
    SCENARIO_MUSIC_3,
}

Аналогичным образом определим интерфейс и его реализацию по умолчанию.

interface MusicPlayer {

    fun play(music: Music)

    fun stop()
}

class MuteMusicPlayer : MusicPlayer {

    override fun play(music: Music) {
        //Do nothing
    }

    override fun stop() {
        //Do nothing
    }
}

Обратите внимание, в этом случае нужны два метода: один для начала воспроизведения, другой для его остановки. Также вполне возможно, в дальнейшем пригодятся дополнительные методы (пауза/возобновление, перемотка итп), но пока что хватит и этих двух.

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

Диаграмма классов системы воспроизведения аудио


Класс Audio — это и есть наш singleton. Он предоставляет единый фасад к подсистеме… кстати, вот фасад (facade) — еще один паттерн проектирования, досконально проработанный и неоднократно описанный (с примерами) в этих ваших интернетах. Потому, уже слыша недовольные крики с задних рядов, я прекращаю растолковывать давным-давно известные вещи и двигаюсь дальше. Код вот:

object Audio {

    private var soundPlayer: SoundPlayer = MuteSoundPlayer()
    private var musicPlayer: MusicPlayer = MuteMusicPlayer()

    fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) {
        this.soundPlayer = soundPlayer
        this.musicPlayer = musicPlayer
    }

    fun playSound(sound: Sound) = this.soundPlayer.play(sound)

    fun playMusic(music: Music) = this.musicPlayer.play(music)

    fun stopMusic() = this.musicPlayer.stop()
}

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

Вот и все. Осталось разобраться собственно с воспроизведением. Что касается проигрывания звуков (или, как говорят умные люди, сэмплов), то в Java есть удобный класс AudioSystem и интерфейс Clip. Все, что нам нужно, это правильно прописать путь к аудио-файлу (который лежит у нас в classpath, помните?):

import javax.sound.sampled.AudioSystem

class BasicSoundPlayer : SoundPlayer {

    private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav"

    override fun play(sound: Sound) {
        val url = javaClass.getResource(pathToFile(sound))
        val audioIn = AudioSystem.getAudioInputStream(url)
        val clip = AudioSystem.getClip()
        clip.open(audioIn)
        clip.start()
    }
}

Метод open() может выбросить IOException (особенно если ему чем-то не понравился формат файла — в этом случае рекомендую открыть файл в аудио-редакторе и пересохранить), поэтому его неплохо бы обернуть в блок try-catch, но мы на первых порах не станем этого делать, чтоб приложение громко падало каждый раз при проблемах со звуком.

«Я даже не знаю, что сказать...»

С музыкой дела обстоят намного хуже. Насколько мне известно, стандартного способа проигрывания музыкальных файлов (например, в формате mp3) в Java нет, поэтому вам в любом случае придется пользоваться сторонней библиотекой (коих десятки разных). Нам подойдет любая легковесная с минимальным функционалом, например довольно популярная JLayer. Добавим ее в зависимости:

<dependencies>

    <dependency>
        <groupId>com.googlecode.soundlibs</groupId>
        <artifactId>jlayer</artifactId>
        <version>1.0.1.4</version>
        <scope>compile</scope>
    </dependency>

</dependencies>

И реализуем с ее помощью наш проигрыватель.

class BasicMusicPlayer : MusicPlayer {

    private var currentMusic: Music? = null
    private var thread: PlayerThread? = null

    private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3"

    override fun play(music: Music) {
        if (currentMusic == music) {
            return
        }
        currentMusic = music
        thread?.finish()
        Thread.yield()
        thread = PlayerThread(pathToFile(music))
        thread?.start()
    }

    override fun stop() {
        currentMusic = null
        thread?.finish()
    }

    // Thread responsible for playback
    private inner class PlayerThread(private val musicPath: String) : Thread() {

        private lateinit var player: Player
        private var isLoaded = false
        private var isFinished = false

        init {
            isDaemon = true
        }

        override fun run() {
            loop@ while (!isFinished) {
                try {
                    player = Player(javaClass.getResource(musicPath).openConnection().apply {
                        useCaches = false
                    }.getInputStream())
                    isLoaded = true
                    player.play()
                } catch (ex: Exception) {
                    finish()
                    break@loop
                }
                player.close()
            }
        }

        fun finish() {
            isFinished = true
            this.interrupt()
            if (isLoaded) {
                player.close()
            }
        }
    }
}

Во-первых, данная библиотека выполняет воспроизведение синхронно, блокируя основной поток до тех пор, пока не будет достигнут конца файла. Поэтому мы должны реализовать отдельный поток (PlayerThread), причем сделать его «необязательным» (демоном), чтобы он ни в коем случае не мешал приложению досрочно завершаться. Во-вторых, в коде проигрывателя сохраняется идентификатор проигрываемого в данный момент музыкального файла (currentMusic). Если вдруг придет повторная команда на его воспроизведение, мы не будем начинать проигрывание с самого начала. В-третьих, по достижении конца музыкального файла его воспроизведение начнется заново — и так до тех пор, пока поток не будет явно остановлен командой finish() (или пока не завершатся другие потоки, о чем уже было сказано). В-четвертых, хоть приведенный код и изобилует кажущимися ненужными флагами и командами, он тщательно отлажен и протестирован — проигрыватель работает как положено, не тормозит систему, не прерывается внезапно на полпути, не приводит к утечкам памяти, не содержит генно-модифицированных объектов, сияет свежестью и чистотой. Берите и смело пользуйтесь в своих проектах.

Шаг двенадцатый. Локализация


Наша игра почти готова, но играть в нее никто не будет. Почему?

«Русского нет!.. Нет русского!.. Добавьте русский язык!.. Разрабы псы!»

Откройте страницу любой интересной сюжетной игры (особенно мобильной) на сайте магазина и почитайте отзывы. Станут ли там хвалить потрясающую, заботливо нарисованную от руки графику? Или поражаться атмосферности звукового сопровождения? Или обсуждать захватывающий сюжет, который затягивает с первой минуты и не отпускает до самого конца?

Нет. Недовольные «игроки» наставят кучу единиц и вообще удалят игру. А то еще и деньги назад потребуют — и все это по одной простой причине. Да, вы забыли перевести свой шедевр на все 95 мировых языков. А вернее, на тот единственный, носители которого кричат громче всех. И всё! Понимаете? Месяцы кропотливой работы, долгие бессонные ночи, постоянные нервные срывы — все это хомяку под хвост. Вы лишились огромного количества игроков и это уже никак не исправить.

Поэтому думайте заранее. Определитесь со своей целевой аудиторией, выберите несколько основных языков, закажите услуги переводчика… в общем, делайте все то, что не раз описывали в тематических статьях другие люди (поумнее меня). Мы же сосредоточимся на технической стороне вопроса и поговорим о том, как безболезненно произвести локализацию своего продукта.

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

class TestEnemyTemplate : EnemyTemplate {
    override val name = "Test enemy"
    override val description = "Some enemy standing in your way."

    override val nameLocalizations = mapOf(
            "ru" to "Враг какой-то",
            "ar" to "بعض العدو",
            "iw" to "איזה אויב",
            "zh" to "一些敵人",
            "ua" to "Підступна тварюка"
    )
    override val descriptionLocalizations = mapOf(
            "ru" to "Описание какого-то врага.",
            "ar" to "وصف العدو",
            "iw" to "תיאור האויב",
            "zh" to "一些敵人的描述",
            "ua" to "Воно стоїть і дивиться на тебе."
    )

    override val traits = listOf<Trait>()
}

Для шаблонов такой подход вполне подойдет. Если не хотите указывать перевод для какого-то языка, то и не нужно — всегда есть значение по умолчанию. Однако в конечных объектах не хотелось бы разносить строки на несколько разных полей. Поэтому оставим одно, но заменим его тип.

class LocalizedString(defaultValue: String, localizations: Map<String, String>) {

    private val default: String = defaultValue
    private val values: Map<String, String> = localizations.toMap()

    operator fun get(lang: String) = values.getOrDefault(lang, default)

    override fun equals(other: Any?) = when {
        this === other -> true
        other !is LocalizedString -> false
        else -> default == other.default
    }

    override fun hashCode(): Int {
        return default.hashCode()
    }
  
}

И подправим соответствующим образом код генератора.

fun generateEnemy(template: EnemyTemplate) = Enemy().apply {
    name = LocalizedString(template.name, template.nameLocalizations)
    description = LocalizedString(template.description, template.descriptionLocalizations)
    template.traits.forEach { addTrait(it) }
}

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

val language = Locale.getDefault().language
val enemyName = enemy.name[language]

В нашем примере мы предоставили упрощенный вариант локализации, где учитывается только язык (language). Вообще же объекты класса Locale задают также страну и регион. Если в вашем приложении это принципиально, то ваш LocalizedString будет выглядеть слегка по-другому, но нас и так устраивает.

С шаблонами разобрались, осталось локализовать и служебные строки, использующиеся в нашем приложении. К счастью, ResourceBundle уже содержит все необходимые механизмы. Нужно лишь подготовить файлы с переводами и изменить способ их загрузки.

# Game status messages
choose_dice_perform_check=Выберите кубики для прохождения проверки:
end_of_turn_discard_extra=КОНЕЦ ХОДА: Сбросьте лишние кубики:
end_of_turn_discard_optional=КОНЕЦ ХОДА: Сбросьте кубики по желанию:
choose_action_before_exploration=Выберите, что делать:
choose_action_after_exploration=Исследование завершено. Что делать дальше?
encounter_physical=Встречен ФИЗИЧЕСКИЙ кубик. Необходимо пройти проверку.
encounter_somatic=Встречен СОМАТИЧЕСКИЙ кубик. Необходимо пройти проверку.
encounter_mental=Встречен МЕНТАЛЬНЫЙ кубик. Необходимо пройти проверку.
encounter_verbal=Встречен ВЕРБАЛЬНЫЙ кубик. Необходимо пройти проверку.
encounter_divine=Встречен БОЖЕСТВЕННЫЙ кубик. Можно взять без проверки:
die_acquire_success=Вы получили новый кубик!
die_acquire_failure=Вам не удалось получить кубик.
game_loss_out_of_time=У вас закончилось время

# Die types
physical=ФИЗИЧЕСКИЙ
somatic=СОМАТИЧЕСКИй
mental=МЕНТАЛЬНЫЙ
verbal=ВЕРБАЛЬНЫЙ
divine=БОЖЕСТВЕННЫЙ
ally=СОЮЗНИК
wound=РАНА
enemy=ВРАГ
villain=ЗЛОДЕЙ
obstacle=ПРЕПЯТСТВИЕ

# Hero types and descriptions
brawler=Забияка
hunter=Охотник

# Various labels
avg=сред
bag=Сумка
bag_size=Размер сумки
class=Класс
closed=Закрыто
discard=Сброс
empty=Пусто
encountered=На пути
fail=Неудача
hand=Рука
heros_turn=Ходит %s
max=макс
min=мин
perform_check=Пройдите проверку:
pile=Куча
received_new_die=Получен новый кубик
result=Результат
success=Успех
sum=сумм
time=Время
total=Итого

# Action names and descriptions
action_confirm_key=ENTER
action_confirm_name=Подтвердить
action_cancel_key=ESC
action_cancel_name=Отменить
action_explore_location_key=E
action_explore_location_name=Исследовать
action_finish_turn_key=F
action_finish_turn_name=Завершить ход
action_hide_key=H
action_bag_name=Спрятать
action_discard_key=D
action_discard_name=Сбросить
action_acquire_key=A
action_acquire_name=Приобрести
action_leave_key=L
action_leave_name=Уйти
action_forfeit_key=F
action_forfeit_name=Отказаться

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

class PropertiesStringLoader(locale: Locale) : StringLoader {
    private val properties = ResourceBundle.getBundle("text.strings", locale)

    override fun loadString(key: String) = properties.getString(key) ?: ""
}
.
Как уже было сказано, ResourceBundle сам возьмет на себя обязанность найти среди файлов локализаций ту единственную, которая наиболее соответствует текущей локали. А если не найдет — возьмет файл по умолчанию (string.properties). И все будет хорошо…

Ага! Не тут то было!
Увы, поддержка Unicode в файлах .properties появилась только начиная с Java 9. До этого единственной поддерживаемой кодировкой была ISO-8859-1 — ResourceBundle открывает файлы только в ней. Кодировка однобайтная, потому ни о какой кирилице, ни тем более о иероглифах не может быть и речи — мы жестко ограничены единственным языком. Для всех остальных символов придется использовать Unicode-последовательности — ну, вы знаете, вот эти вот: '\uXXXX'. К огромной нашей радости, заниматься кодированием вручную нам не придется, так как Java имеет в своем арсенале замечательное приложение native2ascii, автоматически заменяющее все неподдерживаемые символы на соответствующие последовательности. В итоге наш файл примет вот такой веселый вид:

# Game status messages
choose_dice_perform_check=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438:
end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438:
end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e:
choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c:
choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435?
encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438:
die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a!
die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a.
game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f

Вижу как текут ваши слюнки в предвкушении поддержки всего этого добра. Сконвертировать файл один раз — дело несложное. Постоянно вносить изменения — легче повеситься. К счастью, некоторые IDE способны делать такие (и обратные) преобразования «на лету», но легче от этого не становится — иногда так хочется открыть файлик в любимом текстовом редакторе и быстренько что-то подправить (делаю это постоянно), не запуская громоздкую IDE, а тут такой облом.

Не волнуйтесь, выход есть. Метод getBundle(), который мы доселе использовали, имеет перегруженную версию, принимающую третьим параметром объект класса ResourceBundle.Control — он-то и занимается разными низкоуровневыми вещами на этапе загрузки файлов.

class PropertiesStringLoader(locale: Locale) : StringLoader {
    private val properties = ResourceBundle.getBundle(
                "text.strings",
                locale,
                Utf8ResourceBundleControl())

    override fun loadString(key: String) = properties.getString(key) ?: ""
}

И, собственно, сама реализация:

class Utf8ResourceBundleControl : ResourceBundle.Control() {

    @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class)
    override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? {
        val bundleName = toBundleName(baseName, locale)
        return when (format) {
            "java.class" -> super.newBundle(baseName, locale, format, loader, reload)
            "java.properties" ->
                with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) {
                    when {
                        reload -> reload(this, loader)
                        else -> loader.getResourceAsStream(this)
                    }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } }
                }
            else -> throw IllegalArgumentException("Unknown format: $format")
        }
    }

    @Throws(IOException::class)
    private fun reload(resourceName: String, classLoader: ClassLoader): InputStream {
        classLoader.getResource(resourceName)?.let { url ->
            url.openConnection().let { connection ->
                connection.useCaches = false
                return connection.getInputStream()
            }
        }
        throw IOException("Unable to load data!")
    }

}

Даже не спрашивайте меня, что здесь происходит… вернее, спрашивайте (в комментариях) — охотно расскажу (я люблю Kotlin и его безумные конструкции). Или сами разберитесь — главное, что теперь можно смело сохранять локализованные .properties в кодировке UTF-8 без какой-либо конвертации.

Для тестирования работы приложения на разных языках не обязательно менять настройки операционной системы — достаточно указать требуемый язык при запуске JRE:


java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar

Если вы все еще работаете в Windows, ждите проблем
По умолчанию, стандартная консоль Windows (cmd.exe) работает с кодовой страницей 437 (это однобайтная кодировка DOSLatinUS), где нет символов кирилицы — вместо русских букв вы увидите кракозябры. К счастью, UTF-8 поддерживается, но для ее использования кодовую страницу необходимо переключить:

chcp 65001

Ну и поскольку Java сильно умная, она все еще считает, что в консоли используется кодировка по умолчанию. Нужно ее в этом разубедить:


java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar

А еще убедитесь, что в настройках консоли выбран шрифт, поддерживающий Unicode-символы (например, Lucida Console)

После всех наших волнительных приключений, полученный результат можно с гордостью продемонстрировать широкой общественности и громко заявить: «Мы не псы!»

Расово-верный вариант


И это хорошо.

Шаг тринадцатый. Собираем все вместе


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

А нам осталось лишь собрать воедино и запустить наш проект. Как вы помните, метод main() мы уже создали, теперь наполним его содержимым. Нам понадобятся:

  • сценарий и местности;
  • герои;
  • реализация интерфейса GameInteractor;
  • реализации интерфейсов GameRenderer и StringLoader;
  • реализации интерфейсов SoundPlayer и MusicPlayer;
  • объект класса Game;
  • бутылка шампанского.

Поехали!

fun main(args: Array<String>) {
    Audio.init(BasicSoundPlayer(), BasicMusicPlayer())
    
    val loader = PropertiesStringLoader(Locale.getDefault())
    val renderer = ConsoleGameRenderer(loader)
    val interactor = ConsoleGameInteractor()

    val template = TestScenarioTemplate()
    val scenario = generateScenario(template, 1)
    val locations = generateLocations(template, 1, heroes.size)

    val heroes = listOf(
            generateHero(Hero.Type.BRAWLER, "Brawler"),
            generateHero(Hero.Type.HUNTER, "Hunter")
    )

    val game = Game(renderer, interactor, scenario, locations, heroes)
    game.start()
}

Запускаем и наслаждаемся первым рабочим прототипом. Вот так-то.

Шаг четырнадцатый. Игровой баланс


Эммм…

Шаг пятнадцатый. Тесты


Теперь, когда основная часть кода первого рабочего прототипа написана, неплохо бы добавить парочку модульных тестов…

«Как? Только сейчас? Да тесты нужно было в самом начале писать, а потом уже код!»

Многие читатели справедливо заметят, что написание модульных тестов должно предварять разработку рабочего кода (TDD и прочие модные методологии). Другие возмутятся: нечего людям мозги дурить своими тестами, пусть хотя б что-то разрабатывать начнут, иначе вся мотивация пропадет. Еще пара-тройка человек вылезет из щели в плинтусе и робко сообщит: «я вообще не понимаю, зачем эти тесты нужны — у меня и так все работает»… После чего будут двинуты сапогом в лицо и быстренько запихнуты обратно. Я не стану начинать идеологических противостояний (их и так уже полным полно на просторах интернета), а потому отчасти соглашусь со всеми. Да, тесты иногда полезны (особенно в коде, который часто меняется или связан со сложными вычислениями), да, модульное тестирование подходит не для всего кода (например, оно не покрывает взаимодействий с пользователем или внешними системами), да, кроме модульного тестирования есть еще много других его видов (ну-ка, назвали хотя бы пять), и да, мы не будем акцентировать внимание на написании тестов — наша статья о другом.

Скажем так: многие программисты (особенно начинающие) пренебрегают тестами. Многие оправдывают себя тем, что функциональность их приложений плохо покрывается тестами. Например, чем городить сложные конструкции с участием специализированных фреймворков для тестирования пользовательского интерфейса (а такие есть), гораздо проще запустить приложение и посмотреть, все ли в порядке с внешним видом и взаимодействием. И я вам скажу, когда я занимался реализацией интерфейсов Renderer — я именно так и делал. Однако есть среди нашего кода такие методы, для которых отлично подходит концепция модульного тестирования.

Например, генераторы. Причем все. Это ж идеальный черный ящик: на вход подаются шаблоны, на выходе получаются объекты игрового мира. Внутри происходит невесть что, но именно его нам и нужно тестировать. Например, вот так:

public class DieGeneratorTest {

    @Test
    public void testGetMaxLevel() {
        assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel());
    }

    @Test
    public void testDieGenerationSize() {
        DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY);
        List<? extends List<Integer>> allowedSizes = Arrays.asList(
                null,
                Arrays.asList(4, 6, 8),
                Arrays.asList(4, 6, 8, 10),
                Arrays.asList(6, 8, 10, 12)
        );
        IntStream.rangeClosed(1, 3).forEach(level -> {
            for (int i = 0; i < 10; i++) {
                int size = DieGeneratorKt.generateDie(filter, level).getSize();
                assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size));
                assertTrue("Incorrect die size: " + size, size >= 4);
                assertTrue("Incorrect die size: " + size, size <= 12);
                assertTrue("Incorrect die size: " + size, size % 2 == 0);
            }
        });
    }

    @Test
    public void testDieGenerationType() {
        List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL);
        List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL);
        List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY);
        for (int i = 0; i < 10; i++) {
            Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType();
            assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1));
            Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType();
            assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2));
            Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType();
            assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3));
        }
    }

}

Или так:

public class BagGeneratorTest {

    @Test
    public void testGenerateBag() {
        BagTemplate template1 = new BagTemplate();
        template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL));
        template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC));
        template1.setFixedDieCount(null);
        BagTemplate template2 = new BagTemplate();
        template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE));
        template2.setFixedDieCount(5);
        BagTemplate template3 = new BagTemplate();
        template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY));
        template3.setFixedDieCount(50);

        for (int i = 0; i < 10; i++) {
            Bag bag1 = BagGeneratorKt.generateBag(template1, 1);
            assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15);
            assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count());
            Bag bag2 = BagGeneratorKt.generateBag(template2, 1);
            assertEquals("Incorrect bag size", 5, bag2.getSize());
            Bag bag3 = BagGeneratorKt.generateBag(template3, 1);
            assertEquals("Incorrect bag size", 50, bag3.getSize());
            List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList());
            assertEquals("Incorrect die types", 1, dieTypes3.size());
            assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0));
        }

    }

}

Или даже так:

public class LocationGeneratorTest {

    private void testLocationGeneration(String name, LocationTemplate template) {
        System.out.println("Template: " + template.getName());
        assertEquals("Incorrect template type", name, template.getName());
        IntStream.rangeClosed(1, 3).forEach(level -> {
            Location location = LocationGeneratorKt.generateLocation(template, level);
            assertEquals("Incorrect location type", name, location.getName().get(""));
            assertTrue("Location not open by default", location.isOpen());
            int closingDifficulty = location.getClosingDifficulty();
            assertTrue("Closing difficulty too small", closingDifficulty > 0);
            assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2);
            Bag bag = location.getBag();
            assertNotNull("Bag is null", bag);
            assertTrue("Bag is empty", location.getBag().getSize() > 0);
            Deck<Enemy> enemies = location.getEnemies();
            assertNotNull("Enemies are null", enemies);
            assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount());
            if (bag.drawOfType(Die.Type.ENEMY) != null) {
                assertTrue("Enemy cards not specified", enemies.getSize() > 0);
            }
            Deck<Obstacle> obstacles = location.getObstacles();
            assertNotNull("Obstacles are null", obstacles);
            assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount());
            List<SpecialRule> specialRules = location.getSpecialRules();
            assertNotNull("SpecialRules are null", specialRules);
        });

    }

    @Test
    public void testGenerateLocation() {
        testLocationGeneration("Test Location", new TestLocationTemplate());
        testLocationGeneration("Test Location 2", new TestLocationTemplate2());
    }

}

«Стоп, стоп, стоп! Это что? Java???»

Вы поняли. Причем как раз такого рода тесты хорошо писать в начале, перед тем как вы начнете реализовывать, собственно, генератор. Конечно, тестируемый код достаточно прост и скорее всего метод заработает с первого раза и без всяких тестов, но написав тест один раз вы о нем навсегда забудете обезопасите себя от любых возможных проблем в будущем (решение которых отбирает уйму времени, особенно когда с момента начала разработки прошло пять лет и вы уже забыли, как там все внутри метода работает). И если вдруг когда-нибудь ваш проект перестанет собираться из-за проваленных тестов, ты вы однозначно будете знать причину: поменялись требования к системе и ваши старые тесты им больше не удовлетворяют (а вы о чем подумали?).

И еще. Помните, класс HandMaskRule и его наследников? А теперь представьте, что в какой-то момент для использования навыка герою необходимо взять из руки три кубика, причем типы этих кубиков заняты жесткими ограничениями (например, «первый кубик должен быть синим, зеленым или белым, второй — желтым, белым или голубым, а третий — синим или фиолетовым» — чуете сложность?). Как подойти к реализации класса? Ну… для начала можете определиться с входными и выходными параметрами. Очевидно, нужно, чтобы класс принимал три массива (или набора), каждый из которых содержит допустимые типы для, соответственно, первого, второго и третьего кубиков. А дальше что? Переборы? Рекурсии? А вдруг что-то пропущу? Сделайте глубокий вход. Теперь отложите реализацию методов класса и напишите тест — благо требования просты, понятны и хорошо формализуемы. А лучше напишите несколько тестов… Но мы рассмотрим один, вот такой например:

public class TripleDieHandMaskRuleTest {

    private Hand hand;

    @Before
    public void init() {
        hand = new Hand(10);
        hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //0
        hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //1
        hand.addDie(new Die(Die.Type.SOMATIC, 4)); //2 
        hand.addDie(new Die(Die.Type.SOMATIC, 4)); //3
        hand.addDie(new Die(Die.Type.MENTAL, 4)); //4
        hand.addDie(new Die(Die.Type.MENTAL, 4)); //5
        hand.addDie(new Die(Die.Type.VERBAL, 4)); //6
        hand.addDie(new Die(Die.Type.VERBAL, 4)); //7
        hand.addDie(new Die(Die.Type.DIVINE, 4)); //8
        hand.addDie(new Die(Die.Type.DIVINE, 4)); //9
        hand.addDie(new Die(Die.Type.ALLY, 4)); //A (0)
        hand.addDie(new Die(Die.Type.ALLY, 4)); //B (1)
    }

    @Test
    public void testRule1() {
        HandMaskRule rule = new TripleDieHandMaskRule(
                hand,
                new Die.Type[]{Die.Type.PHYSICAL, Die.Type.SOMATIC},
                new Die.Type[]{Die.Type.MENTAL, Die.Type.VERBAL},
                new Die.Type[]{Die.Type.PHYSICAL, Die.Type.ALLY}
        );
        HandMask mask = new HandMask();
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertTrue("Should be on", rule.isPositionActive(mask, 5));
        assertTrue("Should be on", rule.isPositionActive(mask, 6));
        assertTrue("Should be on", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addPosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertTrue("Should be on", rule.isPositionActive(mask, 5));
        assertTrue("Should be on", rule.isPositionActive(mask, 6));
        assertTrue("Should be on", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addPosition(4);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addAllyPosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertFalse("Should be off", rule.isPositionActive(mask, 1));
        assertFalse("Should be off", rule.isPositionActive(mask, 2));
        assertFalse("Should be off", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertTrue("Rule should be met", rule.checkMask(mask));
        mask.removePosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met again", rule.checkMask(mask));
    }

}

Это утомительно, но не настолько как кажется, пока не начнешь (в какой-то момент даже увлекательно становится). Зато написав такой тест (и парочку других, на разные случаи), вы внезапно почувствуете спокойствие и уверенность в себе. Теперь никакая мелкая опечатка не испортит ваш метод и не приведет к неприятным неожиданностям, которые гораздо сложнее тестировать вручную. Мало-помалу, не торопясь, начинаем реализовывать нужные методы класса. И в конце запускаем тест, чтобы убедиться, что где-то мы допустили оплошлость. Найти проблемное место и переписать. Повторить до готовности.

class TripleDieHandMaskRule(
        hand: Hand,
        types1: Array<Die.Type>,
        types2: Array<Die.Type>,
        types3: Array<Die.Type>)
    : HandMaskRule(hand) {

    private val types1 = types1.toSet()
    private val types2 = types2.toSet()
    private val types3 = types3.toSet()

    override fun checkMask(mask: HandMask): Boolean {
        if (mask.positionCount + mask.allyPositionCount != 3) {
            return false
        }
        return getCheckedDice(mask).asSequence()
                .filter { it.type in types1 }
                .any { d1 ->
                    getCheckedDice(mask)
                            .filter { d2 -> d2 !== d1 }
                            .filter { it.type in types2 }
                            .any { d2 ->
                                getCheckedDice(mask)
                                        .filter { d3 -> d3 !== d1 }
                                        .filter { d3 -> d3 !== d2 }
                                        .any { it.type in types3 }
                            }
                }
    }

    override fun isPositionActive(mask: HandMask, position: Int): Boolean {
        if (mask.checkPosition(position)) {
            return true
        }
        val die = hand.dieAt(position) ?: return false
        return when (mask.positionCount + mask.allyPositionCount) {
            0 -> die.type in types1 || die.type in types2 || die.type in types3
            1 -> with(getCheckedDice(mask).first()) {
                (this.type in types1 && (die.type in types2 || die.type in types3))
                        || (this.type in types2 && (die.type in types1 || die.type in types3))
                        || (this.type in types3 && (die.type in types1 || die.type in types2))
            }
            2-> with(getCheckedDice(mask)) {
                val d1 = this[0]
                val d2 = this[1]
                (d1.type in types1 && d2.type in types2 && die.type in types3) ||
                (d2.type in types1 && d1.type in types2 && die.type in types3) ||
                (d1.type in types1 && d2.type in types3 && die.type in types2) ||
                (d2.type in types1 && d1.type in types3 && die.type in types2) ||
                (d1.type in types2 && d2.type in types3 && die.type in types1) ||
                (d2.type in types2 && d1.type in types3 && die.type in types1)
            }
            3 -> false
            else -> false
        }
    }

    override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean {
        if (mask.checkAllyPosition(position)) {
            return true
        }
        if (hand.allyDieAt(position) == null) {
            return false
        }
        return when (mask.positionCount + mask.allyPositionCount) {
            0 -> ALLY in types1 || ALLY in types2 || ALLY in types3
            1 -> with(getCheckedDice(mask).first()) {
                (this.type in types1 && (ALLY in types2 || ALLY in types3))
                        || (this.type in types2 && (ALLY in types1 || ALLY in types3))
                        || (this.type in types3 && (ALLY in types1 || ALLY in types2))
            }
            2-> with(getCheckedDice(mask)) {
                val d1 = this[0]
                val d2 = this[1]
                (d1.type in types1 && d2.type in types2 && ALLY in types3) ||
                        (d2.type in types1 && d1.type in types2 && ALLY in types3) ||
                        (d1.type in types1 && d2.type in types3 && ALLY in types2) ||
                        (d2.type in types1 && d1.type in types3 && ALLY in types2) ||
                        (d1.type in types2 && d2.type in types3 && ALLY in types1) ||
                        (d2.type in types2 && d1.type in types3 && ALLY in types1)
            }
            3 -> false
            else -> false
        }
    }

}

Если у вас есть идеи, как реализовать такой функционал проще — милости прошу в комментарии. А я несказанно рад, что мне хватило ума начать реализацию данного класса именно с написания теста.

«И я <...> тоже <...> очень <...> рад <...>. Залезь! <...> обратно! <...> в щель!»

Шаг шестнадцатый. Модульность


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

Перед нами стоит достаточно тривиальная задача. Необходимо разбить все созданные доселе классы на три группы:

  • базовая функциональность: модуль, игровой движок, интерфейсы-коннекторы и не зависящие от платформы реализации (core);
  • шаблоны сценариев, местностей, врагов и препятствий — составные части так называемого «приключения» (adventure);
  • конкретные реализации интерфейсов, специфичные для конкретной платформы: в нашем случае — консольного приложения (cli).

Результат такого разделения в конечном итоге будет выглядеть приблизительно как на следующей диаграмме:

Словно актеры в конце представления, наши сегодняшние герои вновь выходят на сцену в полном составе


Создайте дополнительные проекты и перенесите соответствующие класс. А нам осталось только грамотно настроить взаимодействие проектов между собой.

Проект Core
Данный проект представляет собой движок в чистом виде. Все специфичные классы были перенесены в другие проекты — осталась лишь базовая функциональность, ядро. Библиотека, если хотите. Здесь больше нет запускающего класса, нет даже необходимости собирать пакет. Сборки этого проекта будут размещаться в локальном репозитории Maven (о чем позже) и использоваться другими проектами в качестве зависимостей.

Файл pom.xml выглядт следующим образом:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>my.company</groupId>
    <artifactId>dice-core</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <dependencies>

        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>${kotlin.version}</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit-dep</artifactId>
            <version>4.8.2</version>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <!-- other Kotlin setup -->
            </plugin>
        </plugins>
    </build>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <kotlin.version>1.3.20</kotlin.version>
        <kotlin.compiler.incremental>true</kotlin.compiler.incremental>
    </properties>

</project>

Отныне собирать его будем так:

mvn -f "path_to_project/DiceCore/pom.xml" install

Проект Cli
Здесь находится точка входа в приложение — именно с этим проектом будет взаимодействовать конечный пользователь. Ядро используется в качестве зависимости. Поскольку в нашем примере мы работаем с консолью, проект будет содержать классы, необходимые для работы с ней (если вдруг нам захочется запустить игру на кофеварке, мы попросту заменим этот проект на аналогичный — с соответствующими реализациями). Тут же будем складывать ресурсы (строки, аудиофайлы итп.).

В файл pom.xml перекочуют зависимости от внешних библиотек:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>my.company</groupId>
    <artifactId>dice-cli</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>

    <dependencies>

        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>${kotlin.version}</version>
        </dependency>

        <dependency>
            <groupId>my.company</groupId>
            <artifactId>dice-core</artifactId>
            <version>1.0</version>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>org.fusesource.jansi</groupId>
            <artifactId>jansi</artifactId>
            <version>1.17.1</version>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>jline</groupId>
            <artifactId>jline</artifactId>
            <version>2.14.6</version>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>com.googlecode.soundlibs</groupId>
            <artifactId>jlayer</artifactId>
            <version>1.0.1.4</version>
            <scope>compile</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
             <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <!-- other Kotlin setup -->
            </plugin>

            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>2.6</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>my.company.dice.MainKt</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>

        </plugins>

    </build>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <kotlin.version>1.3.20</kotlin.version>
        <kotlin.compiler.incremental>true</kotlin.compiler.incremental>
    </properties>

</project>

Скрипт для сборки и запуска этого проекта мы уже видели — не станем повторяться.

Приключения
Ну и напоследок в отдельный проект вынесем сюжетную часть. То есть, все сценарии, местности, врагов и прочие уникальные объекты игрового мира, какие только вообразят сотрудники сценарного отдела вашей компании (ну или пока что лишь наша собственная больная фантазия — мы все еще единственный гейм-дизайнер в округе). Идея состоит в том, чтобы группировать сценарии в наборы (приключения) и распространять каждый такой набор в виде отдельного проекта (подобно тому как это делается в мире настольных и видео-игр). То есть, собирать jar-архивы и складывать их в отдельную папку, с тем чтобы игровой движок сканировал эту папку и автоматически подключал все содержащиеся там приключения. Однако техническая реализация такого подхода сопряжена с огромными трудностями.

С чего бы начать? Ну во-первых, с того что мы распространяем шаблоны в виде конкретных java-классов (ага, бейте меня и ругайте — я заранее это предвидел). А раз так, то эти классы во время запуска должны находиться в classpath приложения. Обеспечить выполнение этого требования несложно — вы явно прописываете ваши jar-файлы в соответствующую переменную окружения (начиная с Java 6 можно даже использовать * — wildcards).

java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar

«Дурак, что ли? При использовании ключа -jar ключ -classpath игнорируется!»

Однако это работать не будет. Classpath для исполняемых jar-архивов необходимо явно прописывать во внутреннем файле META-INF/MANIFEST.MF (секция так и называется — Claspath:). Ничего страшного, для этого даже специальные плагины имеются (maven-compiler-plugin или, на худой конец, maven-assembly-plugin). Вот только wildcards в манифесте, увы, не работают — вам придется явно указывать названия зависимых jar-файлов. То есть, знать их заранее, что в нашем случае проблематично.

И вообще, я не так хотел. Я хотел, чтобы проект не нужно было заново компилировать. Чтобы в папку adventures/ можно было накидать любое количество приключений, и чтобы все они были видны игровому движку в процессе выполнения. К сожалению, кажущаяся очевидной функциональность выходит за рамки стандартных представлений мира Java. А потому и не приветствуется. Нужно реализовывать другой подход к распространению независимых приключений. Какой? Не знаю, пишите в комментариях — наверняка у кого-то есть умные идеи.

А пока идей нет, вот мелкая (или крупная, смотря как посмотреть) хитрость, позволяющая динамически добавлять зависимости в classpath даже не зная их названий и без необходимости заново компилировать проект:

В Windows:

@ECHO OFF

call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install
call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package
call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package

mkdir path_to_project\DiceCli\target\adventures
copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\

chcp 65001 
cd path_to_project\DiceCli\target\

java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt

pause

И в Unix:

#!/bin/sh

mvn -f "path_to_project/DiceCore/pom.xml" install
mvn -f "path_to_project/DiceCli/pom.xml" package
mvn -f "path_to_project/TestAdventure/pom.xml" package

mkdir path_to_project/DiceCli/target/adventures
cp path_to_project/TestAdventure/target/test-adventure-1.0.jar path_to_project/DiceCli/target/adventures/

cd path_to_project/DiceCli/target/

java -cp "dice-cli-1.0-jar-with-dependencies.jar:adventures/*" my.company.dice.MainKt

А хитрость вот в чем. Вместо использования ключа -jar мы добавляем проект Cli в classpath и явно указываем в качестве точки входа содержащийся внутри него класс MainKt. Плюс здесь же подключаем все архивы из папки adventures/.

Не нужно лишний раз указывать, насколько это кривое решение — я и сам знаю, спасибо. Лучше предложите свои идеи в комментариях. Please. (ಥ﹏ಥ)

Шаг семнадцатый. Сюжет


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

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

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

Сюжетная завязка игры
Действие игры происходит с вымышленной фентезийной вселенной и начинается, как это часто бывает, с глобальной войны всех со всеми. Из восьми вступивших в противостояние государств, к концу осталось лишь два: Эстрелла (конституционная монархия) и Асмус (олигархическая республика), меньше всего пострадавших. Посколько дальнейшая борьба не имела никакого смысла, выжившие вынуждены были подписать перемирие.

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

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

Я, к счастью, не Толкиен, излишне детализированно игровой мир не прорабатывал, но попытался сделать его достаточно интересным и, что самое важное, логически обоснованным. При этом позволил себе внести некоторые неясности, кои каждый игрок волен интерпретировать на свой лад. Например, нигде не делал акцент на уровне технологического развития описываемого мира: феодальный строй и современные демократические институты, злобные тираны и организованные преступные группировки, высшая цель и банальное выживание, поездки на автобусах и драки в тавернах — даже стреляют персонажи непонятно из чего: то ли из луков/арбалетов, то ли из штурмовых винтовок. В мире присутствует подобие магии (ее наличие добавляет геймплею тактических возможностей) и элементы мистики (просто, чтоб было).

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

Что я хочу всем этим сказать? Продуманный интересный сюжет — возможно, и не такая большая необходимость, но от его наличия ваша игра совершенно не пострадает: в лучшем случае игроки будут им наслаждаться, в худшем — просто проигнорируют. А особо увлеченные даже простят вашей игре какие-то функциональные огрехи, лишь бы узнать, чем закончится история.

Что дальше?


Дальше программирование заканчивается и начинается game design. Теперь пора не код писать, а продумывать сценарии, локации, врагов — вы поняли, вот эту всю муть. Если вы по-прежнему работаете в одиночку, я вас поздравляю — вы достигли этапа, на котором бросается большинство игровых проектов. В крупных ААА-студиях дизайнерами и сценаристами работают специальные люди, которые за это деньги получают — им попросту деваться некуда. У нас же вариантов полно: пойти погулять, поесть, поспать банально — да что уж там, даже начать новый проект, пользуясь накопленным опытом и знаниями.

Если вы все еще тут и желаете во что бы то ни стало продолжать, то готовьтесь к трудностям. Нехватка времени, лень, отсутствие творческого вдохновения — вас постоянно будет что-то отвлекать. Преодолеть все эти препятствия нелегко (опять-таки, много статей написано на эту тему), но возможно. В первую очередь советую хорошенько спланировать дальнейшее развитие проекта. Благо, мы работаем в свое удовольствие, издатели нас не подгоняют, выполнения каких-то конкретных сроков никто не требует — а значит есть возможность подойти к делу без лишней спешки. Составьте «дорожную карту» (roadmap) проекта, определите основные этапы и (если хватит смелости) приблизительные сроки их выполнения. Заведите себе записную книжку (можно электронную) и постоянно записывайте в нее возникающие идеи (даже внезпно проснувшись среди ночи). Отмечайте свой прогресс при помощи таблиц (например, таких) или других вспомогательных средств. Начните вести документацию: как внешнуюю, публичную (вики, например) для будущего огромного сообщества фанатов, так и внутреннюю, для себя (ссылкой не поделюсь) — поверьте, без нее после месяца перерыва вы уже и не вспомните, что конкретно и как делали. В общем, пишите как можно больше сопроводительной информации о своей игре, только не забывайте при этом писать саму игру. Базовые варианты я предложил, а конкретных советов не даю — каждый сам для себя решает, каким образом ему комфортнее организовывать свой рабочий процесс.

«А все-таки, про игровой баланс не хочешь рассказать?»

Сразу подготовьте себя к тому, что создать идеальную игру с первого раза не получится. Рабочий прототип это хорошо — он на первых порах покажет состоятельность проекта, убедит или разочарует вас и даст ответ на очень важный вопрос: «а стоит ли продолжать?». Однако он не ответит на множество других вопросов, главный из которых, наверное: «будет ли интересно играть в мою игру в долгосрочной перспективе?». Существует огромное количество теорий и статей (ну вот, опять) на эту тему. Интересная игра должна быть в меру сложной, так как слишком простая игра не делает вызов (challenge) игроку. С другой стороны, если сложность будет запредельная, из игровой аудитории останутся только упоротые хардкорщики или люди, стремящиеся что-то кому-то доказать. Игра должна быть достаточно разнообразной, в идеале — предоставлять несколько вариантов достижения цели, чтобы каждый игрок подобрал себе вариант по вкусу. Одна стратегия прохождения не должна доминировать над остальными, иначе использовать будут только ее… И так далее.

Иными словами, игру нужно сбалансировать. Особенно это касается настольной игры, где правила четко формализованы. Как это сделать? Понятия не имею. Если у вас нет друга-математика, способного составить математическую модель (я видел, такое делают) и вы сами в этом ничего не понимаете (а мы не понимаем), то остается единственный выход — положиться на интуицию playtesting. Сначала играйте в игру сами. Когда надоест — предлагайте играть жене. После развода предлагайте играть другим родственникам, друзьям, знакомым, случайным людям на улице. Когда останетесь совсем один — выкладывайте сборки в интернете. Люди заинтересуются, захотят поиграть, а вы им в ответ: «с тебя feedback!». Может, кто-то полюбит вашу мечту так же, как и вы, и захочет с вами сотрудничать — найдете таким образом единомышленников или хотя бы группу поддержки (как думаете, зачем я эту статью написал?) (хе-хе).

Шутки в сторону, желаю нам… вам всем успехов. Побольше читайте (кто бы мог подумать!) — про гейм-дизайн и не только. Все рассмотренные нами вопросы уже освещались так или иначе в статьях и литературе (хотя, если вы все еще здесь, призывать вас к чтению явно излишне). Делитесь впечатлениями, общайтесь на форумах — в общем, вы и так все лучше меня знаете. Не ленитесь и все у вас получится.

На этой оптимистичной ноте разрешите откланяться. Благодарю всех за внимание. Увидимся!

«Э! Какой увидимся? Как теперь это все на мобилке запустить? Я что, зря ждал, что ли?»

Послесловие. Андроид


Для описания интеграции нашего игрового движка с платформой Андроид, оставим в покое класс Game и рассмотрим аналогичный ему, но гораздо более простой класс MainMenu. Как понятно из названия, предназначен он для реализации главного меню приложения и по сути является первым классом, с которым пользователь начинает взаимодействие.

В консольном интерфейсе это выглядит так


Как и класс Game, он задает бесконечный цикл, на каждой итерации которого происходит отрисовка экрана и запрос команды от пользователя. Только никакой сложной логики здесь нет и команд этих значительно меньше. Мы реализуем по сути одну — «Exit».

Диаграмма деятельности для главного меню


Несложно, правда? О том и речь. Код тоже на порядок проще.

class MainMenu(
        private val renderer: MenuRenderer, 
        private val interactor: MenuInteractor
) {

    private var actions = ActionList.EMPTY

    fun start() {
        Audio.playMusic(Music.MENU_MAIN)
        actions = ActionList()
        actions.add(Action.Type.NEW_ADVENTURE)
        actions.add(Action.Type.CONTINUE_ADVENTURE, false)
        actions.add(Action.Type.MANUAL, false)
        actions.add(Action.Type.EXIT)
        processCycle()
    }

    private fun processCycle() {
        while (true) {
            renderer.drawMainMenu(actions)

            when (interactor.pickAction(actions).type) {
                Action.Type.NEW_ADVENTURE -> TODO()
                Action.Type.CONTINUE_ADVENTURE -> TODO()
                Action.Type.MANUAL -> TODO()
                Action.Type.EXIT -> {
                    Audio.stopMusic()
                    Audio.playSound(Sound.LEAVE)
                    renderer.clearScreen()
                    Thread.sleep(500)
                    return
                }
                else -> throw AssertionError("Should not happen")
            }

        }
    }

}

Взаимодействие с пользователем реализуется при помощи интерфейсов MenuRenderer и MenuInteractor, работающими аналогично виденному ранее.

interface MenuRenderer: Renderer {

    fun drawMainMenu(actions: ActionList)

}

interface Interactor {

    fun anyInput()

    fun pickAction(list: ActionList): Action

}

Как вы уже поняли, мы не зря отделяли интерфейсы от конкретных реализаций. Все, что нам теперь нужно, заменить проект Cli новым проектом (назовем его Droid), добавив зависимость от проекта Core. Сделаем это.

Запустим Android Studio (обычно проекты под Андроид разрабатываются в ней), создадим простой проект, удалив всю ненужную стандартную мишуру и оставив лишь поддержку языка Kotlin. Добавим также зависимость от проекта Core, который хранится в локальном Maven-репозитории нашей машины.

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "my.company.dice"
        minSdkVersion 14
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation "my.company:dice-core:1.0"
}

По умолчанию, однако, нашу зависимость никто не увидит — необходимо явно указать необходимость использования локального репозитория (mavenLocal) при сборке проекта.

buildscript {
    ext.kotlin_version = '1.3.20'

    repositories {
        google()
        jcenter()
        mavenLocal()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:3.3.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        mavenLocal()
    }
}

Вы увидите, что все разработанные ранее классы досутпны для использования, а интерфейсы — для реализации. Интересует нас, по большому счету, уже знакомые нам интерфейсы: SoundPlayer, MusicPlayer, MenuInteractor (аналог GameInteractor), MenuRenderer (аналог GameRenderer) и StringLoader, для которых напишим новые, специфичные для андроида реализации. Но перед этим прикинем, как вообще будет происходить взаимодействие пользователя с нашей новой системой.

Для отрисовки элементов интерфейса мы не станем использовать стандартные компоненты (кнопки, картинки, поля для ввода итп) Android — вместо этого ограничимся возможностями класса Canvas. Для этого нам достаточно создать один-единственный наследник класса View — это и будет наш «холст». С вводом чуть сложнее, так как клавиатуры у нас больше нет, и интерфейс необходимо разрабатывать таким образом, чтобы вводом команд считались нажатия пользователя на определенные части экрана. Для этого воспользуемся все тем же наследником View — таким образом, он будет выступать посредником между пользователем и игровым движком (аналогично тому, как ранее таким посредником выступала системная консоль).

Создадим основную активность для нашего View и пропишем ее в манифесте.

<manifest
        xmlns:android="http://schemas.android.com/apk/res/android"
        package="my.company.dice">

    <application
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:theme="@style/AppTheme">

        <activity
                android:name=".ui.MainActivity"
                android:screenOrientation="sensorLandscape"
                android:configChanges="orientation|keyboardHidden|screenSize">
            <intent-filter>
                <category android:name="android.intent.category.LAUNCHER"/>
                <action android:name="android.intent.action.MAIN"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

Зафиксируем активность в альбомной ориентации — как в случае с большинством остальных игр, портретная нам не понядобится. Более того, развернем ее на весь экран устройства, прописав соответствующим образом основную тему.

<resources>
    <style name="AppTheme" 
           parent="android:Theme.Black.NoTitleBar.Fullscreen"/>
</resources>

И раз уж мы полезли в ресурсы, перенесем из проекта Cli нужные нам локализованные строки, приведя их к нужному формату:

<resources>
    <string name="action_new_adventure_key">N</string>
    <string name="action_new_adventure_name">ew adventure</string>
    <string name="action_continue_adventure_key">C</string>
    <string name="action_continue_adventure_name">ontinue adventure</string>
    <string name="action_manual_key">M</string>
    <string name="action_manual_name">anual</string>
    <string name="action_exit_key">X</string>
    <string name="action_exit_name">Exit</string>
</resources>

А также используемые в главном меню файлы звуков и музыки (по одному каждого вида), расположив их в /assets/sound/leave.wav и /assets/music/menu_main.mp3 соответственно.

Когда с ресурсами разобрались, настало время заняться дизайном (да, опять). В отличие от консоли, платформа Андроид имеет свои архитектурные особенности, что вынуждает нас использовать специфические подходы и методы.

Диаграмма классов и интерфейсов


Подождите, не падайте в обморок, сейчас все подробно объясню.

Начнем, пожалуй, с самого сложного — класса DiceSurface — того самого наследника View, который призван скрепить воедино независимые части нашей системы (при желании можно унаследовать его от класса SurfaceView — или даже GlSurfaceView — и производить отрисовку в отдельном потоке, но игра у нас пошаговая, бедная на анимации, сложного графического вывода не требующая, потому не станем усложнять). Как было сказано ранее, его реализация будет решать сразу две задачи: вывод изображения и обработка нажатий, каждая из которых имеет свои неожиданные сложности. Рассмотрим их по порядку.

Когда мы рисовали на консоли, наш Renderer отправлял команды вывода и формировал изображение на экране. В случае с Андроид ситуация обратная — отрисовка инициируется самим View, который к моменту выполнения метода onDraw() уже должен знать, что, как и где, рисовать. А как же метод drawMainMenu() интерфейса MainMenu? Он теперь не управляет выводом?

Попробуем решить эту задачу при помощи функциональных интерфейсов. Класс DiceSurface будет содержать особый параметр instructions — по сути, блок кода, который необходимо выполнить каждый раз при вызове метода onDraw(). Renderer же, при помощи публичного метода будет указывать, какие конкретно инструкции следует исполнять. Если кому интересно, используемый паттерн называется стратегия (strategy). Выглядит это следующим образом:

typealias RenderInstructions = (Canvas, Paint) -> Unit

class DiceSurface(context: Context) : View(context) {

    private var instructions: RenderInstructions = { _, _ -> }

    private val paint = Paint().apply {
        color = Color.YELLOW
        style = Paint.Style.STROKE
        isAntiAlias = true
    }

    fun updateInstructions(instructions: RenderInstructions) {
        this.instructions = instructions
        this.postInvalidate()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawColor(Color.BLACK) //Fill background with black color
        instructions.invoke(canvas, paint) //Execute current render instructions
    }

}

class DroidMenuRenderer(private val surface: DiceSurface): MenuRenderer {

    override fun clearScreen() {
        surface.updateInstructions { _, _ -> }
    }

    override fun drawMainMenu(actions: ActionList) {

        surface.updateInstructions { c, p ->
            val canvasWidth = c.width
            val canvasHeight = c.height
            //Draw title text
            p.textSize = canvasHeight / 3f
            p.strokeWidth = 0f
            p.color = Color.parseColor("#ff808000")
            c.drawText(
                "DICE",
                (canvasWidth - p.measureText("DICE")) / 2f,
                (buttonTop - p.ascent() - p.descent()) / 2f,
                p
            )
            //Other instructions...
        }

    }
}

То есть, вся графическая функциональность по-прежнему находится в классе Renderer, но в этот раз мы не напрямую исполняем команды, а подготавливаем их для исполнения нашим View. Обратите внимание на тип свойства instructions — можно было бы создать отдельный интерфейс и вызывать его единственный метод, но Kotlin позволяет значительно сократить количество кода.

Теперь про Interactor. Ранее ввода данных происходил синхронно: когда мы запрашивали данные у консоли (клавиатуры), выполнение приложения (циклов) приостанавливалось, пока пользователь не нажимал клавишу. С Андроидом такой трюк не пройдет — у него есть свой Looper, работу которого мы ни в коем случае не должны нарушать, а значит ввод должен быть асинхронным. То есть методы интерфейса Interactor по-прежнему приостанавливают работу движка и ожидают команд, в то время как Activity и все ее View продолжают работать, пока рано или поздно эту команду не отправят.

Такой подход достаточно просто реализовать при помощи стандартного интерфейса BlockingQueue. Класс DroidMenuInteractor будет вызывать метод take(), который приостановит выполнение игрового потока до тех пор, пока в очереди не появятся элементы (экземпляры знакомого нам класса Action). DiceSurface, в свою очередь, будет регировать на нажатия пользователя (стандартный метод onTouchEvent() класса View), генерировать объекты и добавлять их в очередь методом offer(). Выглядеть это будет следующим образом:

class DiceSurface(context: Context) : View(context) {

    private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>()

    fun awaitAction(): Action = actionQueue.take()

    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_UP) {
            actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS)
        }
        return true
    }

}

class DroidMenuInteractor(private val surface: DiceSurface) : Interactor {

    override fun anyInput() {
        surface.awaitAction()
    }

    override fun pickAction(list: ActionList): Action {
        while (true) {
            val type = surface.awaitAction().type
            list
                .filter(Action::isEnabled)
                .find { it.type == type }
                ?.let { return it }
        }
    }
}

То есть, Interactor вызывает метод awaitAction() и если в очереди что-то есть, обрабатывает полученную команду. Обратите внимание на то, как команды добавляются в очередь. Поскольку UI-поток выполняется непрерывно, пользователь может нажать на экран много раз подряд, что способно привести к подвисаниям активности, особенно если игровой движок не готов принимать команды (например, во время выполнения анимаций). В этом случае поможет увеличение емкости очереди и/или уменьшение значения таймаута.

Конечно, команды мы вроде как передаем, но только одну-единственную. Нам же необходимо различать координаты нажатия, и в зависимости от их значений вызывать ту или иную команду. Однако вот незадача — Interactor понятия не имеет, где в каком месте экрана нарисованы активные кнопки — за отрисовку у нас отвечает Renderer. Наладим их взаимодействие следующим образом. Класс DiceSurface будет хранить специальную коллекцию — список активных прямоугольников (или других фигур, если мы когда-нибудь до этого дорастем). Такие прямоугольники содержат координаты вершин и подвязанный Action. Renderer будет генерировать эти прямоугольники и добавлять их в список, метод onTouchEvent() будет определять, который из прямоугольников оказался нажатым, и добавлять в очередь соответствующий Action.

private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) {

    val rect = RectF(left, top, right, bottom)

    fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h)
}

Метод check() занимается проверкой попадания указанных координат внутрь прямоугольника. Обратите внимание, на этапе работы Renderer'а (а это именно тот момент, когда прямоугольники создаются) мы не имеем ни малейшего представления о размере холста. Поэтому координаты нам придется хранить в относительных величинах (процент ширины или высоты экрана) со значениями от 0 до 1 и пересчитывать в момент нажатия. Такой подход не совсем аккуратный, так как не учитывает соотношение сторон — в будущем его придется переделывать. Однако для нашей учебной задачи на первых порах сгодится.

Реализуем в классе DiceSurface дополнительное поле, добавим два метода (addRectangle() и clearRectangles()) для управления им извне (со стороны Renderer'а), и расширим onTouchEvent(), заставив брать во внимание координаты прямоугольников.

class DiceSurface(context: Context) : View(context) {

    private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>()
    private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>())
    private var instructions: RenderInstructions = { _, _ -> }

    private val paint = Paint().apply {
        color = Color.YELLOW
        style = Paint.Style.STROKE
        isAntiAlias = true
    }

    fun updateInstructions(instructions: RenderInstructions) {
        this.instructions = instructions
        this.postInvalidate()
    }

    fun clearRectangles() {
        rectangles.clear()
    }

    fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) {
        rectangles.add(ActiveRect(action, left, top, right, bottom))
    }

    fun awaitAction(): Action = actionQueue.take()

    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (event.action == MotionEvent.ACTION_UP) {
            with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) {
                if (this != null) {
                    actionQueue.put(action)
                } else {
                    actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS)
                }
            }
        }
        return true
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawColor(Color.BLACK)
        instructions(canvas, paint)
    }
}

Для хранения прямоугольников используется конкуррентная коллекция — она позволит избежать возникновения ConcurrentModificationException в случае, если набор будет одновременно обновляться и перебираться разными потоками (что в нашем случае обязательно произойдет).

Код класса DroidMenuInteractor останется без изменений, а вот DroidMenuRenderer изменится. Добавим в отображение четыре кнопки для каждого элемента ActionList. Расположим их под заголовком DICE, равномерно распределив по ширине экрана. Ну и об активных прямоугольниках не забудем.

class DroidMenuRenderer (
        private val surface: DiceSurface,
        private val loader: StringLoader
) : MenuRenderer {

    protected val helper = StringLoadHelper(loader)

    override fun clearScreen() {
        surface.clearRectangles()
        surface.updateInstructions { _, _ -> }
    }

    override fun drawMainMenu(actions: ActionList) {

        //Prepare rectangles
        surface.clearRectangles()
        val percentage = 1.0f / actions.size
        actions.forEachIndexed { i, a ->
            surface.addRectangle(a, i * percentage, 0.45f, i * percentage + percentage, 1f)
        }

        //Prepare instructions
        surface.updateInstructions { c, p ->
            val canvasWidth = c.width
            val canvasHeight = c.height
            val buttonTop = canvasHeight * 0.45f
            val buttonWidth = canvasWidth / actions.size
            val padding = canvasHeight / 144f

            //Draw title text
            p.textSize = canvasHeight / 3f
            p.strokeWidth = 0f
            p.color = Color.parseColor("#ff808000")
            p.isFakeBoldText = true
            c.drawText(
                "DICE",
                (canvasWidth - p.measureText("DICE")) / 2f,
                (buttonTop - p.ascent() - p.descent()) / 2f,
                p
            )
            p.isFakeBoldText = false

            //Draw action buttons
            p.textSize = canvasHeight / 24f
            actions.forEachIndexed { i, a ->
                p.color = if (a.isEnabled) Color.YELLOW else Color.LTGRAY
                p.strokeWidth = canvasHeight / 240f
                c.drawRect(
                    i * buttonWidth + padding,
                    buttonTop + padding,
                    i * buttonWidth + buttonWidth - padding,
                    canvasHeight - padding,
                    p
                )
                val name = mergeActionData(helper.loadActionData(a))
                p.strokeWidth = 0f
                c.drawText(
                    name,
                    i * buttonWidth + (buttonWidth - p.measureText(name)) / 2f,
                    (canvasHeight + buttonTop - p.ascent() - p.descent()) / 2f,
                    p
                )
            }

        }
    }

    private fun mergeActionData(data: Array<String>) = if (data.size > 1) {
        if (data[1].first().isLowerCase()) data[0] + data[1] else data[1]
    } else data.getOrNull(0) ?: ""
}

Здесь мы вновь вернулись к интерфейсу StringLoader и возможностям вспомогательного класса StringLoadHelper (не представлен на диаграмме). Реализация первого имеет название ResourceStringLoader и занимается загрузкой локализованных строк из (очевидно) ресурсов приложения. Однако делает это динамически, поскольку идентификаторы ресурсов нам заранее не известны — их мы вынуждены конструировать на ходу.

class ResourceStringLoader(context: Context) : StringLoader {

    private val packageName = context.packageName
    private val resources = context.resources

    override fun loadString(key: String): String =
        resources.getString(resources.getIdentifier(key, "string", packageName))
}

Осталось рассказать про звуки и музыку. В андроиде есть замечательный класс MediaPlayer, который как раз и занимается этими вещами. Ничего лучше для проигрывания музыки не найти:

class DroidMusicPlayer(private val context: Context): MusicPlayer {

    private var currentMusic: Music? = null
    private val player = MediaPlayer()

    override fun play(music: Music) {
        if (currentMusic == music) {
            return
        }
        currentMusic = music

        player.setAudioStreamType(AudioManager.STREAM_MUSIC)
        val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3")
        player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
        player.setOnCompletionListener {
            it.seekTo(0)
            it.start()
        }
        player.prepare()
        player.start()
    }

    override fun stop() {
        currentMusic = null
        player.release()
    }

}

Два замечания. Во-первых, метод prepare() выполняется синхронно, что при большом размере файла (ввиду буферизации) будет подвешивать систему. Рекомендуется либо запускать его в отдельном потоке, либо использовать асинхронный метод prepareAsync() и OnPreparedListener. Во-вторых, хорошо бы связать воспроизведение с жизненным циклом активности (приостанавливать, когда пользователь сворачивает приложение и возобновлять при восстановлении), но мы этого не сделали. Ай-ай-ай…

Для звуков MediaPlayer тоже подойдет, но если их мало и они простые (как в нашем случае), подойдет и SoundPool. Преимущество его состоит в том, что когда звуковые файлы уже загружены в память, их воспроизведение начинается мгновенно. Недостаток очевиден — памяти может не хватить (но нам хватит, мы скромные).

class DroidSoundPlayer(context: Context) : SoundPlayer {

    private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100)
    private val sounds = mutableMapOf<Sound, Int>()
    private val rate = 1f

    private val lock = ReentrantReadWriteLock()

    init {
        Thread(SoundLoader(context)).start()
    }

    override fun play(sound: Sound) {
        if (lock.readLock().tryLock()) {
            try {
                sounds[sound]?.let { s ->
                    soundPool.play(s, 1f, 1f, 1, 0, rate) 
                }
            } finally {
                lock.readLock().unlock()
            }
        }
    }

    private inner class SoundLoader(private val context: Context) : Runnable {

        override fun run() {
            val assets = context.assets
            lock.writeLock().lock()
            try {
                Sound.values().forEach { s ->
                    sounds[s] = soundPool.load(
                        assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1
                    )
                }
            } finally {
                lock.writeLock().unlock()
            }
        }

    }
}

При создании класса все звуки из перечисления Sound загружаются в хранилище в отдельном потоке. В этот раз мы не используем синхронизированную коллекцию, но реализуем мьютекс при помощи стандартного класса ReentrantReadWriteLock.

Теперь наконец-то слепим все компоненты воедино внутри нашей MainActivity — не забыли о такой? Обратите внимание, что MainMenu (да и Game впоследствии) должен запускаться в отдельном потоке.

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this))

        val surface = DiceSurface(this)
        val renderer = DroidMenuRenderer(surface)
        val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this))

        setContentView(surface)

        Thread {
            MainMenu(renderer, interactor).start()
            finish()
        }.start()
    }

    override fun onBackPressed() {
    }
}

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

Главное меню во всю ширь мобильного экрана


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

Полезные ссылки


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


Ну и вдруг у кого-то появится желание запустить и посмотреть проект, а самостоятельно собирать его лень, вот ссылка на рабочую версию: ССЫЛКА!

Здесь для запуска используется удобный launcher (о создании которого вполне можно отдельную статью написать). Он использует JavaFX и потому может не запуститься на машинах с OpenJDK (пишите — поможем), но по крайней мере избавляет от необходимости вручную прописывать пути к файлам. Справка по установке содержится в файле readme.txt (помните такие?). Скачивайте, смотрите, пользуйтесь, а я наконец умолкаю.

Если вас заинтересовал проект, или используемый инструмент, или механики, или какое-то интересное решение, или, я не знаю, lore игры, можно подробнее рассмотреть его в отдельной статье. Если хотите. А если не хотите, то просто присылайте замечания, пожаления и предложения. Буду рад пообщаться.

Всего хорошего.
Теги:
Хабы:
+105
Комментарии 71
Комментарии Комментарии 71

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн