Cómo escribir un zapador en Phaser y ejecutar una tarea de prueba de desarrollador HTML5

Buenas tardes, queridos colegas!

Mi nombre es Alexander, soy desarrollador de juegos HTML5.

En una de las compañías donde envié mi currículum, me pidieron que completara una tarea de prueba. Acepté y, después de 1 día, envié como resultado el juego desarrollado de acuerdo con TOR HTML5.



Como estoy entrenando en programación de juegos, así como para un uso más eficiente de mi código, decidí que sería útil escribir un artículo de capacitación sobre el proyecto completado. Y dado que la prueba completa recibió una evaluación positiva y condujo a una invitación a una entrevista, probablemente mi decisión tiene derecho a existir y, posiblemente, ayudará a alguien en el futuro.

Este artículo dará una idea de la cantidad de trabajo suficiente para completar con éxito la tarea de prueba promedio para la posición HTML5 del desarrollador. El material también puede ser de interés para cualquiera que quiera familiarizarse con el marco Phaser. Y si ya está trabajando con Phaser y escribiendo en JS, vea cómo desarrollar un proyecto en TypeScript.

Entonces, ¡debajo de cat hay mucho código TypeScript!

Introduccion


Damos una breve declaración del problema.

  1. Desarrollaremos un juego HTML5 simple: un zapador clásico.
  2. Como herramientas principales usaremos phaser 3, mecanografiado y webpack.
  3. El juego estará diseñado para el escritorio y se ejecutará en el navegador.

Proporcionamos enlaces al proyecto final.

Enlaces a la demostración y fuente

Y recuerda la mecánica del zapador, si de repente alguien olvida las reglas del juego. Pero dado que este es un caso poco probable, las reglas se colocan bajo el spoiler :)

Reglas de zapador
El campo de juego consiste en celdas dispuestas en una mesa. Por defecto, cuando comienza el juego, todas las celdas están cerradas. Se colocan bombas en algunas de las celdas.

Al hacer clic izquierdo en una celda cerrada, se abre. Si hubo una bomba en una celda abierta, entonces el juego termina en derrota.

Si no había una bomba en la celda, se muestra un número dentro de ella, que indica la cantidad de bombas que hay en las celdas vecinas en relación con la apertura actual. Si no hay bombas cerca, entonces la celda se ve vacía.

Al hacer clic con el botón derecho en una celda cerrada, se marca una bandera. La tarea del jugador es organizar todas las banderas disponibles para que marquen todas las celdas minadas. Después de colocar todas las banderas, el jugador presiona el botón izquierdo del mouse en una de las celdas abiertas para verificar si ganó.

A continuación, vamos directamente al manual en sí. Todo el material se divide en pequeños pasos, cada uno de los cuales describe la implementación de una tarea específica en poco tiempo. Entonces, realizando pequeños objetivos paso a paso, al final crearemos un juego completo. Use la tabla de contenido si decide ir rápidamente a un paso específico.


1. Preparación


1.1 Plantilla de proyecto


Descargue la plantilla de proyecto phaser predeterminada . Esta es la plantilla recomendada por el autor del marco y nos ofrece la siguiente estructura de directorios:
index.htmlPágina HTML que inicia el juego
paquete web /base.jsconfiguración de compilación para entorno de prueba
prod.jsconfiguración de compilación para producción
src /activos /activos del juego (sprites, sonidos, fuentes)
index.jspunto de entrada
Para nuestro proyecto, no necesitamos el archivo index.js actual, así que elimínelo. Luego cree el directorio /src/scripts/ y coloque el archivo vacío index.ts en él. Agregaremos todos nuestros scripts a esta carpeta.
También vale la pena tener en cuenta que al crear un proyecto para producción, se creará un directorio dist en la raíz, en el que se colocará la versión de lanzamiento.

1.2 Configuración de compilación


Utilizaremos webpack para el montaje. Como nuestra plantilla se preparó originalmente para trabajar con JavaScript, y escribimos en TypeScript, necesitamos realizar pequeños cambios en la configuración del recopilador.

En el webpack/base.js agregue la clave de entry , que indica el punto de entrada al construir nuestro proyecto, así como la configuración de ts-loader que describe las reglas para construir scripts TS:

 // webpack/base.js //... module.exports = { entry: './src/scripts/index.ts', // ... resolve: { extensions: [ '.ts', '.tsx', '.js' ] }, module: { rules: [{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }, //... 

También necesitaremos crear el archivo tsconfig.json en la raíz del proyecto. Para mí tiene el siguiente contenido:

 { "compilerOptions": { "module": "commonjs", "lib": [ "dom", "es5", "es6", "es2015", "es2017", "es2015.promise" ], "target": "es5", "skipLibCheck": true }, "exclude": ["node_modules", "dist"] } 

1.3 Instalación de módulos


Instale todas las dependencias de package.json y agregue los módulos mecanografiado y ts-loader:

 npm i npm i typescript --save-dev npm i ts-loader --save-dev 

Ahora el proyecto está listo para comenzar el desarrollo. Tenemos 2 comandos a nuestra disposición que ya están definidos en la propiedad de scripts en el archivo package.json .

  1. Cree un proyecto para la depuración y ábralo en un navegador a través de un servidor local

     npm start 
  2. Ejecute la compilación para la venta y coloque la compilación de lanzamiento en la carpeta dist /

     npm run build 

1.4 Preparación de activos


Todos los recursos para este juego se descargan honestamente de OpenGameArt (versión 61x61) y tienen la más amigable de las licencias llamadas Feel free to use , que la página con el paquete nos dice cuidadosamente). Por cierto, ¡el código presentado en el artículo tiene la misma licencia! ;)

Eliminé la imagen del reloj del conjunto descargado y cambié el nombre del resto de los archivos para obtener nombres de cuadros que sean fáciles de usar. La lista de nombres y los archivos correspondientes se muestran en la pantalla a continuación.

A partir de los sprites resultantes, crearemos un atlas de formato Phaser JSONArray en el programa TexturePacker (hay una versión gratuita más que suficiente, todavía no he conseguido trabajo) y spritesheet.json archivos spritesheet.png y spritesheet.json generados en el directorio src/assets/ project.



2. Creando escenas


2.1 Punto de entrada


