📜 ⬆️ ⬇️

How to create a game if you are never an artist


In every programmer's life there were moments when he dreamed of making an interesting game. Many programmers realize these dreams, and even successfully, but this is not about them now. Speech is about those who love to play games, who (even without knowledge and experience) and he himself tried to create them, inspired by examples of single characters who achieved worldwide fame (and huge profits), but deep down to compete with the guru igrostroya he can not afford.

Do not need…

Small introduction


Immediately make a reservation: our goal is not to make money - Habré is full of articles on this topic. No, we will make a dream game.

Lyrical digression about the dream game
How many times have I heard this word from lonely developers and small studios. Wherever you look, all beginner igrodels hurry to show the world the realization of their dreams and the “ideal vision,” and then write long articles about their heroic efforts, work process, inevitable financial difficulties, problems with publishers and in general “ungrateful players-dogs-im Give-grafonchik-and-coins-and-all-free-and-pay-not-want-a-game-pirates-and-we-profits-lost-because of them-that's it. ”
')
People, do not be fooled. You are not doing a dream game, but a game that will sell well is two different things. The players (especially the sophisticated) don't care about your dream and they won't pay for it. If you want profits - study trends, see what is popular now, do something unique, do better, more unusual than others, read articles (there are a lot of them), communicate with publishers - in general, realize the dreams of end users, not yours.

If you have not escaped and still want to realize a dream game, give up profits in advance. Do not sell the dream at all - share it for free. Give people your dream, attach them to it, and if your dream is worth something, you will receive, if not money, but love and recognition. Sometimes it is much more valuable.

Many people think that games are a waste of time and energy, and that serious people shouldn’t talk about it at all. But people who are not serious gathered here, so we will only partly agree - the games really take a lot of time if you play them. However, the development of games, although it takes many times more time, can bring a lot of benefit. For example, it allows you to get acquainted with the principles, approaches and algorithms that are not found in the development of non-game applications. Or to deepen the skills of possession of tools (for example, programming language), doing something unusual and fascinating. From myself I can add (and many will agree) that the development of games (even unsuccessful) is always a special, incomparable experience, which you later recall with awe and love, which I wish every developer to try at least once in a lifetime.

We will not use new-fangled game engines, frameworks, libraries - we will look into the very essence of the gameplay and feel it from the inside. We will give up flexible development methodologies (the task is simplified by the need to organize the work of just one person). We will not waste time and effort searching for designers, artists, composers and sound specialists - we will do everything ourselves, as we can (but we will do everything rationally - if the artist suddenly appears, we will not make much effort to fasten the fashionable graphics on the finished frame). In the end, we will not even particularly study the toolkit and choose the right one - we will do it on the one we know well and can use. For example, in Java, so that later, if necessary, transfer to Android (or to a coffee maker).

"BUT!!! Horror! Nightmare! As for such nonsense in general, you can spend time! Get out of here, I'll go see something more interesting! ”

Why do this? In a sense, reinvent the wheel? Why not use a ready-made game engine? The answer is simple: we do not know anything about him, but we want the game now. Imagine the mind of an average programmer: “I want to make a game! There will be meat, and explosions, and pumping, and you can loot the cows , and the plot is bombarding, and this has never happened before! I'll start writing right now! .. And on what? Let's see what is popular with us now ... Aha, X, Y and Z. Take Z, now everyone is writing on it ... ”. And he begins to study the engine. And the idea throws, because it does not have enough time. Fin. Or, okay, he does not quit, but without really examining the engine, he starts to play. It’s good if then he has no conscience for anyone not to show his first “handicraft.” Usually not (go to any application store, see for yourself) - well, I want profits, I have no strength to endure. Once the creation of games was the lot of enthusiastic creative people. Alas, this time has irrevocably passed - now the main thing in the game is not the soul, but the business model (at least, there are more talk of it). We have a simple goal: we will make games with the soul. Therefore we abstract from the tool (any will approach) and we will concentrate on a task.

So let's continue.
I will not go into the details of my own bitter experience, but I will say that one of the main problems for a programmer when developing games is graphics. Programmers usually do not know how to draw (although there are exceptions), and artists usually do not know how to program (although there are exceptions). And without graphics, you see, a rare game costs. What to do?

Options are:

1. Draw everything yourself in a simple graphic editor.

Screenshots of the game "Kill Him All", 2003

2. Draw everything yourself in the vector

Screenshots of the game "Raven", 2001


Screenshots of the game "Inferno", 2002

3. Ask a brother who does not draw too (but does it a little better)

Screenshots of the game "Trembling", 2004

4. Download some program for 3D-modeling and drag assets from there

Screenshots of the game "Fucking 2. Demo", 2006

5. Desperate to tear hair


Screenshots of the game "Trembling", 2004

6. Draw everything yourself in pseudographics (ASCII)

Screenshots of the game "Fifa", 2000


Screenshots of the game "Sumo", 1998

Let us dwell on the latter (partly because it does not look so sad as the others). Many inexperienced gamers believe that games without cool modern graphics are not capable of capturing the hearts of players - they cannot even be called games by games. Developers of such masterpieces as ADOM , NetHack and Dwarf Fortress tacitly object to such arguments. Appearance is not always a decisive factor, the use of ASCII gives some interesting advantages:


The above long introduction was intended to help beginning igrodelym overcome fears and prejudices, stop worrying and still try to do something like that. Ready? Then let's get started.

Step one. Idea


How? You still have no idea?

Turn off the computer, go eat, walk, exercise, exercise. Or sleep at worst. Come up with a game that does not wash the windows - insight in the process does not come. Usually the idea of ​​a game is born suddenly, unexpectedly, when you don’t think about it at all. If this suddenly happened, quickly grab a pencil and write down until the idea has flown away. Any creative process is just like that.

And you can also copy other people's games. Well, copy it. Of course, not to fight godlessly, telling at every corner how clever you are, but to use other people's work in your product. How much after that, it will remain specifically from your dream is a minor question, because gamers often have this: like everything in the game, except for some two or three annoying things, but if it’s done differently here ... Who knows Perhaps bringing to mind someone's good idea is your dream.

But we will go in a simple way - suppose that we already have an idea, and we have not thought about it for a long time. As our first grand project, we will make a clone of a good game from Obsidian - Pathfinder Adventures .

“What the hell is this! What are some? ”

As they say, pourquoi pas? We, it seems, have already left prejudices, and therefore are boldly starting to polish the idea. Naturally, we will not clone the game one to one, but we will borrow the basic mechanics. In addition, the implementation of turn-based desktop cooperative games has its advantages:


For those not familiar with the rules, a brief introduction:
Pathfinder Adventures is a digital version of a board-based card game created on the basis of a board-based role-playing game (or rather, an entire role-playing system) Pathfinder. Players (from 1 to 6) choose their character and together with him go on an adventure, divided into a number of scenarios. Each character has at its disposal cards of different types (such as: weapons, armor, spells, allies, objects, etc.), with which in each scenario must find and severely punish Rouse - a special card with special properties.

In each scenario, there are a number of locations or locations (their number depends on the number of players) that players need to visit and explore. Each location contains a deck of cards, lying face up, which the characters investigate during their turn - that is, they open the top card and try to overcome it according to the appropriate rules. In addition to the harmless cards that fill up the player’s deck, there are also evil enemies and obstacles in these decks - they must be defeated in order to advance further. The Scoundrel Card also lies in one of the decks, but players do not know which one — it needs to be found.

To defeat the cards (and to acquire new ones), the characters must pass a test of one of their characteristics (standard for RPG strength, dexterity, wisdom, etc.), throwing a die, the size of which is determined by the value of the corresponding characteristic (from d4 to d12), adding modifiers (defined rules and the level of character development) and playing to enhance the effect of suitable cards from the hand. Upon victory, the met card is either removed from the game (if it is an enemy), or replenishes the player’s hand (if it is an item) and the turn passes to another player. When losing a character, damage is often inflicted, causing him to fold cards from his hand. An interesting mechanic is that a character’s health is determined by the number of cards in his deck — as soon as a player needs to pull a card out of the deck, and there are no cards, his character dies.

The goal is, having made your way through the location maps, to find and conquer the Scoundrel, having previously blocked his path to retreat (you can learn more about this and much more by reading the rules). You need to do this for a while, what is the main difficulty of the game. The number of moves is strictly limited and the goal cannot be reached by simply enumerating all the available cards. Therefore it is necessary to apply various tricks and clever techniques.

As scenarios are completed, characters will grow and develop, improving their characteristics and acquiring new useful skills. Deck management is also a very important element of the game, since the outcome of the scenario (especially in the later stages) usually depends on the correctly chosen cards (and on the heap of luck, but what do you want from playing with dice?).

