Comment créer un jeu si vous n'êtes jamais artiste


Il y avait des moments dans la vie de chaque programmeur quand il rêvait de faire un jeu intéressant. De nombreux programmeurs réalisent ces rêves, et même avec succès, mais cela ne les concerne pas. Il s'agit de ceux qui aiment jouer à des jeux, qui (même sans connaissances ni expérience) ont essayé de les créer une fois, s'inspirant d'exemples de héros isolés qui ont atteint une renommée mondiale (et d'énormes profits), mais qui ont compris au fond que rivaliser avec le gourou igrostroya qu'il ne peut pas se permettre.

Et non ...

Petite introduction


Je ferai une réservation tout de suite: notre objectif n'est pas de gagner de l'argent - il y a beaucoup d'articles sur ce sujet sur Habré. Non, nous allons faire un jeu de rêve.

Digression lyrique sur le jeu des rêves
Combien de fois ai-je entendu ce mot de développeurs uniques et de petits studios. Où que vous regardiez, tous les joueurs débutants sont pressés de révéler leurs rêves et leur «vision parfaite» au monde, puis d'écrire de longs articles sur leurs efforts héroïques, leur processus de travail, leurs difficultés financières inévitables, leurs problèmes avec les éditeurs et généralement «joueurs-ingrats-chiens-im- donner-graphique-et-pièces-et-tout-gratuitement-et-payer-ne-pas-vouloir-un-jeu-pirates-et-nous-avons-perdu-les profits-à cause d'eux-ici. "

Les gens, ne vous laissez pas berner. Vous ne faites pas un jeu de rêve, mais un jeu qui se vendra bien - ce sont deux choses différentes. Les joueurs (et surtout les plus sophistiqués) ne se soucient pas de votre rêve et ne le paieront pas. Si vous voulez des profits - étudiez les tendances, voyez ce qui est populaire maintenant, faites quelque chose d'unique, faites mieux, plus inhabituel que d'autres, lisez des articles (il y en a beaucoup), communiquez avec les éditeurs - en général, réalisez les rêves des utilisateurs finaux, pas les vôtres.

Si vous ne vous êtes pas encore enfui et souhaitez toujours réaliser le jeu de vos rêves, renoncez à l'avance aux bénéfices. Ne vendez pas du tout votre rêve - partagez-le gratuitement. Donnez aux gens votre rêve, amenez-les à lui, et si votre rêve vaut quelque chose, vous recevrez, sinon de l'argent, mais de l'amour et de la reconnaissance. C'est parfois beaucoup plus précieux.

Beaucoup de gens pensent que les jeux sont une perte de temps et d'énergie, et que les gens sérieux ne devraient pas du tout parler de ce sujet. Mais les gens réunis ici ne sont pas sérieux, donc nous ne sommes d'accord qu'en partie - les jeux prennent vraiment beaucoup de temps si vous les jouez. Cependant, le développement de jeux, bien que cela prenne beaucoup plus de temps, peut apporter de nombreux avantages. Par exemple, il vous permet de vous familiariser avec les principes, les approches et les algorithmes que l'on ne trouve pas dans le développement d'applications non ludiques. Ou approfondissez les compétences de posséder des outils (par exemple, un langage de programmation), en faisant quelque chose d'inhabituel et d'excitant. Tout seul, je peux ajouter (et beaucoup seront d'accord) que le développement de jeux (même infructueux) est toujours une expérience spéciale et incomparable, que vous vous souviendrez plus tard avec appréhension et amour, que je veux vivre pour chaque développeur au moins une fois dans ma vie.

Nous n'utiliserons pas de nouveaux moteurs de jeu, de nouveaux frameworks, de nouvelles bibliothèques - nous examinerons l'essence même du gameplay et le ressentirons de l'intérieur. Nous abandonnons les méthodologies de développement flexibles (la tâche est simplifiée par la nécessité d'organiser le travail d'une seule personne). Nous ne passerons pas de temps et d'énergie à chercher des designers, des artistes, des compositeurs et des spécialistes du son - nous ferons tout nous-mêmes comme nous le pouvons (mais en même temps nous ferons tout sagement - si nous avons soudainement un artiste, nous ne ferons pas beaucoup d'efforts pour attacher la mode graphiques sur le cadre fini). En fin de compte, nous n’étudierons même pas vraiment les outils et ne choisirons pas le bon - nous le ferons sur celui que nous connaissons et savons utiliser. Par exemple, en Java, afin que plus tard, si nécessaire, transférez-le vers Android (ou vers une cafetière).

«Ah !!! Horreur! Un cauchemar! Comment pouvez-vous passer du temps sur un tel non-sens! Sortez d'ici, je vais lire quelque chose de plus intéressant! »

Pourquoi faire ça? Je veux dire, réinventer la roue? Pourquoi ne pas utiliser un moteur de jeu prêt à l'emploi? La réponse est simple: nous ne savons rien de lui, mais nous voulons le jeu maintenant. Imaginez l'état d'esprit du programmeur moyen: «Je veux faire un jeu! Il y aura de la viande, des explosions et du pompage, et vous pouvez voler un korovan , et l'intrigue est en train de bombarder, et cela ne s'est jamais produit ailleurs! Je vais commencer à écrire maintenant! .. Et sur quoi? Voyons ce qui est populaire chez nous maintenant ... Ouais, X, Y et Z. Prenons Z, maintenant tout le monde y écrit ... ". Et commence à étudier le moteur. Et il jette l'idée, car il n'y a déjà pas assez de temps pour cela. Fin. Ou bien, ça n'abandonne pas, mais sans vraiment apprendre le moteur, c'est pris pour le jeu. Eh bien, si alors il a la conscience de ne montrer à personne son premier "métier". Habituellement non (allez dans n'importe quel magasin d'applications, voyez par vous-même) - eh bien, eh bien, je veux des profits, aucune force à endurer. Une fois que la création de jeux était le lot de gens créatifs enthousiastes. Hélas, cette fois est irrévocablement passée - maintenant, l'essentiel dans le jeu n'est plus l'âme, mais le modèle commercial (au moins, il y a beaucoup plus de conversations à ce sujet). Notre objectif est simple: nous allons faire des jeux avec l'âme. Par conséquent, nous résumons l'outil (n'importe qui le fera) et nous concentrons sur la tâche.

Alors continuons.
Je n'entrerai pas dans les détails de ma propre expérience amère, mais je dirai que l'un des principaux problèmes pour un programmeur dans le développement de jeux est le graphisme. Les programmeurs ne savent généralement pas comment dessiner (bien qu'il y ait des exceptions), et les artistes ne savent généralement pas comment programmer (bien qu'il y ait des exceptions). Et sans graphisme, il faut l'avouer, un jeu rare est contourné. Que faire?

Il y a des options:

1. Dessinez tout vous-même dans un simple éditeur graphique

Captures d'écran du jeu "Kill Him All", 2003

2. Dessinez tout vous-même dans un vecteur

Captures d'écran du jeu "Raven", 2001


Captures d'écran du jeu "Inferno", 2002

3. Demandez à un frère qui ne sait pas non plus dessiner (mais le fait un peu mieux)

Captures d'écran du jeu "Fucking", 2004

4. Téléchargez un programme de modélisation 3D et faites glisser les ressources à partir de là

Captures d'écran du jeu "Fucking 2. Demo", 2006

5. En désespoir de cause, se déchirer les cheveux sur la tête


Captures d'écran du jeu "Fucking", 2004

6. Dessinez tout vous-même en pseudographie (ASCII)

Captures d'écran du jeu "Fifa", 2000


Captures d'écran du jeu "Sumo", 1998

Arrêtons-nous sur ce dernier (en partie parce qu'il n'a pas l'air aussi déprimant que les autres). De nombreux joueurs inexpérimentés croient que les jeux sans graphismes modernes et cool ne peuvent pas gagner le cœur des joueurs - même le nom du jeu ne les transforme même pas en jeux. Les développeurs de chefs-d'œuvre tels que ADOM , NetHack et Dwarf Fortress s'opposent tacitement à de tels arguments. L'apparence n'est pas toujours un facteur décisif, l'utilisation de l' ASCII offre des avantages intéressants:

  • en cours de développement, le programmeur se concentre sur le gameplay, la mécanique du jeu, la composante intrigue et plus, sans être distrait par des choses mineures;
  • développer un composant graphique ne prend pas trop de temps - un prototype fonctionnel (c'est-à-dire une version en jouant que vous pouvez comprendre, mais cela vaut la peine de continuer) sera prêt beaucoup plus tôt;
  • pas besoin d'apprendre les frameworks et les moteurs graphiques;
  • vos graphismes ne deviendront pas obsolètes dans les cinq ans que vous développerez le jeu;
  • les travailleurs inconditionnels pourront évaluer votre produit même sur des plates-formes qui n'ont pas d'environnement graphique;
  • si tout est fait correctement, les graphismes sympas peuvent être attachés plus tard, plus tard.

La longue introduction ci-dessus était destinée à aider les novices igrodelov à surmonter leurs peurs et leurs préjugés, à cesser de s'inquiéter et à essayer de faire quelque chose comme ça. Êtes-vous prêt? Commençons alors.

Première étape. Idée


Comment? Vous n'en avez toujours aucune idée?

Éteignez l'ordinateur, allez manger, marcher, faire de l'exercice. Ou dormir, au pire. Concevoir un jeu, ce n'est pas laver les fenêtres - il n'y a pas de perspicacité dans le processus. D'habitude, l'idée d'un jeu naît soudainement, de façon inattendue, quand on n'y pense pas du tout. Si cela se produit soudainement, prenez un crayon plus rapidement et écrivez jusqu'à ce que l'idée s'envole. Tout processus créatif est mis en œuvre de cette manière.

Et vous pouvez copier les jeux d'autres personnes. Eh bien, copie. Bien sûr, ne déchirez pas sans vergogne, racontant à chaque coin à quel point vous êtes intelligent, mais utilisez l'expérience des autres dans votre produit. Combien après cela restera spécifiquement de votre rêve est une question secondaire, car souvent les joueurs ont ceci: ils aiment tout dans le jeu, à l'exception de deux ou trois choses ennuyeuses, mais si c'était fait différemment ... Qui sait peut-être que vous rêvez de la bonne idée de quelqu'un.

Mais nous allons suivre la voie simple - supposons que nous ayons déjà une idée, et que nous n'y ayons pas réfléchi depuis longtemps. Comme notre premier projet grandiose, nous ferons un clone d'un bon jeu d'Obsidian - Pathfinder Adventures .

«Qu'est-ce que c'est que ça! Des tables? "

Comme on dit, pourquoi pas? Nous semblons avoir déjà abandonné les préjugés, et nous commençons donc hardiment à affiner l'idée. Naturellement, nous ne clonerons pas le jeu un à un, mais nous emprunterons les mécanismes de base. De plus, la mise en place d'un jeu coopératif au tour par tour a ses avantages:

  • c'est étape par étape - cela vous permet de ne pas vous soucier des minuteries, de la synchronisation, de l'optimisation, des FPS et d'autres choses mornes;
  • il est coopératif, c'est-à-dire que le ou les joueurs ne se font pas concurrence, mais contre un certain "environnement" jouant selon des règles déterministes - cela élimine la nécessité de programmer l' IA ( AI ) - l'une des étapes les plus difficiles du développement du jeu;
  • c'est significatif - les dessus de table sont généralement des gens fantaisistes, ils ne joueront rien: donnez-leur des mécanismes réfléchis et un gameplay intéressant - vous ne sortirez pas dans une belle image (cela donne quelque chose à des amis, non?);
  • c'est avec l'intrigue - de nombreux e-sportifs ne seront pas d'accord, mais pour moi personnellement, le jeu devrait raconter une histoire intéressante - comme un livre, en utilisant uniquement ses moyens artistiques spéciaux.
  • elle est divertissante, ce qui n'est pas pour tout le monde - les approches décrites peuvent être appliquées à tout rêve ultérieur, peu importe le nombre que vous avez.

Pour ceux qui ne connaissent pas les règles, une brève introduction:
Pathfinder Adventures est une version numérique d'un jeu de cartes créé sur la base d'un jeu de rôle (ou plutôt d'un système de jeu de rôles) Pathfinder. Les joueurs (de 1 à 6) choisissent un personnage pour eux-mêmes et, avec lui, partent à l'aventure, divisés en plusieurs scénarios. Chaque personnage a à sa disposition des cartes de différents types (telles que: armes, armures, sorts, alliés, objets, etc.), avec l'aide desquelles dans chaque scénario, il doit trouver et punir brutalement le Scoundrel - une carte spéciale avec des propriétés spéciales.

Chaque scénario fournit un certain nombre d'emplacements ou d'emplacements (leur nombre dépend du nombre de joueurs) que les joueurs doivent visiter et explorer. Chaque emplacement contient un jeu de cartes couché face cachée, que les personnages explorent à leur tour - c'est-à-dire qu'ils ouvrent la carte du dessus et essaient de la surmonter selon les règles pertinentes. En plus des cartes inoffensives reconstituant le deck du joueur, ces decks contiennent également des ennemis et des obstacles maléfiques - ils doivent être vaincus pour avancer plus loin. La carte Scoundrel se trouve également dans l'un des decks, mais les joueurs ne savent pas lequel - elle doit être trouvée.

Pour vaincre les cartes (et en acquérir de nouvelles), les personnages doivent passer le test d'une de leurs caractéristiques (standard pour les RPG, force, dextérité, sagesse, etc.) en lançant un dé dont la taille est déterminée par la valeur de la caractéristique correspondante (de d4 à d12), en ajoutant des modificateurs (définis règles et le niveau de développement du personnage) et jouer pour améliorer l'effet des cartes appropriées de la main. En cas de victoire, la carte rencontrée est soit retirée du jeu (s'il s'agit d'un ennemi), soit reconstitue la main d'un joueur (s'il s'agit d'un objet) et le coup revient à un autre joueur. En perdant, le personnage est souvent endommagé, ce qui lui fait défausser les cartes de sa main. Un mécanisme intéressant est que la santé du personnage est déterminée par le nombre de cartes dans son deck - dès que le joueur doit piocher une carte du deck, mais elles ne sont pas là, son personnage meurt.

Le but est, après avoir fait son chemin à travers des cartes de localisation, de trouver et de vaincre le Scoundrel, ayant précédemment bloqué son chemin de retraite (vous pouvez en apprendre plus à ce sujet et bien plus encore en lisant les règles). Cela doit être fait pendant un certain temps, ce qui est la principale difficulté du jeu. Le nombre de coups est strictement limité et une simple énumération de toutes les cartes disponibles n'atteint pas l'objectif. Par conséquent, vous devez appliquer diverses astuces et techniques intelligentes.

Au fur et à mesure que les scénarios se réaliseront, les personnages grandiront et se développeront, améliorant leurs caractéristiques et acquérant de nouvelles compétences utiles. La gestion du jeu est également un élément très important du jeu, car le résultat du scénario (en particulier dans les étapes ultérieures) dépend généralement de cartes correctement sélectionnées (et de beaucoup de chance, mais que voulez-vous d'un jeu avec des dés?).

En général, le jeu est intéressant, digne, digne d'attention, et, ce qui est important pour nous, assez compliqué (notez que je dis «difficile» pas au sens de «difficile») pour le rendre intéressant à implémenter son clone.

Dans notre cas, nous ferons un changement conceptuel global - nous abandonnerons les cartes. Au contraire, nous ne refuserons pas du tout, mais nous remplacerons les cartes par des cubes, toujours de tailles et de couleurs différentes (techniquement, ce n'est pas tout à fait correct d'utiliser leurs "cubes", car il y a d'autres formes que le bon hexagone, mais il est inhabituel pour moi de les appeler "os" et c'est désagréable, mais utiliser la marguerite américaine est un signe de mauvais goût, alors laissez-le tel quel). Désormais, au lieu des decks, les joueurs auront des sacs. Et les emplacements auront également des sacs, desquels les joueurs en cours de recherche sortiront des cubes arbitraires. La couleur du cube déterminera son type et, par conséquent, les règles de réussite du test. Les caractéristiques personnelles du personnage (force, dextérité, etc.), en conséquence, seront éliminées, mais de nouvelles mécaniques intéressantes apparaîtront (plus sur lesquelles plus tard).

Sera-ce amusant à jouer? Je n'en ai aucune idée, et personne ne peut le comprendre tant qu'un prototype fonctionnel n'est pas prêt. Mais nous n'aimons pas le jeu, mais le développement, non? Par conséquent, il ne devrait y avoir aucun doute sur le succès.

Deuxième étape La conception


Avoir une idée n'est qu'un tiers de l'histoire. Maintenant, il est important de développer cette idée. Autrement dit, ne vous promenez pas dans le parc ou ne prenez pas de bain de vapeur, mais asseyez-vous à la table, prenez du papier avec un stylo (ou ouvrez votre éditeur de texte préféré) et rédigez soigneusement un document de conception, en travaillant minutieusement tous les aspects de la mécanique du jeu. Le temps pour cela prendra une percée, alors ne vous attendez pas à terminer l'écriture en une seule séance. Et n'espérez même pas penser à tout à la fois - au fur et à mesure que vous implémentez, vous verrez la nécessité d'apporter un tas de changements et de changements (et parfois de retravailler quelque chose à l'échelle mondiale), mais une base doit être présente avant le début du processus de développement.

Au début, votre document de conception ressemblera à ceci




Et seulement après avoir fait face à la première vague d'idées grandioses, vous prenez la tête, décidez de la structure du document et commencez à le remplir méthodiquement de contenu (en vérifiant chaque seconde avec ce qui a déjà été écrit afin d'éviter les répétitions inutiles et surtout les contradictions). Petit à petit, étape par étape, vous obtenez quelque chose de significatif et de concis, comme ceci .

Lorsque vous décrivez le design, choisissez la langue dans laquelle il vous sera plus facile d'exprimer vos pensées, surtout si vous travaillez seul. Si jamais vous avez besoin d'impliquer des développeurs tiers dans le projet, assurez-vous qu'ils comprennent toutes les bêtises créatives qui se passent dans votre tête.

Pour continuer, je vous recommande fortement de lire le document cité au moins en diagonale, car à l'avenir je ferai référence aux termes et concepts qui y sont présentés, sans m'attarder en détail sur leur interprétation.

«Auteur, tuez-vous contre le mur. Trop de lettres. "

Troisième étape Modélisation


Autrement dit, le même design, mais plus détaillé.
Je sais que beaucoup sont déjà impatients d'ouvrir un IDE et de commencer à coder, mais soyez patient un peu plus. Lorsque les idées nous submergent la tête, il nous semble que nous n'avons qu'à toucher le clavier et nos mains se précipiteront à des distances vertigineuses - avant que le café n'ait le temps de bouillir sur le poêle, lorsque la version de travail de l'application est prête ... pour aller à la poubelle. Afin de ne pas réécrire la même chose plusieurs fois (et surtout de ne pas vous assurer après trois heures de développement que la mise en page ne fonctionne pas et doit être recommencée), je vous suggère d'abord de réfléchir (et de documenter) la structure principale de l'application.

Puisque nous, en tant que développeurs, connaissons bien la programmation orientée objet (POO), nous utiliserons ses principes dans notre projet. Mais pour la POO, rien n'est plus attendu que de commencer le développement avec un tas de diagrammes UML ennuyeux. (Vous ne savez pas ce qu'est UML ? J'ai presque oublié aussi, mais je m'en souviendrai avec plaisir - juste pour montrer ce que je suis un programmeur assidu, hehe.)

Commençons par le diagramme de cas d'utilisation. Nous allons y décrire les façons dont notre utilisateur (joueur) interagit avec le futur système:

Cas d'utilisation


"Euh ... qu'est-ce que c'est que ça?"

Je plaisante, je plaisante ... et, peut-être, j'arrête de plaisanter à ce sujet - c'est une question sérieuse (un rêve, après tout). Sur le schéma des cas d'utilisation, il est nécessaire d'afficher les possibilités que le système offre à l'utilisateur. En détails. Mais il est arrivé historiquement que ce type particulier de diagrammes soit le pire pour moi - la patience ne suffit pas, apparemment. Et vous n'avez pas à me regarder comme ça - nous ne sommes pas à l'université pour protéger le diplôme, mais nous apprécions le processus de travail. Et pour ce processus, les cas d'utilisation ne sont pas si importants. Il est beaucoup plus important de diviser correctement l'application en modules indépendants, c'est-à-dire d'implémenter le jeu de telle manière que les fonctionnalités de l'interface visuelle n'affectent pas la mécanique du jeu et que le composant graphique puisse être facilement modifié si vous le souhaitez.

Ce point peut être détaillé dans le diagramme de composants suivant:

Composants système


Ici, nous avons déjà identifié des sous-systèmes spécifiques qui font partie de notre application et, comme nous le verrons plus loin, ils seront tous développés indépendamment les uns des autres.

De plus, au même stade, nous déterminerons à quoi ressemblera le cycle de jeu principal (ou plutôt, sa partie la plus intéressante est celle qui implémente les personnages dans le script). Pour cela, un diagramme d'activité nous convient:

Si vous vous levez, asseyez-vous


Et enfin, il serait bien de présenter en termes généraux la séquence de l'interaction de l'utilisateur final avec le moteur de jeu via un système d'entrée-sortie.

Saucisses


La nuit est longue, bien avant l'aube. Après vous être assis comme il se doit à la table, vous dessinerez calmement les deux douzaines de diagrammes - croyez-moi, à l'avenir, leur présence vous aidera à rester sur le chemin choisi, augmentera votre estime de soi, mettra à jour l'intérieur de la pièce, suspendra des papiers peints décolorés avec des affiches colorées, et transmettra votre vision en termes simples à collègues développeurs qui se précipiteront bientôt aux portes de votre nouveau studio en masse (nous ne visons pas le succès, vous vous souvenez?).

Jusqu'à présent, nous n'allons pas citer les diagrammes de classes (classe) que nous aimons tous - les classes devraient beaucoup percer et l'image dans trois écrans de clarté au début ne s'ajoutera pas. Il est préférable de le décomposer et de le disposer progressivement, au fur et à mesure que vous développez le sous-système approprié.

Quatrième étape Sélection d'outils


Comme déjà convenu, nous développerons une application multiplateforme qui s'exécute à la fois sur des ordinateurs de bureau exécutant divers systèmes d'exploitation et sur des appareils mobiles. Nous choisirons Java comme langage de programmation, et Kotlin est encore meilleur, car ce dernier est plus récent et plus frais, et n'a pas encore eu le temps de nager dans les vagues d'indignation qui ont balayé son prédécesseur avec sa tête (en même temps je le formerai si quelqu'un d'autre ne le possède pas). La JVM , comme vous le savez, est disponible partout (sur trois milliards d'appareils, hehe), nous prendrons en charge Windows et UNIX, et même sur un serveur distant, nous pouvons jouer via une connexion SSH (il est inconnu de tous ceux qui en ont besoin, mais Nous fournirons une telle opportunité). Nous le transférerons également sur Android lorsque nous serons riches et embaucherons un artiste, mais plus à ce sujet plus tard.

Les bibliothèques (nous ne pouvons aller nulle part sans elles), nous choisirons en fonction de nos exigences multiplateformes. Nous utiliserons Maven comme système de construction. Ou Gradle. Ou tout de même, Maven, commençons par ça. Je vous conseille immédiatement de mettre en place un système de contrôle de version (celui que vous préférez), de sorte qu'après de nombreuses années, il sera plus facile de rappeler avec des sentiments nostalgiques à quel point c'était génial. IDE choisit également le familier, le favori et le plus pratique.

En fait, nous n'avons besoin de rien d'autre. Vous pouvez commencer à développer.

Cinquième étape Création et mise en place d'un projet


Si vous utilisez un IDE, la création d'un projet est triviale. Il vous suffit de choisir un nom sonore (par exemple, Dice ) pour notre futur chef-d'œuvre, n'oubliez pas d'activer le support Maven dans les paramètres et d'écrire les identifiants nécessaires dans le fichier pom.xml :

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

Ajoutez également la prise en charge de Kotlin, qui est manquante par défaut:

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

et certains paramètres sur lesquels nous ne nous attarderons pas en détail:

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

Un peu d'informations sur les projets hybrides
Si vous prévoyez d'utiliser à la fois Java et Kotlin dans votre projet, en plus du src/main/kotlin , vous aurez également le dossier src/main/java . Les développeurs de Kotlin affirment que les fichiers source du premier dossier ( *.kt ) doivent être compilés plus tôt que les fichiers source du deuxième dossier ( *.java ) et recommandent donc fortement de modifier les paramètres des cibles Maven standard:

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

Je ne peux pas dire à quel point cela est important - les projets se déroulent assez bien sans cette feuille. Mais juste au cas où, vous êtes prévenu.

Créons trois packages à la fois (pourquoi chier quelque chose?):

  • model - pour les classes qui décrivent des objets du monde du jeu;
  • game - pour les classes qui mettent en œuvre le gameplay;
  • ui - pour les classes responsables de l'interaction avec l'utilisateur.

Ce dernier ne contiendra que des interfaces dont nous utiliserons les méthodes d'entrée et de sortie des données. Nous allons stocker des implémentations spécifiques dans un projet distinct, mais plus à ce sujet plus tard. En attendant, afin de ne pas trop vaporiser, nous ajouterons ici ces classes côte à côte.

N'essayez pas de le faire immédiatement parfaitement: réfléchissez aux détails des noms de package, des interfaces, des classes et des méthodes; prescrire à fond l'interaction des objets entre eux - tout cela va changer, et plus d'une douzaine de fois. Au fur et à mesure que le projet se développe, beaucoup de choses vous sembleront moches, encombrantes, inefficaces et similaires - n'hésitez pas à les changer, car la refactorisation dans les IDE modernes est une opération très bon marché.

Nous allons également créer une classe avec la fonction main et nous sommes prêts pour de grandes réalisations. Vous pouvez utiliser l'IDE lui-même pour le lancement, mais comme vous le verrez plus tard, cette méthode ne convient pas à nos fins (la console IDE standard n'est pas en mesure d'afficher nos résultats graphiques comme il se doit), nous allons donc configurer le lancement de l'extérieur à l'aide de batch (ou shell sur les systèmes UNIX) fichier. Mais avant cela, nous allons faire des paramètres supplémentaires.

Une fois l'opération de mvn package terminée, nous obtenons la sortie de l'archive JAR avec toutes les classes compilées. Tout d'abord, par défaut, cette archive n'inclut pas les dépendances nécessaires au fonctionnement du projet (jusqu'à présent nous ne les avons pas, mais elles apparaîtront certainement à l'avenir). Deuxièmement, le chemin d'accès à la classe principale contenant la méthode main n'est pas spécifié dans le fichier manifeste d'archive, nous ne pourrons donc pas démarrer le projet avec la commande java -jar dice-1.0.jar . Corrigez cela en ajoutant des paramètres supplémentaires à 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> 

Faites attention au nom de la classe principale. Pour les fonctions Kotlin contenues en dehors des classes (telles que les fonctions main ), les classes sont quand même créées pendant la compilation (car la JVM ne sait rien et ne veut pas savoir). Le nom de cette classe est le nom du fichier avec l'ajout de Kt . Autrement dit, si vous avez nommé la classe principale Main , elle sera compilée dans le fichier MainKt.class . C'est ce dernier que nous devons indiquer dans le manifeste du fichier jar.

Maintenant, lors de la construction du projet, nous obtiendrons deux fichiers jar à la sortie: dice-1.0.jar et dice-1.0-jar-with-dependencies.jar . Nous sommes intéressés par le second. Nous allons lui écrire un script de lancement.

dice.bat (pour 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 (pour 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 

Veuillez noter que si la compilation échoue, nous sommes obligés d'interrompre le script. Sinon, pas la dernière harpe ne sera lancée, mais le fichier restant de l'assemblage réussi précédent (parfois nous ne trouverons même pas la différence). Souvent, les développeurs utilisent la commande mvn clean package pour supprimer tous les fichiers précédemment compilés, mais dans ce cas, l'ensemble du processus de compilation commencera toujours depuis le tout début (même si le code source n'a pas changé), ce qui prendra beaucoup de temps. Mais nous ne pouvons pas attendre - nous devons créer un jeu.

Donc, le projet démarre bien, mais jusqu'à présent ne fait rien. Ne vous inquiétez pas, nous le réparerons bientôt.

Étape six Objets principaux


Petit à petit, nous commencerons à remplir le package du model avec les classes nécessaires au gameplay.

Diagramme de classe


Les cubes sont notre tout, ajoutez-les d'abord. Chaque dé (une instance de la classe Die ) est caractérisé par son type (couleur) et sa taille. Pour les types de cube, nous allons faire une énumération distincte ( Die.Type ), marquer la taille avec un entier de 4 à 12. Nous implémentons également la méthode roll() , qui produira un nombre arbitraire et uniformément distribué à partir de la plage disponible pour le cube (de 1 à la valeur de taille incluse).

La classe implémente l'interface Comparable que les cubes puissent être comparés les uns aux autres (utile plus tard lorsque nous afficherons plusieurs cubes dans une rangée ordonnée). Des cubes plus grands seront placés plus tôt.

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

Afin de ne pas accumuler de poussière, les cubes sont stockés dans des sacs à main (copies de la classe Bag ). On ne peut que deviner ce qui se passe à l'intérieur du sac; par conséquent, cela n'a aucun sens d'utiliser une collection commandée. Il semble que ce soit le cas. Les ensembles (ensembles) mettent bien en œuvre l'idée dont nous avons besoin, mais ne conviennent pas pour deux raisons. Tout d'abord, lorsque vous les utiliserez, vous devrez implémenter les méthodes equals() et hashCode() , et il n'est pas clair comment, car il est incorrect de comparer les types et les tailles de cubes - n'importe quel nombre de cubes identiques peut être stocké dans notre ensemble. Deuxièmement, en tirant le cube hors du sac, nous nous attendons à obtenir non seulement quelque chose de non déterministe, mais aléatoire, chaque fois différent. Par conséquent, je vous conseille néanmoins d'utiliser une collection ordonnée (liste) et de la mélanger à chaque fois que vous ajoutez un nouvel élément (dans la méthode put() ) ou immédiatement avant de le publier (dans la méthode draw() ).

La méthode examine() convient aux cas où un joueur fatigué de l'incertitude secoue le contenu du sac sur la table dans le cœur (attention au tri) et la méthode clear() - si les cubes secoués ne reviennent pas dans le sac.

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

En plus des sacs avec des cubes, vous avez également besoin de tas de cubes (instances de la classe Pile ). Du premier, les seconds diffèrent en ce que leur contenu est visible pour les joueurs, et donc, si nécessaire, retirez un dé du tas, le joueur peut sélectionner une instance d'intérêt spécifique. Nous implémentons cette idée en utilisant la méthode removeDie() .

 class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) } 

Nous nous tournons maintenant vers nos personnages principaux - les héros. Autrement dit, des personnages que nous appellerons maintenant des héros (il y a une bonne raison de ne pas appeler votre classe avec le nom de Character en Java). Il existe différents types de personnages (pour le mettre en classe, bien qu'il soit préférable de ne pas utiliser le mot class ), mais pour notre prototype de travail, nous n'en prendrons que deux: Brawler (c'est-à-dire Fighter en mettant l'accent sur la force et la force) et Hunter (aka Ranger / Thief, avec l'accent dextérité et furtivité). La classe du héros détermine ses caractéristiques, ses compétences et l'ensemble initial de cubes, mais comme nous le verrons plus loin, les héros ne seront pas strictement liés aux classes, et donc leurs paramètres personnels peuvent être facilement modifiés en un seul endroit.

Nous ajouterons les propriétés nécessaires au héros conformément au document de conception: nom, type de cube préféré, limites du cube, compétences apprises et non étudiées, main, sac et pile à réinitialiser. Faites attention aux fonctionnalités d'implémentation des propriétés de collection. Dans tout le monde civilisé, il est considéré comme une mauvaise forme de fournir un accès extérieur (avec l'aide d'un getter) aux collections stockées à l'intérieur de l'objet - des programmeurs sans scrupules pourront modifier le contenu de ces collections à l'insu de la classe. Une façon de résoudre ce problème consiste à implémenter des méthodes distinctes pour ajouter et supprimer des éléments, obtenir leur nombre et accéder par index. Vous pouvez implémenter getter, mais en même temps, ne retournez pas la collection elle-même, mais sa copie immuable - pour un petit nombre d'éléments, il n'est pas particulièrement effrayant de faire exactement cela.

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

La main du héros (les cubes qu'il a en ce moment) est décrite par un objet séparé (classe Hand ). La décision de conception de garder les cubes alliés séparés du bras principal a été l'une des premières à venir à l'esprit. Au début, cela semblait être une fonctionnalité super cool, mais plus tard, cela a généré un grand nombre de problèmes et d'inconvénients. Néanmoins, nous ne recherchons pas de moyens faciles, et donc les listes de dice et d' allies sont à notre service, avec toutes les méthodes dont vous avez besoin pour ajouter, recevoir et supprimer (certaines d'entre elles déterminent intelligemment laquelle des deux listes accéder). Lorsque vous supprimez un cube de votre main, tous les cubes suivants se déplaceront en haut de la liste, remplissant les blancs - à l'avenir, cela facilitera grandement la recherche (pas besoin de gérer les situations avec 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() } 

La collection d'objets de la classe DiceLimit fixe des limites sur le nombre de cubes de chaque type que le héros peut avoir au début du script. Il n'y a rien de spécial à dire, on détermine dans un premier temps, les valeurs maximales et actuelles pour chaque type.

 class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int) 

Mais avec des compétences, c'est plus intéressant. Chacun d'eux devra être implémenté individuellement (à propos de quoi plus tard), mais nous n'en considérerons que deux: Hit et Shoot (un pour chaque classe, respectivement). Les compétences peuvent être développées («pompées») du niveau initial au niveau maximum, ce qui affecte souvent les modificateurs qui sont ajoutés aux lancers de dés. Cela se maxLevel dans le level propriétés, maxLevel , modifier1 et modifier2 .

 class Skill(val type: Type) { enum class Type { //Brawler HIT, //Hunter SHOOT, } var level = 1 var maxLevel = 3 var isActive = true var modifier1 = 0 var modifier2 = 0 } 

Faites attention aux méthodes auxiliaires de la classe des Hero , qui vous permettent de cacher ou de lancer un dé de votre main, de vérifier si le héros possède une certaine compétence, et également d'augmenter le niveau de la compétence acquise ou d'en apprendre une nouvelle. Tous seront nécessaires tôt ou tard, mais maintenant nous ne nous attarderons pas sur eux en détail.

N'ayez pas peur du nombre de cours que nous devons créer. Pour un projet de cette complexité, plusieurs centaines est chose courante. Ici, comme dans toute occupation sérieuse - on commence petit, on augmente progressivement le rythme, en un mois on est terrifié par la portée. N'oubliez pas, nous sommes toujours un petit studio d'une seule personne - nous ne sommes pas confrontés à des tâches écrasantes.

«Quelque chose est tombé malade de moi. Je vais fumer ou quelque chose ... "

Et nous allons continuer.
Les héros et leurs capacités sont décrits, il est temps de passer aux forces adverses - la grande et terrible Game Mechanics. Ou plutôt des objets avec lesquels nos héros doivent interagir.

Un autre diagramme de classes


Trois vaillants cubes et cartes opposeront nos vaillants protagonistes: les méchants (classe Villain ), les ennemis (classe Enemy ) et les obstacles (classe Obstacle ), unis sous le terme général de "menaces" (La Threat est une classe abstraite "verrouillée", la liste de ses héritiers possibles est strictement limitée). Chaque menace a un ensemble de caractéristiques distinctives (trait) qui décrivent des règles de comportement spéciales face à une telle menace et ajoutent de la variété au 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 } 

Veuillez noter que la liste des objets de la classe Trait est définie comme mutable ( MutableList ), mais elle est donnée comme une interface List immuable. Bien que cela fonctionne dans Kotlin, l'approche n'est cependant pas sûre, car rien ne l'empêche de convertir la liste résultante en une interface mutable et d'apporter diverses modifications - il est particulièrement facile de le faire si vous accédez à la classe à partir du code Java (où l'interface List est mutable). La façon la plus paranoïaque de protéger votre collection est de faire quelque chose comme ceci:

 fun getTraits(): List<Trait> = Collections.unmodifiableList(traits) 

mais nous ne serons pas aussi scrupuleux dans l'approche du problème (vous êtes cependant prévenu).

En raison des particularités des mécanismes de jeu, la classe Obstacle diffère de ses homologues en présence de champs supplémentaires, mais nous ne nous concentrerons pas sur eux.

Les cartes de menace (et si vous lisez attentivement le document de conception, n'oubliez pas que ce sont des cartes) sont combinées en decks représentés par la classe Deck :

 class Deck<E: Threat> { private val cards = LinkedList<E>() val size get() = cards.size fun addToTop(card: E) = cards.addFirst(card) fun addToBottom(card: E) = cards.addLast(card) fun revealTop(): E = cards.first fun drawFromTop(): E = cards.removeFirst() fun shuffle() = cards.shuffle() fun clear() = cards.clear() fun examine() = cards.toList() } 

Il n'y a rien d'inhabituel ici, sauf que la classe est paramétrée et contient une liste ordonnée (ou plutôt une file d'attente bidirectionnelle), qui peut être mélangée en utilisant la méthode appropriée. Des ponts d'ennemis et d'obstacles nous seront nécessaires littéralement dans une seconde, lorsque nous en arriverons à la considération ...

... de la classe Location , dont chaque instance décrit une localité unique que nos héros devront visiter dans le cadre du 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 } 

Chaque localité a un nom, une description, une difficulté de fermeture et le signe «ouvert / fermé». Quelque part ici, le méchant peut se cacher (ou il peut ne pas se cacher, à la suite de quoi la propriété du villain peut être null ). Dans chaque zone, il y a un sac avec des cubes et un jeu de cartes avec des menaces. De plus, la zone peut avoir ses propres caractéristiques de jeu uniques ( SpecialRule ), qui, comme les propriétés des menaces, ajoutent de la variété au gameplay. Comme vous pouvez le voir, nous jetons les bases de fonctionnalités futures, même si nous ne prévoyons pas de les implémenter dans un avenir proche (pour lequel, en fait, nous avons besoin de l'étape de modélisation).

Enfin, il reste à implémenter les scripts (classe 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) } 

Chaque scénario est caractérisé par le niveau et la valeur initiale du temporisateur. Semblable à ce qui a été vu précédemment, des règles spéciales (règles spéciales) et les compétences des alliés sont définies (nous allons manquer de considération). Vous pourriez penser que le script devrait également contenir une liste d'emplacements (objets de la classe Location ) et, logiquement, c'est vraiment le cas. Mais comme on le verra plus loin, nous n'utiliserons une telle connexion nulle part et cela ne donne aucun avantage technique.

Je vous rappelle que toutes les classes considérées jusqu'à présent sont contenues dans le package du model - nous, en tant qu'enfant, en prévision d'une bataille de jouets épique, avons placé des soldats à la surface de la table.Et maintenant, après quelques moments douloureux, au signal du commandant en chef, nous allons nous précipiter dans la bataille, rassembler nos jouets et profiter des conséquences du gameplay. Mais avant cela, un peu sur l'arrangement lui-même.

"Bien sooo ..."

Septième étape. Modèles et générateurs


Imaginons une seconde quel sera le processus de génération de l'un des objets précédemment considérés, par exemple, l'emplacement (terrain). Nous devons créer une instance de la classe Location, initialiser ses champs avec des valeurs, et ainsi pour chaque localité que nous voulons utiliser dans le jeu. Mais attendez: chaque emplacement doit avoir un sac, qui doit également être généré. Et les sacs ont des cubes - ce sont aussi des instances de la classe correspondante ( Die). Je ne parle pas des ennemis et des obstacles - ils doivent généralement être collectés sur des ponts. Et le méchant ne détermine pas le terrain lui-même, mais les caractéristiques du scénario situées un niveau plus haut. Eh bien, vous obtenez le point. Le code source de ce qui précède peut ressembler à ceci:

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

C'est aussi grâce au langage et à la conception de Kotlin apply{}- en Java, le code serait deux fois plus volumineux. De plus, il y aura de nombreux endroits, comme nous l'avons dit, et à côté d'eux il y a aussi des scénarios, des aventures et des héros avec leurs compétences et leurs caractéristiques - en général, il y a quelque chose à faire pour le game designer.

Mais le concepteur du jeu n'écrira pas de code, et il est gênant pour nous de recompiler le projet au moindre changement dans le monde du jeu. Ici, tout programmeur compétent objectera que les descriptions des objets du code de classe doivent être séparées - idéalement, de sorte que les instances de ce dernier soient générées dynamiquement en fonction du premier si nécessaire, de la même manière que la pièce est fabriquée à partir d'une usine de dessin. Nous implémentons également de tels dessins, nous les appelons uniquement des modèles et les représentons comme des instances d'une classe spéciale. Ayant de tels modèles, un code de programme spécial (générateur) créera les objets finaux à partir du modèle décrit précédemment.

Le processus de génération d'un objet à partir d'un modèle


Ainsi, pour chaque classe de nos objets, deux nouvelles entités doivent être définies: l'interface modèle et la classe générateur. Et comme une quantité décente d'objets s'est accumulée, il y aura également un certain nombre d'entités ... indécentes:

Diagramme de classe


Veuillez respirer plus profondément, écouter attentivement et ne pas être distrait. Premièrement, le diagramme ne montre pas tous les objets du monde du jeu, mais seulement les principaux, dont vous ne pouvez pas vous passer au début. Deuxièmement, afin de ne pas surcharger le circuit avec des détails inutiles, certaines des connexions déjà mentionnées précédemment dans d'autres diagrammes ont été omises.

Commençons par quelque chose de simple: générer des cubes. «Comment? - dites-vous. - Sommes-nous pas assez constructeur? Oui, c'est celui avec le type et la taille. " Non, je répondrai, pas assez. En effet, dans de nombreux cas (lire les règles), les cubes doivent être générés arbitrairement en quantité arbitraire (par exemple: «d'un à trois cubes de bleu ou de vert»). De plus, la taille doit être sélectionnée en fonction du niveau de complexité du script. Par conséquent, nous introduisons une interface spéciale DieTypeFilter.

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

Différentes implémentations de cette interface vérifieront si le type de cube correspond à différents ensembles de règles (celles qui ne viennent qu'à l'esprit). Par exemple, si le type correspond à une valeur strictement spécifiée ("bleu") ou à une plage de valeurs ("bleu, jaune ou vert"); ou, à l'inverse, correspond à tout type autre que celui donné ("si seulement il n'était pas blanc dans tous les cas" - n'importe quoi, mais pas ça). Même si on ne sait pas à l'avance quelles implémentations spécifiques sont nécessaires, cela n'a pas d'importance - elles peuvent être ajoutées plus tard, le système n'en cassera pas (polymorphisme, rappelez-vous?).

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

La taille du cube sera également définie arbitrairement, mais plus à ce sujet plus tard. En attendant, nous écrirons un générateur de cubes ( DieGenerator) qui, contrairement au constructeur de classe Die, n'acceptera pas le type et la taille explicites du cube, mais le filtre et le niveau de complexité.

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

En Java, ces méthodes seraient statiques, mais comme nous avons affaire à Kotlin, nous n'avons pas besoin de la classe en tant que telle, ce qui est également vrai pour les autres générateurs décrits ci-dessous (néanmoins, au niveau logique, nous utiliserons toujours le concept de classe).

Deux méthodes privées génèrent séparément le type et la taille du cube - quelque chose d'intéressant peut être dit à propos de chacune. La méthode generateDieType()peut être conduite dans une boucle infinie en passant un filtre d'entrée avec

 override fun test(filter: DieTypeFilter) = false 

(Les écrivains croient fermement que l'on peut sortir des incohérences logiques et tracer des trous si les personnages eux-mêmes les dirigent vers le public pendant l'histoire). La méthode generateDieSize()génère une taille pseudo-aléatoire basée sur la distribution spécifiée sous la forme d'un tableau (un pour chaque niveau). Quand je serai riche et que j'achèterai un paquet de cubes de jeu multicolores, je ne pourrai pas jouer aux dés , parce que je ne saurai pas récupérer un sac au hasard (sauf pour demander à un voisin et se détourner à ce moment-là). Ce n'est pas un jeu de cartes qui peut être mélangé à l'envers, il nécessite des mécanismes et des dispositifs spéciaux. Si quelqu'un a des idées (et il a eu la patience de lire à cet endroit), veuillez partager dans les commentaires.

Et puisque nous parlons de sacs, nous allons développer un modèle pour eux. Contrairement à vos amis, ce modèle ( BagTemplate) sera une classe spécifique. Il contient d'autres modèles - chacun d'eux décrit les règles (ou Plan) selon lesquelles un ou plusieurs cubes (rappelez-vous les exigences faites précédemment?) Sont ajoutés au sac.

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

Chaque plan définit un modèle pour le type de cubes, ainsi que le nombre (minimum et maximum) de cubes qui satisfont ce modèle. Grâce à cette approche, vous pouvez générer des sacs selon des règles bizarres (et je pleure à nouveau amèrement la vieillesse, car mon voisin refuse catégoriquement de m'aider). Quelque chose comme ça:

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

Si vous, comme moi, êtes fatigués de tout ce fonctionnalisme, attachez-vous - cela ne fera qu'empirer. Mais alors, contrairement à de nombreux didacticiels indistincts sur Internet, nous avons la possibilité d'étudier l'utilisation de diverses méthodes intelligentes par rapport à un sujet réel et compréhensible.

En eux-mêmes, les sacs ne seront pas couchés sur le terrain - vous devez les donner aux héros et aux lieux. Commençons par ce dernier.

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

Dans le langage Kotlin, au lieu de méthodes, get()vous pouvez utiliser les propriétés d'interface - c'est beaucoup plus concis. Nous connaissons déjà le modèle de sac, considérons les méthodes restantes. La propriété basicClosingDifficultydéfinira la complexité de base du contrôle de fermeture du terrain. Le mot «basique» signifie ici seulement que la complexité finale dépendra du niveau du scénario et n'est pas claire à ce stade. De plus, nous devons définir des modèles pour les ennemis et les obstacles (et les méchants en même temps). De plus, parmi la variété d'ennemis et d'obstacles décrits dans le modèle, tous ne seront pas utilisés, mais seulement un nombre limité (pour augmenter la valeur de relecture). Veuillez noter que les règles spéciales ( SpecialRule) de la zone sont implémentées par une simple énumération ( enum class), et ne nécessitent donc pas de modèle séparé.

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

Et laissez le générateur créer non seulement des objets individuels, mais aussi des decks entiers avec eux.

 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 } 

S'il y a plus de cartes dans le jeu que nous n'en avons besoin (paramètre limit), nous les en retirerons. Pouvant générer des sacs avec des cubes et des packs de cartes, nous pouvons enfin créer du 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) } } 

Le terrain que nous avons explicitement défini dans le code au début du chapitre prendra maintenant un tout autre aspect:

 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) 

La génération de scénarios se produira de la même manière.

 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 } 

Conformément aux règles, le nombre d'emplacements générés dynamiquement dépend du nombre de héros. L'interface définit une fonction de calcul standard qui, si vous le souhaitez, peut être redéfinie dans des implémentations spécifiques. En lien avec cette exigence, le générateur de scénario générera également un terrain pour ces scénarios - au même endroit, les méchants seront répartis au hasard entre les localités.

 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 } 

De nombreux lecteurs attentifs objecteront que les modèles doivent être stockés non pas dans le code source des classes, mais dans certains fichiers texte (scripts) afin que même ceux qui ne sont pas programmés puissent les créer et les maintenir. Je suis d'accord, j'enlève mon chapeau, mais je ne saupoudre pas de cendres sur ma tête - car l'un n'interfère pas avec l'autre. Si vous le souhaitez, définissez simplement une implémentation spéciale du modèle, dont les valeurs de propriété seront chargées à partir d'un fichier externe. Le processus de génération n'en changera pas un iota.

Eh bien, il semble qu'ils n'aient rien oublié ... Oh oui, les héros - ils doivent également être générés, ce qui signifie qu'ils ont également besoin de leurs propres modèles. En voici quelques exemples, par exemple:

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

Et immédiatement, nous remarquons deux bizarreries. Premièrement, nous n'utilisons pas de modèles pour y générer des sacs et des cubes. Pourquoi?Oui, car pour chaque type (classe) de héros, la liste des cubes initiaux est strictement définie - cela n'a aucun sens de compliquer le processus de création. Deuxièmement, getDiceCount()- quel genre de lie est-ce ??? Calmez-vous, ce sont eux DiceLimitqui définissent les restrictions sur les cubes. Et le modèle pour eux a été choisi sous une forme si bizarre que des valeurs spécifiques ont été enregistrées plus clairement. Voyez par vous-même l'exemple:

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

Mais avant d'écrire un générateur, nous définissons un modèle de compétences.

 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 } 

Malheureusement, nous ne réussirons pas à riveter les compétences par lots de la même manière que les ennemis et les scripts. Chaque nouvelle compétence nécessite l'extension des mécanismes de jeu, l'ajout d'un nouveau code au moteur de jeu - même avec des héros à cet égard est plus facile. Peut-être que ce processus peut être abstrait, mais je n'ai pas encore trouvé de moyen. Oui, et pas trop essayé, pour être honnête.

 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 } 

Quelques instants sont frappants. Tout d'abord, la méthode de génération elle-même sélectionne le modèle souhaité en fonction de la classe du héros. Deuxièmement, il n'est pas nécessaire de spécifier un nom immédiatement (parfois au stade de la génération, nous ne le connaîtrons pas encore). Troisièmement, Kotlin a apporté une quantité sans précédent de sucre syntaxique, dont certains développeurs abusent déraisonnablement. Et pas un peu honteux.

Étape huit. Cycle de jeu


Enfin, nous sommes arrivés au plus intéressant - la mise en œuvre du cycle de jeu. En termes simples, ils ont commencé à «faire le jeu». De nombreux développeurs débutants partent souvent précisément de cette étape, à part la création de jeux, tout le reste. Surtout toutes sortes de petits schémas insignifiants à dessiner, pfff ... Mais nous ne nous précipiterons pas (c'est encore loin du matin), et donc un peu plus de modélisation. Oui encore.

Tableau d'activité


Comme vous pouvez le voir, le fragment donné du cycle de jeu est d'un ordre de grandeur inférieur à ce que nous avons cité ci-dessus. Nous ne considérerons que le processus de transfert du parcours, l'exploration de la zone (et nous décrirons la réunion avec seulement deux types de cubes) et l'élimination des cubes à la fin du tour. Et terminer le scénario avec une perte (oui, nous ne réussirons pas encore à gagner notre jeu) - mais comment aimez-vous? Le chronomètre diminuera à chaque tour, et à la fin, quelque chose doit être fait. Par exemple, affichez un message et terminez le jeu - tout est comme il est écrit dans les règles. Un autre jeu doit être terminé à la mort des héros, mais personne ne leur fera de mal, donc nous le quitterons. Pour gagner, vous devez fermer toutes les zones, ce qui est difficile même si ce n'est qu'une seule. Par conséquent, quittons ce moment. Cela n'a pas de sens de pulvériser trop - il est important pour nous de comprendre l'essence et de terminer le reste plus tard, pendant mon temps libre (ou plutôt de le terminer,et vous - allez écrire un jeude vos rêves).

Donc, la première chose à faire est de décider des objets dont nous avons besoin.

Les héros Le script. Emplacements.
Nous avons déjà revu le processus de leur création - nous ne le répéterons pas. Nous notons seulement le modèle de terrain que nous utiliserons dans notre petit exemple.

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

Comme vous pouvez le voir, dans le sac ne sont que des cubes "positifs" - bleu, vert, violet, jaune et bleu. Il n'y a pas d'ennemis et d'obstacles dans la région, les méchants et les blessures ne sont pas trouvés. Il n'y a pas non plus de règles spéciales - leur mise en œuvre est très secondaire.

Tas de cubes retenus.
Ou une pile dissuasive. Puisque nous mettons les cubes bleus dans le sac du terrain, ils peuvent être utilisés dans les contrôles et après utilisation, conservés dans un tas spécial. Une instance de la classe est utile pour cela Pile.

Modificateurs.
Autrement dit, les valeurs numériques qui doivent être ajoutées ou soustraites du résultat du jet de dé. Vous pouvez implémenter un modificateur global ou un modificateur distinct pour chaque cube. Nous choisirons la deuxième option (donc plus clairement), donc nous créerons une classe simple DiePair.

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

L'emplacement des personnages dans la zone.
Dans le bon sens, ce moment doit être suivi à l'aide d'une structure spéciale. Par exemple, des cartes du formulaire Map<Location, List<Hero>>où chaque localité contiendra une liste des héros qui s'y trouvent actuellement (ainsi qu'une méthode pour l'inverse - déterminer la localité dans laquelle se trouve un héros particulier). Si vous décidez de suivre ce chemin, n'oubliez pas d'ajouter des Locationméthodes à la classe d' implémentation equals()et hashCode(), j'espère, il n'est pas nécessaire d'expliquer pourquoi. Nous ne perdrons pas de temps là-dessus, car la région n'en est qu'une et les héros ne la quittent nulle part.

Vérifier les mains du héros.
Au cours du jeu, les héros doivent constamment passer par des contrôles (décrits ci-dessous), c'est-à-dire prendre des cubes de la main, les lancer (ajouter des modificateurs), agréger les résultats s'il y a plusieurs cubes (résumer, prendre le maximum / minimum, la moyenne, etc.), les comparer avec le lancer un autre cube (celui qui est retiré du sac de la zone) et, selon le résultat, effectuez les actions suivantes. Mais tout d'abord, il faut comprendre si le héros est en principe capable de passer le test, c'est-à-dire s'il a les cubes nécessaires en main. Pour cela, nous proposons une interface simple HandFilter.

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

Les implémentations d'interface prennent la main du héros (objet de classe Hand) en entrée et retournent l'une trueou l'autre en falsefonction des résultats de la vérification. Pour notre fragment du jeu, nous avons besoin d'une seule implémentation: si un cube bleu, vert, violet ou jaune est rencontré, nous devons déterminer si la main du héros a un cube de la même couleur.

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

Oui, le fonctionnalisme à nouveau.

Éléments actifs / sélectionnés.
Maintenant que nous nous sommes assurés que la main du héros est appropriée pour effectuer le test, il est nécessaire que le joueur choisisse dans la main les dés (ou cubes) avec lesquels il réussira ce test. Tout d'abord, vous devez mettre en évidence (surligner) les positions appropriées (dans lesquelles il y a des cubes du type souhaité). Deuxièmement, vous devez en quelque sorte marquer les cubes sélectionnés. Pour ces deux exigences, une classe convient HandMask, qui, en fait, contient un ensemble d'entiers (nombre de positions sélectionnées) et des méthodes pour les ajouter et les supprimer.

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

J'ai déjà dit à quel point je souffre de l'idée «ingénieuse» de stocker des cubes blancs dans une main séparée? En raison de cette stupidité, vous devez faire face à deux ensembles et dupliquer chacune des méthodes présentées. Si quelqu'un a des idées sur la façon de simplifier la mise en œuvre de cette exigence (par exemple, utilisez un ensemble, mais pour les cubes blancs, les indices commencent par cent - ou autre chose tout aussi obscur) - partagez-les dans les commentaires.

Soit dit en passant, une classe similaire doit être implémentée pour sélectionner des cubes dans le tas ( PileMask), mais cette fonctionnalité sort du cadre de cet exemple.

Le choix des cubes de la main.
Mais il ne suffit pas de «surligner» des positions acceptables, il est important de changer ce «surlignage» dans le processus de choix des cubes. Autrement dit, si un joueur est tenu de ne prendre qu'un seul dé de sa main, alors lors du choix de ce dé, toutes les autres positions devraient devenir inaccessibles. De plus, à chaque étape, il est nécessaire de contrôler la réalisation du but par le joueur, c'est-à-dire de savoir si les cubes sélectionnés suffisent pour passer l'un ou l'autre test. Une tâche aussi difficile nécessite une instance complexe d'une classe complexe.

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

Logique assez compliquée, je vais vous comprendre et vous pardonner si ce cours vous est incompréhensible. Et essayez toujours d'expliquer. Les implémentations de cette classe stockent toujours une référence à la main (objet Hand) avec laquelle elles seront traitées. Chacune des méthodes reçoit un masque ( HandMask), qui reflète l'état actuel de la sélection (quelles positions sont sélectionnées par le joueur et lesquelles ne le sont pas). La méthode checkMask()indique si les cubes sélectionnés sont suffisants pour réussir le test. La méthode isPositionActive()indique s'il est nécessaire de mettre en évidence une position spécifique - s'il est possible d'ajouter un cube à cette position au test (ou de supprimer un cube déjà sélectionné). La méthode isAllyPositionActive()est la même pour les dés blancs (oui, je sais, je suis un idiot). Eh bien et la méthode d'aidegetCheckedDice()il renvoie simplement une liste de tous les cubes de la main qui correspondent au masque - cela est nécessaire pour les prendre tous à la fois, les jeter sur la table et profiter du drôle de coup, avec lequel ils se dispersent dans différentes directions.

Nous aurons besoin de deux réalisations de cette classe abstraite (surprise, surprise!). Le premier contrôle le processus de réussite du test lors de l'acquisition d'un nouveau cube d'un type spécifique (pas blanc). Comme vous vous en souvenez, n'importe quel nombre de cubes bleus peut être ajouté à un tel contrôle.

 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 } 

La deuxième implémentation est plus compliquée. Elle contrôle le jet de dé à la fin du tour. Dans ce cas, deux options sont possibles. Si le nombre de cubes dans la main dépasse sa taille maximale autorisée (capacité), nous devons jeter tous les cubes supplémentaires plus n'importe quel nombre de cubes supplémentaires (si nous le voulons). Si la taille n'est pas dépassée, vous ne pouvez rien réinitialiser (ou vous pouvez réinitialiser, si vous le souhaitez). En aucun cas, les dés gris ne peuvent être jetés.

 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: Handune propriété est soudainement apparue dans la classe woundCountqui n'existait pas auparavant. Vous pouvez écrire vous-même son implémentation, c'est simple. Pratiquez en même temps.

Passer les chèques.
Enfin arrivé à eux. Lorsque les dés sont retirés de la main, il est temps de les lancer. Pour chaque cube il faut considérer: sa taille, ses modificateurs, le résultat de son lancer. Bien qu'un seul cube puisse être retiré du sac à la fois, plusieurs dés peuvent être placés contre lui, agrégeant les résultats de leurs jets. En général, abstenons-nous des dés et représentons les troupes sur le champ de bataille. D'une part, nous avons un ennemi - il n'est qu'un, mais il est fort et féroce. D'un autre côté, un adversaire de force égale à lui, mais avec soutien. L'issue de la bataille sera décidée en une courte escarmouche, le vainqueur ne pourra être qu'un ...

Désolé, emporté. Pour simuler notre bataille générale, nous implémentons une classe spéciale.

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

Étant donné que chaque cube peut avoir un modificateur, nous stockons les données dans des objets DiePair. Il semble que ce soit le cas. En fait, non, car en plus du cube et du modificateur, vous devez également stocker le résultat de son lancer (rappelez-vous, bien que le cube lui-même génère cette valeur, il ne la stocke pas parmi ses propriétés). Par conséquent, enveloppez chaque paire dans un wrapper ( Wrap). Faites attention à la méthode infixe with, hehe.

Le constructeur de classe définit la méthode d'agrégation (une instance de l'énumération interne Method) et l'adversaire (qui peut ne pas exister). La liste des cubes héros est formée en utilisant les méthodes appropriées. Il fournit également un tas de méthodes pour impliquer les paires dans le test et les résultats de leurs lancers (le cas échéant).

La méthoderoll()appelle la méthode du même nom de chaque cube, enregistre les résultats intermédiaires et marque le fait de son exécution avec un drapeau isRolled. Veuillez noter que le résultat final du lancer n'est pas calculé immédiatement - il existe une méthode spéciale pour cela calculateResult(), dont le résultat est d'écrire la valeur finale dans la propriété result. Pourquoi est-ce nécessaire? Pour un effet dramatique. La méthode roll()sera exécutée plusieurs fois, chaque fois sur les faces des cubes différentes valeurs seront affichées (comme dans la vraie vie). Et seulement lorsque les cubes se calment sur la table, nous apprenons notre sort le résultat final (la différence entre les valeurs des cubes du héros et des cubes de l'adversaire). Pour soulager le stress, je dirai qu'un résultat de 0 sera considéré comme une réussite au test.

L'état du moteur de jeu.
Des objets sophistiqués triés, maintenant les choses sont plus simples. Ce ne sera pas une grande découverte de dire que nous devons contrôler la «progression» actuelle du moteur de jeu, le stade ou la phase dans laquelle il se trouve. Une énumération spéciale est utile pour cela.

 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 } 

En fait, il y a plus de phases, mais nous avons sélectionné uniquement celles qui sont utilisées dans notre exemple. Pour changer la phase du moteur de jeu, nous utiliserons des méthodes changePhaseX(), où Xest la valeur de la liste ci-dessus. Dans ces méthodes, toutes les variables internes du moteur seront réduites à des valeurs adéquates pour le début de la phase correspondante, mais plus à ce sujet plus tard.

Des messages
Garder l'état du moteur de jeu ne suffit pas. Il est également important pour l'utilisateur de l'informer d'une manière ou d'une autre - sinon, comment ce dernier saura-t-il ce qui se passe sur son écran? C'est pourquoi nous avons besoin d'une autre liste.

 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 } 