Comenzamos el desarrollo creando el punto de entrada descrito en la configuración del paquete web.

 // src/scripts/index.ts import * as Phaser from "phaser"; new Phaser.Game({ type: Phaser.AUTO, parent: "minesweeper", width: window.innerWidth, height: window.innerHeight, backgroundColor: "#F0FFFF", scene: [] }); 

Dado que el juego que tenemos está diseñado para el escritorio y ocupará toda la pantalla, utilizamos audazmente todo el ancho y alto del navegador para los campos de width y height .
El campo de scene es actualmente una matriz vacía y lo arreglaremos.

2.2 Escena de inicio


Cree la clase de la primera escena en el src/scripts/scenes/StartScene.ts :

 export class StartScene extends Phaser.Scene { constructor() { super('Start'); } public preload(): void { } public create(): void { } } 

Para una herencia válida de Phaser.Scene pasamos el nombre de la escena como parámetro al constructor de la clase padre.

Esta escena combinará la funcionalidad de precarga de recursos y la pantalla de inicio, invitando al usuario al juego.

Por lo general, en mis proyectos, un jugador pasa por dos escenas antes de llegar a la inicial, en este orden:

 Boot => Preload => Start 

Pero en este caso, el juego es tan simple, y hay tan pocos recursos que no hay razón para colocar la precarga en una escena separada y aún más lo hace el cargador de Boot inicial separado.

Cargaremos todos los activos en el método de preload . Para poder trabajar con el atlas creado en el futuro, debemos realizar 2 pasos:

  1. obtener archivos png y json atlas usando require :

     // StartScene.ts const spritesheetPng = require("./../../assets/spritesheet.png"); const spritesheetJson = require("./../../assets/spritesheet.json"); // ... 

  2. cárguelos en el método de preload de la escena de inicio:

     // StartScene.ts // ... public preload(): void { this.load.atlas("spritesheet", spritesheetPng, spritesheetJson); } // ... 


2.3 Textos de la escena inicial


En la escena inicial, quedan 2 cosas por hacer:

  1. dile al jugador cómo comenzar el juego
  2. iniciar el juego por iniciativa del jugador

Para cumplir con el primer punto, primero creamos dos enumeraciones al comienzo del archivo de escena para describir los textos y sus estilos:

 // StartScene.js enum Texts { Title = 'Minesweeper HTML5', Message = 'Click anywhere to start' } enum Styles { Color = '#008080', Font = 'Arial' } //... 

Y luego cree ambos textos como objetos en el método de create . Permítame recordarle que el método de create de escenas en Phaser se llamará solo después de cargar todos los recursos en el método de preload y esto es bastante adecuado para nosotros.

 // StartScene.js //... public create(): void { this.add.text( this.cameras.main.centerX, this.cameras.main.centerY - 100, Texts.Title, {font: `52px ${Styles.Font}`, fill: Styles.Color}) .setOrigin(0.5); this.add.text( this.cameras.main.centerX, this.cameras.main.centerY + 100, Texts.Message, {font: `28px ${Styles.Font}`, fill: Styles.Color}) .setOrigin(0.5); } //... 

En otro proyecto más grande, podríamos poner los textos y estilos en archivos locales de json o en configuraciones separadas, pero dado que ahora solo tenemos 2 líneas, considero que este paso es redundante y en este caso sugiero no complicar nuestras vidas, limitándonos a los listados al comienzo del archivo de escena.

2.4 Transición al nivel del juego


Lo último que haremos en esta escena antes de continuar es rastrear el evento de clic del mouse para lanzar al jugador al juego:

 // StartScene.js //... public create(): void { //... this.input.once('pointerdown', () => { this.scene.start('Game'); }); } //... 

Escena de 2.5 niveles


A juzgar por el parámetro "Game" pasado al método this.scene.start ya this.scene.start que era hora de crear una segunda escena, que procesaría la lógica principal del juego. Cree el archivo src/scripts/scenes/GameScene.ts :

 export class GameScene extends Phaser.Scene { constructor() { super('Game'); } public create(): void { } } 

En esta escena, no necesitamos el método de preload , porque Ya hemos cargado todos los recursos necesarios en la escena anterior.

2.6 Configuración de escenas en el punto de entrada


Ahora que ambas escenas están creadas, agréguelas a nuestro punto de entrada
src/scripts/index.ts :

 //... import { StartScene } from "./scenes/StartScene"; import { GameScene } from "./scenes/GameScene"; //... new Phaser.Game({ // ... scene: [StartScene, GameScene] }); 

3. objetos del juego


Entonces, la clase GameScene implementará la lógica de nivel de juego. ¿Y qué esperamos del nivel de juego zapador? Visualmente, esperamos ver un campo de juego con celdas cerradas. Sabemos que el campo es una tabla, lo que significa que tiene un número dado de filas y columnas, en varias de las cuales se colocan cómodamente bombas. Por lo tanto, tenemos suficiente información para crear una entidad separada que describa el campo de juego.

3.1 Tablero de juego


Cree el archivo src/scripts/models/Board.ts en el que src/scripts/models/Board.ts la clase Board :

 import { Field } from "./Field"; export class Board extends Phaser.Events.EventEmitter { private _scene: Phaser.Scene = null; private _rows: number = 0; private _cols: number = 0; private _bombs: number = 0; private _fields: Field[] = []; constructor(scene: Phaser.Scene, rows: number, cols: number, bombs: number) { super(); this._scene = scene; this._rows = rows; this._cols = cols; this._bombs = bombs; this._fields = []; } public get cols(): number { return this._cols; } public get rows(): number { return this._rows; } } 

Hagamos que la clase sea la sucesora de Phaser.Events.EventEmitter para acceder a la interfaz para registrar y llamar a eventos, que necesitaremos en el futuro.

Una matriz de objetos de la clase Field se almacenará en la propiedad privada _fields . Implementaremos este modelo más tarde.

Configuramos propiedades numéricas privadas _rows y _cols para indicar el número de filas y columnas del campo de juego. Crea _rows públicos para leer _rows y _cols .

El campo _bombs nos dice la cantidad de bombas que deberán generarse para el nivel. Y en el parámetro _scene pasamos una referencia al objeto de la escena del juego GameScene , en el que crearemos una instancia de la clase Board .

