pom.xml
file: <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
, you will also have a src/main/java
folder. The developers of the Kotlin language claim that the source files from the first folder ( *.kt
) should be compiled before the source files from the second ( *.java
) and therefore strongly recommend changing the settings of the standard Maven targets: <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
- for classes describing objects of the game world;game
- for classes that implement the gameplay;ui
- for classes responsible for user interaction.main
and we are ready for great things. You can use the IDE to launch it, but as you will see later, this method is not suitable for our purposes (the standard IDE console is not able to display our graphical surveys as it should), so we will configure the launch from the outside using batch (or shell on UNIX systems) a file. But before that, let's make some additional settings.mvn package
operation, we will get a JAR-archive with all the combined classes at the output. First, by default, the dependencies necessary for the project’s work are not included in this archive (for the time being, we don’t have them, but they will definitely appear in the future). Secondly, the path to the main class containing the main
method is not specified in the archive manifest file, so we will not be java -jar dice-1.0.jar
to start the project with the java -jar dice-1.0.jar
. Fix this by adding additional settings to 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
functions) classes are still created during compilation (because the JVM does not know anything else and does not want to know anything). The name of this class is the name of the file with the addition of Kt
. That is, if you named Main
as the main class, then it will be compiled into the MainKt.class
file. It is this last we should indicate in the manifest of the jar file.dice-1.0.jar
and dice-1.0-jar-with-dependencies.jar
. We are interested in the second. Let's write a startup script for it. @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
command to remove all previously compiled files, but in this case the whole compilation process will always start from the very beginning (even if the source code has not changed), which will take a lot of time. And we can not wait - we need to make the game.model
package with the classes necessary for the game process.Die
class instance) is characterized by type (color) and size. For the types of the cube, we make a separate enumeration ( Die.Type
), mark the size with an integer from 4 to 12. We also implement the roll()
method, which will produce an arbitrary, evenly distributed number from the available range for the cube (from 1 to the size value inclusive).Comparable
interface Comparable
that the cubes can be compared with each other (useful later when we display several cubes in an ordered row). Larger cubes will be placed earlier. 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
class). One can only guess about what is going on inside the bag, so there is no point in using an ordered collection. It seems to be. Sets (sets) well implement the idea we need, but do not fit for two reasons. First, when using them, you will have to implement the equals()
and hashCode()
methods, and it’s not clear how, since it is wrong to compare the types and sizes of the cubes - any number of identical cubes can be stored in our set. Secondly, pulling the cube out of the bag, we expect to get not just something non-deterministic, but random, each time is different. Therefore, I advise you to use an ordered collection (list) and mix it every time you add a new element (in the put()
method) or immediately before issuing (in the draw()
method).examine()
method is suitable for cases when a player tired of uncertainty in the hearts shakes out the contents of the bag on the table (pay attention to sorting), and the clear()
method - if the diced out cubes no longer return to the bag. 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
class). The latter differ from the first ones in that their contents are visible to the players, and therefore, if necessary, get a cube out of the heap, the player can select a specific instance of interest. This idea is implemented by the removeDie()
method. class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) }
Character
in Java). Heroes are of different types (that is, classes, although the word class
also better not to use), but for our working prototype we take only two: Brawler (that is, Fighter with an emphasis on stamina and strength) and Hunter (aka Ranger / Thief, with emphasis on dexterity and stealth). The class of the hero determines its characteristics, skills and initial set of cubes, but as will be seen later, the heroes will not have a strict binding to the classes, and therefore their personal settings can be easily changed in one single place. 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
). The design decision to keep the allied cubes apart from the main hand was one of the first to come to mind. At first, it seemed to be a super cool feature, but later it gave rise to a huge number of problems and inconveniences. However, we are not looking for easy ways, and therefore dice
and allies
lists are our services, with all the methods needed to add, get and delete (some of them cleverly determine which of the two lists to apply to). When the cube is removed from the hand, all subsequent cubes will move to the top of the list, filling in the blanks - in the future this will greatly simplify the search (no need to handle situations with 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 sets limits on the number of cubes of each type that the hero can have at the beginning of the script. There is nothing special to say here, we define the initial, maximum and current values for each type. class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)
level
, maxLevel
, modifier1
and modifier2
properties. 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
class, which allow you to hide or discard a die from your hand, check whether the hero has a certain skill, and also increase the level of the learned skill or learn a new one. All of them will be needed sooner or later, but now we will not dwell on them in detail.Villain
class), enemies ( Enemy
class) and obstacles ( Obstacle
class), united under the general term "threats" ( Threat
- abstract "locked" class, the list of its possible heirs is strictly limited). Each threat has a set of distinctive features ( Trait
) that describe specific rules of behavior when meeting this threat and bring diversity to the gameplay. 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
class is defined as mutable ( MutableList
), but is MutableList
outside as an unchangeable List
interface. Although this will work in Kotlin, the approach is however unsafe because it doesn’t prevent the resulting list from being converted to a modifiable interface and making various modifications — especially simple if you access the class from Java code (where the List
interface is modifiable). The most paranoid way to protect your collection is to do something like this: fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)
Obstacle
class differs from its counterparts in the presence of additional fields, but we will not focus on them.Deck
class: 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, each instance of which describes a unique area that our heroes will have to visit within the script. 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
property can be null
). In each area there is a bag with cubes and a deck of cards with threats. Also, a terrain may have its own unique gaming features ( SpecialRule
), which, like the properties of threats, bring diversity to the gameplay. As you see, we are laying the basis for future functionality, even if we do not plan to implement it in the near future (for which, in essence, we need a modeling stage).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
) and skills of allies are set (we will omit consideration). One might think that the script should also contain a list of localities (objects of the Location
class) and, logically, this is true. But as will be seen later, we will not use such a link anywhere, and it does not give any technical advantages.model
package - we, as a child in anticipation of an epic toy battle, placed the soldiers on the tabletop.And just about, in a few moments, at the signal of the commander-in-chief, we will rush into battle, pushing our toys together and enjoying the consequences of the gameplay. But before that - a little about the very arrangement.Location
, initialize its fields with values, and so for each locality that we want to use in the game. But wait, each location must have a bag that must also be generated. And bags have cubes - these are also instances of the corresponding class ( Die
). I’m still not talking about enemies and obstacles - they generally need to be assembled into decks. A villain does not determine the terrain itself, but the features of the script, located one level higher. Well, you understand. The source code for the above may be: 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{}
- in Java, the code would be twice as cumbersome. Moreover, as we have said, there will be a lot of localities, but besides them there are still scenarios, adventures and heroes with their skills and characteristics - in general, there is something to do with the game designer.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
), which, unlike the class constructor Die
, will accept not the explicit type and size of the cube, but the filter and the level of complexity. 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()
can be driven into an infinite loop, passing the input filter with override fun test(filter: DieTypeFilter) = false
generateDieSize()
generates a pseudo-random size based on the distribution specified as an array (one for each level). When I get rich in old age and buy myself a package of multi-colored dice, I will not be able to play Dice , because I won’t know a way to randomly make a bag out of them (except to ask a neighbor, but to turn away myself). This is not a deck of cards that can be shuffled face-down, it requires special mechanisms and devices. If someone has ideas (and he had the patience to read this far), please share in the comments.BagTemplate
) will be a specific class. It contains other templates - each of them describes the rules (or Plan
) by which one or several cubes (remember the requirements voiced earlier?) Are added to the bag. 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()
you can use the properties of the interfaces - so much more concisely. We are already familiar with the bag pattern, consider the remaining methods. The property basicClosingDifficulty
will set the base complexity of the closure check. The word “basic” here means only that the final complexity will depend on the level of the scenario and is not clear at this stage. In addition, we need to define patterns for enemies and obstacles (and villains at the same time). In this case, from the variety of enemies and obstacles described in the template, not all will be used, but only a limited number (to increase replayability). Please note that special rules ( SpecialRule
) areas are implemented by a simple listing ( enum class
), and therefore do not require a separate template. 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
), we will remove them from there. Knowing how to generate bags with cubes and a deck of cards, we can finally create terrain: 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()
- what is all this dregs ??? Calm down, these are the ones DiceLimit
that set the restrictions on the cubes. And the pattern for them is chosen in such a bizarre way that specific values are more clearly recorded. See for yourself from the example: 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>>
, where each locality will contain a list of heroes currently in it (as well as a method for the reverse — determining the locality in which a particular hero is located). If you decide to go this way, then do not forget to add Location
methods to the class, equals()
and hashCode()
I hope you don’t need to explain why. We will not waste time on this, since the terrain is only one and the heroes do not leave it anywhere.HandFilter
. interface HandFilter { fun test(hand: Hand): Boolean }
Hand
) and return true
or false
, depending on the test results. For our fragment of the game, a single implementation will be needed: if a blue, green, purple or yellow cube is encountered, it is necessary to determine if the hero has a cube of the same color. 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
, which, in fact, contains a set of integers (numbers of selected positions) and methods for adding and deleting them. 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
), but this functionality is beyond the limits of the considered example. 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
) they will deal with. Each of the methods accepts a mask ( HandMask
) at the input , reflecting the current state of choice (which positions are chosen by the player and which are not). The method checkMask()
reports whether there are enough selected cubes to pass the test. The method isPositionActive()
says whether it is necessary to highlight a specific position - whether it is possible to add a cube in this position to the check (or remove a cube that has already been selected). The method isAllyPositionActive()
is the same for the white cube (yes, I know, I'm an idiot). Well, the auxiliary methodgetCheckedDice()
simply returns a list of all the cubes from the hand that correspond to the mask - this is necessary in order to take them all at once, throw them on the table and enjoy the cheerful knock they fly in different directions. 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
a property suddenly appeared in the class woundCount
that had not existed before. You can write its implementation yourself, it is easy. Practice at the same time. 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
. It seems to be. Actually, no, because in addition to the cube and the modifier, you also need to store the result of his throw (remember, the cube itself though generates this value, but does not store it among its properties). Therefore, we wrap each pair in a wrapper ( Wrap
). Notice the infix method with
, hehe.Method
) and the opponent (which may not be) are specified . The list of hero cubes is formed using appropriate methods. There are also a bunch of methods for obtaining couples involved in the test, and the results of their throws (if any).roll()
calls the method of each cube of the same name, saves intermediate results and marks the fact of its execution with a flag isRolled
. Note that the final result of the throw is not calculated immediately - there is a special method for this calculateResult()
, the result of which is the writing of the final value to the property result
. Why do you need it?For dramatic effect. The method roll()
will be launched several times, each time different values will be displayed on the faces of the cubes (just like in real life). And only when the cubes calm down on the table, we will know 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()
, where X
is the value from the above listing. In these methods, all internal variables of the engine will be reduced to adequate values for the beginning of the corresponding phase, but more on that later. 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
- this is a special value), but we will know about it a little later. 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
describes the type of action performed. The field is isEnabled
necessary in order to display actions in an inactive state. That is, to report that this action is usually available, but at the moment for some reason cannot be performed (such a display is much more informative than when the action is not displayed at all). The property data
(necessary for some types of actions) stores a special value that communicates some additional details (for example, the index of the position selected by the user or the number of the selected item from the list).Action
is the main "interface" between the game engine and input / output systems (which are discussed below). Since there are often several actions (otherwise why then a choice?), They will be combined into groups (lists). Instead of using standard collections, let's write our own, extended one. 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()
- we apply the bracket operator to our list). The implementation of the interface Iterator
allows enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS }
GameRenderer
, designed to display pictures on the screen. I remind you, we abstract from the size of the screen, from specific graphic libraries and so on. We simply send the command: “draw me this here” - and those of you who understood our vague conversation about the screens, have already guessed that for each of these screens, the interface has its own method. 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
(yes, spell checking scripts will henceforth always underline this word, although it would seem ...). His methods will ask the player for the required commands for different situations: choose an action from the list of suggestions, select an element from the list, select the cubes from the hand, just press something like that. It should be immediately noted that the input is synchronous (the game is step-by-step), that is, the execution of the game cycle is suspended until the user responds to the request. interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action }
HandMask
- the number of active positions. The execution of the method will continue until some of them are selected - in this case, the method will return an action of type HAND_POSITION
(or HAND_ALLY_POSITION
, mda) with the number of the selected position in the field data
. In addition, it is possible to select another action (for example, CONFIRM
or CANCEL
) from an object ActionList
. Implementations of input methods should distinguish situations when the field is isEnabled
set to false
and ignore user input of such actions.Game
with the following content: 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()
- the entry point into the game. Here variables are initialized, heroes are weighed, hands are filled with cubes, and reporters shine with cameras from all sides. The main loop will be started from minute to minute, after which it will not be stopped. The method drawInitialHand()
speaks for itself (we do not seem to have considered the code of the drawOfType()
class method Bag
, but having gone so long a way together, you will easily write this code yourself). The method refillHeroHand()
has two options (depending on the value of the argument redrawScreen
): fast and quiet (when you need to fill the hands of all the characters at the beginning of the game), and loud with a bunch of pathos, when at the end of the turn you need to demonstratively get the cubes out of the bag, bringing the hand to the desired size.changePhase
, - as we have already said, they serve to change the current game phase and are engaged in assigning the corresponding values of game variables. Here is formed a list actions
, which adds characteristic for this phase of action.pickDiceFromHand()
in a generalized form deals with the choice of cubes from the hand. An object of a familiar class HandMaskRule
that sets selection rules is passed here . It also indicates the possibility to refuse to select ( allowCancel
), as well as the function onEachLoop
whose code must be called whenever the list of selected cubes changes (usually this is a redrawing of the screen). The cubes selected by this method can be assembled from the hand using the collectPickedDice()
and methods collectPickedAllyDice()
.performStatDieAcquireCheck()
fully implements the passage of the hero checks for the purchase of a new cube. Object plays a central role in this method DieBattleCheck
. The process begins with the choice of cubes by the method pickDiceFromHand()
(at each step, the list of "participants" is updated DieBattleCheck
). The selected cubes are removed from the hand, after which a “throw” occurs - each cube updates its value (eight times in a row), after which the result is calculated and displayed. With a successful roll, the new cube falls into the hero's hand. The participating cubes are either held (if they are blue), dropped (if shouldDiscard = true
), or hidden in a bag (if shouldDiscard = false
).processCycle()
contains an endless loop (I will ask without fainting), in which the screen is first drawn, then the user is asked to enter, then this input is processed - with all the ensuing consequences. The method drawScreen()
calls the desired interface method GameRenderer
(depending on the current value screen
), passing it the required objects to the input.checkLocationCanBeExplored()
, checkHeroCanAttemptStatCheck()
and checkHeroCanAcquireDie()
. Their names speak for themselves, so we will not dwell on them in detail. And then there are the class method calls Audio
, underlined with a red wavy line. Comment them for the time being - we will consider their purpose later.GameRenderer
and its three methods, and since there is still no talented artist in our team, we will do it ourselves with the help of pseudographics. But for a start it would be nice to understand what we generally expect to see at the exit. And we want to see three screens of approximately the following content:prinltn()
will not be enough for us. I would like to be able to jump to arbitrary places on the screen and draw symbols with different colors. <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency>
Ansi
(obtained as a result of a static call Ansi.ansi()
) with a bunch of convenient methods that can be chained. It works on the principle of StringBuilder
'a - first we form an object, then send it to print. From useful methods we can use:a()
- to display characters;cursor()
- to move the cursor around the screen;eraseLine()
- as it speaks for itself;eraseScreen()
- similarly;fg(), bg(), fgBright(), bgBright()
- very inconvenient methods for working with text and background colors - we will make our own, more pleasant ones;reset()
- to reset the installed color settings, flicker, etc.ConsoleRenderer
with utility methods that can be useful to us in our work. The first version will look something like this: 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()
creates a new (empty) object Ansi
that will be filled with the necessary commands (move, output, etc.). Upon completion of the filling, the generated object is sent to the print method render()
, and the variable is initialized by the new object. So far, nothing complicated, right? And if so, then begin to fill this class with other useful methods.CONSOLE_WIDTH
and CONSOLE_HEIGHT
. We will not be bound to specific values and will try to make the design as rubbery as possible (as on the web). The numbering of coordinates begins with one, the first coordinate is a row, the second is a column. Knowing all this, we will write the service methoddrawHorizontalLine()
to fill the specified string with the specified character. 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()
or cursor()
does not lead to any instant effect, but only adds to the object the Ansi
corresponding sequence of commands. Only when these sequences are sent to print, we will see them on the screen.for
and functional approach ClosedRange
and forEach{}
there is no fundamental difference - each developer decides that it is more convenient. However, I will continue to fool your head with functionalism, simply because drawBlankLine()
that does the same thing asdrawHorizontalLine(offsetY, ' ')
with extension only. Sometimes we will need to make the line not completely empty, but leave a vertical line at the beginning and end (frame, aha). The code will look something like this: 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
contains constants Color
for eight primary colors (black, blue, green, blue, red, purple, yellow, gray), which need to be passed to the input methods fg()/bg()
for the dark version or fgBright()/bgBright()
- for the light one, which is terribly inconvenient to do, so as to identify colors the way is not enough for us one value - you need at least two (color and brightness). Therefore, we will create our own list of constants and our own extension methods (as well as color-matching cards for dice types and hero classes): 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
file objects .properties
. Here we start with this file: # 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
=
. The file can be put anywhere - as long as the path to it is included in the classpath. Note that the text for actions consists of two parts: the first letter is not only highlighted in yellow when displayed on the screen, but also identifies the key that must be pressed to perform this action. Therefore, it is convenient to store them separately. 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()
for displaying the current state of the game engine ( StatusMessage
) and a method drawActionList()
for displaying a list of available actions ( ActionList
) on the screen . As well as other service methods that only the soul desires. 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
), painted in red in the center of the screen ( drawCenteredCaption()
). The rest of the code fills the rest of the screen with empty lines. Maybe someone will ask why this is needed - there is a method clearScreen()
, it is enough to call it at the beginning of the method, clear the screen, and then draw the desired text. Alas, this is a lazy approach, which we will not use. The reason is very simple: with this approach, some positions on the screen are drawn two times, which leads to a noticeable flicker, especially when the screen is sequentially drawn several times in a row (during animations). Therefore, our task is not only to draw the necessary characters in the right places, but also to fill the entireThe rest of the screen is empty characters (so that it does not remain artifacts from other rendering). And this task is not so simple. 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()
. As you understand, the code here will be an order of magnitude more. In addition, the screen contents will dynamically change in response to user actions and it will have to be constantly redrawn (sometimes with animation). Well, in order to finally finish you off: Imagine that, in addition to the one shown in the screenshot above, within this method you need to implement the display of three more: 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
. There are only three of them, but they require special attention. First, synchronicity. The game engine must pause until the player presses a key. Secondly, the processing of clicks. Unfortunately, the capacity of standard classes Reader
, Scanner
, Console
is not enough to recognize these most pressing: we do not require the user to press ENTER after each command. We need something like KeyListener
aa, but it is tightly tied to the Swing framework, and our console application is without all this graphical tinsel. <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency>
ConsoleReader
with a method readCharacter()
. As the name implies, this method returns the code of the character pressed on the keyboard (it works synchronously, which is what we need). The rest is a matter of technology: create a table of correspondences between symbols and types of actions ( Action.Type
) and by clicking on one to return another.ConsoleInteractor
with the service methods we need. 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
and method read()
. In addition, we will consider a method getIndexForKey()
used in situations where we need to select an element from the list or cubes from the hand. It remains to inherit from this class our implementation of the interface 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
and GameInteractor
, its implementation also needs to be passed to the class instance as input Game
. To begin with, the implementation may be as follows: 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
is our singleton. It provides a single facade to the subsystem ... by the way, here is the facade - another design pattern, thoroughly crafted and repeatedly described (with examples) on these your Internet sites. Therefore, already hearing dissatisfied shouts from the back rows, I stop interpreting well-known things a long time ago and move on. Code here: 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()
a single time somewhere at the very beginning (having initialized it with the necessary objects) and continue to use convenient methods, completely forgetting about the implementation details. Even if you do not do this, do not worry, the system will not die - the object will be initialized by default classes.AudioSystem
and interface Clip
. All we need is to correctly set the path to the audio file (which we have in the classpath, remember?): 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()
can be thrown away IOException
(especially if he didn’t like the file format — in this case I recommend opening the file in the audio editor and re-saving), so it would be nice to wrap it in a block try-catch
, but we’ll not do it at first, so that the application is loud fell every time with problems with sound. <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
), and make it “optional” (daemon), so that it does not in any way interfere with the application to complete ahead of schedule. Secondly, the identifier of the currently playing music file ( currentMusic
) is saved in the player’s code . If a repeat command comes to replay it, we will not start playing from the very beginning. Thirdly, when the end of the music file is reached, its playback will start anew - and so on until the stream is explicitly stopped by the commandfinish()
(or until other threads are completed, as already mentioned). Fourth, although the code above is replete with seemingly unnecessary flags and commands, it is carefully debugged and tested - the player works as expected, does not slow down the system, does not suddenly stop in the middle of the way, does not cause memory leaks, does not contain genetically modified objects, it shines freshness and purity. Take it and feel free to use it in your projects.String
? Now this will not work. In addition to the default language, you also need to provide translation into all the languages you plan to support. For example, like this: 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
also define a country and a region. If it is important in your application, then yours LocalizedString
will look slightly different, but we are satisfied with it.ResourceBundle
already contains all the necessary mechanisms. It is only necessary to prepare files with translations and change the way they are downloaded. # 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
he will assume the responsibility to find among the localization files the one that most closely matches the current locale. And if it does not, it will take the default file ( string.properties
). And all will be well….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()
we have already created the method , now we will fill it with content. We will need:GameInteractor
;GameRenderer
and StringLoader
;SoundPlayer
and 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
- I did just that. However, among our code there are methods for which the concept of unit testing is excellent. 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
and its heirs? Now imagine that at some point in order to use a skill, a hero needs to take three dice from his hand, and the types of these cubes are busy with strict restrictions (for example, “the first cube must be blue, green or white, the second - yellow, white or blue, and the third - blue or purple "- do you feel the difficulty?). How to approach class implementation? Well ... for starters, you can decide on input and output parameters. Obviously, it is necessary for the class to accept three arrays (or sets), each of which contains valid types for the first, second, and third cubes, respectively. And then what?Surfing? Recursion? What if I miss something? Make a deep entrance. Now put off the implementation of the class methods and write a test - the benefit of the requirements is simple, clear and well formalized. And better write a few tests ... But we will consider one, for example: 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
looks like this: <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
dependencies on external libraries are migrated to the file : <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
(the section is called - Claspath:
). It's okay, for this, even special plug-ins are available ( maven-compiler-plugin or, at worst, maven-assembly-plugin ). Here, alas, wildcards do not work in the manifest - you will have to explicitly specify the names of the dependent jar-files. That is, to know them in advance, which in our case is problematic.adventures/
it was possible to throw in any number of adventures, and so that all of them were visible to the game engine in the process of execution. Unfortunately, the seemingly obvious functionality goes beyond standard views of the Java world. And therefore not welcome. Need to implement a different approach to the spread of independent adventures. Which oneI do not know, write in the comments - surely someone has clever ideas. @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
#!/bin/sh mvn -f "path_to_project/DiceCore/pom.xml" install mvn -f "path_to_project/DiceCli/pom.xml" package mvn -f "path_to_project/TestAdventure/pom.xml" package mkdir path_to_project/DiceCli/target/adventures cp path_to_project/TestAdventure/target/test-adventure-1.0.jar path_to_project/DiceCli/target/adventures/ cd path_to_project/DiceCli/target/ java -cp "dice-cli-1.0-jar-with-dependencies.jar:adventures/*" my.company.dice.MainKt
-jar
we add the Cli project to the classpath and explicitly specify the class contained within it as an entry point MainKt
. Plus, here we connect all the archives from the folder adventures/
.Game
and consider a similar but much more simple class MainMenu
. As the name implies, it is designed to implement the main menu of the application and in fact is the first class with which the user begins to interact.Game
, it sets an infinite loop, at each iteration of which the screen is drawn and a command is requested from the user. But there is no complicated logic here and these commands are much smaller. We are implementing essentially one - “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
and MenuInteractor
operating in the same way as previously seen. 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
(analog GameInteractor
) MenuRenderer
(analog GameRenderer
) and StringLoader
for which I will write a new, specific to the implementation of the android. But before that, let's figure out how the user will interact with our new system.Canvas
. For this we need to create a single class heir.View
- this will be our "canvas". The input is a bit more complicated, since we no longer have a keyboard, and the interface needs to be designed in such a way that the user’s presses on certain parts of the screen are considered as input. To do this, we will use the same successor View
- thus, it will act as an intermediary between the user and the game engine (in the same way as the system console acted as such intermediary). <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
and /assets/music/menu_main.mp3
respectively.DiceSurface
- the same heir View
who is supposed to bond together the independent parts of our system (if you wish, you can inherit it from the class SurfaceView
- or even GlSurfaceView
- and draw in a separate stream, but the game is step-by-step, poor in animation , complex graphic output does not require, therefore we will not complicate it). As mentioned earlier, its implementation will solve two problems at once: outputting the image and processing clicks, each of which has its own unexpected difficulties. Consider them in order.onDraw()
is executed should already know what, how and where, to draw. But what about the drawMainMenu()
interface method MainMenu
? Does he not control the output now?DiceSurface
will contain a special parameter instructions
- in fact, a block of code that must be executed each time the method is called onDraw()
. Renderer, by means of a public method, will indicate which specific instructions should be executed. If anyone is interested, the pattern used is called strategy . It looks like this: 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
- it would be possible to create a separate interface and call its only method, but Kotlin can significantly reduce the amount of code.BlockingQueue
. The class DroidMenuInteractor
will call the methodtake()
which will suspend the execution of the game stream until elements appear in the queue (instances of the class we are familiar with Action
). DiceSurface
in turn, it will register user clicks (standard onTouchEvent()
class method View
), generate objects and add them to the queue method offer()
. It will look like this: 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()
and if there is something in the queue, it processes the received command. Notice how the commands are added to the queue. Since the UI stream runs continuously, the user can click on the screen many times in a row, which can lead to activity hangs, especially if the game engine is not ready to accept commands (for example, during animations). In this case, increasing the queue capacity and / or decreasing the timeout value will help.DiceSurface
will store a special collection - a list of active rectangles (or other shapes, if we ever grow up to this). Such rectangles contain the coordinates of the vertices and bound Action
. Renderer will generate these rectangles and add them to the list, the method onTouchEvent()
will determine which of the rectangles was pressed, and add the appropriate one to the queue 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()
checks whether the specified coordinates fall inside the rectangle. Please note that at the Renderer stage of work (this is exactly the moment when the rectangles are created), we have no idea about the size of the canvas. Therefore, we will have to store the coordinates in relative values (percentage of the width or height of the screen) with values from 0 to 1 and recalculate at the moment of pressing. This approach is not very accurate, since it does not take into account the aspect ratio - in the future it will have to be redone. However, for our educational tasks at first fit.DiceSurface
an additional field, add two methods ( addRectangle()
and clearRectangles()
) to control it from the outside (from the Renderer'a), and expand onTouchEvent()
, forcing to take into account the coordinates of the rectangle. 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
if the collection is simultaneously updated and sorted by different streams (which in our case will happen).DroidMenuInteractor
will remain unchanged, but DroidMenuRenderer
will change. Add four buttons to the display for each item ActionList
. Place them under the heading DICE, evenly distributed across the width of the screen. Well, let's not forget about the active rectangles. 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
and the capabilities of the auxiliary class StringLoadHelper
(not shown in the diagram). The implementation of the first has a name ResourceStringLoader
and is engaged in loading localized strings from (obviously) application resources. However, it does this dynamically, since resource identifiers are not known to us in advance - we have to construct them on the fly. 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
that deals with these things. There is nothing better for playing music: 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()
is executed synchronously, that with a large file size (due to buffering), the system will be suspended. It is recommended to either run it in a separate thread, or use the asynchronous method prepareAsync()
and OnPreparedListener
. Secondly, it would be good to link the reproduction with the activity life cycle (suspend when the user minimizes the application and resume when restoring), but we did not. Ahhhhhhhhh ...MediaPlayer
will also work for sounds , but if there are few of them and they are simple (as in our case), so will be SoundPool
. The advantage of it is that when sound files are already loaded into memory, their playback starts instantly. The disadvantage is obvious - the memory may not be enough (but we have enough, we are modest). 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
are loaded into the storage in a separate stream. This time we do not use a synchronized collection, but we implement a mutex using the standard class ReentrantReadWriteLock
.MainActivity
— haven't you forgotten about this? Please note that MainMenu
(and Game
later) should run in a separate thread. 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() { } }
Source: https://habr.com/ru/post/445278/
All Articles