О чем этот пример
Этот пример демонстрирует мощный паттерн управления несколькими независимыми сценами в Phaser как перетаскиваемыми окнами. Вместо одного гигантского игрового цикла вы создаёте модульную систему, где каждая мини-игра (например, «Звёздное поле», «Космические захватчики» или анимированные часы) живёт в собственной сцене. Это полезно для создания редакторов уровней, сложных UI с интерактивными элементами или игр в жанре «песочница», где игрок может запускать различные активности по своему усмотрению. Ключевые техники, которые мы разберём: динамическое создание сцен, привязка вьюпорта камеры к перетаскиваемой зоне, организация событий drag-and-drop и синхронизация состояния между управляющей и дочерними сценами. Вы сможете применять эти принципы для построения чистого, масштабируемого кода.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Controller extends Phaser.Scene {
constructor ()
{
super();
this.count = 0;
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/phaser3/workbench.png');
this.load.image('demosWindow', 'assets/phaser3/demos-window.png');
this.load.image('eyesIcon', 'assets/phaser3/eyes-icon.png');
this.load.image('starsIcon', 'assets/phaser3/stars-icon.png');
this.load.image('jugglerIcon', 'assets/phaser3/juggler-icon.png');
this.load.image('twistIcon', 'assets/phaser3/twist-icon.png');
this.load.image('invadersIcon', 'assets/phaser3/invaders-icon.png');
this.load.image('clockIcon', 'assets/phaser3/clock-icon.png');
this.load.image('starsWindow', 'assets/phaser3/stars-window.png');
this.load.image('sineWindow', 'assets/phaser3/sinewave-window.png');
this.load.image('eyesWindow', 'assets/phaser3/eyes-window.png');
this.load.image('jugglerWindow', 'assets/phaser3/juggler-window.png');
this.load.image('invadersWindow', 'assets/phaser3/invaders-window.png');
this.load.image('clockWindow', 'assets/phaser3/clock-window.png');
this.load.spritesheet('juggler', 'assets/phaser3/juggler.png', { frameWidth: 128, frameHeight: 184 });
this.load.image('star', 'assets/phaser3/star2.png');
this.load.image('eye', 'assets/phaser3/eye.png');
this.load.image('invaders.boom', 'assets/games/multi/boom.png');
this.load.spritesheet('invaders.bullet', 'assets/games/multi/bullet.png', { frameWidth: 12, frameHeight: 14 });
this.load.image('invaders.bullet2', 'assets/games/multi/bullet2.png');
this.load.image('invaders.explode', 'assets/games/multi/explode.png');
this.load.spritesheet('invaders.invader1', 'assets/games/multi/invader1.png', { frameWidth: 16, frameHeight: 16 });
this.load.spritesheet('invaders.invader2', 'assets/games/multi/invader2.png', { frameWidth: 22, frameHeight: 16 });
this.load.spritesheet('invaders.invader3', 'assets/games/multi/invader3.png', { frameWidth: 24, frameHeight: 16 });
this.load.image('invaders.mothership', 'assets/games/multi/mothership.png');
this.load.image('invaders.ship', 'assets/games/multi/ship.png');
}
create ()
{
// Create animations
this.anims.create({
key: 'juggler',
frames: this.anims.generateFrameNumbers('juggler'),
frameRate: 28,
repeat: -1
});
this.anims.create({
key: 'bullet',
frames: this.anims.generateFrameNumbers('invaders.bullet'),
frameRate: 8,
repeat: -1
});
this.anims.create({
key: 'invader1',
frames: this.anims.generateFrameNumbers('invaders.invader1'),
frameRate: 2,
repeat: -1
});
this.anims.create({
key: 'invader2',
frames: this.anims.generateFrameNumbers('invaders.invader2'),
frameRate: 2,
repeat: -1
});
this.anims.create({
key: 'invader3',
frames: this.anims.generateFrameNumbers('invaders.invader3'),
frameRate: 2,
repeat: -1
});
this.add.image(0, 0, 'bg').setOrigin(0);
var demosWindow = this.add.image(0, 0, 'demosWindow').setOrigin(0);
var eyesIcon = this.add.image(32, 34, 'eyesIcon', 0).setOrigin(0).setInteractive();
var jugglerIcon = this.add.image(64, 110, 'jugglerIcon', 0).setOrigin(0).setInteractive();
var starsIcon = this.add.image(230, 40, 'starsIcon', 0).setOrigin(0).setInteractive();
var invadersIcon = this.add.image(120, 34, 'invadersIcon', 0).setOrigin(0).setInteractive();
var clockIcon = this.add.image(200, 120, 'clockIcon', 0).setOrigin(0).setInteractive();
var demosContainer = this.add.container(32, 70, [ demosWindow, eyesIcon, jugglerIcon, starsIcon, invadersIcon, clockIcon ]);
demosContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, demosWindow.width, demosWindow.height), Phaser.Geom.Rectangle.Contains);
this.input.setDraggable(demosContainer);
demosContainer.on('drag', function (pointer, dragX, dragY) {
this.x = dragX;
this.y = dragY;
});
eyesIcon.on('pointerup', function () {
this.createWindow(Eyes);
}, this);
jugglerIcon.on('pointerup', function () {
this.createWindow(Juggler);
}, this);
starsIcon.on('pointerup', function () {
this.createWindow(Stars);
}, this);
invadersIcon.on('pointerup', function () {
this.createWindow(Invaders);
}, this);
clockIcon.on('pointerup', function () {
this.createWindow(Clock);
}, this);
}
createWindow (func)
{
var x = Phaser.Math.Between(400, 600);
var y = Phaser.Math.Between(64, 128);
var handle = 'window' + this.count++;
var win = this.add.zone(x, y, func.WIDTH, func.HEIGHT).setInteractive().setOrigin(0);
var demo = new func(handle, win);
this.input.setDraggable(win);
win.on('drag', function (pointer, dragX, dragY) {
this.x = dragX;
this.y = dragY;
demo.refresh()
});
this.scene.add(handle, demo, true);
}
resize (width, height)
{
if (width === undefined) { width = this.game.config.width; }
if (height === undefined) { height = this.game.config.height; }
this.cameras.resize(width, height);
}
}
class Juggler extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
}
create ()
{
var bg = this.add.image(0, 0, 'jugglerWindow').setOrigin(0);
this.cameras.main.setViewport(this.parent.x, this.parent.y, Juggler.WIDTH, Juggler.HEIGHT);
this.add.sprite(100, 22, 'juggler').setOrigin(0).play('juggler');
}
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
}
Juggler.WIDTH = 328;
Juggler.HEIGHT = 226;
class Stars extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
this.blitter;
this.width = 320;
this.height = 220;
this.depth = 1700;
this.distance = 200;
this.speed = 6;
this.max = 300;
this.xx = [];
this.yy = [];
this.zz = [];
}
create ()
{
this.cameras.main.setViewport(this.parent.x, this.parent.y, Stars.WIDTH, Stars.HEIGHT);
this.cameras.main.setBackgroundColor(0x000000);
this.blitter = this.add.blitter(0, 0, 'star');
for (var i = 0; i < this.max; i++)
{
this.xx[i] = Math.floor(Math.random() * this.width) - (this.width / 2);
this.yy[i] = Math.floor(Math.random() * this.height) - (this.height / 2);
this.zz[i] = Math.floor(Math.random() * this.depth) - 100;
var perspective = this.distance / (this.distance - this.zz[i]);
var x = (this.width / 2) + this.xx[i] * perspective;
var y = (this.height / 2) + this.yy[i] * perspective;
var a = (x < 0 || x > 320 || y < 20 || y > 260) ? 0 : 1;
this.blitter.create(x, y);
}
var bg = this.add.image(0, 0, 'starsWindow').setOrigin(0);
}
update (time, delta)
{
var list = this.blitter.children.list;
for (var i = 0; i < this.max; i++)
{
var perspective = this.distance / (this.distance - this.zz[i]);
var x = (this.width / 2) + this.xx[i] * perspective;
var y = (this.height / 2) + this.yy[i] * perspective;
this.zz[i] += this.speed;
if (this.zz[i] > this.distance)
{
this.zz[i] -= (this.distance * 2);
}
list[i].x = x;
list[i].y = y;
list[i].a = (x < 0 || x > 320 || y < 20 || y > 260) ? 0 : 1;
}
}
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
}
Stars.WIDTH = 328;
Stars.HEIGHT = 266;
class Eyes extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
this.left;
this.right;
this.leftTarget;
this.rightTarget;
this.leftBase;
this.rightBase;
this.mid = new Phaser.Math.Vector2();
}
create ()
{
var bg = this.add.image(0, 0, 'eyesWindow').setOrigin(0);
this.cameras.main.setViewport(this.parent.x, this.parent.y, Eyes.WIDTH, Eyes.HEIGHT);
this.left = this.add.image(46, 92, 'eye');
this.right = this.add.image(140, 92, 'eye');
this.leftTarget = new Phaser.Geom.Line(this.left.x, this.left.y, 0, 0);
this.rightTarget = new Phaser.Geom.Line(this.right.x, this.right.y, 0, 0);
this.leftBase = new Phaser.Geom.Ellipse(this.left.x, this.left.y, 24, 40);
this.rightBase = new Phaser.Geom.Ellipse(this.right.x, this.right.y, 24, 40);
}
update ()
{
this.leftTarget.x2 = this.input.activePointer.x - this.parent.x;
this.leftTarget.y2 = this.input.activePointer.y - this.parent.y;
// Within the left eye?
if (this.leftBase.contains(this.leftTarget.x2, this.leftTarget.y2))
{
this.mid.x = this.leftTarget.x2;
this.mid.y = this.leftTarget.y2;
}
else
{
Phaser.Geom.Ellipse.CircumferencePoint(this.leftBase, Phaser.Geom.Line.Angle(this.leftTarget), this.mid);
}
this.left.x = this.mid.x;
this.left.y = this.mid.y;
this.rightTarget.x2 = this.input.activePointer.x - this.parent.x;
this.rightTarget.y2 = this.input.activePointer.y - this.parent.y;
// Within the right eye?
if (this.rightBase.contains(this.rightTarget.x2, this.rightTarget.y2))
{
this.mid.x = this.rightTarget.x2;
this.mid.y = this.rightTarget.y2;
}
else
{
Phaser.Geom.Ellipse.CircumferencePoint(this.rightBase, Phaser.Geom.Line.Angle(this.rightTarget), this.mid);
}
this.right.x = this.mid.x;
this.right.y = this.mid.y;
}
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
}
Eyes.WIDTH = 183;
Eyes.HEIGHT = 162;
class Invaders extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
this.left;
this.right;
this.ship;
this.invaders;
this.mothership;
this.bullet;
this.topLeft;
this.bottomRight;
this.bulletTimer;
this.mothershipTimer;
this.isGameOver = false;
this.invadersBounds = { x: 12, y: 62, right: 152 };
}
create (config)
{
this.left = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT);
this.right = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT);
this.physics.world.setBounds(4, 22, 400, 300);
this.cameras.main.setViewport(this.parent.x, this.parent.y, Invaders.WIDTH, Invaders.HEIGHT);
this.cameras.main.setBackgroundColor('#000');
this.createInvaders();
this.bullet = this.physics.add.image(200, 290, 'invaders.bullet2');
this.mothership = this.physics.add.image(500, 40, 'invaders.mothership');
this.ship = this.physics.add.image(200, 312, 'invaders.ship');
var bg = this.add.image(0, 0, 'invadersWindow').setOrigin(0);
this.ship.setCollideWorldBounds(true);
this.physics.add.overlap(this.bullet, this.invaders, this.bulletHit, null, this);
this.physics.add.overlap(this.bullet, this.mothership, this.bulletHitMothership, null, this);
this.launchBullet();
this.mothershipTimer = this.time.addEvent({ delay: 10000, callback: this.launchMothership, callbackScope: this, repeat: -1 });
this.invaders.setVelocityX(50);
}
launchMothership ()
{
this.mothership.setVelocityX(-100);
}
bulletHit (bullet, invader)
{
this.launchBullet();
invader.body.enable = false;
this.invaders.killAndHide(invader);
this.refreshOutliers();
}
bulletHitMothership (bullet, mothership)
{
this.launchBullet();
this.mothership.body.reset(500, 40);
}
refreshOutliers ()
{
var list = this.invaders.getChildren();
var first = this.invaders.getFirst(true);
var last = this.invaders.getLast(true);
for (var i = 0; i < list.length; i++)
{
var vader = list[i];
if (vader.active)
{
if (vader.x < first.x)
{
first = vader;
}
else if (vader.x > last.x)
{
last = vader;
}
}
}
this.topLeft = first;
this.bottomRight = last;
}
launchBullet ()
{
this.bullet.body.reset(this.ship.x, this.ship.y);
this.bullet.body.velocity.y = -400;
}
createInvaders ()
{
this.invaders = this.physics.add.group();
var x = this.invadersBounds.x;
var y = this.invadersBounds.y;
for (var i = 0; i < 10; i++)
{
this.invaders.create(x, y, 'invaders.invader1').setTint(0xff0000).play('invader1');
x += 26;
}
x = this.invadersBounds.x;
y += 28
for (var i = 0; i < 16; i++)
{
this.invaders.create(x, y, 'invaders.invader2').setTint(0x00ff00).play('invader2');
x += 33;
if (i === 7)
{
x = this.invadersBounds.x;
y += 28;
}
}
x = this.invadersBounds.x;
y += 28
for (var i = 0; i < 14; i++)
{
this.invaders.create(x, y, 'invaders.invader3').setTint(0x00ffff).play('invader3');
x += 38;
if (i === 6)
{
x = this.invadersBounds.x;
y += 28;
}
}
// We can use these markers to work out where the whole Group is and how wide it is
this.topLeft = this.invaders.getFirst(true);
this.bottomRight = this.invaders.getLast(true);
}
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
gameOver ()
{
this.invaders.setVelocityX(0);
this.ship.setVisible(false);
this.bullet.setVisible(false);
this.isGameOver = true;
}
update ()
{
if (this.isGameOver)
{
return;
}
if (this.left.isDown)
{
this.ship.body.velocity.x = -400;
}
else if (this.right.isDown)
{
this.ship.body.velocity.x = 400;
}
else
{
this.ship.body.velocity.x = 0;
}
// Bullet bounds
if (this.bullet.y < -32)
{
this.launchBullet();
}
// Invaders bounds
var moveDown = false;
if (this.bottomRight.body.velocity.x > 0 && this.bottomRight.x >= 390)
{
this.invaders.setVelocityX(-50);
moveDown = true;
}
else if (this.topLeft.body.velocity.x < 0 && this.topLeft.x <= 12)
{
this.invaders.setVelocityX(50);
moveDown = true;
}
if (moveDown)
{
var list = this.invaders.getChildren();
var lowest = 0;
for (var i = 0; i < list.length; i++)
{
var vader = list[i];
vader.body.y += 4;
if (vader.active && vader.body.y > lowest)
{
lowest = vader.body.y;
}
}
if (lowest > 240)
{
this.gameOver();
}
}
}
}
Invaders.WIDTH = 408;
Invaders.HEIGHT = 326;
class Clock extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
this.graphics;
this.clockSize = 120;
}
create ()
{
var bg = this.add.image(0, 0, 'clockWindow').setOrigin(0);
this.cameras.main.setViewport(this.parent.x, this.parent.y, Clock.WIDTH, Clock.HEIGHT);
this.cameras.main.setBackgroundColor(0x0055aa);
this.graphics = this.add.graphics();
}
update ()
{
var graphics = this.graphics;
var timer = this.timerEvent;
var clockSize = this.clockSize;
var x = Clock.WIDTH / 2;
var y = 8 + Clock.HEIGHT / 2;
graphics.clear();
// Progress is between 0 and 1, where 0 = the hand pointing up and then rotating clockwise a full 360
// The frame
graphics.fillStyle(0xffffff, 1);
graphics.lineStyle(3, 0x000000, 1);
graphics.fillCircle(x, y, clockSize);
graphics.strokeCircle(x, y, clockSize);
var angle;
var dest;
var p1;
var p2;
var size;
var date = new Date;
var seconds = date.getSeconds() / 60;
var mins = date.getMinutes() / 60;
var hours = date.getHours() / 24;
// The hours hand
size = clockSize * 0.9;
angle = (360 * hours) - 90;
dest = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle), size);
graphics.fillStyle(0x000000, 1);
graphics.beginPath();
graphics.moveTo(x, y);
p1 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle - 5), size * 0.7);
graphics.lineTo(p1.x, p1.y);
graphics.lineTo(dest.x, dest.y);
graphics.moveTo(x, y);
p2 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle + 5), size * 0.7);
graphics.lineTo(p2.x, p2.y);
graphics.lineTo(dest.x, dest.y);
graphics.fillPath();
graphics.closePath();
// The minutes hand
size = clockSize * 0.9;
angle = (360 * mins) - 90;
dest = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle), size);
graphics.fillStyle(0x000000, 1);
graphics.beginPath();
graphics.moveTo(x, y);
p1 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle - 5), size * 0.7);
graphics.lineTo(p1.x, p1.y);
graphics.lineTo(dest.x, dest.y);
graphics.moveTo(x, y);
p2 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle + 5), size * 0.7);
graphics.lineTo(p2.x, p2.y);
graphics.lineTo(dest.x, dest.y);
graphics.fillPath();
graphics.closePath();
// The seconds hand
size = clockSize * 0.9;
angle = (360 * seconds) - 90;
dest = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle), size);
graphics.fillStyle(0xff0000, 1);
graphics.beginPath();
graphics.moveTo(x, y);
p1 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle - 5), size * 0.3);
graphics.lineTo(p1.x, p1.y);
graphics.lineTo(dest.x, dest.y);
graphics.moveTo(x, y);
p2 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle + 5), size * 0.3);
graphics.lineTo(p2.x, p2.y);
graphics.lineTo(dest.x, dest.y);
graphics.fillPath();
graphics.closePath();
}
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
}
Clock.WIDTH = 275;
Clock.HEIGHT = 276;
var config = {
type: Phaser.WEBGL,
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: '#0055aa',
parent: 'phaser-example',
scene: Controller,
physics: {
default: 'arcade',
arcade: {
debug: false,
gravity: { y: 0 }
}
}
};
var game = new Phaser.Game(config);
window.addEventListener('resize', function (event) {
game.resize(window.innerWidth, window.innerHeight);
}, false);
Архитектура: главная сцена как менеджер окон
Вся логика управления вынесена в класс Controller. Он загружает все ресурсы для демо, создаёт панель с иконками и обрабатывает создание новых окон. Главная сцена не содержит игровой логики, а лишь координирует дочерние сцены.
Ключевой метод — createWindow(func). Он создаёт интерактивную зону (Phaser.GameObjects.Zone), которая служит контейнером и «ручкой» для перетаскивания окна.
createWindow (func) {
var x = Phaser.Math.Between(400, 600);
var y = Phaser.Math.Between(64, 128);
var handle = 'window' + this.count++;
var win = this.add.zone(x, y, func.WIDTH, func.HEIGHT).setInteractive().setOrigin(0);
var demo = new func(handle, win);
this.input.setDraggable(win);
win.on('drag', function (pointer, dragX, dragY) {
this.x = dragX;
this.y = dragY;
demo.refresh();
});
this.scene.add(handle, demo, true);
}
Обратите внимание: func — это класс сцены (например, Stars). При создании сцены ей передаётся уникальный ключ (handle) и ссылка на зону (win). Сцена запускается немедленно (true в методе add). Зона подписана на событие drag, и при перемещении вызывает метод refresh() у дочерней сцены, чтобы та обновила положение своей камеры.
Привязка камеры сцены к движущейся зоне
Каждая дочерняя сцена (например, Juggler, Stars) рисует своё содержимое не в координатах всего холста, а внутри собственного вьюпорта камеры. Размеры вьюпорта задаются статическими свойствами класса (например, Stars.WIDTH, Stars.HEIGHT). При создании сцены вьюпорт камеры позиционируется по координатам родительской зоны.
// В методе create дочерней сцены
create () {
this.cameras.main.setViewport(this.parent.x, this.parent.y, Stars.WIDTH, Stars.HEIGHT);
this.cameras.main.setBackgroundColor(0x000000);
// ... остальное содержимое сцены
}
При перетаскивании зоны вызывается метод refresh(), который обновляет позицию камеры, чтобы содержимое сцены «двигалось» вместе с окном. Также он вызывает this.scene.bringToTop(), чтобы активное окно всегда было поверх других.
refresh () {
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
Этот подход изолирует логику отрисовки каждой мини-игры: она «думает», что рисует в своём фиксированном прямоугольнике, а система камер отвечает за его позиционирование на основном экране.
Обработка интерактивности: иконки и перетаскивание
Главная сцена делает интерактивными как отдельные иконки, так и весь контейнер с ними. Иконки создаются с помощью setInteractive() и реагируют на событие pointerup, запуская создание соответствующего окна.
var eyesIcon = this.add.image(32, 34, 'eyesIcon', 0).setOrigin(0).setInteractive();
eyesIcon.on('pointerup', function () {
this.createWindow(Eyes);
}, this);
Целый контейнер (demosContainer) также получает интерактивную область (хитбокс) в виде прямоугольника и помечается как перетаскиваемый.
demosContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, demosWindow.width, demosWindow.height), Phaser.Geom.Rectangle.Contains);
this.input.setDraggable(demosContainer);
// Обработчик события drag обновляет координаты контейнера
demosContainer.on('drag', function (pointer, dragX, dragY) {
this.x = dragX;
this.y = dragY;
});
Важно: чтобы объект можно было перетаскивать, он должен быть добавлен в список перетаскиваемых через this.input.setDraggable(gameObject). Колбэк события drag автоматически получает новые координаты указателя (dragX, dragY).
Пример мини-сцены: реализация звёздного поля (Stars)
Сцена Stars демонстрирует, как можно встроить полноценный визуальный эффект в окно. Для рисования множества звёзд используется Phaser.GameObjects.Blitter — высокопроизводительный объект для отрисовки множества спрайтов из одного атласа.
В create() создаётся набор из 300 звёзд со случайными начальными координатами в 3D-пространстве (координата `z`). Для каждой звезды рассчитывается перспектива и начальная 2D-позиция на экране.
for (var i = 0; i < this.max; i++) {
this.xx[i] = Math.floor(Math.random() * this.width) - (this.width / 2);
this.yy[i] = Math.floor(Math.random() * this.height) - (this.height / 2);
this.zz[i] = Math.floor(Math.random() * this.depth) - 100;
var perspective = this.distance / (this.distance - this.zz[i]);
var x = (this.width / 2) + this.xx[i] * perspective;
var y = (this.height / 2) + this.yy[i] * perspective;
this.blitter.create(x, y);
}
В update() координата `zкаждой звезды увеличивается, создавая эффект движения на игрока. Когда звезда улетает слишком далеко, еёz-координата сбрасывается, и она появляется «сзади». Позиция и прозрачность (alpha) каждого спрайта вblitter` обновляются каждый кадр.
list[i].x = x;
list[i].y = y;
list[i].a = (x < 0 || x > 320 || y < 20 || y > 260) ? 0 : 1;
Это показывает, что каждая мини-сцена может иметь свой собственный игровой цикл (update), независимый от других.
Пример мини-сцены: интерактивные глаза (Eyes)
Сцена Eyes реализует слежение зрачков за курсором мыши. Это пример обработки ввода и геометрических вычислений внутри изолированного окна.
Логика следующая: для каждого глаза определяется целевая точка — позиция курсора, но с учётом смещения окна (this.parent.x, this.parent.y).
this.leftTarget.x2 = this.input.activePointer.x - this.parent.x;
this.leftTarget.y2 = this.input.activePointer.y - this.parent.y;
Затем проверяется, находится ли целевая точка внутри эллипса, ограничивающего движение зрачка (this.leftBase). Если да, зрачок перемещается прямо к цели. Если нет, вычисляется точка на границе этого эллипса в направлении к цели, и зрачок «упирается» в край.
if (this.leftBase.contains(this.leftTarget.x2, this.leftTarget.y2)) {
this.mid.x = this.leftTarget.x2;
this.mid.y = this.leftTarget.y2;
} else {
Phaser.Geom.Ellipse.CircumferencePoint(this.leftBase, Phaser.Geom.Line.Angle(this.leftTarget), this.mid);
}
Этот пример наглядно демонстрирует, как координаты ввода (activePointer) корректно работают внутри вьюпорта камеры: вычитание координат окна (parent) переводит глобальные координаты мыши в локальные координаты сцены.
Адаптация к изменению размера окна браузера
Проект включает простую, но важную функцию адаптации под ресайз окна браузера. При изменении размера окна генерируется событие resize, которое вызывает метод resize у экземпляра игры.
// В конфигурации игры
var game = new Phaser.Game(config);
window.addEventListener('resize', function (event) {
game.resize(window.innerWidth, window.innerHeight);
}, false);
Метод resize определен в главной сцене Controller. Он изменяет размер камеры главной сцены под новые габариты.
resize (width, height) {
if (width === undefined) { width = this.game.config.width; }
if (height === undefined) { height = this.game.config.height; }
this.cameras.resize(width, height);
}
Обратите внимание, что дочерние сцены не требуют дополнительной обработки ресайза, так как их вьюпорты привязаны к координатам своих зон, а не к глобальным размерам холста. Главная камера просто меняет область отображения, и все дочерние вьюпорты остаются на своих местах относительно неё.
Что попробовать дальше
Паттерн «сцены как перетаскиваемые окна» открывает путь к созданию сложных, модульных интерфейсов в Phaser. Вы можете разрабатывать отдельные игровые системы или инструменты изолированно, а затем легко интегрировать их в общую среду. Для экспериментов попробуйте: добавить кнопку закрытия окна с уничтожением сцены, реализовать сохранение позиций окон между сессиями, создать систему вкладок внутри одного окна или связать несколько окон общей логикой (например, инвентарь и персонаж). Этот подход отлично масштабируется и поддерживает чистоту кода.
