Сущности и компоненты (ECS лайт)

Когда классы игровых объектов раздуваются до сотен строк, пора разбить их на маленькие компоненты-данные и общие системы — это и есть ECS лайт.

Сущность — это просто набор компонентов. Не делай отдельный класс под каждого персонажа. Сделай один мешочек данных и навешивай на него кусочки: position, velocity, sprite. А системы пробегают по всем сущностям сразу и двигают всё, у чего есть нужные компоненты.

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

Зачем это нужно

Представь, что ты ведёшь нашу игру про цыплёнка дальше. Сначала был только chicken — у него координаты x, y и спрайт. Потом ты добавил врага: у него тоже x, y, спрайт, плюс скорость, плюс «искусственный интеллект» (ну, ходит влево-вправо). Потом монетку: координаты, спрайт, анимация блеска. Потом пулю: координаты, спрайт, скорость, время жизни.

Замечаешь? Половина данных у всех одинаковая — координаты и спрайт есть у каждого. Но если ты пишешь отдельный класс Chicken, отдельный Enemy, отдельный Coin, отдельный Bullet, то одно и то же — «как нарисовать спрайт по координатам» — ты копируешь четыре раза. А когда захочешь, чтобы у монетки тоже появилась гравитация (выпала из врага и упала на землю), придётся лезть в класс монетки и дописывать туда падение, которое уже сто раз написано у цыплёнка.

К концу урока у нас будет крошечный движок, в котором добавить новый тип объекта — это собрать его из готовых кубиков-компонентов за пару строк, не трогая ни один старый файл. Это и есть цель: добавлять поведение, не переписывая то, что уже работает.

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

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

Болезнь больших классов

Давай честно посмотрим, как выглядит «класс на всё». Вот типичный цыплёнок, который оброс способностями за несколько уроков:

class Chicken {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = 0;
    this.vy = 0;
    this.sprite = chickenSprite;
    this.frame = 0;
    this.health = 3;
    this.coins = 0;
    this.onGround = false;
    this.invincibleTimer = 0;
    // ...и так ещё двадцать полей
  }

  update(dt) {
    this.applyGravity(dt);
    this.move(dt);
    this.animate(dt);
    this.checkGround();
    this.tickInvincible(dt);
    // ...и так ещё десять методов
  }
  // applyGravity, move, animate, checkGround, draw, jump, hurt, collect...
}

Результат: на экране всё работает — цыплёнок бегает и прыгает. Но файл класса разросся до 250 строк, и любая правка пугает: тронешь одно — отвалится другое.

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

Метафора: герой как рюкзак с вещами

ECS расшифровывается как Entity-Component-System: сущность — компонент — система. По-русски: объект, кусочек данных и обработчик.

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

Цыплёнок — это рюкзак с координатами, скоростью, спрайтом, здоровьем и гравитацией. Монетка — рюкзак с координатами и спрайтом. Стена — рюкзак только с координатами и спрайтом, без скорости (она не двигается). Один и тот же «вид вещи» лежит в разных рюкзаках, и обрабатывается одинаково.

А кто эти вещи использует? Системы. Система — это функция, которая говорит: «дайте мне всех, у кого в рюкзаке есть и координаты, и скорость» — и двигает их всех скопом. Системе всё равно, цыплёнок это, пуля или падающая монетка: есть нужные компоненты — значит, обрабатываем.

Сравни с обычным подходом «класс на всё». Там цыплёнок — это монолитная фигурка, у которой намертво приклеены и движение, и анимация, и здоровье. Хочешь такого же монстра, но без анимации? Придётся либо копировать класс, либо городить флаги «а вот этот без анимации». В ECS ты просто не кладёшь в рюкзак ненужную вещь. Объект — это не готовая статуэтка, а конструктор, который ты собираешь под задачу прямо на месте.

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

Термин ECSЧто это в кодеПример
Сущность (entity)объект с id и набором компонентовцыплёнок, монетка, пуля
Компонент (component)маленький объект только с даннымиposition, velocity, sprite
Система (system)функция, которая обрабатывает сущности с нужными компонентамидвижение, отрисовка, гравитация

Пример 1. Сущность как мешок компонентов

Начнём с самого простого. Сущность у нас — это обычный объект, у которого есть id и поле components. Никакого класса на каждый тип не будет — все объекты строятся по одной фабрике.

let nextId = 1;

function createEntity(components) {
  return {
    id: nextId++,        // уникальная бирка на рюкзаке
    components,           // объект: { position: {...}, velocity: {...} }
  };
}

// собираем цыплёнка из кубиков
const chicken = createEntity({
  position: { x: 100, y: 200 },
  velocity: { vx: 0, vy: 0 },
  sprite:   { image: chickenSprite, w: 48, h: 48 },
});

// монетка — те же координаты и спрайт, но без скорости
const coin = createEntity({
  position: { x: 300, y: 150 },
  sprite:   { image: coinSprite, w: 24, h: 24 },
});

Результат: на экране пока ничего — мы только описали данные. В памяти лежат два объекта: chicken с тремя компонентами и coin с двумя. Заметь, мы не писали ни класса Chicken, ни класса Coin — только разные наборы вещей в рюкзаке.

Разберём по шагам. createEntity — это фабрика: ей дали набор компонентов, она вернула сущность с уникальным id. Счётчик nextId гарантирует, что у каждого рюкзака своя бирка — это пригодится позже, когда нужно будет, например, понять, какие две сущности столкнулись. Каждый компонент — это просто маленький объект с данными: position хранит x и y, velocity хранит vx и vy. Никаких методов внутри компонентов нет — только цифры и значения. Логика будет жить отдельно, в системах.

Обрати внимание на то, чего здесь нет. Нет слова class, нет extends, нет наследования. Цыплёнок и монетка — объекты одной и той же формы, отличаются только содержимым поля components. Именно поэтому одна функция потом сможет обработать их обоих: для кода они на одно лицо, разница только в том, какие вещи лежат в рюкзаке. Имя chicken и спрайт chickenSprite мы при этом сохраняем те же, что и в прошлых уроках, — наш сквозной герой переезжает в новую архитектуру без переименований.

Пример 2. Система движения

Теперь оживим объекты. Соберём все сущности в один список world и напишем систему движения. Её правило простое: «найди всех, у кого есть и position, и velocity, и сдвинь их».

const world = [chicken, coin];

function movementSystem(entities, dt) {
  for (const e of entities) {
    const pos = e.components.position;
    const vel = e.components.velocity;
    // нет одного из компонентов — пропускаем сущность
    if (!pos || !vel) continue;

    pos.x += vel.vx * dt;   // дельта-время, чтобы скорость не зависела от FPS
    pos.y += vel.vy * dt;
  }
}

Результат: цыплёнок (у него есть и позиция, и скорость) начинает плавно ехать в сторону своего вектора скорости. Монетка стоит на месте — у неё нет компонента velocity, и система её спокойно пропускает. Та же функция корректно работает с обоими объектами, хотя мы нигде не написали if (это цыплёнок).

Вот она, главная магия ECS: система не спрашивает «кто ты?». Она спрашивает «что у тебя есть?». Дашь монетке компонент velocity — и та же самая система начнёт её двигать, без единой новой строчки. Вспомни dt из урока про дельта-время: умножая скорость на него, мы держим одинаковую скорость при любом FPS — «успеть на один автобус при разной скорости ходьбы».

Пример 3. Система отрисовки и новая способность без переписывания

Добавим вторую систему — отрисовку. Она берёт всех, у кого есть position и sprite, и рисует их на canvas.

function renderSystem(entities, ctx) {
  for (const e of entities) {
    const pos = e.components.position;
    const spr = e.components.sprite;
    if (!pos || !spr) continue;

    ctx.drawImage(spr.image, pos.x, pos.y, spr.w, spr.h);
  }
}

// игровой цикл: сердцебиение игры
function gameLoop(dt) {
  movementSystem(world, dt);
  renderSystem(world, ctx);
}

Результат: на canvas появляются и цыплёнок, и монетка — обоих рисует одна система, потому что у обоих есть position и sprite. Цыплёнок при этом ещё и движется (его трогает movementSystem), а монетка просто стоит.

Теперь самое важное — добавим врагу гравитацию, не тронув ни цыплёнка, ни существующие системы. Пишем третью систему и новый компонент gravity:

function gravitySystem(entities, dt) {
  for (const e of entities) {
    const vel = e.components.velocity;
    const g = e.components.gravity;
    if (!vel || !g) continue;

    vel.vy += g.force * dt;   // каждый кадр прибавляем ускорение вниз
  }
}

// враг, который падает: даём ему компонент gravity
const enemy = createEntity({
  position: { x: 400, y: 0 },
  velocity: { vx: 0, vy: 0 },
  sprite:   { image: enemySprite, w: 48, h: 48 },
  gravity:  { force: 980 },
});
world.push(enemy);

function gameLoop(dt) {
  gravitySystem(world, dt);   // добавили одну строчку
  movementSystem(world, dt);
  renderSystem(world, ctx);
}

Результат: новый враг появляется сверху и падает вниз — гравитация разгоняет его vy, а движение переносит это в координаты. При этом цыплёнок и монетка ведут себя как раньше: у них нет компонента gravity, поэтому gravitySystem их игнорирует. Мы добавили целую новую способность, дописав один компонент и одну строку в цикл — и ни строчки старого кода не переписали.

Вот ради этого ECS и затевается. Хочешь, чтобы цыплёнок тоже падал? Добавь ему gravity в набор компонентов — и всё. Хочешь летающего врага? Просто не клади ему gravity. Поведение собирается из кубиков, а не зашивается в класс намертво.

Частые ошибки и подводные камни

1. Тащить методы внутрь компонентов

Главное правило ECS: компонент — это только данные, без логики. Новичок по привычке пишет velocity с методом move() внутри — и снова получает раздутый класс, только под другим соусом. Держи поведение в системах, а в компонентах — голые цифры. Если в компоненте появилась функция, ты, скорее всего, делаешь обычный класс и зря назвал это ECS.

2. Система проверяет тип сущности

Если внутри системы появился if (e.type === 'chicken') — это тревожный звоночек. Системы не должны знать про конкретные типы. Они работают по наличию компонентов: «есть position и velocity — двигаю». Как только начнёшь спрашивать «кто это», вернёшься к той самой мешанине, от которой убегал.

3. Забыл проверить, что компонент есть

Строчки if (!pos || !vel) continue; — не для красоты. Если их убрать, система наткнётся на сущность без нужного компонента и упадёт с ошибкой Cannot read properties of undefined. Всегда проверяй наличие компонентов перед тем, как лезть в их поля — сущности-то в мире разные.

4. Изменяешь список world прямо во время цикла

Соблазн удалить мёртвого врага прямо в системе через splice велик, но если делать это посреди for...of, перебор собьётся и часть сущностей пропустишь. Лучше пометить сущность флагом dead: true, а в конце кадра одним проходом отфильтровать: world = world.filter(e => !e.dead).

5. Порядок систем имеет значение

Системы в игровом цикле выполняются по очереди, и порядок не случаен. Если вызвать renderSystem раньше movementSystem, ты нарисуешь объекты в старых координатах — будет лёгкое запаздывание картинки. Правило простое: сначала меняем состояние (гравитация, движение, коллизии), и только потом рисуем кадр.

Мини-практика: компонент здоровья и система урона

