О чем этот пример

Визуализация данных в реальном времени — частая задача в разработке игр: индикаторы здоровья, таймеры, мини-карты. В этой статье разберем, как создать сцену с анимированными аналоговыми часами, используя возможности динамической отрисовки 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), изменить форму стрелок, реализовать таймер обратного отсчета вместо текущего времени или привязать вращение стрелки к значению шкалы здоровья игрока.