Como escrever um sapador no Phaser e executar uma tarefa de teste do desenvolvedor HTML5

Boa tarde, queridos colegas!

Meu nome é Alexander, sou desenvolvedor de jogos HTML5.

Em uma das empresas para as quais enviei meu currículo, fui solicitado a concluir uma tarefa de teste. Concordei e, após 1 dia, enviei como resultado o jogo desenvolvido de acordo com o TOR HTML5.



Como estou treinando em programação de jogos, bem como para um uso mais eficiente do meu código, decidi que seria útil escrever um artigo de treinamento sobre o projeto concluído. E como o teste concluído recebeu uma avaliação positiva e levou a um convite para uma entrevista, provavelmente minha decisão tem o direito de existir e, possivelmente, ajudará alguém no futuro.

Este artigo dará uma idéia da quantidade de trabalho suficiente para concluir com êxito a tarefa de teste média para a posição HTML5 do desenvolvedor. O material também pode ser de interesse para quem deseja se familiarizar com a estrutura da Phaser. E se você já estiver trabalhando com a Phaser e escrevendo em JS - veja como desenvolver um projeto no TypeScript.

Portanto, no cat, há muito código TypeScript!

1. Introdução


Damos uma breve declaração do problema.

  1. Vamos desenvolver um jogo HTML5 simples - um sapador clássico.
  2. Como principais ferramentas, usaremos o phaser 3, typescript e webpack.
  3. O jogo será projetado para a área de trabalho e será executado no navegador.

Fornecemos links para o projeto final.

Links para a demonstração e fonte

E lembre-se da mecânica do sapador, se de repente alguém esquecer as regras do jogo. Mas, como esse é um caso improvável, as regras são colocadas sob o spoiler :)

Regras do Sapper
O campo de jogo consiste em células dispostas em uma mesa. Por padrão, quando o jogo é iniciado, todas as células são fechadas. Bombas são colocadas em algumas células.

Ao clicar com o botão esquerdo em uma célula fechada, ela é aberta. Se houve uma bomba em uma célula aberta, o jogo termina em derrota.

Se não havia bomba na célula, um número é exibido dentro dela, indicando o número de bombas que estão nas células vizinhas em relação à corrente aberta. Se não houver bombas por perto, a célula parecerá vazia.

Clicar com o botão direito do mouse em uma célula fechada define uma sinalização nela. A tarefa do jogador é organizar todas as bandeiras disponíveis para que ele marque todas as células minadas. Depois de colocar todas as bandeiras, o jogador pressiona o botão esquerdo do mouse em uma das células abertas para verificar se ganhou.

Em seguida, vamos diretamente ao próprio manual. Todo o material é dividido em pequenas etapas, cada uma das quais descreve a implementação de uma tarefa específica em um curto espaço de tempo. Assim, realizando pequenos objetivos passo a passo, no final, criaremos um jogo completo. Use o índice se decidir ir rapidamente para uma etapa específica.


1. Preparação


1.1 Modelo de Projeto


Faça o download do modelo de projeto phaser padrão . Este é o modelo recomendado pelo autor da estrutura e oferece a seguinte estrutura de diretórios:
index.htmlPágina HTML iniciando o jogo
webpack /base.jsconstruir configuração para ambiente de teste
prod.jsconstruir configuração para produção
src /ativos /recursos do jogo (sprites, sons, fontes)
index.jsponto de entrada
Para o nosso projeto, não precisamos do arquivo index.js atual; portanto, exclua-o. Em seguida, crie o diretório /src/scripts/ e coloque o arquivo index.ts vazio nele. Adicionaremos todos os nossos scripts a esta pasta.
Também é importante ter em mente que, ao criar um projeto para produção, um diretório dist será criado na raiz, no qual a compilação do release será colocada.

1.2 Configuração da compilação


Usaremos o webpack para montagem. Como nosso modelo foi originalmente preparado para trabalhar com JavaScript e escrevemos no TypeScript, precisamos fazer pequenas alterações na configuração do coletor.

No webpack/base.js adicione a chave de entry , que indica o ponto de entrada ao criar nosso projeto, bem como a configuração do ts-loader que descreve as regras para a criação de 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/ }, //... 

Também precisaremos criar o arquivo tsconfig.json na raiz do projeto. Para mim, tem o seguinte conteúdo:

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

1.3 Instalando módulos