Vale la pena señalar que transferimos el objeto de escena al modelo solo para su posterior transmisión a las vistas, donde lo usaremos solo para mostrar la vista. El hecho es que el phaser usa directamente el objeto de escena para renderizar sprites y, por lo tanto, nos obliga a proporcionar un enlace a la escena actual al crear prefabricados de sprites, que desarrollaremos en el futuro. Y por nosotros mismos, aceptaremos el acuerdo de que transferimos el enlace a la escena solo para su uso posterior como motor de visualización y aceptaremos que no llamaremos directamente a los métodos personalizados de la escena en modelos y vistas.

Una vez que hayamos decidido la interfaz de creación del tablero, propongo inicializarla en la escena de nivel, finalizando la clase GameScene :

  // GameScene.ts import { Board } from "../models/Board"; const Rows = 8; const Cols = 8; const Bombs = 8; export class GameScene extends Phaser.Scene { private _board: Board = null; //... public create(): void { this._board = new Board(this, Rows, Cols, Bombs); } } 

Llevamos los parámetros del tablero a constantes al comienzo del archivo de escena y los pasamos al constructor del Board al crear una instancia de esta clase.

3.2 Modelo de celda


El tablero consta de celdas, que desea mostrar en la pantalla. Cada celda debe colocarse en la posición correspondiente, determinada por la fila y la columna.

Las celdas también se seleccionan como una entidad separada. Cree el archivo src/scripts/models/Field.ts en el que src/scripts/models/Field.ts la clase que describe la celda:

 import { Board } from "./Board"; export class Field extends Phaser.Events.EventEmitter { private _scene: Phaser.Scene = null; private _board: Board = null; private _row: number = 0; private _col: number = 0; constructor(scene: Phaser.Scene, board: Board, row: number, col: number) { super(); this._init(scene, board, row, col); } public get col(): number { return this._col; } public get row(): number { return this._row; } public get board(): Board { return this._board; } private _init(scene: Phaser.Scene, board: Board, row: number, col: number): void { this._scene = scene; this._board = board; this._row = row; this._col = col; } } 

Cada celda debe tener métricas de fila y columna en las que se encuentra. Configuramos los parámetros _board y _scene para establecer enlaces a los objetos del tablero y la escena. Implementamos getters para leer los _row , _col y _board .

3.3 Vista de celda


Se crea la celda abstracta y ahora queremos visualizarla. Para mostrar una celda en la pantalla, debe crear su vista. Cree el archivo src/scripts/views/FieldView.ts y coloque la clase de vista en él:

 import { Field } from "../models/Field"; export class FieldView extends Phaser.GameObjects.Sprite { private _model: Field = null; constructor(scene: Phaser.Scene, model: Field) { super(scene, 0, 0, 'spritesheet', 'closed'); this._model = model; this._init(); this._create(); } private _init(): void { } private _create(): void { } } 

Tenga en cuenta que convertimos esta clase en descendiente de Phaser.GameObjects.Sprite . En términos de fase, esta clase se ha convertido en un sprite prefabricado. Es decir, obtuve la funcionalidad del objeto de juego del sprite, que ampliaremos aún más con nuestros propios métodos.

Veamos el constructor de esta clase. Aquí, en primer lugar, debemos llamar al constructor de la clase padre con los siguientes conjuntos de parámetros:

  • enlace al objeto de la escena (como advertí en la sección 3.1: el phaser requiere que nos vinculemos a la escena actual para renderizar sprites)
  • coordenadas y en lienzo
  • la clave de cadena para la que está disponible el atlas, que cargamos en el método de preload de la escena de inicio
  • la clave de cadena de fotograma en este atlas que desea seleccionar para mostrar el sprite

Establezca una referencia al modelo (es decir, una instancia de la clase Field ) en la propiedad privada _model .

También iniciamos prudentemente 2 _create _init y _create actualmente vacíos, que implementaremos un poco más tarde.

3.4 Crear un sprite en una clase de vista


Entonces, se creó la vista, pero ella todavía no sabe cómo dibujar un sprite. Para colocar el sprite con el marco que necesitamos en el lienzo, deberá modificar nuestro propio método privado _create :

 // FieldView.js //... private _create(): void { this.scene.add.existing(this); //      this.setOrigin(0.5); //  pivot point    } //... 

3.5 Posicionamiento de sprites


Por el momento, todos los sprites creados se colocarán en las coordenadas (0, 0) del lienzo. También necesitamos colocar cada celda en su posición correspondiente en el tablero. Es decir, al lugar que corresponde a la fila y columna de esta celda. Para hacer esto, necesitamos escribir un código para calcular las coordenadas de cada instancia de la clase FieldView .

Agregue la propiedad _position a la clase, que es responsable de las coordenadas finales de la celda en el campo de juego:

 // FieldView.ts //... interface Vec2 {x: number, y: number}; export class FieldView extends Phaser.GameObjects.Sprite { private _position: Vec2 = {x: 0, y: 0}; //... 

Dado que queremos alinear el tablero y, en consecuencia, las celdas en él, en relación con el centro de la pantalla, también necesitamos la propiedad _offset , que indica el desplazamiento de esta celda en particular en relación con los bordes izquierdo y superior de la pantalla. Agréguelo con un captador privado:

 // FieldView.ts //... private get _offset(): Vec2 { return { x: (this.scene.cameras.main.width - this._model.board.cols * this.width) / 2, y: (this.scene.cameras.main.height - this._model.board.rows * this.height) / 2 }; } //... 

Por lo tanto, nosotros:

  1. this._scene.cameras.main.width ancho total de la pantalla en this._scene.cameras.main.width .
  2. Obtuvimos el ancho total de la placa multiplicando el número de celdas por el ancho de una celda: this._board.cols * this.width .
  3. Quitando el ancho del tablero del ancho de la pantalla, obtuvimos un lugar en la pantalla, no ocupado por el tablero.
  4. Dividiendo el número resultante por 2, obtuvimos el valor de sangría a la izquierda y a la derecha del tablero.
  5. Al cambiar cada celda por el valor de esta sangría, garantizamos la alineación de toda la placa a lo largo del eje x .

Realizamos acciones absolutamente similares para obtener desplazamiento vertical.

Queda por agregar el código necesario en el método _init :

 // FieldView.ts // ... private _init(): void { const offset = this._offset; this.x = this._position.x = offset.x + this.width * this._model.col + this.width / 2; this.y = this._position.y = offset.y + this.height * this._model.row + this.height / 2; } // ... 

