Коротко
Модули логики игры должны зависеть друг от друга в одну сторону: интерфейс знает об очках, очки — о шине событий, но не наоборот. Цикл зависимостей находят обходом графа в глубину, порядок инициализации получают топологической сортировкой, а разрывают цикл выносом общего кода вниз или шиной событий. Ниже — схема слоёв, проверенный в Node.js 24 код поиска цикла и шины, а также реальная ошибка циклического импорта ES-модулей.
Содержание
- Признаки того, что логику пора делить
- Слои модулей: кто кого может импортировать
- Почему цикл опасен: опыт с ES-модулями в Node.js
- Поиск цикла и порядок инициализации
- Три способа разорвать цикл и шина событий
- Таблица: модули платформера и их зависимости
- Код на JavaScript: граф, цикл, порядок, шина
- Практический сценарий: разбираем один файл логики на модули
- Когда вам это не подойдёт
Признаки того, что логику пора делить
Логику пора делить, когда одна правка требует читать код, не связанный с ней. Типичные сигналы: правило урона скопировано в бой, ловушки и падение в пропасть; изменение очков ломает отрисовку интерфейса; новый человек в команде не может найти, где включается пауза. Делить нужно по зонам ответственности — ввод, игрок, бой, очки, интерфейс, — а не просто «первые 300 строк в один файл, остальные в другой».
Слои модулей: кто кого может импортировать
Модули раскладывают по слоям, и импортировать разрешено только вниз. Нижний слой — настройки, общие константы и шина событий, выше — ввод, ещё выше — игровые системы (игрок, бой, очки), сверху — интерфейс и сцена, которая всё собирает. Правило одно:
модуль слоя N может импортировать только модули слоёв < N
Если нижнему модулю нужно сообщить что-то верхнему, он не импортирует его, а публикует событие. Так бой не знает об очках, а очки узнают о победе над врагом из шины.
Почему цикл опасен: опыт с ES-модулями в Node.js
Циклический импорт делает результат зависимым от порядка загрузки модулей. По MDN, цикл не всегда приводит к ошибке: значение из другого модуля читается в момент использования, и ошибка возникает, только если оно к этому моменту ещё не инициализировано. Мы проверили это в Node.js 24 на двух файлах: combat.mjs импортирует функцию из score.mjs, а score.mjs при загрузке читает константу DAMAGE из combat.mjs. Запуск node combat.mjs завершился ошибкой:
ReferenceError: Cannot access 'DAMAGE' before initialization
Если бы первым загружался score.mjs, ошибки могло не быть — и именно поэтому циклы коварны: код работает, пока кто-то не переставит импорты.
Поиск цикла и порядок инициализации
Цикл в графе зависимостей находят обходом в глубину с тремя состояниями вершин: «не посещена», «в стеке обхода», «проверена». Если из вершины в стеке есть ребро в другую вершину, которая тоже в стеке, — найден цикл, и путь по стеку показывает его целиком. Время работы — O(V + E), где V — число модулей, E — число импортов.
Когда циклов нет, порядок инициализации даёт топологическая сортировка: модуль добавляется в список после всех своих зависимостей. Тот же обход в глубину, только вершина записывается при выходе из неё.
Три способа разорвать цикл и шина событий
MDN перечисляет три способа устранить цикл: объединить модули, вынести общий код в третий модуль, перенести часть кода из одного модуля в другой. В игровой логике чаще всего работают два:
- Вынос вниз. Константа урона, которую читают и бой, и очки, переезжает в модуль настроек.
- Шина событий. Бой публикует «враг побеждён» с данными (
id,points), очки подписываются. Бой больше не импортирует очки.
Шина событий — известный приём развязки игровых систем; в книге Роберта Нистрома «Game Programming Patterns» очередь событий описана как средство общения систем высокого уровня, которые хотят оставаться развязанными. Используйте шину для сообщений «что-то произошло», а прямой вызов — когда нужен ответ.
Таблица: модули платформера и их зависимости
Ниже — схема из семи модулей после разрыва цикла; стрелки идут только вниз по слоям.
| Слой | Модуль | Импортирует | Публикует события | Слушает события |
|---|---|---|---|---|
| 0 | config | — | — | — |
| 0 | bus | — | — | — |
| 1 | input | config | — | — |
| 2 | player | input, config | playerHurt | — |
| 2 | combat | player, config, bus | enemyKilled | — |
| 2 | score | bus | scoreChanged | enemyKilled |
| 3 | hud | score, player | — | scoreChanged |
Проверка таблицы — тот же код поиска цикла: если он вернул null, слои соблюдены.
Код на JavaScript: граф, цикл, порядок, шина
Код находит цикл «бой → очки → бой», проверяет исправленную схему, выводит порядок инициализации и показывает шину событий. Он проверен в Node.js 24, вывод совпадает с комментариями.
// Модули логики и их зависимости: кто кого импортирует
const modules = {
config: [],
input: ["config"],
player: ["input", "config"],
combat: ["player", "score"], // бой начисляет очки напрямую…
score: ["combat"], // …а очки читают урон из боя — кольцо
hud: ["score", "player"],
};
// Поиск цикла обходом в глубину: серый — «в стеке», чёрный — «проверен»
function findCycle(graph) {
const color = {};
const stack = [];
function dfs(n) {
color[n] = "gray";
stack.push(n);
for (const m of graph[n]) {
if (color[m] === "gray") return stack.slice(stack.indexOf(m)).concat(m);
if (!color[m]) { const c = dfs(m); if (c) return c; }
}
stack.pop();
color[n] = "black";
return null;
}
for (const n in graph) if (!color[n]) { const c = dfs(n); if (c) return c; }
return null;
}
// Порядок инициализации: сначала зависимости, потом зависимые (топологическая сортировка)
function initOrder(graph) {
const done = new Set(), order = [];
const visit = function (n) {
if (done.has(n)) return;
done.add(n);
graph[n].forEach(visit);
order.push(n);
};
Object.keys(graph).forEach(visit);
return order;
}
console.log(findCycle(modules).join(" → ")); // combat → score → combat
// Разрыв кольца: бой публикует событие, очки подписываются; общий урон уезжает в config
function createBus() {
const subs = {};
return {
on(name, fn) { (subs[name] = subs[name] || []).push(fn); },
emit(name, data) { (subs[name] || []).forEach(function (fn) { fn(data); }); },
};
}
const fixed = {
config: [], bus: [],
input: ["config"],
player: ["input", "config"],
combat: ["player", "config", "bus"],
score: ["bus"],
hud: ["score", "player"],
};
console.log(findCycle(fixed)); // null
console.log(initOrder(fixed).join(", ")); // config, bus, input, player, combat, score, hud
const bus = createBus();
const score = { value: 0 };
bus.on("enemyKilled", function (e) { score.value += e.points; });
bus.emit("enemyKilled", { id: 7, points: 100 });
bus.emit("enemyKilled", { id: 8, points: 250 });
console.log(score.value); // 350
Порядок инициализации начинается с config и bus и заканчивается hud: каждый модуль запускается после всех, от кого зависит.
Практический сценарий: разбираем один файл логики на модули
Перенос логики из одного файла на 600 строк в модули занимает 1–2 вечера и идёт в таком порядке:
- Инвентаризация. Выпишите все глобальные переменные и функции, отметьте, кто их читает и кто меняет.
- Нижний слой. Вынесите константы и настройки в
config— это сразу убирает самые частые циклы. - Системы. Перенесите ввод, игрока, бой и очки в отдельные модули; каждый экспортирует функции
initиupdate. - Граф. Запишите зависимости как объект из кода выше и запустите
findCycle; при цикле решите, что выносится вниз, а что идёт через шину. - Порядок. Вызывайте
initмодулей в порядкеinitOrder, аupdate— в фиксированном порядке игрового цикла. - Защита. Добавьте проверку графа в тесты, чтобы новый импорт, создающий цикл, ломал сборку сразу.
Когда вам это не подойдёт
Слои, граф и шина нужны не каждому проекту:
- Прототип на выходные: игра из 200 строк в одном файле читается целиком, и разбиение на модули только замедлит эксперименты.
- Движок уже задаёт архитектуру: если инструмент сам управляет порядком подключения логики и сцен, ручная топологическая сортировка не нужна — важнее соблюдать его правила импорта.
- Логика поведения одного персонажа: когда сложность внутри одного героя (бег, прыжок, атака, урон), её лучше организовать конечным автоматом состояний, а не дробить на модули.
Мы разрабатываем J2DRU — браузерный 2D игровой движок, где логику можно разделять между несколькими листами событий, связывать через импорты и переиспользовать в функциях, а порядок подключения проекта и сцен остаётся предсказуемым. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): MDN — JavaScript modules, раздел Cyclic imports, Game Programming Patterns — Event Queue.
Читайте также: