О чем этот пример
Создание сложных игр часто требует разделения логики на независимые модули — сцены. Phaser предоставляет мощный API для управления несколькими сценами, но как эффективно переключаться между ними, менять их порядок и состояние во время выполнения? В этой статье мы разберем практический пример контроллера сцен, который демонстрирует профессиональный подход к организации межсценного взаимодействия. Вы научитесь создавать панель управления для динамического переключения, изменения видимости, активности и порядка отрисовки сцен, что особенно полезно для разработки редакторов уровней, сложных интерфейсов или игр с многослойным геймплеем.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Controller extends Phaser.Scene {
constructor ()
{
super('Controller');
this.active;
this.currentScene;
this.button1;
this.button2;
this.button3;
this.button4;
this.button5;
this.button6;
this.text1;
this.text2;
this.toggle1;
this.toggle2;
this.showTip = false;
this.dpad;
this.padUp = new Phaser.Geom.Rectangle(23, 0, 32, 26);
this.padDown = new Phaser.Geom.Rectangle(23, 53, 32, 26);
this.padLeft = new Phaser.Geom.Rectangle(0, 26, 23, 27);
this.padRight = new Phaser.Geom.Rectangle(55, 26, 23, 27);
this.bg;
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/tests/scenes/bg.jpg');
this.load.atlas('space', 'assets/tests/scenes/space.png', 'assets/tests/scenes/space.json');
this.load.atlas('ui', 'assets/tests/scenes/ui.png', 'assets/tests/scenes/ui.json');
this.load.bitmapFont('digital', 'assets/tests/scenes/digital.png', 'assets/tests/scenes/digital.xml');
}
create ()
{
this.textures.addSpriteSheetFromAtlas('mine', { atlas: 'space', frame: 'mine', frameWidth: 64 });
this.textures.addSpriteSheetFromAtlas('asteroid', { atlas: 'space', frame: 'asteroid', frameWidth: 96 });
this.anims.create({ key: 'asteroid', frames: this.anims.generateFrameNumbers('asteroid', { start: 0, end: 25 }), frameRate: 14, repeat: -1 });
this.anims.create({ key: 'mine', frames: this.anims.generateFrameNumbers('mine', { start: 0, end: 15 }), frameRate: 20, repeat: -1 });
this.bg = this.add.tileSprite(0, 135, 1024, 465, 'bg').setOrigin(0);
this.add.image(0, 0, 'ui', 'panel').setOrigin(0);
// Buttons
this.createButton(1, 'SceneA', 'nebula', 36, 26);
this.createButton(2, 'SceneB', 'sun', 157, 26);
this.createButton(3, 'SceneC', 'asteroids', 278, 26);
this.createButton(4, 'SceneD', 'planet', 36, 76);
this.createButton(5, 'SceneE', 'ship', 157, 76);
this.createButton(6, 'SceneF', 'mines', 278, 76);
// Button 1 is active first
this.button1.setFrame('button-down');
this.button1.setData('active', true);
this.active = this.button1;
// Button Labels
this.add.image(0, 0, 'ui', 'scene-labels').setOrigin(0);
// Toggles
this.toggle1 = this.createVisibleToggle(902, 35);
this.toggle2 = this.createActiveToggle(902, 75);
// LCD
this.text1 = this.add.bitmapText(520, 42, 'digital', 'nebula', 32).setOrigin(0.5, 0).setAlpha(0.8);
this.text2 = this.add.bitmapText(520, 74, 'digital', 'index 1 / 6', 22).setOrigin(0.5, 0).setAlpha(0.8);
// D-Pad
this.createDPad();
this.scene.launch('SceneA');
this.scene.launch('SceneB');
this.scene.launch('SceneC');
this.scene.launch('SceneD');
this.scene.launch('SceneE');
this.scene.launch('SceneF');
this.currentScene = this.scene.get('SceneA');
}
createVisibleToggle (x, y)
{
let toggle = this.add.image(x, y, 'ui', 'toggle-on').setOrigin(0);
toggle.setInteractive();
toggle.setData('on', true);
toggle.on('pointerup', function () {
if (toggle.getData('on'))
{
toggle.setFrame('toggle-off');
toggle.setData('on', false);
this.scene.setVisible(false, this.currentScene);
}
else
{
toggle.setFrame('toggle-on');
toggle.setData('on', true);
this.scene.setVisible(true, this.currentScene);
}
}, this);
return toggle;
}
createActiveToggle (x, y)
{
let toggle = this.add.image(x, y, 'ui', 'toggle-on').setOrigin(0);
toggle.setInteractive();
toggle.setData('on', true);
toggle.on('pointerup', function () {
if (toggle.getData('on'))
{
toggle.setFrame('toggle-off');
toggle.setData('on', false);
this.scene.setActive(false, this.currentScene);
}
else
{
toggle.setFrame('toggle-on');
toggle.setData('on', true);
this.scene.setActive(true, this.currentScene);
}
}, this);
return toggle;
}
createButton (id, scene, name, x, y)
{
let btn = this.add.image(x, y, 'ui', 'button-out').setOrigin(0);
btn.setInteractive();
btn.setData('id', id);
btn.setData('scene', scene);
btn.setData('name', name);
btn.setData('active', false);
btn.on('pointerover', function () {
if (!this.getData('active'))
{
this.setFrame('button-over');
}
});
btn.on('pointerout', function () {
if (this.getData('active'))
{
this.setFrame('button-down');
}
else
{
this.setFrame('button-out');
}
});
btn.on('pointerup', function () {
if (!btn.getData('active'))
{
this.setActiveScene(btn);
}
}, this);
this['button' + id] = btn;
}
createDPad ()
{
this.dpad = this.add.image(670, 26, 'ui', 'nav-out').setOrigin(0);
this.dpad.setInteractive();
this.dpad.on('pointermove', function (pointer, px, py) {
this.showTip = true;
if (this.padUp.contains(px, py))
{
this.dpad.setFrame('nav-up');
this.updateToolTip('bring to top');
}
else if (this.padDown.contains(px, py))
{
this.dpad.setFrame('nav-down');
this.updateToolTip('send to back');
}
else if (this.padLeft.contains(px, py))
{
this.dpad.setFrame('nav-left');
this.updateToolTip('move down');
}
else if (this.padRight.contains(px, py))
{
this.dpad.setFrame('nav-right');
this.updateToolTip('move up');
}
else
{
this.dpad.setFrame('nav-out');
this.showTip = false;
}
}, this);
this.dpad.on('pointerout', function () {
this.dpad.setFrame('nav-out');
this.showTip = false;
}, this);
this.dpad.on('pointerup', function (pointer, px, py) {
if (this.padUp.contains(px, py))
{
this.scene.bringToTop(this.currentScene);
this.showTip = false;
}
else if (this.padDown.contains(px, py))
{
this.scene.moveAbove('Controller', this.currentScene);
this.showTip = false;
}
else if (this.padLeft.contains(px, py))
{
let idx = this.scene.getIndex(this.currentScene);
if (idx > 1)
{
this.scene.moveDown(this.currentScene);
}
this.showTip = false;
}
else if (this.padRight.contains(px, py))
{
this.scene.moveUp(this.currentScene);
this.showTip = false;
}
}, this);
}
setActiveScene (btn)
{
// De-activate the old one
this.active.setData('active', false);
this.active.setFrame('button-out');
btn.setData('active', true);
btn.setFrame('button-down');
this.active = btn;
this.currentScene = this.scene.get(btn.getData('scene'));
if (this.scene.isVisible(this.currentScene))
{
this.toggle1.setFrame('toggle-on');
this.toggle1.setData('on', true);
}
else
{
this.toggle1.setFrame('toggle-off');
this.toggle1.setData('on', false);
}
if (this.scene.isActive(this.currentScene))
{
this.toggle2.setFrame('toggle-on');
this.toggle2.setData('on', true);
}
else
{
this.toggle2.setFrame('toggle-off');
this.toggle2.setData('on', false);
}
this.text1.setText(btn.getData('name'));
}
updateToolTip (tip)
{
if (!tip)
{
let idx = this.scene.getIndex(this.currentScene);
tip = 'index ' + idx + ' / 6';
}
this.text2.setText(tip);
}
update (time, delta)
{
this.bg.tilePositionX += 0.02 * delta;
this.bg.tilePositionY += 0.005 * delta;
if (!this.showTip)
{
this.updateToolTip();
}
}
}
Архитектура контроллера: сцена, управляющая сценами
Класс Controller наследуется от Phaser.Scene. Это ключевой момент: контроллер сам является сценой, что позволяет ему использовать весь API Phaser для отрисовки UI и обработки событий. Его главная задача — управлять другими сценами (SceneA-SceneF).
В конструкторе инициализируются свойства для хранения ссылок на активную сцену, UI-элементы (кнопки, переключатели) и геометрические области для D-Pad.
constructor ()
{
super('Controller');
this.active; // Активная кнопка
this.currentScene; // Текущая управляемая сцена
this.button1;
// ... другие свойства
}
В методе create() загружаются и запускаются все управляемые сцены, создается интерфейс. Запуск сцен с помощью this.scene.launch() позволяет им работать параллельно.
this.scene.launch('SceneA');
this.scene.launch('SceneB');
// ...
this.currentScene = this.scene.get('SceneA');
Создание интерактивных кнопок переключения сцен
Метод createButton() создает интерактивные кнопки, каждая из которых привязана к конкретной сцене. В данных (setData) кнопки хранится идентификатор, ключ сцены и ее отображаемое имя.
btn.setData('id', id);
btn.setData('scene', scene);
btn.setData('name', name);
btn.setData('active', false);
Обработчики событий pointerover, pointerout и pointerup меняют кадр (frame) спрайта кнопки, обеспечивая визуальный фидбек. При клике вызывается setActiveScene(), который обновляет состояние.
btn.on('pointerup', function () {
if (!btn.getData('active')) {
this.setActiveScene(btn);
}
}, this);
Метод setActiveScene() выполняет ключевую логику: снимает выделение с предыдущей активной кнопки, выделяет новую и получает ссылку на соответствующую сцену через this.scene.get(). Также он синхронизирует состояние переключателей видимости и активности.
this.active = btn;
this.currentScene = this.scene.get(btn.getData('scene'));
Управление видимостью и активностью сцен через переключатели
Переключатели (toggle) — это интерактивные изображения, реализованные методами createVisibleToggle() и createActiveToggle(). Они используют setData('on') для хранения своего состояния.
При клике они меняют кадр изображения (на toggle-on или toggle-off) и вызывают соответствующие методы API менеджера сцен: this.scene.setVisible() и this.scene.setActive().
// Для видимости
toggle.on('pointerup', function () {
if (toggle.getData('on')) {
// ...
this.scene.setVisible(false, this.currentScene);
} else {
// ...
this.scene.setVisible(true, this.currentScene);
}
}, this);
Важно понимать разницу:
* **Видимость (setVisible)**: влияет только на отрисовку сцены. Ее update()-метод продолжает выполняться.
* **Активность (setActive)**: если сцена неактивна, ее методы update() и обработчики ввода не вызываются. Это экономит ресурсы.
Изменение порядка сцен с помощью D-Pad и геометрии
Навигационный D-Pad позволяет менять порядок отрисовки сцен в стеке. Визуально это кнопка, но логически она разделена на четыре невидимые прямоугольные области (Phaser.Geom.Rectangle).
this.padUp = new Phaser.Geom.Rectangle(23, 0, 32, 26);
// ... другие области
Событие pointermove проверяет, в какую область попал курсор, с помощью метода contains(), и меняет кадр D-Pad, создавая эффект подсветки. Также обновляется текст подсказки.
if (this.padUp.contains(px, py)) {
this.dpad.setFrame('nav-up');
this.updateToolTip('bring to top');
}
В `pointerup` вызываются методы API для изменения порядка сцен:
* `this.scene.bringToTop()` — помещает сцену поверх всех.
* `this.scene.moveAbove('Controller', ...)` — помещает сцену под контроллер (отправляет "назад").
* `this.scene.moveDown()` и `this.scene.moveUp()` — перемещают на одну позицию в стеке.
if (this.padUp.contains(px, py)) {
this.scene.bringToTop(this.currentScene);
}
Текущий индекс сцены в стеке можно получить с помощью this.scene.getIndex(this.currentScene).
Динамическое обновление и фон
Метод update() контроллера выполняет две задачи. Во-первых, он анимирует фоновый tileSprite, создавая параллакс-эффект, используя delta для независимой от частоты кадров скорости.
update (time, delta)
{
this.bg.tilePositionX += 0.02 * delta;
this.bg.tilePositionY += 0.005 * delta;
// ...
}
Во-вторых, если не отображается подсказка от D-Pad (!this.showTip), он обновляет текст в нижней части LCD-дисплея, вызывая this.updateToolTip() без аргументов. В этом случае метод выводит текущий индекс активной сцены.
if (!this.showTip)
{
this.updateToolTip();
}
Метод updateToolTip() проверяет, передан ли аргумент tip. Если нет — формирует строку с индексом.
updateToolTip (tip)
{
if (!tip)
{
let idx = this.scene.getIndex(this.currentScene);
tip = 'index ' + idx + ' / 6';
}
this.text2.setText(tip);
}
Что попробовать дальше
Разобранный контроллер — это готовый каркас для управления множеством сцен в Phaser. Он демонстрирует ключевые концепции: запуск параллельных сцен, разделение состояния через setData, обработку сложного ввода с геометрией и использование полного API this.scene. Для экспериментов попробуйте
- Добавить кнопки для паузы (
this.scene.pause()) и возобновления (this.scene.resume()) сцен - Реализовать перетаскивание иконок сцен для изменения порядка
- Создать систему слоев, где каждая "сцена" — это слой с объектами на одном экране
- Сохранять и загружать состояние всех переключателей и порядок сцен