Las propiedades this.x , this.y , this.width y this.height aquí son las propiedades heredadas de la clase Phaser.GameObjects.Sprite . Cambiar las propiedades de this.x this.y conduce a la correcta colocación del sprite en el lienzo.

3.6 Crear una instancia de FieldView


Cree una vista en la clase Field :

 // Field.ts // ... private _view: FieldView = null; public get view(): FieldView { return this._view; } private _init(scene: Phaser.Scene, board: Board, row: number, col: number): void { //... this._view = new FieldView(this._scene, this); } // ... 

3.7 Mostrar los campos del tablero.


Volvamos a la clase Board , que es esencialmente una colección de objetos Field y creará celdas.

_create código de creación de la placa en un método _create separado y llamaremos a este método desde el constructor. Sabiendo que en el método _create no solo crearemos celdas, _createFields el código para crear celdas en un método _createFields separado.

 // Board.ts constructor(scene: Phaser.Scene, rows: number, cols: number, bombs: number) { // ... this._create(); } private _create(): void { this._createFields(); } private _createFields(): void { } 

Es en este método que crearemos el número deseado de celdas en un bucle anidado:

 // Board.ts // ... private _createFields(): void { for (let row = 0; row < this._rows; row++) { for (let col = 0; col < this._cols; col++) { this._fields.push(new Field(this._scene, this, row, col)); } } } //... 

Es la primera vez que se ejecuta el ensamblado para la depuración con el comando

 npm start 

Asegúrese de que en el centro de la pantalla se espera que veamos 64 celdas en 8 filas.

3.8 Fabricando bombas


Anteriormente, informé que en el método _create de la clase Board , no solo crearemos campos. Que mas También habrá la creación de bombas y la configuración de las células creadas para el número de bombas vecinas. Comencemos con las bombas mismas.

Necesitamos colocar N bombas en el tablero en celdas aleatorias. Describimos el proceso de creación de bombas con un algoritmo aproximado:

                          

En cada iteración del bucle, obtendremos una celda aleatoria de la propiedad this._fields hasta que creemos tantas bombas como se especifica en el campo this._bombs . Si la celda recibida está vacía, instalaremos una bomba en ella y actualizaremos el contador de las bombas necesarias para la generación.

Para generar un número aleatorio, utilizamos el método estático Phaser.Math.Between .

 // Board.ts //... private _createBombs(): void { let count = this._bombs; //      while (count > 0) { //       let field = this._fields[Phaser.Math.Between(0, this._fields.length - 1)]; //    if (field.empty) { //     field.setBomb(); //     --count; //    } } } 

No olvides escribir la llamada a this._createBombs(); en el archivo Board.ts this._createBombs(); al final del método _create

Como ya notó, para que este código funcione correctamente, debe refinar la clase Field agregando el getter empty y el método setBomb .

Agregue un campo privado _value a la _value Field, que regulará el contenido de la celda. Aceptamos los siguientes acuerdos.
_value === 0la celda está vacía y no hay minas ni valores en ella
_value === -1hay una mina en la celda
_value > 0en la celda es el número de minas ubicadas al lado de la celda actual

Siguiendo estas reglas, desarrollaremos métodos en la clase Field que funcionen con la propiedad _value :

 // Field.ts // ... private _value: number = 0; // ... public get value(): number { return this._value; } public set value(value) { this._value = value; } public get empty(): boolean { return this._value === 0; } public get mined(): boolean { return this._value === -1; } public get filled(): boolean { return this._value > 0; } public setBomb(): void { this._value = -1; } // ... 

3.9 Configuración de valores


Las bombas están ordenadas y ahora tenemos todos los datos para establecer los valores numéricos en todas las celdas que lo requieren.

Permítame recordarle que de acuerdo con las reglas del zapador, la celda debe tener el número que corresponde al número de bombas ubicadas al lado de esta celda. En base a esta regla, escribimos el pseudocódigo correspondiente.

                    

En la clase Board , cree un nuevo método y traduzca el pseudocódigo especificado en código real:

 // Board.ts //... private _createValues() { //      this._fields.forEach(field => { //      if (field.mined) { //     field.getClosestFields().forEach(item => { //      if (item.value >= 0) { ++item.value; } }); } }); } //... 

Veamos cuál de las interfaces que utilizamos no está implementada. getClosestFields agregar el método getClosestFields para obtener las celdas vecinas.

¿Cómo identificar las células vecinas?

Por ejemplo, considere cualquier celda del tablero que no esté en el borde, es decir, no en la fila extrema y no en la columna extrema. Dichas celdas tienen un número máximo de vecinos: 1 en la parte superior, 1 en la parte inferior, 3 a la izquierda y 3 a la derecha (incluidas las celdas en diagonal).

Por lo tanto, en cada una de las celdas vecinas, los indicadores _row y _col no difieren en más de 1. Esto significa que podemos especificar de antemano la diferencia entre los parámetros _row y _col con el campo actual. Agregue una constante al comienzo del archivo a la descripción de la clase:

 // Field.ts const Positions = [ {row : 0, col : 1}, //  {row : 0, col : -1}, //  {row : 1, col : 0}, //  {row : 1, col : 1}, //   {row : 1, col : -1}, //   {row : -1, col : 0}, //  {row : -1, col : 1}, //   {row : -1, col : -1} //   ]; //... 

Y ahora podemos agregar el método faltante, en el que recorreremos esta matriz:

 // Field.ts //... public getClosestFields(): Field[] { let results = []; //      Positions.forEach(position => { //      let field = this._board.getField(this._row + position.row, this._col + position.col); //       if (field) { //     results.push(field); } }); return results; }; //... 

No olvide verificar la variable de field en cada iteración, ya que no todas las celdas en el tablero tienen 8 vecinos. Por ejemplo, la celda superior izquierda no tendrá vecinos a su izquierda, y así sucesivamente.

Queda por implementar el método getField y agregar todas las llamadas necesarias al método _create en la clase Board

 // Board.ts //... public getField(row: number, col: number): Field { return this._fields.find(field => field.row === row && field.col === col); } //... private _create(): void { this._createFields(); this._createBombs(); this._createValues(); } //... 

