В жизни каждого программиста бывали моменты, когда он мечтал сделать интересную игру. Многие программисты эти мечты реализовывают, и даже успешно, но речь сейчас не о них. Речь о тех, кто любит играть в игры, кто (даже не имея знаний и опыта) и сам пытался их когда-то создавать, вдохновляясь примерами героев-одиночек, добившихся всемирной известности (и огромных прибылей), но в глубине души понимал, что тягаться с гуру игростроя ему не по силам.
И не надо…
Небольшое вступление
Сразу оговорюсь: нашей целью не является зарабатывание денег — на Хабре полно статей на эту тему. Нет, мы будем делать игру мечты.
Люди, не обманывайте себя. Вы делаете не игру мечты, а игру, которая будет хорошо продаваться — это разные вещи. Игрокам (а особенно искушенным) нет дела до вашей мечты и платить за нее они не будут. Хотите прибылей — изучайте тренды, смотрите, что сейчас популярно, делайте что-то уникальное, делайте лучше, необычнее, чем у других, читайте статьи (их много), общайтесь с издателями — в общем, реализовывайте мечты конечных пользователей, не свою.
Если вы еще не сбежали и все же хотите реализовать игру мечты, заранее откажитесь от прибылей. Вообще не продавайте мечту — делитесь ей даром. Дарите людям свою мечту, приобщайте их к ней, и если ваша мечта чего-то стоит, вы получите пусть не деньги, но любовь и признание. Иногда это намного ценнее.
Многие считают, что игры — пустая трата времени и сил, и что серьезные люди не должны вообще на эту тему разговаривать. Но здесь собрались люди несерьезные, поэтому согласимся лишь отчасти — игры действительно отбирают много времени, если в них играть. Однако разработка игр, хоть и занимает во много раз больше времени, способна принести немало пользы. Например, позволяет познакомиться с принципами, подходами и алгоритмами, не встречающимися в разработке неигровых приложений. Или углубить навыки владения инструментами (например, языком программирования), занимаясь чем-то необычным и увлекательным. От себя могу добавить (и многие согласятся), что разработка игр (даже неудачная) — это всегда особый, ни с чем не сравнимый опыт, о котором потом вспоминаешь с трепетом и любовью, чего каждому разработчику хотя бы раз в жизни желаю испытать.
Мы не будем использовать новомодные игровые движки, фреймворки, библиотеки — мы заглянем в самую суть игрового процесса и прочувствуем его изнутри. Откажемся от гибких методологий разработки (задача упрощается необходимостью организовать работу всего одного человека). Мы не будем тратить время и силы на поиски дизайнеров, художников, композиторов и спецов по звуку — мы все сделаем сами, как умеем (но при этом сделаем все по-умному — если вдруг художник у нас появится, нам не составит особых усилий прикрутить модную графику на готовый каркас). В конце концов, мы даже не будем особо изучать инструментарий и выбирать подходящий — сделаем на том, который хорошо знаем и умеем пользовать. Например, на Java, чтоб потом, если нужно, перенести на Андроид (или на кофеварку).
«А!!! Ужас! Кошмар! Как на такую чушь вообще можно время тратить! Проваливай отсюда, я пойду что-то более интересное почитаю!»
Зачем это делать? В смысле, велосипед изобретать? Почему бы не использовать готовый игровой движок? Ответ прост: мы ничего про него не знаем, а игру хотим уже сейчас. Представьте образ мысли среднестатистического программиста: «Хочу делать игру! Там будет мясо, и взрывы, и прокачка, и можно грабить корованы, и сюжет бомбезный, и такого вообще никогда и нигде больше не было! Начну писать прямо сейчас!.. А на чем? Посмотрим, что у нас сейчас популярно… Ага, X, Y и Z. Возьмем Z, на нем сейчас все пишут...». И начинает изучать движок. А идею бросает, потому что на нее уже времени не хватает. Fin. Или ладно, не бросает, но толком не изучив движок, принимается за игру. Хорошо, если потом ему хватит совести никому не показывать свою первую «поделку». Обычно нет (зайдите в любой магазин приложений, посмотрите сами) — ну как же, хочется прибылей, нет сил терпеть. Когда-то создание игр было уделом увлеченных творческих людей. Увы, это время безвозвратно прошло — сейчас в игре главное не душа, а бизнес-модель (по крайней мере, разговоров о ней на порядок больше). У нас же цель простая: мы будем делать игры с душой. Потому абстрагируемся от инструмента (подойдет любой) и сосредоточимся на задаче.
Итак, продолжим.
Не буду вдаваться в подробности собственного горького опыта, но скажу, что одна из основных проблем для программиста при разработке игр — это графика. Рисовать программисты обычно не умеют (хотя бывают исключения), а художники обычно не умеют программировать (хотя бывают исключения). А без графики, согласитесь, редкая игра обходится. Что же делать?
Варианты есть:
Остановимся подробнее на последнем (отчасти потому что он выглядит не так уныло как остальные). Многие неопытные геймеры считают, что игры без крутой современной графики не способны покорить сердца игроков — их даже играми-то назвать язык не поворачивается. Подобным аргументам молчаливо возражают разработчики таких шедевров, как ADOM, NetHack и Dwarf Fortress. Внешний вид не всегда является решающим фактором, использование же ASCII дает некторые интересные примущества:
- в процессе разработки программист сосредотачивается на геймплее, игровой механике, сюжетной составляющей и прочем, не отвлекаясь на второстепенные вещи;
- разработка графической составляющей не отнимает слишком много времени — рабочий прототип (то есть, версия, поиграв в которую можно понять, а стоит ли вообще продолжать) будет готов намного раньше;
- не нужно осваивать фреймворки и графические движки;
- ваша графика не устареет за те пять лет, которые вы будете разрабатывать игру;
- хардкорщики смогут оценить ваш продукт даже на платформах, не имеющих графической среды;
- если все сделать правильно, то крутую графику можно прикрутить потом, попозже.
Приведенное выше длинное вступление имело целью помочь начинающим игроделам побороть страхи и предрассудки, перестать волноваться и все ж таки попробовать что-нибудь эдакое сотворить. Готовы? Тогда приступим.
Шаг первый. Идея
Как? У вас все еще нет идеи?
Выключайте компьютер, пойдите покушайте, погуляйте, спортом позанимайтесь. Или поспите, на худой конец. Придумать игру это не окна помыть — озарение в процессе не приходит. Обычно идея игры рождается вдруг, неожиданно, когда вы совершенно об этом не думаете. Если такое вдруг произошло, быстрее хватайте карандаш и записывайте, пока идея не улетела. Любой творческий процесс именно так и реализуется.
А еще можно копировать чужие игры. Ну как, копировать. Конечно, не драть безбожно, рассказывая на каждом углу, какой вы сообразительный, но использовать чужие наработки в своем продукте. Как много после этого в нем останется конерктно от вашей мечты — вопрос второстепенный, ибо частенько у геймеров бывает так: вот все нравится в игре, кроме каких-то двух-трех раздражающих вещей, а вот если бы тут сделать по-другому… Кто знает, возможно, доведение до ума чьей-то хорошей идеи — это и есть ваша мечта.
Но мы пойдем простым путем — предположим, что идея у нас уже есть, и мы над ней долго не думали. В качестве первого нашего грандиозного проекта будем делать клон хорошей игры от Obsidian — Pathfinder Adventures.
«Это что еще за бред! Настолки какие-то?»
Как говорится, pourquoi pas? Предрассудки мы, кажись, уже оставили, а потому смело начинаем отшлифовывать идею. Естественно, клонировать игру один к одну мы не будем, но основные механики позаимствуем. К тому же реализация пошаговой настольной кооперативной игры имеет свои преимущества:
- она пошаговая — это позволяет не заботиться о таймерах, синхронизации, оптимизации, FPS и прочих муторных вещах;
- она кооперативная, то есть игрок или игроки соревнуются не друг против друга, а против некоего «окружения», играющего по детерминированным правилам — это избавляет от необходимости программировать ИИ (AI) — одного из самых сложных этапов разработки игр;
- она осмысленная — настолщики вообще люди прихотливые, во что попало играть не будут: им подавай продуманные механики и интересный геймплей — на одной красивой картинке не выедешь (чем-то знакомым отдает, не так ли?);
- она с сюжетом — многие киберспортсмены не согласятся, но лично для меня игра должна рассказывать интересную историю — как книга, только с использованием своих особых художественных средств.
- она занятная, что на любителя — описываемые подходы можно будет применить к любой последующей мечте, сколько бы их у вас ни было.
В каждый сценарии предусмотрен ряд местностей (локаций — их количество зависит от количества игроков), которые игрокам нужно посетить и исследовать. Каждая локация содержит колоду карт, лежащую рубашкой вверх, которую персонажи в свой ход исследуют — то есть открывают верхнюю карту и пытаются по соответствующим правилам ее преодолеть. В этих колодах помимо безобидных карт, пополняющих колоду игрока, имеются также злые враги и препятствия — их необходимо победить, чтобы продвинуться дальше. Карта Негодяя тоже лежит в одной из колод, но игроки не знают в какой именно — ее нужно найти.
Для победы над картами (и для приобретения новых) персонажи должны пройти проверку одной из своих характеристик (стандартные для РПГ сила, ловкость, мудрость итп), кинув кубик, размер которого определяется значением соответствующей характеристики (от d4 до d12), добавив модификаторы (определяемые правилами и уровнем развития персонажа) и играя для усиления эффекта походящие карты из руки. При победе встреченная карта либо убирается из игры (если это враг), или пополняет руку игрока (если это предмет) и ход переходит к другому игроку. При проигрыше персонажу часто наносится урон, заставляющий его сбрасывать карты из руки. Интересная механика состоит в том, что здоровье персонажа определяется количеством карт в его колоде — как только игроку нужно вытащить из колоды карту, а их нет — его персонаж погибает.
Целью же является, пробравшись через карты локаций, найти и победить Негодяя, предварительно перекрыв ему пути к отступлению (подробнее об этом и много другом можно узнать, почитав правила). Сделать же это нужно на время, в чем состоит основная сложность игры. Количество ходов строго ограничено и простым перебором всех имеющихся карт цели не достичь. Потому приходится применять различные ухищрения и умные техники.
По мере выполнения сценариев персонажи будут расти и развиваться, улучшая свои характеристики и приобретая новые полезные навыки. Управление колодой также является очень важным элементом игры, так как исход сценария (особенно на поздних этапах) как правило зависит от правильно подобранных карт (и от кучи везения, но чего вы хотите от игры с кубиками?).
В целом, игра интересная, достойная, заслуживающая внимания и, что важно для нас, достаточно сложная (обратите внимание, я говорю «сложная» не в значении «трудная»), чтобы ее клон было интересно реализовывать.
В нашем случае сделаем одно глобальное концептуальное изменение — откажемся от карт. Вернее, не откажемся вовсе, но заменим карты на кубики, по-прежнему разных размеров и разных цветов (технически, не совсем корректно навывать их «кубики», так как кроме правильного шестигранника присутствуют и другие формы, но называть их «кости» мне непривычно и неприятно, а пользоваться американизмом «дайсы» — и вовсе признак дурного тона, потому оставим как есть). Теперь вместо колод у игроков будут мешочки. И у локаций тоже будут лежать мешочки, из которых игроки в процессе исследования будут вытаскивать произвольные кубики. Цвет кубика будет определять его тип и, соответственно, правила прохождения проверки. Личные характеристики персонажа (сила, ловкость итп), как следствие, упразднятся, но зато появятся новые интересные механики (о чем позже).
Будет ли в это интересно играть? Понятия не имею, и никто этого понять не сможет, пока не будет готов рабочий прототип. Но ведь мы получаем удовольствие не от игры, а от разработки, правда? Потому никаких сомнений в успехе быть не должно.
Шаг второй. Дизайн
Иметь идею — это только треть дела. Теперь важно эту идею развить. То есть не прогуливаться в парке или париться в баньке, а сесть за стол, взять бумагу с ручкой (или открыть любимый текстовый редактор) и вдумчиво написать дизайн-документ, кропотливо прорабатывая каждый аспект игровой механики. Времени на это уйдет прорва, поэтому не рассчитывайте завершить написание в один присест. И даже не надейтесь полностью все продумать с одного раза — по мере реализации вы увидите необходимость сделать кучу правок и изменений (а иногда и глобально что-то переработать), однако какая-то основа должна обязательно присутствовать до начала процесса разработки.
И только справившись с первой волной грандиозных идей вы возьметесь за голову, определитесь со структурой документа и начнете методично наполнять его содержимым (ежесекундно сверяясь с уже написанным, дабы избежать ненужных повторений и особенно противоречий). Постепенно, шаг за шагом получится что-то осмысленное и лаконичное, вроде этого.
При описании дизайна выбирайте тот язык, на котором вам легче излагать свои мысли, особенно если вы работаете в одиночку. Если же когда-либо понадобится привлекать к проекту сторонних разработчиков, убедитесь, что они понимают весь тот творческий бред, который творится у вас в голове.
Для продолжения настоятельно рекомендую прочитать приведенный документ хотя-бы по-диагонали, потому что в дальнейшем я буду ссылаться на представленные там термины и концепции, подробно не задерживаясь на их толковании.
«Автор, убей себя об стену. Слишком много букв.»
Шаг третий. Моделирование
То есть, все тот же design, только более подробный.
Знаю, многим уже не терпится открыть IDE и начать кодить, но потерпите еще немного. Когда идеи переполняют нашу голову, нам кажется, что стоит лишь прикоснуться к клавиатуре, и руки сами понесутся в заоблачные дали — не успеет кофе вскипеть на плите, как рабочая версия приложения уже будет готова… отправиться в мусор. Чтобы много раз не переписывать одно и то же (а особенно чтобы не убеждаться через три часа разработки, что макет нерабочий и нужно начинать заново), предлагаю для начала хорошенько продумать (и задокументировать) основную структуру приложения.
Поскольку мы, как разработчики, хорошо знакомы с объектно-ориентированным программированием (ООП), будем использовать его принципы в нашем проекте. А для ООП нет ничего более ожидаемого, чем начать разработку с кучи нудных UML-диаграм. (Как, вы не знаете, что такое UML? Я тоже уже почти забыл, но с радостью вспомню — просто чтобы показать, какой я прилежный программист, хе-хе.)
Начнем, пожалуй, с диаграммы «вариантов использования» (use-case). Изобразим на ней способы взаимодействия нашего пользователя (игрока) с будущей системой:
«Э… это что вообще?»
Шучу-шучу… и, пожалуй, на этом прекращаю шутить — дело-то серьезное (мечта, как-никак). На диаграмме вариантов использования необходимо отобразить возможности, которые система предоставляет пользователю. В подробностях. Но так уж исторически сложилось, что именно данный тип диаграмм получается у меня хуже всего — терпения не хватает, судя по всему. И не надо на меня так смотреть — мы не в ВУЗе диплом защищаем, а получаем удовольствие от рабочего процесса. И для данного процесса не так важны варианты использования. Гораздо важнее грамотно разбить приложение на независимые модули, то есть реализовать игру таким образом, чтобы особенности визуального интерфейса не влияли на игровые механики, и чтобы графическую составляющую при желании можно было легко изменить.
Этот момент можно детализировать на следующей диаграмме компонентов (components):
Здесь мы уже выделили конкретные подсистемы, входящие в состав нашего приложения и, как будет показано дальше, все они будут разрабатываться независимо друг от друга.
Также, на этом же этапе прикинем, как будет выглядеть основной игровой цикл (вернее, его наиболее интересная часть — та самая, которая реализует прохождение персонажами сценария). Для этого нам подойдет диаграмма деятельности (activity):
Ну и напоследок неплохо бы представить в общем виде последовательность (sequence) взаимодействия конечного пользователя с игровым движком, посредством системы ввода-вывода.
Ночь длинна, до рассвета еще далеко. Посидев как следует за столом, вы спокойно нарисуете остальные два десятка диаграмм — поверьте, в дальнейшем их наличие поможет не сбиться с выбранного пути, повысить свою самооценку, обновить интерьер комнаты, завесив выцветшие обои цветастыми плакатами, а также в простых выражениях донести ваше видение до коллег-разработчиков, которые в скором времени толпами прибегут к дверям вашей новой студии (мы нецелены на успех, помните?).
Любимые всеми диаграммы классов (class) приводить пока что не станем — классов ожидается прорва уйма и картинка в три экрана ясности на первых порах не добавит. Лучше разбить ее на части и выкладывать постепенно, по мере перехода к разработке соответствующей подсистемы.
Шаг четвертый. Выбор инструментов
Как уже было условлено, разрабатывать будем кроссплатформенное приложение, работающее как на десктопах под управлением различных операционных систем, так и на мобильных устройствах. В качестве языка программирования выберем Java, а еще лучше Kotlin, так как последний более нов и свеж, и еще не успел искупаться в волнах негодования, с головой захлестнувших его предшественника (заодно подучим, если кто еще не владеет). JVM, как вы знаете, доступен везде и всюду (на трех миллиардах устройств, хе-хе), будем поддерживать и Windows, и UNIX, и даже на удаленном сервере через SSH-подключение можно будет играть (кому это может понадобиться — неизвестно, но возможность такую предоставим). На Андроид тоже перенесем, когда разбогатеем и наймем художника, но об этом позже.
Библиотеки (без них никуда не деться) будем выбирать соответственно нашему требованию кроссплатформенности. В качестве системы сборки будем использовать Maven. Или Gradle. Или все ж таки Maven, начнем с него. Сразу советую настроить систему контроля версий (любую, какая больше нравится), чтобы легче было через много лет с ностальгическими чувствами вспоминать, как было здорово когда-то. IDE тоже выбирайте привычную, любимую и удобную.
Собственно, больше нам ничего и не нужно. Можно приступать к разработке.
Шаг пятый. Создание и настройка проекта
Если вы используете IDE, то создать проект — дело тривиальное. Нужно только выбрать для нашего будущего шедевра какое-то звучное имя (например, Dice), не забыть включить поддержку Maven в настройках, и в файле pom.xml
прописать необходимые идентификаторы:
<modelVersion>4.0.0</modelVersion>
<groupId>my.company</groupId>
<artifactId>dice</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
Также добавим поддержку Kotlin, по умолчанию отсутствующую:
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
и некоторые настройки, на которых не станем подробно останавливаться:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<kotlin.version>1.3.20</kotlin.version>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
</properties>
src/main/kotlin
у вас также будет присутствовать папка src/main/java
. Разработчики языка Kotlin утверждают, что исходные файлы из первой папки (*.kt
) должны компилироваться раньше, чем исходные файлы из второй (*.java
) и потому настоятельно рекомендуют изменить настройки стандартных целей Maven:
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<executions>
<!-- Replacing default-compile -->
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<!-- Replacing default-testCompile -->
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Насколько это важно, сказать не могу — проекты вполне неплохо собираются и без этой простыни. Но на всякий случай вы предупреждены.
Создадим сразу три пакета (чего мелочиться-то?):
model
— для классов, описывающих объекты игрового мира;game
— для классов, реализующих игровой процесс;ui
— для классов, отвечающих за взаимодействие с пользователем.
Последний будет содержать лишь интерфейсы, к методам которых мы будем обращаться для ввода и вывода данных. Конкретные реализации будем хранить вообще в отдельном проекте, но об этом позже. Пока же, чтобы сильно не распыляться, эти классы будем складывать здесь же, рядышком.
Не пытайтесь сразу делать идеально: продумывать до мелочей названия пакетов, интерфейсов, классов и методов; досконально прописывать взаимодействие объектов между собой — все это будет меняться, и не один десяток раз. По мере развития проекта многие вещи будут казаться вам некрасивыми, громоздкими, неэффективными и тому подобное — смело меняйте их, благо рефакторинг в современных IDE — весьма дешевая операция.
Создадим также класс c функцией main
и мы готовы к великим свершениям. Для запуска можно использовать саму IDE, но как вы в дальнейшем убедитесь, для наших целей этот способ не подходит (стандартная консоль IDE не способна как следет отобразить наши графические изыскания), потому настроим запуск извне, про помощи batch (или shell в системах UNIX) файла. Но перед этим, сделаем кое-какие дополнительные настройки.
После выполнения операции mvn package
мы получим на выходе JAR-архив со всеми скомилированными классами. Во-первых, по умолчанию в состав этого архива не входят зависимоти, необходимые для работы проекта (пока что их у нас нет, но в будущем обязательно появятся). Во-вторых, в файле-манифесте архива не прописан путь к главному классу, содержащему метод main
, поэтому запустить проект командой java -jar dice-1.0.jar
у нас не выйдет. Исправим это, добавив дополнительные настройки в pom.xml
:
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>my.company.dice.MainKt</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
Обратите внимание на название главного класса. Для функций Kotlin, содержащихся вне классов (как, например, функции main
) при компиляции все равно создаются классы (потому как JVM ничего другого не знает и знать не желает). В качестве имени этого класса используется имя файла с добавкой Kt
. То есть, если главный класс вы назвали Main
, то скомпилирован он будет в файл MainKt.class
. Именно этот последний мы и должны указывать в манифесте jar-файла.
Теперь при сборке проекта мы будем получать на выходе два jar-файла: dice-1.0.jar
и dice-1.0-jar-with-dependencies.jar
. Нас интересует второй. Напишем для него скрипт запуска.
dice.bat (для Windows)
@ECHO OFF
rem Compiling
call "path_to_mavenmvn.bat" -f "path_to_projectDicepom.xml" package
if errorlevel 1 echo Project compilation failed! & pause & goto :EOF
rem Running
java -jar path_to_projectDicetargetdice-1.0-jar-with-dependencies.jar
pause
dice.sh (для UNIX)
#!/bin/sh
# Compiling
mvn -f "path_to_project/Dice/pom.xml" package
if [[ "$?" -ne 0 ]] ; then
echo 'Project compilation failed!'; exit $rc
fi
# Running
java -jar path_to_project/Dice/target/dice-1.0-jar-with-dependencies.jar
Обратите внимание, при неудачной компиляции мы вынуждены прервать выполнение скрипта. Иначе будет запущена не последний арфив, а файл, оставшийся от предыдущей успешной сборки (иногда мы и разницу-то не обнаружим). Часто разработчики используют команду mvn clean package
для удаления всех скомпилированных ранее файлов, но в этом случае весь процесс компиляции всегда будет начинаться с самого начала (даже если исходный код не менялся), что займет уйму времени. А ждать мы не можем — нам игру нужно делать.
Итак, проект отлично запускается, но пока что ничего не делает. Не волнуйтесь, в скором времени мы это исправим.
Шаг шестой. Основные объекты
Постепенно начнем наполнять пакет model
необходимыми для игрового процесса классами.
Кубики — наше все, добавим их в первую очередь. Каждый кубик (экземпляр класса Die
) характеризуется типом (цветом) и размером. Для типов кубика заведем отдельное перечисление (Die.Type
), размер отметим целым числом от 4 до 12. Также реализуем метод roll()
, который будет выдавать произвольное, равномерно распределенное число из доступного кубику диапазона (от 1 до значения размера включительно).
Класс реализует интерфейс Comparable
, чтобы кубики можно было сравнивать между собой (пригодится позже, когда будем отображать несколько кубиков в упорядоченном ряду). Кубики большего размера будут располагаться раньше.
class Die(val type: Type, val size: Int) : Comparable<Die> {
enum class Type {
PHYSICAL, //Blue
SOMATIC, //Green
MENTAL, //Purple
VERBAL, //Yellow
DIVINE, //Cyan
WOUND, //Gray
ENEMY, //Red
VILLAIN, //Orange
OBSTACLE, //Brown
ALLY //White
}
fun roll() = (1.. size).random()
override fun toString() = "d$size"
override fun compareTo(other: Die): Int {
return compareValuesBy(this, other, Die::type, { -it.size })
}
}
Чтобы не пылились, кубики хранятся в сумочках (экземплярах класса Bag
). О том, что творится внутри сумки, можно лишь догадываться, потому нет смысла использовать упорядоченную коллекцию. Вроде бы. Наборы (sets) хорошо реализуют нужную нам идею, но не подходят по двум причинам. Во-первых, при их использовании придется реализовывать методы equals()
и hashCode()
, причем непонятно каким образом, так как сравнивать типы и размеры кубиков неверно — в нашем наборе может храниться любое количество идентичных кубиков. Во-вторых, вытягивая кубик из сумки, мы ожидаем получить не просто что-то недетерминированное, но случайное, каждый раз разное. Потому советую все же использовать упорядоченную коллекцию (список) и перемешивать ее каждый раз при добавлении нового элемента (в методе put()
) или непосредственно перед выдачей (в методе draw()
).
Метод examine()
подойдет для случаев, когда уставший от неопределенности игрок в сердцах вытряхнет содержимое сумки на стол (обратите внимание на сортировку), а метод clear()
— если вытряхнутые кубики больше в сумку не вернутся.
open class Bag {
protected val dice = LinkedList<Die>()
val size
get() = dice.size
fun put(vararg dice: Die) {
dice.forEach(this.dice::addLast)
this.dice.shuffle()
}
fun draw(): Die = dice.pollFirst()
fun clear() = dice.clear()
fun examine() = dice.sorted().toList()
}
Помимо сумок с кубиками, нужны также кучи с кубиками (экземпляры класса Pile
). От первых вторые отличаются тем, что их содержимое видно игрокам, а потому при необходимости достать из кучи кубик, игрок может выбрать конкретный интересующий экземпляр. Эту идею реализуем методом removeDie()
.
class Pile : Bag() {
fun removeDie(die: Die) = dice.remove(die)
}
Теперь перейдем к нашим главным действующим лицам — героям. То бишь, персонажам, которых отныне будем называть героями (есть весомая причина не называть свой класс именем Character
в Java). Герои бывают разных типов (сиречь классов, хотя слово class
лучше тоже не использовать), но для нашего рабочего прототипа возьмем лишь два: Brawler (то есть, Fighter с упором на стойкость и силу) и Hunter (он же Ranger/Thief, с упором на ловкость и скрытность). Класс героя определяет его характеристики, умения и начальный набор кубиков, но как будет позже видно, строгой привязки к классам герои иметь не будут, а потому их персональные настройки можно будет с легкостью менять в одном-единственном месте.
Добавим герою необходимые свойства в соответствии с дизайн-документом: имя, любимый тип кубика, лимиты кубиков, навыки изученные и неизученные, руку, сумку и кучу для сброса. Обратите внимание на особенности реализации свойств-коллекций. Во всем цивилизованном мире считается дурным тоном предоставлять наружу доступ (при помощи getter'а) к коллекциям, хранящимся внутри объекта — недобросовестные программисты смогут без ведома класса менять содержимое этих коллекций. Один из способов борьбы с этим — реализовывать отдельные методы для добавления и удаления элементов, получения их количества и доступа по индексу. Можно и getter реализовать, но при этом возвращать не саму коллекцию, а ее неизменяемую копию — для небольшого количества элементов не особо страшно именно так и поступить.
data class Hero(val type: Type) {
enum class Type {
BRAWLER
HUNTER
}
var name = ""
var isAlive = true
var favoredDieType: Die.Type = Die.Type.ALLY
val hand = Hand(0)
val bag: Bag = Bag()
val discardPile: Pile = Pile()
private val diceLimits = mutableListOf<DiceLimit>()
private val skills = mutableListOf<Skill>()
private val dormantSkills = mutableListOf<Skill>()
fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit)
fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits)
fun addSkill(skill: Skill) = skills.add(skill)
fun getSkills(): List<Skill> = Collections.unmodifiableList(skills)
fun addDormantSkill(skill: Skill) = dormantSkills.add(skill)
fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills)
fun increaseDiceLimit(type: Die.Type) {
diceLimits.find { it.type == type }?.let {
when {
it.current < it.maximal -> it.current++
else -> throw IllegalArgumentException("Already at maximum")
}
} ?: throw IllegalArgumentException("Incorrect type specified")
}
fun hideDieFromHand(die: Die) {
bag.put(die)
hand.removeDie(die)
}
fun discardDieFromHand(die: Die) {
discardPile.put(die)
hand.removeDie(die)
}
fun hasSkill(type: Skill.Type) = skills.any { it.type == type }
fun improveSkill(type: Skill.Type) {
dormantSkills
.find { it.type == type }
?.let {
skills.add(it)
dormantSkills.remove(it)
}
skills
.find { it.type == type }
?.let {
when {
it.level < it.maxLevel -> it.level += 1
else -> throw IllegalStateException("Skill already maxed out")
}
} ?: throw IllegalArgumentException("Skill not found")
}
}
Рука героя (кубики, которыми он располагает в данный момент), описывается отдельным объектом (класс Hand
). Дизайн-решение хранить кубики-союзники отдельно от основной руки было одним из первых, пришедших на ум. Поначалу оно казалось супер-крутой фичей, но впоследствии породило огромое количество проблем и неудобств. Тем не менее, легких путей мы не ищем, а потому списки dice
и allies
— к нашим услучам, со всеми нужными для добавления, получения и удаления методами (некоторые из них умно определяют, к которому из двух списков обращаться). При удалении кубика из руки все последующие кубики будут сдвигаться к началу списка, заполняя пробелы — в дальнейшем это сильно облегчит перебор (не нужно обрабатывать ситуации с null
).
class Hand(var capacity: Int) {
private val dice = LinkedList<Die>()
private val allies = LinkedList<Die>()
val dieCount
get() = dice.size
val allyDieCount
get() = allies.size
fun dieAt(index: Int) = when {
(index in 0 until dieCount) -> dice[index]
else -> null
}
fun allyDieAt(index: Int) = when {
(index in 0 until allyDieCount) -> allies[index]
else -> null
}
fun addDie(die: Die) = when {
die.type == Die.Type.ALLY -> allies.addLast(die)
else -> dice.addLast(die)
}
fun removeDie(die: Die) = when {
die.type == Die.Type.ALLY -> allies.remove(die)
else -> dice.remove(die)
}
fun findDieOfType(type: Die.Type): Die? = when (type) {
Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null
else -> dice.firstOrNull { it.type == type }
}
fun examine(): List<Die> = (dice + allies).sorted()
}
Коллекция объектов класса DiceLimit
задает ограничения по количеству кубиков каждого типа, которое герой может иметь в начале сценария. Говорить тут особо нечего, определяем начально, максимальное и текущее значения для каждого типа.
class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)
А вот с навыками дело обстоит интереснее. Каждый из них придется индивидуально реализовывать (о чем позже), но мы рассмотрим всего два: Hit и Shoot (по одному для каждого класса соответственно). Навыки можно развивать («прокачивать») с начального до максимального уровня, что зачастую влияет на модификаторы, которые добавляются к броскам кубиков. Отразим это в свойствах level
, maxLevel
, modifier1
и modifier2
.
class Skill(val type: Type) {
enum class Type {
//Brawler
HIT,
//Hunter
SHOOT,
}
var level = 1
var maxLevel = 3
var isActive = true
var modifier1 = 0
var modifier2 = 0
}
Обратите внимание на вспомагательные методы класса Hero
, позволяющие спрятать или сбросить кубик из руки, проверить, обладает ли герой определенным навыком, а также повысить уровень изученного навыка или изучить новый. Все они рано или поздно понадобятся, но сейчас не станем на них подробно останавливаться.
Просьба не пугаться количеству классов, которые нам приходится создавать. Для проекта такой сложности несколько сотен — обычное дело. Тут как в любом серьезном занятии — начинаем с малого, постепенно наращиваем темпы, через месяц ужасаемся размаху. Не забывайте, мы все еще маленькая студия из одного человека — непосильных задач перед нами не стоит.
«Чего-то мне поплохело. Пойду покурю, что ли...»
А мы продолжим.
Героев и их способности описали, пора перейти к противоборствуюшим силам — великим и ужасным Игровым Механикам. А вернее объектам, с которыми нашим героям предстоит взаимодействовать.
Противостоять нашим доблестным протагонистам будут кубики и карты трех видов: злодеи (класс Villain
), враги (класс Enemy
) и преграды (класс Obstacle
), объединенные под общим термином «угрозы» (Threat
— абстрактный «запертый» класс, список его возможных наследников строго ограничен). Каждая угроза имеет набор отличительных особенностей (Trait
), описывающих особые правила поведения при встрече с такой угрозой и вносящие разнообразие в игровой процесс.
sealed class Threat {
var name: String = ""
var description: String = ""
private val traits = mutableListOf<Trait>()
fun addTrait(trait: Trait) = traits.add(trait)
fun getTraits(): List<Trait> = traits
}
class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat()
class Villain : Threat()
class Enemy : Threat()
enum class Trait {
MODIFIER_PLUS_ONE, //Add +1 modifier
MODIFIER_PLUS_TWO, //Add +2 modifier
}
Обратите внимание, список объектов класса Trait
определен как изменяемый (MutableList
), но наружу отдается в виде неизменяемого интерфейса List
. Хоть в Kotlin это и будет работать, подход однако небезопасный, поскольку ничего не мешает преобразовать полученный список к изменяемому интерфейсу и произвести различные модификации — особенно просто это сделать, если обращаться к классу из кода на Java (где интерфейс List
— изменяемый). Наиболее параноидальный способ защитить свою коллекцию — сделать что-то вроде этого:
fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)
но мы не станем настолько скрупулезно подходить к вопросу (вы, однако, предупреждены).
Ввиду особенностей игровой механики, класс Obstacle
отличается от своих собратьев наличием дополнительных полей, но мы не станем заострять на них внимания.
Карты угроз (а если вы внимательно читали дизайн-документ, то помните, что это карты) объединяются в колоды, представленные классом Deck
:
class Deck<E: Threat> {
private val cards = LinkedList<E>()
val size
get() = cards.size
fun addToTop(card: E) = cards.addFirst(card)
fun addToBottom(card: E) = cards.addLast(card)
fun revealTop(): E = cards.first
fun drawFromTop(): E = cards.removeFirst()
fun shuffle() = cards.shuffle()
fun clear() = cards.clear()
fun examine() = cards.toList()
}
Здесь нет ничего необычного, кроме того что класс параметризованный и содержит в своем составе упорядоченный список (а вернее двухстороннюю очередь), который можно перемешивать соответствующим методом. Колоды врагов и препятствий понадобятся нам буквально через секунду, когда мы перейдем к рассмотрению…
… класса Location
, каждый экземпляр которого описывает уникальную местность, которую в рамках сценария придется посетить нашим героям.
class Location {
var name: String = ""
var description: String = ""
var isOpen = true
var closingDifficulty = 0
lateinit var bag: Bag
var villain: Villain? = null
lateinit var enemies: Deck<Enemy>
lateinit var obstacles: Deck<Obstacle>
private val specialRules = mutableListOf<SpecialRule>()
fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule)
fun getSpecialRules() = specialRules
}
Каждая местность имеет название, описание, сложность закрытия и признак «открытая/закрытая». Где-то здесь может таиться злодей (а может и не таиться, ввиду чего свойство villain
может принимать значение null
). В каждой местности есть сумка с кубиками и колоды карт с угрозами. Также местность может обладать своими уникальными игровыми особенностями (SpecialRule
), которые, подобно свойствам угроз, вносят разнообразие в игровой процесс. Как видите, мы закладываем базис под будущую функциональность, даже если не планируем в ближайшее время ее реализовывать (для чего, по сути, и нужен этап моделирования).
Напоследок осталось реализовать сценарии (класс Scenario
):
class Scenario {
var name = ""
var description = ""
var level = 0
var initialTimer = 0
private val allySkills = mutableListOf<AllySkill>()
private val specialRules = mutableListOf<SpecialRule>()
fun addAllySkill(skill: AllySkill) = allySkills.add(skill)
fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills)
fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule)
fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules)
}
Каждый сценарий характеризуется уровнем и начальным значением таймера. Аналогично виденному ранее задаются особые правила (specialRules
) и навыки союзников (упустим из рассмотрения). Можно подумать, что сценарий также должен содержать список местностей (объектов класса Location
) и по логике вещей это действительно так. Но как станет видно позже, такую связь мы нигде не будем использовать и никакого технического примущества она на дает.
Напоминаю, все рассмотренные доселе классы содержатся в пакете model
— мы как ребенок в предвкушении эпического игрушечного сражения расставили солдатиков на поверхности стола. И вот-вот, через несколько тягостных мгновений, по сигналу главнокомандующего мы устремимся в бой, сталкивая наши игрушки между собой и наслаждаясь последствиями игрового процесса. Но перед этим — немного о самой расстановке.
«Ну вооот...»
Шаг седьмой. Шаблоны и генераторы
Представим на секундочку, в чем будет состоять процесс генерации какого-либо из рассмотренных ранее объектов, например локации (местности). Нам необходимо создать экземпляр класса Location
, инициализировать его поля значениями, и так для каждой местности, которую мы захотим использовать в игре. Но постойте: у каждой локации должна быть сумка, которую тоже необходимо сгенерировать. А сумках есть кубики — это тоже экземпляры соответствующего класса (Die
). Это я еще не говорю про врагов и препятствия — их вообще нужно в колоды собрать. А злодея не сама местность определяет, но особенности сценария, расположенного на уровень выше. Ну, вы поняли. Исходный код для вышеперечисленного может иметь такой вид:
val location = Location().apply {
name = "Some location"
description = "Some description"
isOpen = true
closingDifficulty = 4
bag = Bag().apply {
put(Die(Die.Type.PHYSICAL, 4))
put(Die(Die.Type.SOMATIC, 4))
put(Die(Die.Type.MENTAL, 4))
put(Die(Die.Type.ENEMY, 6))
put(Die(Die.Type.OBSTACLE, 6))
put(Die(Die.Type.VILLAIN, 6))
}
villain = Villain().apply {
name = "Some villain"
description = "Some description"
addTrait(Trait.MODIFIER_PLUS_ONE)
}
enemies = Deck<Enemy>().apply {
addToTop(Enemy().apply {
name = "Some enemy"
description = "Some description"
})
addToTop(Enemy().apply {
name = "Other enemy"
description = "Some description"
})
shuffle()
}
obstacles = Deck<Obstacle>().apply {
addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply {
name = "Some obstacle"
description = "Some Description"
})
}
}
Это еще спасибо языку Kotlin и конструкции apply{}
— в Java код был бы в два раза более громоздким. Причем местностей, как мы сказали, будет много, а кроме них есть еще сценарии, приключения и герои с их навыками и характеристиками — в общем, есть, чем заняться гейм-дизайнеру.
Вот только гейм-дизайнер код писать не будет, да и нам неудобно при малейшем изменении игрового мира заново компилировать проект. Тут любой грамотный программист возразит, что описания объектов от кода классов нужно отделить — в идеале, чтобы экземпляры последних генерировались динамически на основе первых по мере необходимости, аналогично тому как на заводе по чертежу изготавливают деталь. Реализуем такие чертежи и мы, только назовем их шаблонами (templates) и представим экземплярами специального класса. Имея такие шаблоны, специальный программный код (генератор) будет создавать конечные объекты из описанной ранее модели.
Таким образом, для каждого класса наших объектов необходимо задать две новых сущности: интерфейс-шаблон и класс-генератор. А поскольку объектов поднакопилось приличное количество, то и сущностей тоже окажется количество… неприличное:
Просьба дышать глубже, слушать внимательно и не отвлекаться. Во-первых, на диаграмме представлены не все объекты игрового мира, а лишь основные, без которых на первых порах не обойтись. Во вторых, дабы не перегружать схему излишними делатяли, некоторые связи, уже упомянутые ранее на других диаграммах, были опущены.
Начнем с чего-нибудь простого — генерации кубиков. «Как? — скажете вы. — Разве нам мало конструктора? Да-да, вот того самого, с типом и размером». Нет, отвечу, недостаточно. Ведь во многих случаях (читайте правила) кубики необходимо генерировать произвольным образом в произвольном количестве (например: «от одного до трех кубиков либо синего, либо зеленого цвета»). Да еще размер подбирать в завимости от уровня сложности сценария. Поэтому введем специальный интерфейс DieTypeFilter
.
interface DieTypeFilter {
fun test(type: Die.Type): Boolean
}
Различные реализации этого интерефейса будут проверять, соответствует ли тип кубика различным наборам правил (любым, какие только в голову прийдут). Например, соответствует ли тип строго заданному значению («синий») или диапазону значений («синий, желтый или зеленый»); или, наоборот, соответствует любому типу кроме заданного («лишь бы не белый ни в коем случае» — все, что угодно, только не это). Даже если заранее и непонятно, какие конкретно реализации нужны, не беда — их можно добавить позже, система от этого не сломается (полиморфизм, помните?).
class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
override fun test(type: Die.Type) = (this.type == type)
}
class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
override fun test(type: Die.Type) = (this.type != type)
}
class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
override fun test(type: Die.Type) = (type in types)
}
class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
override fun test(type: Die.Type) = (type !in types)
}
Размер кубика тоже будет задаваться произвольным образом, но об этом позже. А пока напишем генератор кубиков (DieGenerator
), который, в отличие от конструктора класса Die
, будет принимать не явный тип и размер кубика, а фильтр и уровень сложности.
private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8)
private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10)
private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12)
private val DISTRIBUTIONS = arrayOf(
intArrayOf(4),
DISTRIBUTION_LEVEL1,
DISTRIBUTION_LEVEL2,
DISTRIBUTION_LEVEL3
)
fun getMaxLevel() = DISTRIBUTIONS.size - 1
fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level))
private fun generateDieType(filter: DieTypeFilter): Die.Type {
var type: Die.Type
do {
type = Die.Type.values().random()
} while (!filter.test(type))
return type
}
private fun generateDieSize(level: Int) =
DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random()
В Java эти методы были бы статическими, но поскольку мы имеем дело с Kotlin, класс, как таковой, нам не нужен, что справедливо и для прочих рассматриваемых ниже генераторов (тем не менее, на логическом уровне мы все же будем пользоваться понятием класса).
Два приватных метода генерируют отдельно тип и размер кубика — про каждый можно сказать что-то интересное. Метод generateDieType()
можно загнать в бесконечный цикл, передав на вход фильтр с
override fun test(filter: DieTypeFilter) = false
(у сценаристов есть стойкое убеждение, что из логических нестыковок и сюжетных дыр можно выкрутиться, если сами персонажи в ходе повествования укажут на них зрителям). Метод generateDieSize()
, производит генерацию псевдослучайного размера на основе распределения, заданного в виде массива (по одному на каждый уровень). Когда в старости я разбогатею и куплю себе пакет разноцветных игральных кубиков, я не смогу сыграть в Dice, потому как не буду знать способа случайным образом собрать из них сумку (кроме как попросить соседа, а самому в это время отвернуться). Это не колода карт, которую можно перетасовать рубашкой вверх, тут требуются специальные механизмы и приспособления. Если у кого-то есть идеи (и ему хватило терпения дочитать до этого места), пожалуйста, поделитесь в коментариях.
И раз уж мы заговорили о сумках, разработаем для них шаблон. В отличие от своих товарищей, этот шаблон (BagTemplate
) будет конкретным классом. В его составе другие шаблоны — каждый из них описывает правила (или Plan
), по которым один или несколько кубиков (помните требования, озвученные ранее?) добавляются в сумку.
class BagTemplate {
class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter)
val plans = mutableListOf<Plan>()
fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) {
plans.add(Plan(minQuantity, maxQuantity, filter))
}
}
Каждый план задает шаблон для типа кубиков, а также количество (минимальное и максимальное) кубиков, удовлетворяющих этому шаблону. Благодаря этому подходу, можно генерировать сумки по причудливым правилам (а я снова горько плачу на старости лет, потому как мой сосед наотрез отказывается мне помогать). Как-то так:
private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> {
val count = (plan.minQuantity..plan.maxQuantity).shuffled().last()
return (1..count).map { generateDie(plan.filter, level) }.toTypedArray()
}
fun generateBag(template: BagTemplate, level: Int): Bag {
return template.plans.asSequence()
.map { realizePlan(it, level) }
.fold(Bag()) { b, d -> b.put(*d); b }
}
}
Если вы так же, как и я, устали от всей этой функциональщины, крепитесь — дальше будет только хуже. Но зато, в отличие от многих невнятных туториалов на просторах интернета, у нас есть возможность изучить использование разных хитрых методов применительно к реальной, понятной предметной области.
Сами по себе сумки на поле валяться не будут — нужно раздать их героям и локациям. Начнем с последних.
interface LocationTemplate {
val name: String
val description: String
val bagTemplate: BagTemplate
val basicClosingDifficulty: Int
val enemyCardsCount: Int
val obstacleCardsCount: Int
val enemyCardPool: Collection<EnemyTemplate>
val obstacleCardPool: Collection<ObstacleTemplate>
val specialRules: List<SpecialRule>
}
В языке Kotlin вместо методов getЧтоТо()
можно использоваить свойства интерфейсов — так гораздо лаконичнее. С шаблоном сумки мы уже знакомы, рассмотрим оставшиеся методы. Свойство basicClosingDifficulty
будет задавать базовую сложность проверки на закрытие местности. Слово «базовую» означает здесь лишь то, что конечная сложность будет зависеть от уровня сценария и на данном этапе неясна. Кроме этого, нам нужно определить шаблоны для врагов и препятствий (и злодеев заодно). При этом из описанного в шаблоне разнообразия врагов и препятствий будут использоваться не все, а лишь ограниченное количество (для повышения реиграбельности). Обратите внимание, что специальные правила (SpecialRule
) местности реализуются простым перечислением (enum class
), а потому отдельного шаблона не требуют.
interface EnemyTemplate {
val name: String
val description: String
val traits: List<Trait>
}
interface ObstacleTemplate {
val name: String
val description: String
val tier: Int
val dieTypes: Array<Die.Type>
val traits: List<Trait>
}
interface VillainTemplate {
val name: String
val description: String
val traits: List<Trait>
}
И пусть генератор создает не только отдельные объекты, но и целые колоды с ними.
fun generateVillain(template: VillainTemplate) = Villain().apply {
name = template.name
description = template.description
template.traits.forEach { addTrait(it) }
}
fun generateEnemy(template: EnemyTemplate) = Enemy().apply {
name = template.name
description = template.description
template.traits.forEach { addTrait(it) }
}
fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply {
name = template.name
description = template.description
template.traits.forEach { addTrait(it) }
}
fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> {
val deck = types
.map { generateEnemy(it) }
.shuffled()
.fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d }
limit?.let {
while (deck.size > it) deck.drawFromTop()
}
return deck
}
fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> {
val deck = templates
.map { generateObstacle(it) }
.shuffled()
.fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d }
limit?.let {
while (deck.size > it) deck.drawFromTop()
}
return deck
}
Если в колоде окажется больше карт, чем нам нужно (параметр limit
), мы их оттуда уберем. Умея генерировать сумки с кубиками и колоды карт, мы наконец-то можем и местности создавать:
fun generateLocation(template: LocationTemplate, level: Int) = Location().apply {
name = template.name
description = template.description
bag = generateBag(template.bagTemplate, level)
closingDifficulty = template.basicClosingDifficulty + level * 2
enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount)
obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount)
template.specialRules.forEach { addSpecialRule(it) }
}
Местность, которую мы явно задавали в коде в начале главы, теперь примет совершенно другой вид:
class SomeLocationTemplate: LocationTemplate {
override val name = "Some location"
override val description = "Some description"
override val bagTemplate = BagTemplate().apply {
addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL))
addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC))
addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL))
addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE))
}
override val basicClosingDifficulty = 2
override val enemyCardsCount = 2
override val obstacleCardsCount = 1
override val enemyCardPool = listOf(
SomeEnemyTemplate(),
OtherEnemyTemplate()
)
override val obstacleCardPool = listOf(
SomeObstacleTemplate()
)
override val specialRules = emptyList<SpecialRule>()
}
class SomeEnemyTemplate: EnemyTemplate {
override val name = "Some enemy"
override val description = "Some description"
override val traits = emptyList<Trait>()
}
class OtherEnemyTemplate: EnemyTemplate {
override val name = "Other enemy"
override val description = "Some description"
override val traits = emptyList<Trait>()
}
class SomeObstacleTemplate: ObstacleTemplate {
override val name = "Some obstacle"
override val description = "Some description"
override val traits = emptyList<Trait>()
override val tier = 1
override val dieTypes = arrayOf(
Die.Type.PHYSICAL,
Die.Type.VERBAL
)
}
val location = generateLocation(SomeLocationTemplate(), 1)
Генерация сценариев будет происходить аналогичным образом.
interface ScenarioTemplate {
val name: String
val description: String
val initialTimer: Int
val staticLocations: List<LocationTemplate>
val dynamicLocationsPool: List<LocationTemplate>
val villains: List<VillainTemplate>
val specialRules: List<SpecialRule>
fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2
}
В соответствии с правилами, количество динамически генерируемых локаций зависит от количества героев. В интерфейсе задана стандартная функция расчета, которую при желании можно переопределить в конкретных реализациях. В связи с этим требованием, генератор сценариев будет также генерировать и местности для этих сценариев — там же злодеи будут случайным образом распределены по местностям.
fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply {
name =template.name
description = template.description
this.level = level
initialTimer = template.initialTimer
template.specialRules.forEach { addSpecialRule(it) }
}
fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> {
val locations = template.staticLocations.map { generateLocation(it, level) } +
template.dynamicLocationsPool
.map { generateLocation(it, level) }
.shuffled()
.take(template.calculateDynamicLocationsCount(numberOfHeroes))
val villains = template.villains
.map(::generateVillain)
.shuffled()
locations.forEachIndexed { index, location ->
if (index < villains.size) {
location.villain = villains[index]
location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level))
}
}
return locations
}
Многие внимательные читатели возразят, что шаблоны нужно хранить не в исходном коде классов, а в каких-нибудь текстовых файлах (скриптах), чтобы создавать и поддерживать их могли даже люди, далекие от программирования. Соглашусь, снимаю шляпу, но голову пеплом не посыпаю — ибо одно другому не мешает. Если хотите, достаточно определить специальную реализацию шаблона, значения свойств которой будут загружаться из внешнего файла. Процесс генерации от этого ни на йоту не изменится.
Ну вот, кажись ничего не забыли… Ах да, герои — их ведь тоже нужно генерировать, а значит им тоже нужны свои шаблоны. Вот такие, например:
interface HeroTemplate {
val type: Hero.Type
val initialHandCapacity: Int
val favoredDieType: Die.Type
val initialDice: Collection<Die>
val initialSkills: List<SkillTemplate>
val dormantSkills: List<SkillTemplate>
fun getDiceCount(type: Die.Type): Pair<Int, Int>?
}
И сразу же мы замечаем две странности. Во-первых, мы не используем шаблоны для генерации сумок и кубиков в них. Почему? Да потому что для каждого типа (класса) героев список начальных кубиков строго определен — нет смысла усложнять процесс их создания. Во-вторых, getDiceCount()
— что это вообще за муть такая??? Успокойтесь, это те самые DiceLimit
, задающие ограничения по кубикам. А шаблон для них выбран в столь причудливом виде, чтобы нагляднее записывались конкретные значения. Убедитесь сами из примера:
class BrawlerHeroTemplate : HeroTemplate {
override val type = Hero.Type.BRAWLER
override val favoredDieType = PHYSICAL
override val initialHandCapacity = 4
override val initialDice = listOf(
Die(PHYSICAL, 6),
Die(PHYSICAL, 6),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(SOMATIC, 6),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(MENTAL, 4),
Die(VERBAL, 4),
Die(VERBAL, 4)
)
override fun getDiceCount(type: Die.Type) = when (type) {
PHYSICAL -> 8 to 12
SOMATIC -> 4 to 7
MENTAL -> 1 to 2
VERBAL -> 2 to 4
else -> null
}
override val initialSkills = listOf(
HitSkillTemplate()
)
override val dormantSkills = listOf<SkillTemplate>()
}
class HunterHeroTemplate : HeroTemplate {
override val type = Hero.Type.HUNTER
override val favoredDieType = SOMATIC
override val initialHandCapacity = 5
override val initialDice = listOf(
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(PHYSICAL, 4),
Die(SOMATIC, 6),
Die(SOMATIC, 6),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(SOMATIC, 4),
Die(MENTAL, 6),
Die(MENTAL, 4),
Die(MENTAL, 4),
Die(MENTAL, 4),
Die(VERBAL, 4)
)
override fun getDiceCount(type: Die.Type) = when (type) {
PHYSICAL -> 3 to 5
SOMATIC -> 7 to 11
MENTAL -> 4 to 7
VERBAL -> 1 to 2
else -> null
}
override val initialSkills = listOf(
ShootSkillTemplate()
)
override val dormantSkills = listOf<SkillTemplate>()
}
Но прежде чем писать генератор, определим шаблон для навыков.
interface SkillTemplate {
val type: Skill.Type
val maxLevel: Int
val modifier1: Int
val modifier2: Int
val isActive
get() = true
}
class HitSkillTemplate : SkillTemplate {
override val type = Skill.Type.HIT
override val maxLevel = 3
override val modifier1 = +1
override val modifier2 = +3
}
class ShootSkillTemplate : SkillTemplate {
override val type = Skill.Type.SHOOT
override val maxLevel = 3
override val modifier1 = +0
override val modifier2 = +2
}
К сожалению, клепать навыки пачками так же, как врагов и сценарии, нам не удастся. Каждый новый навык требует расширения игровой механики, добавления нового кода в игровой движок — даже с героями в этом плане проще. Возможно, этот процесс и можно абстрагировать, но я способа пока не придумал. Да и не слишком пытался, если честно.
fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill {
val skill = Skill(template.type)
skill.isActive = template.isActive
skill.level = initialLevel
skill.maxLevel = template.maxLevel
skill.modifier1 = template.modifier1
skill.modifier2 = template.modifier2
return skill
}
fun generateHero(type: Hero.Type, name: String = ""): Hero {
val template = when (type) {
BRAWLER -> BrawlerHeroTemplate()
HUNTER -> HunterHeroTemplate()
}
val hero = Hero(type)
hero.name = name
hero.isAlive = true
hero.favoredDieType = template.favoredDieType
hero.hand.capacity = template.initialHandCapacity
template.initialDice.forEach { hero.bag.put(it) }
for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) {
l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) }
}
template.initialSkills
.map { generateSkill(it) }
.forEach { hero.addSkill(it) }
template.dormantSkills
.map { generateSkill(it, 0) }
.forEach { hero.addDormantSkill(it) }
return hero
}
Сразу несколько моментов бросаются в глаза. Во-первых, метод генерации сам подбирает нужный шаблон в зависимости от класса героя. Во-вторых, имя не обязательно задавать сразу (иногда на этапе генерации мы еще не будем его знать). В-третьих, Kotlin привнес невиданное доселе количество синтаксического сахара, коим некоторые разработчики без меры злоупотребляют. И ни капли того не стыдятся.
Шаг восьмой. Игровой цикл
Наконец-то мы подобрались к самому интересному — реализации игрового цикла. Говоря по-простому, начали «делать игру». Многие начинающие разработчики частенько именно с этого этапа и начинают, не считая игроделанием все остальное. Особенно всякие бессмысленные схемки рисовать, пффф… Но мы не станем торопиться (до утра еще далеко), а потому еще немного моделирования. Да, опять.
Как видите, приведенный фрагмент игрового цикла на порядок меньше чем то, что мы приводили выше. Мы рассмотрим лишь процесс передачи хода, исследования местности (причем опишем встречу только с двумя типами кубиков) и сброса кубиков в конце хода. А еще завершение сценария проигрышем (да, победить в нашей игре пока не получится) — а как вы хотели? Таймер будет уменьшаться каждый ход, и по его завершении что-то нужно делать. Например, вывести сообщение и завершить игру — все, как в правилах написано. Еще игру нужно завершать при смерти героев, но наносить вред им никто не будет, потому оставим. Для победы же нужно закрыть все местности, что сложно даже в случае, если она всего одна. Потому оставим и этот момент. Нет смысла слишком распыляться — нам важно понять суть, а остальное доделать уже позже, в свободное время (вернее мне — доделать, а вам — пойти писать игру своей мечты).
Итак, первым делом необходимо определиться с тем, какие объекты нам нужны.
Герои. Сценарий. Локации.
Выше мы уже рассмотрели процесс их создания — не будем повторяться. Отметим только шаблон местности, который будем использовать в нашем маленьком примере.
class TestLocationTemplate : LocationTemplate {
override val name = "Test"
override val description = "Some Description"
override val basicClosingDifficulty = 0
override val enemyCardsCount = 0
override val obstacleCardsCount = 0
override val bagTemplate = BagTemplate().apply {
addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL))
addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC))
addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL))
addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL))
addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE))
}
override val enemyCardPool = emptyList<EnemyTemplate>()
override val obstacleCardPool = emptyList<ObstacleTemplate>()
override val specialRules = emptyList<SpecialRule>()
}
Как видите, в сумке лежат только «позитивные» кубики — синие, зеленые, фиолетовые, желтые и голубые. Врагов и препятствий в местности нет, злодеи и раны не водятся. Каких-то особых правил тоже нет — их реализация весьма второстепенна.
Куча для удержанных кубиков.
Или deterrent pile. Поскольку мы положили голубые кубики в сумку местности, их можно будет использовать в проверках и после использования удерживать в специальной куче. Для этого пригодится экземпляр класса Pile
.
Модификаторы.
То есть, числовые значения, которые необходимо добавлять или вычитать из результата броска кубика. Можно реализовать либо глобальный модификатор, либо отдельный модификор для кажого кубика. Мы выберем второй вариант (так нагляднее), потому создадим простой класс DiePair
.
class DiePair(val die: Die, var modifier: Int = 0)
Расположение героев в местности.
По-хорошему, этот момент нужно отслеживать при помощи специальной структуры. Например, карты вида Map<Location, List<Hero>>
, где каждая местность будет содержать список героев, находящихся в ней в данный момент (а также метод для обратного — определения местности, в которой конкретный герой находится). Если вы решитесь идти этим путем, то не забудьте добавить в класс Location
реализации методов equals()
и hashCode()
— надеюсь, не нужно объяснять зачем. Мы же не станем тратить на это время, так как местность всего одна и герои из нее никуда не уходят.
Проверка руки героя.
В процессе игры героям постоянно приходится проходить проверки (о которых ниже), то есть брать кубики из руки, бросать их (добавлять модификаторы), агрегировать результаты, если кубиков несколько (суммировать, брать максимальный/минимальный, средний итп), сравнивать их с броском другого кубика (того, который вынут из сумки местности) и в зависимости от результата выполнять последующие действия. Но прежде всего необходимо понять, способен ли герой в принципе пройти проверку, то есть, есть ли у него в руке нужные кубики. Для этого предусмотрим простой интерфейс HandFilter
.
interface HandFilter {
fun test(hand: Hand): Boolean
}
Реализации интерфейса принимают на вход руку героя (объект класса Hand
) и возвращают true
или false
в зависимости от результатов проверки. Для нашего фрагмента игры понадобится единственная реализация: если встречен синий, зеленый, фиолетовый или желтый кубик, нужно определить, есть ли в руке героя кубик такого же цвета.
class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter {
override fun test(hand: Hand) =
(0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types }
|| (Die.Type.ALLY in types && hand.allyDieCount > 0)
}
Да, опять функциональщина.
Активные/выбранные позиции.
Теперь, когда мы убедились, что рука героя подходит для выполнения проверки, необходимо, чтобы игрок выбрал из руки тот кубик (или кубики), при помощи которого он эту проверку будет проходить. Во-первых, нужно выделить (подсветить) подходящие позиции (в которых есть кубики нужного типа). Во-вторых, нужно как-то отмечать выбранные кубики. Для обоих этих требований подойдет класс HandMask
, который, по сути, содержит набор целых чисел (номеров выбранных позиций) и методы для их добавления и удаления.
class HandMask {
private val positions = mutableSetOf<Int>()
private val allyPositions = mutableSetOf<Int>()
val positionCount
get() = positions.size
val allyPositionCount
get() = allyPositions.size
fun addPosition(position: Int) = positions.add(position)
fun removePosition(position: Int) = positions.remove(position)
fun addAllyPosition(position: Int) = allyPositions.add(position)
fun removeAllyPosition(position: Int) = allyPositions.remove(position)
fun checkPosition(position: Int) = position in positions
fun checkAllyPosition(position: Int) = position in allyPositions
fun switchPosition(position: Int) {
if (!removePosition(position)) {
addPosition(position)
}
}
fun switchAllyPosition(position: Int) {
if (!removeAllyPosition(position)) {
addAllyPosition(position)
}
}
fun clear() {
positions.clear()
allyPositions.clear()
}
}
Я уже говорил, как я страдаю от «гениальной» идеи хранить белые кубики в отдельной руке? Из-за этой глупости приходится управляться с двумя наборами и дублировать каждый из представленных методов. Если у кого-то есть идеи, как упростить реализацию этого требования (например, использовать один набор, но у белых кубиков индексы начинаются с сотни — или еще что-то в той же степени невразумительное) — делитесь ими в коментариях.
Кстати, похожий класс нужно реализовать для выбора кубиков из кучи (PileMask
), но эта функциональность находится за пределами рассматриваемого примера.
Выбор кубиков из руки.
Но мало «подсветить» допустимые позиции, важно менять эту «подсветку» в процессе выбора кубиков. То есть, если от игрока требуется взять из руки только один кубик, то при выборе этого кубика, все остальные позиции должны стать недоступными. Более того, на каждом этапе необходимо контролировать выполнение игроком цели — то есть, понимать, достаточно ли выбранных кубиков для прохождения той или иной проверки. Такая сложная задача требует непростой экземпляр непростого класса.
abstract class HandMaskRule(val hand: Hand) {
abstract fun checkMask(mask: HandMask): Boolean
abstract fun isPositionActive(mask: HandMask, position: Int): Boolean
abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean
fun getCheckedDice(mask: HandMask): List<Die> {
return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt))
.plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt))
.filterNotNull()
}
}
Довольно сложная логика, я пойму и прощу вас, если этот класс окажется для вас непонятным. И все же попытаюсь объяснить. Реализации этого класса всегда хранят ссылку на руку (объект Hand
), с которой будут иметь дело. Каждый из методов принимает на вход маску (HandMask
), отражающую текущее состояние выбора (какие позиции выбраны игроком, а какие нет). Метод checkMask()
сообщает, достаточно ли выбранных кубиков для прохождения проверки. Метод isPositionActive()
говорит, нужно ли подсвечивать конкретную позицию — можно ли добавить к проверке находящийся в этой позиции кубик (или убрать кубик, который уже выбран). Метод isAllyPositionActive()
— то же самое для белых кубик (да, знаю, я идиот). Ну и вспомогательный метод getCheckedDice()
попросту возвращает список всех кубиков из руки, которые соответствуют маске — это нужно для того чтобы всех их разом взять, бросить на стол и наслаждаться веселым стуком, с коим они разлетаются в разные стороны.
Реализаций этого абстрактного класса нам понадобится две (сюрприз, сюрприз!). Первая контролирует процесс прохождения проверки при приобретении нового кубика конкретного типа (не белого). Как вы помните, к такой проверке можно добавлять любое количество голубых кубиков.
class StatDieAcquireHandMaskRule(hand: Hand,
private val requiredType: Die.Type)
: HandMaskRule(hand) {
/**
* Define how many dice of specified type are currently checked
*/
private fun checkedDieCount(mask: HandMask) =
(0 until hand.dieCount)
.filter(mask::checkPosition)
.mapNotNull(hand::dieAt)
.count { it.type === requiredType }
override fun checkMask(mask: HandMask) =
(mask.allyPositionCount == 0 && checkedDieCount(mask) == 1)
override fun isPositionActive(mask: HandMask, position: Int) =
with(hand.dieAt(position)) {
when {
mask.checkPosition(position) -> true
this == null -> false
this.type === Die.Type.DIVINE -> true
this.type === requiredType && checkedDieCount(mask) < 1 -> true
else -> false
}
}
override fun isAllyPositionActive(mask: HandMask, position: Int) = false
}
Вторая реализация сложнее. Она управляет процессом сброса кубиков в конце хода. При этом возможны два варианта. Если количество кубиков в руке превышает ее максимально допустимый размер (capacity), мы должны сбросить все лишние кубики плюс любое количество дополнительных кубиков (если хотим). Если же размер не превышается, то можно ничего не сбрасывать (а можно и сбросить, по желанию). Серые кубики ни в одном из случаев сбрасывать нельзя.
class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) {
private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0
private val maxDiceToDiscard = hand.dieCount - hand.woundCount
override fun checkMask(mask: HandMask) =
(mask.positionCount in minDiceToDiscard..maxDiceToDiscard) &&
(mask.allyPositionCount in 0..hand.allyDieCount)
override fun isPositionActive(mask: HandMask, position: Int) = when {
mask.checkPosition(position) -> true
hand.dieAt(position) == null -> false
hand.dieAt(position)!!.type == Die.Type.WOUND -> false
mask.positionCount < maxDiceToDiscard -> true
else -> false
}
override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null
}
Нежданчик: в классе Hand
вдруг появилось свойство woundCount
, которого раньше не было. Его реализацию можете написать сами, это несложно. Заодно попрактикуетесь.
Прохождение проверок.
Наконец добрались до них. Когда кубики взяты из руки, пришла пора их бросать. Для каждого кубика необходимо учитывать: его размер, его модификаторы, результат его броска. Хотя из сумки местности одновременно можно вынимать лишь один кубик, против него можно выставлять несколько кубиков, аггрегируя результаты их бросков. Вообще, давайте абстрагируемся от кубиков и представим войска на поле боя. С одной стороны у нас враг — он всего лишь один, но он силен и свиреп. С другой стороны равный ему по силе соперник, но с поддержкой. Исход битвы решится в одной короткой стычке, победитель может быть лишь один…
Извините, увлекся. Для моделирования нашего генерального сражения реализуем специальный класс.
class DieBattleCheck(val method: Method, opponent: DiePair? = null) {
enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN }
private inner class Wrap(val pair: DiePair, var roll: Int)
private infix fun DiePair.with(roll: Int) = Wrap(this, roll)
private val opponent: Wrap? = opponent?.with(0)
private val heroics = ArrayList<Wrap>()
var isRolled = false
var result: Int? = null
val heroPairCount
get() = heroics.size
fun getOpponentPair() = opponent?.pair
fun getOpponentResult() = when {
isRolled -> opponent?.roll ?: 0
else -> throw IllegalStateException("Not rolled yet")
}
fun addHeroPair(pair: DiePair) {
if (method == Method.SUM && heroics.size > 0) {
pair.modifier = 0
}
heroics.add(pair with 0)
}
fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier))
fun clearHeroPairs() = heroics.clear()
fun getHeroPairAt(index: Int) = heroics[index].pair
fun getHeroResultAt(index: Int) = when {
isRolled -> when {
(index in 0 until heroics.size) -> heroics[index].roll
else -> 0
}
else -> throw IllegalStateException("Not rolled yet")
}
fun roll() {
fun roll(wrap: Wrap) {
wrap.roll = wrap.pair.die.roll()
}
isRolled = true
opponent?.let { roll(it) }
heroics.forEach { roll(it) }
}
fun calculateResult() {
if (!isRolled) {
throw IllegalStateException("Not rolled yet")
}
val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0
val stats = heroics.map { it.roll + it.pair.modifier }
val heroResult = when (method) {
DieBattleCheck.Method.SUM -> stats.sum()
DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt()
DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt()
DieBattleCheck.Method.MAX -> stats.max() ?: 0
DieBattleCheck.Method.MIN -> stats.min() ?: 0
}
result = heroResult - opponentResult
}
}
Поскольку каждый кубик может иметь модификатор, хранить данные будем в объектах DiePair
. Вроде бы. На самом деле, нет, так как помимо кубика и модификатора нужно хранить еще и результат его броска (помните, сам кубик хоть и генерирует это значение, но не хранит его среди своих свойств). Поэтому обернем каждую пару в обертку (Wrap
). Обратите внимание на инфиксный метод with
, хе-хе.
В конструкторе класса задается метод аггрегации (экземпляр внутреннего перечисления Method
) и оппонент (которого может и не быть). Список кубиков героя формируется при помощи соответствующих методов. Также предусмотрена куча методов для получения пар, участвующих в проверке, и результатов их бросков (если они есть).
Метод roll()
вызывает одноименной метод каждого кубика, сохраняет промежуточные результаты и отмечает факт своего выполнения флагом isRolled
. Обратите внимание, что финальный результат броска не вычисляется сразу — для этого есть специальный метод calculateResult()
, результатом выполнения которого является запись конечного значения в свойство result
. Зачем это нужно? Для драматического эффекта. Метод roll()
будет запускаться несколько раз, каждый раз на гранях кубиков будут отображаться разные значения (прямо как в реальной жизни). И только когда кубики успокоятся на столе, мы узнаем нашу судьбу финальный результат (разность значений кубиков героя и кубика-оппонента). Для снятия напряжения скажу, что результат 0 будет считаться успешным прохождением проверки.
Состояние игрового движка.
Со сложными объектами разобрались, теперь вещи попроще. Не будет большим открытием сказать, что нам необходимо контролировать текущий «прогресс» игрового движка, этап или фазу (phase), в которой он находится. Для этого пригодится специальное перечисление.
enum class GamePhase {
SCENARIO_START,
HERO_TURN_START,
HERO_TURN_END,
LOCATION_BEFORE_EXPLORATION,
LOCATION_ENCOUNTER_STAT,
LOCATION_ENCOUNTER_DIVINE,
LOCATION_AFTER_EXPLORATION,
GAME_LOSS
}
На самом деле фаз больше, но мы отобрали только те, которые используются в нашем примере. Для смены фазы игрового движка будем использовать методы changePhaseX()
, где X
— значение из приведенного выше перечисления. В этих методах все внутренние переменные движка будут приводиться к адекватным для начала соответствующей фазы значениям, но об этом позже.
Сообщения.
Хранить состояние игрового движка недостаточно. Важно еще и пользователю как-то о нем сообщать — иначе как последний узнает, что вообще у него на экране происходит? Именно поэтому нам нужно еще одно перечисление.
enum class StatusMessage {
EMPTY,
CHOOSE_DICE_PERFORM_CHECK,
END_OF_TURN_DISCARD_EXTRA,
END_OF_TURN_DISCARD_OPTIONAL,
CHOOSE_ACTION_BEFORE_EXPLORATION,
CHOOSE_ACTION_AFTER_EXPLORATION,
ENCOUNTER_PHYSICAL,
ENCOUNTER_SOMATIC,
ENCOUNTER_MENTAL,
ENCOUNTER_VERBAL,
ENCOUNTER_DIVINE,
DIE_ACQUIRE_SUCCESS,
DIE_ACQUIRE_FAILURE,
GAME_LOSS_OUT_OF_TIME
}
Как видите, все возможные состояния из нашего примера описываются значениями этого перечисления. Для каждого из них предусмотрена текстовая строка, которая и будет отображаться на экране (кроме EMPTY
— это специальное значение), но мы узнаем об этом чуть позже.
Действия.
Для коммуникации между пользователем и игровым движком простых сообщений недостаточно. Важно еще сообщить первому о тех действиях, которые он в данный момент может совершать (исследовать, передать кубики, завершить ход — вот это все добро). Для этого разработаем специальный класс.
class Action(
val type: Type,
var isEnabled: Boolean = true,
val data: Int = 0
) {
enum class Type {
NONE, //Blank type
CONFIRM, //Confirm some action
CANCEL, //Cancel action
HAND_POSITION, //Some position in hand
HAND_ALLY_POSITION, //Some ally position in hand
EXPLORE_LOCATION, //Explore current location
FINISH_TURN, //Finish current turn
ACQUIRE, //Acquire (DIVINE) die
FORFEIT, //Remove die from game
HIDE, //Put die into bag
DISCARD, //Put die to discard pile
}
}
Внутреннее перечисление Type
описывает тип совершаемого действия. Поле isEnabled
нужно для того, чтобы отображать действия в неактивном состоянии. То есть, сообщать, что это действие обычно доступно, но в данный момент по какой-то причине не может быть выполнено (такое отображение гораздо более информативно, чем когда действие не отображается вовсе). Свойство data
(необходимо для некоторых типов действий) хранит специальное значение, сообщающее какие-то дополнительные детали (например, индекс выбранной пользователем позиции или номер выбранного пункта из списка).
Клас Action
является главным «интерфейсом» между игровым движком и системами ввода-вывода (о которых ниже). Поскольку действий зачастую несколько (иначе зачем тогда выбор?), они будут объединяться в группы (списки). Вместо использования стандартных коллекций, напишем свою, расширенную.
class ActionList : Iterable<Action> {
private val actions = mutableListOf<Action>()
val size
get() = actions.size
fun add(action: Action): ActionList {
actions.add(action)
return this
}
fun add(type: Action.Type, enabled: Boolean = true): ActionList {
add(Action(type, enabled))
return this
}
fun addAll(actions: ActionList): ActionList {
actions.forEach { add(it) }
return this
}
fun remove(type: Action.Type): ActionList {
actions.removeIf { it.type == type }
return this
}
operator fun get(index: Int) = actions[index]
operator fun get(type: Action.Type) = actions.find { it.type == type }
override fun iterator(): Iterator<Action> = ActionListIterator()
private inner class ActionListIterator : Iterator<Action> {
private var position = -1
override fun hasNext() = (actions.size > position + 1)
override fun next() = actions[++position]
}
companion object {
val EMPTY
get() = ActionList()
}
}
Класс содержит много разных методов для добавления и удаления действий из списка (которые можно объединять в цепочки), а также получения как по индексу, так и по типу (обратите внимание на «перегрузку» get()
— к нашему списку применим оператор квадратных скобок). Реализация интерфейса Iterator
позволяет проделывать с нашим классом all sorts of crazy shit различные потоковые манипуляции (функциональщина, ага). Также предусмотрено значение EMPTY для быстрого создания пустого списка.
Экраны.
Напоследок еще одно перечисление, которое описывает различные типы отображаемого в данный момент контента… Вы смотрите на меня и хлопаете глазами, я знаю. Когда я начал придумывать, как бы попонятнее описать этот класс, я ударился головой об стол, потому ничего толком сообразить не смог. Сами поймете, я надеюсь.
enum class GameScreen {
HERO_TURN_START,
LOCATION_INTERIOR,
GAME_LOSS
}
Отобрал только те, которые используются в примере. Для каждого из них будет предусмотрен отдельный метод отрисовки… я опять непонятно объясняю.
«Отображалка» и «вводилка».
И вот мы наконец подошли к самому важному моменту — взаимодейтствию игрового движка с пользователем (игроком). Если столь длинное вступление вас еще не утомило, то вы наверняка помните, что мы договорились функционально отделить две эти части друг от друга. Поэтому вместо конкретной реализации системы ввода-вывода мы предоставим лишь интерфейс. Точнее, два.
Первый интерфейс, GameRenderer
, предназначен для отображения картинки на экране. Напоминаю, мы абстрагируемся от размеров экрана, от конкретных графических библиотек итп. Мы просто отсылаем команду: «отрисуй-ка мне вот это» — и те из вас, кто понял наш невнятный разговор об экранах, уже догадался, что для каждого из таких экранов в рамках интерфейса предусмотрен свой собственный метод.
interface GameRenderer {
fun drawHeroTurnStart(hero: Hero)
fun drawLocationInteriorScreen(
location: Location,
heroesAtLocation: List<Hero>,
timer: Int,
currentHero: Hero,
battleCheck: DieBattleCheck?,
encounteredDie: DiePair?,
pickedDice: HandMask,
activePositions: HandMask,
statusMessage: StatusMessage,
actions: ActionList
)
fun drawGameLoss(message: StatusMessage)
}
Думаю, дополнительтных объяснений здесь не нужно — предназначение всех передаваемых объектов подробно рассмотрено выше.
Для пользовательского ввода реализуем другой интерфейс — GameInteractor
(да, скрипты проверки орфографии отныне всегда будут подчеркивать это слово, хотя казалось бы...). Его методы будут запрашивать у игрока требуемые команды для различных ситуаций: выбрать действие из списка предложенных, выбрать элемент из списка, выбрать кубики с руки, просто хоть что-то нажать итп. Следует сразу отметить, что ввод происходит синхронно (игра-то у нас пошаговая), то есть выполнение игрового цикла приостанавливается до тех пор, пока пользователь не ответит на запрос.
interface GameInteractor{
fun anyInput()
fun pickAction(list: ActionList): Action
fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action
}
Про последний метод чуть подробнее. Как видно из названия, от предлагает пользователю выбрать кубики из руки, предоставляя объект HandMask
— номера активных позиций. Выполнение метода будет продолжаться до тех пор пока какая-то их них не будет выбрана — в этом случае метод вернет действие типа HAND_POSITION
(или HAND_ALLY_POSITION
, мда) с номером выбранной позиции в поле data
. Кроме того, возможно выбрать другое действие (например, CONFIRM
или CANCEL
) из объекта ActionList
. Реализации методов ввода должны различать ситуации когда поле isEnabled
выставлено в false
и игнорировать ввод пользователем таких действий.
Класс игрового движка.
Все необходимое для работы добро мы рассмотрели, пришло время и движок реализовать. Создадим класс Game
со следующим наполнением:
class Game(
private val renderer: GameRenderer,
private val interactor: GameInteractor,
private val scenario: Scenario,
private val locations: List<Location>,
private val heroes: List<Hero>) {
private var timer = 0
private var currentHeroIndex = -1
private lateinit var currentHero: Hero
private lateinit var currentLocation: Location
private val deterrentPile = Pile()
private var encounteredDie: DiePair? = null
private var battleCheck: DieBattleCheck? = null
private val activeHandPositions = HandMask()
private val pickedHandPositions = HandMask()
private var phase: GamePhase = GamePhase.SCENARIO_START
private var screen = GameScreen.SCENARIO_INTRO
private var statusMessage = StatusMessage.EMPTY
private var actions: ActionList = ActionList.EMPTY
fun start() {
if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!")
if (locations.isEmpty()) throw IllegalStateException("Location list is empty!")
heroes.forEach { it.isAlive = true }
timer = scenario.initialTimer
//Draw initial hand for each hero
heroes.forEach(::drawInitialHand)
//First hero turn
currentHeroIndex = -1
changePhaseHeroTurnStart()
processCycle()
}
private fun drawInitialHand(hero: Hero) {
val hand = hero.hand
val favoredDie = hero.bag.drawOfType(hero.favoredDieType)
hand.addDie(favoredDie!!)
refillHeroHand(hero, false)
}
private fun refillHeroHand(hero: Hero, redrawScreen: Boolean = true) {
val hand = hero.hand
while (hand.dieCount < hand.capacity && hero.bag.size > 0) {
val die = hero.bag.draw()
hand.addDie(die)
if (redrawScreen) {
Audio.playSound(Sound.DIE_DRAW)
drawScreen()
Thread.sleep(500)
}
}
}
private fun changePhaseHeroTurnEnd() {
battleCheck = null
encounteredDie = null
phase = GamePhase.HERO_TURN_END
//Discard extra dice (or optional dice)
val hand = currentHero.hand
pickedHandPositions.clear()
activeHandPositions.clear()
val allowCancel =
if (hand.dieCount > hand.capacity) {
statusMessage = StatusMessage.END_OF_TURN_DISCARD_EXTRA
false
} else {
statusMessage = StatusMessage.END_OF_TURN_DISCARD_OPTIONAL
true
}
val result = pickDiceFromHand(DiscardExtraDiceHandMaskRule(hand), allowCancel)
statusMessage = StatusMessage.EMPTY
actions = ActionList.EMPTY
if (result) {
val discardDice = collectPickedDice(hand)
val discardAllyDice = collectPickedAllyDice(hand)
pickedHandPositions.clear()
(discardDice + discardAllyDice).forEach { die ->
Audio.playSound(Sound.DIE_DISCARD)
currentHero.discardDieFromHand(die)
drawScreen()
Thread.sleep(500)
}
}
pickedHandPositions.clear()
//Replenish hand
refillHeroHand(currentHero)
changePhaseHeroTurnStart()
}
private fun changePhaseHeroTurnStart() {
phase = GamePhase.HERO_TURN_START
screen = GameScreen.HERO_TURN_START
//Tick timer
timer--
if (timer < 0) {
changePhaseGameLost(StatusMessage.GAME_LOSS_OUT_OF_TIME)
return
}
//Pick next hero
do {
currentHeroIndex = ++currentHeroIndex % heroes.size
currentHero = heroes[currentHeroIndex]
} while (!currentHero.isAlive)
currentLocation = locations[0]
//Setup
Audio.playMusic(Music.SCENARIO_MUSIC_1)
Audio.playSound(Sound.TURN_START)
}
private fun changePhaseLocationBeforeExploration() {
phase = GamePhase.LOCATION_BEFORE_EXPLORATION
screen = GameScreen.LOCATION_INTERIOR
encounteredDie = null
battleCheck = null
pickedHandPositions.clear()
activeHandPositions.clear()
statusMessage = StatusMessage.CHOOSE_ACTION_BEFORE_EXPLORATION
actions = ActionList()
actions.add(Action.Type.EXPLORE_LOCATION, checkLocationCanBeExplored(currentLocation))
actions.add(Action.Type.FINISH_TURN)
}
private fun changePhaseLocationEncounterStatDie() {
Audio.playSound(Sound.ENCOUNTER_STAT)
phase = GamePhase.LOCATION_ENCOUNTER_STAT
screen = GameScreen.LOCATION_INTERIOR
battleCheck = null
pickedHandPositions.clear()
activeHandPositions.clear()
statusMessage = when (encounteredDie!!.die.type) {
Die.Type.PHYSICAL -> StatusMessage.ENCOUNTER_PHYSICAL
Die.Type.SOMATIC -> StatusMessage.ENCOUNTER_SOMATIC
Die.Type.MENTAL -> StatusMessage.ENCOUNTER_MENTAL
Die.Type.VERBAL -> StatusMessage.ENCOUNTER_VERBAL
else -> throw AssertionError("Should not happen")
}
val canAttemptCheck = checkHeroCanAttemptStatCheck(currentHero, encounteredDie!!.die.type)
actions = ActionList()
actions.add(Action.Type.HIDE, canAttemptCheck)
actions.add(Action.Type.DISCARD, canAttemptCheck)
actions.add(Action.Type.FORFEIT)
}
private fun changePhaseLocationEncounterDivineDie() {
Audio.playSound(Sound.ENCOUNTER_DIVINE)
phase = GamePhase.LOCATION_ENCOUNTER_DIVINE
screen = GameScreen.LOCATION_INTERIOR
battleCheck = null
pickedHandPositions.clear()
activeHandPositions.clear()
statusMessage = StatusMessage.ENCOUNTER_DIVINE
actions = ActionList()
actions.add(Action.Type.ACQUIRE, checkHeroCanAcquireDie(currentHero, Die.Type.DIVINE))
actions.add(Action.Type.FORFEIT)
}
private fun changePhaseLocationAfterExploration() {
phase = GamePhase.LOCATION_AFTER_EXPLORATION
screen = GameScreen.LOCATION_INTERIOR
encounteredDie = null
battleCheck = null
pickedHandPositions.clear()
activeHandPositions.clear()
statusMessage = StatusMessage.CHOOSE_ACTION_AFTER_EXPLORATION
actions = ActionList()
actions.add(Action.Type.FINISH_TURN)
}
private fun changePhaseGameLost(message: StatusMessage) {
Audio.stopMusic()
Audio.playSound(Sound.GAME_LOSS)
phase = GamePhase.GAME_LOSS
screen = GameScreen.GAME_LOSS
statusMessage = message
}
private fun pickDiceFromHand(rule: HandMaskRule, allowCancel: Boolean = true, onEachLoop: (() -> Unit)? = null): Boolean {
//Preparations
pickedHandPositions.clear()
actions = ActionList().add(Action.Type.CONFIRM, false)
if (allowCancel) {
actions.add(Action.Type.CANCEL)
}
val hand = rule.hand
while (true) {
//Recurring action
onEachLoop?.invoke()
//Define success condition
val canProceed = rule.checkMask(pickedHandPositions)
actions[Action.Type.CONFIRM]?.isEnabled = canProceed
//Prepare active hand commands
activeHandPositions.clear()
(0 until hand.dieCount)
.filter { rule.isPositionActive(pickedHandPositions, it) }
.forEach { activeHandPositions.addPosition(it) }
(0 until hand.allyDieCount)
.filter { rule.isAllyPositionActive(pickedHandPositions, it) }
.forEach { activeHandPositions.addAllyPosition(it) }
//Draw current phase
drawScreen()
//Process interaction result
val result = interactor.pickDiceFromHand(activeHandPositions, actions)
when (result.type) {
Action.Type.CONFIRM -> if (canProceed) {
activeHandPositions.clear()
return true
}
Action.Type.CANCEL -> if (allowCancel) {
activeHandPositions.clear()
pickedHandPositions.clear()
return false
}
Action.Type.HAND_POSITION -> {
Audio.playSound(Sound.DIE_PICK)
pickedHandPositions.switchPosition(result.data)
}
Action.Type.HAND_ALLY_POSITION -> {
Audio.playSound(Sound.DIE_PICK)
pickedHandPositions.switchAllyPosition(result.data)
}
else -> throw AssertionError("Should not happen")
}
}
}
private fun collectPickedDice(hand: Hand) =
(0 until hand.dieCount)
.filter(pickedHandPositions::checkPosition)
.mapNotNull(hand::dieAt)
private fun collectPickedAllyDice(hand: Hand) =
(0 until hand.allyDieCount)
.filter(pickedHandPositions::checkAllyPosition)
.mapNotNull(hand::allyDieAt)
private fun performStatDieAcquireCheck(shouldDiscard: Boolean): Boolean {
//Prepare check
battleCheck = DieBattleCheck(DieBattleCheck.Method.SUM, encounteredDie)
pickedHandPositions.clear()
statusMessage = StatusMessage.CHOOSE_DICE_PERFORM_CHECK
val hand = currentHero.hand
//Try to pick dice from performer's hand
if (!pickDiceFromHand(StatDieAcquireHandMaskRule(currentHero.hand, encounteredDie!!.die.type), true) {
battleCheck!!.clearHeroPairs()
(collectPickedDice(hand) + collectPickedAllyDice(hand))
.map { DiePair(it, if (shouldDiscard) 1 else 0) }
.forEach(battleCheck!!::addHeroPair)
}) {
battleCheck = null
pickedHandPositions.clear()
return false
}
//Remove dice from hand
collectPickedDice(hand).forEach { hand.removeDie(it) }
collectPickedAllyDice(hand).forEach { hand.removeDie(it) }
pickedHandPositions.clear()
//Perform check
Audio.playSound(Sound.BATTLE_CHECK_ROLL)
for (i in 0..7) {
battleCheck!!.roll()
drawScreen()
Thread.sleep(100)
}
battleCheck!!.calculateResult()
val result = battleCheck?.result ?: -1
val success = result >= 0
//Process dice which participated in the check
(0 until battleCheck!!.heroPairCount)
.map(battleCheck!!::getHeroPairAt)
.map(DiePair::die)
.forEach { d ->
if (d.type === Die.Type.DIVINE) {
currentHero.hand.removeDie(d)
deterrentPile.put(d)
} else {
if (shouldDiscard) {
currentHero.discardDieFromHand(d)
} else {
currentHero.hideDieFromHand(d)
}
}
}
//Show message to user
Audio.playSound(if (success) Sound.BATTLE_CHECK_SUCCESS else Sound.BATTLE_CHECK_FAILURE)
statusMessage = if (success) StatusMessage.DIE_ACQUIRE_SUCCESS else StatusMessage.DIE_ACQUIRE_FAILURE
actions = ActionList.EMPTY
drawScreen()
interactor.anyInput()
//Clean up
battleCheck = null
//Resolve consequences of the check
if (success) {
Audio.playSound(Sound.DIE_DRAW)
currentHero.hand.addDie(encounteredDie!!.die)
}
return true
}
private fun processCycle() {
while (true) {
drawScreen()
when (phase) {
GamePhase.HERO_TURN_START -> {
interactor.anyInput()
changePhaseLocationBeforeExploration()
}
GamePhase.GAME_LOSS -> {
interactor.anyInput()
return
}
GamePhase.LOCATION_BEFORE_EXPLORATION ->
when (interactor.pickAction(actions).type) {
Action.Type.EXPLORE_LOCATION -> {
val die = currentLocation.bag.draw()
encounteredDie = DiePair(die, 0)
when (die.type) {
Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL -> changePhaseLocationEncounterStatDie()
Die.Type.DIVINE -> changePhaseLocationEncounterDivineDie()
else -> TODO("Others")
}
}
Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd()
else -> throw AssertionError("Should not happen")
}
GamePhase.LOCATION_ENCOUNTER_STAT -> {
val type = interactor.pickAction(actions).type
when (type) {
Action.Type.DISCARD, Action.Type.HIDE -> {
performStatDieAcquireCheck(type === Action.Type.DISCARD)
changePhaseLocationAfterExploration()
}
Action.Type.FORFEIT -> {
Audio.playSound(Sound.DIE_REMOVE)
changePhaseLocationAfterExploration()
}
else -> throw AssertionError("Should not happen")
}
}
GamePhase.LOCATION_ENCOUNTER_DIVINE ->
when (interactor.pickAction(actions).type) {
Action.Type.ACQUIRE -> {
Audio.playSound(Sound.DIE_DRAW)
currentHero.hand.addDie(encounteredDie!!.die)
changePhaseLocationAfterExploration()
}
Action.Type.FORFEIT -> {
Audio.playSound(Sound.DIE_REMOVE)
changePhaseLocationAfterExploration()
}
else -> throw AssertionError("Should not happen")
}
GamePhase.LOCATION_AFTER_EXPLORATION ->
when (interactor.pickAction(actions).type) {
Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd()
else -> throw AssertionError("Should not happen")
}
else -> throw AssertionError("Should not happen")
}
}
}
private fun drawScreen() {
when (screen) {
GameScreen.HERO_TURN_START -> renderer.drawHeroTurnStart(currentHero)
GameScreen.LOCATION_INTERIOR -> renderer.drawLocationInteriorScreen(currentLocation, heroes, timer, currentHero, battleCheck, encounteredDie, null, pickedHandPositions, activeHandPositions, statusMessage, actions)
GameScreen.GAME_LOSS -> renderer.drawGameLoss(statusMessage)
}
}
private fun checkLocationCanBeExplored(location: Location) = location.isOpen && location.bag.size > 0
private fun checkHeroCanAttemptStatCheck(hero: Hero, type: Die.Type): Boolean {
return hero.isAlive && SingleDieHandFilter(type).test(hero.hand)
}
private fun checkHeroCanAcquireDie(hero: Hero, type: Die.Type): Boolean {
if (!hero.isAlive) {
return false
}
return when (type) {
Die.Type.ALLY -> hero.hand.allyDieCount < MAX_HAND_ALLY_SIZE
else -> hero.hand.dieCount < MAX_HAND_SIZE
}
}
}
Метод start()
— точка входа в игру. Здесь инициализируются переменные, взвешиваются герои, руки наполняются кубиками, а репортеры светят камерами со всех сторон. Главный цикл будет запущен с минуты на минуту, после чего его уже не остановить. Метод drawInitialHand()
говорит сам за себя (мы, кажется, не рассмотрели код метода drawOfType()
класса Bag
, но пройдя столь длинный путь вместе, этот код вы и сами напишете без труда). Метод refillHeroHand()
имеет два варианта (в зависимости от значения аргумента redrawScreen
): быстрый и тихий (когда нужно наполнить руки всех героев в начале игры), и громкий с кучей пафоса, когда в конце хода нужно демонстративно доставать кубики из сумки, доводя руку до нужного размера.
Куча методов с названиями, начинающимися с changePhase
, — как мы уже сказали, они служат для смены и текущей игровой фазы и занимаются присвоением соответствующих значений игровых переменных. Здесь же формируется список actions
, куда добавляются характерные для данной фазы действия.
Служебный метод pickDiceFromHand()
в обобщенном виде занимается выбором кубиков из руки. Сюда передается объект знакомого класса HandMaskRule
, задающего правила выбора. Тут же указывается возможность отказаться от выбора (allowCancel
), а также функция onEachLoop
, код которой необходимо вызывать при каждом изменении списка выбранных кубиков (обычно это перерисовка экрана). Выбранные этим методом кубики можно собрать из руки при помощи методов collectPickedDice()
и collectPickedAllyDice()
.
Еще один служебный метод performStatDieAcquireCheck()
полностью реализует прохождение героем проверки на приобретение нового кубика. Центральную роль в этом методе играет объект DieBattleCheck
. Процесс начинается с выбора кубиков методом pickDiceFromHand()
(на каждом шаге происходит обновление списка «участников» DieBattleCheck
). Выбранные кубики удаляются из руки, после чего присходит «бросок» — каждый кубик обновляет свое значение (восемь раз подряд), после чего подсчитывается и отображается результат. При успешном броске новый кубик попадает в руку героя. Участвовашие в проверке кубики либо удерживаются (если они голубые), либо сбрасываются (если shouldDiscard = true
), либо прячутся обратно в сумку (если shouldDiscard = false
).
Основной метод processCycle()
содержит бесконечный цикл (попрошу без обмороков), в котором сначала отрисовывается экран, затем у пользователя запрашивается ввод, затем происходит обработка этого ввода — со всеми вытекающими последствиями. Метод drawScreen()
вызывает нужный метод интерфейса GameRenderer
(в зависимости от текущего значения screen
), передавая ему требуемые объекты на вход.
Также класс содержит несколько вспомогательных методов: checkLocationCanBeExplored()
, checkHeroCanAttemptStatCheck()
и checkHeroCanAcquireDie()
. Их названия говорят сами за себя, потому не будем подробно на них останавливаться. А еще есть вызовы методов класса Audio
, подчеркнутые красной волнистой линией. Закомментируйте их до поры до времени — их предназначение мы рассмотрим позже.
Вот и все, игра готова (хе-хе). Остались сущие мелочи, о них ниже.
Шаг девятый. Вывод изображения на экран
Вот мы и подошли к главной теме сегодняшней беседы — графической составляющей приложения. Как вы помните, нашей задачей является реализация интерфейса GameRenderer
и трех его методов, причем поскольку талантливого художника в нашей команде все еще нет, делать это мы будем самостоятельно при помощи псевдографики. Но для начала неплохо бы понять, а что мы вообще ожидаем увидеть на выходе. А увидеть мы хотим три экрана приблизительно следующего содержания:
Думаю, большинство уже смекнуло, что представленные изображения отличаются от всего, что мы обычно привыкли видеть в консоли Java-приложения, и что возможностей обычного prinltn()
нам будет явно недостаточно. Хотелось бы еще уметь прыгать в произвольные места экрана и рисовать символы разными цветами.
К нам на помощь спешат Чип и Дейл коды ANSI. Отправляя на вывод причудливые последовательности символов, можно добиться не менее причудливых эффектов: менять цвет текста/фона, способ начертания символов, положение курсора на экране и многое другое. Разумеется, в чистом виде мы их вводить не будем — спрячем реализацию за методами класса. Да и сам класс мы с нуля писать не будем — к счастью, умные люди сделали это за нас. Нам же остается скачать и подключить к проекту какую-то легковесную библиотеку, например, Jansi:
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>1.17.1</version>
<scope>compile</scope>
</dependency>
И можно начинать творить. Данная библиотека предоставляет нам объект класса Ansi
(получается в результате статического вызова Ansi.ansi()
) с кучей удобных методов, которые можно объединять в цепочки. Работает по принципу StringBuilder
'а — сначала формируем объект, затем отправляем его на печать. Из полезных методов нам пригодятся:
a()
— для вывода символов;cursor()
— для перемещения курсора по экрану;eraseLine()
— как-бы говорит сам за себя;eraseScreen()
— аналогично;fg(), bg(), fgBright(), bgBright()
— очень неудобные методы для работы с цветами текста и фона — мы сделаем свои, более приятные;reset()
— для сброса установленных настроек цветов, мерцания итп.
Создадим класс ConsoleRenderer
со служебными методами, которые могут пригодиться нам в работе. Первая версия будет иметь приблизительно такой вид:
abstract class ConsoleRenderer() {
protected lateinit var ansi: Ansi
init {
AnsiConsole.systemInstall()
clearScreen()
resetAnsi()
}
private fun resetAnsi() {
ansi = Ansi.ansi()
}
fun clearScreen() {
print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1))
}
protected fun render() {
print(ansi.toString())
resetAnsi()
}
}
Метод resetAnsi()
создает новый (пустой) объект Ansi
, который будет наполняться нужными командами (перемещения, вывода итп). По завершении наполнения, сформированный объект отправляется на печать методом render()
, а переменная инициализируется новым объектом. Пока что ничего сложного, верно? А раз так, то начнем наполнять этот класс другими полезными методами.
Начнем с размеров. Стандартная консоль большинства терминалов имеет размер 80х24. Отметим этот факт двумя константами CONSOLE_WIDTH
и CONSOLE_HEIGHT
. Мы не будем привязываться к конкретным значениям и постараемся сделать дизайн максимально резиновым (как в вебе). Нумерация координат начинается с единицы, первая координата — строка, вторая — столбец. Зная все это, напишем служебный метод drawHorizontalLine()
для заполнения указанной строки указанным символом.
protected fun drawHorizontalLine(offsetY: Int, filler: Char) {
ansi.cursor(offsetY, 1)
(1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
//for (i in 1..CONSOLE_WIDTH) { ansi.a(filler) }
}
Еще раз напоминаю, что вызов команд a()
или cursor()
не приводит ни к какому мгновенному эффекту, а лишь добавляет в объект Ansi
соответствующие последовательности команд. Только когда эти последовательности будут отправлены на печать, мы увидим их на экране.
Между использованием классического цикла for
и функционального подхода с ClosedRange
и forEach{}
нет никакой принципиальной разницы — каждый разработчик сам решает, что ему удобнее. Однако я и дальше буду дурить вам головы функциональщиной, просто потому что я обезьяна, которая любит все новое и блестящее скобки не переносятся на новую строку и код выглядит компактнее.
Реализуем еще один служебный метод drawBlankLine()
, делающий то же самое, что и drawHorizontalLine(offsetY, ' ')
, только с расширением. Иногда нам понадобится сделать строку пустой не полностью, а оставить в начале и конце вертикальную черту (рамочку, ага). Код будет выглядеть как-то так:
protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) {
ansi.cursor(offsetY, 1)
if (drawBorders) {
ansi.a('│')
(2 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
ansi.a('│')
} else {
ansi.eraseLine(Ansi.Erase.ALL)
}
}
Как, вы никогда не рисовали рамочки из псевдографики? Символы можно вставлять прямо в исходный код. Зажимаете клавишу Alt и набираете код символа на цифровой клавиатуре. Затем отпускаете. Нужные нам ASCII-коды в любой кодировке одинаковые, вот минимальный джентельменский набор:
А дальше как в майнкрафте — возможности ограничены лишь пределами вашего воображения. И размером экрана.
protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) {
val center = (CONSOLE_WIDTH - text.length) / 2
ansi.cursor(offsetY, 1)
ansi.a(if (drawBorders) '│' else ' ')
(2 until center).forEach { ansi.a(' ') }
ansi.color(color).a(text).reset()
(text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') }
ansi.a(if (drawBorders) '│' else ' ')
}
Поговорим немножко о цветах. Класс Ansi
содержит константы Color
для восьми основных цветов (черный, синий, зеленый, голубой, красный, фиолетовый, желтый, серый), которые нужно передавать на вход методов fg()/bg()
для темного варианта или fgBright()/bgBright()
— для светлого, что делать жутко неудобно, так как для идентификации цвета таким способом нам недостаточно одного значения — нужно как-минимум два (цвет и яркость). Поэтому мы создадим свой список констант и свои методы-расширения (а еще карты-привязки цветов к типам кубиков и классам героев):
protected enum class Color {
BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY,
DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE
}
protected fun Ansi.color(color: Color?): Ansi = when (color) {
Color.BLACK -> fgBlack()
Color.DARK_BLUE -> fgBlue()
Color.DARK_GREEN -> fgGreen()
Color.DARK_CYAN -> fgCyan()
Color.DARK_RED -> fgRed()
Color.DARK_MAGENTA -> fgMagenta()
Color.DARK_YELLOW -> fgYellow()
Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE)
Color.DARK_GRAY -> fgBrightBlack()
Color.LIGHT_BLUE -> fgBrightBlue()
Color.LIGHT_GREEN -> fgBrightGreen()
Color.LIGHT_CYAN -> fgBrightCyan()
Color.LIGHT_RED -> fgBrightRed()
Color.LIGHT_MAGENTA -> fgBrightMagenta()
Color.LIGHT_YELLOW -> fgBrightYellow()
Color.WHITE -> fgBright(Ansi.Color.WHITE)
else -> this
}
protected fun Ansi.background(color: Color?): Ansi = when (color) {
Color.BLACK -> ansi.bg(Ansi.Color.BLACK)
Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE)
Color.DARK_GREEN -> ansi.bgGreen()
Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN)
Color.DARK_RED -> ansi.bgRed()
Color.DARK_MAGENTA -> ansi.bgMagenta()
Color.DARK_YELLOW -> ansi.bgYellow()
Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE)
Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK)
Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE)
Color.LIGHT_GREEN -> ansi.bgBrightGreen()
Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN)
Color.LIGHT_RED -> ansi.bgBrightRed()
Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA)
Color.LIGHT_YELLOW -> ansi.bgBrightYellow()
Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE)
else -> this
}
protected val dieColors = mapOf(
Die.Type.PHYSICAL to Color.LIGHT_BLUE,
Die.Type.SOMATIC to Color.LIGHT_GREEN,
Die.Type.MENTAL to Color.LIGHT_MAGENTA,
Die.Type.VERBAL to Color.LIGHT_YELLOW,
Die.Type.DIVINE to Color.LIGHT_CYAN,
Die.Type.WOUND to Color.DARK_GRAY,
Die.Type.ENEMY to Color.DARK_RED,
Die.Type.VILLAIN to Color.LIGHT_RED,
Die.Type.OBSTACLE to Color.DARK_YELLOW,
Die.Type.ALLY to Color.WHITE
)
protected val heroColors = mapOf(
Hero.Type.BRAWLER to Color.LIGHT_BLUE,
Hero.Type.HUNTER to Color.LIGHT_GREEN
)
Теперь каждый из 16-ти доступных цветов однозначно идентифицируется единственной константой. Напишем еще пару служебных методов, но перед этим разберемся вот еще с чем:
Где хранить константы для текстовых строк?
«Строковые константы нужно выносить в отдельные файлы, чтобы они хранились все в одном месте — так их легче поддерживать. А еще это важно для локализации...»
Строковые константы нужно выносить в отдельные файлы… ну да. Вынесем и мы. Стандартным механизмом Java для работы с такого рода ресурсами являются объекты java.util.ResourceBundle
, работающие с файлами .properties
. Вот с такого файла и начнем:
# Game status messages
choose_dice_perform_check=Choose dice to perform check:
end_of_turn_discard_extra=END OF TURN: Discard extra dice:
end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed:
choose_action_before_exploration=Choose your action:
choose_action_after_exploration=Already explored this turn. Choose what to do now:
encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die.
encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die.
encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die.
encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die.
encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed):
die_acquire_success=You have acquired the die!
die_acquire_failure=You have failed to acquire the die.
game_loss_out_of_time=You ran out of time
# Die types
physical=PHYSICAL
somatic=SOMATIC
mental=MENTAL
verbal=VERBAL
divine=DIVINE
ally=ALLY
wound=WOUND
enemy=ENEMY
villain=VILLAIN
obstacle=OBSTACLE
# Hero types and descriptions
brawler=Brawler
hunter=Hunter
# Various labels
avg=avg
bag=Bag
bag_size=Bag size
class=Class
closed=Closed
discard=Discard
empty=Empty
encountered=Encountered
fail=Fail
hand=Hand
heros_turn=%s's turn
max=max
min=min
perform_check=Perform check:
pile=Pile
received_new_die=Received new die
result=Result
success=Success
sum=sum
time=Time
total=Total
# Action names and descriptions
action_confirm_key=ENTER
action_confirm_name=Confirm
action_cancel_key=ESC
action_cancel_name=Cancel
action_explore_location_key=E
action_explore_location_name=xplore
action_finish_turn_key=F
action_finish_turn_name=inish
action_hide_key=H
action_hide_name=ide
action_discard_key=D
action_discard_name=iscard
action_acquire_key=A
action_acquire_name=cquire
action_leave_key=L
action_leave_name=eave
action_forfeit_key=F
action_forfeit_name=orfeit
Каждая строка содержит пару ключ-значения, разделенные символом =
. Файл можно положить куда угодно — главное, чтобы путь к нему входил в classpath. Обратите внимание, текст для действий состоит из двух частей: первая буква не только выделяется желтым цветом при отображении на экране, но еще и определяет клавишу, которую необходимо нажать для выполнения этого действия. Поэтому и хранить их удобно по отдельности.
Абстрагируемся, однако, от конкретного формата (в Андроиде, например, строки хранятся по-другому) и опишем интерфейс для загрузки строковых констант.
interface StringLoader {
fun loadString(key: String): String
}
На вход передается ключ, на выходе получаем конкретную строку. Реализация так же незамысловата, как и сам интерфейс (предположим, что файл лежит по пути src/main/resources/text/strings.properties
).
class PropertiesStringLoader() : StringLoader {
private val properties = ResourceBundle.getBundle("text.strings")
override fun loadString(key: String) = properties.getString(key) ?: ""
}
Теперь не составит труда реализовать метод drawStatusMessage()
для отображения на экране текущего состояния игрового движка (StatusMessage
) и метод drawActionList()
для отображения списка доступных действий (ActionList
). А также других служебных методов, какие только душа пожелает.
abstract class ConsoleRenderer(private val strings: StringLoader) {
protected lateinit var ansi: Ansi
init {
AnsiConsole.systemInstall()
clearScreen()
resetAnsi()
}
protected fun loadString(key: String) = strings.loadString(key)
private fun resetAnsi() {
ansi = Ansi.ansi()
}
fun clearScreen() {
print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1))
}
protected fun render() {
ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH)
System.out.print(ansi.toString())
resetAnsi()
}
protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) {
var currentX = offsetX
cursor(offsetY, currentX)
val text = number.toString()
text.forEach {
when (it) {
'0' -> {
cursor(offsetY, currentX)
a(" ███ ")
cursor(offsetY + 1, currentX)
a("█ █ ")
cursor(offsetY + 2, currentX)
a("█ █ ")
cursor(offsetY + 3, currentX)
a("█ █ ")
cursor(offsetY + 4, currentX)
a(" ███ ")
}
'1' -> {
cursor(offsetY, currentX)
a(" █ ")
cursor(offsetY + 1, currentX)
a(" ██ ")
cursor(offsetY + 2, currentX)
a("█ █ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a("█████ ")
}
'2' -> {
cursor(offsetY, currentX)
a(" ███ ")
cursor(offsetY + 1, currentX)
a("█ █ ")
cursor(offsetY + 2, currentX)
a(" █ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a("█████ ")
}
'3' -> {
cursor(offsetY, currentX)
a("████ ")
cursor(offsetY + 1, currentX)
a(" █ ")
cursor(offsetY + 2, currentX)
a(" ██ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a("████ ")
}
'4' -> {
cursor(offsetY, currentX)
a(" █ ")
cursor(offsetY + 1, currentX)
a(" ██ ")
cursor(offsetY + 2, currentX)
a(" █ █ ")
cursor(offsetY + 3, currentX)
a("█████ ")
cursor(offsetY + 4, currentX)
a(" █ ")
}
'5' -> {
cursor(offsetY, currentX)
a("█████ ")
cursor(offsetY + 1, currentX)
a("█ ")
cursor(offsetY + 2, currentX)
a("████ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a("████ ")
}
'6' -> {
cursor(offsetY, currentX)
a(" ███ ")
cursor(offsetY + 1, currentX)
a("█ ")
cursor(offsetY + 2, currentX)
a("████ ")
cursor(offsetY + 3, currentX)
a("█ █ ")
cursor(offsetY + 4, currentX)
a(" ███ ")
}
'7' -> {
cursor(offsetY, currentX)
a("█████ ")
cursor(offsetY + 1, currentX)
a(" █ ")
cursor(offsetY + 2, currentX)
a(" █ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a(" █ ")
}
'8' -> {
cursor(offsetY, currentX)
a(" ███ ")
cursor(offsetY + 1, currentX)
a("█ █ ")
cursor(offsetY + 2, currentX)
a(" ███ ")
cursor(offsetY + 3, currentX)
a("█ █ ")
cursor(offsetY + 4, currentX)
a(" ███ ")
}
'9' -> {
cursor(offsetY, currentX)
a(" ███ ")
cursor(offsetY + 1, currentX)
a("█ █ ")
cursor(offsetY + 2, currentX)
a(" ████ ")
cursor(offsetY + 3, currentX)
a(" █ ")
cursor(offsetY + 4, currentX)
a(" ███ ")
}
}
currentX += 6
}
}
protected fun drawHorizontalLine(offsetY: Int, filler: Char) {
ansi.cursor(offsetY, 1)
(1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
}
protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) {
ansi.cursor(offsetY, 1)
if (drawBorders) {
ansi.a('│')
(2 until CONSOLE_WIDTH).forEach { ansi.a(' ') }
ansi.a('│')
} else {
ansi.eraseLine(Ansi.Erase.ALL)
}
}
protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) {
val center = (CONSOLE_WIDTH - text.length) / 2
ansi.cursor(offsetY, 1)
ansi.a(if (drawBorders) '│' else ' ')
(2 until center).forEach { ansi.a(' ') }
ansi.color(color).a(text).reset()
(text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') }
ansi.a(if (drawBorders) '│' else ' ')
}
protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) {
//Setup
val messageText = loadString(message.toString().toLowerCase())
var currentX = 1
val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0
//Left border
ansi.cursor(offsetY, 1)
if (drawBorders) {
ansi.a('│')
currentX++
}
ansi.a(' ')
currentX++
//Text
ansi.a(messageText)
currentX += messageText.length
//Right border
(currentX..rightBorder).forEach { ansi.a(' ') }
if (drawBorders) {
ansi.a('│')
}
}
protected fun drawActionList(offsetY: Int, actions: ActionList, drawBorders: Boolean = true) {
val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0
var currentX = 1
//Left border
ansi.cursor(offsetY, 1)
if (drawBorders) {
ansi.a('│')
currentX++
}
ansi.a(' ')
currentX++
//List of actions
actions.forEach { action ->
val key = loadString("action_${action.toString().toLowerCase()}_key")
val name = loadString("action_${action.toString().toLowerCase()}_name")
val length = key.length + 2 + name.length
if (currentX + length >= rightBorder) {
(currentX..rightBorder).forEach { ansi.a(' ') }
if (drawBorders) {
ansi.a('│')
}
ansi.cursor(offsetY + 1, 1)
currentX = 1
if (drawBorders) {
ansi.a('│')
currentX++
}
ansi.a(' ')
currentX++
}
if (action.isEnabled) {
ansi.color(Color.LIGHT_YELLOW)
}
ansi.a('(').a(key).a(')').reset()
ansi.a(name)
ansi.a(" ")
currentX += length + 2
}
//Right border
(currentX..rightBorder).forEach { ansi.a(' ') }
if (drawBorders) {
ansi.a('│')
}
}
protected enum class Color {
BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY,
DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE
}
protected fun Ansi.color(color: Color?): Ansi = when (color) {
Color.BLACK -> fgBlack()
Color.DARK_BLUE -> fgBlue()
Color.DARK_GREEN -> fgGreen()
Color.DARK_CYAN -> fgCyan()
Color.DARK_RED -> fgRed()
Color.DARK_MAGENTA -> fgMagenta()
Color.DARK_YELLOW -> fgYellow()
Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE)
Color.DARK_GRAY -> fgBrightBlack()
Color.LIGHT_BLUE -> fgBrightBlue()
Color.LIGHT_GREEN -> fgBrightGreen()
Color.LIGHT_CYAN -> fgBrightCyan()
Color.LIGHT_RED -> fgBrightRed()
Color.LIGHT_MAGENTA -> fgBrightMagenta()
Color.LIGHT_YELLOW -> fgBrightYellow()
Color.WHITE -> fgBright(Ansi.Color.WHITE)
else -> this
}
protected fun Ansi.background(color: Color?): Ansi = when (color) {
Color.BLACK -> ansi.bg(Ansi.Color.BLACK)
Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE)
Color.DARK_GREEN -> ansi.bgGreen()
Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN)
Color.DARK_RED -> ansi.bgRed()
Color.DARK_MAGENTA -> ansi.bgMagenta()
Color.DARK_YELLOW -> ansi.bgYellow()
Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE)
Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK)
Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE)
Color.LIGHT_GREEN -> ansi.bgBrightGreen()
Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN)
Color.LIGHT_RED -> ansi.bgBrightRed()
Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA)
Color.LIGHT_YELLOW -> ansi.bgBrightYellow()
Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE)
else -> this
}
protected val dieColors = mapOf(
Die.Type.PHYSICAL to Color.LIGHT_BLUE,
Die.Type.SOMATIC to Color.LIGHT_GREEN,
Die.Type.MENTAL to Color.LIGHT_MAGENTA,
Die.Type.VERBAL to Color.LIGHT_YELLOW,
Die.Type.DIVINE to Color.LIGHT_CYAN,
Die.Type.WOUND to Color.DARK_GRAY,
Die.Type.ENEMY to Color.DARK_RED,
Die.Type.VILLAIN to Color.LIGHT_RED,
Die.Type.OBSTACLE to Color.DARK_YELLOW,
Die.Type.ALLY to Color.WHITE
)
protected val heroColors = mapOf(
Hero.Type.BRAWLER to Color.LIGHT_BLUE,
Hero.Type.HUNTER to Color.LIGHT_GREEN
)
protected open fun shortcut(index: Int) = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index]
}
Для чего мы все это делали, спросите вы? Да для того, чтобы унаследовать от этого замечательного класса нашу реализацию интерфейса GameRenderer
.
Вот так будет выглядеть реализация первого, самого простого метода:
override fun drawGameLoss(message: StatusMessage) {
val centerY = CONSOLE_HEIGHT / 2
(1 until centerY).forEach { drawBlankLine(it, false) }
val data = loadString(message.toString().toLowerCase()).toUpperCase()
drawCenteredCaption(centerY, data, LIGHT_RED, false)
(centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
render()
}
Ничего сверхъестественного, просто одна текстовая строка (data
), нарисованная красным цветом в центре экрана (drawCenteredCaption()
). Остальной код заполняет пустыми строками оставшуюся часть экрана. Возможно, кто-то спросит, зачем это нужно — есть ведь метод clearScreen()
, достаточно вызвать его в начале метода, очистить экран, а потом отрисовать нужный текст. Увы, это ленивый подход, использовать который мы не станем. Причина очень проста: при таком подходе некоторые позиции на экране отрисовываются по два раза, что приводит к заметному мерцанию, особенно когда экран последовательно отрисовывается несколько раз подряд (во время анимаций). Поэтому нашей задачей является не просто отрисовать нужные символы в нужных местах, но и заполнить весь остальной экран пустыми символами (чтобы на нем не оставались артефакты от прочей отрисовки). А эта задача уже не так проста.
Следующий метод следует этому принципу:
override fun drawHeroTurnStart(hero: Hero) {
val centerY = (CONSOLE_HEIGHT - 5) / 2
(1 until centerY).forEach { drawBlankLine(it, false) }
ansi.color(heroColors[hero.type])
drawHorizontalLine(centerY, '─')
drawHorizontalLine(centerY + 4, '─')
ansi.reset()
ansi.cursor(centerY + 1, 1).eraseLine()
ansi.cursor(centerY + 3, 1).eraseLine()
ansi.cursor(centerY + 2, 1)
val text = String.format(loadString("heros_turn"), hero.name.toUpperCase())
val index = text.indexOf(hero.name.toUpperCase())
val center = (CONSOLE_WIDTH - text.length) / 2
ansi.cursor(centerY + 2, center)
ansi.eraseLine(Ansi.Erase.BACKWARD)
ansi.a(text.substring(0, index))
ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset()
ansi.a(text.substring(index + hero.name.length))
ansi.eraseLine(Ansi.Erase.FORWARD)
(centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) }
render()
}
Здесь помимо отцентрованного текста также присутствуют две горизонтальные линии (смотрите скриншоты выше). Обратите внимание, что центральная надпись отображается двумя цветами. А также убедитесь, что учить математику в школе все-таки полезно.
Ну что ж, мы рассмотрели самые простые методы и пришло время познакомиться с реализацией drawLocationInteriorScreen()
. Как вы и сами понимаете, кода здесь будет на порядок больше. Кроме того, содержимое экрана будет динамически меняться в ответ на действия пользователя и его придется постоянно перерисовывать (иногда с анимацией). Ну и чтобы окончательно вас добить: представьте, что помимо приведенного выше на снимке экрана, в рамках данного метода необходимо реализовать отображение еще трех:
Поэтому вот мой вам большой совет: не пихайте весь код в один метод. Разбейте реализацию на несколько методов (даже если каждый из них будет вызываться только один раз). Ну и о «резине» не забывайте.
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, а наше приложение консольное — без всей этой графической мишуры.
Что же делать? Искать библиотеки, разумеется, и в этот раз их работа будет всецело опираться на нативный код. Что значит «прощай, кроссплатформенность»… Или нет? Увы, мне еще предстоит найти библиотеку, которая в легковесном, независимом от платформы виде реализует простую функциональность. А пока что обратим внимание на монстра jLine, реализующего комбайн по построению продвинутых пользовательских интерфейсов (в консоли). Да, он имеет нативную реализацию, да, он поддерживает как Windows, так и Linux/UNIX (путем предоставления соответствующих библиотек). И да, большая часть его функциональности нам триста лет не нужна. Нужна лишь мелкая, плохо документированная возможность, работу которой мы сейчас разберем.
<dependency>
<groupId>jline</groupId>
<artifactId>jline</artifactId>
<version>2.14.6</version>
<scope>compile</scope>
</dependency>
Обратите внимание, нам понадобится не третяя, последняя версия, а вторая, где есть класс ConsoleReader
с методом readCharacter()
. Как понятно из названия, данный метод возвращает код нажатого на клавиатуре символа (при этом работает синхронно, что нам и нужно). Остальное — дело техники: составить таблицу соответствий между символами и типами действий (Action.Type
) и по нажатию на одно возвращать другое.
«А известно ли тебе, что не все клавиши на клавиатуре можно представить одним символом? Многие клавиши используют escape-последовательности из двух, трех, четырех разных символов. Как быть с ними?»
Следует отметить, что задача ввода усложняется, если мы захотим распознавать «несимвольные клавиши»: стрелки, F-ки, Home, Insert, PgUp/Dn, End, Delete, num-pad и прочие. Но мы не хотим, потому продолжим. Создадим класс ConsoleInteractor
с необходимыми нам служебными методами.
abstract class ConsoleInteractor {
private val reader = ConsoleReader()
private val mapper = mapOf(
CONFIRM to 13.toChar(),
CANCEL to 27.toChar(),
EXPLORE_LOCATION to 'e',
FINISH_TURN to 'f',
ACQUIRE to 'a',
LEAVE to 'l',
FORFEIT to 'f',
HIDE to 'h',
DISCARD to 'd',
)
protected fun read() = reader.readCharacter().toChar()
protected open fun getIndexForKey(key: Char) =
"1234567890abcdefghijklmnopqrstuvw".indexOf(key)
}
Задаем карту mapper
и метод read()
. Кроме того предусмотрим метод getIndexForKey()
, использующийся в ситуациях, когда нам необходимо выбрать элемент из списка или кубики из руки. Осталось унаследовать от этого класса нашу реализацию интерфейса GameInteractor
.
И, собственно, код:
class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor {
override fun anyInput() {
read()
}
override fun pickAction(list: ActionList): Action {
while (true) {
val key = read()
list
.filter(Action::isEnabled)
.find { mapper[it.type] == key }
?.let { return it }
}
}
override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList)
: Action {
while (true) {
val key = read()
actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it }
when (key) {
in '1'..'9' -> {
val index = key - '1'
if (activePositions.checkPosition(index)) {
return Action(HAND_POSITION, data = index)
}
}
'0' -> {
if (activePositions.checkPosition(9)) {
return Action(HAND_POSITION, data = 9)
}
}
in 'a'..'f' -> {
val allyIndex = key - 'a'
if (activePositions.checkAllyPosition(allyIndex)) {
return Action(HAND_ALLY_POSITION, data = allyIndex)
}
}
}
}
}
}
Реализации наших методов достаточно вежливы и воспитанны, чтобы не возвращать наружу разную неадекватную ерунду. Они сами проверяют, чтобы выбранное действие было активным, а выбранная позиция руки входила в набор допустимых. И нам всем желаю быть такими же вежливыми с окружающими нас людьми.
Шаг одиннадцатый. Звуки и музычка
А то как же без них-то? Если вы хоть раз играли в игры с выключенным звуком (например, с планшетом под одеялом, пока никто из домашних не видит), вы возможно осознавали, как многого вы лишаетесь. Это как будто играть только в половину игры. Многие игры невозможно вообразить без звукового сопровождения, для многих это и вовсе неотъемлемое требование, хотя бывают и обратные ситуации (например, когда звуков нет в принципе, или они настолько убогие, что лучше бы без них). Сделать дело хорошо на самом деле не так просто, как кажется на первый взгляд (недаром в больших студиях этим занимаются высококвалифицированные специалисты), но как бы там ни было, в большинстве случаев иметь в своей игре аудиальную составляющую (хоть какую-то) гораздо лучше, чем не иметь ее вовсе. В крайнем случае, качество звука можно улучшить позже, когда время и настроение позволит.
Ввиду специфики жанра, наша игра не будет характеризоваться шедевральными звуковыми эффектами — если вы играли в цифровые адаптации настольных игр, то понимаете о чем я. Звуки отталкивают своей однообразостью, скоро приедаются и через некоторое время игра без них уже не кажется серьезной потерей. Проблема усугубляется тем, что отсутствуют эффективные способы борьбы с этим явлением. Замените игровые звуки на совершенно другие, и со временем опостылеют и они. В хороших играх звуки дополняют игровой процесс, раскрывают атмосферу происходящего действа, делают ее живой — этого сложно добиться в случае, если атмосфера — всего лишь стол с кучей пыльных мешков, а весь игровой процесс состоит в кидании кубиков. Тем не менее, именно это мы и станем озвучивать: шелчок тут, бросок здесь, шелест и шуршание под громкие крики — как будто мы не картинку на экране наблюдаем, а действительно взаимодействуем с реальными физическими объектами. Озвучивать их нужно полноценно, но ненавязчиво — на протяжении сценария вы будете слышать одно и то же сотню раз, поэтому звуки не должны выходить на первый план — лишь мягко оттенять игровой процесс. Как грамотно этого добиться? Понятия не имею, я не спец по звуку. Могу лишь посоветовать как можно больше играть в свою игру, замечая и шлифуя бросающиеся в глаза недостатки (этот совет, кстати, не только к звукам относится).
С теорией, кажись, разобрались, теперь пора и к практике перейти. А перед этим нужно задаться вопросом: а где, собственно, брать игровые файлы? Самый простой и верный способ — записать их самому в уродливом качестве, используя старенький микрофон или вообще телефоном пользуясь. В интернете полно роликов о том, как откручивая ботву ананаса или ломая лед сапогом можно добиться эффекта дробящихся костей и хрустящего позвоночника. Если вы не чужды эстетики сюрреализма, можете воспользоваться собственным голосом или кухонной утварью в качестве музыкального инструмента (есть примеры — и даже удачные — где такое делалось). А можете пойти на freesound.org, где сотня других людей давным-давно сделала это за вас. Только на лицензию обращайте внимание: многие авторы очень трепетно относятся к аудиозаписям своего громкого кашля или брошенной на пол монетки — вы ни в коем случае не хотите бессовестно воспользоваться плодами их трудов, не заплатив оригинальному создателю или не упомянув его творческий псевдоним (иногда весьма причудливый) в комментариях.
Натаскайте файликов, какие понравятся, и сложите их куда-нибудь в classpath. Для их идентификации будем использовать перечисление, где каждый экземпляр соответствует одному звуковому эффекту.
enum class Sound {
TURN_START, //Hero starts the turn
BATTLE_CHECK_ROLL, //Perform check, type
BATTLE_CHECK_SUCCESS, //Check was successful
BATTLE_CHECK_FAILURE, //Check failed
DIE_DRAW, //Draw die from bag
DIE_HIDE, //Remove die to bag
DIE_DISCARD, //Remove die to pile
DIE_REMOVE, //Remove die entirely
DIE_PICK, //Check/uncheck the die
TRAVEL, //Move hero to another location
ENCOUNTER_STAT, //Hero encounters STAT die
ENCOUNTER_DIVINE, //Hero encounters DIVINE die
ENCOUNTER_ALLY, //Hero encounters ALLY die
ENCOUNTER_WOUND, //Hero encounters WOUND die
ENCOUNTER_OBSTACLE, //Hero encounters OBSTACLE die
ENCOUNTER_ENEMY, //Hero encounters ENEMY die
ENCOUNTER_VILLAIN, //Hero encounters VILLAIN die
DEFEAT_OBSTACLE, //Hero defeats OBSTACLE die
DEFEAT_ENEMY, //Hero defeats ENEMY die
DEFEAT_VILLAIN, //Hero defeats VILLAIN die
TAKE_DAMAGE, //Hero takes damage
HERO_DEATH, //Hero death
CLOSE_LOCATION, //Location closed
GAME_VICTORY, //Scenario completed
GAME_LOSS, //Scenario failed
ERROR, //When something unexpected happens
}
Поскольку способ воспроизведения звуков будет разниться в зависимости от аппаратной платформы, акбстрагируемсчя от конкретной реализации при помощи интерфейса. Например, вот такого:
interface SoundPlayer {
fun play(sound: Sound)
}
Подобно ранее рассмотренным интерфейсам GameRenderer
и GameInteractor
, его реализацию также необходимо передавать на вход экземпляру класса Game
. Для начала, реализация может быть такой:
class MuteSoundPlayer : SoundPlayer {
override fun play(sound: Sound) {
//Do nothing
}
}
Впоследствии мы рассмотрим более интересные реализации, а пока поговорим о музыке.
Подобно звуковым эффектам, она играет огромную роль в создании атмосферы игры, и точно также прекрасную игру можно загубить неподходящей музыкой. Как и звуки, музыка должна быть ненавязчивой, не выходить на первый план (кроме случаев, когда это необходимо для художественного эффекта) и адекватно соответствовать творящемуся на экране действу (не надейтесь, что кто-то всерьез проникнется судьбой попавшего в засаду и безжалостно убитого главного героя, если сцена его трагической гибели будет сопровождаться веселой музычкой из детской песенки). Добиться этого весьма непросто, специально обученные люди занимаются такими вопросами (мы с ними незнакомы), но и мы, как начинающие гении игростроя, тоже чего-то можем. Например, зайти куда-нибудь на freemusicarchive.org или soundcloud.com (или даже YouTube) и найти что-нибудь по душе. Для настолок хорошим выбором будет ambient — тихая плавная музыка без выраженной мелодии, хорошо подходящая для создания фона. Вдвойне обращайте внимание на лицензию: даже бесплатную музыку порой пишут талантливые композиторы, заслуживающие если и не денежного вознаграждения, то по крайней мере всеобщего признания.
Создадим еще одно перечисление:
enum class Music {
SCENARIO_MUSIC_1,
SCENARIO_MUSIC_2,
SCENARIO_MUSIC_3,
}
Аналогичным образом определим интерфейс и его реализацию по умолчанию.
interface MusicPlayer {
fun play(music: Music)
fun stop()
}
class MuteMusicPlayer : MusicPlayer {
override fun play(music: Music) {
//Do nothing
}
override fun stop() {
//Do nothing
}
}
Обратите внимание, в этом случае нужны два метода: один для начала воспроизведения, другой для его остановки. Также вполне возможно, в дальнейшем пригодятся дополнительные методы (пауза/возобновление, перемотка итп), но пока что хватит и этих двух.
Каждый раз передавать ссылки на классы-проигрыватели между объектами может показаться не очень удобным решением. В единицу времени нам нужен лишь один экзепмляр проигрывателя, а потому рискну предложить вынести все необходимые для воспроизведения звуков и музыки методы в отдельный объект и сделать его одиночкой (singleton). Таким образом отвечающая за аудио подсистема всегда доступна из любого места приложения без постоянной передачи ссылок на один и тот же экземпляр. Выглядеть это будет например так:
Класс Audio
— это и есть наш singleton. Он предоставляет единый фасад к подсистеме… кстати, вот фасад (facade) — еще один паттерн проектирования, досконально проработанный и неоднократно описанный (с примерами) в этих ваших интернетах. Потому, уже слыша недовольные крики с задних рядов, я прекращаю растолковывать давным-давно известные вещи и двигаюсь дальше. Код вот:
object Audio {
private var soundPlayer: SoundPlayer = MuteSoundPlayer()
private var musicPlayer: MusicPlayer = MuteMusicPlayer()
fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) {
this.soundPlayer = soundPlayer
this.musicPlayer = musicPlayer
}
fun playSound(sound: Sound) = this.soundPlayer.play(sound)
fun playMusic(music: Music) = this.musicPlayer.play(music)
fun stopMusic() = this.musicPlayer.stop()
}
Достаточно вызвать init()
один-единственный раз где-то в самом начале (инициализировав его нужными объектами) и в дальнейшем пользоваться удобными методами, полностью забыв о подробностях реализации. Даже если вы этого не сделаете, не волнуйтесь, система на умрет — объект будет инициализирован классами по умолчанию.
Вот и все. Осталось разобраться собственно с воспроизведением. Что касается проигрывания звуков (или, как говорят умные люди, сэмплов), то в Java есть удобный класс AudioSystem
и интерфейс Clip
. Все, что нам нужно, это правильно прописать путь к аудио-файлу (который лежит у нас в classpath, помните?):
import javax.sound.sampled.AudioSystem
class BasicSoundPlayer : SoundPlayer {
private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav"
override fun play(sound: Sound) {
val url = javaClass.getResource(pathToFile(sound))
val audioIn = AudioSystem.getAudioInputStream(url)
val clip = AudioSystem.getClip()
clip.open(audioIn)
clip.start()
}
}
Метод open()
может выбросить IOException
(особенно если ему чем-то не понравился формат файла — в этом случае рекомендую открыть файл в аудио-редакторе и пересохранить), поэтому его неплохо бы обернуть в блок try-catch
, но мы на первых порах не станем этого делать, чтоб приложение громко падало каждый раз при проблемах со звуком.
«Я даже не знаю, что сказать...»
С музыкой дела обстоят намного хуже. Насколько мне известно, стандартного способа проигрывания музыкальных файлов (например, в формате mp3) в Java нет, поэтому вам в любом случае придется пользоваться сторонней библиотекой (коих десятки разных). Нам подойдет любая легковесная с минимальным функционалом, например довольно популярная JLayer. Добавим ее в зависимости:
<dependencies>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1.4</version>
<scope>compile</scope>
</dependency>
</dependencies>
И реализуем с ее помощью наш проигрыватель.
class BasicMusicPlayer : MusicPlayer {
private var currentMusic: Music? = null
private var thread: PlayerThread? = null
private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3"
override fun play(music: Music) {
if (currentMusic == music) {
return
}
currentMusic = music
thread?.finish()
Thread.yield()
thread = PlayerThread(pathToFile(music))
thread?.start()
}
override fun stop() {
currentMusic = null
thread?.finish()
}
// Thread responsible for playback
private inner class PlayerThread(private val musicPath: String) : Thread() {
private lateinit var player: Player
private var isLoaded = false
private var isFinished = false
init {
isDaemon = true
}
override fun run() {
loop@ while (!isFinished) {
try {
player = Player(javaClass.getResource(musicPath).openConnection().apply {
useCaches = false
}.getInputStream())
isLoaded = true
player.play()
} catch (ex: Exception) {
finish()
break@loop
}
player.close()
}
}
fun finish() {
isFinished = true
this.interrupt()
if (isLoaded) {
player.close()
}
}
}
}
Во-первых, данная библиотека выполняет воспроизведение синхронно, блокируя основной поток до тех пор, пока не будет достигнут конца файла. Поэтому мы должны реализовать отдельный поток (PlayerThread
), причем сделать его «необязательным» (демоном), чтобы он ни в коем случае не мешал приложению досрочно завершаться. Во-вторых, в коде проигрывателя сохраняется идентификатор проигрываемого в данный момент музыкального файла (currentMusic
). Если вдруг придет повторная команда на его воспроизведение, мы не будем начинать проигрывание с самого начала. В-третьих, по достижении конца музыкального файла его воспроизведение начнется заново — и так до тех пор, пока поток не будет явно остановлен командой finish()
(или пока не завершатся другие потоки, о чем уже было сказано). В-четвертых, хоть приведенный код и изобилует кажущимися ненужными флагами и командами, он тщательно отлажен и протестирован — проигрыватель работает как положено, не тормозит систему, не прерывается внезапно на полпути, не приводит к утечкам памяти, не содержит генно-модифицированных объектов, сияет свежестью и чистотой. Берите и смело пользуйтесь в своих проектах.
Шаг двенадцатый. Локализация
Наша игра почти готова, но играть в нее никто не будет. Почему?
«Русского нет!.. Нет русского!.. Добавьте русский язык!.. Разрабы псы!»
Откройте страницу любой интересной сюжетной игры (особенно мобильной) на сайте магазина и почитайте отзывы. Станут ли там хвалить потрясающую, заботливо нарисованную от руки графику? Или поражаться атмосферности звукового сопровождения? Или обсуждать захватывающий сюжет, который затягивает с первой минуты и не отпускает до самого конца?
Нет. Недовольные «игроки» наставят кучу единиц и вообще удалят игру. А то еще и деньги назад потребуют — и все это по одной простой причине. Да, вы забыли перевести свой шедевр на все 95 мировых языков. А вернее, на тот единственный, носители которого кричат громче всех. И всё! Понимаете? Месяцы кропотливой работы, долгие бессонные ночи, постоянные нервные срывы — все это хомяку под хвост. Вы лишились огромного количества игроков и это уже никак не исправить.
Поэтому думайте заранее. Определитесь со своей целевой аудиторией, выберите несколько основных языков, закажите услуги переводчика… в общем, делайте все то, что не раз описывали в тематических статьях другие люди (поумнее меня). Мы же сосредоточимся на технической стороне вопроса и поговорим о том, как безболезненно произвести локализацию своего продукта.
Первым делом залезем в шаблоны. Помните, раньше названия и описания хранились в виде простых String
? Теперь так не пойдет. Помимо языка по умолчанию, вам также необходимо предоставить перевод на все языки, какие вы планируете поддерживать. Например, вот так:
class TestEnemyTemplate : EnemyTemplate {
override val name = "Test enemy"
override val description = "Some enemy standing in your way."
override val nameLocalizations = mapOf(
"ru" to "Враг какой-то",
"ar" to "بعض العدو",
"iw" to "איזה אויב",
"zh" to "一些敵人",
"ua" to "Підступна тварюка"
)
override val descriptionLocalizations = mapOf(
"ru" to "Описание какого-то врага.",
"ar" to "وصف العدو",
"iw" to "תיאור האויב",
"zh" to "一些敵人的描述",
"ua" to "Воно стоїть і дивиться на тебе."
)
override val traits = listOf<Trait>()
}
Для шаблонов такой подход вполне подойдет. Если не хотите указывать перевод для какого-то языка, то и не нужно — всегда есть значение по умолчанию. Однако в конечных объектах не хотелось бы разносить строки на несколько разных полей. Поэтому оставим одно, но заменим его тип.
class LocalizedString(defaultValue: String, localizations: Map<String, String>) {
private val default: String = defaultValue
private val values: Map<String, String> = localizations.toMap()
operator fun get(lang: String) = values.getOrDefault(lang, default)
override fun equals(other: Any?) = when {
this === other -> true
other !is LocalizedString -> false
else -> default == other.default
}
override fun hashCode(): Int {
return default.hashCode()
}
}
И подправим соответствующим образом код генератора.
fun generateEnemy(template: EnemyTemplate) = Enemy().apply {
name = LocalizedString(template.name, template.nameLocalizations)
description = LocalizedString(template.description, template.descriptionLocalizations)
template.traits.forEach { addTrait(it) }
}
Естественно, такой же подход следует применить и к оставшимся типам шаблонов. Когда изменения готовы, пользоваться ими можно без труда.
val language = Locale.getDefault().language
val enemyName = enemy.name[language]
В нашем примере мы предоставили упрощенный вариант локализации, где учитывается только язык (language). Вообще же объекты класса Locale
задают также страну и регион. Если в вашем приложении это принципиально, то ваш LocalizedString
будет выглядеть слегка по-другому, но нас и так устраивает.
С шаблонами разобрались, осталось локализовать и служебные строки, использующиеся в нашем приложении. К счастью, ResourceBundle
уже содержит все необходимые механизмы. Нужно лишь подготовить файлы с переводами и изменить способ их загрузки.
# Game status messages
choose_dice_perform_check=Выберите кубики для прохождения проверки:
end_of_turn_discard_extra=КОНЕЦ ХОДА: Сбросьте лишние кубики:
end_of_turn_discard_optional=КОНЕЦ ХОДА: Сбросьте кубики по желанию:
choose_action_before_exploration=Выберите, что делать:
choose_action_after_exploration=Исследование завершено. Что делать дальше?
encounter_physical=Встречен ФИЗИЧЕСКИЙ кубик. Необходимо пройти проверку.
encounter_somatic=Встречен СОМАТИЧЕСКИЙ кубик. Необходимо пройти проверку.
encounter_mental=Встречен МЕНТАЛЬНЫЙ кубик. Необходимо пройти проверку.
encounter_verbal=Встречен ВЕРБАЛЬНЫЙ кубик. Необходимо пройти проверку.
encounter_divine=Встречен БОЖЕСТВЕННЫЙ кубик. Можно взять без проверки:
die_acquire_success=Вы получили новый кубик!
die_acquire_failure=Вам не удалось получить кубик.
game_loss_out_of_time=У вас закончилось время
# Die types
physical=ФИЗИЧЕСКИЙ
somatic=СОМАТИЧЕСКИй
mental=МЕНТАЛЬНЫЙ
verbal=ВЕРБАЛЬНЫЙ
divine=БОЖЕСТВЕННЫЙ
ally=СОЮЗНИК
wound=РАНА
enemy=ВРАГ
villain=ЗЛОДЕЙ
obstacle=ПРЕПЯТСТВИЕ
# Hero types and descriptions
brawler=Забияка
hunter=Охотник
# Various labels
avg=сред
bag=Сумка
bag_size=Размер сумки
class=Класс
closed=Закрыто
discard=Сброс
empty=Пусто
encountered=На пути
fail=Неудача
hand=Рука
heros_turn=Ходит %s
max=макс
min=мин
perform_check=Пройдите проверку:
pile=Куча
received_new_die=Получен новый кубик
result=Результат
success=Успех
sum=сумм
time=Время
total=Итого
# Action names and descriptions
action_confirm_key=ENTER
action_confirm_name=Подтвердить
action_cancel_key=ESC
action_cancel_name=Отменить
action_explore_location_key=E
action_explore_location_name=Исследовать
action_finish_turn_key=F
action_finish_turn_name=Завершить ход
action_hide_key=H
action_bag_name=Спрятать
action_discard_key=D
action_discard_name=Сбросить
action_acquire_key=A
action_acquire_name=Приобрести
action_leave_key=L
action_leave_name=Уйти
action_forfeit_key=F
action_forfeit_name=Отказаться
Скажу не для протокола: составлять фразы на русском языке намного сложнее, чем на английском. Если стоит требование использовать существительное в определеммом падеже или абстрагироваться от рода (а такие требования обязательно будут стоять), придется порядком попотеть прежде чем получить результат, который во-первых, соответствует требованиям, а во-вторых, не выглядит как механический перевод, выполненный киборгом с куриными мозгами. Также обратите внимание, что мы не меняем клавиши действий — по-прежнему для выполнения последних будут использоваться те же самые символы, что и в английском языке (которые, кстати, не будут работать в отличной от латинской раскладке клавиатуры, но это уже не наше дело — пока что оставим как есть).
class PropertiesStringLoader(locale: Locale) : StringLoader {
private val properties = ResourceBundle.getBundle("text.strings", locale)
override fun loadString(key: String) = properties.getString(key) ?: ""
}
.
Как уже было сказано, ResourceBundle
сам возьмет на себя обязанность найти среди файлов локализаций ту единственную, которая наиболее соответствует текущей локали. А если не найдет — возьмет файл по умолчанию (string.properties
). И все будет хорошо…
.properties
появилась только начиная с Java 9. До этого единственной поддерживаемой кодировкой была ISO-8859-1 — ResourceBundle
открывает файлы только в ней. Кодировка однобайтная, потому ни о какой кирилице, ни тем более о иероглифах не может быть и речи — мы жестко ограничены единственным языком. Для всех остальных символов придется использовать Unicode-последовательности — ну, вы знаете, вот эти вот: 'uXXXX'
. К огромной нашей радости, заниматься кодированием вручную нам не придется, так как Java имеет в своем арсенале замечательное приложение native2ascii, автоматически заменяющее все неподдерживаемые символы на соответствующие последовательности. В итоге наш файл примет вот такой веселый вид:
# Game status messages
choose_dice_perform_check=u0412u044bu0431u0435u0440u0438u0442u0435 u043au0443u0431u0438u043au0438 u0434u043bu044f u043fu0440u043eu0445u043eu0436u0434u0435u043du0438u044f u043fu0440u043eu0432u0435u0440u043au0438:
end_of_turn_discard_extra=u041au041eu041du0415u0426 u0425u041eu0414u0410: u0421u0431u0440u043eu0441u044cu0442u0435 u043bu0438u0448u043du0438u0435 u043au0443u0431u0438u043au0438:
end_of_turn_discard_optional=u041au041eu041du0415u0426 u0425u041eu0414u0410: u0421u0431u0440u043eu0441u044cu0442u0435 u043au0443u0431u0438u043au0438 u043fu043e u0436u0435u043bu0430u043du0438u044e:
choose_action_before_exploration=u0412u044bu0431u0435u0440u0438u0442u0435, u0447u0442u043e u0434u0435u043bu0430u0442u044c:
choose_action_after_exploration=u0418u0441u0441u043bu0435u0434u043eu0432u0430u043du0438u0435 u0437u0430u0432u0435u0440u0448u0435u043du043e. u0427u0442u043e u0434u0435u043bu0430u0442u044c u0434u0430u043bu044cu0448u0435?
encounter_physical=u0412u0441u0442u0440u0435u0447u0435u043d u0424u0418u0417u0418u0427u0415u0421u041au0418u0419 u043au0443u0431u0438u043a. u041du0435u043eu0431u0445u043eu0434u0438u043cu043e u043fu0440u043eu0439u0442u0438 u043fu0440u043eu0432u0435u0440u043au0443.
encounter_somatic=u0412u0441u0442u0440u0435u0447u0435u043d u0421u041eu041cu0410u0422u0418u0427u0415u0421u041au0418u0419 u043au0443u0431u0438u043a. u041du0435u043eu0431u0445u043eu0434u0438u043cu043e u043fu0440u043eu0439u0442u0438 u043fu0440u043eu0432u0435u0440u043au0443.
encounter_mental=u0412u0441u0442u0440u0435u0447u0435u043d u041cu0415u041du0422u0410u041bu042cu041du042bu0419 u043au0443u0431u0438u043a. u041du0435u043eu0431u0445u043eu0434u0438u043cu043e u043fu0440u043eu0439u0442u0438 u043fu0440u043eu0432u0435u0440u043au0443.
encounter_verbal=u0412u0441u0442u0440u0435u0447u0435u043d u0412u0415u0420u0411u0410u041bu042cu041du042bu0419 u043au0443u0431u0438u043a. u041du0435u043eu0431u0445u043eu0434u0438u043cu043e u043fu0440u043eu0439u0442u0438 u043fu0440u043eu0432u0435u0440u043au0443.
encounter_divine=u0412u0441u0442u0440u0435u0447u0435u043d u0411u041eu0416u0415u0421u0422u0412u0415u041du041du042bu0419 u043au0443u0431u0438u043a. u041cu043eu0436u043du043e u0432u0437u044fu0442u044c u0431u0435u0437 u043fu0440u043eu0432u0435u0440u043au0438:
die_acquire_success=u0412u044b u043fu043eu043bu0443u0447u0438u043bu0438 u043du043eu0432u044bu0439 u043au0443u0431u0438u043a!
die_acquire_failure=u0412u0430u043c u043du0435 u0443u0434u0430u043bu043eu0441u044c u043fu043eu043bu0443u0447u0438u0442u044c u043au0443u0431u0438u043a.
game_loss_out_of_time=u0423 u0432u0430u0441 u0437u0430u043au043eu043du0447u0438u043bu043eu0441u044c u0432u0440u0435u043cu044f
Вижу как текут ваши слюнки в предвкушении поддержки всего этого добра. Сконвертировать файл один раз — дело несложное. Постоянно вносить изменения — легче повеситься. К счастью, некоторые 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_projectDicetargetdice-1.0-jar-with-dependencies.jar
chcp 65001
Ну и поскольку Java сильно умная, она все еще считает, что в консоли используется кодировка по умолчанию. Нужно ее в этом разубедить:
java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_projectDicetargetdice-1.0-jar-with-dependencies.jar
А еще убедитесь, что в настройках консоли выбран шрифт, поддерживающий Unicode-символы (например, Lucida Console)
После всех наших волнительных приключений, полученный результат можно с гордостью продемонстрировать широкой общественности и громко заявить: «Мы не псы!»
И это хорошо.
Шаг тринадцатый. Собираем все вместе
Внимательные читатели, должно быть, заметили, что названия конкретных пакетов я упомянул лишь единожды и больше ни разу к ним не возвращался. Во-первых, каждый разработчик имеет собственные соображения касательно того, какой класс в каком пакете должен располагаться. Во-вторых, по мере работы над проектом, с добавлением все новых и новых классов ваши соображения будут меняться. В-третьих, менять структуру приложения просто и дешево (да и современные системы контроля версий обнаружат перенос, так что историю вы не потеряете), потому смело меняйте названия классов, пакетов, методов и переменных — не забывайте только документацию обновлять (вы ж ее ведете, правда?).
А нам осталось лишь собрать воедино и запустить наш проект. Как вы помните, метод main()
мы уже создали, теперь наполним его содержимым. Нам понадобятся:
- сценарий и местности;
- герои;
- реализация интерфейса
GameInteractor
; - реализации интерфейсов
GameRenderer
иStringLoader
; - реализации интерфейсов
SoundPlayer
иMusicPlayer
; - объект класса
Game
; - бутылка шампанского.
Поехали!
fun main(args: Array<String>) {
Audio.init(BasicSoundPlayer(), BasicMusicPlayer())
val loader = PropertiesStringLoader(Locale.getDefault())
val renderer = ConsoleGameRenderer(loader)
val interactor = ConsoleGameInteractor()
val template = TestScenarioTemplate()
val scenario = generateScenario(template, 1)
val locations = generateLocations(template, 1, heroes.size)
val heroes = listOf(
generateHero(Hero.Type.BRAWLER, "Brawler"),
generateHero(Hero.Type.HUNTER, "Hunter")
)
val game = Game(renderer, interactor, scenario, locations, heroes)
game.start()
}
Запускаем и наслаждаемся первым рабочим прототипом. Вот так-то.
Шаг четырнадцатый. Игровой баланс
Эммм…
Шаг пятнадцатый. Тесты
Теперь, когда основная часть кода первого рабочего прототипа написана, неплохо бы добавить парочку модульных тестов…
«Как? Только сейчас? Да тесты нужно было в самом начале писать, а потом уже код!»
Многие читатели справедливо заметят, что написание модульных тестов должно предварять разработку рабочего кода (TDD и прочие модные методологии). Другие возмутятся: нечего людям
Скажем так: многие программисты (особенно начинающие) пренебрегают тестами. Многие оправдывают себя тем, что функциональность их приложений плохо покрывается тестами. Например, чем городить сложные конструкции с участием специализированных фреймворков для тестирования пользовательского интерфейса (а такие есть), гораздо проще запустить приложение и посмотреть, все ли в порядке с внешним видом и взаимодействием. И я вам скажу, когда я занимался реализацией интерфейсов Renderer
— я именно так и делал. Однако есть среди нашего кода такие методы, для которых отлично подходит концепция модульного тестирования.
Например, генераторы. Причем все. Это ж идеальный черный ящик: на вход подаются шаблоны, на выходе получаются объекты игрового мира. Внутри происходит невесть что, но именно его нам и нужно тестировать. Например, вот так:
public class DieGeneratorTest {
@Test
public void testGetMaxLevel() {
assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel());
}
@Test
public void testDieGenerationSize() {
DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY);
List<? extends List<Integer>> allowedSizes = Arrays.asList(
null,
Arrays.asList(4, 6, 8),
Arrays.asList(4, 6, 8, 10),
Arrays.asList(6, 8, 10, 12)
);
IntStream.rangeClosed(1, 3).forEach(level -> {
for (int i = 0; i < 10; i++) {
int size = DieGeneratorKt.generateDie(filter, level).getSize();
assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size));
assertTrue("Incorrect die size: " + size, size >= 4);
assertTrue("Incorrect die size: " + size, size <= 12);
assertTrue("Incorrect die size: " + size, size % 2 == 0);
}
});
}
@Test
public void testDieGenerationType() {
List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL);
List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL);
List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY);
for (int i = 0; i < 10; i++) {
Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType();
assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1));
Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType();
assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2));
Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType();
assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3));
}
}
}
Или так:
public class BagGeneratorTest {
@Test
public void testGenerateBag() {
BagTemplate template1 = new BagTemplate();
template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL));
template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC));
template1.setFixedDieCount(null);
BagTemplate template2 = new BagTemplate();
template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE));
template2.setFixedDieCount(5);
BagTemplate template3 = new BagTemplate();
template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY));
template3.setFixedDieCount(50);
for (int i = 0; i < 10; i++) {
Bag bag1 = BagGeneratorKt.generateBag(template1, 1);
assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15);
assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count());
Bag bag2 = BagGeneratorKt.generateBag(template2, 1);
assertEquals("Incorrect bag size", 5, bag2.getSize());
Bag bag3 = BagGeneratorKt.generateBag(template3, 1);
assertEquals("Incorrect bag size", 50, bag3.getSize());
List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList());
assertEquals("Incorrect die types", 1, dieTypes3.size());
assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0));
}
}
}
Или даже так:
public class LocationGeneratorTest {
private void testLocationGeneration(String name, LocationTemplate template) {
System.out.println("Template: " + template.getName());
assertEquals("Incorrect template type", name, template.getName());
IntStream.rangeClosed(1, 3).forEach(level -> {
Location location = LocationGeneratorKt.generateLocation(template, level);
assertEquals("Incorrect location type", name, location.getName().get(""));
assertTrue("Location not open by default", location.isOpen());
int closingDifficulty = location.getClosingDifficulty();
assertTrue("Closing difficulty too small", closingDifficulty > 0);
assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2);
Bag bag = location.getBag();
assertNotNull("Bag is null", bag);
assertTrue("Bag is empty", location.getBag().getSize() > 0);
Deck<Enemy> enemies = location.getEnemies();
assertNotNull("Enemies are null", enemies);
assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount());
if (bag.drawOfType(Die.Type.ENEMY) != null) {
assertTrue("Enemy cards not specified", enemies.getSize() > 0);
}
Deck<Obstacle> obstacles = location.getObstacles();
assertNotNull("Obstacles are null", obstacles);
assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount());
List<SpecialRule> specialRules = location.getSpecialRules();
assertNotNull("SpecialRules are null", specialRules);
});
}
@Test
public void testGenerateLocation() {
testLocationGeneration("Test Location", new TestLocationTemplate());
testLocationGeneration("Test Location 2", new TestLocationTemplate2());
}
}
«Стоп, стоп, стоп! Это что? Java???»
Вы поняли. Причем как раз такого рода тесты хорошо писать в начале, перед тем как вы начнете реализовывать, собственно, генератор. Конечно, тестируемый код достаточно прост и скорее всего метод заработает с первого раза и без всяких тестов, но написав тест один раз вы о нем навсегда забудете обезопасите себя от любых возможных проблем в будущем (решение которых отбирает уйму времени, особенно когда с момента начала разработки прошло пять лет и вы уже забыли, как там все внутри метода работает). И если вдруг когда-нибудь ваш проект перестанет собираться из-за проваленных тестов, ты вы однозначно будете знать причину: поменялись требования к системе и ваши старые тесты им больше не удовлетворяют (а вы о чем подумали?).
И еще. Помните, класс HandMaskRule
и его наследников? А теперь представьте, что в какой-то момент для использования навыка герою необходимо взять из руки три кубика, причем типы этих кубиков заняты жесткими ограничениями (например, «первый кубик должен быть синим, зеленым или белым, второй — желтым, белым или голубым, а третий — синим или фиолетовым» — чуете сложность?). Как подойти к реализации класса? Ну… для начала можете определиться с входными и выходными параметрами. Очевидно, нужно, чтобы класс принимал три массива (или набора), каждый из которых содержит допустимые типы для, соответственно, первого, второго и третьего кубиков. А дальше что? Переборы? Рекурсии? А вдруг что-то пропущу? Сделайте глубокий вход. Теперь отложите реализацию методов класса и напишите тест — благо требования просты, понятны и хорошо формализуемы. А лучше напишите несколько тестов… Но мы рассмотрим один, вот такой например:
public class TripleDieHandMaskRuleTest {
private Hand hand;
@Before
public void init() {
hand = new Hand(10);
hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //0
hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //1
hand.addDie(new Die(Die.Type.SOMATIC, 4)); //2
hand.addDie(new Die(Die.Type.SOMATIC, 4)); //3
hand.addDie(new Die(Die.Type.MENTAL, 4)); //4
hand.addDie(new Die(Die.Type.MENTAL, 4)); //5
hand.addDie(new Die(Die.Type.VERBAL, 4)); //6
hand.addDie(new Die(Die.Type.VERBAL, 4)); //7
hand.addDie(new Die(Die.Type.DIVINE, 4)); //8
hand.addDie(new Die(Die.Type.DIVINE, 4)); //9
hand.addDie(new Die(Die.Type.ALLY, 4)); //A (0)
hand.addDie(new Die(Die.Type.ALLY, 4)); //B (1)
}
@Test
public void testRule1() {
HandMaskRule rule = new TripleDieHandMaskRule(
hand,
new Die.Type[]{Die.Type.PHYSICAL, Die.Type.SOMATIC},
new Die.Type[]{Die.Type.MENTAL, Die.Type.VERBAL},
new Die.Type[]{Die.Type.PHYSICAL, Die.Type.ALLY}
);
HandMask mask = new HandMask();
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 0));
assertTrue("Should be on", rule.isPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 2));
assertTrue("Should be on", rule.isPositionActive(mask, 3));
assertTrue("Should be on", rule.isPositionActive(mask, 4));
assertTrue("Should be on", rule.isPositionActive(mask, 5));
assertTrue("Should be on", rule.isPositionActive(mask, 6));
assertTrue("Should be on", rule.isPositionActive(mask, 7));
assertFalse("Should be off", rule.isPositionActive(mask, 8));
assertFalse("Should be off", rule.isPositionActive(mask, 9));
assertFalse("Rule should not be met yet", rule.checkMask(mask));
mask.addPosition(0);
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 0));
assertTrue("Should be on", rule.isPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 2));
assertTrue("Should be on", rule.isPositionActive(mask, 3));
assertTrue("Should be on", rule.isPositionActive(mask, 4));
assertTrue("Should be on", rule.isPositionActive(mask, 5));
assertTrue("Should be on", rule.isPositionActive(mask, 6));
assertTrue("Should be on", rule.isPositionActive(mask, 7));
assertFalse("Should be off", rule.isPositionActive(mask, 8));
assertFalse("Should be off", rule.isPositionActive(mask, 9));
assertFalse("Rule should not be met yet", rule.checkMask(mask));
mask.addPosition(4);
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 0));
assertTrue("Should be on", rule.isPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 2));
assertTrue("Should be on", rule.isPositionActive(mask, 3));
assertTrue("Should be on", rule.isPositionActive(mask, 4));
assertFalse("Should be off", rule.isPositionActive(mask, 5));
assertFalse("Should be off", rule.isPositionActive(mask, 6));
assertFalse("Should be off", rule.isPositionActive(mask, 7));
assertFalse("Should be off", rule.isPositionActive(mask, 8));
assertFalse("Should be off", rule.isPositionActive(mask, 9));
assertFalse("Rule should not be met yet", rule.checkMask(mask));
mask.addAllyPosition(0);
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 0));
assertFalse("Should be off", rule.isPositionActive(mask, 1));
assertFalse("Should be off", rule.isPositionActive(mask, 2));
assertFalse("Should be off", rule.isPositionActive(mask, 3));
assertTrue("Should be on", rule.isPositionActive(mask, 4));
assertFalse("Should be off", rule.isPositionActive(mask, 5));
assertFalse("Should be off", rule.isPositionActive(mask, 6));
assertFalse("Should be off", rule.isPositionActive(mask, 7));
assertFalse("Should be off", rule.isPositionActive(mask, 8));
assertFalse("Should be off", rule.isPositionActive(mask, 9));
assertTrue("Rule should be met", rule.checkMask(mask));
mask.removePosition(0);
assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 0));
assertTrue("Should be on", rule.isPositionActive(mask, 1));
assertTrue("Should be on", rule.isPositionActive(mask, 2));
assertTrue("Should be on", rule.isPositionActive(mask, 3));
assertTrue("Should be on", rule.isPositionActive(mask, 4));
assertFalse("Should be off", rule.isPositionActive(mask, 5));
assertFalse("Should be off", rule.isPositionActive(mask, 6));
assertFalse("Should be off", rule.isPositionActive(mask, 7));
assertFalse("Should be off", rule.isPositionActive(mask, 8));
assertFalse("Should be off", rule.isPositionActive(mask, 9));
assertFalse("Rule should not be met again", rule.checkMask(mask));
}
}
Это утомительно, но не настолько как кажется, пока не начнешь (в какой-то момент даже увлекательно становится). Зато написав такой тест (и парочку других, на разные случаи), вы внезапно почувствуете спокойствие и уверенность в себе. Теперь никакая мелкая опечатка не испортит ваш метод и не приведет к неприятным неожиданностям, которые гораздо сложнее тестировать вручную. Мало-помалу, не торопясь, начинаем реализовывать нужные методы класса. И в конце запускаем тест, чтобы убедиться, что где-то мы допустили оплошлость. Найти проблемное место и переписать. Повторить до готовности.
class TripleDieHandMaskRule(
hand: Hand,
types1: Array<Die.Type>,
types2: Array<Die.Type>,
types3: Array<Die.Type>)
: HandMaskRule(hand) {
private val types1 = types1.toSet()
private val types2 = types2.toSet()
private val types3 = types3.toSet()
override fun checkMask(mask: HandMask): Boolean {
if (mask.positionCount + mask.allyPositionCount != 3) {
return false
}
return getCheckedDice(mask).asSequence()
.filter { it.type in types1 }
.any { d1 ->
getCheckedDice(mask)
.filter { d2 -> d2 !== d1 }
.filter { it.type in types2 }
.any { d2 ->
getCheckedDice(mask)
.filter { d3 -> d3 !== d1 }
.filter { d3 -> d3 !== d2 }
.any { it.type in types3 }
}
}
}
override fun isPositionActive(mask: HandMask, position: Int): Boolean {
if (mask.checkPosition(position)) {
return true
}
val die = hand.dieAt(position) ?: return false
return when (mask.positionCount + mask.allyPositionCount) {
0 -> die.type in types1 || die.type in types2 || die.type in types3
1 -> with(getCheckedDice(mask).first()) {
(this.type in types1 && (die.type in types2 || die.type in types3))
|| (this.type in types2 && (die.type in types1 || die.type in types3))
|| (this.type in types3 && (die.type in types1 || die.type in types2))
}
2-> with(getCheckedDice(mask)) {
val d1 = this[0]
val d2 = this[1]
(d1.type in types1 && d2.type in types2 && die.type in types3) ||
(d2.type in types1 && d1.type in types2 && die.type in types3) ||
(d1.type in types1 && d2.type in types3 && die.type in types2) ||
(d2.type in types1 && d1.type in types3 && die.type in types2) ||
(d1.type in types2 && d2.type in types3 && die.type in types1) ||
(d2.type in types2 && d1.type in types3 && die.type in types1)
}
3 -> false
else -> false
}
}
override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean {
if (mask.checkAllyPosition(position)) {
return true
}
if (hand.allyDieAt(position) == null) {
return false
}
return when (mask.positionCount + mask.allyPositionCount) {
0 -> ALLY in types1 || ALLY in types2 || ALLY in types3
1 -> with(getCheckedDice(mask).first()) {
(this.type in types1 && (ALLY in types2 || ALLY in types3))
|| (this.type in types2 && (ALLY in types1 || ALLY in types3))
|| (this.type in types3 && (ALLY in types1 || ALLY in types2))
}
2-> with(getCheckedDice(mask)) {
val d1 = this[0]
val d2 = this[1]
(d1.type in types1 && d2.type in types2 && ALLY in types3) ||
(d2.type in types1 && d1.type in types2 && ALLY in types3) ||
(d1.type in types1 && d2.type in types3 && ALLY in types2) ||
(d2.type in types1 && d1.type in types3 && ALLY in types2) ||
(d1.type in types2 && d2.type in types3 && ALLY in types1) ||
(d2.type in types2 && d1.type in types3 && ALLY in types1)
}
3 -> false
else -> false
}
}
}
Если у вас есть идеи, как реализовать такой функционал проще — милости прошу в комментарии. А я несказанно рад, что мне хватило ума начать реализацию данного класса именно с написания теста.
«И я <...> тоже <...> очень <...> рад <...>. Залезь! <...> обратно! <...> в щель!»
Шаг шестнадцатый. Модульность
Как и ожидалось, повзрослевшие детки не могут всю жизнь находиться под кровом родителей — рано или поздно они должны выбрать свой собственный путь и смело идти по нему, преодолевая трудности и нестроения. Так и разработанные нами компоненты возмужали настолько, что им стало тесно под одной крышей. Пришло время разделить их на несколько частей.
Перед нами стоит достаточно тривиальная задача. Необходимо разбить все созданные доселе классы на три группы:
- базовая функциональность: модуль, игровой движок, интерфейсы-коннекторы и не зависящие от платформы реализации (core);
- шаблоны сценариев, местностей, врагов и препятствий — составные части так называемого «приключения» (adventure);
- конкретные реализации интерфейсов, специфичные для конкретной платформы: в нашем случае — консольного приложения (cli).
Результат такого разделения в конечном итоге будет выглядеть приблизительно как на следующей диаграмме:
Создайте дополнительные проекты и перенесите соответствующие класс. А нам осталось только грамотно настроить взаимодействие проектов между собой.
Проект Core
Данный проект представляет собой движок в чистом виде. Все специфичные классы были перенесены в другие проекты — осталась лишь базовая функциональность, ядро. Библиотека, если хотите. Здесь больше нет запускающего класса, нет даже необходимости собирать пакет. Сборки этого проекта будут размещаться в локальном репозитории Maven (о чем позже) и использоваться другими проектами в качестве зависимостей.
Файл pom.xml
выглядт следующим образом:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>my.company</groupId>
<artifactId>dice-core</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit-dep</artifactId>
<version>4.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<!-- other Kotlin setup -->
</plugin>
</plugins>
</build>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<kotlin.version>1.3.20</kotlin.version>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
</properties>
</project>
Отныне собирать его будем так:
mvn -f "path_to_project/DiceCore/pom.xml" install
Проект Cli
Здесь находится точка входа в приложение — именно с этим проектом будет взаимодействовать конечный пользователь. Ядро используется в качестве зависимости. Поскольку в нашем примере мы работаем с консолью, проект будет содержать классы, необходимые для работы с ней (если вдруг нам захочется запустить игру на кофеварке, мы попросту заменим этот проект на аналогичный — с соответствующими реализациями). Тут же будем складывать ресурсы (строки, аудиофайлы итп.).
В файл pom.xml
перекочуют зависимости от внешних библиотек:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>my.company</groupId>
<artifactId>dice-cli</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>my.company</groupId>
<artifactId>dice-core</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>1.17.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>jline</groupId>
<artifactId>jline</artifactId>
<version>2.14.6</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1.4</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<!-- other Kotlin setup -->
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>my.company.dice.MainKt</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<kotlin.version>1.3.20</kotlin.version>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
</properties>
</project>
Скрипт для сборки и запуска этого проекта мы уже видели — не станем повторяться.
Приключения
Ну и напоследок в отдельный проект вынесем сюжетную часть. То есть, все сценарии, местности, врагов и прочие уникальные объекты игрового мира, какие только вообразят сотрудники сценарного отдела вашей компании (ну или пока что лишь наша собственная больная фантазия — мы все еще единственный гейм-дизайнер в округе). Идея состоит в том, чтобы группировать сценарии в наборы (приключения) и распространять каждый такой набор в виде отдельного проекта (подобно тому как это делается в мире настольных и видео-игр). То есть, собирать jar-архивы и складывать их в отдельную папку, с тем чтобы игровой движок сканировал эту папку и автоматически подключал все содержащиеся там приключения. Однако техническая реализация такого подхода сопряжена с огромными трудностями.
С чего бы начать? Ну во-первых, с того что мы распространяем шаблоны в виде конкретных java-классов (ага, бейте меня и ругайте — я заранее это предвидел). А раз так, то эти классы во время запуска должны находиться в classpath приложения. Обеспечить выполнение этого требования несложно — вы явно прописываете ваши jar-файлы в соответствующую переменную окружения (начиная с Java 6 можно даже использовать * — wildcards).
java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar
«Дурак, что ли? При использовании ключа -jar ключ -classpath игнорируется!»
Однако это работать не будет. Classpath для исполняемых jar-архивов необходимо явно прописывать во внутреннем файле META-INF/MANIFEST.MF
(секция так и называется — Claspath:
). Ничего страшного, для этого даже специальные плагины имеются (maven-compiler-plugin или, на худой конец, maven-assembly-plugin). Вот только wildcards в манифесте, увы, не работают — вам придется явно указывать названия зависимых jar-файлов. То есть, знать их заранее, что в нашем случае проблематично.
И вообще, я не так хотел. Я хотел, чтобы проект не нужно было заново компилировать. Чтобы в папку adventures/
можно было накидать любое количество приключений, и чтобы все они были видны игровому движку в процессе выполнения. К сожалению, кажущаяся очевидной функциональность выходит за рамки стандартных представлений мира Java. А потому и не приветствуется. Нужно реализовывать другой подход к распространению независимых приключений. Какой? Не знаю, пишите в комментариях — наверняка у кого-то есть умные идеи.
А пока идей нет, вот мелкая (или крупная, смотря как посмотреть) хитрость, позволяющая динамически добавлять зависимости в classpath даже не зная их названий и без необходимости заново компилировать проект:
В Windows:
@ECHO OFF
call "path_to_mavenmvn.bat" -f "path_to_projectDiceCorepom.xml" install
call "path_to_mavenmvn.bat" -f "path_to_projectDiceClipom.xml" package
call "path_to_mavenmvn.bat" -f "path_to_projectTestAdventurepom.xml" package
mkdir path_to_projectDiceClitargetadventures
copy "path_to_projectTestAdventuretargettest-adventure-1.0.jar" path_to_projectDiceClitargetadventures
chcp 65001
cd path_to_projectDiceClitarget
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. Сделаем это.
Запустим Android Studio (обычно проекты под Андроид разрабатываются в ней), создадим простой проект, удалив всю ненужную стандартную мишуру и оставив лишь поддержку языка Kotlin. Добавим также зависимость от проекта Core, который хранится в локальном Maven-репозитории нашей машины.
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
applicationId "my.company.dice"
minSdkVersion 14
targetSdkVersion 28
versionCode 1
versionName "1.0"
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "my.company:dice-core:1.0"
}
По умолчанию, однако, нашу зависимость никто не увидит — необходимо явно указать необходимость использования локального репозитория (mavenLocal) при сборке проекта.
buildscript {
ext.kotlin_version = '1.3.20'
repositories {
google()
jcenter()
mavenLocal()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
jcenter()
mavenLocal()
}
}
Вы увидите, что все разработанные ранее классы досутпны для использования, а интерфейсы — для реализации. Интересует нас, по большому счету, уже знакомые нам интерфейсы: SoundPlayer
, MusicPlayer
, MenuInteractor
(аналог GameInteractor
), MenuRenderer
(аналог GameRenderer
) и StringLoader
, для которых напишим новые, специфичные для андроида реализации. Но перед этим прикинем, как вообще будет происходить взаимодействие пользователя с нашей новой системой.
Для отрисовки элементов интерфейса мы не станем использовать стандартные компоненты (кнопки, картинки, поля для ввода итп) Android — вместо этого ограничимся возможностями класса Canvas
. Для этого нам достаточно создать один-единственный наследник класса View
— это и будет наш «холст». С вводом чуть сложнее, так как клавиатуры у нас больше нет, и интерфейс необходимо разрабатывать таким образом, чтобы вводом команд считались нажатия пользователя на определенные части экрана. Для этого воспользуемся все тем же наследником View
— таким образом, он будет выступать посредником между пользователем и игровым движком (аналогично тому, как ранее таким посредником выступала системная консоль).
Создадим основную активность для нашего View и пропишем ее в манифесте.
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="my.company.dice">
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<activity
android:name=".ui.MainActivity"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|keyboardHidden|screenSize">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
</application>
</manifest>
Зафиксируем активность в альбомной ориентации — как в случае с большинством остальных игр, портретная нам не понядобится. Более того, развернем ее на весь экран устройства, прописав соответствующим образом основную тему.
<resources>
<style name="AppTheme"
parent="android:Theme.Black.NoTitleBar.Fullscreen"/>
</resources>
И раз уж мы полезли в ресурсы, перенесем из проекта Cli нужные нам локализованные строки, приведя их к нужному формату:
<resources>
<string name="action_new_adventure_key">N</string>
<string name="action_new_adventure_name">ew adventure</string>
<string name="action_continue_adventure_key">C</string>
<string name="action_continue_adventure_name">ontinue adventure</string>
<string name="action_manual_key">M</string>
<string name="action_manual_name">anual</string>
<string name="action_exit_key">X</string>
<string name="action_exit_name">Exit</string>
</resources>
А также используемые в главном меню файлы звуков и музыки (по одному каждого вида), расположив их в /assets/sound/leave.wav
и /assets/music/menu_main.mp3
соответственно.
Когда с ресурсами разобрались, настало время заняться дизайном (да, опять). В отличие от консоли, платформа Андроид имеет свои архитектурные особенности, что вынуждает нас использовать специфические подходы и методы.
Подождите, не падайте в обморок, сейчас все подробно объясню.
Начнем, пожалуй, с самого сложного — класса DiceSurface
— того самого наследника View
, который призван скрепить воедино независимые части нашей системы (при желании можно унаследовать его от класса SurfaceView
— или даже GlSurfaceView
— и производить отрисовку в отдельном потоке, но игра у нас пошаговая, бедная на анимации, сложного графического вывода не требующая, потому не станем усложнять). Как было сказано ранее, его реализация будет решать сразу две задачи: вывод изображения и обработка нажатий, каждая из которых имеет свои неожиданные сложности. Рассмотрим их по порядку.
Когда мы рисовали на консоли, наш Renderer отправлял команды вывода и формировал изображение на экране. В случае с Андроид ситуация обратная — отрисовка инициируется самим View, который к моменту выполнения метода onDraw()
уже должен знать, что, как и где, рисовать. А как же метод drawMainMenu()
интерфейса MainMenu
? Он теперь не управляет выводом?
Попробуем решить эту задачу при помощи функциональных интерфейсов. Класс DiceSurface
будет содержать особый параметр instructions
— по сути, блок кода, который необходимо выполнить каждый раз при вызове метода onDraw()
. Renderer же, при помощи публичного метода будет указывать, какие конкретно инструкции следует исполнять. Выглядит это следующим образом:
typealias RenderInstructions = (Canvas, Paint) -> Unit
class DiceSurface(context: Context) : View(context) {
private var instructions: RenderInstructions = { _, _ -> }
private val paint = Paint().apply {
color = Color.YELLOW
style = Paint.Style.STROKE
isAntiAlias = true
}
fun updateInstructions(instructions: RenderInstructions) {
this.instructions = instructions
this.postInvalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.BLACK) //Fill background with black color
instructions.invoke(canvas, paint) //Execute current render instructions
}
}
class DroidMenuRenderer(private val surface: DiceSurface): MenuRenderer {
override fun clearScreen() {
surface.updateInstructions { _, _ -> }
}
override fun drawMainMenu(actions: ActionList) {
surface.updateInstructions { c, p ->
val canvasWidth = c.width
val canvasHeight = c.height
//Draw title text
p.textSize = canvasHeight / 3f
p.strokeWidth = 0f
p.color = Color.parseColor("#ff808000")
c.drawText(
"DICE",
(canvasWidth - p.measureText("DICE")) / 2f,
(buttonTop - p.ascent() - p.descent()) / 2f,
p
)
//Other instructions...
}
}
}
То есть, вся графическая функциональность по-прежнему находится в классе Renderer, но в этот раз мы не напрямую исполняем команды, а подготавливаем их для исполнения нашим View. Обратите внимание на тип свойства instructions
— можно было бы создать отдельный интерфейс и вызывать его единственный метод, но Kotlin позволяет значительно сократить количество кода.
Теперь про Interactor. Ранее ввода данных происходил синхронно: когда мы запрашивали данные у консоли (клавиатуры), выполнение приложения (циклов) приостанавливалось, пока пользователь не нажимал клавишу. С Андроидом такой трюк не пройдет — у него есть свой Looper, работу которого мы ни в коем случае не должны нарушать, а значит ввод должен быть асинхронным. То есть методы интерфейса Interactor по-прежнему приостанавливают работу движка и ожидают команд, в то время как Activity и все ее View продолжают работать, пока рано или поздно эту команду не отправят.
Такой подход достаточно просто реализовать при помощи стандартного интерфейса BlockingQueue
. Класс DroidMenuInteractor
будет вызывать метод take()
, который приостановит выполнение игрового потока до тех пор, пока в очереди не появятся элементы (экземпляры знакомого нам класса Action
). DiceSurface
, в свою очередь, будет регировать на нажатия пользователя (стандартный метод onTouchEvent()
класса View
), генерировать объекты и добавлять их в очередь методом offer()
. Выглядеть это будет следующим образом:
class DiceSurface(context: Context) : View(context) {
private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>()
fun awaitAction(): Action = actionQueue.take()
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS)
}
return true
}
}
class DroidMenuInteractor(private val surface: DiceSurface) : Interactor {
override fun anyInput() {
surface.awaitAction()
}
override fun pickAction(list: ActionList): Action {
while (true) {
val type = surface.awaitAction().type
list
.filter(Action::isEnabled)
.find { it.type == type }
?.let { return it }
}
}
}
То есть, Interactor вызывает метод awaitAction()
и если в очереди что-то есть, обрабатывает полученную команду. Обратите внимание на то, как команды добавляются в очередь. Поскольку UI-поток выполняется непрерывно, пользователь может нажать на экран много раз подряд, что способно привести к подвисаниям активности, особенно если игровой движок не готов принимать команды (например, во время выполнения анимаций). В этом случае поможет увеличение емкости очереди и/или уменьшение значения таймаута.
Конечно, команды мы вроде как передаем, но только одну-единственную. Нам же необходимо различать координаты нажатия, и в зависимости от их значений вызывать ту или иную команду. Однако вот незадача — Interactor понятия не имеет, где в каком месте экрана нарисованы активные кнопки — за отрисовку у нас отвечает Renderer. Наладим их взаимодействие следующим образом. Класс DiceSurface
будет хранить специальную коллекцию — список активных прямоугольников (или других фигур, если мы когда-нибудь до этого дорастем). Такие прямоугольники содержат координаты вершин и подвязанный Action
. Renderer будет генерировать эти прямоугольники и добавлять их в список, метод onTouchEvent()
будет определять, который из прямоугольников оказался нажатым, и добавлять в очередь соответствующий Action
.
private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) {
val rect = RectF(left, top, right, bottom)
fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h)
}
Метод check()
занимается проверкой попадания указанных координат внутрь прямоугольника. Обратите внимание, на этапе работы Renderer'а (а это именно тот момент, когда прямоугольники создаются) мы не имеем ни малейшего представления о размере холста. Поэтому координаты нам придется хранить в относительных величинах (процент ширины или высоты экрана) со значениями от 0 до 1 и пересчитывать в момент нажатия. Такой подход не совсем аккуратный, так как не учитывает соотношение сторон — в будущем его придется переделывать. Однако для нашей учебной задачи на первых порах сгодится.
Реализуем в классе DiceSurface
дополнительное поле, добавим два метода (addRectangle()
и clearRectangles()
) для управления им извне (со стороны Renderer'а), и расширим onTouchEvent()
, заставив брать во внимание координаты прямоугольников.
class DiceSurface(context: Context) : View(context) {
private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>()
private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>())
private var instructions: RenderInstructions = { _, _ -> }
private val paint = Paint().apply {
color = Color.YELLOW
style = Paint.Style.STROKE
isAntiAlias = true
}
fun updateInstructions(instructions: RenderInstructions) {
this.instructions = instructions
this.postInvalidate()
}
fun clearRectangles() {
rectangles.clear()
}
fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) {
rectangles.add(ActiveRect(action, left, top, right, bottom))
}
fun awaitAction(): Action = actionQueue.take()
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) {
if (this != null) {
actionQueue.put(action)
} else {
actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS)
}
}
}
return true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.BLACK)
instructions(canvas, paint)
}
}
Для хранения прямоугольников используется конкуррентная коллекция — она позволит избежать возникновения ConcurrentModificationException
в случае, если набор будет одновременно обновляться и перебираться разными потоками (что в нашем случае обязательно произойдет).
Код класса DroidMenuInteractor
останется без изменений, а вот DroidMenuRenderer
изменится. Добавим в отображение четыре кнопки для каждого элемента ActionList
. Расположим их под заголовком DICE, равномерно распределив по ширине экрана. Ну и об активных прямоугольниках не забудем.
class DroidMenuRenderer (
private val surface: DiceSurface,
private val loader: StringLoader
) : MenuRenderer {
protected val helper = StringLoadHelper(loader)
override fun clearScreen() {
surface.clearRectangles()
surface.updateInstructions { _, _ -> }
}
override fun drawMainMenu(actions: ActionList) {
//Prepare rectangles
surface.clearRectangles()
val percentage = 1.0f / actions.size
actions.forEachIndexed { i, a ->
surface.addRectangle(a, i * percentage, 0.45f, i * percentage + percentage, 1f)
}
//Prepare instructions
surface.updateInstructions { c, p ->
val canvasWidth = c.width
val canvasHeight = c.height
val buttonTop = canvasHeight * 0.45f
val buttonWidth = canvasWidth / actions.size
val padding = canvasHeight / 144f
//Draw title text
p.textSize = canvasHeight / 3f
p.strokeWidth = 0f
p.color = Color.parseColor("#ff808000")
p.isFakeBoldText = true
c.drawText(
"DICE",
(canvasWidth - p.measureText("DICE")) / 2f,
(buttonTop - p.ascent() - p.descent()) / 2f,
p
)
p.isFakeBoldText = false
//Draw action buttons
p.textSize = canvasHeight / 24f
actions.forEachIndexed { i, a ->
p.color = if (a.isEnabled) Color.YELLOW else Color.LTGRAY
p.strokeWidth = canvasHeight / 240f
c.drawRect(
i * buttonWidth + padding,
buttonTop + padding,
i * buttonWidth + buttonWidth - padding,
canvasHeight - padding,
p
)
val name = mergeActionData(helper.loadActionData(a))
p.strokeWidth = 0f
c.drawText(
name,
i * buttonWidth + (buttonWidth - p.measureText(name)) / 2f,
(canvasHeight + buttonTop - p.ascent() - p.descent()) / 2f,
p
)
}
}
}
private fun mergeActionData(data: Array<String>) = if (data.size > 1) {
if (data[1].first().isLowerCase()) data[0] + data[1] else data[1]
} else data.getOrNull(0) ?: ""
}
Здесь мы вновь вернулись к интерфейсу StringLoader
и возможностям вспомогательного класса StringLoadHelper
(не представлен на диаграмме). Реализация первого имеет название ResourceStringLoader
и занимается загрузкой локализованных строк из (очевидно) ресурсов приложения. Однако делает это динамически, поскольку идентификаторы ресурсов нам заранее не известны — их мы вынуждены конструировать на ходу.
class ResourceStringLoader(context: Context) : StringLoader {
private val packageName = context.packageName
private val resources = context.resources
override fun loadString(key: String): String =
resources.getString(resources.getIdentifier(key, "string", packageName))
}
Осталось рассказать про звуки и музыку. В андроиде есть замечательный класс MediaPlayer
, который как раз и занимается этими вещами. Ничего лучше для проигрывания музыки не найти:
class DroidMusicPlayer(private val context: Context): MusicPlayer {
private var currentMusic: Music? = null
private val player = MediaPlayer()
override fun play(music: Music) {
if (currentMusic == music) {
return
}
currentMusic = music
player.setAudioStreamType(AudioManager.STREAM_MUSIC)
val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3")
player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
player.setOnCompletionListener {
it.seekTo(0)
it.start()
}
player.prepare()
player.start()
}
override fun stop() {
currentMusic = null
player.release()
}
}
Два замечания. Во-первых, метод prepare()
выполняется синхронно, что при большом размере файла (ввиду буферизации) будет подвешивать систему. Рекомендуется либо запускать его в отдельном потоке, либо использовать асинхронный метод prepareAsync()
и OnPreparedListener
. Во-вторых, хорошо бы связать воспроизведение с жизненным циклом активности (приостанавливать, когда пользователь сворачивает приложение и возобновлять при восстановлении), но мы этого не сделали. Ай-ай-ай…
Для звуков MediaPlayer
тоже подойдет, но если их мало и они простые (как в нашем случае), подойдет и SoundPool
. Преимущество его состоит в том, что когда звуковые файлы уже загружены в память, их воспроизведение начинается мгновенно. Недостаток очевиден — памяти может не хватить (но нам хватит, мы скромные).
class DroidSoundPlayer(context: Context) : SoundPlayer {
private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100)
private val sounds = mutableMapOf<Sound, Int>()
private val rate = 1f
private val lock = ReentrantReadWriteLock()
init {
Thread(SoundLoader(context)).start()
}
override fun play(sound: Sound) {
if (lock.readLock().tryLock()) {
try {
sounds[sound]?.let { s ->
soundPool.play(s, 1f, 1f, 1, 0, rate)
}
} finally {
lock.readLock().unlock()
}
}
}
private inner class SoundLoader(private val context: Context) : Runnable {
override fun run() {
val assets = context.assets
lock.writeLock().lock()
try {
Sound.values().forEach { s ->
sounds[s] = soundPool.load(
assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1
)
}
} finally {
lock.writeLock().unlock()
}
}
}
}
При создании класса все звуки из перечисления Sound
загружаются в хранилище в отдельном потоке. В этот раз мы не используем синхронизированную коллекцию, но реализуем мьютекс при помощи стандартного класса ReentrantReadWriteLock
.
Теперь наконец-то слепим все компоненты воедино внутри нашей MainActivity
— не забыли о такой? Обратите внимание, что MainMenu
(да и Game
впоследствии) должен запускаться в отдельном потоке.
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this))
val surface = DiceSurface(this)
val renderer = DroidMenuRenderer(surface)
val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this))
setContentView(surface)
Thread {
MainMenu(renderer, interactor).start()
finish()
}.start()
}
override fun onBackPressed() {
}
}
Вот, собственно, и все. После всех мучений главный экран нашего приложения выглядит просто потрясающе:
Ну то есть, будет выглядеть потрясающе, когда в наших рядах появится толковый художник, и с его помощью это убожество будет полностью перерисовано.
Полезные ссылки
Знаю, многие прокрутили прямиком до этого пункта. Ничего страшного — большинство читателей и вовсе вкладку закрыли. Тем единицам, кто все же выдержал весь этот поток бессвязной болтовни — респект и уважуха бесконечная любовь и благодарность. Ну и ссылки, конечно, куда ж без них. В первую очередь на исходный код проектов (имейте в виду, что текущее состояние проектов ушло далеко вперед от рассматриваемого в статье):
Ну и вдруг у кого-то появится желание запустить и посмотреть проект, а самостоятельно собирать его лень, вот ссылка на рабочую версию: ССЫЛКА!
Здесь для запуска используется удобный launcher (о создании которого вполне можно отдельную статью написать). Он использует JavaFX и потому может не запуститься на машинах с OpenJDK (пишите — поможем), но по крайней мере избавляет от необходимости вручную прописывать пути к файлам. Справка по установке содержится в файле readme.txt (помните такие?). Скачивайте, смотрите, пользуйтесь, а я наконец умолкаю.
Если вас заинтересовал проект, или используемый инструмент, или механики, или какое-то интересное решение, или, я не знаю, lore игры, можно подробнее рассмотреть его в отдельной статье. Если хотите. А если не хотите, то просто присылайте замечания, пожаления и предложения. Буду рад пообщаться.
Всего хорошего.
Автор: Александр