Cómo crear un juego si nunca eres un artista


Hubo momentos en la vida de cada programador cuando soñaba con hacer un juego interesante. Muchos programadores realizan estos sueños, e incluso con éxito, pero no se trata de ellos. Se trata de aquellos a quienes les gusta jugar, que (incluso sin conocimiento y experiencia) trataron de crearlos una vez, inspirados por ejemplos de héroes solteros que alcanzaron fama mundial (y enormes ganancias), pero en el fondo entendieron que competir con el gurú igrostroya que no puede permitirse.

Y no ...

Pequeña introducción


Haré una reserva de inmediato: nuestro objetivo no es ganar dinero: hay muchos artículos sobre este tema en Habré. No, haremos un juego de ensueño.

Digresión lírica sobre el juego de los sueños
¿Cuántas veces he escuchado esta palabra de desarrolladores individuales y pequeños estudios? Donde quiera que mires, todos los jugadores principiantes tienen prisa por revelar sus sueños y su "visión perfecta" al mundo, y luego escriben largos artículos sobre sus heroicos esfuerzos, procesos de trabajo, dificultades financieras inevitables, problemas con los editores y en general "jugadores-ingratos-perros-im- "dar-gráfico-y-monedas-y-todo-gratis-y-pagar-no-querer-un-juego-piratas-y-hemos-perdido-ganancias-gracias a ellos-aquí".

Gente, no se dejen engañar. No estás haciendo un juego de ensueño, sino un juego que se venderá bien: estas son dos cosas diferentes. Los jugadores (y especialmente los sofisticados) no se preocupan por tu sueño y no lo pagarán. Si desea obtener ganancias: estudie tendencias, vea lo que es popular ahora, haga algo único, hágalo mejor, más inusual que otros, lea artículos (hay muchos), comuníquese con los editores, en general, haga realidad los sueños de los usuarios finales, no los suyos.

Si aún no se ha escapado y todavía quiere realizar el juego de sus sueños, renuncie a las ganancias por adelantado. No vendas tu sueño en absoluto: compártelo gratis. Dale a la gente tu sueño, tráelo, y si tu sueño vale algo, recibirás, si no dinero, amor y reconocimiento. Esto a veces es mucho más valioso.

Muchas personas piensan que los juegos son una pérdida de tiempo y energía, y que las personas serias no deberían hablar sobre este tema en absoluto. Pero las personas reunidas aquí no son serias, por lo que estamos de acuerdo solo en parte: los juegos realmente toman mucho tiempo si los juegas. Sin embargo, el desarrollo de juegos, aunque lleva muchas veces más tiempo, puede traer muchos beneficios. Por ejemplo, le permite familiarizarse con los principios, enfoques y algoritmos que no se encuentran en el desarrollo de aplicaciones que no son de juegos. O profundice las habilidades de poseer herramientas (por ejemplo, un lenguaje de programación), haciendo algo inusual y emocionante. Por mi cuenta, puedo agregar (y muchos estarán de acuerdo) que el desarrollo del juego (incluso sin éxito) siempre es una experiencia especial e incomparable, que luego recordarás con temor y amor, que quiero experimentar para cada desarrollador al menos una vez en mi vida.

No utilizaremos motores de juegos, frameworks y bibliotecas novedosos, veremos la esencia misma del juego y la sentiremos desde adentro. Renunciamos a metodologías de desarrollo flexibles (la tarea se simplifica por la necesidad de organizar el trabajo de una sola persona). No gastaremos tiempo y energía en buscar diseñadores, artistas, compositores y especialistas en sonido: haremos todo lo que podamos (pero al mismo tiempo haremos todo sabiamente), si de repente tenemos un artista, no haremos mucho esfuerzo para mantener la moda. gráficos en el marco terminado). Al final, ni siquiera estudiaremos realmente las herramientas y elegiremos la correcta, lo haremos en la que sabemos y sabemos cómo usar. Por ejemplo, en Java, para que luego, si es necesario, lo transfiera a Android (o a una cafetera).

"Ah !!! Horror! Una pesadilla! ¿Cómo puedes pasar tiempo con esas tonterías? ¡Sal de aquí, iré a leer algo más interesante!

¿Por qué hacer esto? Quiero decir, ¿reinventar la rueda? ¿Por qué no usar un motor de juego listo para usar? La respuesta es simple: no sabemos nada de él, pero queremos el juego ahora. Imagine la mentalidad del programador promedio: "¡Quiero hacer un juego! Habrá carne, explosiones y bombeo, y puedes robar un korovan , y la trama está bombardeando, ¡y esto nunca ha sucedido en ningún otro lugar! ¡Empezaré a escribir ahora mismo! ¿Y sobre qué? Veamos qué es popular entre nosotros ahora ... Sí, X, Y y Z. Tomemos Z, ahora todos escriben sobre eso ... ". Y comienza a estudiar el motor. Y arroja la idea, porque ya no hay suficiente tiempo para ello. Aleta O bien, no se rinde, pero sin realmente aprender el motor, se toma para el juego. Bueno, si tiene la conciencia de no mostrarle a nadie su primer "oficio". Por lo general, no (vaya a cualquier tienda de aplicaciones, compruébelo usted mismo): bueno, bueno, quiero ganancias, sin fuerzas para soportar. Una vez que la creación de juegos fue la gran cantidad de personas creativas entusiastas. Por desgracia, esta vez ha pasado irrevocablemente: ahora lo principal del juego no es el alma, sino el modelo de negocio (al menos hay muchas más conversaciones al respecto). Nuestro objetivo es simple: haremos juegos con el alma. Por lo tanto, hacemos un resumen de la herramienta (cualquiera lo hará) y nos enfocamos en la tarea.

Entonces, continuemos.
No entraré en los detalles de mi propia experiencia amarga, pero diré que uno de los principales problemas para un programador en el desarrollo de juegos son los gráficos. Los programadores generalmente no saben cómo dibujar (aunque hay excepciones), y los artistas generalmente no saben programar (aunque hay excepciones). Y sin gráficos, debes admitir, se pasa por alto un juego raro. Que hacer

Hay opciones:

1. Dibuje todo usted mismo en un editor gráfico simple

Capturas de pantalla del juego "Kill Him All", 2003

2. Dibuja todo tú mismo en un vector

Capturas de pantalla del juego "Raven", 2001


Capturas de pantalla del juego "Inferno", 2002

3. Pregúntale a un hermano que tampoco sabe dibujar (pero lo hace un poco mejor)

Capturas de pantalla del juego "Fucking", 2004

4. Descargue algún programa para modelado 3D y arrastre activos desde allí

Capturas de pantalla del juego "Fucking 2. Demo", 2006

5. En la desesperación, rasgándose el pelo en la cabeza


Capturas de pantalla del juego "Fucking", 2004

6. Dibuja todo tú mismo en pseudographics (ASCII)

Capturas de pantalla del juego "Fifa", 2000


Capturas de pantalla del juego "Sumo", 1998

Detengámonos en esto último (en parte porque no parece tan deprimente como el resto). Muchos jugadores inexpertos creen que los juegos sin gráficos modernos no pueden ganar los corazones de los jugadores, incluso el nombre del juego ni siquiera los convierte en juegos. Los desarrolladores de obras maestras como ADOM , NetHack y Dwarf Fortress objetan tácitamente tales argumentos. La apariencia no siempre es un factor decisivo, el uso de ASCII ofrece algunas ventajas interesantes:

  • En el proceso de desarrollo, el programador se enfoca en el juego, la mecánica del juego, el componente de la trama y más, sin distraerse con cosas menores;
  • el desarrollo de un componente gráfico no lleva demasiado tiempo: un prototipo funcional (es decir, una versión para jugar que puede comprender, pero vale la pena continuar) estará listo mucho antes;
  • no es necesario aprender frameworks y motores gráficos;
  • tus gráficos no se volverán obsoletos en los cinco años que desarrolles el juego;
  • los trabajadores hardcore podrán evaluar su producto incluso en plataformas que no tienen un entorno gráfico;
  • Si todo se hace correctamente, los gráficos geniales se pueden ajustar más tarde, más tarde.

La larga introducción anterior tenía la intención de ayudar a los novatos igrodelov a superar los miedos y los prejuicios, dejar de preocuparse y aún intentar hacer algo así. Estas listo Entonces comencemos.

Primer paso Idea


Como? ¿Aún no tienes idea?

Apague la computadora, vaya a comer, camine, haga ejercicio. O dormir, en el peor de los casos. Desarrollar un juego no es lavar ventanas; no se obtiene información sobre el proceso. Por lo general, la idea de un juego nace repentinamente, inesperadamente, cuando no piensas en ello en absoluto. Si esto sucediera de repente, toma un lápiz más rápido y escribe hasta que la idea desaparezca. Cualquier proceso creativo se implementa de esta manera.

Y puedes copiar los juegos de otras personas. Bueno, copia. Por supuesto, no desgarres descaradamente, diciendo en cada esquina lo inteligente que eres, sino que usa la experiencia de otros en tu producto. Cuánto después de esto quedará específicamente de tu sueño en él es una pregunta secundaria, porque a menudo los jugadores tienen esto: les gusta todo en el juego, excepto algunas dos o tres cosas molestas, pero si pudieran hacerlo de manera diferente ... Quién sabe quizás traer a la mente la buena idea de alguien es tu sueño.

Pero seguiremos el camino simple: supongamos que ya tenemos una idea y que no hemos pensado en ello durante mucho tiempo. Como nuestro primer proyecto grandioso, haremos un clon de un buen juego de Obsidian - Pathfinder Adventures .

¡Qué demonios es esto! ¿Alguna mesa?

Como dicen, pourquoi pas? Parece que ya hemos abandonado los prejuicios, por lo que audazmente comenzamos a refinar la idea. Naturalmente, no clonaremos el juego uno a uno, pero tomaremos prestadas las mecánicas básicas. Además, la implementación de un juego cooperativo de tablero por turnos tiene sus ventajas:

  • es paso a paso: esto le permite no preocuparse por temporizadores, sincronización, optimización, FPS y otras cosas tristes;
  • es cooperativo, es decir, el jugador o los jugadores no compiten entre sí, sino contra un cierto "entorno" que juega de acuerdo con reglas deterministas; esto elimina la necesidad de programar AI ( AI ), una de las etapas más difíciles del desarrollo del juego;
  • es significativo: los tableros de la mesa son generalmente personas caprichosas, no juegan nada: dales una mecánica reflexiva y un juego interesante: no saldrás en una hermosa imagen (da algo a los amigos, ¿verdad?)
  • está en la trama: muchos deportistas electrónicos no estarán de acuerdo, pero para mí personalmente el juego debería contar una historia interesante, como un libro, solo usando sus medios artísticos especiales.
  • es entretenida, lo cual no es para todos: los enfoques descritos se pueden aplicar a cualquier sueño posterior, sin importar cuántos tenga.

Para aquellos que no están familiarizados con las reglas, una breve introducción:
Pathfinder Adventures es una versión digital de un juego de cartas creado a partir de un juego de rol de tablero (o más bien, un sistema completo de rol) Pathfinder. Los jugadores (en la cantidad de 1 a 6) eligen un personaje para ellos y, junto con él, se embarcan en una aventura dividida en varios escenarios. Cada personaje tiene a su disposición cartas de varios tipos (tales como: armas, armaduras, hechizos, aliados, artículos, etc.), con la ayuda de los cuales en cada escenario debe encontrar y castigar brutalmente al Sinvergüenza, una carta especial con propiedades especiales.

Cada escenario proporciona una cantidad de ubicaciones o ubicaciones (su número depende de la cantidad de jugadores) que los jugadores deben visitar y explorar. Cada ubicación contiene una baraja de cartas boca abajo, que los personajes exploran en su turno, es decir, abren la carta superior e intentan superarla de acuerdo con las reglas relevantes. Además de las cartas inofensivas que reponen el mazo del jugador, estos mazos también contienen enemigos malvados y obstáculos: deben ser derrotados para avanzar más. La carta de sinvergüenza también se encuentra en una de las barajas, pero los jugadores no saben cuál, es necesario encontrarla.

Para derrotar las cartas (y adquirir otras nuevas), los personajes deben pasar una prueba de una de sus características (estándar para la fuerza de RPG, destreza, sabiduría, etc.) lanzando un dado cuyo tamaño está determinado por el valor de la característica correspondiente (de d4 a d12), agregando modificadores (definidos reglas y el nivel de desarrollo del personaje) y jugar para mejorar el efecto de las cartas apropiadas de la mano. Tras la victoria, la carta alcanzada se retira del juego (si es un enemigo) o repone la mano de un jugador (si es un objeto) y el movimiento pasa a otro jugador. Al perder, el personaje a menudo se daña, lo que le hace descartar cartas de su mano. Una mecánica interesante es que la salud del personaje está determinada por el número de cartas en su mazo, tan pronto como el jugador necesita robar una carta del mazo, pero no están allí, su personaje muere.

El objetivo es, después de haber recorrido los mapas de ubicación, encontrar y vencer al sinvergüenza, haber bloqueado previamente su camino hacia la retirada (puede obtener más información sobre esto y mucho más leyendo las reglas). Esto debe hacerse por un tiempo, que es la principal dificultad del juego. El número de movimientos es estrictamente limitado y una simple enumeración de todas las cartas disponibles no alcanza la meta. Por lo tanto, debes aplicar varios trucos y técnicas inteligentes.

A medida que se cumplan los escenarios, los personajes crecerán y se desarrollarán, mejorando sus características y adquiriendo nuevas habilidades útiles. Administrar el mazo también es un elemento muy importante del juego, ya que el resultado del escenario (especialmente en las etapas posteriores) generalmente depende de cartas correctamente seleccionadas (y de mucha suerte, pero ¿qué quieres de un juego con dados?).

En general, el juego es interesante, digno, digno de atención y, lo que es importante para nosotros, bastante complicado (tenga en cuenta que digo "difícil" no significa "difícil") para que sea interesante implementar su clon.

En nuestro caso, haremos un cambio conceptual global: abandonaremos las cartas. Por el contrario, no nos negaremos en absoluto, pero reemplazaremos las tarjetas con cubos, aún de diferentes tamaños y colores (técnicamente, no es del todo correcto usar sus "cubos", ya que hay otras formas además del hexágono correcto, pero es inusual que los llame "huesos" y es desagradable, pero usar margarita americana es una señal de mal gusto, así que dejémoslo como está). Ahora, en lugar de mazos, los jugadores tendrán bolsas. Y las ubicaciones también tendrán bolsas, de las cuales los jugadores en el proceso de investigación sacarán cubos arbitrarios. El color del cubo determinará su tipo y, en consecuencia, las reglas para aprobar la prueba. Como resultado, se eliminarán las características personales del personaje (fuerza, destreza, etc.), pero aparecerán nuevas mecánicas interesantes (más sobre esto más adelante).

¿Será divertido jugar? No tengo idea, y nadie puede entender esto hasta que un prototipo funcional esté listo. Pero no disfrutamos el juego, sino el desarrollo, ¿verdad? Por lo tanto, no debe haber ninguna duda de éxito.

Paso dos Diseño


Tener una idea es solo un tercio de la historia. Ahora es importante desarrollar esta idea. Es decir, no camine por el parque ni tome un baño de vapor, sino siéntese a la mesa, tome papel con un bolígrafo (o abra su editor de texto favorito) y escriba cuidadosamente un documento de diseño, trabajando minuciosamente cada aspecto de la mecánica del juego. El tiempo para esto tomará un gran avance, así que no esperes completar la escritura de una sola vez. Y ni siquiera espere pensar en todo de una vez: a medida que implemente, verá la necesidad de hacer un montón de cambios y cambios (y, a veces, reelaborar algo globalmente), pero debe haber alguna base antes de que comience el proceso de desarrollo.

Al principio, su documento de diseño se verá así




Y solo después de hacer frente a la primera ola de ideas grandiosas, toma la cabeza, decide la estructura del documento y comienza a llenarlo metódicamente con contenido (verificando cada segundo con lo que ya se ha escrito para evitar repeticiones innecesarias y especialmente contradicciones). Poco a poco, paso a paso, obtienes algo significativo y conciso, como esto .

Al describir el diseño, elija el idioma en el que le sea más fácil expresar sus pensamientos, especialmente si trabaja solo. Si alguna vez necesita involucrar a desarrolladores externos en el proyecto, asegúrese de que entiendan todas las tonterías creativas que ocurren en su cabeza.