4. Manejo de eventos de entrada


4.1 Seguimiento de eventos de clic del mouse


Por el momento, el tablero está completamente inicializado, tiene bombas y hay celdas con números, pero todas están cerradas y no hay forma de abrirlas. Corregiremos esto e implementaremos la apertura de celdas haciendo clic en el botón izquierdo del mouse.

Primero, necesitamos rastrear este mismo clic. En la clase, FieldViewagregue el _createsiguiente código al final del método :

 // FielView.ts //... private _create(): void { // ... this.setInteractive(); } //... 

En phaser, puede suscribirse a objetos del espacio de nombres para diferentes eventos Phaser.GameObjects. En particular, suscribiremos al evento click ( pointerdown) el prefabricado del propio sprite, es decir, un objeto de una clase FieldViewheredada de Phaser.GameObjects.Sprite.

Pero antes de hacer esto, debemos indicar explícitamente que el sprite es potencialmente interactivo, es decir, generalmente necesita escuchar la entrada del usuario sobre él. Debe hacer esto llamando al método setInteractivesin parámetros en el propio sprite, lo que hicimos en el ejemplo anterior.

Ahora que nuestro sprite se ha vuelto interactivo, volvamos a la clase Boarden el lugar donde se crean los nuevos objetos modelo Field, es decir, al método _createFieldsy registremos la devolución de llamada para los eventos de entrada para la vista:

 // Board.ts //... private _createFields(): void { for (let row = 0; row < this._rows; row++) { for (let col = 0; col < this._cols; col++) { const field = new Field(this._scene, this, row, col) field.view.on('pointerdown', this._onFieldClick.bind(this, field)); this._fields.push(field); } } } //... 

Una vez que hayamos establecido que al hacer clic en el sprite queremos ejecutar el método _onFieldClick, necesitamos implementarlo. Pero eliminaremos la lógica de procesar el clic de la clase Board. Se cree que es mejor procesar el modelo según la entrada y, en consecuencia, cambiar sus datos en un controlador separado, cuya similitud es la clase de la escena del juego GameScene. Por lo tanto, debemos reenviar el evento de clic más lejos, desde la clase Boarda la escena misma. Entonces haremos:

 // Board.ts //... private _onFieldClick(field: Field, pointer: Phaser.Input.Pointer): void { if (pointer.leftButtonDown()) { this.emit(`left-click`, field); } else if (pointer.rightButtonDown()) { this.emit(`right-click`, field); } } //... 

Aquí no solo lanzamos el evento de clic como lo fue, sino que también especificamos qué clic fue. Esto será útil en el futuro, cuando en la clase de escena procesaremos cada opción de manera diferente. Por supuesto, sería posible enviar el evento de clic tal como está, pero simplificaremos el código de escena, dejando parte de la lógica con respecto al evento en sí en la clase Field.

Bueno, ahora volvamos a la clase de la escena del juego GameSceney agreguemos un _createcódigo al final del método que rastrea los eventos de un clic en las celdas:

 // Board.ts //... import { Field } from "../models/Field"; //... public create(): void { this._board = new Board(this, Rows, Cols, Bombs); this._board.on('left-click', this._onFieldClickLeft, this); this._board.on('right-click', this._onFieldClickRight, this); } private _onFieldClickLeft(field: Field): void { } private _onFieldClickRight(field: Field): void { } //... 

4.2. Procesamiento de clic izquierdo


Procedemos a implementar el procesamiento de eventos de clic del mouse. Y comience abriendo las celdas. Las celdas deben abrirse presionando el botón izquierdo. Y antes de comenzar a programar, expresemos las condiciones que deben cumplirse:

  1. Al hacer clic en una celda cerrada, debe abrirse
  2. si hay una mina en una celda abierta, el juego se pierde
  3. si no hay minas o valores en la celda abierta, entonces min no está en las celdas vecinas, en este caso necesita abrir todas las celdas vecinas y continuar así hasta que el valor aparezca en la celda abierta
  4. cuando haces clic en una celda abierta, debes verificar si todas las banderas están configuradas correctamente y, de ser así, finalizar el juego con una victoria

Y ahora, para simplificar la comprensión de la funcionalidad requerida, traducimos la lógica anterior en pseudocódigo:

                           

Ahora tenemos una comprensión de lo que necesita ser programado. Implementamos el método _onFieldClickLeft:

 // GameScene.ts //... private _onFieldClickLeft(field: Field): void { if (field.closed) { //    field.open(); //   if (field.mined) { //    field.exploded = true; this._onGameOver(false); //   } else if (field.empty) { //    this._board.openClosestFields(field); //   } } else if (field.opened) { //    if (this._board.completed) { //       this._onGameOver(true); //   } } } //... 

Y luego, como siempre, finalizaremos las clases Fielde Boardimplementaremos los métodos que llamamos en el controlador.

Indicamos 3 estados posibles de la celda en la enumeración States, agregamos un campo _statee implementamos un captador para cada estado posible:

 // Field.ts enum States { Closed = 'closed', Opened = 'opened', Marked = 'flag' }; export class Field extends Phaser.Events.EventEmitter { private _state: string = States.Closed; //... public get marked(): boolean { return this._state === States.Marked; } public get closed(): boolean { return this._state === States.Closed; } public get opened(): boolean { return this._state === States.Opened; } //... 

Ahora que tenemos estados que indican si la celda está cerrada o no, podemos agregar un método openque cambiará el estado:

 // Field.ts //... public open(): void { this._setState(States.Opened); } private _setState(state: string): void { if (this._state !== state) { this._state = state; this.emit('change'); } } //... 

Cada cambio en el estado del modelo debe desencadenar un evento que informa esto. Por lo tanto, presentamos un método privado adicional _setStateen el que se implementará toda la lógica del cambio de estado. Este método se llamará en todos los métodos públicos del modelo, que deberían cambiar su estado.

Agregue una bandera booleana _explodedpara indicar explícitamente exactamente el objeto Field que se hizo explotar:

 // Field.ts private _exploded: boolean = false; //... public set exploded(exploded: boolean) { this._exploded = exploded; this.emit('change'); } public get exploded(): boolean { return this._exploded; } //... 

Ahora abra la clase Boarde implemente el método en ella openClosestFields. Este método es recursivo y su tarea será abrir todos los campos vecinos vacíos en relación con la celda aceptada en el parámetro.
El algoritmo será el siguiente:

  :                 