In general, the game is interesting, worthy, worthy of attention and, what is important for us, quite complex (note, I say “difficult” not in the sense of “difficult”), so that its clone is interesting to implement.

In our case, we will make one global conceptual change - discard the maps. Or rather, we will not refuse at all, but we will replace cards with cubes, still of different sizes and different colors (technically, it is not entirely correct to roll their “cubes”, since other forms are present besides the right hexagon, but I don’t call them “bones” and it is unpleasant, but to use Americanism “daisy” is a sign of bad taste, therefore we will leave it as it is). Now instead of decks players will have bags. And the locations will also have bags, from which players in the process of research will pull out arbitrary cubes. The color of the cube will determine its type and, accordingly, the rules for passing the test. Personal characteristics of the character (strength, dexterity, etc.), as a result, will be abolished, but new interesting mechanics will appear (about which later).

Will it be fun to play? I have no idea, and no one will be able to understand this until a working prototype is ready. But we get pleasure not from the game, but from the development, right? Therefore, there should be no doubt about success.

Step two. Design


Having an idea is only a third of the story. Now it is important to develop this idea. That is, do not walk in the park or bathe in the bathhouse, but sit at the table, take paper with a pen (or open your favorite text editor) and thoughtfully write a design document, carefully working through every aspect of game mechanics. It will take a long time for this to happen, so don’t expect to finish writing in one sitting. And do not even hope to think everything through once - as you implement it you will see the need to make a bunch of edits and changes (and sometimes globally process something), but some kind of foundation must be present before the development process begins.

At first, your design document will look something like this.




And having only coped with the first wave of grandiose ideas, you will take up your head, decide on the structure of the document and start methodically filling it with content (every second, checking the already written, in order to avoid unnecessary repetitions and especially contradictions). Gradually, step by step, you will get something meaningful and concise, like this .

When describing a design, choose the language in which it is easier for you to express your thoughts, especially if you work alone. If you ever need to involve third-party developers in the project, make sure that they understand all the creative nonsense that is going on in your head.

To continue, I strongly recommend that you read the document at least diagonally, because in the future I will refer to the terms and concepts presented there, without lingering on their interpretation in detail.

“Author, kill yourself against the wall. Too many letters. ”

Step three. Modeling


That is, all the same design, only more detailed.
I know, many are not eager to open IDE and start coding, but be patient a little more. When ideas overwhelm our head, it seems to us that all we need to do is touch the keyboard, and the hands themselves will rush into the sky-high distance - they won't have time to boil coffee on the stove, as the working version of the application is ready ... to go to the trash. In order not to rewrite the same thing many times (and especially not to be convinced after three hours of development that the layout is non-working and you need to start anew), I suggest to start with a good thought out (and document) the basic structure of the application.

Since we, as developers, are well acquainted with object-oriented programming (OOP), we will use its principles in our project. And for OOP, there is nothing more expected than starting development with a bunch of tedious UML diagrams. (As you don’t know what UML is ? I’ve almost forgotten too, but I’m happy to remember it - just to show what a diligent programmer I am, hehe.)

Let's start with the use-case diagram. Let us draw on it the ways of interaction of our user (player) with the future system:

Use cases


"Uh ... is that all?"

Just kidding, kidding ... and, perhaps, on that I stop joking - this is a serious matter (a dream, after all). In the use case diagram, you must display the capabilities that the system provides to the user. In detail. But historically, it is this type of diagrams that turns out to be the worst for me - I don’t have enough patience, apparently. And do not look at me like that - we are not defending a diploma at a university, but enjoy the work process. And for this process is not so important use cases. It is much more important to intelligently split the application into independent modules, that is, to implement the game in such a way that the features of the visual interface do not affect the game mechanics, and so that the graphic component can be easily changed if desired.

This point can be detailed in the following component diagram (components):

System components


Here we have already identified the specific subsystems that are part of our application and, as will be shown later, they will all be developed independently of each other.

Also, at this stage, let us estimate what the main game cycle will look like (or rather, its most interesting part is the one that implements the passage of characters by the characters). For this we need an activity diagram (activity):

If you are, sit down


And finally, it would be nice to present in general the sequence (sequence) of the interaction of the end user with the game engine, through the input-output system.

Sausages


The night is long, dawn is still far away. After sitting properly at the table, you calmly draw the remaining two dozen diagrams - believe that in the future their presence will help not to stray from the chosen path, increase your self-esteem, update the interior of the room, veiling faded wallpapers with colorful posters, and in simple expressions bring your vision to fellow developers, who will soon rush in crowds to the doors of your new studio (we are not focused on success, remember?).

We will not bring the favorite class diagrams (class) for the time being - a lot of breakthroughs are expected and a picture in three screens will not add clarity at first. It is better to break it into pieces and lay out gradually, as we move to the development of the corresponding subsystem.

Step Four. Selection of tools


As already agreed, we will develop a cross-platform application that works both on desktops running different operating systems and on mobile devices.As a programming language, we will choose Java, and even better, Kotlin, since the latter is more new and fresh, and has not yet had time to swim in the waves of indignation, which have swept over its predecessor with its head (at the same time we learn, if anyone does not own). JVM , as you know, is available everywhere and everywhere (on three billion devices, hehe), we will support both Windows and UNIX, and even on a remote server via an SSH connection it will be possible to play (who may need it is unknown, but we will give such opportunity). We will also transfer to Android when we get rich and hire an artist, but more on that later.

Libraries (we can’t go without them) will be selected according to our demand for cross-platform. We will use Maven as the build system. Or gradle. Or all the same Maven, let's start with it. Immediately I advise you to set up a version control system (whatever you like), so that after many years with nostalgic feelings it was easier to remember how great it was once. IDE also choose the familiar, favorite and convenient.

Actually, we do not need anything else. You can begin to develop.

Step five. Creating and setting up a project


If you are using an IDE, then creating a project is trivial. You just need to choose some kind of sonorous name (for example, Dice ) for our future masterpiece, do not forget to include support for Maven in the settings, and enter the necessary identifiers in the pom.xml file:

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

We will also add support for Kotlin, which is missing by default:

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

and some settings on which we will not dwell in detail:

 <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> 

Some information regarding hybrid projects.
If you plan to use both Java and Kotlin in your project, then in addition to the 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> 

How important it is, I can not say - the projects are quite well assembled without this sheet. But just in case you are warned.

Create three packages at once (why trivial things?):


The latter will contain only interfaces, the methods of which we will use for data input and output. We will store specific implementations in general in a separate project, but more on that later. In the meantime, in order not to be heavily sprayed, these classes will be folded here, side by side.

Do not immediately try to do perfectly: think over to the last detail the names of packages, interfaces, classes and methods; thoroughly register the interaction of objects with each other - all this will change, and more than a dozen times. As the project progresses, many things will seem ugly, cumbersome, inefficient, and the like to you - feel free to change them, since refactoring in modern IDEs is a very cheap operation.

We will also create a class with the function 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.

After performing the 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> 

Note the name of the main class. For Kotlin functions contained outside classes (like, for example, the 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.

Now, when building the project, we will receive two jar files at the output: 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.

dice.bat (for Windows)

 @ECHO OFF rem Compiling call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package if errorlevel 1 echo Project compilation failed! & pause & goto :EOF rem Running java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar pause 

dice.sh (for UNIX)

 #!/bin/sh # Compiling mvn -f "path_to_project/Dice/pom.xml" package if [[ "$?" -ne 0 ]] ; then echo 'Project compilation failed!'; exit $rc fi # Running java -jar path_to_project/Dice/target/dice-1.0-jar-with-dependencies.jar 

Please note that if the compilation failed, we are forced to abort the execution of the script. Otherwise, not the last harp will be launched, but the file left over from the previous successful build (sometimes we won’t find the difference). Often, developers use the 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.

So, the project runs perfectly, but so far nothing has been done. Do not worry, soon we will fix it.

Step Six. Main objects


Gradually we will begin to fill the model package with the classes necessary for the game process.

Class diagram


Cubes - our all, add them first. Each cube ( 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).

The class implements the 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 }) } } 

In order not to get dusty, the cubes are stored in handbags (copies of the 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).

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

In addition to bags with cubes, heaps with cubes are also needed (instances of the 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) } 