Para continuar, le recomiendo que lea el documento citado al menos en diagonal, porque en el futuro me referiré a los términos y conceptos presentados allí, sin detenerme en detalles sobre su interpretación.

“Autor, mátate contra la pared. Demasiadas letras.

Paso tres Modelado


Es decir, todo el mismo diseño, solo que más detallado.
Sé que muchos ya están ansiosos por abrir un IDE y comenzar a codificar, pero tengan paciencia un poco más. Cuando las ideas abruman nuestras cabezas, nos parece que solo tiene que tocar el teclado y sus manos se apresurarán a la distancia del cielo, antes de que el café tenga tiempo de hervir en la estufa, cuando la versión funcional de la aplicación esté lista ... para ir a la basura. Para no volver a escribir lo mismo muchas veces (y especialmente para no asegurarme después de tres horas de desarrollo de que el diseño no funciona y debe iniciarse de nuevo), sugiero que primero piense (y documente) la estructura principal de la aplicación.

Como nosotros, como desarrolladores, conocemos bien la programación orientada a objetos (OOP), utilizaremos sus principios en nuestro proyecto. Pero para OOP no se espera nada más que comenzar el desarrollo con un montón de diagramas UML aburridos. (¿No sabes qué es UML ? Casi lo olvido también, pero lo recordaré con gusto, solo para demostrar que soy un programador diligente, jeje).

Comencemos con el diagrama de casos de uso. Representaremos en él las formas en que nuestro usuario (jugador) interactúa con el sistema futuro:

Casos de uso


"Uh ... ¿de qué va todo eso?"

Solo bromeaba, solo bromeaba ... y, tal vez, dejo de bromear al respecto; este es un asunto serio (un sueño, después de todo). En el diagrama de casos de uso, es necesario mostrar las posibilidades que el sistema ofrece al usuario. En detalles Pero históricamente sucedió que este tipo particular de diagramas es lo peor para mí: aparentemente, la paciencia no es suficiente. Y no tiene que mirarme así: no estamos en la universidad protegiendo el diploma, pero disfrutamos el proceso de trabajo. Y para este proceso, los casos de uso no son tan importantes. Es mucho más importante dividir correctamente la aplicación en módulos independientes, es decir, implementar el juego de tal manera que las características de la interfaz visual no afecten la mecánica del juego y que el componente gráfico se pueda cambiar fácilmente si se desea.

Este punto puede detallarse en el siguiente diagrama de componentes:

Componentes del sistema


Aquí ya hemos identificado subsistemas específicos que forman parte de nuestra aplicación y, como se mostrará más adelante, todos se desarrollarán independientemente uno del otro.

Además, en la misma etapa, descubriremos cómo será el ciclo principal del juego (o más bien, su parte más interesante es la que implementa los personajes en el guión). Para esto, un diagrama de actividad es adecuado para nosotros:

Si te paras, siéntate


Y finalmente, sería bueno presentar en términos generales la secuencia de la interacción del usuario final con el motor del juego a través de un sistema de entrada-salida.

Salchichas


La noche es larga, mucho antes del amanecer. Después de sentarse como debería en la mesa, dibujará con calma las otras dos docenas de diagramas: créame, en el futuro su presencia lo ayudará a mantenerse en el camino elegido, aumentar su autoestima, actualizar el interior de la habitación, colgar fondos de pantalla descoloridos con carteles coloridos, así como transmitir su visión en términos simples para compañeros desarrolladores que pronto se apresurarán a las puertas de su nuevo estudio en masa (no estamos apuntando al éxito, ¿recuerdan?).

Hasta ahora no vamos a citar diagramas de clase (clase) que a todos nos encantan: se espera que las clases rompan mucho y la imagen en tres pantallas de claridad al principio no se agregará. Es mejor romperlo en pedazos y colocarlo gradualmente, a medida que avanza para desarrollar el subsistema apropiado.

Paso cuatro Selección de herramienta


Como ya se acordó, desarrollaremos una aplicación multiplataforma que se ejecute tanto en computadoras de escritorio que ejecutan varios sistemas operativos como en dispositivos móviles. Elegiremos Java como lenguaje de programación, y Kotlin es aún mejor, ya que este último es más nuevo y más fresco, y aún no ha tenido tiempo de nadar en las olas de indignación que abrumaron a su predecesor (al mismo tiempo aprenderé si alguien más no lo posee). La JVM , como saben, está disponible en todas partes (en tres mil millones de dispositivos, jeje), admitiremos tanto Windows como UNIX, e incluso en un servidor remoto podemos jugar a través de una conexión SSH (es desconocido para cualquiera que lo necesite, pero Brindaremos esa oportunidad). También lo transferiremos a Android cuando nos hagamos ricos y contratemos a un artista, pero más sobre eso más adelante.

Las bibliotecas (no podemos acceder a ninguna parte sin ellas) elegiremos de acuerdo con nuestros requisitos multiplataforma. Usaremos Maven como sistema de compilación. O Gradle. O de todos modos, Maven, comencemos con eso. Inmediatamente le aconsejo que configure un sistema de control de versiones (el que prefiera), para que después de muchos años sea más fácil recordar con sentimientos nostálgicos lo genial que fue alguna vez. IDE también elige lo familiar, favorito y conveniente.

En realidad, no necesitamos nada más. Puedes comenzar a desarrollar.

Paso cinco Crear y configurar un proyecto


Si usa un IDE, entonces crear un proyecto es trivial. Solo necesita elegir un nombre sonoro (por ejemplo, Dados ) para nuestra futura obra maestra , no olvide habilitar el soporte de Maven en la configuración y pom.xmlescribir los identificadores necesarios en el archivo :

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

Agregue también el soporte de Kotlin, que falta por defecto:

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

y algunas configuraciones en las que no nos detendremos en detalle:

 <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 poco de información sobre proyectos híbridos
Java, Kotlin src/main/kotlin src/main/java . Kotlin , ( *.kt ) , ( *.java ) Maven:

 <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>process-sources</phase> <goals> <goal>compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/main/kotlin</sourceDir> <sourceDir>${project.basedir}/src/main/java</sourceDir> </sourceDirs> </configuration> </execution> <execution> <id>test-compile</id> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.basedir}/src/test/java</sourceDir> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.1</version> <executions> <!-- Replacing default-compile --> <execution> <id>default-compile</id> <phase>none</phase> </execution> <!-- Replacing default-testCompile --> <execution> <id>default-testCompile</id> <phase>none</phase> </execution> <execution> <id>java-compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>java-test-compile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build> 

, — . .

Creemos tres paquetes a la vez (¿por qué jugar algo?):

  • model - para clases que describen objetos del mundo del juego;
  • game - para clases que implementan el juego;
  • ui - para las clases responsables de la interacción del usuario.

Este último contendrá solo interfaces, cuyos métodos usaremos para ingresar y generar datos. Almacenaremos implementaciones específicas en un proyecto separado, pero más sobre eso más adelante. Mientras tanto, para no rociar demasiado, agregaremos estas clases aquí, una al lado de la otra.

No intente hacerlo a la perfección de inmediato: piense en los detalles de los nombres de paquetes, interfaces, clases y métodos; prescriba minuciosamente la interacción de los objetos entre ellos; todo esto cambiará, y más de una docena de veces. A medida que se desarrolle el proyecto, muchas cosas le parecerán feas, voluminosas, ineficaces para usted y otras cosas similares, no dude en cambiarlas, ya que refactorizar en IDEs modernos es una operación muy barata.

También creamos una clase con una funciónmainy estamos listos para grandes logros. Puede usar el IDE en sí mismo para iniciarlo, pero como verá más adelante, este método no es adecuado para nuestros propósitos (la consola IDE estándar no puede mostrar nuestros resultados gráficos como debería), por lo que configuraremos el lanzamiento desde el exterior usando lote (o shell en sistemas UNIX) archivo. Pero antes de eso, realizaremos algunas configuraciones adicionales.

Una vez completada la operación, mvn packageobtenemos la salida del archivo JAR con todas las clases compiladas. Primero, por defecto, este archivo no incluye las dependencias necesarias para que el proyecto funcione (hasta ahora no las tenemos, pero ciertamente aparecerán en el futuro). En segundo lugar, la ruta a la clase principal que contiene el método no se especifica en el archivo de manifiesto de archivo main, por lo tanto, comience el proyecto con el comandojava -jar dice-1.0.jarNo funcionará con nosotros. Solucione esto agregando configuraciones adicionales a 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> 

Presta atención al nombre de la clase principal. Para las funciones de Kotlin contenidas fuera de las clases (como las funciones, por ejemplo main), las clases aún se crean durante la compilación (porque la JVM no sabe nada y no quiere saber). El nombre de esta clase es el nombre del archivo con la adición Kt. Es decir, si nombró la clase principal Main, se compilará en un archivo MainKt.class. Es este último el que debemos indicar en el manifiesto del archivo jar.

Ahora, al construir el proyecto, obtendremos dos archivos jar: dice-1.0.jary dice-1.0-jar-with-dependencies.jar. Estamos interesados ​​en el segundo. Vamos a escribir un script de lanzamiento para ello.

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

Tenga en cuenta que si la compilación falla, nos vemos obligados a interrumpir el script. De lo contrario, no se lanzará el último arpa, sino el archivo restante del ensamblaje exitoso anterior (a veces ni siquiera encontraremos la diferencia). A menudo, los desarrolladores usan el comando mvn clean packagepara eliminar todos los archivos compilados previamente, pero en este caso todo el proceso de compilación siempre comenzará desde el principio (incluso si el código fuente no ha cambiado), lo que llevará mucho tiempo. Pero no podemos esperar, tenemos que hacer un juego.

Entonces, el proyecto comienza bien, pero hasta ahora no hace nada. No se preocupe, lo arreglaremos pronto.

Paso seis Objetos principales


Poco a poco, comenzaremos a llenar el paquete con modellas clases necesarias para el juego.

Diagrama de clase


Los cubos son nuestro todo, agréguelos primero. Cada cubo (instancia de clase Die) se caracteriza por tipo (color) y tamaño. Para los tipos de cubo Die.Type, haremos una enumeración separada ( ), marcaremos el tamaño con un número entero de 4 a 12. También implementamos un método roll()que producirá un número arbitrario y uniformemente distribuido del rango disponible para el cubo (desde 1 hasta el valor de tamaño incluido).

La clase implementa la interfaz Comparablepara que los cubos se puedan comparar entre sí (útil más adelante cuando mostraremos varios cubos en una fila ordenada). Los cubos más grandes se colocarán antes.

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

Para no acumular polvo, los cubos se almacenan en bolsos (instancias de la clase Bag). Uno solo puede adivinar lo que está sucediendo dentro de la bolsa; por lo tanto, no tiene sentido usar una colección ordenada. Parece ser Los conjuntos (conjuntos) implementan bien la idea que necesitamos, pero no se ajustan por dos razones. Primero, cuando los use, tendrá que implementar métodos equals()y hashCode(), no está claro cómo, ya que es incorrecto comparar los tipos y tamaños de cubos: se puede almacenar cualquier cantidad de cubos idénticos en nuestro conjunto. En segundo lugar, sacando el cubo de la bolsa, esperamos obtener no solo algo no determinista, sino aleatorio, cada vez diferente. Por lo tanto, le aconsejo que utilice una colección ordenada (lista) y la baraje cada vez que agregue un nuevo elemento (en el métodoput()) o inmediatamente antes de emitir (en el método draw()).

El método es examine()adecuado para los casos en que un jugador cansado de la incertidumbre sacude el contenido de la bolsa en los corazones de la mesa (preste atención a la clasificación) y el método clear(), si los cubos sacudidos ya no se devuelven a la bolsa.

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

Además de las bolsas con cubos, también necesita montones con cubos (instancias de la clase Pile). Desde el primero, los segundos difieren en que sus contenidos son visibles para los jugadores y, por lo tanto, si es necesario, elimine un cubo del montón, el jugador puede seleccionar una instancia específica de interés. Realizamos esta idea por el método removeDie().

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

Ahora pasamos a nuestros personajes principales: héroes. Es decir, personajes que ahora llamaremos héroes (hay una buena razón para no llamar a su clase un nombre Characteren Java). Hay diferentes tipos de personajes (para ponerlo en clases, aunque es classmejor no usar la palabra ), pero para nuestro prototipo de trabajo solo tomaremos dos: Brawler (es decir, luchador con énfasis en la fuerza y ​​la fuerza) y Hunter (también conocido como Ranger / Thief, centrándose en destreza y sigilo). La clase del héroe determina sus características, habilidades y el conjunto inicial de cubos, pero como se verá más adelante, los héroes no estarán estrictamente vinculados a las clases y, por lo tanto, su configuración personal se puede cambiar fácilmente en un solo lugar.

Agregaremos las propiedades necesarias al héroe de acuerdo con el documento de diseño: nombre, tipo de cubo favorito, límites de cubo, habilidades aprendidas y no estudiadas, mano, bolsa y pila para reiniciar. Preste atención a las características de la implementación de las propiedades de la colección. En todo el mundo civilizado, se considera una mala forma de proporcionar acceso externo (con la ayuda de un captador) a las colecciones almacenadas dentro del objeto: los programadores sin escrúpulos podrán cambiar el contenido de estas colecciones sin el conocimiento de la clase. Una forma de lidiar con esto es implementar métodos separados para agregar y eliminar elementos, obtener su número y acceder por índice. Puede implementar getter, pero al mismo tiempo no devolver la colección en sí, sino su copia inmutable: para una pequeña cantidad de elementos, no es particularmente aterrador hacer eso.

 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 mano del héroe (los cubos que tiene en este momento) se describe mediante un objeto separado (clase Hand). La decisión de diseño de mantener los cubos aliados separados del brazo principal fue uno de los primeros que se me ocurrió. Al principio parecía una característica súper genial, pero luego generó una gran cantidad de problemas e inconvenientes. Sin embargo, no estamos buscando formas fáciles y, por lo tanto, las listas dicey alliesestamos a nuestros servicios, con todos los métodos necesarios para agregar, recibir y eliminar (algunos de ellos determinan de manera inteligente a cuál de las dos listas aplicar). Cuando eliminas un cubo de tu mano, todos los cubos posteriores se moverán a la parte superior de la lista, llenando los espacios en blanco; en el futuro esto facilitará enormemente la búsqueda (no es necesario manejar situaciones con 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 colección de objetos de clase DiceLimitestablece límites en la cantidad de cubos de cada tipo que un héroe puede tener al comienzo del guión. No hay nada especial que decir, determinamos inicialmente, los valores máximos y actuales para cada tipo.

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

Pero con habilidades es más interesante. Cada uno de ellos tendrá que implementarse individualmente (sobre el cual más adelante), pero consideraremos solo dos: Hit and Shoot (uno para cada clase, respectivamente). Las habilidades se pueden desarrollar ("bombear") desde el nivel inicial hasta el máximo, lo que a menudo afecta los modificadores que se agregan a las tiradas de dados. Reflejar esto en las propiedades level, maxLevel, modifier1y 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 } 

Presta atención a los métodos auxiliares de la clase Hero, que te permiten esconder o tirar un dado de tu mano, verificar si el héroe tiene cierta habilidad, y también aumentar el nivel de la habilidad aprendida o aprender una nueva. Todos ellos serán necesarios tarde o temprano, pero ahora no nos detendremos en ellos en detalle.

No tenga miedo de la cantidad de clases que tenemos que crear. Para un proyecto de esta complejidad, varios cientos es algo común. Aquí, como en cualquier ocupación seria: comenzamos de a poco, aumentamos gradualmente el ritmo, en un mes nos aterra el alcance. No lo olvide, seguimos siendo un pequeño estudio de una persona, no nos enfrentamos a tareas abrumadoras.

“Algo se cansó de mí. Iré a fumar o algo así ... "

Y continuaremos.
Se describen los héroes y sus habilidades, es hora de pasar a las fuerzas enemigas: la gran y terrible mecánica de juego. O más bien, objetos con los que nuestros héroes tienen que interactuar.

Otro diagrama de clase


Tres valientes cubos y cartas se opondrán a nuestros valientes protagonistas: villanos (clase Villain), enemigos (clase Enemy) y obstáculos (clase Obstacle), unidos bajo el término general "amenazas" ( Threat- clase abstracta "bloqueada", la lista de sus posibles herederos es estrictamente limitada). Cada amenaza tiene un conjunto de características distintivas ( Trait) que describen reglas especiales de comportamiento cuando se enfrentan a tal amenaza y agregan variedad a la jugabilidad.

 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 } 