Instale todas as dependências do package.json e adicione os módulos typescript e ts-loader a eles:

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

Agora o projeto está pronto para iniciar o desenvolvimento. Temos 2 comandos à nossa disposição que já estão definidos na propriedade scripts no arquivo package.json .

  1. Crie um projeto para depuração e abra em um navegador por meio de um servidor local

     npm start 
  2. Execute a compilação para venda e coloque a compilação de lançamento na pasta dist /

     npm run build 

1.4 Preparação de ativos


Todos os recursos deste jogo são honestamente baixados do OpenGameArt (versão 61x61) e possuem as licenças mais amigáveis, denominadas Sinta-se livre para usar , o que a página com o pacote nos informa cuidadosamente). A propósito, o código apresentado no artigo tem a mesma licença! ;)

Eu apaguei a imagem do relógio do conjunto baixado e renomeei o restante dos arquivos para obter nomes de quadros fáceis de usar. A lista de nomes e os arquivos correspondentes são exibidos na tela abaixo.

A partir dos sprites resultantes, criaremos um atlas do formato Phaser JSONArray no programa TexturePacker (há uma versão gratuita mais do que suficiente, ainda não consegui trabalhar) e colocaremos os arquivos spritesheet.json e spritesheet.png gerados no diretório src/assets/ project.



2. Criando cenas


2.1 Ponto de entrada


Iniciamos o desenvolvimento criando o ponto de entrada descrito na configuração do webpack.

 // 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: [] }); 

Como o jogo que temos foi projetado para a área de trabalho e preencherá a tela inteira, usamos com ousadia toda a largura e altura do navegador nos campos de width e height .
Atualmente, o campo de scene é um array vazio e vamos corrigi-lo!

2.2 Cena Inicial


Crie a classe da primeira cena no src/scripts/scenes/StartScene.ts :

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

Para herança válida de Phaser.Scene passamos o nome da cena como um parâmetro para o construtor da classe pai.

Esta cena combinará a funcionalidade de pré-carregamento de recursos e a tela inicial, convidando o usuário ao jogo.

Normalmente, em meus projetos, um jogador passa por duas cenas antes de chegar à inicial, nesta ordem:

 Boot => Preload => Start 

Mas, neste caso, o jogo é tão simples, e há tão poucos recursos que não há razão para colocar a pré-carga em uma cena separada, e mais ainda o carregador de Boot inicial separado.

Carregaremos todos os ativos no método de preload - preload . Para poder trabalhar com o atlas criado no futuro, precisamos executar 2 etapas:

  1. obtenha os arquivos png e json atlas usando o require :

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

  2. carregue-os no método de preload - preload da cena inicial:

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


2.3 Textos da cena inicial


Há duas coisas a serem feitas na cena inicial:

  1. diga ao jogador como iniciar o jogo
  2. iniciar o jogo por iniciativa do jogador

Para cumprir o primeiro ponto, primeiro crie duas enumerações no início do arquivo de cena para descrever os textos e seus estilos:

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

E, em seguida, crie os dois textos como objetos no método create . Deixe-me lembrá-lo de que o método de create de cenas na Phaser será chamado somente após o carregamento de todos os recursos no método de preload - preload , e isso é bastante adequado para nós.

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

Em outro projeto maior, poderíamos levar os textos e os estilos para arquivos json locale ou para configurações separadas, mas, como agora temos apenas duas linhas, considero essa etapa redundante e, neste caso, sugiro não complicar nossas vidas, nos limitando a listagens no início do arquivo de cena.

2.4 Transição para o nível do jogo


A última coisa que faremos nesta cena antes de prosseguir é rastrear o evento de clique do mouse para iniciar o jogador no jogo:

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

Cena de 2,5 níveis


A julgar pelo parâmetro "Game" passado para o método this.scene.start você já imaginou que era hora de criar uma segunda cena, que processaria a lógica principal do jogo. Crie o src/scripts/scenes/GameScene.ts :

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

Nesta cena, não precisamos do método de preload - preload , porque já carregamos todos os recursos necessários na cena anterior.

2.6 Configurando cenas no ponto de entrada


Agora que as duas cenas foram criadas, adicione-as ao nosso ponto de entrada
src/scripts/index.ts :

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

3. Objetos do jogo


