О чем этот пример
Создание процедурно генерируемого, бесконечного игрового мира — ключевая задача для многих жанров игр. В этом примере мы рассмотрим, как можно реализовать бесконечный ландшафт из астероидов, используя детерминированные хеш-функции Phaser. Этот подход не требует хранения огромных массивов данных и позволяет создавать сложные, повторяемые паттерны на лету, что идеально подходит для космических симуляторов, бесконечных раннеров или фоновых декораций.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
acc = 0;
dist = 0;
rows = [];
ship;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('asteroid1', 'assets/games/asteroids/asteroid1.png');
this.load.image('asteroid2', 'assets/games/asteroids/asteroid2.png');
this.load.image('asteroid3', 'assets/games/asteroids/asteroid3.png');
this.load.image('ship', 'assets/games/asteroids/ship.png');
}
create()
{
this.ship = this.add.image(640, 500, 'ship').setRotation(-Math.PI / 2).setDepth(100);
for (let y = 0; y < this.scale.height + 128; y += 8)
{
this.update(0, 16.7);
}
}
update (time, delta)
{
const { width, height } = this.scale;
const d = delta / 2;
this.acc += d;
this.dist += d;
this.ship.setPosition(
640 + 64 * Math.cos(this.dist / 500),
500 + 64 * Math.sin(this.dist / 487)
);
// Advance landscape.
const color = new Phaser.Display.Color();
for (const row of this.rows)
{
if (row[0].y > height + 128)
{
for (const asteroid of row)
{
asteroid.destroy();
}
this.rows = this.rows.filter(r => r !== row);
}
for (const asteroid of row)
{
asteroid.y += d;
color.gray((asteroid.landscapeTint + 0.75 - 0.35 * asteroid.depth * 8 / height) * 255);
asteroid.setTint(color.color);
}
}
const configA = {
noiseCells: [ 4, 3, 4, 4 ],
noiseWrap: [ 4, 9, 4, 4 ],
noiseIterations: 3
};
if (this.acc > 8)
{
this.acc -= 8;
for (const row of this.rows)
{
for (const asteroid of row)
{
asteroid.depth++;
}
}
const row = [];
this.rows.push(row);
for (let x = 0; x <= width; x += 32)
{
const rotation = Phaser.Math.Hash(x + this.dist - 1) * Math.PI * 2;
const dy = Phaser.Math.HashCell([x / width, this.dist / height, 0, 0], configA);
const asteroidNumber = Math.ceil(Phaser.Math.Hash(x + this.dist - 2) * 3);
color.gray(255 * (0.5 + dy * 0.5));
const asteroid = this.add.image(
x,
dy * -128 - 64,
`asteroid${asteroidNumber}`
)
.setScale(2)
.setRotation(rotation)
.setTintMode(Phaser.TintModes.HARD_LIGHT);
asteroid.landscapeTint = dy * 0.25;
row.push(asteroid);
}
}
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1280,
height: 720,
scene: Example
};
const game = new Phaser.Game(config);
Принцип работы: Лента конвейера
В основе лежит принцип "ленты конвейера". Мы создаем ряды (rows) астероидов, которые непрерывно движутся вниз по экрану. Когда ряд полностью скрывается за нижней границей, он уничтожается, а вверху создается новый. Это создает иллюзию бесконечного движения сквозь поле астероидов.
Ключевая переменная this.dist накапливает время и является основным "семенем" (seed) для всей процедурной генерации. От нее зависит положение корабля и параметры каждого нового астероида.
Генерация астероидов: Хеши и шум
Для того чтобы каждый запуск игры и каждый ряд астероидов выглядел детерминировано (предсказуемо), но при этом естественно и разнообразно, используются две функции из Phaser.Math.
Phaser.Math.Hash() генерирует псевдослучайное число от 0 до 1 на основе входного значения (например, координаты `xи времениthis.dist`). Это число используется для выбора текстуры астероида и его начального угла поворота.
Более сложный паттерн задается с помощью Phaser.Math.HashCell(). Эта функция генерирует ячеистый шум, который идеально подходит для создания кластеров и плавных переходов. Конфигурация configA управляет размером и поведением этих ячеек.
const rotation = Phaser.Math.Hash(x + this.dist - 1) * Math.PI * 2;
const dy = Phaser.Math.HashCell([x / width, this.dist / height, 0, 0], configA);
const asteroidNumber = Math.ceil(Phaser.Math.Hash(x + this.dist - 2) * 3);
Управление глубиной и цветом
Для создания ощущения объема и движения вглубь экрана используется свойство depth спрайтов и динамический расчет цвета (тинта). Каждый кадр все астероиды в активных рядах смещаются вниз (asteroid.y += d). Каждые 8 условных единиц времени (this.acc > 8) всем существующим астероидам увеличивается depth, что визуально "отдаляет" их, а вверху создается новый ряд.
Цвет тинта вычисляется на основе изначального оттенка ландшафта (asteroid.landscapeTint), который был задан при создании, и текущей глубины объекта. Чем дальше астероид (больше depth), тем он темнее. Это имитирует рассеяние света в пространстве.
color.gray((asteroid.landscapeTint + 0.75 - 0.35 * asteroid.depth * 8 / height) * 255);
asteroid.setTint(color.color);
Режим наложения цвета Phaser.TintModes.HARD_LIGHT применяется при создании астероида, чтобы тинт взаимодействовал с текстурой, создавая более сложные визуальные эффекты.
Анимация и управление памятью
Движение корабля по простой круговой орбите добавляет динамики сцене и наглядно демонстрирует независимость генерации ландшафта от других игровых процессов.
Важный аспект — управление памятью. Объекты, вышедшие за пределы видимой области, необходимо уничтожать, чтобы избежать утечек памяти. В методе update() происходит проверка, ушел ли целый ряд за нижнюю границу экрана плюс запас в 128 пикселей. Если да, то для каждого астероида в ряду вызывается asteroid.destroy(), а сам ряд удаляется из массива this.rows.
if (row[0].y > height + 128)
{
for (const asteroid of row)
{
asteroid.destroy();
}
this.rows = this.rows.filter(r => r !== row);
}
Что попробовать дальше
Этот пример демонстрирует мощь процедурной генерации на основе хешей для создания эффективных и визуально интересных бесконечных миров. Хеш-функции обеспечивают детерминизм и высокую производительность. Для экспериментов попробуйте изменить конфигурацию configA, чтобы получить другой тип ячеистого шума, или замените движение корабля на управляемое игроком. Можно также добавить несколько слоев ландшафта с разной скоростью движения для эффекта параллакса, используя тот же принцип с разными множителями для depth и скорости.