Теперь твоя очередь. Сделаем так, чтобы у цыплёнка и врага было здоровье, а специальная система отнимала очко жизни, когда сработал флаг hit.

  1. Добавь цыплёнку и врагу новый компонент health: { hp: 3, hit: false }.
  2. Монетке здоровье не давай — она не должна попадать под систему урона.
  3. Напиши damageSystem(entities): для каждой сущности с компонентом health, если health.hit === true, уменьши hp на 1 и сбрось hit обратно в false.
  4. Если hp стало 0 или меньше — поставь сущности флаг dead: true.
  5. В конце игрового цикла отфильтруй мёртвых: world = world.filter(e => !e.dead).

Проверь себя: выставь врагу hit = true на одном кадре и убедись, что его hp упало ровно на единицу, а не утекает каждый кадр. Подсказка по структуре системы:

function damageSystem(entities) {
  for (const e of entities) {
    const h = e.components.health;
    if (!h) continue;
    if (h.hit) {
      h.hp -= 1;
      h.hit = false;        // важно сбросить, иначе урон каждый кадр
      if (h.hp <= 0) e.dead = true;
    }
  }
}

Результат: враг, которому выставили hit, теряет одну единицу здоровья и при нуле исчезает со сцены в конце кадра. Цыплёнок с тем же компонентом обрабатывается той же системой, а монетка без health остаётся нетронутой — ты снова добавил поведение, не трогая старые системы.

Итоги

Сегодня ты разобрал, как перестать топить в гигантских классах и собрать игру из маленьких кубиков:

  • Сущность — это просто id плюс набор компонентов, мешок с вещами.
  • Компонент — это только данные (position, velocity, sprite, gravity, health), без всякой логики внутри.
  • Система — функция, которая обрабатывает все сущности с нужным набором компонентов и не спрашивает, кто это.
  • Новое поведение добавляется новым компонентом и новой системой — старый код остаётся нетронутым.

Это «лёгкая» версия ECS — настоящие движки хранят компоненты ещё хитрее ради скорости, но идея ровно эта. Для игры про цыплёнка нашего подхода хватает с запасом, а читается он в разы приятнее, чем класс на 250 строк.

В следующем уроке мы используем этот каркас по полной: научим системы общаться друг с другом и обрабатывать столкновения сущностей через AABB — чтобы цыплёнок наконец-то по-честному собирал монетки и получал урон от врагов.

Проверьте себя
1. Что такое сущность (entity) в подходе ECS?
AБольшой класс со всеми методами игрового объекта
BОбъект с id и набором компонентов-данных
CФункция, которая двигает объекты по экрану
DКартинка героя, которую рисуют на canvas
2. Что должно лежать внутри компонента?
AТолько данные: координаты, скорость, ссылка на спрайт
BДанные и методы для их обработки
CВесь игровой цикл целиком
DСписок всех остальных сущностей мира
3. По какому признаку система решает, обрабатывать сущность или пропустить?
AПо типу сущности через if (e.type === 'chicken')
BПо порядку, в котором сущности добавлены в мир
CПо наличию у сущности нужных компонентов
DПо размеру спрайта сущности
4. Как в нашем ECS добавить врагу гравитацию, не переписывая старый код?
AСоздать новый класс FallingEnemy и скопировать туда падение
BДобавить врагу компонент gravity и вызвать gravitySystem в цикле
CДописать падение прямо внутрь movementSystem с проверкой типа
DДобавить метод applyGravity внутрь компонента velocity
5. Зачем в начале каждой системы стоит проверка вроде if (!pos || !vel) continue;?
AЧтобы ускорить отрисовку кадра
BЧтобы система не упала на сущности без нужного компонента
CЧтобы удалить мёртвые сущности из мира
DЧтобы пересчитать дельта-время
6. Почему мёртвую сущность лучше помечать флагом dead и удалять в конце кадра, а не сразу через splice внутри цикла?
Asplice работает только с компонентами, а не с сущностями
BУдаление прямо во время for...of сбивает перебор и пропускает сущности
CФлаг dead делает игру быстрее в два раза
DБраузер запрещает менять массивы внутри игрового цикла