---
title: "Модульная архитектура логики 2D-игры без циклических зависимостей"
description: "Как разделить логику 2D-игры на модули с зависимостями в одну сторону: слои модулей, поиск цикла обходом в глубину, порядок инициализации топологической сортировкой и шина событий вместо взаимных импортов. Что происходит с циклическим импортом ES-модулей и код, проверенный в Node.js 24."
slug: modulnye-listy-sobytij-i-funkcii-v-2d-proekte
date: 2026-11-02
updated: 2026-11-02
scheduled_at: "2026-11-02T09:00:00+03:00"
rubric: logika-i-arhitektura
tags: ["arhitektura", "moduli", "javascript", "profi", "prepodavatelyam"]
reading_time: 9
draft: false
summary: "Логику 2D-игры удобно делить на модули по зонам ответственности — настройки, ввод, игрок, бой, очки, интерфейс — так, чтобы зависимости шли в одну сторону, от верхних слоёв к нижним. Циклическая зависимость, когда бой импортирует очки, а очки импортируют бой, делает порядок загрузки непредсказуемым: в ES-модулях обращение к ещё не инициализированному значению из цикла даёт ReferenceError. Цикл находят обходом графа зависимостей в глубину и разрывают тремя способами: выносом общего в нижний модуль, слиянием модулей или шиной событий, через которую нижний модуль сообщает о происшедшем, не зная о верхнем."
---

## Коротко

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

## Содержание

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

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

Логику пора делить, когда одна правка требует читать код, не связанный с ней. Типичные сигналы: правило урона скопировано в бой, ловушки и падение в пропасть; изменение очков ломает отрисовку интерфейса; новый человек в команде не может найти, где включается пауза. Делить нужно по зонам ответственности — ввод, игрок, бой, очки, интерфейс, — а не просто «первые 300 строк в один файл, остальные в другой».

## Слои модулей: кто кого может импортировать

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

```text
модуль слоя N может импортировать только модули слоёв < N
```

Если нижнему модулю нужно сообщить что-то верхнему, он не импортирует его, а публикует событие. Так бой не знает об очках, а очки узнают о победе над врагом из шины.

## Почему цикл опасен: опыт с ES-модулями в Node.js

Циклический импорт делает результат зависимым от порядка загрузки модулей. По MDN, цикл не всегда приводит к ошибке: значение из другого модуля читается в момент использования, и ошибка возникает, только если оно к этому моменту ещё не инициализировано. Мы проверили это в Node.js 24 на двух файлах: `combat.mjs` импортирует функцию из `score.mjs`, а `score.mjs` при загрузке читает константу `DAMAGE` из `combat.mjs`. Запуск `node combat.mjs` завершился ошибкой:

```text
ReferenceError: Cannot access 'DAMAGE' before initialization
```

Если бы первым загружался `score.mjs`, ошибки могло не быть — и именно поэтому циклы коварны: код работает, пока кто-то не переставит импорты.

## Поиск цикла и порядок инициализации

Цикл в графе зависимостей находят обходом в глубину с тремя состояниями вершин: «не посещена», «в стеке обхода», «проверена». Если из вершины в стеке есть ребро в другую вершину, которая тоже в стеке, — найден цикл, и путь по стеку показывает его целиком. Время работы — O(V + E), где V — число модулей, E — число импортов.

Когда циклов нет, порядок инициализации даёт топологическая сортировка: модуль добавляется в список после всех своих зависимостей. Тот же обход в глубину, только вершина записывается при выходе из неё.

## Три способа разорвать цикл и шина событий

MDN перечисляет три способа устранить цикл: объединить модули, вынести общий код в третий модуль, перенести часть кода из одного модуля в другой. В игровой логике чаще всего работают два:

1. **Вынос вниз.** Константа урона, которую читают и бой, и очки, переезжает в модуль настроек.
2. **Шина событий.** Бой публикует «враг побеждён» с данными (`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, вывод совпадает с комментариями.

```js
// Модули логики и их зависимости: кто кого импортирует
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 игровой движок, где логику можно разделять между несколькими листами событий, связывать через импорты и переиспользовать в функциях, а порядок подключения проекта и сцен остаётся предсказуемым. Движок в разработке и публично пока не открыт, заявку можно оставить на [странице раннего доступа](/early-access?utm_source=blog&utm_medium=article&utm_campaign=modulnye-listy-sobytij-i-funkcii-v-2d-proekte) (подача заявки не гарантирует приглашение).

**Источники (проверено: ноябрь 2026):** [MDN — JavaScript modules, раздел Cyclic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), [Game Programming Patterns — Event Queue](https://gameprogrammingpatterns.com/event-queue.html).

**Читайте также:**
- [Патрулирование врагов и читаемые опасности](/blog/patrulirovanie-vragov-i-chitaemye-opasnosti)
- [Действия ввода вместо клавиш: KeyboardEvent.code, Gamepad API, мёртвая зона](/blog/vvod-klaviatura-gejmpad-kasanie-v-2d-igre)
- [HUD и адаптивный интерфейс браузерной игры: якоря, масштаб, размер цели касания](/blog/interfejs-poverh-igry-schetchik-ochkov-i-knopki)