Comme vous pouvez le voir, tous les états possibles de notre exemple sont décrits par les valeurs de cette énumération. Pour chacun d'eux, une ligne de texte est fournie, qui sera affichée à l'écran (sauf EMPTY- c'est une signification particulière), mais nous en apprendrons un peu plus tard.

Actions
Pour la communication entre l'utilisateur et le moteur de jeu, de simples messages ne suffisent pas. Il est également important d'informer le premier des actions qu'il peut entreprendre en ce moment (rechercher, passer les blocs, terminer le mouvement - c'est tout bon). Pour ce faire, nous développerons une classe spéciale.

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

Une énumération interne Typedécrit le type d'action effectuée. Le champ est isEnablednécessaire pour afficher les actions dans un état inactif. Autrement dit, pour signaler que cette action est généralement disponible, mais pour le moment, pour une raison quelconque, ne peut pas être effectuée (un tel affichage est beaucoup plus informatif que lorsque l'action n'est pas affichée du tout). La propriété data(nécessaire pour certains types d'actions) stocke une valeur spéciale qui communique certains détails supplémentaires (par exemple, l'index de la position sélectionnée par l'utilisateur ou le numéro de l'élément sélectionné dans la liste).

KlasActionest la principale "interface" entre le moteur de jeu et les systèmes d'entrée-sortie (dont ci-dessous). Puisqu'il y a souvent plusieurs actions (sinon, pourquoi alors choisir?), Elles seront combinées en groupes (listes). Au lieu d'utiliser des collections standard, nous écrirons notre propre collection étendue.

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

La classe contient de nombreuses méthodes différentes pour ajouter et supprimer des actions de la liste (qui peuvent être chaînées ensemble), ainsi que pour obtenir à la fois par index et par type (notez la «surcharge» get()- l'opérateur entre crochets est applicable à notre liste). L'implémentation de l'interface Iteratornous permet de faire diverses manipulations de flux (fonctionnalité, aha) avec nos toutes sortes de classe de merde folle . Une valeur VIDE est également fournie pour créer rapidement une liste vide.

Écrans.
Enfin, une autre liste qui décrit les différents types de contenus actuellement affichés ... Vous me regardez et clignez des yeux, je sais. Lorsque j’ai commencé à réfléchir à la façon de décrire plus clairement ce cours, je me suis cogné la tête sur la table, car je ne pouvais vraiment rien comprendre. Comprenez-vous, j'espère.

 enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS } 

Sélectionnez uniquement ceux utilisés dans l'exemple. Une méthode de rendu distincte sera fournie pour chacun d'eux ... je l'explique encore une fois inexplicablement.

"Affichage" et "entrée".
Et maintenant, nous arrivons enfin au point le plus important - l'interaction du moteur de jeu avec l'utilisateur (joueur). Si une si longue introduction ne vous a pas encore ennuyé, vous vous souvenez probablement que nous avons convenu de séparer fonctionnellement ces deux parties l'une de l'autre. Par conséquent, au lieu d'une implémentation spécifique du système d'E / S, nous ne fournirons qu'une interface. Plus précisément, deux.

Première interfaceGameRenderer, conçu pour afficher des images à l'écran. Je vous rappelle que nous faisons abstraction des tailles d'écran, de bibliothèques graphiques spécifiques, etc. Nous envoyons simplement la commande: "dessine-moi ceci" - et ceux d'entre vous qui ont compris notre conversation trouble sur les écrans ont déjà deviné que chacun de ces écrans avait sa propre méthode dans l'interface.

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

Je pense qu'il n'y a pas besoin d'explications supplémentaires ici - le but de tous les objets transférés est discuté en détail ci-dessus.

Pour la saisie par l'utilisateur, nous implémentons une interface différente - GameInteractor(oui, les scripts de vérification orthographique mettront toujours l'accent sur ce mot, bien qu'il semble ...). Ses méthodes demanderont au joueur les commandes requises pour diverses situations: sélectionnez une action dans la liste des propositions, sélectionnez un élément dans la liste, sélectionnez des cubes dans la main, appuyez au moins sur quelque chose, etc. Il convient de noter immédiatement que l'entrée se produit de manière synchrone (le jeu est étape par étape), c'est-à-dire que l'exécution de la boucle de jeu est suspendue jusqu'à ce que l'utilisateur réponde à la demande.

 interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action } 