We now turn to our main characters - the heroes. I mean, characters who will henceforth be called heroes (there is a good reason not to call your class name 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.

Add to the hero the necessary properties in accordance with the design document: name, favorite type of cube, limits of cubes, skills learned and unexplored, hand, bag and pile to reset. Pay attention to the features of the implementation of properties-collections. In the entire civilized world, it is considered a bad form to provide outside access (with the help of a getter) to collections stored inside the object — unscrupulous programmers will be able to change the contents of these collections without the knowledge of the class. One way to deal with this is to implement separate methods for adding and removing items, getting their number and accessing by index. It is possible to implement a getter, but at the same time, it’s not particularly scary to do so to return the collection itself, but its immutable copy - for a small number of elements.

 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") } } 

The hero's hand (the cubes that he has at the moment) is described by a separate object (class 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() } 

The collection of objects of the 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) 

But with skills, the situation is more interesting. Each of them will have to be individually implemented (about which later), but we will consider only two: Hit and Shoot (one for each class, respectively). Skills can be developed ("pumped") from the initial to the maximum level, which often affects the modifiers that are added to the rolls of the dice. We reflect this in the 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 } 

Pay attention to the auxiliary methods of the 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.

Please do not be afraid of the number of classes that we have to create. For a project of such complexity several hundred is a common thing. Here, as in any serious occupation, we start small, gradually increase the pace, in a month we are horrified by the scope. Do not forget, we are still a small studio of one person - there are no impossible tasks ahead of us.

“Something poplohe me. I'll go have a smoke or something ... "

And we will continue.
Heroes and their abilities described, it's time to go to the opposing forces - the great and terrible game mechanics. Or rather, the objects with which our heroes have to interact.

Regular class diagram


Opposing our valiant protagonists will be cubes and maps of three types: villains ( 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 } 

Please note that the list of objects of the 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) 

but we will not be so scrupulous in approaching the question (you are, however, warned).

Due to the features of the game mechanics, the Obstacle class differs from its counterparts in the presence of additional fields, but we will not focus on them.

Threat cards (and if you carefully read the design document, remember that these are cards) are combined into decks represented by the 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() } 

There is nothing unusual here, except that the class is parameterized and contains in its composition an ordered list (or rather a two-way queue), which can be mixed by an appropriate method. We will need decks of enemies and obstacles in just a second, when we proceed to review ...

... 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 } 

Each locality has a name, description, difficulty of closing and a sign of "open / closed." Somewhere here a villain may lurk (or may not lurk, which is why the 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).

Finally it remains to implement the scripts (class 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) } 

Each scenario is characterized by the level and initial value of the timer. Similarly to the previously seen, special rules ( 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.

I remind you that all the classes considered so far are contained in the 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.

"Well vooot ..."

Step Seven. Patterns and Generators


Imagine for a moment what will be the process of generating any of the objects considered earlier, for example, a location (terrain). We need to create an instance of the class 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" }) } } 

This is also thanks to the Kotlin language and constructs 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.

Here are just a game designer code will not write, and we are uncomfortable at the slightest change in the game world to re-compile the project. Here, any competent programmer would argue that the descriptions of objects from the class code should be separated - ideally, the instances of the latter should be dynamically generated on the basis of the first ones as needed, just like a piece is made in the factory. We realize such drawings and we will only call them templates and present them as instances of a special class. Having such templates, a special program code (generator) will create finite objects from the model described earlier.

The process of generating an object from a template


Thus, for each class of our objects it is necessary to set two new entities: an interface-template and a class-generator. And since the objects have accumulated a decent amount, then the entities will also be a number ... indecent:

Class diagram


Please breathe deeply, listen carefully and not be distracted. First, the diagram does not represent all the objects of the game world, but only the basic ones, without which it cannot be done at first. Second, in order not to overload the scheme with unnecessary details, some of the connections already mentioned earlier in the other diagrams were omitted.

Let's start with something simple - generating cubes. "How? - you say.- Are we a little designer? Yes, that's it, with the type and size. " No, I will answer, not enough. Indeed, in many cases (read the rules), cubes must be generated in an arbitrary manner in an arbitrary number (for example: “from one to three cubes, either blue or green”). Moreover, the size should be chosen depending on the level of complexity of the scenario. Therefore, we introduce a special interface DieTypeFilter.

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

Different implementations of this interface will check if the dice type matches different sets of rules (whatever comes to mind). For example, does the type correspond to a strictly specified value (“blue”) or a range of values ​​(“blue, yellow, or green”); or, on the contrary, it corresponds to any type except the specified one (“if only it is not white by any means” - anything but this). Even if it is not clear in advance what specific implementations are needed, it does not matter - they can be added later, the system will not break from this (polymorphism, remember?).

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

The size of the cube will also be set in an arbitrary way, but more on that later. In the meantime, we will write a cube generator ( 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() 

In Java, these methods would be static, but since we are dealing with Kotlin, we don’t need a class as such, which is also true for the other generators discussed below (nevertheless, at the logical level, we will still use the concept of a class).

Two private methods generate separately the type and size of the cube - one can say something interesting about each. The method generateDieType()can be driven into an infinite loop, passing the input filter with

 override fun test(filter: DieTypeFilter) = false 

(The writers have a firm conviction that you can get out of logical inconsistencies and plot holes if the characters themselves in the course of the narrative point them to the audience). The method 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.

And since we are talking about bags, we will develop a template for them. Unlike its comrades, this pattern ( 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)) } } 

Each plan sets a pattern for the type of cubes, as well as the number (minimum and maximum) of cubes that satisfy this pattern. Thanks to this approach, it is possible to generate bags according to bizarre rules (and I again cry bitterly in my old age, because my neighbor refuses to help me). Something like this:

 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 } } } 

If you, like me, are tired of all this functionality, brace yourself - it will only get worse. But then, unlike many obscure tutorials on the Internet, we have the opportunity to study the use of various cunning methods in relation to the real, understandable subject area.

By themselves, the bags on the field will not roll around - you need to distribute them to heroes and locations. Let's start with the last.

 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> } 

In the Kotlin language, instead of methods, 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 basicClosingDifficultywill 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> } 

And let the generator creates not only individual objects, but also whole decks with them.

 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 } 

If there are more cards in the deck than we need (parameter 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) } } 

The area, which we explicitly asked in the code at the beginning of the chapter, will now take a completely different form:

 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) 

Script generation will occur in a similar way.

 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 } 

In accordance with the rules, the number of dynamically generated locations depends on the number of heroes. The interface has a standard calculation function, which, if desired, can be overridden in specific implementations. In connection with this requirement, the script generator will also generate terrain for these scenarios - in the same place villains will be randomly distributed to localities.

 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 } 

Many attentive readers will argue that templates should be stored not in the source code of classes, but in some text files (scripts), so that even people far from programming can create and maintain them. I agree, I take off my hat, but I do not sprinkle my head with ashes - for one does not interfere with one another. If you want, it is enough to define a special implementation of the template, whose property values ​​will be loaded from an external file. The generation process from this will not change a jot.

Well, it seems they have not forgotten anything ... Oh, yes, the heroes - they, too, need to be generated, which means they also need their own templates. These are, for example:

 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>? } 

And immediately we notice two oddities. First, we do not use templates to generate bags and cubes in them. Why?Yes, because for each type (class) of heroes the list of initial cubes is strictly defined - there is no point in complicating the process of their creation. Secondly, getDiceCount()- what is all this dregs ??? Calm down, these are the ones DiceLimitthat 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>() } 

But before writing a generator, we define a pattern for skills.

 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 } 

Unfortunately, rivet skills in batches just like enemies and scenarios, we will not succeed. Each new skill requires the expansion of the game mechanics, adding a new code to the game engine - even with the characters in this regard is easier. Perhaps this process can be abstracted, but I have not yet invented a method. And I did not try too hard, to be honest.

 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 } 

Just a few moments are striking. First, the generation method itself selects the desired pattern depending on the class of the hero. Secondly, it is not necessary to set the name immediately (sometimes at the generation stage we will not know it yet). Thirdly, Kotlin introduced an unprecedented amount of syntactic sugar, which some developers have misused without a measure. And not a drop of that are not ashamed.

Step Eight. Game cycle


Finally, we got to the most interesting - the implementation of the game cycle. Simply put, they began to "make the game." Many novice developers often start from this stage, apart from igrodelanie everything else. Especially any senseless schemes to draw, pfff ... But we are not going to be in a hurry (it is still far from morning), and therefore there is still a little modeling. Yes, again.

Activity Chart


