Браузерные игры
April 4, 2022

JS + Phaser пишем игру с Дино (часть 1) 🦖

Всем привет, сегодня я пришла к выводу, что я достаточно давно занимаюсь программированием, но вот всякие легкие проекты по типу "секретного" динозаврика из офлайн режима хрома так и не делала.

Поэтому сегодня, мы с вами начнем писать нашу простую игру, где динозавр будет бежать, прыгать, пригибаться и собирать свои драгоценные победные очки!

Соберем заготовку проекта

Так как в этой игре мы будем использовать с вами статику, то нам нужно настроить проект со сборкой, давайте начнем с простого создания парки проекта и инициализации npm:

mkdir dino-game
cd dino-game
npm init -y

Дальше поставим пакеты, которые нам нужны для сборки проекта:

npm install phaser webpack-merge babel clean-webpack-plugin copy-webpack-plugin html-webpack-plugin terser-webpack-plugin webpack webpack-cli webpack-dev-server babel-loader babel-preset-env --save-dev

Создадим файлы настройки webpack:

touch webpack.common.js
touch webpack.prod.js

В файле webpack.common.js поместим следующую конфигурацию:

const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'development',
  entry: {
    app: './src/index.js'
  },
  devtool: "eval-source-map",
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'build'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        }
      }
    ]
  },
  devServer: {
    static: {
      directory: path.resolve(__dirname, 'build'),
    },
    compress: true,
    port: 3000,
  },
  plugins: [
    new webpack.DefinePlugin({
      'CANVAS_RENDERER': JSON.stringify(true),
      'WEBGL_RENDERER': JSON.stringify(true)
    }),
    new HtmlWebpackPlugin({
      template: './index.html'
    }),
    new CopyPlugin({
      patterns: [
        {
          from: path.resolve(__dirname, 'assets/**/*'),
          to: path.resolve(__dirname, 'build'),
          noErrorOnMissing: true
        }
      ],
    })
  ]
};

А в файле webpack.prod.js поместим следующую конфигурацию:

const { merge } = require('webpack-merge');
const common = require('./webpack.common');
const TerserPlugin = require('terser-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = merge(common, {
  mode: 'production',
  devtool: false,
  performance: {
    maxEntrypointSize: 90000,
    maxAssetSize: 900000
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          output: {
            comments: false
          }
        }
      })
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
  ]
})

В package.json добавим пару скриптов для сборки и запуска нашего webpack сервера:

"build": "webpack --config webpack.prod.js",
"dev": "webpack-dev-server --config webpack.common.js --watch"

Теперь нам остается создать только стартовые файлы, первый из них index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      body {
        padding: 200px;
      }
    </style>
  </head>
  <body>
  </body>
</html>

А второй это src/index.js (в него пока поместим просто лог запуска):

console.log('Скоро динозавр побежит...');

Теперь, если вы выполните команду:

npm run dev

То сервер развернется по адресу localhost:3000

Настраиваем конфиг Phaser

И так, давайте откроем наш index.js и настроим в нем конфигурацию для игрового движка Phaser:

import Phaser from 'phaser';
import PlayScene from './PlayScene';
import PreloadScene from './PreloadScene';

const config = {
  type: Phaser.AUTO,
  width: 1000,
  height: 340,
  pixelArt: true,
  transparent: true,
  physics: {
    default: 'arcade',
    arcade: {
      debug: false
    }
  },
  scene: [PreloadScene, PlayScene]
};

new Phaser.Game(config);

Тут мы с вами настроили размер canvas, в котором будет происходить отрисовка игры и режим физики, который предоставляет нам движок Phaser.

Как вы можете заметить тут у нас есть два импорта, PlayScene и PreloadScene

  • PlayScene - место где мы добавим всю логику игры и наших персонажей
  • PreloadScene - место где подключается статика

Подключим статику

В PreloadScene у нас будет храниться загрузка всей статики, которая нужна нам для игры (картинки, звуки и т.д). В целом я собрала всю нужную статику, так что вы можете скачать ее тут и добавить в вашу папку assets.