À propos de la dernière méthode un peu plus. Comme son nom l'indique, from invite l'utilisateur à sélectionner des cubes dans la main, fournissant un objet HandMask- le nombre de positions actives. L'exécution de la méthode se poursuivra jusqu'à ce que certaines d'entre elles soient sélectionnées - dans ce cas, la méthode renverra une action de type HAND_POSITION(ou HAND_ALLY_POSITION, mda) avec le numéro de la position sélectionnée dans le champ data. De plus, il est possible de sélectionner une autre action (par exemple, CONFIRMou CANCEL) dans l'objet ActionList. Les implémentations des méthodes de saisie doivent distinguer les situations lorsque le champ est isEnableddéfini sur falseet ignorer la saisie par l'utilisateur de telles actions.

Classe de moteur de jeu.
Nous avons examiné tout le nécessaire au travail, le moment est venu et le moteur à mettre en œuvre. Créer une classeGame avec le contenu suivant:

Désolé, cela ne doit pas être montré aux personnes impressionnables.
 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 } } } 

Méthode start()- le point d'entrée au jeu. Ici, les variables sont initialisées, les héros sont pesés, les mains remplies de cubes et les journalistes brillent avec des caméras de tous les côtés. Le cycle principal sera lancé toutes les minutes, après quoi il ne pourra plus être arrêté. La méthode drawInitialHand()parle d'elle-même (nous n'avons pas semblé considérer le code de la méthode de drawOfType()classe Bag, mais après avoir fait un si long chemin ensemble, vous pouvez écrire ce code vous-même). La méthode refillHeroHand()a deux options (en fonction de la valeur de l'argument redrawScreen): rapide et silencieuse (lorsque vous devez remplir les mains de tous les héros au début du jeu), et bruyante avec un tas de pathos, quand à la fin du mouvement, vous devez retirer les cubes du sac, ce qui porte la main à la bonne taille.

