Каждый туториал по разработке игр начинается одинаково: цветные прямоугольники, скользящие по серому холсту. Это подходит для изучения синтаксиса, но ничего не говорит о том, как на самом деле «дышит» игровой движок. Я хотел выйти из этого «бизнеса прямоугольников». Я хотел создать что-то, что ощущалось бы как настоящая игра — dungeon crawler с видом сверху с перемещением, боем, HUD и звуком.

Я выбрал Phaser v4 и установил себе одно жесткое правило: никаких внешних ассетов. Никаких файлов изображений, никаких аудиоклипов и никаких инструментов сборки вроде Webpack или Vite. Весь проект должен был уместиться в одном HTML-файле, написанном на чистом JavaScript. Это ограничение было не ради минимализма как такового. Оно было нужно, чтобы убрать любые оправдания и любые «черные ящики». Когда вы не можете скачать спрайт-пак, чтобы закрыть пробел в своих знаниях, вы вынуждены изучать, как движок управляет текстурами, анимациями, аудио и состоянием «под капотом».

Рисование мира кодом

В обычном проекте Phaser вы вызываете this.load.image() внутри функции preload и указываете движку путь к PNG-файлу. Без этой возможности вы обращаетесь к объекту Graphics. Вы создаете его экземпляр, рисуете примитивные фигуры — прямоугольники для плиток пола, более толстые линии для стен, возможно, круг для игрока — а затем вызываете generateTexture. Этот метод захватывает буфер графики и регистрирует его в менеджере текстур Phaser под выбранным вами ключом.

С этого момента движок относится к этой сгенерированной битовой карте точно так же, как к загруженному файлу изображения. Вы можете назначить её тайлмапам, разрезать на спрайты или изменить её оттенок. Для моего dungeon crawler это означало, что я мог процедурно генерировать сетку пола, расставлять сегменты стен и менять цветовую палитру, даже не выходя из редактора кода. Практический урок заключается в том, что текстура — это просто фрагмент битовых данных в памяти. Phaser всё равно, пришла она через HTTP-запрос или через программный вызов Graphics.

Такой подход также заставляет вас осознанно задумываться о порядке отрисовки и батчинге. Когда каждая стена и плитка пола относятся к одному семейству сгенерированных текстур, вы начинаете обращать внимание на то, как Phaser группирует вызовы рендеринга. Вы замечаете разницу между статическим слоем тайлмапа и спрайт-картой отдельных объектов, потому что вы вручную решаете, что именно заслуживает существования в виде экземпляра текстуры.

Анимация без спрайтшитов

Статичные квадраты быстро надоедают, но система анимации Phaser ожидает спрайтшит — обычно это один PNG-файл с кадрами, расположенными в сетке. Я воссоздал эту полосу, используя элемент offscreen HTML canvas. Для каждого кадра анимации я очищал canvas и рисовал новую позу: простой взмах мечом, двухшаговый цикл ходьбы или покачивание врага в режиме ожидания. Как только полоса была готова, я регистрировал её в Phaser как спрайтшит, определяя ширину и высоту кадра, чтобы движок знал, где заканчивается одна поза и начинается следующая.

Выполнение этого вручную наглядно показывает, что такое анимация на самом деле: набор границ кадров на общей текстуре. Компонент анимации Phaser запрашивает начальный кадр, конечный кадр и частоту кадров. Затем он перемещает указатель через эти прямоугольные срезы на каждом тике игрового таймера. Вы перестаете воспринимать спрайтшиты как магические ассеты, созданные художниками, и начинаете видеть в них математику координат. Этот взгляд неоценим в дальнейшем, когда вам придется нарезать реальный арт или отлаживать причину, по которой анимация воспроизводит кадры не по порядку.

Звук из ничего

Аудиофайлы были под запретом, поэтому я использовал Web Audio API напрямую. Несколько строк JavaScript могут создать узел осциллятора, установить его в режим квадратной или синусоидальной волны, пропустить через узел усиления и запланировать короткий звуковой импульс. Я написал крошечные вспомогательные функции для распространенных событий: низкочастотный писк для шагов, восходящий свист при подборе добычи и резкий тон квадратной волны при получении урона.

Эти синтезированные звуки по задумке являются лишь заглушками, но они мгновенно дают игре механическую обратную связь. Вы можете почувствовать, правильный ли выбран тайминг, прежде чем переходить к записи или поиску реального аудио. Настоящая выгода заключается в архитектуре. Поскольку вы обернули триггер звука в простую функцию, замена синтезированного писка на загруженный звуковой буфер позже потребует изменения всего одной строки. Остальная часть игры — событие столкновения, вспышка интерфейса, увеличение счета — останется нетронутой. Сначала вы прототипируете ощущения, а затем повышаете качество.

Управление несколькими мирами

A real game needs more than one screen, so I split the project into separate Phaser scenes: Menu, Game, UI, and Pause. The UI scene runs in parallel with the Game scene, launched simultaneously so the health bar and score counter live in their own sandbox while the dungeon crawls below. They communicate strictly through events. When the player takes damage, the Game scene emits a change. The UI scene listens and updates its text objects. The Game scene does not import the UI, call its methods, or even check if it exists. It simply sends data into the void. That decoupling means you can yank the HUD out for testing, or replace it entirely, without touching the core game loop.

For pausing, I used a stacked Pause scene that sits on top of the Game scene. Crucially, calling scene.pause() on the Game scene actually freezes the physics world and halts timers. The Game scene stops updating, but the Pause scene remains awake to render a menu and wait for an unpause signal. If you have only ever managed pause states with a boolean flag inside one giant update loop, this feels like discovering a light switch. The engine gives you a real pause lifecycle rather than forcing you to pepper your code with if (isPaused) return guards.

Hard Lessons from Real Bugs

Working this close to the metal exposed two habits I needed to change.

First, I tried to use a method in Phaser v4 that looked public but was not part of the documented API. It changed between versions and broke my build. I refactored to use getChildren(), which is a stable, documented public method, and the instability vanished. The lesson is blunt: if a method is not in the official documentation, do not build your game on it. Internal APIs are internal for a reason. Stick to the public surface area and your project will survive engine updates.

Second, I learned never to trust events for everything. Event callbacks work perfectly well for low-stakes interactions like picking up a coin or opening a chest. But for critical state transitions—especially Game Over—I added a redundant check inside the main update loop. Events can misfire if a listener is removed, a scene pauses at an awkward microsecond, or a race condition sneaks in between emission and handling. By checking the player’s health directly in the update loop and forcing the game-over state if it hits zero, I ensured the game could never get stuck in a limbo state if an event failed to fire. The events still handle the secondary effects—screen shake, sound cues, score submission—but the authoritative logic lives where the game clock lives.

Why You Should Try This

If you are learning game development, impose this exact constraint on your next project: no external assets, one HTML file. It sounds restrictive, but it removes every excuse. You do not need to configure a bundler, wrestle with CORS errors on local audio files, or spend an afternoon curating free asset packs. You write code, you refresh the browser, and you see results.

More importantly, you will understand why the engine behaves the way it does. You will know how a texture enters the GPU because you called generateTexture. You will know how animation frames are indexed because you registered the boundaries by hand. You will know how audio reaches the speakers because you wired the oscillator. That knowledge transfers directly to larger projects that do use external assets, because the underlying mechanics never change—the engine