Tenga en cuenta que la lista de objetos de clase se Traitdefine como mutable ( MutableList), pero se representa como una interfaz inmutable List. Aunque esto funcionará en Kotlin, el enfoque no es seguro, sin embargo, ya que no hay nada que impida que la lista resultante se convierta en una interfaz mutable y que realice varias modificaciones, es especialmente fácil hacerlo si accede a la clase desde el código Java (donde la interfaz Listes mutable). La forma más paranoica de proteger su colección es hacer algo como esto:

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

pero no seremos tan escrupulosos al abordar el problema (usted, sin embargo, está advertido).

Debido a las peculiaridades de la mecánica del juego, una clase Obstacledifiere de sus contrapartes por la presencia de campos adicionales, pero no nos centraremos en ellos.

Las cartas de amenaza (y si lees cuidadosamente el documento de diseño, entonces recuerda que son cartas) se combinan en mazos representados por la clase 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() } 

Aquí no hay nada inusual, excepto que la clase está parametrizada y contiene una lista ordenada (o más bien una cola bidireccional), que se puede mezclar utilizando el método apropiado. Se necesitarán cubiertas de enemigos y obstáculos literalmente en un segundo, cuando procedamos a considerar ...

... una clase Location, cada una de las cuales describe un área única que nuestros héroes tendrán que visitar en el escenario.

 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 } 

Cada localidad tiene un nombre, descripción, dificultad de cierre y el signo de "abierto / cerrado". En algún lugar aquí el villano puede estar al acecho (o puede que no esté al acecho, como resultado de lo cual la propiedad villainpuede cobrar valor null). En cada área hay una bolsa con cubos y una baraja de cartas con amenazas. Además, el área puede tener sus propias características de juego únicas ( SpecialRule), que, como las propiedades de las amenazas, agregan variedad a la jugabilidad. Como puede ver, estamos sentando las bases para la funcionalidad futura, incluso si no planeamos implementarla en el futuro cercano (para lo cual, de hecho, necesitamos la etapa de modelado).

Finalmente, queda por implementar los scripts (clase 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) } 

Cada escenario se caracteriza por el nivel y el valor inicial del temporizador. De manera similar a lo que se vio anteriormente, se establecen reglas especiales ( specialRules) y las habilidades de los aliados (lo perderemos de vista). Puede pensar que el script también debe contener una lista de localidades (objetos de clase Location) y, por lógica de las cosas, esto es realmente así. Pero como se verá más adelante, no utilizaremos dicha conexión en ningún lado y no ofrece ninguna ventaja técnica.

Les recuerdo que todas las clases revisadas anteriormente están contenidas en el paquetemodel- Nosotros, como niños, en anticipación de una batalla épica de juguetes, colocamos a los soldados en la superficie de la mesa. Y ahora, después de unos momentos dolorosos, a la señal del Comandante en Jefe, nos apresuraremos a la batalla, juntando nuestros juguetes y disfrutando de las consecuencias del juego. Pero antes de eso, un poco sobre el acuerdo en sí.

"Bueno muuuy ..."

Séptimo paso. Patrones y Generadores


Imaginemos por un segundo cuál será el proceso de generación de cualquiera de los objetos considerados anteriormente, por ejemplo, ubicación (terreno). Necesitamos crear una instancia de la clase Location, inicializar sus campos con valores, y así para cada localidad que queramos usar en el juego. Pero espere: cada ubicación debe tener una bolsa, que también debe generarse. Y las bolsas tienen cubos; estas también son instancias de la clase correspondiente ( Die). Esto no estoy hablando de enemigos y obstáculos: en general, deben recogerse en mazos. Y el villano no determina el terreno en sí, sino las características del escenario ubicadas un nivel más arriba. Bueno, entiendes el punto. El código fuente de lo anterior puede verse así:

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

Esto también es gracias al lenguaje y diseño de Kotlin apply{}: en Java, el código sería dos veces más voluminoso. Además, habrá muchos lugares, como dijimos, y además de ellos también hay escenarios, aventuras y héroes con sus habilidades y características; en general, hay algo que el diseñador del juego debe hacer.

Pero el diseñador del juego no escribirá el código, y es inconveniente para nosotros volver a compilar el proyecto al más mínimo cambio en el mundo del juego. Aquí, cualquier programador competente objetará que las descripciones de los objetos del código de clase se separen, idealmente, de modo que las instancias de este último se generen dinámicamente en función del primero según sea necesario, de forma similar a cómo se hace una parte de una planta de dibujo. También implementamos dichos dibujos, solo los llamamos plantillas y los representamos como instancias de una clase especial. Con tales patrones, un código de programa especial (generador) creará los objetos finales del modelo descrito anteriormente.

El proceso de generar un objeto a partir de una plantilla


Por lo tanto, para cada clase de nuestros objetos, se deben definir dos nuevas entidades: la interfaz de plantilla y la clase de generador. Y dado que se ha acumulado una cantidad decente de objetos, también habrá una serie de entidades ... indecentes:

Diagrama de clase


Respira profundo, escucha atentamente y no te distraigas. En primer lugar, el diagrama no muestra todos los objetos del mundo del juego, sino solo los principales, que no puedes prescindir al principio. En segundo lugar, para no sobrecargar el circuito con detalles innecesarios, se omitieron algunas de las conexiones mencionadas anteriormente en otros diagramas.

Comencemos con algo simple: generar cubos. "¿Cómo? - usted dice - ¿No somos suficientes constructores? Sí, ese es el tipo y tamaño ". No, responderé, no lo suficiente. De hecho, en muchos casos (lea las reglas), los cubos deben generarse arbitrariamente en una cantidad arbitraria (por ejemplo: "de uno a tres cubos de azul o verde"). Además, el tamaño debe seleccionarse en función del nivel de complejidad del script. Por lo tanto, presentamos una interfaz especial DieTypeFilter.

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

Las diferentes implementaciones de esta interfaz verificarán si el tipo de cubo corresponde a diferentes conjuntos de reglas (cualquiera que solo se le ocurra). Por ejemplo, si el tipo corresponde a un valor estrictamente especificado ("azul") o un rango de valores ("azul, amarillo o verde"); o, por el contrario, corresponde a cualquier tipo que no sea el dado ("si no fuera blanco en cualquier caso", cualquier cosa, pero no eso). Incluso si no está claro de antemano qué implementaciones específicas se necesitan, no importa: se pueden agregar más tarde, el sistema no se saldrá de esto (polimorfismo, ¿recuerdas?).

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

El tamaño del cubo también se establecerá arbitrariamente, pero más sobre eso más adelante. Mientras tanto, escribiremos un generador de cubos ( DieGenerator) que, a diferencia del constructor de la clase Die, no aceptará el tipo explícito y el tamaño del cubo, sino el filtro y el nivel de complejidad.

 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, estos métodos serían estáticos, pero dado que estamos tratando con Kotlin, no necesitamos la clase como tal, lo que también es cierto para los otros generadores que se analizan a continuación (sin embargo, a nivel lógico, todavía usaremos el concepto de la clase).

Dos métodos privados generan por separado el tipo y el tamaño del cubo; se puede decir algo interesante sobre cada uno. El método generateDieType()se puede conducir a un bucle infinito pasando un filtro de entrada con

 override fun test(filter: DieTypeFilter) = false 

(los escritores tienen una fuerte creencia de que uno puede salir de inconsistencias lógicas y trazar agujeros si los personajes mismos los señalan a la audiencia durante la historia). El método generateDieSize()genera un tamaño pseudoaleatorio basado en la distribución especificada en forma de matriz (una para cada nivel). Cuando me haga rico en la vejez y me compre un paquete de cubos de juego multicolores, no podré jugar a los dados , porque no sabré cómo recogerles una bolsa al azar (excepto pedirle a un vecino y darles la espalda en este momento). Este no es un mazo de cartas que puede barajarse al revés, requiere mecanismos y dispositivos especiales. Si alguien tiene ideas (y tuvo la paciencia de leer en este lugar), por favor comparta en los comentarios.

Y como estamos hablando de bolsos, desarrollaremos una plantilla para ellos. A diferencia de sus compañeros, esta plantilla ( BagTemplate) será una clase específica. Contiene otras plantillas: cada una de ellas describe las reglas (o Plan) mediante las cuales uno o más cubos (¿recuerda los requisitos establecidos anteriormente?) Se agregan a la bolsa.

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

Cada plan define un patrón para el tipo de cubos, así como el número (mínimo y máximo) de cubos que satisfacen este patrón. Gracias a este enfoque, puede generar bolsas de acuerdo con reglas extrañas (y nuevamente lloro amargamente por la vejez, porque mi vecino se niega rotundamente a ayudarme). Algo como esto:

 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 usted, como yo, está cansado de todo este funcionalismo, abróchese, solo empeorará. Pero luego, a diferencia de muchos tutoriales confusos en Internet, tenemos la oportunidad de estudiar el uso de varios métodos inteligentes en relación con un área temática real y comprensible.

Por sí solos, las bolsas no estarán en el campo: debes dárselas a los héroes y ubicaciones. Comencemos con el último.

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

En el lenguaje Kotlin, en lugar de los métodos, get()puede usar las propiedades de la interfaz; esto es mucho más conciso. Ya estamos familiarizados con la plantilla de la bolsa, considere los métodos restantes. La propiedad basicClosingDifficultyestablecerá la complejidad básica de la verificación para cerrar el terreno. La palabra "básico" aquí solo significa que la complejidad final dependerá del nivel del escenario y no está clara en esta etapa. Además, necesitamos definir patrones para enemigos y obstáculos (y villanos al mismo tiempo). Además, de la variedad de enemigos y obstáculos descritos en la plantilla, no se utilizarán todos, sino solo un número limitado (para aumentar el valor de repetición). Tenga en cuenta que las reglas especiales ( SpecialRule) del área se implementan mediante una enumeración simple ( enum class) y, por lo tanto, no requieren una plantilla separada.

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

Y deje que el generador cree no solo objetos individuales, sino también cubiertas completas con ellos.

 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 } 

Si hay más cartas en el mazo de las que necesitamos (parámetro limit), las eliminaremos de allí. Al poder generar bolsas con cubos y paquetes de cartas, finalmente podemos crear terreno:

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

El terreno que establecimos explícitamente en el código al comienzo del capítulo ahora tendrá un aspecto completamente diferente:

 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 generación de escenarios ocurrirá de manera similar.

 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 } 

De acuerdo con las reglas, el número de ubicaciones generadas dinámicamente depende del número de héroes. La interfaz define una función de cálculo estándar que, si se desea, se puede redefinir en implementaciones específicas. En relación con este requisito, el generador de escenarios también generará terreno para estos escenarios: en el mismo lugar, los villanos se distribuirán aleatoriamente entre las localidades.

 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 } 

Muchos lectores atentos objetarán que las plantillas deben almacenarse no en el código fuente de las clases, sino en algunos archivos de texto (scripts) para que incluso aquellos que están lejos de la programación puedan crearlos y mantenerlos. Estoy de acuerdo, me quito el sombrero, pero no esparzo cenizas en mi cabeza, porque una no interfiere con la otra. Si lo desea, simplemente defina una implementación especial de la plantilla, cuyos valores de propiedad se cargarán desde un archivo externo. El proceso de generación no cambiará ni un ápice de esto.

Bueno, parece que no han olvidado nada ... Oh sí, héroes, también necesitan ser generados, lo que significa que también necesitan sus propias plantillas. Aquí hay algunos, por ejemplo:

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

E inmediatamente notamos dos rarezas. En primer lugar, no usamos plantillas para generar bolsas y cubos en ellos. Por quéSí, porque para cada tipo (clase) de héroes, la lista de cubos iniciales está estrictamente definida; no tiene sentido complicar el proceso de creación de ellos. En segundo lugar, getDiceCount()¿qué clase de heces son estas? Cálmate, estos son los DiceLimitque definen las restricciones en los cubos. Y la plantilla para ellos fue elegida en una forma tan extraña que los valores específicos se registraron con mayor claridad. Véalo usted mismo del ejemplo:

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

Pero antes de escribir un generador, definimos una plantilla para las habilidades.

 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 } 

Desafortunadamente, no lograremos remachar habilidades en lotes de la misma manera que los enemigos y los guiones. Cada nueva habilidad requiere la expansión de la mecánica del juego, agregando un nuevo código al motor del juego, incluso con héroes en este sentido es más fácil. Quizás este proceso pueda abstraerse, pero aún no he encontrado una manera. Sí, y no demasiado probado, para ser honesto.

 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 } 

Solo unos momentos son sorprendentes. En primer lugar, el método de generación en sí selecciona la plantilla deseada dependiendo de la clase del héroe. En segundo lugar, no es necesario especificar un nombre de inmediato (a veces en la etapa de generación aún no lo sabremos). En tercer lugar, Kotlin aportó una cantidad sin precedentes de azúcar sintáctica, que algunos desarrolladores abusan sin razón. Y no un poco avergonzado.

Paso ocho Ciclo de juego


Finalmente, llegamos a lo más interesante: la implementación del ciclo del juego. En términos simples, comenzaron a "hacer el juego". Muchos desarrolladores principiantes a menudo comienzan precisamente desde esta etapa, aparte de la creación de juegos, todo lo demás. Especialmente todo tipo de pequeños esquemas sin sentido para dibujar, pfff ... Pero no nos apuraremos (aún está lejos de la mañana), y por lo tanto un poco más de modelado. Si otra vez.

Tabla de actividades


Como puede ver, el fragmento dado del ciclo del juego es un orden de magnitud menor que el que citamos anteriormente. Consideraremos solo el proceso de transferencia del curso, explorando el área (y describiremos la reunión con solo dos tipos de cubos) y descartando los cubos al final del turno. Y completar el escenario con una pérdida (sí, aún no lograremos ganar nuestro juego), pero ¿cómo te gusta? El temporizador disminuirá cada turno y, una vez completado, debe hacerse algo. Por ejemplo, muestre un mensaje y finalice el juego: todo está tal como está escrito en las reglas. Se debe completar otro juego a la muerte de los héroes, pero nadie los dañará, por lo tanto, lo dejaremos. Para ganar, debe cerrar todas las áreas, lo cual es difícil incluso si es solo una. Por lo tanto, dejemos este momento. No tiene sentido rociar demasiado: es importante para nosotros comprender la esencia y terminar el resto más tarde, en mi tiempo libre (o mejor dicho, terminarlo,y tu - ve a escribir un juegode tus sueños).

Entonces, lo primero que debe hacer es decidir qué objetos necesitamos.

Héroes El guion. Ubicaciones
Ya hemos revisado el proceso de su creación, no lo repetiremos. Solo notamos el patrón de terreno que usaremos en nuestro pequeño ejemplo.

 class TestLocationTemplate : LocationTemplate { override val name = "Test" override val description = "Some Description" override val basicClosingDifficulty = 0 override val enemyCardsCount = 0 override val obstacleCardsCount = 0 override val bagTemplate = BagTemplate().apply { addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE)) } override val enemyCardPool = emptyList<EnemyTemplate>() override val obstacleCardPool = emptyList<ObstacleTemplate>() override val specialRules = emptyList<SpecialRule>() } 

, «» — , , , . , . - — .

.
deterrent pile. , . Pile .

.
, , . , . ( ), DiePair .

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

La ubicación de los personajes en el área.
En el buen sentido, este momento necesita ser rastreado usando una estructura especial. Por ejemplo, mapas de la forma Map<Location, List<Hero>>en que cada localidad contendrá una lista de héroes que se encuentran actualmente en ella (así como un método para lo contrario: determinar la localidad en la que se encuentra un héroe en particular). Si decide seguir este camino, no olvide agregar Locationmétodos a la clase de implementación equals()y hashCode(), espero, no hay necesidad de explicar por qué. No perderemos tiempo en esto, ya que el área es solo una y los héroes no la dejan en ningún lado.

Comprobando las manos del héroe.
( ), , ( ), , (, /, ), (, ) . , , , . HandFilter .

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

( Hand ) true false . : , , , , .

 class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter { override fun test(hand: Hand) = (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types } || (Die.Type.ALLY in types && hand.allyDieCount > 0) } 

Sí, funcionalismo de nuevo.