Un tas de méthodes avec des noms commençant parchangePhase, - comme nous l'avons déjà dit, elles servent à changer la phase de jeu en cours et sont engagées dans l'affectation des valeurs correspondantes des variables de jeu. Ici, une liste est formée actionsoù les actions caractéristiques de cette phase sont ajoutées.

La méthode d'utilité pickDiceFromHand()sous une forme généralisée est engagée dans la sélection des cubes de la main. Un objet d'une classe familière HandMaskRulequi définit les règles de sélection est passé ici . Il indique également la possibilité de refuser la sélection ( allowCancel), ainsi qu'une fonction onEachLoopdont le code doit être appelé à chaque fois que la liste des cubes sélectionnés est modifiée (généralement un redessin d'écran). Les cubes sélectionnés par cette méthode peuvent être assemblés à la main en utilisant les méthodes collectPickedDice()et collectPickedAllyDice().

Une autre méthode utilitaireperformStatDieAcquireCheck()implémente pleinement le héros passant le test pour l'acquisition d'un nouveau cube. Le rôle central dans cette méthode est joué par l'objet DieBattleCheck. Le processus commence par la sélection des cubes par la méthode pickDiceFromHand()(à chaque étape la liste des «participants» est mise à jour DieBattleCheck). Les cubes sélectionnés sont retirés de la main, après quoi un "jet" se produit - chaque dé met à jour sa valeur (huit fois de suite), après quoi le résultat est calculé et affiché. Lors d'un lancer réussi, un nouveau dé tombe entre les mains du héros. Les cubes participant au test sont soit tenus (s'ils sont bleus), soit jetés (si shouldDiscard = true), soit cachés dans le sac (si shouldDiscard = false).

Méthode principaleprocessCycle()contient une boucle infinie (je demande sans s'évanouir) dans laquelle l'écran est d'abord dessiné, puis l'utilisateur est invité à entrer, puis cette entrée est traitée - avec toutes les conséquences qui en découlent. La méthode drawScreen()appelle la méthode d'interface souhaitée GameRenderer(en fonction de la valeur actuelle screen), en lui passant les objets requis à l'entrée.

En outre, la classe contient plusieurs méthodes d'assistance: checkLocationCanBeExplored(), checkHeroCanAttemptStatCheck()et checkHeroCanAcquireDie(). Leurs noms parlent d'eux-mêmes, nous ne nous attarderons donc pas sur eux en détail. Et il existe également des appels de méthode de classe Audio, soulignés par une ligne ondulée rouge. Commentez-les pour le moment - nous examinerons leur objectif plus tard.

Qui ne comprend rien du tout, voici un schéma (pour plus de clarté, pour ainsi dire):


C'est tout, le jeu est prêt (hehe). Il y avait de vraies petites choses, à leur sujet ci-dessous.

Étape neuf. Afficher l'image


Nous arrivons donc au sujet principal de la conversation d'aujourd'hui - le composant graphique de l'application. Comme vous vous en souvenez, notre tâche est de mettre en œuvre l'interface GameRendereret ses trois méthodes, et comme il n'y a toujours pas d'artiste talentueux dans notre équipe, nous le ferons nous-mêmes en utilisant des pseudographies. Mais pour commencer, ce serait bien de comprendre ce que nous attendons généralement à la sortie. Et nous voulons voir trois écrans d'environ le contenu suivant:

Écran 1. ID de tour du joueur


Écran 2. Informations sur la zone et le héros actuel


Écran 3. Message de perte de script


Je pense que la majorité a déjà réalisé que les images présentées sont différentes de tout ce que nous avons l'habitude de voir dans la console des applications Java, et que les fonctionnalités habituelles prinltn()ne seront évidemment pas suffisantes. J'aimerais également pouvoir sauter à des endroits arbitraires sur l'écran et dessiner des symboles de différentes couleurs. Les codes ANSI Chip et Dale se

précipitent à notre secours . En envoyant des séquences de caractères bizarres en sortie, vous pouvez obtenir des effets non moins bizarres: changez la couleur du texte / arrière-plan, la façon dont les caractères sont dessinés, la position du curseur sur l'écran, et bien plus encore. Bien sûr, nous ne les présenterons pas sous leur forme pure - nous cacherons l'implémentation derrière les méthodes de la classe. Et nous n'écrirons pas le cours lui-même à partir de zéro - heureusement, les gens intelligents l'ont fait pour nous. Il suffit de télécharger et de connecter une bibliothèque légère au projet, par exemple Jansi :

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

Et vous pouvez commencer à créer. Cette bibliothèque nous fournit un objet de classe Ansi(obtenu à la suite d'un appel statique Ansi.ansi()) avec un tas de méthodes pratiques qui peuvent être chaînées. Il fonctionne sur le principe de StringBuilder'a - nous formons d'abord l'objet, puis l'envoyons à l'impression. Parmi les méthodes utiles, nous trouverons utiles:

  • a() - pour afficher des caractères;
  • cursor() - pour déplacer le curseur sur l'écran;
  • eraseLine() - comme si elle parlait d'elle-même;
  • eraseScreen() - de même;
  • fg(), bg(), fgBright(), bgBright() - méthodes très gênantes pour travailler avec les couleurs du texte et de l'arrière-plan - nous rendrons les nôtres plus agréables;
  • reset() - pour réinitialiser les paramètres de couleur définis, le scintillement, etc.

Créons une classe ConsoleRendereravec des méthodes utilitaires qui peuvent nous être utiles dans notre travail. La première version ressemblera à ceci:

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

La méthode resetAnsi()crée un nouvel objet (vide) Ansi, qui sera rempli avec les commandes nécessaires (déplacement, sortie, etc.). Une fois le remplissage terminé, l'objet généré est envoyé pour impression par la méthode render()et la variable est initialisée avec un nouvel objet. Rien de compliqué pour l'instant, non? Et si c'est le cas, alors nous commencerons à remplir cette classe avec d'autres méthodes utiles.

Commençons par les tailles. La console standard de la plupart des terminaux mesure 80 x 24. On note ce fait avec deux constantes CONSOLE_WIDTHet CONSOLE_HEIGHT. Nous ne serons pas attachés à des valeurs spécifiques et essaierons de rendre le design aussi caoutchouteux que possible (comme sur le web). La numérotation des coordonnées commence par une, la première coordonnée est une ligne, la seconde est une colonne. Sachant tout cela, nous écrivons une méthode utilitairedrawHorizontalLine() pour remplir la chaîne spécifiée avec le caractère spécifié.

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

Encore une fois, je vous rappelle que l'invocation de commandes a()ou cursor()n'entraîne aucun effet instantané, mais ajoute uniquement la Ansiséquence de commandes correspondante à l'objet . Ce n'est que lorsque ces séquences seront envoyées à l'impression que nous les verrons à l'écran.

Il n'y a pas de différence fondamentale entre l'utilisation du cycle classique foret l'approche fonctionnelle avec ClosedRangeet forEach{}- chaque développeur décide par lui-même de ce qui lui convient le mieux. Cependant, je continuerai à vous tromper avec le fonctionnalisme, simplement parce que je suis un singe qui aime tout ce qui est nouveau et que les supports brillants ne sont pas enveloppés dans une nouvelle ligne et que le code semble plus compact.

Nous implémentons une autre méthode utilitaire drawBlankLine()qui fait la même chose quedrawHorizontalLine(offsetY, ' '), uniquement avec extension. Parfois, nous devons rendre la ligne vide pas complètement, mais laisser une ligne verticale au début et à la fin (cadre, oui). Le code ressemblera à ceci:

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

Comment, vous n'avez jamais dessiné de cadres à partir de pseudographies? Les symboles peuvent être insérés directement dans le code source. Maintenez la touche Alt enfoncée et tapez le code de caractère sur le pavé numérique. Alors lâchez prise. Les codes ASCII dont nous avons besoin dans n'importe quel codage sont les mêmes, voici l'ensemble minimal de gentleman:


Et puis, comme dans minecraft, les possibilités ne sont limitées que par les limites de votre imagination. Et la taille de l'écran.

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

Parlons un peu des fleurs. La classe Ansicontient des constantes Colorpour huit couleurs primaires (noir, bleu, vert, cyan, rouge, violet, jaune, gris), que vous devez passer à l'entrée des méthodes fg()/bg()pour la version sombre ou fgBright()/bgBright()pour la claire, ce qui est terriblement gênant à faire, car pour identifier la couleur par manière, une valeur ne nous suffit pas - nous en avons besoin d'au moins deux (couleur et luminosité). Par conséquent, nous allons créer notre liste de constantes et nos méthodes d'extension (ainsi que les couleurs de liaison de cartes aux types de cubes et aux classes de héros):

 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 ) 

Désormais, chacune des 16 couleurs disponibles est identifiée de manière unique par une seule constante. Nous allons écrire quelques autres méthodes utilitaires, mais avant cela, nous allons trouver une dernière chose:

où stocker les constantes pour les chaînes de texte?

«Les constantes de chaîne doivent être extraites dans des fichiers séparés afin qu'elles soient stockées en un seul endroit, ce qui les rend plus faciles à gérer. Et c'est aussi important pour la localisation ... "

Les constantes de chaîne doivent être déplacées vers des fichiers séparés ... enfin, oui. Nous endurerons. Le mécanisme Java standard pour travailler avec ce type de ressources est les objets java.util.ResourceBundlequi fonctionnent avec des fichiers .properties. Ici, nous partons d'un tel fichier:

 # 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 

Chaque ligne contient une paire clé-valeur, séparée par un caractère =. Vous pouvez placer le fichier n'importe où - l'essentiel est que le chemin d'accès fasse partie du chemin de classe. Veuillez noter que le texte des actions se compose de deux parties: la première lettre est non seulement surlignée en jaune lorsqu'elle est affichée à l'écran, mais détermine également la touche qui doit être enfoncée pour effectuer cette action. Par conséquent, il est pratique de les stocker séparément.

Cependant, nous faisons abstraction d'un format spécifique (dans Android, par exemple, les chaînes sont stockées différemment) et décrivons l'interface de chargement des constantes de chaîne.

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

La clé est transmise à l'entrée, la sortie est une ligne spécifique. L'implémentation est aussi simple que l'interface elle-même (supposons que le fichier se trouve le long du chemin src/main/resources/text/strings.properties).

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

Maintenant, il ne sera pas difficile de mettre en œuvre une méthode drawStatusMessage()pour afficher l'état actuel du moteur de jeu ( StatusMessage) à l'écran et une méthode drawActionList()pour afficher une liste d'actions disponibles ( ActionList). Ainsi que d'autres méthodes officielles que seule l'âme désire.

Il y a beaucoup de code, une partie que nous avons déjà vu ... alors voici un spoiler pour vous
 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] } 

Pourquoi avons-nous tous fait cela, demandez-vous? Oui, afin d'hériter notre implémentation d'interface de cette merveilleuse classe GameRenderer.

Diagramme de classe


Voici à quoi ressemblera l'implémentation de la première méthode la plus simple:

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

Rien de surnaturel, juste une ligne de texte ( data) tracée en rouge au centre de l'écran ( drawCenteredCaption()). Le reste du code remplit le reste de l'écran avec des lignes vides. Peut-être que quelqu'un demandera pourquoi cela est nécessaire - après tout clearScreen(), il existe une méthode , il suffit de l'appeler au début de la méthode, d'effacer l'écran, puis de dessiner le texte souhaité. Hélas, c'est une approche paresseuse que nous n'utiliserons pas. La raison est très simple: avec cette approche, certaines positions sur l'écran sont dessinées deux fois, ce qui conduit à un scintillement notable, surtout lorsque l'écran est dessiné séquentiellement plusieurs fois de suite (lors des animations). Par conséquent, notre tâche n'est pas seulement de dessiner les bons personnages aux bons endroits, mais de remplir l' ensemblele reste de l'écran avec des caractères vides (afin que les artefacts d'autres rendus ne restent pas dessus). Et cette tâche n'est pas si simple.

La méthode suivante suit ce principe:

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

Ici, en plus du texte centré, il y a aussi deux lignes horizontales (voir les captures d'écran ci-dessus). Veuillez noter que le lettrage central est affiché en deux couleurs. Et assurez-vous également que l'apprentissage des mathématiques à l'école est toujours utile.

Eh bien, nous avons examiné les méthodes les plus simples et il est temps de connaître la mise en œuvre drawLocationInteriorScreen(). Comme vous le comprenez vous-même, il y aura un ordre de grandeur plus de code ici. De plus, le contenu de l'écran changera dynamiquement en réponse aux actions de l'utilisateur et devra être constamment redessiné (parfois avec animation). Eh bien, pour enfin vous achever: imaginez qu'en plus de la capture d'écran ci-dessus, dans le cadre de cette méthode, il faut implémenter l'affichage de trois autres:

1. Rencontre avec le cube retiré du sac


2. Sélection des dés pour réussir le test


3. Affichage des résultats des tests


Par conséquent, voici mon excellent conseil: ne mettez pas tout le code dans une seule méthode. Divisez l'implémentation en plusieurs méthodes (même si chacune d'elles ne sera appelée qu'une seule fois). Eh bien, n'oubliez pas le "caoutchouc".

Si cela commence à onduler dans vos yeux, clignez des yeux pendant quelques secondes - cela devrait aider
 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() } } 

Il y a un petit problème associé à la vérification du fonctionnement de tout ce code. Étant donné que la console IDE intégrée ne prend pas en charge les séquences d'échappement ANSI, vous devrez démarrer l'application dans un terminal externe (nous avons déjà écrit un script pour le lancer plus tôt). De plus, avec la prise en charge ANSI, tout ne va pas bien sous Windows - à ma connaissance, seule la 10e version du cmd.exe standard peut nous plaire avec un affichage de haute qualité (et cela, avec certains problèmes sur lesquels nous ne nous concentrerons pas). Et PowerShell n'a pas immédiatement appris à reconnaître les séquences (malgré la demande actuelle). Si vous n'avez pas de chance, ne vous découragez pas - il existe toujours des solutions alternatives ( ceci, par exemple ). Et nous continuons.

Étape dix Entrée utilisateur


L'affichage de l'image à l'écran représente toujours la moitié de la bataille. Il est également important de recevoir correctement les commandes de contrôle de l'utilisateur. Et cette tâche, je tiens à vous le dire, peut s'avérer techniquement beaucoup plus difficile à mettre en œuvre que toutes les précédentes. Mais tout d'abord.

Comme vous vous en souvenez, nous sommes confrontés à la nécessité d'implémenter des méthodes de classe GameInteractor. Il n'y en a que trois, mais elles nécessitent une attention particulière. Tout d'abord, la synchronisation. Le moteur de jeu doit être suspendu jusqu'à ce que le joueur appuie sur une touche. Deuxièmement, cliquez sur le traitement. Malheureusement, la capacité des classes standard Reader, Scanner, Consolene suffit pas à les reconnaître les plus pressants: nous ne demandons pas que l'utilisateur presse ENTRER après chaque commande. Nous avons besoin de quelque chose commeKeyListenerMais, mais il est étroitement lié au cadre Swing, et notre application console est sans tout ce clinquant graphique.

Que faire?La recherche de bibliothèques, bien sûr, et cette fois, leur travail reposera entièrement sur du code natif. Qu'est-ce que cela signifie «au revoir, multiplateforme» ... Ou pas? Hélas, je n'ai pas encore trouvé de bibliothèque qui implémente des fonctionnalités simples sous une forme légère et indépendante de la plateforme. En attendant, faisons attention au monstre jLine , qui implémente un récolteur pour construire des interfaces utilisateur avancées (dans la console). Oui, il a une implémentation native, oui, il prend en charge Windows et Linux / UNIX (en fournissant les bibliothèques appropriées). Et oui, utilisé sur la plupart de ses fonctionnalités, on n'a pas besoin de cent ans. Tout ce qu'il faut, c'est une petite opportunité mal documentée, dont nous allons maintenant analyser le travail.

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

Veuillez noter que nous n'avons pas besoin de la troisième, dernière version, mais de la seconde, où il y a une classe ConsoleReaderavec une méthode readCharacter(). Comme son nom l'indique, cette méthode renvoie le code du caractère pressé sur le clavier (tout en travaillant de manière synchrone, ce dont nous avons besoin). Le reste est une question technique: compiler un tableau de correspondances entre symboles et types d'actions ( Action.Type) et, en cliquant sur l'un, renvoyer l'autre.

«Savez-vous que toutes les touches du clavier ne peuvent pas être représentées avec un seul caractère? De nombreuses touches utilisent des séquences d'échappement de deux, trois, quatre caractères différents. Comment être avec eux? "

Il convient de noter que la tâche de saisie est compliquée si nous voulons reconnaître les «touches sans caractère»: flèches, touches F, accueil, insertion, PgUp / Dn, fin, suppression, pavé numérique et autres. Mais nous ne voulons pas, nous allons donc continuer. Créons une classe ConsoleInteractoravec les méthodes de service nécessaires.

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