После того как вы все скачаете, давайте подключим эту статику:

import Phaser from 'phaser';

class PreloadScene extends Phaser.Scene {

  constructor() {
    super('PreloadScene');
  }

  preload() {
    this.load.audio('jump', 'assets/jump.m4a');
    this.load.audio('hit', 'assets/hit.m4a');
    this.load.audio('reach', 'assets/reach.m4a');

    this.load.image('ground', 'assets/ground.png');
    this.load.image('dino-idle', 'assets/dino-idle.png');
    this.load.image('dino-hurt', 'assets/dino-hurt.png');
    this.load.image('restart', 'assets/restart.png');
    this.load.image('game-over', 'assets/game-over.png');
    this.load.image('cloud', 'assets/cloud.png');

    this.load.spritesheet('star', 'assets/stars.png', {
      frameWidth: 9, frameHeight: 9
    });

    this.load.spritesheet('moon', 'assets/moon.png', {
      frameWidth: 20, frameHeight: 40
    });

    this.load.spritesheet('dino', 'assets/dino-run.png', {
      frameWidth: 88,
      frameHeight: 94
    })

    this.load.spritesheet('dino-down', 'assets/dino-down.png', {
      frameWidth: 118,
      frameHeight: 94
    })

    this.load.spritesheet('enemy-bird', 'assets/enemy-bird.png', {
      frameWidth: 92,
      frameHeight: 77
    })

    this.load.image('obsticle-1', 'assets/cactuses_small_1.png')
    this.load.image('obsticle-2', 'assets/cactuses_small_2.png')
    this.load.image('obsticle-3', 'assets/cactuses_small_3.png')
    this.load.image('obsticle-4', 'assets/cactuses_big_1.png')
    this.load.image('obsticle-5', 'assets/cactuses_big_2.png')
    this.load.image('obsticle-6', 'assets/cactuses_big_3.png')
  }

  create() {
    this.scene.start('PlayScene');
  }
}

export default PreloadScene;

Создание игровых объектов

Теперь давайте создадим наши первые игровые объекты, которые включают нашего главного героя “Дино” и землю, по которой Дино может “бегать”.

Давайте создадим файл PlayScene.js и класс нашей стартовой сцены в нем:

import Phaser from 'phaser';

class PlayScene extends Phaser.Scene {
  constructor() {
    super('PlayScene');
  }
}

export default PlayScene;

Давайте создадим метод create(), в котором будут создаваться наши игровые объекты:

create() {
  const { height, width } = this.game.config;
  this.gameSpeed = 10;
  
  this.ground = this.add.tileSprite(0, height, width, 26, 'ground').setOrigin(0, 1);
  this.dino = this.physics.add.sprite(0, height, 'dino-idle').setOrigin(0, 1);
}

Если вы сейчас откроете браузер, то увидите что-то подобное:

Расположение игровых объектов имеет решающее значение. Давайте разберем то, как игровые объекты добавляются на canvas.

Расположение Дино

Напоминаю, что мы добавили Дино при помощи следующей строчки кода:

this.dino = this.physics.add.sprite(0, height, 'dino-idle').setOrigin(0, 1);

При создании игрового объекта вам необходимо указать координаты x и y и изображение, которое нужно загрузить для персонажа:

  • Первый параметр - x = 0
  • Второй параметр - y = height, а height = 340px, так мы задачи в настройках canvas в index.js
  • Третий параметр - название изображения для загрузки

setOrigin определяет как мы смотрим на размер персонажа на холсте:

Расположение земли

Напоминаю, что мы добавили землю при помощи следующей строчки кода:

this.ground = this.add.tileSprite(0, height, width, 26, 'ground').setOrigin(0, 1);

Землю мы вставим в виде TileSprite. TileSprite - это спрайт, который имеет повторяющуюся текстуру.

При создании TileSprite нам нужно указать пять значений:

  • Координата x -> 0
  • Координата y -> 340px
  • Ширина -> 1000px
  • Высота -> 26px
  • Ключ с названием изображения