Artículos activos / seleccionados.
Ahora que nos hemos asegurado de que la mano del héroe sea adecuada para realizar la prueba, es necesario que el jugador elija entre la mano que corta en dados (o cubos) con la que pasará esta prueba. En primer lugar, debe resaltar (resaltar) las posiciones apropiadas (en las que hay cubos del tipo deseado). En segundo lugar, debe marcar de alguna manera los cubos seleccionados. Para ambos requisitos, una clase es adecuada HandMask, que, de hecho, contiene un conjunto de enteros (números de posiciones seleccionadas) y métodos para agregarlos y eliminarlos.

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

Ya he dicho cómo sufro la idea "ingeniosa" de almacenar cubos blancos en una mano separada. Debido a esta estupidez, debe lidiar con dos conjuntos y duplicar cada uno de los métodos presentados. Si alguien tiene ideas sobre cómo simplificar la implementación de este requisito (por ejemplo, use un conjunto, pero para los cubos blancos los índices comienzan con cien, o algo igualmente oscuro), compártalos en los comentarios.

Por cierto, se necesita implementar una clase similar para seleccionar cubos del montón ( PileMask), pero esta funcionalidad está fuera del alcance de este ejemplo.

La elección de cubos de la mano.
Pero no es suficiente "resaltar" las posiciones aceptables; es importante cambiar este "resaltar" en el proceso de elegir cubos. Es decir, si se requiere que un jugador tome solo un dado de su mano, al elegir este dado, todas las demás posiciones deberían quedar inaccesibles. Además, en cada etapa es necesario controlar el cumplimiento del objetivo por parte del jugador, es decir, comprender si los cubos seleccionados son suficientes para pasar una u otra verificación. Una tarea tan difícil requiere una instancia compleja de una clase compleja.

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

Lógica bastante complicada, te entenderé y te perdonaré si esta clase es incomprensible para ti. Y todavía trato de explicarlo. Las implementaciones de esta clase siempre almacenan una referencia a la mano (objeto Hand) con la que tratarán. Cada uno de los métodos recibe una máscara ( HandMask), que refleja el estado actual de la selección (qué posiciones son seleccionadas por el jugador y cuáles no). El método checkMask()informa si los cubos seleccionados son suficientes para pasar la prueba. El método isPositionActive()dice si es necesario resaltar una posición específica, si es posible agregar un cubo en esta posición a la prueba (o eliminar un cubo que ya está seleccionado). El método isAllyPositionActive()es el mismo para los dados blancos (sí, lo sé, soy un idiota). Bueno y el método auxiliargetCheckedDice() , — , , .

(, !). ( ). , .

 class StatDieAcquireHandMaskRule(hand: Hand, private val requiredType: Die.Type) : HandMaskRule(hand) { /** * Define how many dice of specified type are currently checked */ private fun checkedDieCount(mask: HandMask) = (0 until hand.dieCount) .filter(mask::checkPosition) .mapNotNull(hand::dieAt) .count { it.type === requiredType } override fun checkMask(mask: HandMask) = (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1) override fun isPositionActive(mask: HandMask, position: Int) = with(hand.dieAt(position)) { when { mask.checkPosition(position) -> true this == null -> false this.type === Die.Type.DIVINE -> true this.type === requiredType && checkedDieCount(mask) < 1 -> true else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int) = false } 

. . . (capacity), ( ). , ( , ). .

 class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) { private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0 private val maxDiceToDiscard = hand.dieCount - hand.woundCount override fun checkMask(mask: HandMask) = (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) && (mask.allyPositionCount in 0..hand.allyDieCount) override fun isPositionActive(mask: HandMask, position: Int) = when { mask.checkPosition(position) -> true hand.dieAt(position) == null -> false hand.dieAt(position)!!.type == Die.Type.WOUND -> false mask.positionCount < maxDiceToDiscard -> true else -> false } override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null } 

Nezhdanchik: Handuna propiedad apareció de repente en la clase woundCountque no existía antes. Puede escribir su implementación usted mismo, es fácil. Practica al mismo tiempo.

Pasando cheques.
Finalmente llegó a ellos. Cuando se toman los dados de la mano, es hora de tirarlos. Para cada cubo es necesario tener en cuenta: su tamaño, sus modificadores, el resultado de su lanzamiento. Aunque solo se puede sacar un cubo de la bolsa a la vez, se pueden colocar varios dados contra él, agregando los resultados de sus tiradas. En general, hagamos un resumen de los dados y representemos a las tropas en el campo de batalla. Por un lado, tenemos un enemigo: él es solo uno, pero es fuerte y feroz. Por otro lado, un oponente tiene la misma fuerza que él, pero con apoyo. El resultado de la batalla se decidirá en una breve escaramuza, el ganador solo puede ser uno ...

Lo siento, me dejé llevar. Para simular nuestra batalla general, implementamos una clase especial.

 class DieBattleCheck(val method: Method, opponent: DiePair? = null) { enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN } private inner class Wrap(val pair: DiePair, var roll: Int) private infix fun DiePair.with(roll: Int) = Wrap(this, roll) private val opponent: Wrap? = opponent?.with(0) private val heroics = ArrayList<Wrap>() var isRolled = false var result: Int? = null val heroPairCount get() = heroics.size fun getOpponentPair() = opponent?.pair fun getOpponentResult() = when { isRolled -> opponent?.roll ?: 0 else -> throw IllegalStateException("Not rolled yet") } fun addHeroPair(pair: DiePair) { if (method == Method.SUM && heroics.size > 0) { pair.modifier = 0 } heroics.add(pair with 0) } fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier)) fun clearHeroPairs() = heroics.clear() fun getHeroPairAt(index: Int) = heroics[index].pair fun getHeroResultAt(index: Int) = when { isRolled -> when { (index in 0 until heroics.size) -> heroics[index].roll else -> 0 } else -> throw IllegalStateException("Not rolled yet") } fun roll() { fun roll(wrap: Wrap) { wrap.roll = wrap.pair.die.roll() } isRolled = true opponent?.let { roll(it) } heroics.forEach { roll(it) } } fun calculateResult() { if (!isRolled) { throw IllegalStateException("Not rolled yet") } val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0 val stats = heroics.map { it.roll + it.pair.modifier } val heroResult = when (method) { DieBattleCheck.Method.SUM -> stats.sum() DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt() DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt() DieBattleCheck.Method.MAX -> stats.max() ?: 0 DieBattleCheck.Method.MIN -> stats.min() ?: 0 } result = heroResult - opponentResult } } 

, DiePair . . , , (, , ). ( Wrap ). with , -.

( Method ) ( ). . , , ( ).

roll()llama al método del mismo nombre de cada cubo, guarda los resultados intermedios y marca el hecho de su ejecución con una bandera isRolled. Tenga en cuenta que el resultado final del lanzamiento no se calcula de inmediato; hay un método especial para esto calculateResult(), cuyo resultado es escribir el valor final en la propiedad result. ¿Por qué se necesita esto? Por un efecto dramático. El método roll()se ejecutará varias veces, cada vez en las caras de los cubos se mostrarán diferentes valores (como en la vida real). Y solo cuando los cubos se calman en la mesa, aprendemos nuestro destino el resultado final (la diferencia entre los valores de los cubos del héroe y los cubos del oponente). Para aliviar el estrés, diré que un resultado de 0 se considerará un pase exitoso de la prueba.

.
, . , «» , (phase), . .

 enum class GamePhase { SCENARIO_START, HERO_TURN_START, HERO_TURN_END, LOCATION_BEFORE_EXPLORATION, LOCATION_ENCOUNTER_STAT, LOCATION_ENCOUNTER_DIVINE, LOCATION_AFTER_EXPLORATION, GAME_LOSS } 

, , . changePhaseX() , X — . , .

.
. - — , ? .

 enum class StatusMessage { EMPTY, CHOOSE_DICE_PERFORM_CHECK, END_OF_TURN_DISCARD_EXTRA, END_OF_TURN_DISCARD_OPTIONAL, CHOOSE_ACTION_BEFORE_EXPLORATION, CHOOSE_ACTION_AFTER_EXPLORATION, ENCOUNTER_PHYSICAL, ENCOUNTER_SOMATIC, ENCOUNTER_MENTAL, ENCOUNTER_VERBAL, ENCOUNTER_DIVINE, DIE_ACQUIRE_SUCCESS, DIE_ACQUIRE_FAILURE, GAME_LOSS_OUT_OF_TIME } 

Como puede ver, todos los estados posibles de nuestro ejemplo se describen mediante los valores de esta enumeración. Para cada uno de ellos, se proporciona una línea de texto, que se mostrará en la pantalla (excepto que EMPTYeste es un significado especial), pero lo veremos más adelante.

Acciones
Para la comunicación entre el usuario y el motor del juego, los mensajes simples no son suficientes. También es importante informar la primera de las acciones que puede tomar en este momento (investigar, pasar los bloqueos, completar el movimiento, eso está bien). Para hacer esto, desarrollaremos una clase especial.

 class Action( val type: Type, var isEnabled: Boolean = true, val data: Int = 0 ) { enum class Type { NONE, //Blank type CONFIRM, //Confirm some action CANCEL, //Cancel action HAND_POSITION, //Some position in hand HAND_ALLY_POSITION, //Some ally position in hand EXPLORE_LOCATION, //Explore current location FINISH_TURN, //Finish current turn ACQUIRE, //Acquire (DIVINE) die FORFEIT, //Remove die from game HIDE, //Put die into bag DISCARD, //Put die to discard pile } } 

Type . isEnabled , . , , , - ( , ). data ( ) , - (, ).

Action «» - ( ). ( ?), (). , , .

 class ActionList : Iterable<Action> { private val actions = mutableListOf<Action>() val size get() = actions.size fun add(action: Action): ActionList { actions.add(action) return this } fun add(type: Action.Type, enabled: Boolean = true): ActionList { add(Action(type, enabled)) return this } fun addAll(actions: ActionList): ActionList { actions.forEach { add(it) } return this } fun remove(type: Action.Type): ActionList { actions.removeIf { it.type == type } return this } operator fun get(index: Int) = actions[index] operator fun get(type: Action.Type) = actions.find { it.type == type } override fun iterator(): Iterator<Action> = ActionListIterator() private inner class ActionListIterator : Iterator<Action> { private var position = -1 override fun hasNext() = (actions.size > position + 1) override fun next() = actions[++position] } companion object { val EMPTY get() = ActionList() } } 

( ), , ( «» get() — ). Iterator all sorts of crazy shit (, ). EMPTY .

.
Finalmente, otra lista que describe los diversos tipos de contenido que se muestran actualmente ... Me miras y parpadeas, lo sé. Cuando comencé a pensar cómo describir más claramente esta clase, me golpeé la cabeza contra la mesa, porque realmente no podía entender nada. Comprendete a ti mismo, espero.

 enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS } 

, . … .

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

, GameRenderer, diseñado para mostrar imágenes en la pantalla. Les recuerdo que hacemos un resumen de los tamaños de pantalla, de bibliotecas gráficas específicas, etc. Simplemente enviamos el comando: "dibuja esto", y aquellos de ustedes que entendieron nuestra conversación sobre las pantallas ya han adivinado que cada una de estas pantallas tiene su propio método dentro de la interfaz.

 interface GameRenderer { fun drawHeroTurnStart(hero: Hero) fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList ) fun drawGameLoss(message: StatusMessage) } 

, — .

— GameInteractor (, , ...). : , , , - . , (- ), , .

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

Sobre el último método un poco más. Como su nombre lo indica, desde invita al usuario a seleccionar cubos de la mano, proporcionando un objeto HandMask: el número de posiciones activas. La ejecución del método continuará hasta que se seleccione alguno de ellos; en este caso, el método devolverá una acción de tipo HAND_POSITION(o HAND_ALLY_POSITION, mda) con el número de la posición seleccionada en el campo data. Además, es posible seleccionar otra acción (por ejemplo, CONFIRMo CANCEL) del objeto ActionList. Las implementaciones de métodos de entrada deben distinguir entre situaciones en las que el campo está isEnabledconfigurado falsee ignorar la entrada del usuario de tales acciones.

Clase de motor de juego.
Examinamos todo lo necesario para el trabajo, ha llegado el momento y el motor para implementar. Crear una claseGame con el siguiente contenido:

Lo sentimos, esto no se debe mostrar a personas impresionables.
 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étodo start(): el punto de entrada al juego. Aquí se inicializan las variables, se pesan los héroes, las manos se llenan de cubos y los periodistas brillan con cámaras desde todos los lados. El ciclo principal se iniciará en cualquier momento, después del cual ya no se puede detener. El método drawInitialHand()habla por sí mismo (no parece que consideremos el código del método de drawOfType()clase Bag, pero después de haber recorrido un largo camino juntos, puede escribir este código usted mismo). El método refillHeroHand()tiene dos opciones (dependiendo del valor del argumento redrawScreen): rápido y silencioso (cuando necesitas llenar las manos de todos los héroes al comienzo del juego), y ruidoso con un montón de pathos, cuando al final del movimiento necesitas quitar los cubos de la bolsa, llevando la mano al tamaño correcto.

Un montón de métodos con nombres que comienzan conchangePhase, como ya dijimos, sirven para cambiar la fase actual del juego y se dedican a la asignación de los valores correspondientes de las variables del juego. Aquí, se forma una lista actionsdonde se agregan las acciones características de esta fase.

El método de utilidad pickDiceFromHand()en una forma generalizada se dedica a la selección de cubos de la mano. Aquí se pasa un objeto de una clase familiar HandMaskRuleque define las reglas de selección. También indica la capacidad de rechazar la selección ( allowCancel), así como una función onEachLoopcuyo código se debe invocar cada vez que se cambia la lista de cubos seleccionados (generalmente una nueva pantalla). Los cubos seleccionados por este método pueden ensamblarse de la mano usando los métodos collectPickedDice()y collectPickedAllyDice().

Otro método de utilidadperformStatDieAcquireCheck() . DieBattleCheck . pickDiceFromHand() ( «» DieBattleCheck ). , «» — ( ), . . ( ), ( shouldDiscard = true ), ( shouldDiscard = false ).

processCycle()contiene un bucle infinito (pregunto sin desmayar) en el que primero se dibuja la pantalla, luego se le solicita al usuario que ingrese, luego esta entrada se procesa, con todas las consecuencias resultantes. El método drawScreen()llama al método de interfaz deseado GameRenderer(dependiendo del valor actual screen), pasándole los objetos requeridos a la entrada.

Además, la clase contiene varios métodos de ayuda: checkLocationCanBeExplored(), checkHeroCanAttemptStatCheck()y checkHeroCanAcquireDie(). Sus nombres hablan por sí mismos, por lo tanto, no nos detendremos en ellos en detalle. Y también hay llamadas a métodos de clase Audio, subrayadas por una línea roja ondulada. Comente por el momento; consideraremos su propósito más adelante.

Quién no entiende nada en absoluto, aquí hay un diagrama (para mayor claridad, por así decirlo):


Eso es todo, el juego está listo (jeje). Había pequeñas cosas reales, sobre ellas a continuación.

Paso nueve. Mostrar imagen


Entonces llegamos al tema principal de la conversación de hoy: el componente gráfico de la aplicación. Como recordarán, nuestra tarea es implementar la interfaz GameRenderery sus tres métodos, y dado que todavía no hay un artista talentoso en nuestro equipo, lo haremos por nuestra cuenta utilizando pseudographics. Pero para empezar, sería bueno entender lo que esperamos ver en la salida. Y queremos ver tres pantallas de aproximadamente los siguientes contenidos:

Pantalla 1. ID de turno del jugador


Pantalla 2. Información sobre el área y el héroe actual.


Pantalla 3. Mensaje de pérdida de guión


Creo que la mayoría ya se dio cuenta de que las imágenes presentadas son diferentes de todo lo que estamos acostumbrados a ver en la consola de las aplicaciones Java, y que las características habituales prinltn()obviamente no serán suficientes para nosotros. También me gustaría poder saltar a lugares arbitrarios en la pantalla y dibujar símbolos en diferentes colores. Los códigos ANSI de Chip y Dale

corren en nuestra ayuda . , : /, , . , — . — , . - , , Jansi :

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