Portanto, a classe GameScene implementará a lógica no nível do jogo. E o que esperamos do nível do jogo de sapadores? Visualmente, esperamos ver um campo de jogo com células fechadas. Sabemos que o campo é uma tabela, o que significa que possui um determinado número de linhas e colunas, em várias das quais bombas são confortavelmente colocadas. Assim, temos informações suficientes para criar uma entidade separada que descreve o campo de jogo.

3.1 Tabuleiro de jogo


Crie o src/scripts/models/Board.ts no qual colocamos a classe 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; } } 

Vamos fazer da classe o sucessor do Phaser.Events.EventEmitter para acessar a interface para registrar e chamar eventos, que precisaremos no futuro.

Uma matriz de objetos da classe Field será armazenada na propriedade privada _fields . Implementaremos esse modelo mais tarde.

Configuramos as propriedades numéricas privadas _rows e _cols para indicar o número de linhas e colunas do campo de jogo. Crie getters públicos para ler _rows e _cols .

O campo _bombs nos diz o número de bombas que precisarão ser geradas para o nível. E no parâmetro _scene passamos uma referência ao objeto da cena do jogo GameScene , na qual criaremos uma instância da classe Board .

É importante notar que transferimos o objeto de cena para o modelo apenas para transmissão posterior às vistas, onde o usaremos apenas para exibir a vista. O fato é que o phaser usa diretamente o objeto de cena para renderizar sprites e, portanto, obriga a fornecer um link para a cena atual ao criar pré-fabricados para sprites, que iremos desenvolver no futuro. E por nós mesmos, aceitaremos o acordo de que transferiremos o link para a cena apenas para uso posterior como um mecanismo de exibição e concordaremos que não chamaremos diretamente os métodos personalizados da cena em modelos e visualizações.

Depois de decidirmos sobre a interface de criação do tabuleiro, proponho inicializá-la na cena do nível, finalizando a classe 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); } } 

Tomamos os parâmetros do quadro para constantes no início do arquivo de cena e os passamos para o construtor do Board ao criar uma instância desta classe.

3.2 Modelo Celular


O quadro consiste em células, que você deseja exibir na tela. Cada célula deve ser colocada na posição correspondente, determinada pela linha e coluna.

As células também são selecionadas como uma entidade separada. Crie o src/scripts/models/Field.ts no qual colocaremos a classe que descreve a célula:

 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 célula deve ter métricas de linha e coluna em que está localizada. Nós configuramos os parâmetros _board e _scene para definir links para objetos do quadro e da cena. Implementamos getters para ler os _row , _col e _board .

3.3 Visualização de célula


A célula abstrata é criada e agora queremos visualizá-la. Para exibir uma célula na tela, você precisa criar sua exibição. Crie o src/scripts/views/FieldView.ts e coloque a classe de exibição nele:

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

Observe que fizemos dessa classe o descendente de Phaser.GameObjects.Sprite . Em termos de fases, essa classe se tornou uma pré-fabricada de sprites. Ou seja, obtive a funcionalidade do objeto de jogo do sprite, que expandiremos ainda mais com nossos próprios métodos.

Vejamos o construtor desta classe. Aqui, primeiro, devemos chamar o construtor da classe pai com os seguintes conjuntos de parâmetros:

  • link para o objeto de cena (como avisei na seção 3.1: phaser exige que vinculemos à cena atual para renderizar sprites)
  • coordenadas x e y na tela
  • a chave da string para a qual o atlas está disponível, que carregamos no método de preload - preload da cena inicial
  • a chave de sequência de quadros neste atlas que você deseja selecionar para exibir o sprite

Defina uma referência ao modelo (ou seja, uma instância da classe Field ) na propriedade _model privada.

Também prudentemente iniciamos 2 _create _init e _create atualmente vazios, que implementaremos um pouco mais tarde.

3.4 Criando um sprite em uma classe de exibição


Então, a visão foi criada, mas ela ainda não sabe desenhar um sprite. Para colocar o sprite com o quadro que precisamos na tela, você precisará modificar nosso próprio método _create privado:

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

3.5 Posicionamento de Sprite


No momento, todos os sprites criados serão colocados nas coordenadas (0, 0) da tela. Também precisamos colocar cada célula em sua posição correspondente no quadro. Ou seja, para o local que corresponde à linha e coluna desta célula. Para fazer isso, precisamos escrever um código para calcular as coordenadas de cada instância da classe FieldView .

Adicione a propriedade _position à classe, responsável pelas coordenadas finais da célula no campo de jogo:

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