Y esta vez ya tenemos todas las interfaces necesarias para la implementación completa de este método:

 // Board.ts //... public openClosestFields(field: Field): void { field.getClosestFields().forEach(item => {//     if (item.closed) {//    item.open();//   if (item.empty) {//    this.openClosestFields(item);//     } } }); } //... 

Agregue un captador completeda la clase Boardpara indicar la colocación correcta de banderas en el tablero. ¿Cómo podemos determinar si un tablero se ha limpiado con éxito? El número de campos marcados correctamente debe ser igual al número total de bombas en el tablero.

 // Board.ts //... public get completed(): boolean { return this._fields.filter(field => field.completed).length === this._bombs; } //... 

Este método filtra la matriz _fieldspor getter completed, que debe indicar la validez de la marca de campo. Si la longitud de la matriz filtrada (en la que solo caen correctamente los campos marcados, de los cuales el captador completedya es responsable de la clase Field) es igual al valor del campo _bombs(es decir, el número de bombas en el tablero), entonces volvemos true, en otras palabras, consideramos que el juego ganó.
Tampoco nos importa la oportunidad de abrir todo el tablero con una sola llamada, lo que tenemos que hacer al final del nivel. También agregaremos esta característica a la clase Board:

 // Board.ts //... public open(): void { this._fields.forEach(field => field.open()); } //... 

Queda por agregar un captador completeda la clase misma Field. ¿En qué caso el campo se considerará borrado con éxito? Si está minado y marcado. Ambos captadores necesarios ya están allí y podemos agregar este método:

 // Field.ts //... public get completed(): boolean { return this.marked && this.mined; } //... 

Para completar el procesamiento del clic izquierdo del mouse, crearemos un método _onGameOveren el que deshabilitemos el seguimiento de los eventos del tablero y le mostraremos al jugador todo el tablero. Más adelante también agregaremos un código de representación del informe de finalización de estado basado en el parámetro status.

 // GameScene.ts //... private _onGameOver(status: boolean) { this._board.off('left-click', this._onFieldClickLeft, this); this._board.off('right-click', this._onFieldClickRight, this); this._board.open(); } //... 

4.3 Visualización de campo


Antes de comenzar a procesar el clic derecho, aprenderemos a volver a dibujar las celdas recién abiertas.

Anteriormente en la clase, Fielddesarrollamos un método _setStateque dispara un evento changecuando cambia el estado del modelo. Usaremos esto y en la clase FieldViewrastrearemos este evento:

 // FieldView.ts //... private _init(): void { //... this._model.on('change', this._onStateChange, this); } private _onStateChange(): void { this._render(); } private _render(): void { this.setFrame(this._frameName); } //... 

Específicamente convertimos el método intermedio en una _onStateChangedevolución de llamada del evento de cambio de modelo. En el futuro, necesitaremos verificar cómo se cambió el modelo para comprender si es necesario realizarlo _render.

Para mostrar el sprite actual de una celda en un nuevo estado, debe cambiar su marco. Como cargamos el atlas como activos, podemos llamar al método setFramepara cambiar el marco actual a uno nuevo.

Para obtener el marco en una línea, utilizamos astutamente el getter _frameName, que ahora debe implementarse. Primero, describimos todos los valores posibles que puede tomar un marco de celda.
MarcoCondición
closedEl campo esta cerrado
flagCampo marcado
emptyEl campo está abierto, no extraído o lleno de valor.
exploded
el campo está abierto, explotado y explotado
mined
el campo está abierto, minado, pero no explotado
1...9
el campo está abierto y muestra un valor del 1 al 9, que indica el número de bombas al lado de este campo

Obtuvimos una descripción de todos los estados y ya tenemos todos los métodos del modelo, gracias a los cuales se pueden obtener estos estados. Vamos a obtener una pequeña configuración al comienzo del archivo:

 // FieldView.ts const States = { 'closed': field => field.closed, 'flag': field => field.marked, 'empty': field => field.opened && !field.mined && !field.filled, 'exploded': field => field.opened && field.mined && field.exploded, 'mined': field => field.opened && field.mined && !field.exploded } //... 

Las claves en este objeto serán los valores de los marcos, y los valores de estas claves son las devoluciones de llamada que devuelven un resultado booleano. En base a esta configuración, podemos desarrollar un método para obtener el marco deseado (es decir, la clave de la configuración):

 // FieldView.ts //... private get _frameName(): string { for (let key in States) { if (States[key](this._model)) { return key; } } return this._model.value.toString(); } 

Por lo tanto, mediante una simple enumeración en un bucle, revisamos todas las claves del objeto de configuración y llamamos a cada devolución de llamada a su vez. La función que nos devuelve primero trueindicará que la clave keyen la iteración actual es el marco correcto para el estado actual del modelo.

Si ninguna clave es adecuada, entonces para el estado predeterminado consideraremos un campo abierto con un valor _value, ya Statesque no configuramos este estado en la configuración .

Ahora podemos probar completamente el clic izquierdo en los campos del tablero y verificar cómo se abren las celdas y qué se muestra después de abrirlas.

4.4 Procesamiento de clic derecho


Como en el caso de crear el controlador de clic izquierdo, primero definimos claramente la funcionalidad esperada. Al hacer clic derecho, debemos marcar la celda seleccionada con una bandera. Pero hay ciertas condiciones.

  1. Solo se puede marcar un campo cerrado que no está marcado actualmente
  2. Si el campo está marcado, entonces un clic derecho nuevamente eliminará la bandera del campo
  3. Al configurar / eliminar una bandera, es necesario actualizar el número de banderas disponibles en el nivel y mostrar el texto con el número actual

Traduciendo estas condiciones en pseudocódigo, obtenemos las siguientes líneas de comentarios:

                                

Ahora podemos traducir este algoritmo en llamadas a los métodos que necesitamos, incluso si aún no se han desarrollado:

 // GameScene.ts private _flags: number = 0; //... private _onFieldClickRight(field: Field): void { if (field.closed && this._flags > 0) { //        field.addFlag(); //     } else if (field.marked) { //     field.removeFlag(); //   } this._flags = Bombs - this._board.countMarked; } //... public create(): void { this._flags = Bombs; //... } //... 