Définissez la carte mapperet la méthode read(). De plus, nous fournirons une méthode getIndexForKey()utilisée dans les situations où nous devons sélectionner un élément dans une liste ou des cubes dans une main. Il reste à hériter notre implémentation d'interface de cette classe GameInteractor.

Diagramme de classe


Et, en fait, le 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) } } } } } } 

La mise en œuvre de nos méthodes est assez polie et bien rodée pour ne pas faire ressortir divers bêtises inadéquates. Ils vérifient eux-mêmes que l'action sélectionnée est active et que la position de la main sélectionnée est incluse dans l'ensemble des valides. Et je souhaite que nous soyons tous aussi polis envers les gens qui nous entourent.

Étape onze. Sons et musique


Mais comment cela peut-il se passer d'eux? Si vous avez déjà joué à des jeux avec le son désactivé (par exemple, avec une tablette sous les couvertures alors que personne à la maison ne le voit), vous avez peut-être réalisé combien vous perdez. C'est comme jouer seulement la moitié du match. De nombreux jeux ne peuvent pas être imaginés sans accompagnement sonore, pour beaucoup c'est une exigence inaliénable, bien qu'il y ait des situations inverses (par exemple, quand il n'y a pas de sons en principe, ou ils sont si misérables qu'il serait mieux sans eux). Faire un bon travail n'est en fait pas aussi simple qu'il y paraît à première vue (non sans raison que des spécialistes hautement qualifiés le font dans les grands studios), mais quoi qu'il en soit, dans la plupart des cas, il est préférable d'avoir un composant audio (au moins certains) dans votre jeu que de ne pas l'avoir du tout. En dernier recours, la qualité sonore peut être améliorée ultérieurement,lorsque le temps et l'humeur le permettent.

En raison des spécificités du genre, notre jeu ne sera pas caractérisé par des effets sonores de chef-d'œuvre - si vous avez joué des adaptations numériques de jeux de société, vous comprenez ce que je veux dire. Les sons repoussent leur monotonie, deviennent vite ennuyeux et après un certain temps, jouer sans eux ne semble plus être une perte sérieuse. Le problème est aggravé par le fait qu'il n'existe aucun moyen efficace de faire face à ce phénomène. Remplacez les sons du jeu par des sons complètement différents, et au fil du temps, ils deviendront dégoûtés. Dans les bons jeux, les sons complètent le gameplay, révèlent l'atmosphère de l'action en cours, la rendent vivante - c'est difficile à réaliser si l'atmosphère n'est qu'une table avec un tas de sacs poussiéreux, et le gameplay entier consiste à lancer des dés. Néanmoins, c'est exactement ce que nous allons exprimer: la soie est ici, la distribution est ici,bruissement et bruissement de cris bruyants - comme si nous n'étions pas en train d'observer une image sur l'écran, mais que nous interagissions vraiment avec de vrais objets physiques. Ils doivent être entièrement exprimés, mais discrètement - tout au long du script, vous les entendrez cent fois, de sorte que les sons ne doivent pas venir au premier plan - il suffit d'ombrer doucement le gameplay. Comment y parvenir avec compétence? Je n'en ai aucune idée, je ne suis pas spécial en son. Je ne peux que vous conseiller de jouer à votre jeu autant que possible, en remarquant et en polissant des défauts visibles (ce conseil, d'ailleurs, ne s'applique pas uniquement aux sons).Comment y parvenir avec compétence? Je n'en ai aucune idée, je ne suis pas spécial en son. Je ne peux que vous conseiller de jouer à votre jeu autant que possible, en remarquant et en polissant des défauts visibles (ce conseil, d'ailleurs, ne s'applique pas uniquement aux sons).Comment y parvenir avec compétence? Je n'en ai aucune idée, je ne suis pas spécial en son. Je ne peux que vous conseiller de jouer à votre jeu autant que possible, en remarquant et en polissant des défauts visibles (ce conseil, d'ailleurs, ne s'applique pas uniquement aux sons).

Avec la théorie, semble-t-il, réglée, maintenant il est temps de passer à la pratique. Et avant cela, vous devez vous poser une question: où, en fait, prendre les fichiers du jeu? Le moyen le plus simple et le plus sûr est de les enregistrer vous-même en qualité laide, en utilisant un vieux microphone ou même en utilisant le téléphone. Internet regorge de vidéos sur la façon dont le dévissage du sommet de l'ananas ou la rupture de la glace avec une botte peut produire l'effet d'écrasement des os et d'une colonne vertébrale croustillante. Si vous n'êtes pas étranger à l'esthétique du surréalisme, vous pouvez utiliser votre propre voix ou des ustensiles de cuisine comme instrument de musique (il existe des exemples - et même des succès - où cela a été fait). Ou vous pouvez aller sur freesound.orgoù une centaine d'autres personnes l'ont fait pour vous il y a longtemps. Faites attention uniquement à la licence: de nombreux auteurs sont très sensibles aux enregistrements audio de leur forte toux ou pièces jetées sur le sol - vous ne voulez en aucun cas utiliser sans scrupule les fruits de leurs travaux sans payer le créateur d'origine ou sans mentionner son pseudonyme créatif (parfois très bizarre) dans les commentaires.

Faites glisser les fichiers que vous aimez et placez-les quelque part dans le chemin de classe. Pour les identifier, nous utiliserons l'énumération, où chaque instance correspond à un effet sonore.

 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 } 

Étant donné que la méthode de reproduction des sons variera en fonction de la plate-forme matérielle, nous pouvons être abstraits d'une implémentation spécifique à l'aide de l'interface. Par exemple, celui-ci:

 interface SoundPlayer { fun play(sound: Sound) } 

Comme les interfaces précédemment discutées GameRendereret GameInteractor, son implémentation doit également être passée à l'entrée de l'instance de classe Game. Pour commencer, une implémentation pourrait ressembler à ceci:

 class MuteSoundPlayer : SoundPlayer { override fun play(sound: Sound) { //Do nothing } } 

Par la suite, nous considérerons des implémentations plus intéressantes, mais pour l'instant parlons de musique.
Comme les effets sonores, il joue un rôle énorme dans la création de l'atmosphère du jeu, et de la même manière, un excellent jeu peut être ruiné par une musique inappropriée. Comme les sons, la musique doit être discrète, ne pas être mise en avant (sauf si elle est nécessaire pour un effet artistique) et correspondre adéquatement à l'action à l'écran (n'espérez pas que quelqu'un soit sérieusement imprégné du sort d'un personnage principal pris en embuscade et impitoyablement tué) héros, si la scène de sa mort tragique sera accompagnée d'une petite musique amusante d'une chanson pour enfants). Il est très difficile d'y parvenir, des personnes spécialement formées s'occupent de ces problèmes (nous ne les connaissons pas), mais nous, en tant que débutants du génie de la construction de jeux, pouvons également faire quelque chose. Par exemple, allez quelque part surfreemusicarchive.org ou soundcloud.com (ou même YouTube) et trouvez quelque chose à votre goût. Pour les ordinateurs de bureau, l'ambiant est un bon choix - une musique douce et douce sans mélodie prononcée, bien adaptée pour créer un arrière-plan. Faites attention à la licence: même la musique gratuite est parfois écrite par des compositeurs talentueux qui méritent, sinon une récompense monétaire, au moins une reconnaissance universelle.

Créons une autre énumération:

 enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, } 

De même, nous définissons l'interface et son implémentation par défaut.

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

Veuillez noter que dans ce cas, deux méthodes sont nécessaires: l'une pour démarrer la lecture, l'autre pour l'arrêter. Il est également tout à fait possible que des méthodes supplémentaires (pause / reprise, rembobinage, etc.) soient utiles à l'avenir, mais jusqu'à présent, ces deux sont suffisantes.

Passer des références aux classes de joueurs entre les objets à chaque fois peut ne pas sembler une solution très pratique. À un moment donné , il faut qu'un seul joueur de ekzepmlyar, donc je me permets de suggérer de faire tout le nécessaire pour jouer des sons et des méthodes de musique dans un objet distinct et en faire un loner (singleton). Ainsi, le sous-système audio responsable est toujours disponible de n'importe où dans l'application sans transmettre constamment de liens vers la même instance. Cela ressemblera à ceci:

Diagramme de classe du système de lecture audio


La classe Audioest notre singleton. Il fournit une seule façade au sous-système ... à propos, voici la façade (façade) - un autre modèle de conception, soigneusement conçu et décrit à plusieurs reprises (avec des exemples) sur ceux de votre Internet. Par conséquent, après avoir déjà entendu des cris insatisfaits des rangées arrière, j'arrête d'expliquer les choses connues depuis longtemps et je continue. Le code est:

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

Il suffit de l'appeler init()une seule fois quelque part au tout début (en l'initialisant avec les objets nécessaires) et d'utiliser à l'avenir des méthodes pratiques, en oubliant complètement les détails de l'implémentation. Même si vous ne le faites pas, ne vous inquiétez pas, le système va mourir - l'objet sera initialisé par les classes par défaut.

C’est tout.Reste à gérer la lecture réelle. En ce qui concerne la lecture de sons (ou, comme disent les gens intelligents, les échantillons ), Java a une classe AudioSystemet une interface pratiques Clip. Tout ce dont nous avons besoin est de définir correctement le chemin d'accès au fichier audio (qui se trouve dans notre chemin de classe, vous vous souvenez?):

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

La méthode open()peut le jeter IOException(surtout s'il n'aimait pas le format de fichier avec quelque chose - dans ce cas, je recommande d'ouvrir le fichier dans un éditeur audio et de le ré-enregistrer), donc ce serait bien de l'envelopper dans un bloc try-catch, mais au début, nous ne le ferons pas pour que l'application soit bruyante plantait à chaque fois avec des problèmes de son.

"Je ne sais même pas quoi dire ..."

Les choses sont bien pires avec la musique. Pour autant que je sache, il n'y a pas de méthode standard pour lire des fichiers musicaux (par exemple, au format mp3) en Java, donc dans tous les cas vous devrez utiliser une bibliothèque tierce (il y en a des dizaines de différentes). Tout poids léger avec une fonctionnalité minimale nous convient, par exemple, le JLayer plutôt populaire . Ajoutez-le en fonction:

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

Et nous implémentons notre lecteur avec son aide.

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

Tout d'abord, cette bibliothèque effectue la lecture de manière synchrone, bloquant le flux principal jusqu'à la fin du fichier. Par conséquent, nous devons implémenter un thread séparé ( PlayerThread), et le rendre «facultatif» (démon), afin qu'en aucun cas il n'interfère avec l'application pour se terminer plus tôt. Deuxièmement, l'identifiant du fichier musical en cours de lecture ( currentMusic) est stocké dans le code du lecteur . Si une deuxième commande vient soudainement de la jouer, nous ne commencerons pas la lecture depuis le tout début. Troisièmement, à la fin du fichier musical, sa lecture recommencera - et ainsi de suite jusqu'à ce que le flux soit explicitement arrêté par la commandefinish()(ou jusqu'à ce que d'autres discussions soient terminées, comme déjà mentionné). Quatrièmement, bien que le code ci-dessus regorge de commandes et d'indicateurs apparemment inutiles, il est soigneusement débogué et testé - le lecteur fonctionne comme prévu, ne ralentit pas le système, n'interrompt pas soudainement à mi-chemin, n'entraîne pas de fuites de mémoire, ne contient pas d'objets génétiquement modifiés, brille fraîcheur et pureté. Prenez-le et utilisez-le avec audace dans vos projets.

Étape douze. Localisation


Notre jeu est presque prêt, mais personne ne le jouera. Pourquoi?

"Il n'y a pas de russe! .. Il n'y a pas de russe! .. Ajoutez la langue russe! .. Développé par des chiens!"

Ouvrez la page de tout jeu d'histoire intéressant (en particulier mobile) sur le site Web du magasin et lisez les avis. Vont-ils commencer à faire l'éloge de superbes graphismes dessinés à la main? Ou admirer le son atmosphérique? Ou discuter d'une histoire passionnante qui crée une dépendance dès la première minute et ne lâche pas jusqu'à la fin?

Non.Les "joueurs" insatisfaits instruiront un tas d'unités et supprimeront généralement le jeu. Et puis ils auront également besoin d'argent - et tout cela pour une raison simple. Oui, vous avez oublié de traduire votre chef-d'œuvre dans les 95 langues du monde. Ou plutôt celui dont les porteurs crient le plus fort. Et c'est tout! Tu comprends?Des mois de dur labeur, de longues nuits blanches, des dépressions nerveuses constantes - tout cela est un hamster sous la queue. Vous avez perdu un grand nombre de joueurs et cela ne peut pas être résolu.

Alors, pensez-y. Décidez de votre public cible, sélectionnez plusieurs langues principales, commandez des services de traduction ... en général, faites tout ce que les autres ont décrit plus d'une fois dans des articles thématiques (plus intelligent que moi). Nous nous concentrerons sur le côté technique du problème et discuterons de la façon de localiser sans douleur notre produit.

D'abord, nous entrons dans les modèles. Rappelez-vous, avant que les noms et les descriptions ne soient stockés aussi simplement String? Maintenant ça ne marchera pas. En plus de la langue par défaut, vous devez également fournir une traduction dans toutes les langues que vous prévoyez de prendre en charge. Par exemple, comme ceci:

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

Pour les modèles, cette approche est tout à fait appropriée. Si vous ne souhaitez pas spécifier de traduction pour une langue, vous n'avez pas besoin de le faire - il y a toujours une valeur par défaut. Cependant, dans les objets finaux, je ne voudrais pas étendre les lignes dans plusieurs champs différents. Par conséquent, nous en laisserons un, mais remplacerons son 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() } } 

Et corrigez le code du générateur en conséquence.

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

Naturellement, la même approche doit être appliquée aux types de modèles restants. Lorsque les modifications sont prêtes, elles peuvent être utilisées sans difficulté.

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

Dans notre exemple, nous avons fourni une version simplifiée de la localisation, où seule la langue est prise en compte. En général, les objets de classe Localedéfinissent également le pays et la région. Si cela est important dans votre application, alors le vôtre LocalizedStringsera un peu différent, mais nous en sommes satisfaits de toute façon.

Nous avons traité des modèles, il reste à localiser les lignes de service utilisées dans notre application. Heureusement, il ResourceBundlecontient déjà tous les mécanismes nécessaires. Il suffit de préparer des fichiers avec des traductions et de changer la façon dont ils sont téléchargés.

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

Je ne dirai pas pour mémoire: écrire des phrases en russe est beaucoup plus difficile qu'en anglais. S'il est nécessaire d'utiliser un nom dans un cas définitif ou de se désengager du sexe (et ces exigences seront nécessairement maintenues), vous devrez beaucoup transpirer avant d'obtenir un résultat qui, d'une part, répond aux exigences, et d'autre part, ne ressemble pas à une traduction mécanique faite par un cyborg avec des cerveaux de poulet. Notez également que nous ne modifions pas les touches d'action - comme auparavant, les mêmes caractères seront utilisés pour exécuter cette dernière que dans la langue anglaise (qui, en passant, ne fonctionnera pas dans une disposition de clavier autre que la latine, mais ce n'est pas notre affaire - pour l'instant laissons tel quel).

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" } 
.
Comme déjà mentionné, ResourceBundleil se chargera lui-même de trouver parmi les fichiers de localisation celui qui correspond le mieux aux paramètres régionaux actuels. Et s'il ne le trouve pas, il prendra le fichier par défaut ( string.properties). Et tout ira bien ...

Ouais! Ça y était!
, 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 - .

Pour tester le fonctionnement de l'application dans différentes langues, il n'est pas nécessaire de modifier les paramètres du système d'exploitation - il suffit de spécifier la langue requise lors du démarrage du JRE:

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

Si vous travaillez toujours sur Windows, attendez-vous à des problèmes
, 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)

Après toutes nos aventures passionnantes, le résultat peut être fièrement démontré au grand public et déclaré haut et fort: "Nous ne sommes pas des chiens!"

Option fidèle raciale


Et c'est bien.

Étape treize Tout mettre ensemble