Y puedes comenzar a crear. Esta biblioteca nos proporciona un objeto de clase Ansi(obtenido como resultado de una llamada estática Ansi.ansi()) con un montón de métodos convenientes que se pueden encadenar. Funciona según el principio de StringBuilder'a: primero formamos el objeto y luego lo enviamos a imprimir. De los métodos útiles encontraremos útiles:

  • a() - para mostrar caracteres;
  • cursor() - para mover el cursor en la pantalla;
  • eraseLine() - como si hablara por sí mismo;
  • eraseScreen() - de manera similar;
  • fg(), bg(), fgBright(), bgBright() - métodos muy inconvenientes para trabajar con texto y colores de fondo - haremos los nuestros, más agradables;
  • reset() - para restablecer la configuración de color establecida, parpadeo, etc.

Creemos una clase ConsoleRenderercon métodos de utilidad que nos puedan ser útiles en nuestro trabajo. La primera versión se verá así:

 abstract class ConsoleRenderer() { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { print(ansi.toString()) resetAnsi() } } 

resetAnsi() () Ansi , (, ). , render() , . , ? , .

. 8024. CONSOLE_WIDTH CONSOLE_HEIGHT . ( ). , — , — . , drawHorizontalLine() .

 protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } //for (i in 1..CONSOLE_WIDTH) { ansi.a(filler) } } 

Una vez más, les recuerdo que invocar comandos a()o cursor()no produce ningún efecto instantáneo, sino que solo agrega la Ansisecuencia correspondiente de comandos al objeto . Solo cuando estas secuencias se envían para imprimir las veremos en la pantalla.

No existe una diferencia fundamental entre usar el ciclo clásico fory el enfoque funcional con ClosedRangey forEach{}: cada desarrollador decide por sí mismo qué es más conveniente para él. Sin embargo, continuaré engañando a sus cabezas con el funcionalismo, simplemente porque soy un mono que ama todo lo nuevo y los corchetes brillantes no están envueltos en una nueva línea y el código parece más compacto.

Implementamos otro método de utilidad drawBlankLine()que hace lo mismo quedrawHorizontalLine(offsetY, ' '), solo con extensión. A veces necesitamos dejar la línea vacía, no completamente, pero dejar una línea vertical al principio y al final (cuadro, sí). El código se verá así:

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

¿Cómo, nunca dibujaste cuadros de pseudographics? Los símbolos se pueden insertar directamente en el código fuente. Mantenga presionada la tecla Alt y escriba el código de caracteres en el teclado numérico. Entonces déjalo ir. Los códigos ASCII que necesitamos en cualquier codificación son los mismos, aquí está el conjunto mínimo de caballeros:


Y luego, como en Minecraft, las posibilidades están limitadas solo por los límites de tu imaginación. Y el tamaño de la pantalla.

 protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } 

. Ansi Color (, , , , , , , ), fg()/bg() fgBright()/bgBright() — , , — - ( ). - ( - ):

 protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN ) 

16- . , :

?

« , — . ...»

… . . Java java.util.ResourceBundle , .properties . :

 # Game status messages choose_dice_perform_check=Choose dice to perform check: end_of_turn_discard_extra=END OF TURN: Discard extra dice: end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed: choose_action_before_exploration=Choose your action: choose_action_after_exploration=Already explored this turn. Choose what to do now: encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die. encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die. encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die. encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die. encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed): die_acquire_success=You have acquired the die! die_acquire_failure=You have failed to acquire the die. game_loss_out_of_time=You ran out of time # Die types physical=PHYSICAL somatic=SOMATIC mental=MENTAL verbal=VERBAL divine=DIVINE ally=ALLY wound=WOUND enemy=ENEMY villain=VILLAIN obstacle=OBSTACLE # Hero types and descriptions brawler=Brawler hunter=Hunter # Various labels avg=avg bag=Bag bag_size=Bag size class=Class closed=Closed discard=Discard empty=Empty encountered=Encountered fail=Fail hand=Hand heros_turn=%s's turn max=max min=min perform_check=Perform check: pile=Pile received_new_die=Received new die result=Result success=Success sum=sum time=Time total=Total # Action names and descriptions action_confirm_key=ENTER action_confirm_name=Confirm action_cancel_key=ESC action_cancel_name=Cancel action_explore_location_key=E action_explore_location_name=xplore action_finish_turn_key=F action_finish_turn_name=inish action_hide_key=H action_hide_name=ide action_discard_key=D action_discard_name=iscard action_acquire_key=A action_acquire_name=cquire action_leave_key=L action_leave_name=eave action_forfeit_key=F action_forfeit_name=orfeit 

Cada línea contiene un par clave-valor, separados por un carácter =. Puede colocar el archivo en cualquier lugar; lo principal es que la ruta a él sea parte del classpath. Tenga en cuenta que el texto de las acciones consta de dos partes: la primera letra no solo se resalta en amarillo cuando se muestra en la pantalla, sino que también determina la tecla que debe presionarse para realizar esta acción. Por lo tanto, es conveniente almacenarlos por separado.

Sin embargo, hacemos un resumen de un formato específico (en Android, por ejemplo, las cadenas se almacenan de manera diferente) y describimos la interfaz para cargar constantes de cadena.

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

La clave se transmite a la entrada, la salida es una línea específica. La implementación es tan sencilla como la propia interfaz (supongamos que el archivo se encuentra en la ruta src/main/resources/text/strings.properties).

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

Ahora no será difícil implementar un método drawStatusMessage()para mostrar el estado actual del motor del juego ( StatusMessage) en la pantalla y un método drawActionList()para mostrar una lista de acciones disponibles ( ActionList). Así como otros métodos oficiales que solo el alma desea.

Hay mucho código, parte de él ya lo hemos visto ... así que aquí hay un spoiler para ti
 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] } 

¿Por qué todos hicimos esto, preguntas? Sí, para heredar nuestra implementación de interfaz de esta maravillosa clase GameRenderer.

Diagrama de clase


Así es como se verá la implementación del primer método más 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() } 

Nada sobrenatural, solo una línea de texto ( data) dibujada en rojo en el centro de la pantalla ( drawCenteredCaption()). El resto del código llena el resto de la pantalla con líneas en blanco. Quizás alguien pregunte por qué es necesario: después de todo clearScreen(), hay un método , es suficiente llamarlo al comienzo del método, borrar la pantalla y luego dibujar el texto deseado. Por desgracia, este es un enfoque vago que no utilizaremos. La razón es muy simple: con este enfoque, algunas posiciones en la pantalla se dibujan dos veces, lo que conduce a un parpadeo notable, especialmente cuando la pantalla se dibuja secuencialmente varias veces seguidas (durante las animaciones). Por lo tanto, nuestra tarea no es solo dibujar los caracteres correctos en los lugares correctos, sino completar todoel resto de la pantalla con caracteres vacíos (para que no queden artefactos de otra representación). Y esta tarea no es tan simple.

El siguiente método sigue este principio:

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

Aquí, además del texto centrado, también hay dos líneas horizontales (ver capturas de pantalla arriba). Tenga en cuenta que las letras centrales se muestran en dos colores. Y también asegúrese de que aprender matemáticas en la escuela siga siendo útil.

Bueno, analizamos los métodos más simples y es hora de conocer la implementación drawLocationInteriorScreen(). Como usted mismo comprende, habrá un orden de magnitud más código aquí. Además, el contenido de la pantalla cambiará dinámicamente en respuesta a las acciones del usuario y deberá ser redibujado constantemente (a veces con animación). Bueno, para terminar finalmente: imagina que además de la captura de pantalla anterior, en el marco de este método, es necesario implementar la visualización de tres más:

1. Reunión con el cubo sacado de la bolsa.


2. Seleccionar dados para pasar la prueba


3. Mostrar los resultados de la prueba


Por lo tanto, este es mi gran consejo para usted: no inserte todo el código en un solo método. Divida la implementación en varios métodos (incluso si cada uno de ellos se llamará solo una vez). Bueno, no te olvides de la "goma".

Si comienza a ondularse en sus ojos, parpadee durante un par de segundos; esto debería ayudar
 class ConsoleGameRenderer(loader: StringLoader) : ConsoleRenderer(loader), GameRenderer { private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) { val closedString = loadString("closed").toLowerCase() val timeString = loadString("time") val locationName = location.name.toString().toUpperCase() val separatorX1 = locationName.length + if (location.isOpen) { 6 + if (location.bag.size >= 10) 2 else 1 } else { closedString.length + 7 } val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0 //Top border ansi.cursor(1, 1) ansi.a('┌') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┬' else '─') } ansi.a('┐') //Center row ansi.cursor(2, 1) ansi.a("│ ") if (location.isOpen) { ansi.color(WHITE).a(locationName).reset() ansi.a(": ").a(location.bag.size) } else { ansi.a(locationName).reset() ansi.color(DARK_GRAY).a(" (").a(closedString).a(')').reset() } ansi.a(" │") var currentX = separatorX1 + 2 heroesAtLocation.forEach { hero -> ansi.a(' ') ansi.color(heroColors[hero.type]) ansi.a(if (hero === currentHero) '☻' else '').reset() currentX += 2 } (currentX..separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(timeString).a(": ") when { timer <= 5 -> ansi.color(LIGHT_RED) timer <= 15 -> ansi.color(LIGHT_YELLOW) else -> ansi.color(LIGHT_GREEN) } ansi.bold().a(timer).reset().a(" │") //Bottom border ansi.cursor(3, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┴' else '─') } ansi.a('┤') } private fun drawLocationHeroPanel(offsetY: Int, hero: Hero) { val bagString = loadString("bag").toUpperCase() val discardString = loadString("discard").toUpperCase() val separatorX1 = hero.name.length + 4 val separatorX3 = CONSOLE_WIDTH - discardString.length - 6 - if (hero.discardPile.size >= 10) 1 else 0 val separatorX2 = separatorX3 - bagString.length - 6 - if (hero.bag.size >= 10) 1 else 0 //Top border ansi.cursor(offsetY, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┬' else '─') } ansi.a('┤') //Center row ansi.cursor(offsetY + 1, 1) ansi.a("│ ") ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(" │") val currentX = separatorX1 + 1 (currentX until separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(bagString).a(": ") when { hero.bag.size <= hero.hand.capacity -> ansi.color(LIGHT_RED) else -> ansi.color(LIGHT_YELLOW) } ansi.a(hero.bag.size).reset() ansi.a(" │ ").a(discardString).a(": ") ansi.a(hero.discardPile.size) ansi.a(" │") //Bottom border ansi.cursor(offsetY + 2, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┴' else '─') } ansi.a('┤') } private fun drawDieSize(die: Die, checked: Boolean = false) { when { checked -> ansi.background(dieColors[die.type]).color(BLACK) else -> ansi.color(dieColors[die.type]) } ansi.a(die.toString()).reset() } private fun drawDieFrameSmall(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╗') //Left border ansi.cursor(offsetY + 1, offsetX) ansi.a("║ ") //Bottom border ansi.cursor(offsetY + 2, offsetX) ansi.a("╚") (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╝') //Right border ansi.cursor(offsetY + 1, offsetX + if (longDieSize) 6 else 5) ansi.a('║') } private fun drawDieSmall(offsetX: Int, offsetY: Int, pair: DiePair, rollResult: Int? = null) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameSmall(offsetX, offsetY, longDieSize) //Roll result or die size ansi.cursor(offsetY + 1, offsetX + 1) if (rollResult != null) { ansi.a(String.format(" %2d %s", rollResult, if (longDieSize) " " else "")) } else { ansi.a(' ').a(pair.die.toString()).a(' ') } //Draw modifier ansi.cursor(offsetY + 3, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawDieFrameBig(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╗") //Left border (1..5).forEach { ansi.cursor(offsetY + it, offsetX) ansi.a('║') } //Bottom border ansi.cursor(offsetY + 6, offsetX) ansi.a('╚') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╝") //Right border val currentX = offsetX + if (longDieSize) 20 else 14 (1..5).forEach { ansi.cursor(offsetY + it, currentX) ansi.a('║') } } private fun drawDieSizeBig(offsetX: Int, offsetY: Int, pair: DiePair) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameBig(offsetX, offsetY, longDieSize) //Die size ansi.cursor(offsetY + 1, offsetX + 1) ansi.a(" ████ ") ansi.cursor(offsetY + 2, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 3, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 4, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 5, offsetX + 1) ansi.a(" ████ ") drawBigNumber(offsetX + 8, offsetY + 1, pair.die.size) //Draw modifier ansi.cursor(offsetY + 7, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + 6 * if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length - 1 (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawBattleCheck(offsetY: Int, battleCheck: DieBattleCheck) { val performCheck = loadString("perform_check") var currentX = 4 var currentY = offsetY //Top message ansi.cursor(offsetY, 1) ansi.a("│ ").a(performCheck) (performCheck.length + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border (1..4).forEach { ansi.cursor(offsetY + it, 1) ansi.a("│ ") } //Opponent var opponentWidth = 0 var vsWidth = 0 (battleCheck.getOpponentPair())?.let { //Die if (battleCheck.isRolled) { drawDieSmall(4, offsetY + 1, it, battleCheck.getOpponentResult()) } else { drawDieSmall(4, offsetY + 1, it) } opponentWidth = 4 + if (it.die.size >= 10) 3 else 2 currentX += opponentWidth //VS ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.color(LIGHT_YELLOW).a(" VS ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") vsWidth = 4 currentX += vsWidth } //Clear below for (row in currentY + 5..currentY + 8) { ansi.cursor(row, 1) ansi.a('│') (2 until currentX).forEach { ansi.a(' ') } } //Dice for (index in 0 until battleCheck.heroPairCount) { if (index > 0) { ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.a(if (battleCheck.method == DieBattleCheck.Method.SUM) " + " else " / ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") currentX += 3 } val pair = battleCheck.getHeroPairAt(index) val width = 4 + if (pair.die.size >= 10) 3 else 2 if (currentX + width + 3 > CONSOLE_WIDTH) { //Out of space for (row in currentY + 1..currentY + 4) { ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } currentY += 4 currentX = 4 + vsWidth + opponentWidth } if (battleCheck.isRolled) { drawDieSmall(currentX, currentY + 1, pair, battleCheck.getHeroResultAt(index)) } else { drawDieSmall(currentX, currentY + 1, pair) } currentX += width } //Clear the rest (currentY + 1..currentY + 4).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } if (currentY == offsetY) { //Still on the first line currentX = 4 + vsWidth + opponentWidth (currentY + 5..currentY + 8).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } //Draw result (battleCheck.result)?.let { r -> val frameTopY = offsetY + 5 val result = String.format("%+d", r) val message = loadString(if (r >= 0) "success" else "fail").toUpperCase() val color = if (r >= 0) DARK_GREEN else DARK_RED //Frame ansi.color(color) drawHorizontalLine(frameTopY, '▒') drawHorizontalLine(frameTopY + 3, '▒') ansi.cursor(frameTopY + 1, 1).a("▒▒") ansi.cursor(frameTopY + 1, CONSOLE_WIDTH - 1).a("▒▒") ansi.cursor(frameTopY + 2, 1).a("▒▒") ansi.cursor(frameTopY + 2, CONSOLE_WIDTH - 1).a("▒▒") ansi.reset() //Top message val resultString = loadString("result") var center = (CONSOLE_WIDTH - result.length - resultString.length - 2) / 2 ansi.cursor(frameTopY + 1, 3) (3 until center).forEach { ansi.a(' ') } ansi.a(resultString).a(": ") ansi.color(color).a(result).reset() (center + result.length + resultString.length + 2 until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } //Bottom message center = (CONSOLE_WIDTH - message.length) / 2 ansi.cursor(frameTopY + 2, 3) (3 until center).forEach { ansi.a(' ') } ansi.color(color).a(message).reset() (center + message.length until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } } } private fun drawExplorationResult(offsetY: Int, pair: DiePair) { val encountered = loadString("encountered") ansi.cursor(offsetY, 1) ansi.a("│ ").a(encountered).a(':') (encountered.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') val dieFrameWidth = 3 + 6 * if (pair.die.size >= 10) 3 else 2 for (row in 1..8) { ansi.cursor(offsetY + row, 1) ansi.a("│ ") ansi.cursor(offsetY + row, dieFrameWidth + 4) (dieFrameWidth + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } drawDieSizeBig(4, offsetY + 1, pair) } private fun drawHand(offsetY: Int, hand: Hand, checkedDice: HandMask, activePositions: HandMask) { val handString = loadString("hand").toUpperCase() val alliesString = loadString("allies").toUpperCase() val capacity = hand.capacity val size = hand.dieCount val slots = max(size, capacity) val alliesSize = hand.allyDieCount var currentY = offsetY var currentX = 1 //Hand title ansi.cursor(currentY, currentX) ansi.a("│ ").a(handString) //Left border currentY += 1 currentX = 1 ansi.cursor(currentY, currentX) ansi.a("│ ╔") ansi.cursor(currentY + 1, currentX) ansi.a("│ ║") ansi.cursor(currentY + 2, currentX) ansi.a("│ ╚") ansi.cursor(currentY + 3, currentX) ansi.a("│ ") currentX += 3 //Main hand for (i in 0 until min(slots, MAX_HAND_SIZE)) { val die = hand.dieAt(i) val longDieName = die != null && die.size >= 10 //Top border ansi.cursor(currentY, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) "═" else "") } else { ansi.a("────").a(if (longDieName) "─" else "") } ansi.a(if (i < capacity - 1) '╤' else if (i == capacity - 1) '╗' else if (i < size - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') if (die != null) { drawDieSize(die, checkedDice.checkPosition(i)) } else { ansi.a(" ") } ansi.a(' ') ansi.a(if (i < capacity - 1) '│' else if (i == capacity - 1) '║' else '│') //Bottom border ansi.cursor(currentY + 2, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) '═' else "") } else { ansi.a("────").a(if (longDieName) '─' else "") } ansi.a(if (i < capacity - 1) '╧' else if (i == capacity - 1) '╝' else if (i < size - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i), if (longDieName) " " else "")) ansi.reset() currentX += 5 + if (longDieName) 1 else 0 } //Ally subhand if (alliesSize > 0) { currentY = offsetY //Ally title ansi.cursor(currentY, handString.length + 5) (handString.length + 5 until currentX).forEach { ansi.a(' ') } ansi.a(" ").a(alliesString) (currentX + alliesString.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border currentY += 1 ansi.cursor(currentY, currentX) ansi.a(" ┌") ansi.cursor(currentY + 1, currentX) ansi.a(" │") ansi.cursor(currentY + 2, currentX) ansi.a(" └") ansi.cursor(currentY + 3, currentX) ansi.a(" ") currentX += 4 //Ally slots for (i in 0 until min(alliesSize, MAX_HAND_ALLY_SIZE)) { val allyDie = hand.allyDieAt(i)!! val longDieName = allyDie.size >= 10 //Top border ansi.cursor(currentY, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') drawDieSize(allyDie, checkedDice.checkAllyPosition(i)) ansi.a(" │") //Bottom border ansi.cursor(currentY + 2, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkAllyPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i + 10), if (longDieName) " " else "")).reset() currentX += 5 + if (longDieName) 1 else 0 } } else { ansi.cursor(offsetY, 9) (9 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') ansi.cursor(offsetY + 4, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } //Clear the end of the line (0..3).forEach { row -> ansi.cursor(currentY + row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } override fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList) { //Top panel drawLocationTopPanel(location, heroesAtLocation, currentHero, timer) //Encounter info when { battleCheck != null -> drawBattleCheck(4, battleCheck) encounteredDie != null -> drawExplorationResult(4, encounteredDie) else -> (4..12).forEach { drawBlankLine(it) } } //Fill blank space val bottomHalfTop = CONSOLE_HEIGHT - 11 (13 until bottomHalfTop).forEach { drawBlankLine(it) } //Hero-specific info drawLocationHeroPanel(bottomHalfTop, currentHero) drawHand(bottomHalfTop + 3, currentHero.hand, pickedDice, activePositions) //Separator ansi.cursor(bottomHalfTop + 8, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┤') //Status and actions drawStatusMessage(bottomHalfTop + 9, statusMessage) drawActionList(bottomHalfTop + 10, actions) //Bottom border ansi.cursor(CONSOLE_HEIGHT, 1) ansi.a('└') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┘') //Finalize render() } override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } } 

, . IDE ANSI, ( ). , ANSI Windows — , 10- cmd.exe ( , , ). PowerShell ( ). , — ( , ). .

.


Mostrar la imagen en la pantalla sigue siendo la mitad de la batalla. Es igualmente importante recibir correctamente los comandos de control del usuario. Y quiero decirles que esta tarea puede resultar técnicamente mucho más difícil de implementar que todas las anteriores. Pero lo primero es lo primero.

Como recordarán, nos enfrentamos a la necesidad de implementar métodos de clase GameInteractor. Solo hay tres de ellos, pero requieren atención especial. En primer lugar, la sincronización. El motor del juego debe suspenderse hasta que el jugador presione una tecla. En segundo lugar, haga clic en procesamiento. Por desgracia, la capacidad de las clases estándar Reader, Scanner, Consoleno es suficiente para reconocer estos más urgente: que no requieren que el usuario presione ENTRAR después de cada comando. Necesitamos algo comoKeyListenerPero, pero está estrechamente vinculado al marco Swing, y nuestra aplicación de consola no tiene todo este oropel gráfico.

Que hacerLa búsqueda de bibliotecas, por supuesto, y esta vez su trabajo dependerá completamente del código nativo. ¿Qué significa "adiós, multiplataforma" ... o no? Por desgracia, todavía tengo que encontrar una biblioteca que implemente una funcionalidad simple en una forma liviana e independiente de la plataforma. Mientras tanto, prestemos atención al monstruo jLine , que implementa una cosechadora para construir interfaces de usuario avanzadas (en la consola). Sí, tiene una implementación nativa, sí, es compatible con Windows y Linux / UNIX (al proporcionar las bibliotecas apropiadas). Y sí, que se utiliza en la mayor parte de su funcionalidad, no necesitamos un centenar de años. Todo lo que se necesita es una pequeña oportunidad poco documentada, cuyo trabajo ahora analizaremos.

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

Tenga en cuenta que no necesitamos la tercera, última versión, sino la segunda, donde hay una clase ConsoleReadercon un método readCharacter(). Como su nombre lo indica, este método devuelve el código del carácter presionado en el teclado (mientras trabaja sincrónicamente, que es lo que necesitamos). El resto es una cuestión técnica: compilar una tabla de correspondencias entre símbolos y tipos de acciones ( Action.Type) y, al hacer clic en una, devolver la otra.

“¿Sabes que no todas las teclas del teclado se pueden representar con un solo carácter? Muchas teclas usan secuencias de escape de dos, tres, cuatro caracteres diferentes. ¿Cómo estar con ellos?

, , « »: , F-, Home, Insert, PgUp/Dn, End, Delete, num-pad . , . ConsoleInteractor .

 abstract class ConsoleInteractor { private val reader = ConsoleReader() private val mapper = mapOf( CONFIRM to 13.toChar(), CANCEL to 27.toChar(), EXPLORE_LOCATION to 'e', FINISH_TURN to 'f', ACQUIRE to 'a', LEAVE to 'l', FORFEIT to 'f', HIDE to 'h', DISCARD to 'd', ) protected fun read() = reader.readCharacter().toChar() protected open fun getIndexForKey(key: Char) = "1234567890abcdefghijklmnopqrstuvw".indexOf(key) } 

mapper read() . getIndexForKey() , , . GameInteractor .



, , :

 class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor { override fun anyInput() { read() } override fun pickAction(list: ActionList): Action { while (true) { val key = read() list .filter(Action::isEnabled) .find { mapper[it.type] == key } ?.let { return it } } } override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList) : Action { while (true) { val key = read() actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it } when (key) { in '1'..'9' -> { val index = key - '1' if (activePositions.checkPosition(index)) { return Action(HAND_POSITION, data = index) } } '0' -> { if (activePositions.checkPosition(9)) { return Action(HAND_POSITION, data = 9) } } in 'a'..'f' -> { val allyIndex = key - 'a' if (activePositions.checkAllyPosition(allyIndex)) { return Action(HAND_ALLY_POSITION, data = allyIndex) } } } } } } 

