Сущности и компоненты (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.
- Добавь цыплёнку и врагу новый компонент
health:{ hp: 3, hit: false }. - Монетке здоровье не давай — она не должна попадать под систему урона.
- Напиши
damageSystem(entities): для каждой сущности с компонентомhealth, еслиhealth.hit === true, уменьшиhpна 1 и сбросьhitобратно вfalse. - Если
hpстало0или меньше — поставь сущности флагdead: true. - В конце игрового цикла отфильтруй мёртвых:
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 — чтобы цыплёнок наконец-то по-честному собирал монетки и получал урон от врагов.