Aquí también comenzamos un nuevo campo _flags, que al comienzo del nivel del juego es igual al número de bombas en el tablero, ya que al comienzo del juego no se ha establecido una sola bandera. Este campo está obligado a actualizarse con cada clic derecho, ya que en este caso la bandera se agrega o se elimina del tablero. Agrega un Boardcaptador a la clase countMarked:

 // Board.ts //... public get countMarked(): number { return this._fields.filter(field => field.marked).length; } //... 

Establecer y eliminar el indicador es un cambio en el estado del modelo Field, por lo que implementamos estos métodos en la clase correspondiente de manera similar al método open:

 // Field.ts //... public addFlag(): void { this._setState(States.Marked); } public removeFlag(): void { this._setState(States.Closed); } //... 

Permítame recordarle que _setStateactivará un evento changeque se rastrea en la vista y, por lo tanto, el sprite se volverá a dibujar automáticamente esta vez cuando cambie el modelo.

Al probar la funcionalidad desarrollada, seguramente encontrará que cada vez que hace clic con el botón derecho del mouse, se abre un menú contextual. Agregue el código que deshabilita este comportamiento al constructor de la escena del juego:

 // GameScene.ts //... constructor() { super('Game'); //        document.querySelector("canvas").oncontextmenu = e => e.preventDefault(); } //... 

4.5 Objeto GameSceneView


Para mostrar la interfaz de usuario en la escena del juego, crearemos una clase GameSceneViewy la colocaremos src/scripts/views/GameSceneView.ts.

En este caso, actuaremos de una manera diferente a la creación FieldViewy no haremos de esta clase un prefabricado y un heredero GameObjects.
En este caso, necesitamos generar los siguientes elementos desde la vista de escena:

  • texto en el número de banderas
  • botón de salida
  • Mensaje de estado de finalización del juego (ganar / perder)

Hagamos que cada elemento de la IU sea un campo separado en la clase GameSceneView.
Vamos a preparar un trozo.

 enum Styles { Color = '#008080', Font = 'Arial' } enum Texts { Flags = 'FLAGS: ', Exit = 'EXIT', Success = 'YOU WIN!', Failure = 'YOU LOOSE' }; export class GameSceneView { private _scene: Phaser.Scene = null; private _style: {font: string, fill: string}; constructor(scene: Phaser.Scene) { this._scene = scene; this._style = {font: `28px ${Styles.Font}`, fill: Styles.Color}; this._create(); } private _create(): void { } public render() { } } 

Agregue texto con el número de banderas.

 // GameSceneView.ts //... private _txtFlags: Phaser.GameObjects.Text = null; //... private _createTxtFlags(): void { this._txtFlags = this._scene.add.text( 50, 50, Texts.Flags, this._style ).setOrigin(0, 1); } //... 

Este código colocará el texto que necesitamos en una posición con sangría de 50 px desde los lados superior e izquierdo y lo configurará al estilo especificado. Además, el método setOriginestablece el punto de pivote del texto en las coordenadas (0, 1). Esto significa que el texto se alineará con su borde izquierdo.

Agrega un mensaje de estado.

 // GameSceneView.ts //... private _txtStatus: Phaser.GameObjects.Text = null; //... private _createTxtStatus(): void { this._txtStatus = this._scene.add.text( this._scene.cameras.main.centerX, 50, Texts.Success, this._style ).setOrigin(0.5, 1); this._txtStatus.visible = false; } //... 

Colocamos el texto de estado en el centro de la pantalla y lo alineamos con el centro de la línea llamando setOriginal parámetro 0.5 para la coordenada x. Además, de manera predeterminada, este texto debe estar oculto, ya que solo lo mostraremos al finalizar el juego.

Cree un botón de salida, que en esencia es también un objeto de texto.

 // GameSceneView.ts //... private _btnExit: Phaser.GameObjects.Text = null; //... private _createBtnExit(): void { this._btnExit = this._scene.add.text( this._scene.cameras.main.width - 50, 50, Texts.Exit, this._style ).setOrigin(1); this._btnExit.setInteractive(); this._btnExit.once('pointerdown', () => { this._scene.scene.start('Start'); }); } //... 

Ponemos el botón en la esquina superior derecha de la pantalla y lo usamos nuevamente setOriginpara alinear el texto esta vez con su borde derecho. Hacemos que el botón sea interactivo y agregamos una devolución de llamada al evento de clic, que envía al jugador a la escena inicial. Por lo tanto, le damos al jugador la oportunidad de salir del nivel en cualquier momento.

Queda por desarrollar un método renderpara actualizar correctamente todos los elementos de la interfaz de usuario y agregar llamadas a todos los métodos creados en _create.

 // GameSceneView.ts //... private _create(): void { this._createTxtFlags(); this._createTxtStatus(); this._createBtnExit(); } public render(data: {flags?: number, status?: boolean}) { if (typeof data.flags !== 'undefined') { this._txtFlags.text = Texts.Flags + data.flags.toString(); } if (typeof data.status !== 'undefined') { this._txtStatus.text = data.status ? Texts.Success : Texts.Failure; this._txtStatus.visible = true; } } //... 

Dependiendo de la propiedad pasada en el parámetro, actualizamos la IU, mostrando los cambios necesarios.
Cree una representación en la escena del juego en la clase GameScene y escriba la llamada al método _render siempre que sea necesario con el significado:

 // GameScene.ts //... import { GameSceneView } from "../views/GameSceneView"; //... export class GameScene extends Phaser.Scene { private _view: GameSceneView = null; //... private _onGameOver(status: boolean) { //... this._view.render({status}); } //... private _onFieldClickRight(field: Field): void { //... this._flags = Bombs - this._board.countMarked; this._view.render({flags: this._flags}); } //... public create(): void { //... this._view = new GameSceneView(this); this._view.render({flags: this._flags}); } //... } 

5. Animaciones


¿Qué tipo de fanático de crear un juego, incluso tan simple como el nuestro, si no hay animaciones en él? Además, desde que comenzamos a estudiar el phaser, conozcamos las características más básicas de las animaciones y consideremos la funcionalidad de los gemelos. Los gemelos se implementan en el marco mismo y no se requieren bibliotecas de terceros.

