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

Создание игры, которая корректно отображается на любом устройстве — от десктопа до смартфона — это ключевой навык разработчика. Масштабирование игрового поля (canvas) часто становится головной болью, особенно при работе с фиксированными внутренними координатами, такими как холст `RenderTexture`. В этой статье мы разберем пример, который решает эту проблему, используя режим масштабирования `RESIZE` и умную настройку камеры. Вы научитесь создавать интерактивное поле для рисования с фиксированным внутренним разрешением, которое идеально растягивается под любой размер окна браузера.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


const parent = 'phaser-example';
const config = {
  canvasStyle: 'width: 100%; height: 100%',
  scale: {
    mode: Phaser.Scale.RESIZE,
    parent,
    resizeInterval: 50,
  },
  backgroundColor: '#adadad',
  disableContextMenu: true,
  parent,
  _width: 1024, _height: 1024,
  type: Phaser.WEBGL,
  scene: {
    preload,
    create,
    update,
  },
};
const game = new Phaser.Game(config);

const CANVAS_WIDTH = 512;
const CANVAS_HEIGHT = CANVAS_WIDTH;

function preload ()
{
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
    this.load.image('box', 'assets/sprites/crate.png');
    this.load.image('grid', 'assets/pics/uv-grid.jpg');
}

function create() {

    // Zoom into the top-left corner which has our canvas.
  this.cameras.main.setOrigin(0, 0);

//   this.add.image(0, 0, 'grid').setOrigin(0, 0);

  const rt = this.add.renderTexture(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    rt.fill(0xff0000, 1);
    rt.draw('grid', 0, 0);

  const edgeBrush = this.add.circle(-1_000, -1_000, 20, 0xff0000).setOrigin(0.5, 0.5);

  rt.draw(edgeBrush, 0, 0);
  rt.draw(edgeBrush, CANVAS_WIDTH, 0);
  rt.draw(edgeBrush, 0, CANVAS_HEIGHT);
  rt.draw(edgeBrush, CANVAS_WIDTH, CANVAS_HEIGHT);

  var b = this.add.image(0, 0, 'box').setVisible(false);

  // Events
  this.input.on('pointerdown', (pointer) => {
    // const {x, y} = normalizePoint(pointer, this.scale.canvasBounds);

    if (pointer.which === 3 || pointer.button === 2)
    {
        rt.clear();
    }
    else
    {
        rt.draw(b, pointer.worldX, pointer.worldY);
    }

}, this);

//   this.input.on('pointermove', (pointer) => {
//     if (pointer.isDown) {
//       const {x, y} = normalizePoint(pointer, this.scale.canvasBounds);
//       rt.draw('box', x, y);
//     //   rt.draw(brush, x, y);
//     }
//   }, this);
}

function update(time, delta) {
  const {width, height} = this.scale.canvasBounds;
  // Zoom the camera so that rt spans the full width and height.
  // This would zoom the width and height separately, but that feature
  // was only added in 3.50, so in order to make this example work in 3.24.1,
  // we keep the canvas width and height identical.
  this.cameras.main.setZoom(
    width / CANVAS_WIDTH
  );
}

/** Given a pointer event, returns an x/y coord normalized to CANVAS_WIDTH x CANVAS_HEIGHT. */
function normalizePoint(pointer, canvasBounds) {
  const {width, height} = canvasBounds;
  return {x: pointer.x / width * CANVAS_WIDTH, y: pointer.y / height * CANVAS_HEIGHT};
}

Настройка масштабирования: основа адаптивности

Вся магия адаптивности начинается с конфигурации игры. Ключевая роль отводится объекту scale.

const config = {
  canvasStyle: 'width: 100%; height: 100%',
  scale: {
    mode: Phaser.Scale.RESIZE,
    parent: 'phaser-example',
    resizeInterval: 50,
  },
  // ... остальные параметры
};

Режим Phaser.Scale.RESIZE заставляет физический элемент <canvas> изменять свои атрибуты width и height в пикселях при каждом изменении размеров его родительского контейнера. Стиль width: 100%; height: 100% гарантирует, что канвас займет все доступное пространство. Параметр resizeInterval (в миллисекундах) ограничивает частоту вызова событий ресайза для оптимизации производительности.

Важный нюанс: внутренние размеры игрового мира (_width, _height) в этом контексте не используются для отображения. Их основная роль — определить область, в которой работает физический движок (если он подключен). Все визуальное масштабирование мы будем контролировать камерой.

Фиксированный внутренний холст: RenderTexture

Внутри нашего растягивающегося мира мы создаем холст с фиксированным разрешением. Это идеально для игровых интерфейсов, мини-карт или, как в нашем случае, области для рисования.

const CANVAS_WIDTH = 512;
const CANVAS_HEIGHT = CANVAS_WIDTH;

function create() {
  const rt = this.add.renderTexture(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
  rt.fill(0xff0000, 1);
  rt.draw('grid', 0, 0);
}

Мы создаем RenderTexture размером 512x512 пикселей. Это наше внутреннее, «логическое» пространство для рисования. Метод rt.fill() заливает его красным цветом, а rt.draw() накладывает текстуру сетки. Все дальнейшие операции (например, рисование спрайтов) будут использовать эти фиксированные координаты. Квадратный размер (CANVAS_WIDTH === CANVAS_HEIGHT) упрощает расчет масштаба, как мы увидим далее.

Динамический зум камеры: связь миров

Теперь нужно связать наше фиксированное внутреннее пространство (512x512) с постоянно меняющимся размером физического канваса. Для этого в функции update динамически вычисляется и применяется зум основной камеры.

function update(time, delta) {
  const {width, height} = this.scale.canvasBounds;
  this.cameras.main.setZoom(width / CANVAS_WIDTH);
}

this.scale.canvasBounds содержит текущие фактические размеры HTML-элемента <canvas> в пикселях. Делим текущую ширину канваса на ширину нашего внутреннего холста. Если окно браузера шириной 1024px, зум будет равен 2 (1024 / 512). Это значит, что каждый пиксель RenderTexture будет растянут до 2х пикселей на экране, заполняя всю ширину. Камера смотрит в начало координат (setOrigin(0,0)), поэтому холст всегда прижат к левому верхнему углу.

Обработка ввода в масштабируемом мире

Координаты указателя мыши приходят относительно всего окна, но нам нужно преобразовать их в координаты нашего внутреннего холста RenderTexture. В примере закомментирована вспомогательная функция normalizePoint, но ее логика критически важна для понимания.

/** Given a pointer event, returns an x/y coord normalized to CANVAS_WIDTH x CANVAS_HEIGHT. */
function normalizePoint(pointer, canvasBounds) {
  const {width, height} = canvasBounds;
  return {
    x: pointer.x / width * CANVAS_WIDTH,
    y: pointer.y / height * CANVAS_HEIGHT
  };
}

Однако, в данном примере используется более элегантное решение. Поскольку камера уже смасштабирована так, что холст rt занимает весь экран, мы можем использовать свойство pointer.worldX и pointer.worldY. Эти свойства возвращают координаты указателя уже в системе координат игрового мира, которая благодаря нашему зуму камеры идеально совпадает с системой координат RenderTexture. Поэтому спрайт рисуется точно под курсором.

this.input.on('pointerdown', (pointer) => {
    if (pointer.which === 3 || pointer.button === 2) {
        rt.clear(); // Правая кнопка мыши очищает холст
    } else {
        // worldX/worldY уже приведены к нашим внутренним координатам
        rt.draw(b, pointer.worldX, pointer.worldY);
    }
}, this);

Клик правой кнопкой мыши вызывает rt.clear(), что полностью очищает RenderTexture.

Что попробовать дальше

Комбинация Scale.RESIZE и динамического зума камеры — мощный паттерн для создания адаптивных игровых элементов с фиксированной внутренней логикой. Вы получили рабочую заготовку для интерактивного холста, который корректно работает на любом экране. **Идеи для экспериментов:** 1. Реализуйте pointermove с isDown для рисования линий при зажатой кнопке мыши. 2. Добавьте разные «кисти», меняя спрайт в rt.draw(). 3. Создайте несколько RenderTexture с разным внутренним разрешением и попробуйте масштабировать их независимо с помощью дополнительных камер. 4. Используйте Phaser 3.60+ и функцию setZoomToRect камеры, чтобы более гибко работать с прямоугольными (не квадратными) внутренними холстами.