Модульная архитектура логики 2D-игры без циклических зависимостей

Коротко

Модули логики игры должны зависеть друг от друга в одну сторону: интерфейс знает об очках, очки — о шине событий, но не наоборот. Цикл зависимостей находят обходом графа в глубину, порядок инициализации получают топологической сортировкой, а разрывают цикл выносом общего кода вниз или шиной событий. Ниже — схема слоёв, проверенный в Node.js 24 код поиска цикла и шины, а также реальная ошибка циклического импорта ES-модулей.

Содержание

  1. Признаки того, что логику пора делить
  2. Слои модулей: кто кого может импортировать
  3. Почему цикл опасен: опыт с ES-модулями в Node.js
  4. Поиск цикла и порядок инициализации
  5. Три способа разорвать цикл и шина событий
  6. Таблица: модули платформера и их зависимости
  7. Код на JavaScript: граф, цикл, порядок, шина
  8. Практический сценарий: разбираем один файл логики на модули
  9. Когда вам это не подойдёт

Признаки того, что логику пора делить

Логику пора делить, когда одна правка требует читать код, не связанный с ней. Типичные сигналы: правило урона скопировано в бой, ловушки и падение в пропасть; изменение очков ломает отрисовку интерфейса; новый человек в команде не может найти, где включается пауза. Делить нужно по зонам ответственности — ввод, игрок, бой, очки, интерфейс, — а не просто «первые 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 перечисляет три способа устранить цикл: объединить модули, вынести общий код в третий модуль, перенести часть кода из одного модуля в другой. В игровой логике чаще всего работают два:

  1. Вынос вниз. Константа урона, которую читают и бой, и очки, переезжает в модуль настроек.
  2. Шина событий. Бой публикует «враг побеждён» с данными (id, points), очки подписываются. Бой больше не импортирует очки.

Шина событий — известный приём развязки игровых систем; в книге Роберта Нистрома «Game Programming Patterns» очередь событий описана как средство общения систем высокого уровня, которые хотят оставаться развязанными. Используйте шину для сообщений «что-то произошло», а прямой вызов — когда нужен ответ.

Таблица: модули платформера и их зависимости

Ниже — схема из семи модулей после разрыва цикла; стрелки идут только вниз по слоям.

СлойМодульИмпортируетПубликует событияСлушает события
0config———
0bus———
1inputconfig——
2playerinput, configplayerHurt—
2combatplayer, config, busenemyKilled—
2scorebusscoreChangedenemyKilled
3hudscore, 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 вечера и идёт в таком порядке:

  1. Инвентаризация. Выпишите все глобальные переменные и функции, отметьте, кто их читает и кто меняет.
  2. Нижний слой. Вынесите константы и настройки в config — это сразу убирает самые частые циклы.
  3. Системы. Перенесите ввод, игрока, бой и очки в отдельные модули; каждый экспортирует функции init и update.
  4. Граф. Запишите зависимости как объект из кода выше и запустите findCycle; при цикле решите, что выносится вниз, а что идёт через шину.
  5. Порядок. Вызывайте init модулей в порядке initOrder, а update — в фиксированном порядке игрового цикла.
  6. Защита. Добавьте проверку графа в тесты, чтобы новый импорт, создающий цикл, ломал сборку сразу.

Когда вам это не подойдёт

Слои, граф и шина нужны не каждому проекту:

  • Прототип на выходные: игра из 200 строк в одном файле читается целиком, и разбиение на модули только замедлит эксперименты.
  • Движок уже задаёт архитектуру: если инструмент сам управляет порядком подключения логики и сцен, ручная топологическая сортировка не нужна — важнее соблюдать его правила импорта.
  • Логика поведения одного персонажа: когда сложность внутри одного героя (бег, прыжок, атака, урон), её лучше организовать конечным автоматом состояний, а не дробить на модули.

Мы разрабатываем J2DRU — браузерный 2D игровой движок, где логику можно разделять между несколькими листами событий, связывать через импорты и переиспользовать в функциях, а порядок подключения проекта и сцен остаётся предсказуемым. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Источники (проверено: ноябрь 2026): MDN — JavaScript modules, раздел Cyclic imports, Game Programming Patterns — Event Queue.

Читайте также:

Частые вопросы

Как понять, что логику игры пора делить на модули?

Явные признаки — одно и то же правило скопировано в два и более места, файл логики длиннее нескольких сотен строк или правка урона требует просматривать код ввода и интерфейса. Делите по зонам ответственности, а не по размеру.

Чем опасен циклический импорт в JavaScript?

По MDN, цикл не всегда приводит к ошибке, но если модуль во время своей загрузки читает значение из модуля, который ещё не выполнился, возникает ReferenceError. Проверка в Node.js 24 даёт ошибку Cannot access 'DAMAGE' before initialization. Поведение зависит от того, какой модуль загрузится первым, поэтому ошибка может появиться после безобидной перестановки импортов.

Как разорвать циклическую зависимость между модулями?

MDN называет три способа — объединить два модуля в один, вынести общий код в третий модуль, перенести часть кода из одного модуля в другой. В играх часто добавляют четвёртый — шину событий, через которую модуль боя сообщает «враг побеждён», а модуль очков подписывается на это событие.

Не превратится ли шина событий в новую путаницу?

Превратится, если через неё идут все вызовы подряд. Используйте шину для сообщений «что-то произошло», на которые реагируют несколько модулей, а прямые вызовы — для запросов, где нужен ответ. Список имён событий держите в одном файле.