La implementación de nuestros métodos es bastante cortés y bien educada para no poner en evidencia varias tonterías inadecuadas. Ellos mismos verifican que la acción seleccionada esté activa y que la posición de la mano seleccionada se incluye en el conjunto de válidos. Y deseo que todos seamos tan educados con las personas que nos rodean.

Paso once Sonidos y musica


Pero, ¿cómo puede ser sin ellos? Si alguna vez has jugado juegos con el sonido apagado (por ejemplo, con una tableta debajo de las cubiertas mientras nadie en casa ve), es posible que te hayas dado cuenta de cuánto estás perdiendo. Es como jugar solo la mitad del juego. Muchos juegos no se pueden imaginar sin acompañamiento de sonido, para muchos este es un requisito inalienable, aunque hay situaciones inversas (por ejemplo, cuando no hay sonidos en principio, o son tan miserables que sería mejor sin ellos). Hacer un buen trabajo en realidad no es tan simple como parece a primera vista (no sin razón, especialistas altamente calificados lo hacen en grandes estudios), pero sea como sea, en la mayoría de los casos es mucho mejor tener un componente de audio (al menos algunos) en su juego. que no tenerla en absoluto. Como último recurso, la calidad del sonido se puede mejorar más tarde,cuando el tiempo y el humor lo permiten.

, — , . , . , . , . , , — , — , . , : , , — , . , — , — . ? , . , ( , , ).

Con la teoría, al parecer, lo resolvió, ahora es el momento de pasar a la práctica. Y antes de eso necesitas hacer una pregunta: ¿dónde, de hecho, tomar los archivos del juego? La forma más fácil y segura: grabarlos usted mismo en fea calidad, usando un micrófono viejo o incluso usando el teléfono. Internet está lleno de videos sobre cómo desenroscar las puntas de la piña o romper el hielo con una bota puede lograr el efecto de romper huesos y una columna vertebral crujiente. Si no eres ajeno a la estética del surrealismo, puedes usar tu propia voz o utensilios de cocina como instrumento musical (hay ejemplos, e incluso exitosos, donde se hizo esto). O puedes ir a freesound.orgdonde cientos de personas hicieron esto por ti hace mucho tiempo. Preste atención solo a la licencia: muchos autores son muy sensibles a las grabaciones de audio de su fuerte tos o monedas arrojadas al suelo; de ninguna manera desea utilizar sin escrúpulos los frutos de sus trabajos sin pagar al creador original o sin mencionar su seudónimo creativo (a veces muy extraño) en los comentarios

Arrastre los archivos que desee y colóquelos en algún lugar de la ruta de clase. Para identificarlos, utilizaremos la enumeración, donde cada instancia corresponde a un efecto de sonido.

 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 } 

Dado que el método de reproducción de sonidos variará dependiendo de la plataforma de hardware, podemos abstraernos de una implementación específica utilizando la interfaz. Por ejemplo, este:

 interface SoundPlayer { fun play(sound: Sound) } 

Al igual que las interfaces discutidas anteriormente GameRenderery GameInteractor, su implementación también debe pasarse a la entrada a la instancia de clase Game. Para empezar, una implementación podría ser así:

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

Posteriormente, consideraremos implementaciones más interesantes, pero por ahora hablemos de música.
Al igual que los efectos de sonido, juega un papel muy importante en la creación de la atmósfera del juego, y de la misma manera, un juego excelente puede ser arruinado por la música inapropiada. Al igual que los sonidos, la música debe ser discreta, no aparecer en primer plano (excepto cuando sea necesario para un efecto artístico) y corresponder adecuadamente a la acción en la pantalla (no esperes que alguien esté imbuido seriamente del destino de un personaje principal emboscado y asesinado sin piedad héroe, si la escena de su trágica muerte estará acompañada de una pequeña música divertida de una canción infantil). Esto es muy difícil de lograr, las personas especialmente capacitadas se ocupan de tales problemas (no estamos familiarizados con ellos), pero nosotros, como principiantes del genio de la construcción de juegos, también podemos hacer algo. Por ejemplo, ve a algún lugar enfreemusicarchive.org o soundcloud.com (o incluso YouTube) y encuentre algo de su agrado. Para las computadoras de escritorio, el ambiente es una buena opción: música tranquila y suave sin una melodía pronunciada, muy adecuada para crear un fondo. Preste doble atención a la licencia: incluso la música gratuita a veces es escrita por compositores talentosos que merecen, si no una recompensa monetaria, al menos un reconocimiento universal.

Creemos una enumeración más:

 enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, } 

Del mismo modo, definimos la interfaz y su implementación predeterminada.

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

Tenga en cuenta que en este caso se necesitan dos métodos: uno para iniciar la reproducción y el otro para detenerlo. También es bastante posible que métodos adicionales (pausa / reanudar, rebobinar, etc.) sean útiles en el futuro, pero hasta ahora estos dos son suficientes.

Pasar referencias a clases de jugador entre objetos cada vez puede no parecer una solución muy conveniente. Hubo un tiempo en que sólo un jugador ekzepmlyar necesitamos, por lo que me atrevo a sugerir a hacer todo lo necesario para reproducir los sonidos y los métodos de música en un objeto separado y que sea un solitario (Singleton). Por lo tanto, el subsistema de audio responsable siempre está disponible desde cualquier lugar de la aplicación sin transmitir constantemente enlaces a la misma instancia. Se verá así:

Diagrama de clases del sistema de reproducción de audio


Audio — singleton. … , (facade) — , ( ) . , , - . :

 object Audio { private var soundPlayer: SoundPlayer = MuteSoundPlayer() private var musicPlayer: MusicPlayer = MuteMusicPlayer() fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) { this.soundPlayer = soundPlayer this.musicPlayer = musicPlayer } fun playSound(sound: Sound) = this.soundPlayer.play(sound) fun playMusic(music: Music) = this.musicPlayer.play(music) fun stopMusic() = this.musicPlayer.stop() } 

init() - - ( ) , . , , — .

