О чем этот пример
Разработка динамичного шутера с видом сверху — отличный способ освоить ключевые концепции Phaser. В этом примере мы реализуем управление игроком, систему прицеливания, стрельбу с обеих сторон и простую механику здоровья. Вы научитесь работать с Arcade Physics для обработки столкновений, группами объектов для эффективного управления снарядами и камерой, следующей за игроком. Этот фундамент можно использовать для создания полноценной аркадной игры.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
time = 0;
enemyBullets;
playerBullets;
moveKeys;
reticle;
healthpoints;
player;
enemy;
hp1;
hp2;
hp3;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
// Load in images and sprites
this.load.spritesheet('player_handgun', 'assets/sprites/player_handgun.png',
{ frameWidth: 66, frameHeight: 60 }
); // Made by tokkatrain: https://tokkatrain.itch.io/top-down-basic-set
this.load.image('bullet', 'assets/sprites/bullets/bullet6.png');
this.load.image('target', 'assets/demoscene/ball.png');
this.load.image('background', 'assets/skies/underwater1.png');
}
create ()
{
// Set world bounds
this.physics.world.setBounds(0, 0, 1600, 1200);
// Add 2 groups for Bullet objects
this.playerBullets = this.physics.add.group({ classType: Bullet, runChildUpdate: true });
this.enemyBullets = this.physics.add.group({ classType: Bullet, runChildUpdate: true });
// Add background player, enemy, reticle, healthpoint sprites
const background = this.add.image(800, 600, 'background');
this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.enemy = this.physics.add.sprite(300, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');
this.hp1 = this.add.image(-350, -250, 'target').setScrollFactor(0.5, 0.5);
this.hp2 = this.add.image(-300, -250, 'target').setScrollFactor(0.5, 0.5);
this.hp3 = this.add.image(-250, -250, 'target').setScrollFactor(0.5, 0.5);
// Set image/sprite properties
background.setOrigin(0.5, 0.5).setDisplaySize(1600, 1200);
this.player.setOrigin(0.5, 0.5).setDisplaySize(132, 120).setCollideWorldBounds(true).setDrag(500, 500);
this.enemy.setOrigin(0.5, 0.5).setDisplaySize(132, 120).setCollideWorldBounds(true);
this.reticle.setOrigin(0.5, 0.5).setDisplaySize(25, 25).setCollideWorldBounds(true);
this.hp1.setOrigin(0.5, 0.5).setDisplaySize(50, 50);
this.hp2.setOrigin(0.5, 0.5).setDisplaySize(50, 50);
this.hp3.setOrigin(0.5, 0.5).setDisplaySize(50, 50);
// Set sprite variables
this.player.health = 3;
this.enemy.health = 3;
this.enemy.lastFired = 0;
// Set camera properties
this.cameras.main.zoom = 0.5;
this.cameras.main.startFollow(this.player);
// Creates object for input with WASD kets
this.moveKeys = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D
});
// Enables movement of player with WASD keys
this.input.keyboard.on('keydown_W', event => {
this.player.setAccelerationY(-800);
});
this.input.keyboard.on('keydown_S', event => {
this.player.setAccelerationY(800);
});
this.input.keyboard.on('keydown_A', event => {
this.player.setAccelerationX(-800);
});
this.input.keyboard.on('keydown_D', event => {
this.player.setAccelerationX(800);
});
// Stops player acceleration on uppress of WASD keys
this.input.keyboard.on('keyup_W', event => {
if (this.moveKeys['down'].isUp) { this.player.setAccelerationY(0); }
});
this.input.keyboard.on('keyup_S', event => {
if (this.moveKeys['up'].isUp) { this.player.setAccelerationY(0); }
});
this.input.keyboard.on('keyup_A', event => {
if (this.moveKeys['right'].isUp) { this.player.setAccelerationX(0); }
});
this.input.keyboard.on('keyup_D', event => {
if (this.moveKeys['left'].isUp) { this.player.setAccelerationX(0); }
});
// Fires bullet from player on left click of mouse
this.input.on('pointerdown', (pointer, time, lastFired) =>
{
if (this.player.active === false) { return; }
// Get bullet from bullets group
const bullet = this.playerBullets.get().setActive(true).setVisible(true);
if (bullet)
{
bullet.fire(this.player, this.reticle);
this.physics.add.collider(this.enemy, bullet, (enemyHit, bulletHit) => this.enemyHitCallback(enemyHit, bulletHit));
}
});
// Pointer lock will only work after mousedown
game.canvas.addEventListener('mousedown', () => {
game.input.mouse.requestPointerLock();
});
// Exit pointer lock when Q or escape (by default) is pressed.
this.input.keyboard.on('keydown_Q', event => {
if (game.input.mouse.locked) { game.input.mouse.releasePointerLock(); }
}, 0);
// Move reticle upon locked pointer move
this.input.on('pointermove', pointer =>
{
if (this.input.mouse.locked)
{
this.reticle.x += pointer.movementX;
this.reticle.y += pointer.movementY;
}
});
}
update (time, delta)
{
// Rotates player to face towards reticle
this.player.rotation = Phaser.Math.Angle.Between(this.player.x, this.player.y, this.reticle.x, this.reticle.y);
// Rotates enemy to face towards player
this.enemy.rotation = Phaser.Math.Angle.Between(this.enemy.x, this.enemy.y, this.player.x, this.player.y);
// Make reticle move with player
this.reticle.body.velocity.x = this.player.body.velocity.x;
this.reticle.body.velocity.y = this.player.body.velocity.y;
// Constrain velocity of player
this.constrainVelocity(this.player, 500);
// Constrain position of constrainReticle
this.constrainReticle(this.reticle);
// Make enemy fire
this.enemyFire(time);
}
enemyHitCallback (enemyHit, bulletHit)
{
// Reduce health of enemy
if (bulletHit.active === true && enemyHit.active === true)
{
enemyHit.health = enemyHit.health - 1;
console.log('Enemy hp: ', enemyHit.health);
// Kill enemy if health <= 0
if (enemyHit.health <= 0)
{
enemyHit.setActive(false).setVisible(false);
}
// Destroy bullet
bulletHit.setActive(false).setVisible(false);
}
}
playerHitCallback (playerHit, bulletHit)
{
// Reduce health of player
if (bulletHit.active === true && playerHit.active === true)
{
playerHit.health = playerHit.health - 1;
console.log('Player hp: ', playerHit.health);
// Kill hp sprites and kill player if health <= 0
if (playerHit.health === 2)
{
this.hp3.destroy();
}
else if (playerHit.health === 1)
{
this.hp2.destroy();
}
else
{
this.hp1.destroy();
// Game over state should execute here
}
// Destroy bullet
bulletHit.setActive(false).setVisible(false);
}
}
enemyFire (time)
{
if (this.enemy.active === false)
{
return;
}
if ((time - this.enemy.lastFired) > 1000)
{
this.enemy.lastFired = time;
// Get bullet from bullets group
const bullet = this.enemyBullets.get().setActive(true).setVisible(true);
if (bullet)
{
bullet.fire(this.enemy, this.player);
// Add collider between bullet and player
this.physics.add.collider(this.player, bullet, (playerHit, bulletHit) => this.playerHitCallback(playerHit, bulletHit));
}
}
}
constrainVelocity (sprite, maxVelocity)
{
if (!sprite || !sprite.body)
{ return; }
let angle, currVelocitySqr, vx, vy;
vx = sprite.body.velocity.x;
vy = sprite.body.velocity.y;
currVelocitySqr = vx * vx + vy * vy;
if (currVelocitySqr > maxVelocity * maxVelocity)
{
angle = Math.atan2(vy, vx);
vx = Math.cos(angle) * maxVelocity;
vy = Math.sin(angle) * maxVelocity;
sprite.body.velocity.x = vx;
sprite.body.velocity.y = vy;
}
}
constrainReticle (reticle)
{
const distX = reticle.x - this.player.x; // X distance between player & reticle
const distY = reticle.y - this.player.y; // Y distance between player & reticle
// Ensures reticle cannot be moved offscreen (player follow)
if (distX > 800)
{ reticle.x = this.player.x + 800; }
else if (distX < -800)
{ reticle.x = this.player.x - 800; }
if (distY > 600)
{ reticle.y = this.player.y + 600; }
else if (distY < -600)
{ reticle.y = this.player.y - 600; }
}
}
const config = {
type: Phaser.AUTO,
parent: 'phaser-example',
width: 800,
height: 600,
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false
}
},
scene: Example
};
const game = new Phaser.Game(config);
class Bullet extends Phaser.GameObjects.Image
{
constructor (scene)
{
super(scene, 0, 0, 'bullet');
this.speed = 1;
this.born = 0;
this.direction = 0;
this.xSpeed = 0;
this.ySpeed = 0;
this.setSize(12, 12, true);
}
fire (shooter, target)
{
this.setPosition(shooter.x, shooter.y); // Initial position
this.direction = Math.atan((target.x - this.x) / (target.y - this.y));
// Calculate X and y velocity of bullet to moves it from shooter to target
if (target.y >= this.y)
{
this.xSpeed = this.speed * Math.sin(this.direction);
this.ySpeed = this.speed * Math.cos(this.direction);
}
else
{
this.xSpeed = -this.speed * Math.sin(this.direction);
this.ySpeed = -this.speed * Math.cos(this.direction);
}
this.rotation = shooter.rotation; // angle bullet with shooters rotation
this.born = 0; // Time since new bullet spawned
}
update (time, delta)
{
this.x += this.xSpeed * delta;
this.y += this.ySpeed * delta;
this.born += delta;
if (this.born > 1800)
{
this.setActive(false);
this.setVisible(false);
}
}
}
Инициализация мира и объектов
Сцена начинается с настройки физического мира и создания основных игровых объектов.
this.physics.world.setBounds(0, 0, 1600, 1200);
this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.enemy = this.physics.add.sprite(300, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');
Метод setBounds определяет границы мира, за которые не могут выйти физические тела с включенным setCollideWorldBounds. Игрок, противник и прицел создаются как физические спрайты с помощью this.physics.add.sprite. Камера масштабируется (zoom = 0.5) и начинает следовать за игроком (startFollow(this.player)). Три спрайта здоровья (hp1, hp2, hp3) добавляются со свойством setScrollFactor(0.5), что делает их движение относительно камеры медленнее, создавая эффект параллакса.
Управление движением игрока
Движение реализовано через ускорение (setAcceleration), что дает более плавную и инерционную физику по сравнению с непосредственной установкой скорости.
this.moveKeys = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D
});
this.input.keyboard.on('keydown_W', event => {
this.player.setAccelerationY(-800);
});
Объект moveKeys создается для отслеживания состояния клавиш. События keydown и keyup назначаются на клавиши WASD для применения и сброса ускорения по осям X и Y. Важный нюанс: в обработчиках keyup проверяется состояние противоположной клавиши (например, отпустили `W, но еслиSеще нажата, ускорение по Y не сбрасывается). Это предотвращает резкие остановки. Скорость игрока ограничивается вспомогательной функциейconstrainVelocity`, которая не дает вектору скорости превысить заданный максимум.
Система прицеливания и стрельбы
Прицел управляется мышью с использованием Pointer Lock API, что позволяет бесконечное перемещение курсора.
game.canvas.addEventListener('mousedown', () => {
game.input.mouse.requestPointerLock();
});
this.input.on('pointermove', pointer => {
if (this.input.mouse.locked) {
this.reticle.x += pointer.movementX;
this.reticle.y += pointer.movementY;
}
});
Прицел (reticle) — это физический спрайт, чья скорость синхронизируется со скоростью игрока в update, чтобы он двигался вместе с миром. Его позиция ограничивается функцией constrainReticle, не давая уйти далеко от игрока. Игрок и противник постоянно поворачиваются лицом к своей цели с помощью Phaser.Math.Angle.Between.
Стрельба осуществляется через пользовательский класс Bullet.
this.playerBullets = this.physics.add.group({ classType: Bullet, runChildUpdate: true });
const bullet = this.playerBullets.get().setActive(true).setVisible(true);
if (bullet) {
bullet.fire(this.player, this.reticle);
}
Группы playerBullets и enemyBullets создаются с указанием пользовательского класса. Метод get() извлекает первый неактивный снаряд из пула, что эффективно для переиспользования объектов. Снаряд получает начальную позицию стрелка и рассчитывает вектор скорости по направлению к цели.
Обработка столкновений и механика здоровья
Столкновения снарядов с целями обрабатываются коллайдерами, которые добавляются динамически при выстреле.
// Для выстрела игрока
this.physics.add.collider(this.enemy, bullet, (enemyHit, bulletHit) => this.enemyHitCallback(enemyHit, bulletHit));
// Для выстрела противника
this.physics.add.collider(this.player, bullet, (playerHit, bulletHit) => this.playerHitCallback(playerHit, bulletHit));
В коллайдер передается callback-функция, которая срабатывает при пересечении тел. Внутри этих функций проверяется активность объектов (чтобы избежать повторной обработки) и уменьшается здоровье цели.
enemyHit.health = enemyHit.health - 1;
if (enemyHit.health <= 0) {
enemyHit.setActive(false).setVisible(false);
}
bulletHit.setActive(false).setVisible(false);
Здоровье хранится как пользовательское свойство спрайта (sprite.health). У игрока потеря здоровья визуализируется последовательным уничтожением спрайтов hp3, hp2, hp1 с помощью метода destroy(). Противник просто деактивируется при достижении нуля здоровья.
Логика противника и класс Bullet
Противник стреляет с задержкой в 1 секунду (1000 мс). Время последнего выстрела хранится в enemy.lastFired.
this.enemyFire(time) {
if ((time - this.enemy.lastFired) > 1000) {
this.enemy.lastFired = time;
// ... логика выстрела
}
}
Класс Bullet расширяет Phaser.GameObjects.Image. Его ключевой метод fire рассчитывает направление и скорость полета с помощью тригонометрии.
fire (shooter, target) {
this.setPosition(shooter.x, shooter.y);
this.direction = Math.atan((target.x - this.x) / (target.y - this.y));
// ... расчет xSpeed, ySpeed
this.rotation = shooter.rotation;
}
В методе update снаряд перемещается с учетом прошедшего времени (delta), что обеспечивает плавное движение независимо от частоты кадров. Снаряд автоматически деактивируется через 1800 мс после выстрела, предотвращая их бесконечное накопление.
Что попробовать дальше
Мы разобрали ключевые компоненты шутера на Phaser: плавное управление с ускорением, систему прицеливания с Pointer Lock, пул объектов для снарядов и обработку столкновений с механикой здоровья. Для экспериментов попробуйте: добавить разные типы оружия с собственной скоростью перезарядки и уроном, реализовать систему спавна врагов, добавить препятствия на карту, которые будут блокировать выстрелы, или внедрить визуальные эффекты (particles) при стрельбе и попадании.