Поскольку TileSprite - это повторяющаяся структура, нам также необходимо указать ширину и высоту. Ширина - это размер холста, а высота - фактическая высота основного изображения.

Добавим эффект движения земли

Если мы хотим создать движущийся эффект, нам нужно менять положение земли при каждом обновлении фрейма.

Давайте в классе PlayScene создадим метод update():

update() {
  this.ground.tilePositionX += this.gameSpeed;
}

Скорость игры равна 10, поэтому земля будет перемещаться на 10 пикселей при каждом обновлении.

Научим Дино прыгать

Во-первых, нам нужно обработать нажатие кнопок.

create() {
  // остальной код
  this.dino = this.physics.add.sprite(0, height, 'dino-idle')
   .setCollideWorldBounds(true)
    // применим силу тяжести к Дино
   .setGravityY(5000)
   .setOrigin(0, 1);
   
  this.createControll();
}

createControll() {
  this.input.keyboard.on('keydown-SPACE', () => {
    if (!this.dino.body.onFloor()) { return; }
    
    this.dino.setTexture('dino', 0);
    this.dino.setVelocityY(-1600);
  });
}

setGravityY применит силу тяжести к Дино. Это означает, что Дино будет притягиваться к земле со скоростью 5000 пикселей, которая будет увеличиваться с каждой секундой.

setColliderWorldBounds задает параметры для того, чтобы определить, сталкивается ли это тело с границей мира. Без этого игровой объект выпал бы за пределы игровой области.

Теперь, каждый раз, когда пользователь нажимает клавишу пробела, Дино будет двигаться вверх со скоростью 1600 пикселей в секунду (setVelocityY). Это позволит преодолеть гравитацию на долю секунды и создать эффект прыжка.

setTexture изменит изображение Дино в момент прыжка.

Теперь обратите внимание на утверждение “if”. Чтобы предотвратить прыжки в воздухе, мы проверяем, находится ли динозавр на полу. Это вернет истинное значение, если Дино сталкивается с мировыми границами.

Вот как это выглядит:

Создание анимаций

Во-первых, нам нужно понять как выглядит базовое изображение, которое мы разделим на несколько кадров:

Изображение, которое мы используем, лежит в папке assets. Мы загружаем это изображение в PreloadScene.js с помощью this.load.spritesheet.

create() {
  // остальной код этой функции
  this.initAnims();
}

initAnims() {
  this.anims.create({
    key: 'dino-run',
    frames: this.anims.generateFrameNumbers('dino', 
      {start: 2, end: 3}),
    frameRate: 10,
    repeat: -1
  });
 }

Во-первых, нам нужно инициализировать анимацию. Давайте разберем параметры:

  • key - идентификатор анимации изображения
  • frames - кадры, из которых будет состоять анимация. В этом случае кадры 2,3 (с изображения выше) из изображения assets/dino-run.png
  • frameRate - кадры будут воспроизводиться со скоростью 10 кадров в секунду
  • repeat = -1, это приведет к бесконечному циклу анимации

Мы запустим нашу анимацию в функции обновления:

update() {
 // остальной код функции
 if (this.dino.body.deltaAbsY() > 0) {
   this.dino.anims.stop();
   this.dino.setTexture('dino', 0);
 } else {
   this.dino.play('dino-run', true);
 }
}

Мы проверим, в каком состоянии находится Дино в данный момент. Помните, у нас есть два состояния: одно, в котором Дино стоит на месте, и второе, в котором Дино прыгает.

В функции update() мы можем проверить, больше ли положение Дино по оси y, больше чем 0. Если это так, это означает, что Динозавр прыгает, поэтому мы установим текстуру по умолчанию.

Если это не так, мы хотим воспроизвести запущенную анимацию:

На этом я закончу первую часть из цикла статей по созданию игры с Дино, надеюсь вам было интересно и вы попробовали сами с нуля создать свою игру!

Вы можете найти код для этого урока тут

JS + Phaser пишем игру с Дино (часть 2) 🦖->