Les lecteurs attentifs ont dû remarquer que je n'ai mentionné les noms de packages spécifiques qu'une seule fois et ne leur suis jamais revenu. Premièrement, chaque développeur a ses propres considérations concernant la classe qui doit se trouver dans quel package. Deuxièmement, au fur et à mesure que vous travaillez sur le projet, avec l'ajout de plus en plus de nouvelles classes, vos pensées vont changer. Troisièmement, changer la structure de l'application est simple et bon marché (et les systèmes de contrôle de version modernes détecteront la migration, vous ne perdrez donc pas l'historique), alors n'hésitez pas à changer les noms des classes, des packages, des méthodes et des variables - n'oubliez pas de ne mettre à jour que la documentation (vous la gardez non?).

Et il ne nous reste plus qu'à monter et lancer notre projet. Comme vous vous en souvenez, main()nous avons déjà créé une méthode , nous allons maintenant la remplir de contenu. Nous aurons besoin de:

  • scénario et terrain;
  • Les héros
  • mise en œuvre de l'interface GameInteractor;
  • mise en place d'interfaces GameRendereret StringLoader;
  • mise en place d'interfaces SoundPlayeret MusicPlayer;
  • objet de classe Game;
  • une bouteille de champagne.

C'est parti!

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

Nous lançons et apprécions le premier prototype fonctionnel. Voilà.

Étape quatorze. Équilibre du jeu


Ummm ...

Étape quinze. Les tests


Maintenant que la majeure partie du code du premier prototype fonctionnel a été écrite, il serait bien d'ajouter quelques tests unitaires ...

"Comment? Tout à l'heure? Oui, les tests devaient être écrits au tout début, puis coder! »

De nombreux lecteurs remarquent à juste titre que l'écriture de tests unitaires devrait précéder le développement du code de travail ( TDDet autres méthodologies à la mode). D'autres seront indignés: il n'y a rien pour que les gens trompent leur cerveau avec leurs tests, même si au moins ils commencent à développer quelque chose, sinon toute motivation sera perdue. Un autre couple de personnes sortira de l'espace dans la plinthe et dira timidement: "Je ne comprends pas pourquoi ces tests sont nécessaires - tout fonctionne pour moi" ... Ensuite, ils seront poussés au visage avec une botte et rapidement repoussés. Je ne commencerai pas à initier des confrontations idéologiques (ils en sont déjà pleins sur Internet), et donc je suis partiellement d'accord avec tout le monde. Oui, les tests sont parfois utiles (en particulier dans le code qui change souvent ou est associé à des calculs complexes), oui, les tests unitaires ne conviennent pas à tous les codes (par exemple, ils ne couvrent pas les interactions avec l'utilisateur ou les systèmes externes), oui, il y a plus que des tests unitaires de nombreux autres types (enfin, au moins cinq ont été nommés),et oui, nous ne nous concentrerons pas sur l'écriture de tests - notre article porte sur autre chose.

Disons simplement que de nombreux programmeurs (en particulier les débutants) négligent les tests. Beaucoup se justifient en disant que la fonctionnalité de leurs applications est mal couverte par les tests. Par exemple, il est beaucoup plus facile de lancer l'application et de voir si tout est en ordre avec l'apparence et l'interaction, plutôt que de clôturer des constructions complexes avec la participation de cadres spécialisés pour tester l'interface utilisateur (et il y en a). Et je vais vous dire quand j'implémentais les interfaces Renderer- c'est ce que j'ai fait. Cependant, il existe des méthodes parmi notre code pour lesquelles le concept de test unitaire est génial.

Par exemple, les générateurs. Et c'est tout. C'est une boîte noire idéale: des modèles sont envoyés en entrée, des objets du monde du jeu sont obtenus en sortie. Il se passe quelque chose à l'intérieur, mais nous devons le tester. Par exemple, comme ceci:

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

Ou alors:

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

Ou même comme ça:

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

"Arrête, arrête, arrête!" Qu'est-ce que c'est? Java ??? "

Tu l'as. De plus, il est bon d'écrire de tels tests au début, avant de commencer à implémenter le générateur lui-même. Bien sûr, le code sous test est assez simple et très probablement la méthode fonctionnera la première fois et sans aucun test, mais écrire un test une fois que vous l'oublierez pour toujours vous protégera de tout problème possible à l'avenir (dont la solution prend beaucoup de temps, surtout quand à partir du moment du développement cinq ans se sont écoulés et vous avez déjà oublié comment tout fonctionne à l'intérieur de la méthode). Et si un jour votre projet cesse de collecter en raison d'échecs de tests, vous en connaîtrez certainement la raison: les exigences du système ont changé et vos anciens tests ne les satisfont plus (à quoi avez-vous pensé?).

Et encore une chose. N'oubliez pas la classeHandMaskRuleet ses héritiers? Imaginez maintenant qu'à un moment donné pour utiliser la compétence, le héros doit prendre trois dés de sa main, et les types de ces dés sont occupés par des restrictions sévères (par exemple, "le premier dé doit être bleu, vert ou blanc, le second - jaune, blanc ou bleu, et le troisième - bleu ou violet "- ressentez-vous la difficulté?). Comment aborder l'implémentation de classe? Eh bien ... pour commencer, vous pouvez décider des paramètres d'entrée et de sortie. De toute évidence, vous devez que la classe accepte trois tableaux (ou ensembles), chacun contenant des types valides pour, respectivement, les premier, deuxième et troisième cubes. Et puis quoi? Busting? Des récursions? Et si je manque quelque chose? Faites une entrée profonde. Reportez maintenant l'implémentation des méthodes de classe et écrivez un test - car les exigences sont simples, compréhensibles et bien formalisables.Et mieux écrire quelques tests ... Mais nous en considérerons un, ici par exemple:

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

C'est fatigant, mais pas autant qu'il n'y paraît, jusqu'à ce que vous commenciez (à un moment donné, cela devient même amusant). Mais après avoir passé un tel test (et quelques autres, pour différentes occasions), vous vous sentirez soudainement calme et sûr de vous. Maintenant, aucune petite faute de frappe ne gâchera votre méthode et ne vous conduira à de mauvaises surprises qui seront beaucoup plus difficiles à tester manuellement. Petit à petit, lentement, nous commençons à mettre en œuvre les méthodes nécessaires de la classe. Et à la fin, nous exécutons le test pour nous assurer que quelque part nous avons fait une erreur. Trouvez le problème et réécrivez. Répétez jusqu'à ce que cela soit fait.

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

Si vous avez des idées sur la façon de mettre en œuvre une telle fonctionnalité plus facilement, vous êtes invités à commenter. Et je suis incroyablement heureux d'avoir été assez intelligent pour commencer à implémenter cette classe en écrivant un test.

«Et je <...> suis aussi <...> très <...> content <...>. Montez! <...> de retour! <...> dans l'écart! "

Étape seize. Modularité


Comme prévu, les enfants arrivés à maturité ne peuvent pas être à l'abri de leurs parents toute leur vie - tôt ou tard, ils doivent choisir leur propre chemin et le suivre avec audace, surmontant les difficultés et les perturbations. Les composants que nous avons développés ont tellement mûri qu'ils sont devenus étroits sous un même toit. Le moment est venu de les diviser en plusieurs parties.

Nous sommes confrontés à une tâche plutôt banale. Il est nécessaire de diviser toutes les classes créées jusqu'à présent en trois groupes:

  • fonctionnalités de base: module, moteur de jeu, interfaces de connecteur et implémentations indépendantes de la plate-forme ( cœur );
  • modèles de scénarios, de terrain, d'ennemis et d'obstacles - composants de la soi-disant «aventure» ( aventure );
  • implémentations spécifiques d'interfaces spécifiques à une plateforme particulière: dans notre cas, une application console ( cli ).

Le résultat de cette séparation ressemblera finalement au schéma suivant:

Comme les acteurs à la fin du spectacle, les héros de notre aujourd'hui réintègrent la scène en force


Créez des projets supplémentaires et transférez la classe correspondante. Et nous avons juste besoin de configurer correctement l'interaction des projets entre eux. Projet de

base
Ce projet est un pur moteur. Toutes les classes spécifiques ont été transférées vers d'autres projets - seule la fonctionnalité de base, le noyau, est restée. Bibliothèque si vous voulez. Il n'y a plus de classe de lancement, il n'est même plus nécessaire de construire un package. Les assemblys de ce projet seront hébergés dans le référentiel Maven local (plus à ce sujet plus tard) et utilisés par d'autres projets comme dépendances.

Le fichier pom.xmlest le suivant:

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

A partir de maintenant, nous le collecterons comme ceci:

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

Projet Cli
Voici le point d'entrée de l'application - c'est avec ce projet que l'utilisateur final va interagir. Le noyau est utilisé comme dépendance. Étant donné que dans notre exemple, nous travaillons avec la console, le projet contiendra les classes nécessaires pour travailler avec elle (si nous voulons soudainement démarrer le jeu sur une cafetière, nous remplaçons simplement ce projet par un similaire avec les implémentations correspondantes). Nous ajouterons immédiatement des ressources (lignes, fichiers audio, etc.). Les dépendances sur les bibliothèques externes seront transférées dans le

fichier pom.xml:

 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-cli</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <scope>compile</scope> </dependency> <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <!-- other Kotlin setup --> </plugin> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project> 

Nous avons déjà vu le script pour construire et exécuter ce projet - nous ne commencerons pas à le répéter.

Aventure
Enfin, dans un projet séparé, nous sortons l'intrigue. Autrement dit, tous les scénarios, le terrain, les ennemis et autres objets uniques du monde du jeu que le personnel du département de scénario de votre entreprise peut imaginer (enfin, ou jusqu'à présent, seulement notre imagination malade - nous sommes toujours le seul concepteur de jeux dans la région). L'idée est de regrouper les scripts en ensembles (aventures) et de distribuer chacun de ces ensembles en tant que projet distinct (similaire à la façon dont cela se fait dans le monde des jeux de société et des jeux vidéo). Autrement dit, collectez les archives du pot et placez-les dans un dossier séparé afin que le moteur de jeu analyse ce dossier et connecte automatiquement toutes les aventures qu'il contient. Cependant, la mise en œuvre technique de cette approche se heurte à d'énormes difficultés.

Par où commencer? Eh bien, tout d'abord, du fait que nous distribuons des modèles sous la forme de classes java spécifiques (oui, battez-moi et grondez-moi - je l'avais prévu). Et si c'est le cas, ces classes doivent se trouver dans le chemin d'accès aux classes de l'application au démarrage. L'application de cette exigence n'est pas difficile - vous enregistrez explicitement vos fichiers jar dans la variable d'environnement appropriée (à partir de Java 6, vous pouvez même utiliser * - des caractères génériques ).

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

«Un idiot, ou quoi? Lorsque vous utilisez le commutateur -jar, le commutateur -classpath est ignoré! »

Cependant, cela ne fonctionnera pas. Le chemin de classe pour les archives jar exécutables doit être explicitement écrit dans le fichier interne META-INF/MANIFEST.MF(la section est appelée - Claspath:). C'est bon, il y a même des plugins spéciaux pour cela ( maven-compiler-plugin ou, au pire, maven-assembly-plugin ). Mais les caractères génériques dans le manifeste, hélas, ne fonctionnent pas - vous devrez spécifier explicitement les noms des fichiers jar dépendants. Autrement dit, les connaître à l'avance, ce qui dans notre cas est problématique.

Et de toute façon, je ne voulais pas ça. Je voulais que le projet ne doive pas être recompilé. Vers le dossieradventures/vous pouvez lancer un nombre illimité d'aventures, de sorte que toutes soient visibles par le moteur de jeu pendant l'exécution. Malheureusement, la fonctionnalité apparemment évidente va au-delà des représentations standard du monde Java. Ce n'est donc pas le bienvenu. Une approche différente doit être adoptée pour diffuser l'aventure indépendante. Lequel? Je ne sais pas, écrivez dans les commentaires - c'est sûr que quelqu'un a des idées intelligentes.

En attendant, il n'y a pas d'idées, voici une petite (ou grande, selon votre apparence) qui vous permet d'ajouter dynamiquement des dépendances au chemin de classe sans même connaître leurs noms et sans avoir à recompiler le projet:

Sous 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 

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

Et voici l'astuce. Au lieu d'utiliser la clé, -jarnous ajoutons le projet Cli au chemin de classe et spécifions explicitement la classe qu'il contient comme point d'entrée MainKt. De plus, ici, nous connectons toutes les archives du dossier adventures/.

Inutile d'indiquer une fois de plus à quel point cette décision tordue est - je le sais moi-même, merci. Mieux vaut suggérer vos idées dans les commentaires. Je vous en prie . (ಥ﹏ಥ)

Étape dix-sept. Terrain


Un peu de paroles.
Notre article concerne le côté technique du flux de travail, mais les jeux ne sont pas seulement du code logiciel. Ce sont des mondes passionnants avec des événements intéressants et des personnages vivants, dans lesquels vous plongez avec votre tête, renonçant au monde réel. Chacun de ces mondes est inhabituel à sa manière et intéressant à sa manière, dont beaucoup vous vous souvenez encore, après de nombreuses années. Si vous voulez que votre monde se souvienne également de sentiments chaleureux, rendez-le inhabituel et intéressant.

Je sais que nous sommes des programmeurs ici, pas des scénaristes, mais nous avons quelques idées de base sur la composante narrative du genre de jeu (les joueurs expérimentés, non?). Comme dans tout livre, l'histoire doit avoir un œil (dans lequel nous décrivons progressivement le problème auquel sont confrontés les héros), le développement, deux ou trois virages intéressants, un point culminant (le moment le plus aigu de l'intrigue, lorsque les lecteurs se figent d'excitation et oublient de respirer) et le dénouement (dans quels événements arrivent progressivement à leur conclusion logique). Évitez l'euphémisme, l'inutilité logique et les trous de tracé - toutes les lignes commencées devraient arriver à une conclusion adéquate.

Eh bien, lisons notre histoire aux autres - un regard impartial de côté aide très souvent à comprendre les défauts et à les corriger à temps.

L'intrigue du jeu
, , . , : ( ) ( ), . , .

— , . , , .

, , - . , , , , . .

Heureusement, je ne suis pas Tolkien, je n'ai pas travaillé le monde du jeu avec trop de détails, mais j'ai essayé de le rendre suffisamment intéressant et, surtout, logiquement justifié. En même temps, il s'est permis d'introduire des ambiguïtés, que chaque joueur est libre d'interpréter à sa manière. Par exemple, il ne s'est concentré nulle part sur le niveau de développement technologique du monde décrit: le système féodal et les institutions démocratiques modernes, les tyrans pervers et les groupes criminels organisés, le but le plus élevé et la survie courante, les trajets en bus et les combats dans les tavernes - même les personnages tirent pour une raison quelconque: des arcs / arbalètes ou des fusils d'assaut. Dans le monde, il y a un semblant de magie (sa présence ajoute un gameplay aux capacités tactiques) et des éléments de mysticisme (juste pour être).

Je voulais m'éloigner des clichés de l'intrigue et des biens de consommation fantastiques - tous ces elfes, gnomes, dragons, seigneurs noirs et mal du monde absolu (ainsi que: des héros sélectionnés, des prophéties anciennes, des super-artefacts, des batailles épiques ... bien que ces derniers puissent être laissés). Je voulais aussi vraiment rendre le monde vivant, afin que chaque personnage rencontré (même mineur) ait sa propre histoire et sa motivation, que les éléments de la mécanique du jeu s'inscrivent dans les lois du monde, que le développement des héros se déroule naturellement, que la présence d'ennemis et d'obstacles dans les lieux soit logiquement justifiée par les caractéristiques de l'emplacement lui-même ... et ainsi de suite. Malheureusement, ce désir a joué une plaisanterie cruelle, ralentissant beaucoup le processus de développement, et il n'était pas toujours possible de s'écarter des conventions de jeu. Néanmoins, la satisfaction du produit final s'est avérée être d'un ordre de grandeur supérieur.

Qu'est-ce que je veux dire avec tout ça? Un complot bien pensé peut ne pas être si nécessaire, mais votre jeu ne souffrira pas de sa présence: dans le meilleur des cas, les joueurs l'apprécieront, dans le pire des cas, ils l'ignoreront simplement. Et ceux qui sont particulièrement enthousiastes pardonneront même à votre jeu certains défauts fonctionnels, juste pour découvrir comment l'histoire se termine.

Et ensuite?


La programmation se termine et la conception du jeu commence . Il est maintenant temps de ne pas écrire le code, mais de réfléchir à des scénarios, des emplacements, des ennemis - vous comprenez, toute cette lie. Si vous travaillez toujours seul, je vous félicite - vous avez atteint le stade où la plupart des projets de jeu se précipitent. Dans les grands studios AAA, des personnes spéciales travaillent en tant que concepteurs et scénaristes qui reçoivent de l'argent pour cela - ils n'ont tout simplement nulle part où aller. Mais nous avons beaucoup d'options: aller se promener, manger, dormir de façon banale - mais ce qui peut être fait là-bas, même pour démarrer un nouveau projet, en utilisant l'expérience et les connaissances accumulées.

Si vous êtes toujours là et que vous voulez continuer à tout prix, alors préparez-vous aux difficultés. Manque de temps, paresse, manque d'inspiration créative - quelque chose vous distraira constamment. Il n'est pas facile de surmonter tous ces obstacles (encore une fois, de nombreux articles ont été écrits sur ce sujet), mais c'est possible. Tout d'abord, je vous conseille de planifier soigneusement le développement futur du projet. Heureusement, nous travaillons pour notre plaisir, les éditeurs ne nous poussent pas, personne n'exige le respect de délais précis - ce qui signifie qu'il est possible d'aller au point sans hâte inutile. Faites une «feuille de route» du projet, déterminez les principales étapes et (si vous avez le courage) des termes approximatifs pour leur mise en œuvre. Procurez-vous un cahier (vous pouvez l'électronique) et notez constamment les idées qui en découlent (même en vous réveillant soudainement au milieu de la nuit).Marquez vos progrès avec des tableaux (par exemple, tels ) ou d'autres appareils et accessoires fonctionnels. Démarrez la documentation: à la fois externe, publique ( wiki, par exemple ) pour la future énorme communauté de fans, et interne, pour vous-même (je ne partagerai pas le lien) - croyez-moi, sans elle après une pause d'un mois, vous ne vous souviendrez pas exactement de quoi et comment vous l'avez fait. En général, écrivez autant d'informations que possible sur votre jeu, n'oubliez pas d'écrire le jeu lui-même. J'ai proposé des options de base, mais je ne donne pas de conseils spécifiques - chacun décide par lui-même comment il est plus pratique pour lui d'organiser son processus de travail.