Agregue 2 animaciones al juego: llenando el tablero con celdas al comienzo y volteando la celda en la apertura. Comencemos con el primero de estos.

5.1 Animación de relleno de tablero




Nos aseguramos de que todas las celdas del tablero vuelen en su lugar desde el borde superior izquierdo de la pantalla. Al comenzar el nivel del juego, debemos mover todas las celdas a la esquina superior izquierda de la pantalla y para que cada celda comience la animación del movimiento a sus coordenadas correspondientes.

En la clase, FiledViewagregue la _createllamada al final de los métodos _animateShow:

 // FieldView.ts //... private _create(): void { //... this._animateShow(); } //... 

B implementamos el nuevo método que necesitamos. En él, como acordamos anteriormente, es necesario realizar 2 cosas:

  1. mueva la celda detrás de la esquina superior izquierda para que no sea visible en la pantalla
  2. iniciar el movimiento gemelo a las coordenadas deseadas con el retraso correcto

 // FieldView.ts //... private _animateShow(): Promise<void> { this.x = -this.width; this.y = -this.height; const delay = this._model.row * 50 + this._model.col * 10; return this._moveTo(this._position, delay); } //... 

Dado que la esquina superior izquierda del lienzo tiene coordenadas (0, 0), si configuramos la celda con las coordenadas iguales a sus valores negativos de ancho y alto, esto colocará la celda detrás de la esquina superior izquierda y la ocultará de la pantalla. Por lo tanto, completamos nuestra primera tarea.

Alcanzarás el segundo objetivo llamando al método _moveTo.

 // FieldView.ts //... private _moveTo(position: Vec2, delay: number): Promise<void> { return new Promise(resolve => { this.scene.tweens.add({ targets: this, x: position.x, y: position.y, duration: 600, ease: 'Elastic', easeParams: [1, 1], delay, onComplete: () => { resolve(); } }); }); } //... 

Para crear una animación, usamos la propiedad de escena tweens. En su método, addpasamos el objeto de configuración con la configuración:

  • La propiedad targetsaquí debe contener como valor aquellos objetos del juego a los que desea aplicar efectos de animación. En nuestro caso, este es un enlace thisal objeto actual, ya que es un prefabricado del sprite.
  • El segundo y tercer parámetro pasamos las coordenadas del destino.
  • La propiedad durationes responsable de la duración de la animación, en nuestro caso, 600 ms.
  • Parámetros easey easeParamsestablecer la función de relajación.
  • En el campo de retraso, sustituimos el valor del segundo argumento, que se genera para cada celda individual, teniendo en cuenta su posición en el tablero. Esto se hace para que las células no salgan volando al mismo tiempo. En cambio, cada celda aparecerá con un ligero retraso en relación con la anterior.
  • Finalmente, onCompletecolocamos una devolución de llamada en la propiedad , que se llamará al final de la acción de interpolación.

Es razonable envolver al gemelo en una promesa para que en el futuro pueda acoplar maravillosamente diferentes animaciones, por lo que colocaremos una llamada a la función en la devolución de llamada que resolveindica la ejecución exitosa de la animación.

5.2 Animaciones de volteo de celda




Será genial si, cuando se abrió la celda, se reprodujo el efecto de su inversión. ¿Cómo podemos lograr esto?

La apertura de una celda se realiza actualmente cambiando el marco cuando se llama al método _renderen la vista. Si verificamos el estado del modelo en este método, veremos si la celda estaba abierta. Si la celda estaba abierta, inicie la animación en lugar de mostrar instantáneamente un nuevo marco de inversión.

 // FieldView.ts //... private _onStateChange(): void { if (this._model.opened) { this._animateFlip(); } else { this._render(); } } //... 

Para obtener el efecto deseado, utilizaremos la transformación del sprite a través de la propiedad scale. Si escalamos el sprite a lo largo del eje xa cero con el tiempo , eventualmente se reducirá, conectando los lados izquierdo y derecho. Y viceversa, si escala el sprite a lo largo del eje xde cero a su ancho completo, lo estiramos a su tamaño completo. Implementamos esta lógica en el método _animateFlip.

 // FieldView.ts //... private _animateFlip(): void { this._scaleXTo(0).then(() => { this._render(); this._scaleXTo(1); }) } //... 

Por analogía con el método, _moveToimplementamos _scaleTo:

 // FieldView.ts //... private _scaleXTo(scaleX: number): Promise<void> { return new Promise(resolve => { this.scene.tweens.add({ targets: this, scaleX, ease: 'Elastic.easeInOut', easeParams: [1, 1], duration: 150, onComplete: () => { resolve() } }); }); } //... 

En este método, como parámetro, tomamos el valor de la escala, que usaremos para cambiar el tamaño del sprite en ambas direcciones y pasarlo como un segundo parámetro al objeto de configuración de animación. Todos los demás parámetros de configuración ya nos son familiares de la animación anterior.

¡Ahora comenzaremos el proyecto para probar y después de la depuración consideraremos que nuestro juego está completo y la tarea de prueba completada! :)

¡Agradezco sinceramente a todos por haber llegado a este momento conmigo!

Conclusión


Colegas, me alegraría mucho si el material presentado en el artículo les resulta útil y pueden utilizar estos o los enfoques descritos en sus propios proyectos. Siempre puede recurrir a mí con cualquier pregunta, tanto en este artículo como en la programación por fases o trabajar en gamedev en general. ¡Agradezco la comunicación y me alegrará hacer nuevos conocidos e intercambiar experiencias!

Y tengo una pregunta para ti ahora mismo. Como estoy creando video tutoriales sobre el desarrollo de juegos, naturalmente acumulé una docena de estos pequeños juegos. Cada juego abre el marco a su manera. Por ejemplo, en este juego tocamos el tema de los gemelos, pero hay muchas otras características, como física, mapa de mosaicos, columna vertebral, etc.
A este respecto, la pregunta es: ¿te gustó este artículo y, de ser así, te interesaría seguir leyendo artículos como este, pero sobre otros pequeños juegos clásicos? Si la respuesta es sí, con mucho gusto traduciré los materiales de mis videos tutoriales a formato de texto y continuaré publicando nuevos manuales con el tiempo, pero para otros juegos. Traigo la encuesta correspondiente.

¡Gracias a todos por su atención! Estaré encantado de recibir comentarios y hasta pronto!

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


All Articles