As you can see, the given fragment of the game cycle is an order of magnitude less than what we cited above. We consider only the process of passing the course, the study of the terrain (and describe the meeting with only two types of cubes) and dropping the cubes at the end of the turn. And the completion of the scenario with a loss (yes, we won’t be able to win in our game) - and how did you want to? The timer will decrease each turn, and at the end of it, something needs to be done. For example, display a message and end the game - everything is written in the rules. You also need to complete the game when the heroes die, but no one will harm them, so we’ll leave. To win, you need to close all areas, which is difficult even if it is only one. Therefore, leave this moment. It makes no sense to get too dispersed - it is important for us to understand the essence, and finish the rest later, in free time (or rather, to finish it,and you - go write a gameyour dreams).

So, the first thing you need to decide on what objects we need.

Heroes Scenario. Locations
Above, we have already considered the process of their creation - we will not repeat it. We only note the terrain pattern that we will use in our little example.

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

As you can see, only “positive” cubes are in the bag - blue, green, purple, yellow and blue. There are no enemies and obstacles in the locality, there are no villains and wounds. There are no special rules either - their implementation is quite secondary.

Heap for retained cubes.
Or deterrent pile. Since we put the blue cubes in the bag of the terrain, they can be used in checks and after use to keep in a special heap. For this useful instance of the class Pile.

Modifiers
That is, the numerical values ​​that must be added or subtracted from the result of a die roll. You can implement either a global modifier or a separate modifier for each cube. We will choose the second option (so clearly), so we will create a simple class DiePair.

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

The location of the characters in the area.
In an amicable way, this moment needs to be tracked with the help of a special structure. For example, maps of the form 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 Locationmethods 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.

Check the hands of the hero.
During the game, the heroes constantly have to go through checks (about which below), that is, take the cubes from hand, throw them (add modifiers), aggregate the results, if there are several cubes (sum up, take maximum / minimum, average etc.), compare them with the roll another cube (the one that is taken out of the bag of the terrain) and depending on the result perform subsequent actions. But first of all, it is necessary to understand whether the hero is in principle able to pass the test, that is, if he has the right cubes in his hand. To do this, we provide a simple interface HandFilter.

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

Interface implementations is received at the input arm of the hero (the class object Hand) and return trueor 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) } 

Yes, again functional.

Active / selected positions.
Now that we have verified that the hero’s hand is suitable for performing the check, it is necessary that the player selects that die (or cubes) from his hand with which he will pass this check. First, you need to select (highlight) the appropriate positions (in which there are cubes of the desired type). Secondly, you need to somehow mark the selected cubes. For both of these requirements, a class is suitable 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() } } 

I have already said, how do I suffer from the "ingenious" idea of ​​storing white cubes in a separate hand? Because of this stupidity, you have to manage with two sets and duplicate each of the presented methods. If someone has ideas on how to simplify the implementation of this requirement (for example, use one set, but white cubes have indices starting with a hundred - or something else equally unintelligible) - share them in comments.

By the way, a similar class needs to be implemented to select cubes from a heap ( PileMask), but this functionality is beyond the limits of the considered example.

Selection of dice from the hand.
But it is not enough to “highlight” the admissible positions, it is important to change this “highlight” in the process of selecting the cubes. That is, if a player is required to take only one cube from his hand, then when choosing this cube, all other positions should become inaccessible. Moreover, at each stage, it is necessary to control the player’s fulfillment of the goal - that is, to understand whether the selected dice are sufficient for passing a particular check. Such a complex task requires a difficult copy of a difficult class.

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

Quite a complicated logic, I will understand and forgive you if this class turns out to be incomprehensible to you. And still try to explain. Implementations of this class always store a reference to the hand (object 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.

We will need two implementations of this abstract class (surprise, surprise!). The first controls the process of passing the test when acquiring a new cube of a specific type (not white). As you remember, any number of blue cubes can be added to such a check.

 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 } 

The second implementation is more complicated. It controls the process of dropping dice at the end of a turn. In this case, there are two options. If the number of cubes in the hand exceeds its maximum size (capacity), we must discard all extra cubes plus any number of additional cubes (if we want). If the size is not exceeded, then you can not reset anything (and you can reset, if desired). In any case, gray cubes cannot be dropped.

 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 } 

Nezhdanchik: Handa property suddenly appeared in the class woundCountthat had not existed before. You can write its implementation yourself, it is easy. Practice at the same time.

Passing checks.
Finally got to them. When the cubes are taken from the hand, it's time to throw them. For each cube, it is necessary to take into account: its size, its modifiers, the result of its roll. Although only one cube can be taken out of a terrain bag at a time, several cubes can be placed against it, aggregating the results of their throws. Generally, let's abstract from the cubes and imagine the troops on the battlefield. On the one hand, we have an enemy - he is only one, but he is strong and fierce. On the other hand, his rival is equal in strength, but with support. The outcome of the battle will be decided in one short skirmish, the winner can be only one ...

Sorry, carried away. To simulate our general battle, we are implementing a special class.

 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 } } 

Since each cube can have a modifier, we will store data in objects 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.

In the class constructor, the aggregation method (an instance of the internal enumeration 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).

Methodroll()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 our fate the final result (the difference between the values ​​of the cubes of the hero and the opponent's cube). To relieve the stress, I’ll say that a result of 0 will be considered a successful passing test.

The state of the game engine.
With complex objects sorted out, now things are easier. It will not be a great discovery to say that we need to control the current “progress” of the game engine, the stage or phase in which it is located. For this, a special listing is useful.

 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 } 

In fact, there are more phases, but we have selected only those used in our example. To change the phase of the game engine, we will use the methods changePhaseX(), where Xis 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.

Messages
Keep the state of the game engine is not enough. It is also important to somehow inform the user about him - otherwise, how will the latter know what is happening on his screen at all? That is why we need another listing.

 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 } 

As you can see, all possible states from our example are described by the values ​​of this enumeration. For each of them, a text string is provided, which will be displayed on the screen (except EMPTY- this is a special value), but we will know about it a little later.

Actions.
For communication between the user and the game engine, simple messages are not enough. It is also important to inform the first about the actions that he can perform at the moment (examine, transfer the cubes, complete the move - this is all good). For this we develop a special class.

 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 } } 

Internal listing Typedescribes the type of action performed. The field is isEnablednecessary 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).

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

The class contains many different methods for adding and removing actions from the list (which can be chained), as well as getting both by index and by type (pay attention to “overload” get()- we apply the bracket operator to our list). The implementation of the interface Iteratorallows all sorts of crazy shit to do various stream manipulations with our class (functionalism, aha). It also provides an EMPTY value for quickly creating an empty list.

Screens
Finally, another listing that describes the different types of currently displayed content ... You look at me and clap your eyes, I know. When I started to think up how to describe this class more clearly, I hit my head on the table, so I couldn’t really figure it out. You will understand, I hope.

 enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS } 

I selected only those used in the example. For each of them there will be a separate drawing method ... again, I explain incomprehensibly.

"Maiden" and "vvodilka."
And here we finally came to the most important point - the interaction of the game engine with the user (player). If such a long introduction has not tired you yet, then you probably remember that we agreed to functionally separate these two parts from each other. Therefore, instead of a specific implementation of the I / O system, we will provide only an interface. More precisely, two.

First interfaceGameRenderer, 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) } 

I think there is no need for additional explanations here - the purpose of all transmitted objects is described in detail above.

For user input, we implement a different interface - 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 } 

About the last method in more detail. As the name implies, from prompts the user to select the cubes from the hand, providing the object 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, CONFIRMor CANCEL) from an object ActionList. Implementations of input methods should distinguish situations when the field is isEnabledset to falseand ignore user input of such actions.

Class game engine.
We considered everything that was necessary for the work, the time has come to implement the engine. Create a classGame with the following content:

Sorry, this cannot be shown to impressionable people.
 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 } } } 

Method 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.

A bunch of methods with names starting withchangePhase, - 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.

The utility method pickDiceFromHand()in a generalized form deals with the choice of cubes from the hand. An object of a familiar class HandMaskRulethat sets selection rules is passed here . It also indicates the possibility to refuse to select ( allowCancel), as well as the function onEachLoopwhose 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().

Another service methodperformStatDieAcquireCheck()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).

Main methodprocessCycle()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.

Also, the class contains several helper methods: 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.

To whom nothing is clear at all, here is a diagram (for clarity, so to speak):


That's all, the game is ready (hehe). There are mere details, about them below.

Step nine. Display image


Here we come to the main topic of today's conversation - the graphic component of the application. As you remember, our task is to implement the interface GameRendererand 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:

Screen 1. Player Id


Screen 2. Information about the area and the current hero


Screen 3. Script Loss Report


I think the majority have already realized that the presented images are different from everything that we usually used to see in the Java application console, and that the capabilities of the usual 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. Chip and Dale ANSI Codes

