О чем этот пример
Создание динамичного шутера с видом сверху требует не только отзывчивого управления персонажем, но и интуитивной системы прицеливания. В этой статье разберем пример, где игрок управляет перемещением с помощью WASD, а прицел — мышью с использованием Pointer Lock API. Мы реализуем плавное вращение персонажа в сторону прицела, «умную» камеру, следующую за прицелом, и физические ограничения для скорости и дистанции. Такой подход полезен для создания напряженных экшен-игр, где важны и точность стрельбы, и маневренность. Вы научитесь связывать физические тела, обрабатывать комбинированный ввод с клавиатуры и мыши, а также контролировать игровое пространство, чтобы механика оставаться управляемой и честной.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
time = 0;
lastFired = 0;
bullets;
moveKeys;
reticle;
player;
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('target', 'assets/demoscene/ball.png');
this.load.image('background', 'assets/skies/underwater1.png');
}
create ()
{
// Create world bounds
this.physics.world.setBounds(0, 0, 1600, 1200);
// Add background, player, and reticle sprites
const background = this.add.image(800, 600, 'background');
this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');
// 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.reticle.setOrigin(0.5, 0.5).setDisplaySize(25, 25).setCollideWorldBounds(true);
// Set camera zoom
this.cameras.main.zoom = 0.5;
// 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); }
});
// Locks pointer on 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, this);
// Move reticle upon locked pointer move
this.input.on('pointermove', function (pointer)
{
if (this.input.mouse.locked)
{
this.reticle.x += pointer.movementX;
this.reticle.y += pointer.movementY;
}
}, this);
}
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);
// Camera follows reticle
this.cameras.main.startFollow(this.reticle);
// Makes 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 reticle to radius around player
this.constrainReticle(this.reticle, 550);
}
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, radius)
{
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
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; }
// Ensures reticle cannot be moved further than dist(radius) from player
const distBetween = Phaser.Math.Distance.Between(this.player.x, this.player.y, reticle.x, reticle.y);
if (distBetween > radius)
{
// Place reticle on perimeter of circle on line intersecting player & reticle
const scale = distBetween / radius;
reticle.x = this.player.x + (reticle.x - this.player.x) / scale;
reticle.y = this.player.y + (reticle.y - this.player.y) / scale;
}
}
}
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
parent: 'phaser-example',
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: true
}
},
scene: Example
};
const game = new Phaser.Game(config);
Настройка сцены и загрузка ресурсов
В методе preload() загружаются необходимые графические ресурсы. Обратите внимание на использование load.spritesheet для персонажа — это позволяет в будущем добавить анимацию. Базовый URL задается для удобства, чтобы не указывать полные пути к каждому файлу.
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.spritesheet('player_handgun', 'assets/sprites/player_handgun.png',
{ frameWidth: 66, frameHeight: 60 }
);
this.load.image('target', 'assets/demoscene/ball.png');
this.load.image('background', 'assets/skies/underwater1.png');
В create() происходит базовая инициализация мира. Устанавливаются границы (setBounds), добавляются спрайты фона, игрока и прицела (ретикула) с помощью physics.add.sprite, что сразу делает их физическими телами. Камера уменьшает масштаб (zoom = 0.5), чтобы показать больше игрового пространства.
this.physics.world.setBounds(0, 0, 1600, 1200);
this.player = this.physics.add.sprite(800, 600, 'player_handgun');
this.reticle = this.physics.add.sprite(800, 700, 'target');
this.cameras.main.zoom = 0.5;
Управление движением: клавиатура и физика
Управление персонажем построено на акселерации, что дает ощущение инерции. Создается объект moveKeys для отслеживания состояния клавиш WASD. Однако движение реализовано через обработчики событий keydown и keyup для более точного контроля.
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);
});
Обработка keyup усложнена проверкой состояния противоположной клавиши. Это предотвращает резкую остановку, если игрок, например, отпускает `W, но продолжает удерживатьS` для движения вниз.
this.input.keyboard.on('keyup_W', event => {
if (this.moveKeys['down'].isUp) { this.player.setAccelerationY(0); }
});
Прицеливание мышью и Pointer Lock API
Для абсолютного контроля за прицелом используется Pointer Lock API, который скрывает курсор и предоставляет данные о перемещении мыши (movementX, movementY). Блокировка активируется при клике на canvas.
game.canvas.addEventListener('mousedown', () => {
game.input.mouse.requestPointerLock();
});
this.input.keyboard.on('keydown_Q', event => {
if (game.input.mouse.locked) { game.input.mouse.releasePointerLock(); }
}, 0, this);
В обработчике события pointermove мы изменяем координаты ретикула на величину смещения мыши, но только если активна блокировка указателя. Это дает плавное и прямое управление прицелом.
this.input.on('pointermove', function (pointer)
{
if (this.input.mouse.locked)
{
this.reticle.x += pointer.movementX;
this.reticle.y += pointer.movementY;
}
}, this);
Игровая логика в update: вращение, камера и физика
Метод update() выполняется каждый кадр и отвечает за ключевую игровую логику. Вращение персонажа рассчитывается так, чтобы он всегда "смотрел" на прицел. Для этого используется Phaser.Math.Angle.Between.
this.player.rotation = Phaser.Math.Angle.Between(this.player.x, this.player.y, this.reticle.x, this.reticle.y);
Камера следует за прицелом (startFollow), обеспечивая обзор поля боя вокруг точки прицеливания. При этом ретикул наследует скорость игрока, создавая ощущение связи с персонажем.
this.cameras.main.startFollow(this.reticle);
this.reticle.body.velocity.x = this.player.body.velocity.x;
this.reticle.body.velocity.y = this.player.body.velocity.y;
Далее вызываются два критически важных метода: constrainVelocity ограничивает максимальную скорость игрока, а constrainReticle не дает прицелу улететь слишком далеко от персонажа или за границы экрана.
Ограничивающие функции: контроль скорости и дистанции
Функция constrainVelocity проверяет квадрат текущей скорости тела игрока (vx * vx + vy * vy). Если он превышает квадрат максимально допустимой скорости, вектор скорости нормализуется и умножается на maxVelocity. Это стандартный способ ограничения скорости без изменения направления.
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 решает две задачи. Сначала она проверяет, не ушел ли прицел за пределы прямоугольной зоны (800x600 пикселей от игрока), и возвращает его на границу. Затем вычисляется дистанция между игроком и прицелом. Если она больше заданного радиуса, координаты прицела проецируются на окружность этого радиуса.
const scale = distBetween / radius;
reticle.x = this.player.x + (reticle.x - this.player.x) / scale;
reticle.y = this.player.y + (reticle.y - this.player.y) / scale;
Что попробовать дальше
Мы собрали ядро для топ-даун шутера с раздельным управлением: движение на клавиатуре, прицеливание на мышью. Ключевые элементы — использование Pointer Lock API для точного ввода, физическая связь тел игрока и прицела, а также функции-ограничители, поддерживающие баланс игры.
Для экспериментов попробуйте: добавить стрельбу по нажатию кнопки мыши, создавая пули (physics.add.group) в направлении player.rotation; изменить логику камеры, чтобы она плавно следовала за промежуточной точкой между игроком и прицелом; внедрить систему "увязания" прицела на больших дистанциях для силового оружия.
