О чем этот пример
Визуализация данных в реальном времени — частая задача в разработке игр: индикаторы здоровья, таймеры, мини-карты. В этой статье разберем, как создать сцену с анимированными аналоговыми часами, используя возможности динамической отрисовки Phaser 3. Вы научитесь работать с объектом `Graphics` для рисования сложных фигур, управлять камерами внутри сцены и синхронизировать графику с системным временем. Этот паттерн полезен для создания любых кастомных UI-элементов, которые невозможно сделать стандартными спрайтами.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Clock extends Phaser.Scene {
constructor (handle, parent)
{
super(handle);
this.parent = parent;
this.graphics;
this.clockSize = 120;
}
create ()
{
const 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 ()
{
const graphics = this.graphics;
const timer = this.timerEvent;
const clockSize = this.clockSize;
const x = Clock.WIDTH / 2;
const 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);
let date = new Date;
let seconds = date.getSeconds() / 60;
let mins = date.getMinutes() / 60;
let hours = date.getHours() / 24;
// The hours hand
let size = clockSize * 0.9;
let angle = (360 * hours) - 90;
let 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);
let 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);
let 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;
Архитектура сцены и настройка камеры
Класс Clock расширяет Phaser.Scene, что позволяет использовать его как независимую логическую единицу. Конструктор принимает handle (идентификатор сцены) и parent — объект, содержащий координаты `xиy`, относительно которых позиционируются часы.
Важную роль играют статические свойства Clock.WIDTH и Clock.HEIGHT. Они определяют размеры области просмотра (viewport) камеры. Это позволяет изолировать отрисовку часов в заданном прямоугольнике.
В методе create() мы настраиваем камеру и создаем объект для рисования.
this.cameras.main.setViewport(this.parent.x, this.parent.y, Clock.WIDTH, Clock.HEIGHT);
this.cameras.main.setBackgroundColor(0x0055aa);
this.graphics = this.add.graphics();
Вызов setViewport() определяет, где на основном холсте игры будет отображаться наша сцена-часы. Метод setBackgroundColor() задает фоновый цвет этой области. Объект this.graphics — наш главный инструмент для векторной отрисовки.
Динамическая отрисовка в методе Update
Вся анимация происходит в update(), который вызывается на каждом кадре. Первым делом мы очищаем холст от предыдущего кадра.
graphics.clear();
Затем рисуем циферблат — белый круг с черной обводкой.
graphics.fillStyle(0xffffff, 1);
graphics.lineStyle(3, 0x000000, 1);
graphics.fillCircle(x, y, clockSize);
graphics.strokeCircle(x, y, clockSize);
Методы fillStyle() и lineStyle() задают цвет и прозрачность заливки и линии соответственно. fillCircle() и strokeCircle() рисуют закрашенный круг и его контур.
Далее получаем текущее системное время и переводим часы, минуты и секунды в доли суток/часа/минуты (значения от 0 до 1). Это нужно для последующего расчета угла.
let date = new Date;
let seconds = date.getSeconds() / 60;
let mins = date.getMinutes() / 60;
let hours = date.getHours() / 24;
Магия поворота: Рисование стрелок
Каждая стрелка рисуется как треугольник (ромб), состоящий из двух треугольников, исходящих из центра. Ключевую роль играет метод Phaser.Math.RotateAroundDistance().
Рассмотрим логику для часовой стрелки. Сначала вычисляем угол. Так как 0 градусов в системе координат Phaser указывает направо, мы вычитаем 90°, чтобы стрелка "смотрела" вверх в полночь. Умножение 360 * hours переводит долю часа в градусы.
let angle = (360 * hours) - 90;
Затем находим конечную точку (кончик) стрелки, поворачивая виртуальную точку на расстоянии size от центра (x, y) на вычисленный угол.
let dest = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle), size);
Функция Phaser.Math.DegToRad() конвертирует градусы в радианы, что требуется для тригонометрических расчетов.
Чтобы стрелка не была просто линией, мы рисуем ее с утолщением у основания. Для этого находим две дополнительные точки (p1 и p2), слегка смещенные от основного угла (angle - 5 и angle + 5 градусов) и расположенные ближе к центру (size * 0.7).
let p1 = Phaser.Math.RotateAroundDistance({ x: x, y: y }, x, y, Phaser.Math.DegToRad(angle - 5), size * 0.7);
Последовательность команд beginPath(), moveTo(), lineTo(), fillPath() и closePath() — это стандартный API Graphics для рисования и заливки контуров. Мы рисуем два треугольника, которые вместе образуют ромбовидную стрелку.
Для секундной стрелки используется та же логика, но с красным цветом (0xff0000) и более короткой базой (size * 0.3).
Управление позицией и порядком сцен
Так как часы являются вложенным элементом, их нужно перемещать вместе с "родителем". Для этого используется метод refresh(), который вызывается извне (например, из родительской сцены).
refresh ()
{
this.cameras.main.setPosition(this.parent.x, this.parent.y);
this.scene.bringToTop();
}
setPosition() перемещает вьюпорт камеры часов на новые координаты. this.scene.bringToTop() гарантирует, что сцена с часами будет отрисована поверх других сцен, что важно для корректного отображения UI-элемента.
Что попробовать дальше
Мы разобрали, как создать динамический UI-компонент "с нуля" средствами Phaser. Основные инструменты: сцены для изоляции логики, камеры для управления областью отрисовки и объект Graphics для векторной графики. Для экспериментов попробуйте: добавить цифры на циферблат с помощью текста (this.add.text), изменить форму стрелок, реализовать таймер обратного отсчета вместо текущего времени или привязать вращение стрелки к значению шкалы здоровья игрока.