rush to our aid . By sending fancy sequences of characters to output, you can achieve at least fancy effects: change the color of the text / background, the way the characters are drawn, the position of the cursor on the screen, and much more. Of course, we will not introduce them in their pure form - we hide the implementation by the class methods. Yes, and the class itself, we will not write from scratch - fortunately, smart people did it for us. It remains for us to download and connect some lightweight library to the project, for example, Jansi :

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

And you can start to create. This library provides us with a class object 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:


Let's create a class ConsoleRendererwith 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() } } 

The method resetAnsi()creates a new (empty) object Ansithat 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.

Let's start with the size. The standard console of most terminals has a size of 80x24. We note this fact with two constants CONSOLE_WIDTHand 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) } } 

Once again I remind you that calling a command a()or cursor()does not lead to any instant effect, but only adds to the object the Ansicorresponding sequence of commands. Only when these sequences are sent to print, we will see them on the screen.

Between the use of classic cycle forand functional approach ClosedRangeand 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 I am a monkey who loves everything new and the brilliant brackets are not transferred to a new line and the code looks more compact.

We are implementing another utility method 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) } } 

How, you never painted frames from pseudographics? Characters can be inserted directly into the source code. Hold down the Alt key and type the character code on the numeric keypad. Then let go. We need the ASCII codes in any encoding the same, here is the minimum gentleman's set:


And then, like in minecraft, the possibilities are limited only by the limits of your imagination. And screen size.

 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 ' ') } 

Let's talk a little about colors. The class Ansicontains constants Colorfor 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 ) 

Now each of the 16 available colors is uniquely identified by a single constant. Let's write a couple more utility methods, but before that, let's figure it out with this:

Where to store constants for text strings?

“String constants must be placed in separate files so that they are all stored in one place - this way they are easier to maintain. And it is also important for localization ... "

String constants should be put into separate files ... well, yes. We will take out and we. The standard Java mechanism for working with such resources is java.util.ResourceBundlefile 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 

Each line contains a key-value pair separated by a character =. 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.

Abstracting, however, from a specific format (in Android, for example, strings are stored differently) and we describe the interface for loading string constants.

 interface StringLoader { fun loadString(key: String): String } 

The key is passed to the input, we get a specific string at the output. The implementation is as uncomplicated as the interface itself (suppose the file lies along the way src/main/resources/text/strings.properties).

 class PropertiesStringLoader() : StringLoader { private val properties = ResourceBundle.getBundle("text.strings") override fun loadString(key: String) = properties.getString(key) ?: "" } 

Now it is not difficult to implement a method 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.

There's a lot of code here, we've already seen some of it ... so here's a spoiler for you
 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] } 

Why did we do all this, you ask? Yes, in order to inherit from this wonderful class our implementation of the interface GameRenderer.

Class diagram


This is how the implementation of the first, simplest method will look like:

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

Nothing extraordinary, just one text line ( 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.

The following method follows this principle:

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

Here, in addition to the centered text, there are also two horizontal lines (see screenshots above). Please note that the central inscription is displayed in two colors. And also make sure that learning mathematics at school is still useful.

Well, we have considered the simplest methods and it's time to get acquainted with the implementation 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:

1. Meeting with a cube taken out of the bag


2. The choice of cubes to pass the test


3. Display of test results


So here is my great advice to you: do not shove all the code into one method. Break the implementation into several methods (even if each of them will be called only once). Well, about the "rubber" do not forget.

If the eyes begin to ruffle, blink a few seconds - should help
 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() } } 

There is one little problem related to testing the operation of all this code. Since the built-in IDE console does not support ANSI control sequences, you will have to run the application in an external terminal (we already wrote the script for launching earlier). In addition, with the support of ANSI, not everything is in order in Windows - as far as I know, only from the 10th version, the standard cmd.exe can please us with a quality display (and then, with some problems, which we will not focus on). Yes, and PowerShell did not immediately learn to recognize the sequence (despite the existing demand). If you are unlucky, do not be discouraged - there are always alternative solutions ( this, for example ). And we move on.

Step ten. User input


To display an image on the screen is still half the battle. It is equally important to correctly receive control commands from the user. And this task, I want to tell you, may be technically much more difficult to implement than all previous ones. But first things first.

As far as you remember, we have to implement the methods of the class 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, Consoleis not enough to recognize these most pressing: we do not require the user to press ENTER after each command. We need something like KeyListeneraa, but it is tightly tied to the Swing framework, and our console application is without all this graphical tinsel.

What to do?The search for libraries, of course, and this time their work will fully rely on the native code. What does “goodbye, cross-platform” mean ... Or not? Alas, I have yet to find a library that implements simple functionality in a lightweight, platform independent form. In the meantime, pay attention to the jLine monster that implements the combine to build advanced user interfaces (in the console). Yes, it has a native implementation, yes, it supports both Windows and Linux / UNIX (by providing the appropriate libraries). And yes, used on most of its functionality, we do not need a hundred years. We need only a small, poorly documented opportunity, the work of which we will now disassemble.

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

Please note that we need not the third, the latest version, but the second, where there is a class ConsoleReaderwith 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.

“Do you know that not all keys on the keyboard can be represented by one character? Many keys use escape sequences of two, three, four different characters. How to deal with them? ”

It should be noted that the input task becomes more complicated if we want to recognize “non-character keys”: arrows, F-ki, Home, Insert, PgUp / Dn, End, Delete, num-pad and others. But we do not want, so we will continue. Let's create a class ConsoleInteractorwith 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) } 

We set the map mapperand 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 diagram


And, actually, the code:

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

The implementations of our methods are polite and well-mannered enough not to bring out different inadequate nonsense. They themselves check that the selected action is active, and the selected position of the hand is included in the set of valid. And we all wish to be polite to the people around us.

Step Eleven. Sounds and MUSIC


And how could it be without them? If you have ever played games with the sound turned off (for example, with a tablet under a blanket, while no one at home sees), you may have realized how much you are losing. It is like playing only half the game. Many games can not be imagined without sound, for many it is an essential requirement, although there are also reverse situations (for example, when there are no sounds in principle, or they are so poor that it would be better without them). It’s really not as easy to do business as it seems at first glance (it’s not for nothing that high-skilled specialists do it in large studios), but be that as it may, in most cases it’s much better to have an audio component in your game than not having it at all. In a pinch, the sound quality can be improved later.when time and mood allow.

Due to the specifics of the genre, our game will not be characterized by masterpiece sound effects - if you played digital adaptations of board games, then you know what I mean. Sounds repel their monotony, soon become boring and after a while the game without them no longer seems a serious loss. The problem is aggravated by the fact that there are no effective ways to combat this phenomenon. Replace the game sounds with completely different sounds, and eventually they will become hateful. In good games, the sounds complement the gameplay, reveal the atmosphere of an ongoing action, make it live - this is difficult to achieve if the atmosphere is just a table with a bunch of dusty bags, and the whole game process consists of throwing dice. However, this is exactly what we are going to voice: the silkhock is here, the throw is here,rustling and rustling under loud cries - as if we are not seeing the picture on the screen, but really interacting with real physical objects. They need to be voiced fully, but unobtrusively - throughout the script you will hear the same thing a hundred times, so the sounds should not come to the fore - just gently set off the gameplay. How to achieve this correctly? I have no idea, I'm not an expert on sound. I can only advise to play my game as much as possible, noticing and polishing the striking flaws (this advice, by the way, applies not only to sounds).How to achieve this correctly? I have no idea, I'm not an expert on sound. I can only advise to play my game as much as possible, noticing and polishing the striking flaws (this advice, by the way, applies not only to sounds).How to achieve this correctly? I have no idea, I'm not an expert on sound. I can only advise to play my game as much as possible, noticing and polishing the striking flaws (this advice, by the way, applies not only to sounds).

It seems that we have understood the theory, now it’s time to move on to practice. And before that you need to ask the question: where, in fact, take the game files? The easiest and surest way is to record them yourself in an ugly quality, using an old microphone or using a telephone in general. The Internet is full of videos about how to unscrew the pineapple tops or breaking the ice with your boot, you can achieve the effect of crushing bones and a crisp spine. If you are not alien to the aesthetics of surrealism, you can use your own voice or kitchen utensils as a musical instrument (there are examples - and even successful ones - where this was done). Or you can go to freesound.orgwhere a hundred other people long ago did it for you. Just pay attention to the license: many authors are very sensitive to the audio recordings of their loud cough or a coin thrown on the floor - you do not in any way want to shamelessly use the fruits of their labors without paying the original creator or mentioning his creative pseudonym (sometimes quite bizarre) in comments.

Teach the files that you like, and put them somewhere in the classpath. To identify them, we will use an enumeration, where each copy corresponds to one sound effect.

 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 } 