Como queremos alinhar a placa e, consequentemente, as células nela, em relação ao centro da tela, também precisamos da propriedade _offset , indicando o deslocamento dessa célula específica em relação às bordas esquerda e superior da tela. Adicione-o com um getter 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 }; } //... 

Assim, nós:

  1. Tem a largura total da tela em this._scene.cameras.main.width .
  2. Obtivemos a largura total do quadro multiplicando o número de células pela largura de uma célula: this._board.cols * this.width .
  3. Tirando a largura da placa da largura da tela, conseguimos um lugar na tela, não ocupado pela placa.
  4. Dividindo o número resultante por 2, obtivemos o valor do recuo à esquerda e à direita do quadro.
  5. Ao mudar cada célula pelo valor desse recuo, garantimos o alinhamento de toda a placa ao longo do eixo x .

Realizamos ações absolutamente semelhantes para obter deslocamento vertical.

Resta adicionar o código necessário no 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; } // ... 

As propriedades this.x , this.y , this.width e this.height aqui são as propriedades herdadas da classe pai Phaser.GameObjects.Sprite . Alterar as propriedades de this.x e this.y leva ao posicionamento correto do sprite na tela.

3.6 Criando uma instância do FieldView


Crie uma exibição na classe 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 Exibir campos do quadro.


Vamos voltar à classe Board , que é essencialmente uma coleção de objetos Field e criará células.

_create código de criação da placa em um método _create separado e chamaremos esse método do construtor. Sabendo que no método _create não apenas criaremos células, _createFields o código para criar células em um 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 { } 

É nesse método que criaremos o número desejado de células em um loop aninhado:

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

É hora da primeira vez de executar o assembly para depuração com o comando

 npm start 

Certifique-se de que, no centro da tela, esperamos ver 64 células em 8 linhas.

3.8 Fazendo bombas


Anteriormente, relatei que no método _create da classe Board , não apenas criaremos campos. O que mais? Também haverá a criação de bombas e a definição das células criadas para o número de bombas vizinhas. Vamos começar pelas próprias bombas.

Precisamos colocar N bombas no tabuleiro em células aleatórias. Descrevemos o processo de criação de bombas com um algoritmo aproximado:

                          

A cada iteração do loop, obteremos uma célula aleatória da propriedade this._fields até criarmos tantas bombas quanto indicado no campo this._bombs . Se a célula recebida estiver vazia, instalaremos uma bomba nela e atualizaremos o contador das bombas necessárias para a geração.

Para gerar um número aleatório, usamos o 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; //    } } } 

Não esqueça de escrever a chamada para this._createBombs(); no arquivo Board.ts this._createBombs(); no final do método _create

Como você já percebeu, para que esse código funcione corretamente, você precisa refinar a classe Field adicionando o getter empty e o método setBomb a setBomb .

Adicione um campo _value privado à _value Field, que regulará o conteúdo da célula. Aceitamos os seguintes acordos.
_value === 0a célula está vazia e não há minas ou valores nela
_value === -1existe uma mina na cela
_value > 0na célula é o número de minas localizadas ao lado da célula atual

Seguindo essas regras, desenvolveremos métodos na classe Field que funcionam com a propriedade _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 Valores de configuração


As bombas estão organizadas e agora temos todos os dados para definir os valores numéricos em todas as células que precisam dele.

Deixe-me lembrá-lo de que, de acordo com as regras do sapador, a célula deve ter o número que corresponde ao número de bombas localizadas próximas a essa célula. Com base nessa regra, escrevemos o pseudocódigo correspondente.

                    

Na classe Board , crie um novo método e traduza o pseudocódigo especificado em código real:

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

Vamos ver qual das interfaces que usamos não está implementada. Você precisa adicionar o método getClosestFields para obter as células vizinhas.

Como identificar células vizinhas?

Por exemplo, considere qualquer célula da placa que não esteja na borda, ou seja, não na linha extrema nem na coluna extrema. Essas células têm um número máximo de vizinhos: 1 na parte superior, 1 na parte inferior, 3 à esquerda e 3 à direita (incluindo células na diagonal).

Assim, em cada uma das células vizinhas, os indicadores _row e _col não diferem em mais de 1. Isso significa que podemos especificar antecipadamente a diferença entre os parâmetros _row e _col com o campo atual. Adicione uma constante no início do arquivo à descrição da classe:

 // 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} //   ]; //... 

