Шаблоны корпоративных сайтов
3 750 р.
5 000 р.
pom.xml
прописать необходимые идентификаторы:<modelVersion>4.0.0</modelVersion>
<groupId>my.company</groupId>
<artifactId>dice</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<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
— для классов, отвечающих за взаимодействие с пользователем.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>
main
) при компиляции все равно создаются классы (потому как JVM ничего другого не знает и знать не желает). В качестве имени этого класса используется имя файла с добавкой Kt
. То есть, если главный класс вы назвали Main
, то скомпилирован он будет в файл MainKt.class
. Именно этот последний мы и должны указывать в манифесте jar-файла.dice-1.0.jar
и dice-1.0-jar-with-dependencies.jar
. Нас интересует второй. Напишем для него скрипт запуска.@ECHO OFF
rem Compiling
call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package
if errorlevel 1 echo Project compilation failed! & pause & goto :EOF
rem Running
java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
pause
#!/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, с упором на ловкость и скрытность). Класс героя определяет его характеристики, умения и начальный набор кубиков, но как будет позже видно, строгой привязки к классам герои иметь не будут, а потому их персональные настройки можно будет с легкостью менять в одном-единственном месте.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)
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"
})
}
}
apply{}
— в Java код был бы в два раза более громоздким. Причем местностей, как мы сказали, будет много, а кроме них есть еще сценарии, приключения и герои с их навыками и характеристиками — в общем, есть, чем заняться гейм-дизайнеру.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()
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>
}
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
}
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>()
}
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
}
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()
будет запускаться несколько раз, каждый раз на гранях кубиков будут отображаться разные значения (прямо как в реальной жизни). И только когда кубики успокоятся на столе, мы узнаем 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
позволяет проделывать с нашим классом 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
и трех его методов, причем поскольку талантливого художника в нашей команде все еще нет, делать это мы будем самостоятельно при помощи псевдографики. Но для начала неплохо бы понять, а что мы вообще ожидаем увидеть на выходе. А увидеть мы хотим три экрана приблизительно следующего содержания:prinltn()
нам будет явно недостаточно. Хотелось бы еще уметь прыгать в произвольные места экрана и рисовать символы разными цветами.<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()
, а переменная инициализируется новым объектом. Пока что ничего сложного, верно? А раз так, то начнем наполнять этот класс другими полезными методами.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)
}
}
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
)
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()
}
}
GameInteractor
. Их всего три, но они требуют особого внимания. Во-первых, синхронность. Работа игрового движка должна приостанавливаться до тех пор пока игрок не нажмет на клавишу. Во-вторых, обработка нажатий. К сожалению, возможностей стандартных классов Reader
, Scanner
, Console
недостаточно для распознавания этих самых нажатий: мы не требуем от пользователя жать ENTER после ввода каждой команды. Нам нужно что-то вроде KeyListener
'а, но он крепко привязан к фреймворку Swing, а наше приложение консольное — без всей этой графической мишуры.<dependency>
<groupId>jline</groupId>
<artifactId>jline</artifactId>
<version>2.14.6</version>
<scope>compile</scope>
</dependency>
ConsoleReader
с методом readCharacter()
. Как понятно из названия, данный метод возвращает код нажатого на клавиатуре символа (при этом работает синхронно, что нам и нужно). Остальное — дело техники: составить таблицу соответствий между символами и типами действий (Action.Type
) и по нажатию на одно возвращать другое.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)
}
}
}
}
}
}
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
}
}
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
}
}
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()
один-единственный раз где-то в самом начале (инициализировав его нужными объектами) и в дальнейшем пользоваться удобными методами, полностью забыв о подробностях реализации. Даже если вы этого не сделаете, не волнуйтесь, система на умрет — объект будет инициализирован классами по умолчанию.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
, но мы на первых порах не станем этого делать, чтоб приложение громко падало каждый раз при проблемах со звуком.<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()
(или пока не завершатся другие потоки, о чем уже было сказано). В-четвертых, хоть приведенный код и изобилует кажущимися ненужными флагами и командами, он тщательно отлажен и протестирован — проигрыватель работает как положено, не тормозит систему, не прерывается внезапно на полпути, не приводит к утечкам памяти, не содержит генно-модифицированных объектов, сияет свежестью и чистотой. Берите и смело пользуйтесь в своих проектах.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]
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=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438:
end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438:
end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e:
choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c:
choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435?
encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443.
encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438:
die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a!
die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a.
game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f
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!")
}
}
.properties
в кодировке UTF-8 без какой-либо конвертации.
java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
chcp 65001
java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
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()
}
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());
}
}
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
}
}
}
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
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>
java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar
META-INF/MANIFEST.MF
(секция так и называется — Claspath:
). Ничего страшного, для этого даже специальные плагины имеются (maven-compiler-plugin или, на худой конец, maven-assembly-plugin). Вот только wildcards в манифесте, увы, не работают — вам придется явно указывать названия зависимых jar-файлов. То есть, знать их заранее, что в нашем случае проблематично.adventures/
можно было накидать любое количество приключений, и чтобы все они были видны игровому движку в процессе выполнения. К сожалению, кажущаяся очевидной функциональность выходит за рамки стандартных представлений мира Java. А потому и не приветствуется. Нужно реализовывать другой подход к распространению независимых приключений. Какой? Не знаю, пишите в комментариях — наверняка у кого-то есть умные идеи.
@ECHO OFF
call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install
call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package
call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package
mkdir path_to_project\DiceCli\target\adventures
copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\
chcp 65001
cd path_to_project\DiceCli\target\
java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt
pause
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/
.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
}
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"
}
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
, для которых напишим новые, специфичные для андроида реализации. Но перед этим прикинем, как вообще будет происходить взаимодействие пользователя с нашей новой системой.Canvas
. Для этого нам достаточно создать один-единственный наследник класса View
— это и будет наш «холст». С вводом чуть сложнее, так как клавиатуры у нас больше нет, и интерфейс необходимо разрабатывать таким образом, чтобы вводом команд считались нажатия пользователя на определенные части экрана. Для этого воспользуемся все тем же наследником View
— таким образом, он будет выступать посредником между пользователем и игровым движком (аналогично тому, как ранее таким посредником выступала системная консоль).<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="my.company.dice">
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<activity
android:name=".ui.MainActivity"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|keyboardHidden|screenSize">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
</application>
</manifest>
<resources>
<style name="AppTheme"
parent="android:Theme.Black.NoTitleBar.Fullscreen"/>
</resources>
<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
— и производить отрисовку в отдельном потоке, но игра у нас пошаговая, бедная на анимации, сложного графического вывода не требующая, потому не станем усложнять). Как было сказано ранее, его реализация будет решать сразу две задачи: вывод изображения и обработка нажатий, каждая из которых имеет свои неожиданные сложности. Рассмотрим их по порядку.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...
}
}
}
instructions
— можно было бы создать отдельный интерфейс и вызывать его единственный метод, но Kotlin позволяет значительно сократить количество кода.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 }
}
}
}
awaitAction()
и если в очереди что-то есть, обрабатывает полученную команду. Обратите внимание на то, как команды добавляются в очередь. Поскольку UI-поток выполняется непрерывно, пользователь может нажать на экран много раз подряд, что способно привести к подвисаниям активности, особенно если игровой движок не готов принимать команды (например, во время выполнения анимаций). В этом случае поможет увеличение емкости очереди и/или уменьшение значения таймаута.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() {
}
}