Since the method of playing sounds will vary depending on the hardware platform, we are able to absorb from a specific implementation using the interface. For example, like this:

 interface SoundPlayer { fun play(sound: Sound) } 

Like the interfaces previously discussed GameRendererand 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 } } 

Subsequently, we will consider more interesting implementations, but for now let's talk about music.
Like sound effects, it plays a huge role in creating the atmosphere of the game, and just as great a game can be ruined with improper music. Like sounds, music should be unobtrusive, not to come to the fore (except when it is necessary for an artistic effect) and adequately correspond to what is happening on the screen (do not expect anyone to seriously get into the fate of an ambush and ruthlessly killed main hero, if the scene of his tragic death will be accompanied by a cheerful music from a children's song). It is not easy to achieve this, specially trained people deal with such issues (we are strangers to them), but we, like the beginning geniuses of the game development, can also do something. For example, go somewhere onfreemusicarchive.org or soundcloud.com (or even YouTube) and find something to your liking. For nastolok, ambient is a good choice - quiet smooth music without pronounced melody, well suited for creating the background. Double pay attention to the license: even free music is sometimes written by talented composers who deserve, if not monetary reward, then at least universal acceptance.

Create another listing:

 enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, } 

Similarly, we define the interface and its default implementation.

 interface MusicPlayer { fun play(music: Music) fun stop() } class MuteMusicPlayer : MusicPlayer { override fun play(music: Music) { //Do nothing } override fun stop() { //Do nothing } } 

Please note that in this case two methods are needed: one to start playback, the other to stop it. It is also quite possible, further methods will be useful (pause / resume, rewind, etc.), but so far these two will suffice.

Each time passing links to player classes between objects may not seem like a very convenient solution. At one time, we need only one ekzepmlyar player, so I would venture to suggest to make all the necessary to play sounds and music methods in a separate object and make it a loner (singleton). Thus, the audio subsystem is always accessible from anywhere in the application without constantly sending links to the same instance. It will look like this for example:

Audio Playback System Class Chart


The class Audiois 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() } 

It is enough to call 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.

That's all.It remains to deal with the actual playback. As for playing sounds (or, as they say smart people, samples ), then Java has a convenient class AudioSystemand 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() } } 

The method 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.

“I don’t even know what to say ...”

Things are much worse with music. As far as I know, there is no standard way to play music files (for example, in mp3 format) in Java, so in any case you will have to use a third-party library (of which there are dozens different). Any lightweight with minimal functionality will suit us, for example, the rather popular JLayer . Add it depending:

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

And we realize with its help our player.

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

First, this library performs playback synchronously, blocking the main stream until it reaches the end of the file. Therefore, we must implement a separate thread ( 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.

Step twelve. Localization


Our game is almost ready, but no one will play it. Why?

“There is no Russian! .. There is no Russian! .. Add Russian! .. Developed dogs!”

Open the page of any interesting story game (especially mobile) on the store's website and read reviews. Will there praise the amazing, carefully drawn by hand graphics? Or marvel at the atmospheric sound? Or discuss an exciting story that delays from the first minute and does not let go until the very end?

Not.Dissatisfied "players" will guide a bunch of units and generally remove the game. And then the money back will require - and all this for one simple reason. Yes, you forgot to translate your masterpiece into all 95 world languages. Or rather, on the only one whose carriers scream the loudest. And that's it! Do you understand?Months of hard work, long sleepless nights, constant nervous breakdowns - all this is a hamster's tail. You have lost a huge number of players and this is no way to fix it.

So think in advance. Decide on your target audience, choose a few main languages, order an interpreter ... in general, do everything that other people have repeatedly described in the thematic articles (smarter than me). We will focus on the technical side of the question and talk about how to safely localize your product.

First of all, climb into the templates. Remember, before the names and descriptions were kept as simple 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>() } 

For templates, this approach is fine. If you don’t want to specify a translation for a language, then you don’t need it - there is always a default value. However, in the final objects I would not want to spread the lines into several different fields. Therefore we will leave one, but we will replace its type.

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

And correct the generator code accordingly.

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

Naturally, the same approach should be applied to the remaining types of templates. When changes are ready, you can use them without difficulty.

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

In our example, we have provided a simplified version of localization, where only language is taken into account. In general, class objects Localealso define a country and a region. If it is important in your application, then yours LocalizedStringwill look slightly different, but we are satisfied with it.

With the templates sorted out, it remains to localize and service lines used in our application. Fortunately, ResourceBundlealready 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= 

I will say not for the record: it is much more difficult to write phrases in Russian than in English. If there is a requirement to use a noun in a definable case or to abstract from the gender (such requirements will necessarily be met), you will have to sweat a little before you get a result that, first, meets the requirements, and second, it does not look like a mechanical translation made by cyborg with chicken brains. Also note that we do not change the action keys - still the same characters will be used to perform the latter as in English (which, by the way, will not work in a keyboard layout different from the Latin one, but this is not our business - for now, leave as is).

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" } 
.
As already mentioned, ResourceBundlehe 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…

Aha It was not there!
, Unicode .properties Java 9. ISO-8859-1 — ResourceBundle . , , — . Unicode- — , , : '\uXXXX' . , , Java native2ascii , . :

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

. — . — . , IDE ( ) « », — - ( ), IDE, .

, . getBundle() , , , ResourceBundle.Control — - .

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle( "text.strings", locale, Utf8ResourceBundleControl()) override fun loadString(key: String) = properties.getString(key) ?: "" } 

, , :

 class Utf8ResourceBundleControl : ResourceBundle.Control() { @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class) override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? { val bundleName = toBundleName(baseName, locale) return when (format) { "java.class" -> super.newBundle(baseName, locale, format, loader, reload) "java.properties" -> with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) { when { reload -> reload(this, loader) else -> loader.getResourceAsStream(this) }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } } } else -> throw IllegalArgumentException("Unknown format: $format") } } @Throws(IOException::class) private fun reload(resourceName: String, classLoader: ClassLoader): InputStream { classLoader.getResource(resourceName)?.let { url -> url.openConnection().let { connection -> connection.useCaches = false return connection.getInputStream() } } throw IOException("Unable to load data!") } } 

, … , ( ) — ( Kotlin ). — , .properties UTF-8 - .

To test the application in different languages, it is not necessary to change the operating system settings - it is enough to specify the required language when launching the JRE:

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

If you are still working on Windows, wait for problems.
, Windows (cmd.exe) 437 ( DOSLatinUS), — . , UTF-8 , :

 chcp 65001 

Java , , . :

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

, , Unicode- (, Lucida Console)

After all our exciting adventures, the result can be proudly demonstrated to the general public and loudly declare: “We are not dogs!”

Racial-correct option


And this is good.

Step thirteenth. Putting it all together


Attentive readers must have noticed that I mentioned the names of specific packages only once and never returned to them. First, each developer has his own considerations regarding which class in which package should be located. Secondly, as you work on the project, with the addition of new and new classes, your considerations will change. Thirdly, changing the structure of the application is simple and cheap (and modern version control systems will find a transfer, so you won’t lose history), so boldly change the names of classes, packages, methods and variables - do not forget to update the documentation (you keep , true?).

And we just have to put together and run our project. As you remember, main()we have already created the method , now we will fill it with content. We will need:


Go!

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

We start and enjoy the first working prototype. That's it.

Step fourteen. Game balance


Ummm ...

Step fifteen. Tests


Now that the main part of the code of the first working prototype is written, it would be nice to add a couple of unit tests ...

“How? Just now? Yes, it was necessary to write tests at the very beginning, and then the code! ”

Many readers rightly note that the writing of unit tests should precede the development of working code ( TDDand other fashionable methodology). Others will be indignant: there is nothing for people to fool their brains with their tests, even if they begin to develop something, otherwise the whole motivation will be lost. Another two-three people will crawl out of the gap in the baseboard and timidly inform: “I don’t understand why these tests are needed - everything works for me” ... Then they will be kicked in the face and quickly stuffed back. I will not begin ideological confrontations (they are already full in the Internet), and therefore I partly agree with everyone. Yes, tests are sometimes useful (especially in code that often changes or is associated with complex calculations), yes, unit testing is not suitable for all code (for example, it does not cover interactions with the user or external systems), yes, in addition to unit testing, there are many other types of it (well, at least five were named),and yes, we will not focus on writing tests - our article is about something else.

