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