Хабрахабр

[Из песочницы] Как создать игру, если ты ни разу не художник

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

И не надо…

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

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

Лирическое отступление про игру мечты

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

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

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

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

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

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

В смысле, велосипед изобретать? Зачем это делать? Ответ прост: мы ничего про него не знаем, а игру хотим уже сейчас. Почему бы не использовать готовый игровой движок? Там будет мясо, и взрывы, и прокачка, и можно грабить корованы, и сюжет бомбезный, и такого вообще никогда и нигде больше не было! Представьте образ мысли среднестатистического программиста: «Хочу делать игру! А на чем? Начну писать прямо сейчас!.. Возьмем Z, на нем сейчас все пишут...». Посмотрим, что у нас сейчас популярно… Ага, X, Y и 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. Библиотеки (без них никуда не деться) будем выбирать соответственно нашему требованию кроссплатформенности. Или все ж таки Maven, начнем с него. Или Gradle. 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>$</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 — весьма дешевая операция. Не пытайтесь сразу делать идеально: продумывать до мелочей названия пакетов, интерфейсов, классов и методов; досконально прописывать взаимодействие объектов между собой — все это будет меняться, и не один десяток раз.

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

Во-первых, по умолчанию в состав этого архива не входят зависимоти, необходимые для работы проекта (пока что их у нас нет, но в будущем обязательно появятся). После выполнения операции mvn package мы получим на выходе JAR-архив со всеми скомилированными классами. 0.jar у нас не выйдет. Во-вторых, в файле-манифесте архива не прописан путь к главному классу, содержащему метод main, поэтому запустить проект командой java -jar dice-1. Исправим это, добавив дополнительные настройки в 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-файла.

0.jar и dice-1. Теперь при сборке проекта мы будем получать на выходе два 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) и оппонент (которого может и не быть). Также предусмотрена куча методов для получения пар, участвующих в проверке, и результатов их бросков (если они есть).

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

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

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

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

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

Их названия говорят сами за себя, потому не будем подробно на них останавливаться. Также класс содержит несколько вспомогательных методов: 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-ти доступных цветов однозначно идентифицируется единственной константой. Напишем еще пару служебных методов, но перед этим разберемся вот еще с чем:

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

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

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

# 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 не сразу научился распознавать последовательности (несмотря на имеющийся спрос). Если же вам не повезло, не расстраивайтесь — всегда есть альтернативные решения (вот это, например). А мы двигаемся дальше.

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

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

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

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

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

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

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

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

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

А пока идей нет, вот мелкая (или крупная, смотря как посмотреть) хитрость, позволяющая динамически добавлять зависимости в 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:

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. Сделаем это.

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

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, для которых напишим новые, специфичные для андроида реализации. Но перед этим прикинем, как вообще будет происходить взаимодействие пользователя с нашей новой системой.

Для этого нам достаточно создать один-единственный наследник класса View — это и будет наш «холст». Для отрисовки элементов интерфейса мы не станем использовать стандартные компоненты (кнопки, картинки, поля для ввода итп) Android — вместо этого ограничимся возможностями класса Canvas. Для этого воспользуемся все тем же наследником 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 — и производить отрисовку в отдельном потоке, но игра у нас пошаговая, бедная на анимации, сложного графического вывода не требующая, потому не станем усложнять). Рассмотрим их по порядку.

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

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

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. То есть методы интерфейса Interactor по-прежнему приостанавливают работу движка и ожидают команд, в то время как Activity и все ее View продолжают работать, пока рано или поздно эту команду не отправят. С Андроидом такой трюк не пройдет — у него есть свой Looper, работу которого мы ни в коем случае не должны нарушать, а значит ввод должен быть асинхронным.

Класс DroidMenuInteractor будет вызывать метод take(), который приостановит выполнение игрового потока до тех пор, пока в очереди не появятся элементы (экземпляры знакомого нам класса Action). Такой подход достаточно просто реализовать при помощи стандартного интерфейса BlockingQueue. Выглядеть это будет следующим образом: 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. Такие прямоугольники содержат координаты вершин и подвязанный Action. Класс DiceSurface будет хранить специальную коллекцию — список активных прямоугольников (или других фигур, если мы когда-нибудь до этого дорастем). 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 в случае, если набор будет одновременно обновляться и перебираться разными потоками (что в нашем случае обязательно произойдет).

Добавим в отображение четыре кнопки для каждого элемента ActionList. Код класса DroidMenuInteractor останется без изменений, а вот DroidMenuRenderer изменится. Ну и об активных прямоугольниках не забудем. Расположим их под заголовком 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.

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

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() { }
}

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

Главное меню во всю ширь мобильного экрана

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

Полезные ссылки

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

Он использует JavaFX и потому может не запуститься на машинах с OpenJDK (пишите — поможем), но по крайней мере избавляет от необходимости вручную прописывать пути к файлам. Здесь для запуска используется удобный launcher (о создании которого вполне можно отдельную статью написать). Скачивайте, смотрите, пользуйтесь, а я наконец умолкаю. Справка по установке содержится в файле readme.txt (помните такие?).

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

Всего хорошего.

Теги