Eso es todo . (, , ), Java AudioSystem Clip . , , - ( classpath, ?):

 import javax.sound.sampled.AudioSystem class BasicSoundPlayer : SoundPlayer { private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav" override fun play(sound: Sound) { val url = javaClass.getResource(pathToFile(sound)) val audioIn = AudioSystem.getAudioInputStream(url) val clip = AudioSystem.getClip() clip.open(audioIn) clip.start() } } 

open() IOException ( - — - ), try-catch , , .

« , ...»

. , (, mp3) Java , ( ). , JLayer . :

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

.

 class BasicMusicPlayer : MusicPlayer { private var currentMusic: Music? = null private var thread: PlayerThread? = null private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3" override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music thread?.finish() Thread.yield() thread = PlayerThread(pathToFile(music)) thread?.start() } override fun stop() { currentMusic = null thread?.finish() } // Thread responsible for playback private inner class PlayerThread(private val musicPath: String) : Thread() { private lateinit var player: Player private var isLoaded = false private var isFinished = false init { isDaemon = true } override fun run() { loop@ while (!isFinished) { try { player = Player(javaClass.getResource(musicPath).openConnection().apply { useCaches = false }.getInputStream()) isLoaded = true player.play() } catch (ex: Exception) { finish() break@loop } player.close() } } fun finish() { isFinished = true this.interrupt() if (isLoaded) { player.close() } } } } 

En primer lugar, esta biblioteca realiza la reproducción sincrónicamente, bloqueando la transmisión principal hasta llegar al final del archivo. Por lo tanto, debemos implementar un hilo separado ( PlayerThread) y hacerlo "opcional" (daemon), de modo que en ningún caso interfiera con la aplicación para finalizar antes. En segundo lugar, el identificador del archivo de música que se está reproduciendo actualmente ( currentMusic) se almacena en el código del reproductor . Si de repente viene un segundo comando para jugarlo, no comenzaremos la reproducción desde el principio. En tercer lugar, al llegar al final del archivo de música, su reproducción comenzará nuevamente, y así sucesivamente hasta que el comando detenga explícitamente la transmisiónfinish()(o hasta que se completen otros hilos, como ya se mencionó). Cuarto, aunque el código anterior está repleto de indicadores y comandos aparentemente innecesarios, se depura y prueba a fondo: el reproductor funciona como se espera, no ralentiza el sistema, no interrumpe repentinamente hasta la mitad, no produce pérdidas de memoria, no contiene objetos genéticamente modificados, brilla frescura y pureza. Tómelo y úselo audazmente en sus proyectos.

Paso doce. Localización


Nuestro juego está casi listo, pero nadie lo jugará. Por qué

"¡No hay ruso! ... ¡No hay ruso! ... ¡Agregue el idioma ruso! ... ¡Desarrollado por perros!"

Abra la página de cualquier juego de historia interesante (especialmente móvil) en el sitio web de la tienda y lea los comentarios. ¿Comenzarán a alabar increíbles gráficos dibujados a mano? ¿O maravillarse con el sonido atmosférico? ¿O discutir una historia emocionante que es adictiva desde el primer minuto y que no la deja ir hasta el final?

NoLos "jugadores" insatisfechos instruirán a un grupo de unidades y generalmente eliminarán el juego. Y luego también requerirán la devolución del dinero, y todo esto por una simple razón. Sí, olvidó traducir su obra maestra a los 95 idiomas del mundo. O más bien, aquel cuyos portadores gritan más fuerte. ¡Y eso es todo! ¿Entiendes?Meses de arduo trabajo, largas noches de insomnio, constantes crisis nerviosas: todo esto es un hámster debajo de la cola. Has perdido una gran cantidad de jugadores y esto no se puede arreglar.

Así que piensa en el futuro. Decida su público objetivo, seleccione varios idiomas principales, solicite servicios de traducción ... en general, haga todo lo que otras personas han descrito más de una vez en artículos temáticos (más inteligentes que yo). Nos centraremos en el aspecto técnico del problema y hablaremos sobre cómo localizar nuestro producto sin problemas.

Primero nos metemos en las plantillas. ¿Recuerdas antes de que los nombres y las descripciones se almacenaran como simples String? Ahora no funcionará. Además del idioma predeterminado, también debe proporcionar traducción a todos los idiomas que planea admitir. Por ejemplo, así:

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

Para las plantillas, este enfoque es bastante adecuado. Si no desea especificar una traducción para ningún idioma, entonces no es necesario: siempre hay un valor predeterminado. Sin embargo, en los objetos finales, no me gustaría abarcar líneas en varios campos diferentes. Por lo tanto, dejaremos uno, pero reemplazaremos su tipo.

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

Y corrija el código del generador en consecuencia.

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

Naturalmente, el mismo enfoque debe aplicarse a los tipos restantes de plantillas. Cuando los cambios están listos, se pueden usar sin dificultad.

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

En nuestro ejemplo, hemos proporcionado una versión simplificada de localización, donde solo se tiene en cuenta el idioma. En general, los objetos de clase Localetambién definen el país y la región. Si esto es importante en su aplicación, entonces la suya LocalizedStringse verá un poco diferente, pero estamos contentos con eso de todos modos.

Nos ocupamos de las plantillas, queda por localizar las líneas de servicio utilizadas en nuestra aplicación. Afortunadamente, ResourceBundleya contiene todos los mecanismos necesarios. Solo es necesario preparar archivos con traducciones y cambiar la forma en que se descargan.

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

No diré para el registro: escribir frases en ruso es mucho más difícil que en inglés. Si hay un requisito para usar un sustantivo en un caso definitivo o para desconectarse del género (y dichos requisitos necesariamente se mantendrán), tendrá que sudar mucho antes de obtener un resultado que, en primer lugar, cumpla con los requisitos y, en segundo lugar, no parece una traducción mecánica hecha por un cyborg con cerebro de pollo También tenga en cuenta que no cambiamos las teclas de acción; como antes, se utilizarán los mismos caracteres para ejecutar este último que en el idioma inglés (que, por cierto, no funcionará en un diseño de teclado que no sea el latino, pero este no es nuestro negocio - por ahora vamos a dejarlo como está).

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" } 
.
Como ya se mencionó, ResourceBundleél mismo asumirá la responsabilidad de encontrar entre los archivos de localización el que más se aproxime a la ubicación actual. Y si no lo encuentra, tomará el archivo predeterminado ( string.properties). Y todo estará bien ...

Si! Ahí estaba!
, 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 - .

Para probar el funcionamiento de la aplicación en diferentes idiomas, no es necesario cambiar la configuración del sistema operativo, solo especifique el idioma requerido al iniciar el JRE:

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

Si todavía está trabajando en Windows, espere problemas
, 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)

Después de todas nuestras emocionantes aventuras, el resultado puede demostrarse con orgullo al público en general y declarar en voz alta: "¡No somos perros!"

Opción fiel racial


Y eso está bien.

Paso trece Poniendo todo junto


Los lectores atentos deben haber notado que mencioné los nombres de paquetes específicos solo una vez y nunca volví a ellos. En primer lugar, cada desarrollador tiene sus propias consideraciones con respecto a qué clase debe ubicarse en qué paquete. En segundo lugar, a medida que trabaja en el proyecto, con la adición de más y más clases nuevas, sus pensamientos cambiarán. En tercer lugar, cambiar la estructura de la aplicación es simple y económico (y los sistemas modernos de control de versiones detectarán la migración, por lo que no perderá el historial), así que siéntase libre de cambiar los nombres de clases, paquetes, métodos y variables; no olvide actualizar solo la documentación (la conserva ¿verdad?

Y todo lo que nos queda es armar y lanzar nuestro proyecto. Como recordará, main()ya creamos un método , ahora lo llenaremos con contenido. Necesitaremos:

  • guión y terreno;
  • Héroes
  • implementación de interfaz GameInteractor;
  • implementación de interfaces GameRenderery StringLoader;
  • implementación de interfaces SoundPlayery MusicPlayer;
  • objeto de la clase Game;
  • una botella de champaña

Vamos!

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

Lanzamos y disfrutamos del primer prototipo funcional. Ahí tienes.

Paso catorce. Equilibrio del juego


Ummm ...

Paso quince Pruebas


Ahora que se ha escrito la mayor parte del código para el primer prototipo funcional, sería bueno agregar un par de pruebas unitarias ...

"¿Cómo? ¿Justo ahora? Sí, las pruebas tenían que escribirse desde el principio, ¡y luego codificar!

Muchos lectores notan correctamente que escribir pruebas unitarias debe preceder al desarrollo del código de trabajo ( TDDy otras metodologías de moda). Otros se indignarán: no hay nada para que las personas engañen a sus cerebros con sus pruebas, incluso si al menos comienzan a desarrollar algo, de lo contrario se perderá toda motivación. Otro par de personas se arrastrarán fuera del espacio en el zócalo y tímidamente dirán: "No entiendo por qué son necesarias estas pruebas, todo funciona para mí" ... Después de eso, serán empujados a la cara con una bota y rápidamente empujados hacia atrás. No comenzaré a iniciar confrontaciones ideológicas (ya están llenas de ellas en Internet) y, por lo tanto, estoy parcialmente de acuerdo con todos. Sí, las pruebas a veces son útiles (especialmente en el código que a menudo cambia o está asociado con cálculos complejos), sí, las pruebas unitarias no son adecuadas para todo el código (por ejemplo, no cubre interacciones con el usuario o sistemas externos), sí, hay más que pruebas unitarias muchos otros tipos de él (bueno, al menos cinco fueron nombrados),y sí, no nos enfocaremos en escribir exámenes: nuestro artículo trata sobre otra cosa.

Digamos: muchos programadores (especialmente principiantes) descuidan las pruebas. Muchos se justifican diciendo que la funcionalidad de sus aplicaciones está mal cubierta por las pruebas. Por ejemplo, es mucho más fácil iniciar la aplicación y ver si todo está en orden con la apariencia y la interacción, en lugar de cercar construcciones complejas con la participación de marcos especializados para probar la interfaz de usuario (y las hay). Y te diré cuando estaba implementando las interfaces Renderer, lo hice. Sin embargo, hay métodos entre nuestro código para los cuales el concepto de pruebas unitarias es excelente.

Por ejemplo, generadores. Y eso es todo. Este es un cuadro negro ideal: las plantillas se envían a la entrada, los objetos del mundo del juego se obtienen en la salida. Hay algo sucediendo dentro, pero tenemos que probarlo. Por ejemplo, así:

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

Más o menos:

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

O incluso así:

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

"¡Alto, alto, alto!" Que es esto Java ??? "

Lo tienes Además, es bueno escribir tales pruebas al principio, antes de comenzar a implementar el generador en sí. Por supuesto, el código bajo prueba es bastante simple y lo más probable es que el método funcione la primera vez y sin ninguna prueba, pero escribir una prueba una vez que lo olvide para siempre lo protegerá de cualquier posible problema en el futuro (cuya solución lleva mucho tiempo, especialmente cuando es desde el momento del desarrollo Han pasado cinco años y ya olvidaste cómo funciona todo dentro del método). Y si de repente un día su proyecto deja de recopilarse debido a las pruebas fallidas, definitivamente sabrá la razón: los requisitos para el sistema han cambiado y sus pruebas anteriores ya no los satisfacen (¿en qué pensó?).

Y una cosa más. Recuerda la claseHandMaskRuley sus herederos? Ahora imagine que en algún momento para usar la habilidad el héroe necesita tomar tres dados de su mano, y los tipos de estos dados están ocupados por restricciones severas (por ejemplo, "el primer dado debe ser azul, verde o blanco, el segundo - amarillo, blanco o azul, y el tercero, azul o púrpura ", ¿sientes la dificultad?). ¿Cómo abordar la implementación de la clase? Bueno ... para empezar, puedes decidir sobre los parámetros de entrada y salida. Obviamente, necesita que la clase acepte tres matrices (o conjuntos), cada una de las cuales contiene tipos válidos para, respectivamente, el primer, segundo y tercer cubos. ¿Y luego que? Revienta? Recursiones? ¿Qué pasa si me pierdo algo? Haz una entrada profunda. Ahora posponga la implementación de métodos de clase y escriba una prueba, ya que los requisitos son simples, comprensibles y bien formalizables.Y mejor escriba algunas pruebas ... Pero consideraremos una, aquí, por ejemplo:

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

Esto es agotador, pero no tanto como parece, hasta que comienzas (en algún momento se vuelve incluso divertido). Pero después de haber escrito tal prueba (y un par de otras, para diferentes ocasiones), de repente se sentirá tranquilo y seguro de sí mismo. Ahora, ningún error tipográfico pequeño estropeará su método y dará lugar a sorpresas desagradables que son mucho más difíciles de probar manualmente. Poco a poco, lentamente, comenzamos a implementar los métodos necesarios de la clase. Y al final ejecutamos la prueba para asegurarnos de que en algún lugar cometimos un error. Encuentra el lugar del problema y reescribe. Repita hasta que esté listo.

 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 tiene ideas sobre cómo implementar dicha funcionalidad más fácilmente, puede hacer comentarios. Y estoy increíblemente contento de haber sido lo suficientemente inteligente como para comenzar a implementar esta clase escribiendo una prueba.

"Y yo <...> también <...> estoy muy <...> contento <...>. ¡Entra! <...> de vuelta! <...> en la brecha! "

Paso dieciseis. Modularidad


Como se esperaba, los niños maduros no pueden estar bajo el refugio de sus padres toda su vida; tarde o temprano deben elegir su propio camino y seguirlo con valentía, superando las dificultades y las interrupciones. Así que los componentes desarrollados por nosotros maduraron tanto que quedaron apretados bajo un mismo techo. Ha llegado el momento de dividirlos en varias partes.

Nos enfrentamos a una tarea bastante trivial. Es necesario dividir todas las clases creadas hasta ahora en tres grupos:

  • funcionalidad básica: módulo, motor de juego, interfaces de conector e implementaciones independientes de la plataforma ( núcleo );
  • plantillas de escenarios, terrenos, enemigos y obstáculos: componentes de la llamada "aventura" ( aventura );
  • implementaciones específicas de interfaces específicas para una plataforma en particular: en nuestro caso, una aplicación de consola ( cli ).

El resultado de esta separación finalmente se verá como el siguiente diagrama:

Al igual que los actores al final del espectáculo, los héroes de hoy en día vuelven a entrar en escena con toda su fuerza.


Cree proyectos adicionales y transfiera la clase correspondiente. Y solo necesitamos configurar correctamente la interacción de los proyectos entre ellos. Proyecto

central
Este proyecto es un motor puro. Todas las clases específicas se transfirieron a otros proyectos; solo quedó la funcionalidad básica, el núcleo. Biblioteca si quieres. Ya no hay una clase de lanzamiento, ni siquiera es necesario construir un paquete. Los ensamblados de este proyecto se alojarán en el repositorio local de Maven (más sobre eso más adelante) y serán utilizados por otros proyectos como dependencias.

El archivo pom.xmles el siguiente:

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

De ahora en adelante lo recogeremos así:

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

Proyecto Cli
Aquí está el punto de entrada a la aplicación: es con este proyecto que el usuario final interactuará. El kernel se usa como una dependencia. Como en nuestro ejemplo estamos trabajando con la consola, el proyecto contendrá las clases necesarias para trabajar con ella (si de repente queremos comenzar el juego en una cafetera, simplemente reemplazamos este proyecto con uno similar con las implementaciones correspondientes). Inmediatamente agregaremos recursos (líneas, archivos de audio, etc.). Las dependencias de las bibliotecas externas se transferirán al

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

Ya hemos visto el script para construir y ejecutar este proyecto, no comenzaremos a repetirlo.

Aventura
Bueno, finalmente, en un proyecto separado sacamos la trama. Es decir, todos los escenarios, terrenos, enemigos y otros objetos únicos del mundo del juego que el personal del departamento de escenarios de su empresa puede imaginar (bueno, o hasta ahora solo nuestra propia imaginación enferma, seguimos siendo el único diseñador de juegos en el área). La idea es agrupar los guiones en conjuntos (aventuras) y distribuir cada uno de ellos como un proyecto separado (similar a cómo se hace en el mundo de los juegos de mesa y videojuegos). Es decir, reúna archivos jar y colóquelos en una carpeta separada para que el motor del juego escanee esta carpeta y conecte automáticamente todas las aventuras contenidas allí. Sin embargo, la implementación técnica de este enfoque está llena de enormes dificultades.

Por donde empezar Bueno, en primer lugar, por el hecho de que distribuimos plantillas en forma de clases específicas de Java (sí, golpéenme y regañenme, lo preví). Y si es así, estas clases deberían estar en el classpath de la aplicación al inicio. Hacer cumplir este requisito no es difícil: registra explícitamente sus archivos jar en la variable de entorno apropiada (comenzando con Java 6, incluso puede usar * - comodines ).

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

“¿Un tonto o qué? ¡Cuando se usa el modificador -jar, se ignora el modificador -classpath! ”

Sin embargo, esto no funcionará. El classpath para los archivos jar ejecutables debe escribirse explícitamente en el archivo interno META-INF/MANIFEST.MF(la sección se llama - Claspath:). Está bien, incluso hay complementos especiales para esto ( maven-compiler-plugin o, en el peor de los casos, maven-assembly-plugin ). Pero los comodines en el manifiesto, por desgracia, no funcionan: tendrá que especificar explícitamente los nombres de los archivos jar dependientes. Es decir, conocerlos de antemano, lo que en nuestro caso es problemático.

Y de todos modos, no quería eso. Quería que el proyecto no tuviera que volver a compilarse. A la carpetaadventures/ , . , Java. . . ? , — - .

, ( , ) , classpath :

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 

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 

Y aquí está el truco. En lugar de usar la clave, -jaragregamos el proyecto Cli al classpath y especificamos explícitamente la clase contenida dentro de él como punto de entrada MainKt. Además, aquí conectamos todos los archivos de la carpeta adventures/.

No es necesario indicar una vez más cuánto es esta decisión torcida. Lo sé, gracias. Mejor sugiera sus ideas en los comentarios. Por favor . (ಥ﹏ಥ)

Paso diecisiete. Parcela


Un poco de letra.
Nuestro artículo trata sobre el aspecto técnico del flujo de trabajo, pero los juegos no son solo código de software. Estos son mundos emocionantes con eventos interesantes y personajes animados, en los que te sumerges con la cabeza, renunciando al mundo real. Cada uno de estos mundos es inusual a su manera e interesante a su manera, muchos de los cuales aún recuerdas, después de muchos años. Si quieres que tu mundo sea recordado también con sentimientos cálidos, hazlo inusual e interesante.

Sé que aquí somos programadores, no guionistas, pero tenemos algunas ideas básicas sobre el componente narrativo del género del juego (jugadores con experiencia, ¿verdad?). Como en cualquier libro, la historia debe tener un ojo (en el que describimos gradualmente el problema que enfrentan los héroes), desarrollo, dos o tres vueltas interesantes, un clímax (el momento más agudo de la trama, cuando los lectores se congelan de emoción y se olvidan de respirar) y desenlace (en qué eventos llegan gradualmente a su conclusión lógica). Evite la subestimación, la falta de fundamento lógico y los agujeros de la trama: todas las líneas iniciadas deben llegar a una conclusión adecuada.

Bueno, leamos nuestra historia a los demás: una mirada imparcial desde un lado a menudo ayuda a comprender las fallas hechas y corregirlas a tiempo.

La trama del juego.
, , . , : ( ) ( ), . , .

— , . , , .

, , - . , , , , . .

Afortunadamente, no soy Tolkien, no resolví el mundo del juego con demasiados detalles, pero intenté hacerlo lo suficientemente interesante y, lo más importante, lógicamente justificado. Al mismo tiempo, se permitió introducir algunas ambigüedades, que cada jugador es libre de interpretar a su manera. Por ejemplo, en ninguna parte se centró en el nivel de desarrollo tecnológico del mundo descrito: el sistema feudal y las instituciones democráticas modernas, los tiranos malvados y los grupos delictivos organizados, el objetivo más alto y la supervivencia banal, los viajes en autobús y las peleas en las tabernas, incluso los personajes disparan por alguna razón: de arcos / ballestas, o de rifles de asalto. En el mundo hay una apariencia de magia (su presencia agrega jugabilidad a las capacidades tácticas) y elementos de misticismo (solo para ser).

Quería alejarme de los clichés de la trama y los bienes de consumo de fantasía: todos estos elfos, gnomos, dragones, señores negros y el mal mundial absoluto (así como: héroes seleccionados, profecías antiguas, super-artefactos, batallas épicas ... aunque estos últimos pueden dejarse). También realmente quería dar vida al mundo, para que cada personaje conocido (incluso uno menor) tuviera su propia historia y motivación, que los elementos de la mecánica del juego se ajustaran a las leyes del mundo, que el desarrollo de los héroes ocurriera naturalmente, que la presencia de enemigos y obstáculos en las ubicaciones estuviera lógicamente justificada por las características de la ubicación misma. ... y así sucesivamente. Desafortunadamente, este deseo jugó una broma cruel, ralentizando mucho el proceso de desarrollo, y no siempre fue posible apartarse de las convenciones de juegos. Sin embargo, la satisfacción del producto final resultó ser un orden de magnitud mayor.

¿Qué quiero decir con todo esto? Una trama interesante bien pensada puede no ser tan necesaria, pero su juego no sufrirá su presencia: en el mejor de los casos, los jugadores lo disfrutarán, en el peor de los casos simplemente lo ignorarán. Y aquellos que son especialmente entusiastas incluso perdonarán a su juego algunas fallas funcionales, solo para descubrir cómo termina la historia.

Que sigue


La programación finaliza y comienza el diseño del juego . Ahora es el momento de no escribir el código, sino pensar en escenarios, ubicaciones, enemigos, ya entiendes, todo esto es un desastre. Si aún trabajas solo, te felicito: has llegado a la etapa en la que se apresuran la mayoría de los proyectos de juegos. En los grandes estudios AAA, personas especiales trabajan como diseñadores y guionistas que reciben dinero para esto, simplemente no tienen a dónde ir. Pero tenemos muchas opciones: salir a caminar, comer, dormir de manera banal, pero qué puede ser, incluso para comenzar un nuevo proyecto utilizando la experiencia y el conocimiento acumulados.

Si todavía está aquí y desea continuar a toda costa, prepárese para las dificultades. Falta de tiempo, pereza, falta de inspiración creativa: algo te distraerá constantemente. No es fácil superar todos estos obstáculos (de nuevo, se han escrito muchos artículos sobre este tema), pero es posible. En primer lugar, le aconsejo que planifique cuidadosamente el desarrollo posterior del proyecto. Afortunadamente, trabajamos para nuestro placer, los editores no nos presionan, nadie exige el cumplimiento de ningún plazo específico, lo que significa que existe la oportunidad de hacer negocios sin prisas innecesarias. Haga una "hoja de ruta" del proyecto, determine las etapas principales y (si tiene el coraje) términos aproximados para su implementación. Consíguete un cuaderno (puedes hacerlo electrónicamente) y escribe constantemente las ideas que surjan en él (incluso de repente despertando en medio de la noche).Marque su progreso con tablas (, ) . : , ( , ) , , ( ) — , , . , , . , — , .

« -, ?»

Prepárese de inmediato para el hecho de que crear el juego perfecto la primera vez no funcionará. Un prototipo que funcione es bueno: al principio mostrará la viabilidad del proyecto, lo convencerá o decepcionará y responderá a una pregunta muy importante: "¿vale la pena continuar?". Sin embargo, no responderá muchas otras preguntas, la principal de las cuales, probablemente: "¿será interesante jugar mi juego a largo plazo?" Hay una gran cantidad de teorías y artículos (bueno, de nuevo) sobre este tema. Un juego interesante debería ser moderadamente difícil, ya que un juego demasiado simple no representa un desafío para el jugador. Por otro lado, si la complejidad es prohibitiva, solo los jugadores incondicionales tercos o las personas que intentan demostrarle algo a alguien permanecerán del público del juego. El juego debe ser bastante diverso, idealmente: proporcionar varias opciones para lograr el objetivo,para que cada jugador elija una opción a su gusto. Una estrategia de pase no debería dominar al resto, de lo contrario solo la usarán ... Y así sucesivamente.

En otras palabras, el juego necesita ser equilibrado. Esto es especialmente cierto en el juego de mesa, donde las reglas están claramente formalizadas. Como hacerlo No tengo idea Si no tienes un amigo matemático que pueda crear un modelo matemático (lo he visto, lo están haciendo) y no entiendes nada al respecto (pero no lo entendemos), entonces la única salida es confiar en la intuición de las pruebas de juego . . — . , , , . — . , , : « feedback!». , - , , — ( , ?) (-).

, … . ( !) — - . (, , ). , — , . .

. . !

«! ? ? , , ?»

.


, Game , MainMenu . , , .



Game , , . . — «Exit».



, ? Sobre eso y el discurso. .

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

La interacción con el usuario se implementa mediante interfaces MenuRenderery MenuInteractorfunciona de manera similar a lo que se vio anteriormente.

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

Como ya entendió, separamos a sabiendas las interfaces de implementaciones específicas. Todo lo que necesitamos ahora es reemplazar el proyecto Cli con un nuevo proyecto (llamémoslo Droid ), agregando una dependencia en el proyecto Core . Hagámoslo

Ejecute Android Studio (por lo general, los proyectos para Android se desarrollan en él), cree un proyecto simple, elimine todo el oropel estándar innecesario y deje solo soporte para el lenguaje Kotlin. También agregamos una dependencia en el proyecto Core , que se almacena en el repositorio local de Maven de nuestra máquina.

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

Sin embargo, de forma predeterminada, nadie verá nuestra dependencia: debe indicar explícitamente la necesidad de utilizar un repositorio local (mavenLocal) al crear el proyecto.

 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- Este será nuestro "lienzo". Con la entrada, es un poco más complicado, ya que ya no tenemos un teclado, y la interfaz debe diseñarse de tal manera que la entrada del usuario en ciertas partes de la pantalla se considere como una entrada de comandos. Para hacer esto, usaremos al mismo heredero View; de esta manera, actuará como intermediario entre el usuario y el motor del juego (similar a cómo la consola del sistema actuó como tal intermediario).

Vamos a crear la actividad principal para nuestra Vista y escribirla en el manifiesto.

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

Arreglamos la actividad en orientación horizontal, como en el caso de la mayoría de los otros juegos, no podremos hacer retratos. Además, lo expandiremos a toda la pantalla del dispositivo, prescribiendo el tema principal en consecuencia.

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

, Cli , :

 <resources> <string name="action_new_adventure_key">N</string> <string name="action_new_adventure_name">ew adventure</string> <string name="action_continue_adventure_key">C</string> <string name="action_continue_adventure_name">ontinue adventure</string> <string name="action_manual_key">M</string> <string name="action_manual_name">anual</string> <string name="action_exit_key">X</string> <string name="action_exit_name">Exit</string> </resources> 

( ), /assets/sound/leave.wav /assets/music/menu_main.mp3 .

, (, ). , , .



, , .

, , — DiceSurface — View , ( SurfaceView — GlSurfaceView — , , , , ). , : , . .

, Renderer . — View, onDraw() , , , . drawMainMenu() MainMenu ? ?

. DiceSurface instructions — , , onDraw() . Renderer , , . , (strategy). :

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

Es decir, toda la funcionalidad gráfica todavía está en la clase Renderer, pero esta vez no ejecutamos directamente los comandos, sino que los preparamos para su ejecución en nuestra Vista. Preste atención al tipo de propiedad instructions: puede crear una interfaz separada y llamar a su único método, pero Kotlin puede reducir significativamente la cantidad de código.

Ahora sobre Interactor. Anteriormente, la entrada de datos se producía sincrónicamente: cuando solicitamos datos de la consola (teclado), la aplicación (ciclos) se detuvo hasta que el usuario presionó una tecla. Con Android, tal truco no funcionará: tiene su propio Looper, cuyo trabajo no debería interrumpir en ningún caso, lo que significa que la entrada debe ser asíncrona. Es decir, los métodos de la interfaz Interactor aún detienen el motor y esperan los comandos, mientras que Activity y toda su Vista continúan funcionando hasta que tarde o temprano envían este comando.

Este enfoque es bastante simple de implementar utilizando una interfaz estándar BlockingQueue. La clase DroidMenuInteractorllamará al método.take(), que suspenderá la ejecución de la transmisión del juego hasta que los elementos (instancias de la clase familiar Action) aparezcan en la cola . DiceSurface, a su vez, se ajustará a los clics del usuario (método de onTouchEvent()clase estándar View), generará objetos y los agregará a la cola mediante el método offer(). Se verá así:

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

Es decir, Interactor llama al método awaitAction()y si hay algo en la cola, procesa el comando recibido. Presta atención a cómo se agregan los equipos a la cola. Dado que la transmisión de la IU se ejecuta continuamente, el usuario puede hacer clic en la pantalla muchas veces seguidas, lo que puede provocar bloqueos, especialmente si el motor del juego no está listo para aceptar comandos (por ejemplo, durante las animaciones). En este caso, ayudará a aumentar la capacidad de la cola y / o disminuir el valor del tiempo de espera.

, , -. , . — Interactor , — Renderer. . DiceSurface — ( , - ). Action . Renderer , onTouchEvent() , , Action .

 private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) { val rect = RectF(left, top, right, bottom) fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h) } 