E agora podemos adicionar o método ausente, no qual percorreremos esse array:

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

Não se esqueça de verificar a variável de field a cada iteração, pois nem todas as células na placa têm 8 vizinhos. Por exemplo, a célula superior esquerda não terá vizinhos à sua esquerda e assim por diante.

Resta implementar o método getField e adicionar todas as chamadas necessárias ao método _create na classe 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. Manipulando Eventos de Entrada


4.1 Rastreando eventos de clique do mouse


No momento, o painel está completamente inicializado, possui bombas e há células com números, mas todas estão atualmente fechadas e não há como abri-las. Vamos corrigir isso e implementar a abertura de células clicando no botão esquerdo do mouse.

Primeiro, precisamos rastrear esse mesmo clique. Na classe, FieldViewadicione o _createseguinte código ao final do método :

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

No phaser, você pode se inscrever em objetos do namespace para diferentes eventos Phaser.GameObjects. Em particular, assinaremos o evento click ( pointerdown), a pré-fabricada do próprio sprite, ou seja, um objeto de uma classe FieldViewherdada Phaser.GameObjects.Sprite.

Porém, antes de fazer isso, devemos indicar explicitamente que o sprite é potencialmente interativo, ou seja, você geralmente precisa ouvir as informações do usuário. Você precisa fazer isso chamando o método setInteractivesem parâmetros no próprio sprite, como fizemos no exemplo acima.

Agora que nosso sprite se tornou interativo, voltemos à classe Boardno local em que novos objetos de modelo são criados Field, ou seja, ao método _createFieldse registramos o retorno de chamada para eventos de entrada para a exibição:

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

Depois de estabelecermos que, clicando no sprite, queremos executar o método _onFieldClick, precisamos implementá-lo. Mas removeremos a lógica de processar o clique da classe Board. Acredita-se que é melhor processar o modelo dependendo da entrada e alterar seus dados em um controlador separado, cuja similaridade temos é a classe da cena do jogo GameScene. Portanto, precisamos encaminhar o evento de clique ainda mais, da classe Boardpara a própria cena. Então vamos fazer:

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

Aqui não estamos apenas lançando o evento click como estava, mas também especificando qual clique foi. Isso será útil no futuro, quando na aula de cena processaremos cada opção de maneira diferente. Obviamente, seria possível enviar o evento click como está, mas simplificaremos o código da cena, deixando parte da lógica do evento em si na classe Field.

Bem, agora vamos voltar à classe da cena do jogo GameScenee adicionar um _createcódigo no final do método que rastreia eventos de um clique nas células:

 // 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 Processamento do clique esquerdo


Prosseguimos para implementar o processamento de eventos de clique do mouse. E comece abrindo as células. As células devem ser abertas pressionando o botão esquerdo. E antes de começarmos a programar, vamos expressar as condições que devem ser atendidas:

  1. ao clicar em uma célula fechada, ela deve ser aberta
  2. se houver uma mina em uma célula aberta - o jogo está perdido
  3. se não houver minas ou valores na célula aberta, min não estará nas células vizinhas; nesse caso, você precisará abrir todas as células vizinhas e continuar fazendo isso até que o valor apareça na célula aberta
  4. quando você clica em uma célula aberta, deve verificar se todas as bandeiras estão definidas corretamente e, se sim, terminar o jogo com uma vitória

E agora, para simplificar o entendimento da funcionalidade necessária, traduzimos a lógica acima em pseudo-código:

                           

Agora temos uma compreensão do que precisa ser programado. Implementamos o 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); //   } } } //... 

E então, como sempre, finalizaremos as classes Fielde Boardimplementaremos os métodos que chamamos no manipulador.

Indicamos 3 estados possíveis da célula na enumeração States, adicionamos um campo _statee implementamos um getter para cada estado possível:

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

Agora que temos estados indicando se a célula está fechada ou não, podemos adicionar um método openque alterará o 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 alteração no estado do modelo deve acionar um evento que relate isso. Portanto, introduzimos um método privado adicional _setStateno qual toda a lógica da mudança de estado será implementada. Este método será chamado em todos os métodos públicos do modelo, que devem mudar de estado.

Adicione um sinalizador booleano _explodedpara indicar explicitamente exatamente o objeto Field que foi explodido:

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