Let's just say: many programmers (especially beginners) neglect tests. Many justify themselves by the fact that the functionality of their applications is poorly covered by tests. For example, than to make complex constructions involving specialized frameworks for testing the user interface (there are such), it is much easier to launch the application and see if everything is fine with the appearance and interaction. And I will tell you when I was implementing interfaces Renderer- I did just that. However, among our code there are methods for which the concept of unit testing is excellent.

For example, generators. And everything. This is a perfect black box: the input is supplied with templates, the output of the game world objects. Inside there is something that knows, but it is precisely what we need to test. For example, like this:

 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)); } } } 

Or so:

 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)); } } } 

Or even like this:

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

“Stop, stop, stop! What's this? Java ??? ”

You understand. And just this kind of tests to write well in the beginning, before you begin to implement, in fact, the generator. Of course, the code being tested is quite simple and most likely the method will work the first time and without any tests, but by writing a test once you forget about it forever protect yourself from any possible future problems (the solution of which takes a lot of time, especially when starting development five years have passed and you have already forgotten how everything inside the method works there). And if suddenly your project stops gathering because of failed tests, you definitely will know the reason: the requirements for the system have changed and your old tests are no longer satisfied (and what did you think about?).

And further.Remember the class HandMaskRuleand 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)); } } 

It's tiring, but not as it seems, until you start (at some point it even becomes fascinating). But having written such a test (and a couple of others, for different occasions), you suddenly feel calm and confident. Now, no minor typo will spoil your method and will not lead to unpleasant surprises, which are much more difficult to test manually. Little by little, slowly, we begin to implement the necessary methods of the class. And at the end we run the test to make sure that we have made a mistake somewhere. Find a problem place and rewrite. Repeat until ready.

 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 } } } 

If you have an idea how to implement such functionality easier - you are welcome in the comments. And I am extremely happy that I had the sense to start the implementation of this class with the writing of the test.

“And I <...> also <...> are very <...> pleased <...>. Get in! <...> back! <...> into the crack! ”

Step Sixteen. Modularity


Predictably, matured children cannot be under the parents' shelter all their life - sooner or later they have to choose their own path and boldly walk along it, overcoming difficulties and disorder. So the components developed by us matured so much that it became crowded under one roof. It's time to divide them into several parts.

We face a rather trivial task. It is necessary to break all the classes created hitherto into three groups:


The result of this separation will eventually look something like the following diagram:

As if the actors are at the end of the show, our heroes of today are again on the stage in full force.


Create additional projects and transfer the appropriate class. And we only need to correctly configure the interaction of projects between themselves.

Core Project
This project is a pure engine. All specific classes were transferred to other projects - only the core functionality, the core, remained. Library if you want. There is no longer a launching class, there is not even a need to build a package. The builds of this project will be placed in the local Maven repository (which we will discuss later) and used by other projects as dependencies.

The file pom.xmllooks 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> 

From now on we will collect it like this:

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

The Cli project
Here is the entry point to the application - it is with this project that the end user will interact. The kernel is used as a dependency. Since in our example we are working with the console, the project will contain the classes needed to work with it (if we suddenly want to start playing the coffee maker, we will simply replace this project with a similar one - with the corresponding implementations). Here we will add resources (strings, audio files, etc.).

The pom.xmldependencies 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> 

We have already seen the script for building and running this project - we will not repeat it.

Adventure
And finally, in a separate project we will take out the plot part. That is, all the scenarios, locations, enemies and other unique objects of the game world, which the staff of the scenario department of your company can only imagine (well, or so far only our own sick imagination - we are still the only game designer in the district). The idea is to group scenarios into sets (adventures) and distribute each such set as a separate project (just as it is done in the world of desktop and video games). That is, to collect jar-archives and put them in a separate folder so that the game engine scans this folder and automatically connects all the adventures contained there. However, the technical implementation of this approach is fraught with enormous difficulties.

Where to begin? Well, firstly, from the fact that we are distributing templates in the form of specific java-classes (yeah, hit me and scold me - I foresaw it in advance). And if so, these classes must be located in the application classpath at startup time. It is easy to ensure that this requirement is met - you explicitly write your jar files to the appropriate environment variable (starting with Java 6, you can even use * - wildcards ).

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

“Fool, what? When using the -jar switch, the -classpath switch is ignored! ”

However, this will not work. The classpath for executable jar archives must be explicitly specified in the internal file 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.

And in general, I did not want so. I wanted the project not to need to be re-compiled. To folderadventures/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.

In the meantime, there are no ideas, here is a small (or large, depending on how you look) trick that allows you to dynamically add dependencies to the classpath without even knowing their names and without having to re-compile the project:

In Windows:

 @ECHO OFF call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package mkdir path_to_project\DiceCli\target\adventures copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\ chcp 65001 cd path_to_project\DiceCli\target\ java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt pause 

And in Unix:

 #!/bin/sh mvn -f "path_to_project/DiceCore/pom.xml" install mvn -f "path_to_project/DiceCli/pom.xml" package mvn -f "path_to_project/TestAdventure/pom.xml" package mkdir path_to_project/DiceCli/target/adventures cp path_to_project/TestAdventure/target/test-adventure-1.0.jar path_to_project/DiceCli/target/adventures/ cd path_to_project/DiceCli/target/ java -cp "dice-cli-1.0-jar-with-dependencies.jar:adventures/*" my.company.dice.MainKt 

And the trick is this. Instead of using the key, -jarwe 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/.

No need to once again indicate how this crooked solution - I myself know, thank you. Better suggest your ideas in the comments. Please . (ಥ﹏ಥ)

Step seventeen. Plot


Some lyrics.
Our article is about the technical side of the workflow, but games are not only program code. These are exciting worlds with interesting events and live characters in which you plunge headlong, renouncing the real world. Each such world is in its own way unusual and interesting in its own way, many of them you still remember, many years later. If you want your world to be remembered with warm feelings too, make it unusual and interesting.