check() . , Renderer' ( , ) . ( ) 0 1 . , — . .

DiceSurface , ( addRectangle() clearRectangles() ) ( Renderer'), onTouchEvent() , .

 class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>()) private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } fun clearRectangles() { rectangles.clear() } fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) { rectangles.add(ActiveRect(action, left, top, right, bottom)) } fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) { if (this != null) { actionQueue.put(action) } else { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } } } return true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) instructions(canvas, paint) } } 

Se utiliza una colección competitiva para almacenar los rectángulos: permitirá evitar la ocurrencia ConcurrentModificationExceptionsi el conjunto se actualiza y se mueve al mismo tiempo por diferentes hilos (lo que en nuestro caso sucederá).

El código de clase DroidMenuInteractorpermanecerá sin cambios, pero DroidMenuRenderercambiará. Agregue cuatro botones a la pantalla para cada elemento ActionList. Colóquelos debajo del encabezado DICE, distribuidos uniformemente en todo el ancho de la pantalla. Bueno, no nos olvidemos de los rectángulos activos.

 class DroidMenuRenderer ( private val surface: DiceSurface, private val loader: StringLoader ) : MenuRenderer { protected val helper = StringLoadHelper(loader) override fun clearScreen() { surface.clearRectangles() surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) { //Prepare rectangles surface.clearRectangles() val percentage = 1.0f / actions.size actions.forEachIndexed { i, a -> surface.addRectangle(a, i * percentage, 0.45f, i * percentage + percentage, 1f) } //Prepare instructions surface.updateInstructions { c, p -> val canvasWidth = c.width val canvasHeight = c.height val buttonTop = canvasHeight * 0.45f val buttonWidth = canvasWidth / actions.size val padding = canvasHeight / 144f //Draw title text p.textSize = canvasHeight / 3f p.strokeWidth = 0f p.color = Color.parseColor("#ff808000") p.isFakeBoldText = true c.drawText( "DICE", (canvasWidth - p.measureText("DICE")) / 2f, (buttonTop - p.ascent() - p.descent()) / 2f, p ) p.isFakeBoldText = false //Draw action buttons p.textSize = canvasHeight / 24f actions.forEachIndexed { i, a -> p.color = if (a.isEnabled) Color.YELLOW else Color.LTGRAY p.strokeWidth = canvasHeight / 240f c.drawRect( i * buttonWidth + padding, buttonTop + padding, i * buttonWidth + buttonWidth - padding, canvasHeight - padding, p ) val name = mergeActionData(helper.loadActionData(a)) p.strokeWidth = 0f c.drawText( name, i * buttonWidth + (buttonWidth - p.measureText(name)) / 2f, (canvasHeight + buttonTop - p.ascent() - p.descent()) / 2f, p ) } } } private fun mergeActionData(data: Array<String>) = if (data.size > 1) { if (data[1].first().isLowerCase()) data[0] + data[1] else data[1] } else data.getOrNull(0) ?: "" } 

StringLoader StringLoadHelper ( ). ResourceStringLoader () . , — .

 class ResourceStringLoader(context: Context) : StringLoader { private val packageName = context.packageName private val resources = context.resources override fun loadString(key: String): String = resources.getString(resources.getIdentifier(key, "string", packageName)) } 

. MediaPlayer , . :

 class DroidMusicPlayer(private val context: Context): MusicPlayer { private var currentMusic: Music? = null private val player = MediaPlayer() override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music player.setAudioStreamType(AudioManager.STREAM_MUSIC) val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3") player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) player.setOnCompletionListener { it.seekTo(0) it.start() } player.prepare() player.start() } override fun stop() { currentMusic = null player.release() } } 

Dos puntos En primer lugar, el método prepare()se ejecuta sincrónicamente, lo que con un gran tamaño de archivo (debido al almacenamiento en búfer) suspenderá el sistema. Se recomienda que lo ejecute en un hilo separado o que utilice el método asincrónico prepareAsync()y OnPreparedListener. En segundo lugar, sería bueno asociar la reproducción con el ciclo de vida de la actividad (pausa cuando el usuario minimiza la aplicación y reanuda la recuperación), pero no lo hicimos. Ai-ai-ai ...

También es MediaPlayeradecuado para sonidos , pero si son pocos y simples (como en nuestro caso), funcionará SoundPool. Su ventaja es que cuando los archivos de sonido ya están cargados en la memoria, su reproducción comienza instantáneamente. La desventaja es obvia: puede que no haya suficiente memoria (pero para nosotros somos modestos).

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

Al crear una clase, todos los sonidos de la enumeración Soundse cargan en el repositorio en una secuencia separada. Esta vez no usamos una colección sincronizada, pero implementamos el mutex usando la clase estándar ReentrantReadWriteLock.

Ahora, finalmente, ocultamos todos los componentes juntos dentro del nuestro MainActivity, ¿no se olvidó de esto? Tenga en cuenta que MainMenu(y Gameposteriormente) debe iniciarse en un hilo separado.

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

Eso, de hecho, es todo. Después de todo el tormento, la pantalla principal de nuestra aplicación se ve simplemente increíble:

El menú principal en toda la pantalla móvil.


Bueno, es decir, se verá increíble cuando un artista inteligente aparezca en nuestras filas, y con su ayuda, esta miseria se redibujará por completo.

Enlaces utiles


, . — . , — . , , . ( , ):


Bueno, de repente, alguien tendrá el deseo de comenzar y ver el proyecto, y acumular pereza por su cuenta, aquí hay un enlace a la versión de trabajo: ¡ENLACE!

Aquí, se utiliza un iniciador conveniente para iniciar (puede escribir un artículo separado sobre su creación). Utiliza JavaFX y, por lo tanto, puede no iniciarse en máquinas con OpenJDK (escritura y ayuda), pero al menos elimina la necesidad de registrar manualmente las rutas de los archivos. La ayuda de instalación está contenida en el archivo readme.txt (¿recuerda eso?). Descargar, ver, usar, y finalmente estoy en silencio.

, , , - , , , lore , . . , , . .

Todo lo mejor

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


All Articles