Agora abra a classe Boarde implemente o método nela openClosestFields. Este método é recursivo e sua tarefa será abrir todos os campos vizinhos vazios em relação à célula aceita no parâmetro.
O algoritmo será o seguinte:

  :                 

E desta vez já temos todas as interfaces necessárias para a implementação completa deste método:

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

Adicione um getter completedà classe Boardpara indicar o posicionamento correto das bandeiras no quadro. Como podemos determinar se um quadro foi liberado com sucesso? O número de campos marcados corretamente deve ser igual ao número total de bombas no tabuleiro.

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

Este método filtra a matriz _fieldspelo getter completed, o que deve indicar a validade da marca do campo. Se o comprimento da matriz filtrada (na qual somente os campos marcados corretamente caírem, pelos quais o getter completedé responsável pela classe Field) for igual ao valor do campo _bombs(ou seja, o número de bombas no tabuleiro), então retornaremos true, em outras palavras, consideraremos o jogo ganho.
Também não nos importamos com a oportunidade de abrir todo o quadro com uma chamada, o que temos que fazer no final do nível. Também adicionaremos esse recurso à classe Board:

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

Resta adicionar um getter completedà própria classe Field. Nesse caso, o campo será considerado limpo com êxito? Se for extraído e sinalizado. Ambos os getters necessários já estão lá e podemos adicionar este método:

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

Para concluir o processamento do clique esquerdo do mouse, criaremos um método _onGameOverno qual desativamos o rastreamento de eventos do tabuleiro e mostraremos ao jogador o tabuleiro inteiro. Mais tarde, nós também adicionar o código para a prestação de relatórios sobre o estado de conclusão do nível com base no 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 Exibição em campo


Antes de começar a processar o clique com o botão direito, aprenderemos como redesenhar as células recém-abertas.

Anteriormente, Fielddesenvolvemos um método _setStateque dispara um evento changequando o estado do modelo é alterado. Usaremos isso e, na classe, 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); } //... 

Tornamos especificamente o método intermediário um _onStateChangeretorno de chamada do evento de alteração do modelo. No futuro, precisaremos verificar como o modelo foi alterado para entender se é necessário executar _render.

Para mostrar o sprite atual de uma célula em um novo estado, você precisa alterar seu quadro. Como carregamos o atlas como ativo, podemos chamar o método setFramepara alterar o quadro atual para um novo.

Para obter o quadro em uma linha, usamos astuciosamente o getter _frameName, que agora precisa ser implementado. Primeiro, descrevemos todos os valores possíveis que um quadro de célula pode assumir.
MolduraCondição
closedO campo está fechado
flagCampo sinalizado
emptyO campo está aberto, não é extraído ou preenchido com valor
exploded
o campo está aberto, minado e explodido
mined
o campo está aberto, minado, mas não explodido
1...9
o campo está aberto e exibe um valor de 1 a 9, indicando o número de bombas ao lado desse campo

Temos uma descrição de todos os estados e já temos todos os métodos do modelo, graças aos quais esses estados podem ser obtidos. Vamos fazer uma pequena configuração no início do arquivo:

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

As chaves neste objeto serão os valores dos quadros e os valores dessas chaves são os retornos de chamada que retornam um resultado booleano. Com base nessa configuração, podemos desenvolver um método para obter o quadro desejado (ou seja, a chave da configuração):

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

Assim, por enumeração simples em um loop, examinamos todas as chaves do objeto de configuração e chamamos cada retorno de chamada por vez. A função que nos retorna primeiro trueindica que a chave keyna iteração atual é o quadro correto para o estado atual do modelo.

Se nenhuma chave for adequada, para o estado padrão, consideraremos um campo aberto com um valor _value, pois Statesnão definimos esse estado na configuração .

Agora podemos testar completamente o clique esquerdo nos campos do quadro e verificar como as células se abrem e o que é exibido depois de abri-las.

4.4 Processamento com o botão direito


Como no caso de criar o manipulador com o botão esquerdo, primeiro definimos claramente a funcionalidade esperada. Ao clicar com o botão direito do mouse, devemos marcar a célula selecionada com um sinalizador. Mas existem certas condições.

  1. Apenas um campo fechado que não está marcado no momento pode ser sinalizado
  2. Se o campo estiver marcado, clique novamente com o botão direito do mouse para remover o sinalizador do campo
  3. Ao definir / remover um sinalizador, é necessário atualizar o número de sinalizadores disponíveis no nível e exibir o texto com o número atual

