Проект epich.ru устроен прямолинейно: несколько Git-сабдиректорий, каждая со своим Dockerfile. Статические домены собираются за одно FROM nginx:alpine / COPY public/ /usr/share/nginx/html/ и живут отдельно. Подключить React сюда можно, но тогда появится сборочный шаг, node_modules и Dockerfile из двух stage. Для игры на 1000 строк это неоправданная цена.
Задача звучала конкретно: текстовый квест про бомжа с атмосферой, ломающим четвёртую стену, grotesque SVG-сценами, Web Audio синтезом без mp3-файлов, инвентарём и несколькими концовками. Всё в браузере, без сервера.
Архитектура: 5 файлов и папка SVG
Игра разделена на четыре JS-файла с иерархией зависимостей: каждый следующий опирается на предыдущий. Порядок тегов script в index.html критичен.
Engine
state, audio, DOM"] I["items.js
ITEMS
onUse, helpers"] SC["scenes.js
SCENES
text, choices"] G["game.js
Engine.init()"] end E -->|читает| SC E -->|читает| I SC -->|вызывает| E I -->|вызывает| E G -->|запускает| E E -.->|img src| SVG["svg/*.svg
nginx static"] E -->|обновляет| DOM["DOM
#scene-svg
#scene-text
#choices"] E -->|синтез| Audio["Web Audio API
5 настроений"]
Разделение не ради ради принципа, а ради редактируемости. Сценарист открывает scenes.js и видит только текст и логику переходов. Художник редактирует SVG-файлы в папке. Механики инвентаря сосредоточены в items.js, а движок при этом остаётся нетронутым.
Движок: IIFE, состояние и функция go()
Весь движок оформлен как IIFE-модуль, который возвращает публичный API. Никакого class, никакого синглтона через import. В основе лежит обычное замыкание:
const Engine = (() => {
const S = {
sceneId: null,
inventory: new Set(), // Set<string> -- id предметов
flags: new Map(), // Map<string, any>
visits: new Map(), // Map<sceneId, number>
step: 0,
};
function go(sceneId) {
const scene = SCENES[sceneId];
if (!scene) return;
S.sceneId = sceneId;
// ВАЖНО: инкремент выполняется ДО вызова text() и choices()
S.visits.set(sceneId, (S.visits.get(sceneId) || 0) + 1);
S.step++;
// ... рендер SVG, заголовка, текста, кнопок
if (scene.onEnter) scene.onEnter();
}
return { go, init, addItem, removeItem, hasItem, visitCount, ... };
})();
Состояние игры полностью инкапсулировано. Сцены и предметы обращаются к нему через публичный API: Engine.hasItem('lighter'), Engine.visitCount('yard_morning'), Engine.setFlag('cat_spoke', true). Это снижает связность: сцена не импортирует состояние напрямую, она общается через контракт.
Кнопки выбора рендерятся функцией setButtons(choices). У каждого choice либо строковое action: 'scene_id', либо функция. Движок приводит оба варианта к единому формату исполнения:
function setButtons(choices) {
const btns = $id('choices');
const padded = [...choices.slice(0, 4)];
while (padded.length < 4) padded.push(null);
padded.forEach((ch, i) => {
const b = btns.children[i];
if (!ch) { b.textContent = '· · ·'; b.disabled = true; return; }
b.textContent = ch.label;
b.disabled = false;
b.onclick = () => {
playClick();
if (typeof ch.action === 'string') go(ch.action);
else ch.action();
};
});
}
Всегда четыре кнопки в сетке 2x2. Пустой слот показывает "· · ·" и заблокирован. Это создаёт ощущение завершённого UI без необходимости динамически менять раскладку.
Web Audio без файлов: синтез пяти настроений
Для атмосферы нужен звук. Хранить mp3-файлы внутри Docker-образа можно, но это увеличивает размер и усложняет деплой. Web Audio API позволяет синтезировать звук прямо в браузере без единого аудиофайла.
В игре пять режимов атмосферы: calm, creepy, horror, void, transcend. Каждый режим создаётся набором осцилляторов и шумовых буферов:
// Нарастающая жуть: расстроенные осцилляторы + низкочастотный гул
creepy() {
const ns = [];
for (const [freq, vol] of [[110, .03], [110.9, .025], [109.1, .025]]) {
const o = A.createOscillator(); o.type = 'sawtooth'; o.frequency.value = freq;
// LFO медленно раскачивает частоту -- создаёт эффект нестабильности
const lfo = A.createOscillator(); lfo.frequency.value = 0.08;
const lg = A.createGain(); lg.gain.value = 1.8;
lfo.connect(lg); lg.connect(o.frequency);
const g = A.createGain(); g.gain.value = vol;
o.connect(g); g.connect(ambientGain);
o.start(); lfo.start(); ns.push(o, lfo);
}
// Шум через lowpass-фильтр = низкочастотный фоновый гул
const n = makeNoise(3);
const lp = A.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 65;
const ng = A.createGain(); ng.gain.value = 0.09;
n.connect(lp); lp.connect(ng); ng.connect(ambientGain); n.start(); ns.push(n);
return ns;
}
Три слегка расстроенных осциллятора на 110, 110.9 и 109.1 Гц создают биения, характерные для жуткой атмосферы. Каждый медленно качается LFO с периодом 12 секунд. Слушатель воспринимает это как нечто неправильное, но не может сразу объяснить что именно.
При смене сцены движок плавно затухает старые ноды и запускает новые через ambientGain.gain.linearRampToValueAtTime. Смена настроения занимает 2 секунды.
SVG как отдельные файлы: простой подход
Каждая сцена имеет иллюстрацию. Первый рабочий вариант хранил SVG-разметку прямо в scenes.js как template literal. Это плохо: большие строки в логике, подсветка синтаксиса не работает, художнику неудобно редактировать.
Отдельные файлы в public/svg/ решают проблему. Nginx раздаёт их как статику. В сцене хранится тег img:
function sceneSVG(name) {
return `<img src="/svg/${name}.svg" width="100%" style="display:block;min-height:220px">`;
}
const SCENES = {
yard_morning: {
svg: sceneSVG('yard_morning'),
// ...
}
};
Движок вставляет эту строку через innerHTML контейнера #scene-svg. Браузер загружает SVG как обычное изображение. CSS glitch-эффект при переходах применяется к контейнеру и захватывает img вместе с ним.
Стиль иллюстраций: grotesque cartoon. Намеренно кривые линии, диспропорции, гротескные черты лица. Всё написано руками через path, ellipse, rect с stroke-linecap="round". Никаких трассировок, никаких внешних шрифтов кроме системного monospace.
Четвёртая стена: время суток и кошка с голосом
Игра знает, что за ней наблюдают. Это реализуется несколькими простыми трюками.
Первый: приветствие зависит от времени суток реального пользователя. Вступительный текст сцены intro обращается к Engine.getTimeOfDay(), которая читает new Date().getHours(). Игрок в три часа ночи видит "Ночью играют в такое." Игрок утром видит другое.
Второй: счётчик посещений. Каждый вызов Engine.go(sceneId) инкрементирует S.visits. Сцена коробки на третьем визите замечает пятно на потолке "в форме руки с указательным пальцем, направленным вниз". На четвёртом пятно исчезает и сменяется надписью.
Третий: предупреждение, которое подтверждает разные источники. Кошка на заборе говорит "Не ходи к Семёну". Надпись на стене у мусорных баков: "Семён знает. Не иди". При повторном визите к бакам надпись уже другая: "Уже поздно". При третьем посещении стена чистая, как будто никакой надписи никогда не было.
Кнопка "Кто это делал?" во вступлении показывает мета-текст: "Эта игра знает, что ты здесь. Она не будет притворяться, что не знает." Сюрпризом это не назовёшь: контракт с игроком заключается в самом начале.
Два бага, которые нашлись сразу
visitCount равен 1 на первом визите
Первый прогон показал: сцена box_morning на первом посещении показывает текст второго визита. Причина оказалась в порядке операций внутри go(). Инкремент счётчика выполняется до вызова scene.text() и scene.choices().
Исправление прямолинейное: заменить все n === 0 на n === 1, n >= 1 на n >= 2 и так далее по всем сценам. И добавить комментарий в код, чтобы следующий редактор не воспроизвёл ту же ошибку.
Буквальные переносы строк в JS-строках
При добавлении обработчиков для items.js через инструмент замены файлов, в строковых литералах появились буквальные (unescaped) переносы строк. JavaScript их не допускает внутри одинарных или двойных кавычек. Браузер выбросил "Invalid or unexpected token".
Решение: для многострочного текста использовать template literals с backtick-кавычками. Это явная защита от такой ошибки, поскольку `...` разрешает реальные переносы строк.
Структура сцены
Каждая сцена в SCENES оформлена как объект с несколькими полями. Ничего лишнего:
back_alley: {
mood: 'creepy',
title: 'Переулок',
svg: sceneSVG('back_alley'),
text() {
const n = Engine.visitCount('back_alley'); // 1 = первый визит
if (n === 1) return `Переулок. Узкий, тёмный...`;
if (n === 2) return `В конце появилась дверь...`;
// Нарастание ужаса при каждом возвращении
return `Дверь открыта. Пахнет корицей.`;
},
choices() {
const n = Engine.visitCount('back_alley');
return choices(
n >= 4 ? { label: 'Войти в комнату', action() { /* концовка */ } }
: { label: 'Идти вглубь', action() { /* нагнетание */ } },
useItemChoice('lighter', 'Посветить зажигалкой', 'back_alley'),
useItemChoice('phone', 'Включить телефон', 'back_alley'),
{ label: 'Назад во двор', action: 'yard_morning' },
);
},
onEnter() {
const n = Engine.visitCount('back_alley');
if (n >= 4) Engine.setMood('horror');
else if (n >= 2) Engine.setMood('creepy');
},
},
Логика прогрессии полностью в одном объекте. text() описывает что игрок видит, choices() что может сделать, onEnter() меняет атмосферу при входе. Хелперы useItemChoice и choices() фильтруют null-значения, и кнопки с предметами появляются автоматически, когда предмет есть в инвентаре.
Результат и цифры
Игра живёт на bomj.epich.ru. Dockerfile занимает две строки: FROM nginx:alpine и COPY public/. Образ пересобирается только если изменились файлы домена: деплой-скрипт проверяет SHA256-хеш директории.
Архитектура масштабируется линейно: новая сцена оформляется как новый объект в SCENES, новая SVG-иллюстрация ложится в папку, при необходимости новый предмет в ITEMS. Никаких регистраций, никаких импортов, никаких конфигурационных файлов.
Осталось написать ещё 25 сцен и пять концовок.