I know that we are programmers here, not script writers, but we have some basic ideas about the narrative component of the game genre (gamers with experience, aren't they?). As in any book, the story should have a starting point (in which we gradually describe the problem appearing before the heroes), development, two or three interesting turns, climax (the most acute moment of the plot, when readers stop in excitement and forget to breathe) and decoupling (in which events are gradually approaching their logical conclusion). Avoid understatement, logical groundlessness and plot holes - all started lines should come to an adequate conclusion.

Well, let us read your story to others - an unbiased look from the outside very often helps to understand the flaws made and fix them in time.

The plot of the game
, , . , : ( ) ( ), . , .

— , . , , .

, , - . , , , , . .

I, fortunately, not Tolkien, the game world didn’t work through the details of the game, but I tried to make it quite interesting and, most importantly, logically justified. At the same time, he allowed himself to introduce some ambiguities, which each player is free to interpret in his own way. For example, he never focused on the level of technological development of the world described: the feudal system and modern democratic institutions, evil tyrants and organized criminal groups, the highest goal and banal survival, bus trips and fights in taverns - it’s not clear from what: from bows / crossbows, or from assault rifles. In the world there is a semblance of magic (its presence adds tactical capabilities to the gameplay) and elements of mysticism (just so it was).

I wanted to move away from plot clichés and fantasy consumer goods - all these elves, dwarfs, dragons, black rulers and absolute world evil (and also: selected heroes, ancient prophecies, super-artifacts, epic battles ... although the latter can be left). I also wanted to make the world alive, so that each character (even a minor one) had his own history and motivation, that the elements of game mechanics fit into the laws of the world, that the development of heroes would occur naturally, and that the presence of enemies and obstacles in the locations would be logically justified … and so on. Unfortunately, this aspiration played a cruel joke, slowed down the development process very much, and it was not always possible to move away from the game conventions. Nevertheless, satisfaction from the final product turned out to be an order of magnitude greater.

What do I want to say to all of this? A thoughtful interesting plot is perhaps not so much a necessity, but your game will not suffer at all from its presence: at best, players will enjoy it, at worst they will simply ignore it. And especially enthusiastic even forgive your game some functional flaws, just to find out how the story ends.

What's next?


Further programming ends and game design begins . Now it’s time not to write the code, but to think through the scenarios, locations, enemies - you understand, this whole mess. If you are still working alone, I congratulate you - you have reached the stage where most game projects are thrown. In large AAA-studios, special people work for designers and screenwriters who get paid for it — they simply have nowhere to go. We have plenty of options: go for a walk, eat, sleep is commonplace - yes, even there, even to start a new project, using the accumulated experience and knowledge.

If you are still here and wish to continue by any means, then prepare for difficulties. Lack of time, laziness, lack of creative inspiration - you will always be distracted by something. To overcome all these obstacles is not easy (again, many articles are written on this topic), but it is possible. First of all, I advise you to carefully plan the further development of the project. Fortunately, we work for our own pleasure, we are not urged by the publishers, no-one is demanding the fulfillment of any specific deadlines - which means there is an opportunity to approach the matter without too much haste. Make a roadmap of the project, determine the main stages and (if you have enough courage) the approximate timing of their implementation. Get yourself a notebook (you can email) and constantly write to it any ideas (even waking up in the middle of the night).Mark your progress with tables (for example, such ) or other aids. Start keeping records: both external, public ( wikis, for example ) for the future huge community of fans, and internal, for yourself (I will not share the link) - believe me, without it, after a month of interruption, you can’t remember what exactly and how you did. In general, write as much as possible the accompanying information about your game, just do not forget to write the game itself. I suggested the basic options, but I don’t give specific advice - everyone decides for himself how to organize his work process more comfortably.

"But still, about the game balance you do not want to tell?"

Immediately prepare yourself to create the perfect game the first time will not work. The working prototype is good - at first it will show the viability of the project, convince or disappoint you and give an answer to the very important question: “is it worth it to continue?”. However, he will not answer many other questions, the main one of which is probably: “will it be interesting to play my game in the long term?”. There is a huge number of theories and articles (well, again) on this topic. An interesting game must be moderately difficult, since a too simple game does not make a challenge to the player. On the other hand, if the complexity is prohibitive, only hard-core hardcore players or people who are trying to prove something to someone will remain from the gaming audience. The game should be quite diverse, ideally - to provide several options for achieving the goal,so that each player can choose an option to his liking. One passing strategy should not dominate the others, otherwise they will only use it ... And so on.

In other words, the game needs to be balanced. This is especially true of the board game, where the rules are clearly formalized. How to do it? I have no idea.If you don’t have a math friend who can compose a mathematical model (I’ve seen it done this way) and you don’t understand anything about it (and we don’t understand it), then the only way out is to rely on the intuition of playtesting . First play the game yourself. When tired - offer to play wife. After a divorce, offer to play other relatives, friends, acquaintances, random people on the street. When left alone - lay out the assembly on the Internet. People will be interested, they will want to play, and you will answer them: “feedback from you!”. Maybe someone will love your dream as much as you want to cooperate with you - you will find like-minded people in this way or at least a support group (why do you think I wrote this article?) (Hehe).

Joking aside, I wish you ... success to you all. Read more (who would have thought!) - about game design and more. All the issues we have already discussed have been covered in one way or another in articles and literature (although, if you are still here, it is clearly unnecessary to encourage you to read). Share your impressions, communicate on the forums - well, you already know everything better than me. Do not be lazy and everything will work out.

On this optimistic note, allow me to bow out. Thank you all for your attention. See you later!

“Eh! What will see you? How now is it all on the mobile phone run? Am I waiting for nothing, or what? ”

Afterword. Android


To describe the integration of our game engine with the Android platform, let's leave the class alone Gameand 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.

In the console interface, it looks like this


Like a class 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”.

Activity Chart for Main Menu


Easy, right? About that and speech. The code is also much easier.

 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") } } } } 

User interaction is implemented using interfaces MenuRendererand MenuInteractoroperating in the same way as previously seen.

 interface MenuRenderer: Renderer { fun drawMainMenu(actions: ActionList) } interface Interactor { fun anyInput() fun pickAction(list: ActionList): Action } 

As you already understood, we knowingly separated interfaces from specific implementations. All we need now is to replace the Cli project with a new project (let's call it Droid ), adding a dependency on the Core project . Let's do it.

Launch Android Studio (usually Android projects are developed in it), create a simple project, removing all unnecessary standard tinsel and leaving only support for the Kotlin language. Add a dependency on the Core project , which is stored in our machine’s local Maven repository.

 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" } 

By default, however, no one will see our dependency - it is necessary to clearly indicate the need to use a local repository (mavenLocal) when building a project.

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

You will see that all previously developed classes are available for use, and interfaces for implementation. We are interested in, by and large, we are already familiar interfaces: SoundPlayer, MusicPlayer, MenuInteractor(analog GameInteractor) MenuRenderer(analog GameRenderer) and StringLoaderfor 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.

To draw the elements of the interface, we will not use the standard components (buttons, pictures, input fields, etc.) of Android - instead, we will limit ourselves to the class features 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).

Let's create the main activity for our View and write it in the manifest.

 <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> 

We fix the activity in landscape orientation - as is the case with most other games, the portrait one doesn’t need us. Moreover, we will deploy it to the full screen of the device, writing the main theme accordingly.

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

And since we’ve gotten into the resources, we’ll transfer the localized strings we need from the Cli project , bringing them to the required format:

 <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> 

And also used in the main menu files of sound and music (one of each type), placing them in /assets/sound/leave.wavand /assets/music/menu_main.mp3respectively.

When the resources were sorted out, it was time to start designing (yes, again). Unlike the console, the Android platform has its own architectural features, which forces us to use specific approaches and methods.

Class and interface diagram


Wait, do not faint, now I will explain everything in detail.

Let's start with the most difficult - the class DiceSurface- the same heir Viewwho 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.

When we were drawing on the console, our Renderer sent output commands and formed an image on the screen. In the case of Android, the situation is the opposite - drawing is initiated by the View itself, which by the time the method 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?

Let's try to solve this problem with the help of functional interfaces. The class DiceSurfacewill 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... } } } 

That is, all the graphical functionality is still in the Renderer class, but this time we do not directly execute commands, but prepare them for execution by our View. Pay attention to the type of property instructions- it would be possible to create a separate interface and call its only method, but Kotlin can significantly reduce the amount of code.

Now about Interactor. Previously, data entry occurred synchronously: when we requested data from the console (keyboard), the execution of the application (cycles) was suspended until the user pressed a key. With Android, this trick will not work - he has his own Looper, whose work we should not violate in any way, which means that the input must be asynchronous. That is, the methods of the Interactor interface still suspend the engine and wait for commands, while the Activity and all its View continue to work until sooner or later this command is sent.

This approach is quite simple to implement using a standard interface BlockingQueue. The class DroidMenuInteractorwill 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). DiceSurfacein 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 } } } } 

That is, Interactor calls the method 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.

Of course, we are sort of transmitting commands, but only one. We also need to distinguish between the coordinates of the click, and depending on their values ​​to call a particular team. But here's a bad luck - Interactor has no idea where in which place of the screen the active buttons are drawn - Renderer is responsible for rendering. Let's adjust their interaction as follows. The class DiceSurfacewill 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) } 

The method 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.

Implement in the classroom DiceSurfacean 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) } } 

A rectangular collection is used to store the rectangles - it will avoid the occurrence ConcurrentModificationExceptionif the collection is simultaneously updated and sorted by different streams (which in our case will happen).

Class code DroidMenuInteractorwill remain unchanged, but DroidMenuRendererwill 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) ?: "" } 

Here we are back to the interface StringLoaderand the capabilities of the auxiliary class StringLoadHelper(not shown in the diagram). The implementation of the first has a name ResourceStringLoaderand 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)) } 

It remains to tell about the sounds and music. Android has a wonderful class MediaPlayerthat 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() } } 

Two comments.First, the method 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 ...

It MediaPlayerwill 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() } } } } 

When creating a class, all sounds from the enumeration Soundare 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.

Now, finally, we blind all the components together within ours MainActivity— haven't you forgotten about this? Please note that MainMenu(and Gamelater) 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() { } } 

That's all. After all the torments, the main screen of our application looks simply amazing:

Main menu in full width of the mobile screen


Well, that is, it will look amazing when a sensible artist appears in our ranks, and with his help this misery will be completely redrawn.

useful links


I know, many have scrolled straight to this point. Do not worry - most readers and the tab is completely closed. To those units who still endured the whole stream of disjointed chatter - respect and respect, endless love and gratitude. Well, links, of course, where do without them. First of all, on the source code of projects (keep in mind that the current state of projects has gone far ahead of the one considered in the article):


Well, all of a sudden someone will have a desire to start and see a project, and it's too lazy to collect it yourself, here's a link to the working version: LINK!

Here, a launcher is used for launching (you can write a separate article about creating it). It uses JavaFX and therefore may not start on machines with OpenJDK (write - we will help), but at least it eliminates the need to manually prescribe file paths. Installation help is contained in the readme.txt file (remember these?). Download, watch, use, and I finally fall silent.

If you are interested in a project, or a used tool, or mechanics, or some interesting solution, or, I don't know, lore games, you can take a closer look at it in a separate article. If you want to. And if you do not want, just send comments, rewards and suggestions. I will be glad to talk.

Good luck.

Source: https://habr.com/ru/post/445278/


All Articles