Traduzindo essas condições em pseudo-código, obtemos as seguintes linhas de comentários:

                                

Agora podemos traduzir esse algoritmo em chamadas para os métodos de que precisamos, mesmo que eles ainda não tenham sido desenvolvidos:

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

Aqui também iniciamos um novo campo _flags, que no início do nível do jogo é igual ao número de bombas no tabuleiro, pois no início do jogo nenhuma bandeira foi definida. Esse campo é forçado a ser atualizado a cada clique direito, pois nesse caso o sinalizador é adicionado ou removido do quadro. Adicione um Boardgetter à classe countMarked:

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

Definir e remover o sinalizador é uma alteração no estado do modelo Field; portanto, implementamos esses métodos na classe correspondente de maneira semelhante ao método open:

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

Deixe-me lembrá-lo de que ele _setStateacionará um evento changeque é rastreado na exibição e, portanto, o sprite será redesenhado automaticamente desta vez quando o modelo for alterado.

Ao testar a funcionalidade desenvolvida, você certamente descobrirá que sempre que clicar no botão direito do mouse, um menu de contexto é aberto. Adicione o código que desativa esse comportamento ao construtor da cena do jogo:

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

4.5 Objeto GameSceneView


Para exibir a interface do usuário na cena do jogo, criaremos uma classe GameSceneViewe a colocaremos src/scripts/views/GameSceneView.ts.

Nesse caso, agiremos de maneira diferente da criação FieldViewe não faremos desta classe um pré-fabricado e um herdeiro GameObjects.
Nesse caso, precisamos gerar os seguintes elementos da exibição da cena:

  • texto no número de sinalizadores
  • botão sair
  • Mensagem de status de conclusão do jogo (vitória / derrota)

Vamos transformar cada elemento da interface do usuário em um campo separado na classe GameSceneView.
Vamos preparar um esboço.

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

Adicione texto com o número de sinalizadores.

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

Esse código colocará o texto que precisamos em uma posição recuada 50px dos lados superior e esquerdo e o definirá no estilo especificado. Além disso, o método setOrigindefine o ponto de articulação do texto para as coordenadas (0, 1). Isso significa que o texto ficará alinhado com sua borda esquerda.

Adicione uma mensagem de status.

 // 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 o texto de status no centro da tela e o alinhamos com o meio da linha, chamando setOrigincom o parâmetro 0.5 para a coordenada x. Além disso, por padrão, esse texto precisa ser oculto, pois somente o mostraremos após a conclusão do jogo.

Crie um botão de saída, que em essência também é um 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'); }); } //... 

Colocamos o botão no canto superior direito da tela e o usamos novamente setOriginpara alinhar o texto dessa vez com a borda direita. Tornamos o botão interativo e adicionamos um retorno de chamada ao evento click, que envia o player para a cena inicial. Assim, damos ao jogador a oportunidade de sair do nível a qualquer momento.

Resta desenvolver um método renderpara atualizar corretamente todos os elementos da interface do usuário e adicionar chamadas a todos os métodos criados no _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; } } //... 

Dependendo da propriedade transmitida no parâmetro, atualizamos a interface do usuário, exibindo as alterações necessárias.
Crie uma representação na cena do jogo na classe GameScene e escreva a chamada no método _render sempre que necessário, por 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. Animações


Que tipo de fã criando um jogo, mesmo que simples como o nosso, se não houver animações ?! Além disso, desde que começamos a estudar o phaser, vamos nos familiarizar com os recursos mais básicos das animações e considerar a funcionalidade dos gêmeos. Gêmeos são implementados na própria estrutura e nenhuma biblioteca de terceiros é necessária.

Adicione 2 animações ao jogo: encher o tabuleiro com células no início e virar a célula na abertura. Vamos começar com o primeiro deles.

5.1 Animação de preenchimento de quadro




Garantimos que todas as células da placa voem no lugar a partir da borda superior esquerda da tela. Ao iniciar o nível do jogo, precisamos mudar todas as células para o canto superior esquerdo da tela e para cada célula iniciar a animação do movimento para as coordenadas correspondentes.

Na classe, FiledViewadicione a _createchamada ao final dos métodos _animateShow:

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