"Mais encore, vous ne voulez pas parler de l'équilibre du jeu?"

Préparez-vous immédiatement au fait que la création du jeu parfait la première fois ne fonctionnera pas. Un prototype fonctionnel est bon - au début, il montrera la viabilité du projet, vous convaincra ou vous décevra et donnera une réponse à une question très importante: «cela vaut-il la peine de continuer?». Cependant, il ne répondra pas à beaucoup d'autres questions, dont la principale, probablement: "sera-t-il intéressant de jouer mon jeu sur le long terme?" Il existe un grand nombre de théories et d'articles (enfin, encore une fois) sur ce sujet. Un jeu intéressant devrait être modérément difficile, car un jeu trop simple ne pose pas de défi au joueur. D'un autre côté, si la complexité est prohibitive, seuls les joueurs hardcore têtus ou les personnes qui essaient de prouver quelque chose à quelqu'un resteront du public du jeu. Le jeu devrait être assez diversifié, idéalement - fournir plusieurs options pour atteindre l'objectif,afin que chaque joueur choisisse une option à son goût. Une stratégie de dépassement ne devrait pas dominer les autres, sinon ils ne l'utiliseront que ... Et ainsi de suite.

En d'autres termes, le jeu doit être équilibré. Cela est particulièrement vrai du jeu de société, où les règles sont clairement formalisées. Comment faire Je n'en ai aucune idée. Si vous n'avez pas d'ami mathématicien capable de créer un modèle mathématique (je l'ai vu, ils le font) et que vous-même n'y comprenez rien (et nous ne comprenons pas), alors la seule issue est de s'appuyer sur l'intuition du test de jeu. Jouez d'abord le jeu vous-même. Lorsque vous êtes fatigué - offrez de jouer à votre femme. Après le divorce, invitez d'autres parents, amis, connaissances, personnes au hasard dans la rue à jouer. Lorsque vous vous retrouvez complètement seul, téléchargez l'assembly sur Internet. Les gens seront intéressés, voudront jouer, et vous leur répondrez: "retour d'information de votre part!". Peut-être que quelqu'un aimera votre rêve de la même manière que vous et voudra travailler avec vous - de cette façon, vous trouverez des personnes partageant les mêmes idées ou au moins un groupe de soutien (pourquoi pensez-vous que j'ai écrit cet article?) (Hehe).

Blague à part, je nous souhaite ... à vous tous de réussir. En savoir plus (qui aurait pensé!) - sur la conception de jeux et plus encore. Toutes les questions que nous avons examinées ont déjà été abordées d'une manière ou d'une autre dans des articles et de la littérature (bien que, si vous êtes toujours là, il est évidemment inutile de vous inciter à lire). Partagez vos impressions, communiquez sur les forums - en général, vous me connaissez de mieux en mieux. Ne soyez pas paresseux et vous réussirez.

Sur cette note optimiste, permettez-moi de prendre votre congé. Merci à tous pour votre attention. A très bientôt!

«Eh! Qui vous voyez? Comment lancer maintenant tout cela sur un téléphone portable? Ai-je attendu en vain, ou quoi? "

Postface. Android


Pour décrire l'intégration de notre moteur de jeu avec la plate-forme Android, laissons la classe tranquille Gameet considérons une classe similaire, mais beaucoup plus simple MainMenu. Comme son nom l'indique, il est destiné à implémenter le menu principal de l'application et, en fait, est la première classe avec laquelle l'utilisateur commence à interagir.

Dans l'interface de la console, cela ressemble à ceci


Comme une classe Game, il définit une boucle infinie, à chaque itération dont un écran est dessiné et une commande est demandée à l'utilisateur. Seulement, il n'y a pas de logique compliquée ici et ces commandes sont beaucoup plus petites. Nous mettons en œuvre essentiellement une chose - «Quitter».

Tableau d'activités pour le menu principal


Facile, non? À ce sujet et discours. Le code est également un ordre de grandeur plus simple.

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

L'interaction avec l'utilisateur est mise en œuvre à l'aide d'interfaces MenuRendereret MenuInteractorfonctionne de manière similaire à ce qui a été vu précédemment.

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

Comme vous l'avez déjà compris, nous avons sciemment séparé les interfaces des implémentations spécifiques. Il ne nous reste plus qu'à remplacer le projet Cli par un nouveau projet (appelons-le Droid ), en ajoutant une dépendance au projet Core . Faisons-le.

Exécutez Android Studio (généralement des projets pour Android y sont développés), créez un projet simple, supprimant tous les guirlandes standard inutiles et ne laissant que la prise en charge du langage Kotlin. Nous ajoutons également une dépendance sur le projet Core , qui est stocké dans le référentiel Maven local de notre machine.

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

Par défaut, cependant, personne ne verra notre dépendance - vous devez explicitement indiquer la nécessité d'utiliser un référentiel local (mavenLocal) lors de la construction du projet.

 buildscript { ext.kotlin_version = '1.3.20' repositories { google() jcenter() mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:3.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() mavenLocal() } } 

, , — . , , : SoundPlayer , MusicPlayer , MenuInteractor ( GameInteractor ), MenuRenderer ( GameRenderer ) StringLoader , , . , .

(, , ) Android — Canvas . - View- ce sera notre «toile». Avec la saisie, c'est un peu plus compliqué, car nous n'avons plus de clavier, et l'interface doit être conçue de telle sorte que la saisie par l'utilisateur sur certaines parties de l'écran soit considérée comme une entrée de commandes. Pour ce faire, nous utiliserons le même héritier View- de cette manière, il agira en tant qu'intermédiaire entre l'utilisateur et le moteur de jeu (similaire à la façon dont la console système a agi en tant qu'intermédiaire).

Créons l'activité principale de notre vue et écrivons-la dans le manifeste.

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

Nous fixons l'activité en orientation paysage - comme dans le cas de la plupart des autres jeux, nous ne serons pas en mesure de portrait portrait. De plus, nous allons l'étendre à tout l'écran de l'appareil, en prescrivant le thème principal en conséquence.

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

Et depuis que nous sommes entrés dans les ressources, nous transférons les chaînes localisées dont nous avons besoin du projet Cli , en les amenant au format souhaité:

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

Ainsi que les fichiers de sons et de musique utilisés dans le menu principal (un de chaque type), en les plaçant respectivement dans /assets/sound/leave.wavet /assets/music/menu_main.mp3.

Lorsque nous avons trié les ressources, il était temps de passer à la conception (oui, encore une fois). Contrairement à la console, la plate-forme Android possède ses propres caractéristiques architecturales, ce qui nous oblige à utiliser des approches et des méthodes spécifiques.

Diagramme de classe et d'interface


Attendez, ne vous évanouissez pas, maintenant je vais tout expliquer en détail.

Nous commencerons peut-être par le plus difficile - la classe DiceSurface- l'héritier même Viewqui est appelé à lier ensemble les parties indépendantes de notre système (si vous le souhaitez, vous pouvez l'hériter de la classe SurfaceView- ou même GlSurfaceView- et dessiner dans un thread séparé, mais nous avons un jeu au tour par tour, pauvre en animation , qui ne nécessite pas de sortie graphique complexe, nous ne le compliquerons donc pas). Comme mentionné précédemment, sa mise en œuvre résoudra deux problèmes à la fois: la sortie d'image et le traitement des clics, chacun ayant ses propres difficultés inattendues. Considérons-les dans l'ordre.

Lorsque nous avons peint sur la console, notre moteur de rendu a envoyé des commandes de sortie et formé une image à l'écran. Dans le cas d'Android, la situation est inverse - le rendu est initié par la vue elle-même, qui au moment où la méthode onDraw()est exécutée devrait déjà savoir quoi, comment et où dessiner. Mais qu'en est-il de la méthode d' drawMainMenu()interface MainMenu? Ne contrôle-t-il pas la sortie maintenant?

Essayons de résoudre ce problème à l'aide d'interfaces fonctionnelles. La classe DiceSurfacecontiendra un paramètre spécial instructions- en fait, un bloc de code qui doit être exécuté à chaque appel de la méthode onDraw(). Le rendu, en utilisant une méthode publique, indiquera quelles instructions spécifiques doivent être suivies. Si quelqu'un est intéressé, le modèle utilisé est appelé une stratégie. Cela ressemble à ceci:

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

Autrement dit, toutes les fonctionnalités graphiques sont toujours dans la classe Renderer, mais cette fois nous n'exécutons pas directement les commandes, mais les préparons pour l'exécution par notre vue. Faites attention au type de propriété instructions- vous pouvez créer une interface distincte et appeler sa seule méthode, mais Kotlin peut réduire considérablement la quantité de code.

Maintenant sur Interactor. Auparavant, la saisie des données se faisait de manière synchrone: lorsque nous demandions des données à la console (clavier), l'application (cycles) était suspendue jusqu'à ce que l'utilisateur appuie sur une touche. Avec Android, une telle astuce ne fonctionnera pas - elle a son propre Looper, dont le travail ne doit en aucun cas être perturbé, ce qui signifie que l'entrée doit être asynchrone. C'est-à-dire que les méthodes d'interface Interactor mettent toujours le moteur en pause et attendent les commandes, tandis que Activity et toute sa vue continuent de fonctionner jusqu'à ce que tôt ou tard ils envoient cette commande.

Cette approche est assez simple à implémenter à l'aide d'une interface standard BlockingQueue. La classe DroidMenuInteractorappellera la méthodetake(), ce qui suspendra l'exécution du flux de jeu jusqu'à ce que les éléments (instances de la classe familière Action) apparaissent dans la file d'attente . DiceSurface, à son tour, s'adaptera aux clics des utilisateurs (méthode de onTouchEvent()classe standard View), générera des objets et les ajoutera à la file d'attente par la méthode offer(). Cela ressemblera à ceci:

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

Autrement dit, Interactor appelle la méthode awaitAction()et s'il y a quelque chose dans la file d'attente, il traite la commande reçue. Faites attention à la façon dont les équipes sont ajoutées à la file d'attente. Étant donné que le flux d'interface utilisateur s'exécute en continu, l'utilisateur peut cliquer sur l'écran plusieurs fois de suite, ce qui peut entraîner des blocages, surtout si le moteur de jeu n'est pas prêt à accepter des commandes (par exemple, pendant les animations). Dans ce cas, l'augmentation de la capacité de la file d'attente et / ou la diminution de la valeur du délai d'attente seront utiles.

Bien sûr, nous transférons en quelque sorte les commandes, mais seulement la seule et la seule. Nous devons faire la distinction entre les coordonnées de la pression et, selon leurs valeurs, appeler telle ou telle commande. Cependant, c'est une malchance - Interactor n'a aucune idée où à quel endroit sur l'écran les boutons actifs sont dessinés - nous sommes responsables du rendu Renderer. Nous établirons leur interaction comme suit. La classe DiceSurfacestockera une collection spéciale - une liste de rectangles actifs (ou d'autres formes, si nous arrivons à ce point). Ces rectangles contiennent les coordonnées des sommets et du sommet lié Action. Le rendu générera ces rectangles et les ajoutera à la liste, la méthode onTouchEvent()déterminera quel rectangle a été pressé et ajoutera celui correspondant à la file d'attente 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) } 

La méthode check()consiste à vérifier les coordonnées spécifiées à l'intérieur du rectangle. Veuillez noter qu'au stade du travail de Renderer (et c'est exactement le moment où les rectangles sont créés), nous n'avons pas la moindre idée de la taille de la toile. Par conséquent, nous devrons stocker les coordonnées dans des valeurs relatives (pourcentage de la largeur ou de la hauteur de l'écran) avec des valeurs de 0 à 1 et recompter au moment de la pression. Cette approche n'est pas entièrement exacte, car elle ne prend pas en compte le rapport hauteur / largeur - à l'avenir, elle devra être refaite. Cependant, pour notre tâche éducative au début, cela suffira.

Nous allons implémenter DiceSurfaceun champ supplémentaire dans la classe , ajouter deux méthodes ( addRectangle()et clearRectangles()) pour le contrôler de l'extérieur (du côté du onTouchEvent()rendu ), et développer en forçant les coordonnées des rectangles à prendre en compte.

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

Une collection compétitive est utilisée pour stocker les rectangles - elle permettra d'éviter l'occurrence ConcurrentModificationExceptionsi l'ensemble est mis à jour et déplacé en même temps par différents threads (ce qui dans notre cas se produira).

Le code de classe DroidMenuInteractorrestera inchangé, mais il DroidMenuRendererchangera. Ajoutez quatre boutons à l'affichage pour chaque élément ActionList. Placez-les sous le titre DICE, répartis uniformément sur toute la largeur de l'écran. Eh bien, n'oublions pas les rectangles actifs.

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

Ici, nous sommes revenus à nouveau à l'interface StringLoaderet aux capacités de la classe auxiliaire StringLoadHelper(non représentées dans le diagramme). L'implémentation de la première a un nom ResourceStringLoaderet est engagée dans le chargement de chaînes localisées à partir (évidemment) des ressources d'application. Cependant, il le fait de manière dynamique, car nous ne connaissons pas les identifiants de ressource à l'avance - nous sommes obligés de les construire sur le pouce.

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

Reste à parler de sons et de musique. Il y a une merveilleuse classe dans Android MediaPlayerqui traite de ces choses. Il n'y a rien de mieux pour jouer de la musique:

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

Deux points. Tout d'abord, la méthode prepare()est exécutée de manière synchrone, ce qui avec une grande taille de fichier (en raison de la mise en mémoire tampon) suspendra le système. Il est recommandé de l'exécuter dans un thread séparé ou d'utiliser la méthode asynchrone prepareAsync()et OnPreparedListener. Deuxièmement, il serait bien d'associer la lecture au cycle de vie de l'activité (pause lorsque l'utilisateur minimise l'application et reprise lors de la récupération), mais nous ne l'avons pas fait. Ai-ai-ai ...

Il MediaPlayerconvient également aux sons , mais s'il y en a peu et qu'ils sont simples (comme dans notre cas), alors ça ira SoundPool. Son avantage est que lorsque les fichiers audio sont déjà chargés en mémoire, leur lecture démarre instantanément. L'inconvénient est évident - il n'y a peut-être pas assez de mémoire (mais assez pour nous, nous sommes modestes).

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

Lors de la création d'une classe, tous les sons de l'énumération Soundsont chargés dans le référentiel dans un flux séparé. Cette fois, nous n'utilisons pas de collection synchronisée, mais nous implémentons le mutex à l'aide de la classe standard ReentrantReadWriteLock.

Maintenant, enfin, nous aveuglons tous les composants ensemble à l'intérieur du nôtre MainActivity- ne l'avez pas oublié? Veuillez noter que MainMenu(et Gamepar la suite) doit être lancé dans un fil distinct.

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

En fait, c'est tout. Après tous les tourments, l'écran principal de notre application est tout simplement incroyable:

Le menu principal sur toute l'étendue de l'écran mobile


Eh bien, c'est-à-dire que cela semblera étonnant lorsqu'un artiste intelligent apparaîtra dans nos rangs, et avec son aide, cette misère sera complètement redessinée.

Liens utiles


Je sais, beaucoup ont défilé directement jusqu'à ce point. C'est bon - la plupart des lecteurs ont complètement fermé l'onglet. Pour ces unités qui ont néanmoins résisté à tout ce flux de bavardages incohérents - respect et respect, amour infini et gratitude. Eh bien et les liens, bien sûr, où sans eux. Tout d'abord, sur le code source des projets (gardez à l'esprit que l'état actuel des projets est très en avance sur celui considéré dans l'article):


Eh bien, tout à coup, quelqu'un aura envie de démarrer et de voir le projet, et de recueillir la paresse par lui-même, voici un lien vers la version de travail: LINK!

Ici, un lanceur pratique est utilisé pour lancer (vous pouvez écrire un article séparé sur sa création). Il utilise JavaFX et peut donc ne pas démarrer sur les machines avec OpenJDK (écriture et aide), mais élimine au moins la nécessité d'enregistrer manuellement les chemins d'accès aux fichiers. L'aide à l'installation est contenue dans le fichier readme.txt (vous vous en souvenez?). Téléchargez, regardez, utilisez et enfin je me tais.

Si vous êtes intéressé par un projet, ou un outil utilisé, ou une mécanique, ou une solution intéressante, ou, je ne sais pas, des jeux traditionnels, vous pouvez l'examiner plus en détail dans un article séparé. Si tu veux. Et si vous ne le souhaitez pas, envoyez simplement des commentaires, des regrets et des suggestions. Je serai ravi de parler.

Mes meilleurs vœux.

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


All Articles