B implementamos o novo método que precisamos. Nele, como concordamos acima, é necessário executar duas coisas:

  1. mova a célula para trás do canto superior esquerdo para que não fique visível na tela
  2. inicie o movimento duplo com as coordenadas desejadas com o atraso correto

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

Como o canto superior esquerdo da tela possui coordenadas (0, 0), se definirmos a célula com as coordenadas iguais aos seus valores negativos de largura e altura, isso colocará a célula atrás do canto superior esquerdo e ocultará a tela. Assim, concluímos nossa primeira tarefa.

O segundo objetivo a alcançar, chamando o 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 criar uma animação, usamos a propriedade scene tweens. No método dele, addpassamos o objeto de configuração com as configurações:

  • A propriedade targetsaqui deve conter como valor os objetos de jogo aos quais você deseja aplicar efeitos de animação. No nosso caso, este é um link thispara o objeto atual, pois é uma pré-fabricada do sprite.
  • O segundo e o terceiro parâmetros passam pelas coordenadas do destino.
  • A propriedade durationé responsável pela duração da animação, no nosso caso - 600ms.
  • Parâmetros easee easeParamsdefina a função de atenuação.
  • No campo atraso, substituímos o valor do segundo argumento, gerado para cada célula individual, levando em consideração sua posição no quadro. Isso é feito para que as células não voem ao mesmo tempo. Em vez disso, cada célula aparecerá com um pequeno atraso em relação à anterior.
  • Por fim, onCompletecolocamos um retorno de chamada na propriedade , que será chamado no final da ação de interpolação.

É razoável envolver o gêmeo em uma promessa para que, no futuro, seja capaz de atracar maravilhosamente diferentes animações, para que, no retorno de chamada, façamos uma chamada de função resolveindicando a execução bem-sucedida da animação.

5.2 Animações de flip de célula




Será ótimo se, quando a célula foi aberta, o efeito de sua reversão foi reproduzido. Como podemos conseguir isso?

A abertura de uma célula é atualmente realizada alterando o quadro quando o método é chamado _renderna exibição. Se verificarmos o estado do modelo neste método, veremos se a célula estava aberta. Se a célula estiver aberta, inicie a animação em vez de exibir instantaneamente um novo quadro de reversão.

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

Para obter o efeito desejado, utilizar transformação do sprite através da propriedade scale. Se escalarmos o sprite ao longo do eixo xpara zero com o tempo , ele diminuirá, conectando os lados esquerdo e direito. E vice-versa, se você escalar o sprite ao longo do eixo xde zero a sua largura total, o esticaremos para o tamanho máximo. Nós implementar esta lógica no método _animateFlip.

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

Por analogia com o 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() } }); }); } //... 

Neste método, como parâmetro, pegamos o valor da escala, que usaremos para alterar o tamanho do sprite em ambas as direções e passá-lo como um segundo parâmetro para o objeto de configuração da animação. Todos os outros parâmetros de configuração já nos são familiares da animação anterior.

Agora iniciaremos o projeto para teste e, após a depuração, consideraremos nosso jogo concluído e a tarefa de teste concluída! :)

Agradeço sinceramente a todos por terem chegado a este momento comigo!

Conclusão


Colegas, ficarei muito satisfeito se o material apresentado no artigo for útil para você e você pode usar essas ou as abordagens descritas em seus próprios projetos. Você sempre pode recorrer a mim com qualquer pergunta, tanto neste artigo quanto na programação fasorial ou no gamedev em geral. Congratulo-me com a comunicação e ficarei feliz em fazer novos conhecidos e trocar experiências!

E eu tenho uma pergunta para você agora. Desde que estou criando tutoriais em vídeo sobre desenvolvimento de jogos, naturalmente acumulei uma dúzia desses pequenos jogos. Cada jogo abre a estrutura à sua maneira. Por exemplo, neste jogo, abordamos o tema dos gêmeos, mas existem muitos outros recursos, como física, mapa de peças, coluna, etc.
A esse respeito, a pergunta é: você gostou deste artigo e, em caso afirmativo, estaria interessado em continuar a ler artigos como este, mas sobre outros pequenos jogos clássicos? Se a resposta for afirmativa, traduzirei com prazer os materiais dos meus tutoriais em vídeo para o formato de texto e continuarei publicando novos manuais ao longo do tempo, mas para outros jogos. Eu trago a pesquisa correspondente.

Obrigado a todos pela atenção! Terei o maior prazer em feedback e até breve!

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


All Articles