Коротко
Снаряды, монеты и частицы появляются во время выполнения игры и должны так же надёжно исчезать. Три правила закрывают почти все ошибки: у каждого объекта есть время жизни, поэтому максимум одновременно живущих объектов считается заранее; удаление откладывается до конца обхода, чтобы цикл не пропускал соседей; частые объекты берутся из пула вместо создания заново. Ниже — формула, таблица ошибок и код на JavaScript, проверенный в Node.js.
Содержание
- Время жизни: сколько объектов будет одновременно
- Почему удаление во время обхода ломает игру
- Отложенное удаление: флаг, уборка и swap-and-pop
- Пул объектов: когда нужен и как устроен
- Практический сценарий: 10 секунд стрельбы
- Когда вам это не подойдёт
Время жизни: сколько объектов будет одновременно
Число одновременно живущих объектов определяется двумя числами: временем жизни и интервалом создания. Если объекты появляются равномерно, а исчезают по таймеру, формула такая:
максимум одновременно = ⌈ время жизни ÷ интервал создания ⌉
время жизни снаряда, который должен улететь за экран:
время жизни = расстояние ÷ скорость
| Объект | Время жизни | Интервал создания | Максимум одновременно |
|---|---|---|---|
| Снаряд героя | 2 с | 0,25 с (4 выстрела в секунду) | 8 |
| Монета из сундука | 5 с | 0,5 с | 10 |
Снаряд, пересекающий экран 1280 px со скоростью 600 px/с | 1280 ÷ 600 ≈ 2,13 с | 0,25 с | 9 |
Искры от удара, 12 за раз | 0,4 с | 0,3 с между ударами | 2 × 12 = 24 |
Время жизни нужно даже тем объектам, которые «и так» исчезают при столкновении: снаряд, улетевший мимо всех врагов, без таймера будет жить вечно за пределами экрана. Расчёт заодно даёт размер пула из раздела 4.
Почему удаление во время обхода ломает игру
Удаление элемента из массива внутри цикла по этому же массиву заставляет цикл пропустить следующий элемент. Нистром разбирает это в главе Update Method: после удаления все элементы сдвигаются на одну позицию назад, а индекс цикла всё равно увеличивается (Game Programming Patterns, проверено: ноябрь 2026).
// Ошибка: удаление через splice прямо в цикле обновления.
function updateNaive(list, dt) {
const updated = [];
for (let i = 0; i < list.length; i++) {
const o = list[i];
o.life -= dt;
updated.push(o.id);
if (o.life <= 0) list.splice(i, 1); // следующий объект сдвинулся на место i
}
return updated;
}
const objs = [
{ id: "a", life: 1 }, { id: "b", life: 0.01 },
{ id: "c", life: 1 }, { id: "d", life: 1 },
];
console.log(updateNaive(objs, 0.016)); // [ 'a', 'b', 'd' ] — объект c пропущен
В этом примере объект c в кадре не получил обновления: его таймер не уменьшился, он не сдвинулся и не проверил столкновения. В игре это выглядит как редкое «подрагивание» или пролёт снаряда сквозь врага, и такую ошибку трудно поймать, потому что она случается только в кадр, когда кто-то исчез.
Отложенное удаление: флаг, уборка и swap-and-pop
Надёжное решение — во время обхода только помечать объект как мёртвый, а убирать мёртвые после обхода. Это второй способ из главы Нистрома; первый (обход с конца) тоже работает, но ломается, если объект во время своего обновления удаляет другой объект.
// Отложенное удаление: в кадре — только флаг, уборка — после обхода.
function update(list, dt, onDie) {
for (let i = 0; i < list.length; i++) {
const o = list[i];
if (o.dead) continue; // помеченные в этом кадре больше не участвуют
o.life -= dt;
if (o.life <= 0) {
o.dead = true;
if (onDie) onDie(o); // последнее слово объекта: вспышка, бонус, звук
}
}
sweep(list);
}
// Уборка за O(1) на объект: последний элемент переезжает на место мёртвого.
function sweep(list) {
let i = 0;
while (i < list.length) {
if (list[i].dead) {
list[i] = list[list.length - 1];
list.pop(); // i не увеличиваем: на месте i теперь новый объект
} else {
i++;
}
}
}
const list2 = [
{ id: "a", life: 1 }, { id: "b", life: 0.01 },
{ id: "c", life: 1 }, { id: "d", life: 1 },
];
const effects = [];
update(list2, 0.016, function (o) { effects.push("вспышка на месте " + o.id); });
console.log(list2.map(function (o) { return o.id; })); // [ 'a', 'd', 'c' ]
console.log(effects); // [ 'вспышка на месте b' ]
У swap-and-pop одна цена: порядок элементов меняется. Если порядок важен (например, объекты в массиве отсортированы по слою отрисовки), вместо него используют сдвиг: живые объекты переписывают в начало массива по порядку и укорачивают массив один раз.
Новые объекты тоже лучше добавлять отложенно. Нистром замечает, что объект, добавленный в конец списка во время обхода, успеет обновиться в том же кадре, ещё до того, как игрок его увидит. Простое решение — складывать созданные в кадре объекты в отдельный массив и переносить в основной после уборки.
| Ошибка | Как проявляется | Исправление |
|---|---|---|
Удаление через splice в цикле | Соседний объект пропускает кадр | Флаг dead и уборка после обхода |
| Нет времени жизни | Число объектов растёт, игра постепенно замедляется | Таймер у каждого создаваемого объекта |
| Создание в каждом кадре без интервала | При 60 кадр/с — 60 объектов в секунду вместо 4 | Накопитель времени и интервал создания |
| Действие с уже удалённым объектом | Бонус или вспышка появляются в точке (0, 0) | Читать координаты до пометки dead, в onDie |
| Объект из пула не очищен | Новый снаряд «помнит» скорость или цвет прошлого | Сбрасывать все поля в init |
Пул объектов: когда нужен и как устроен
Пул — это заранее созданный набор объектов, которые не удаляются, а выключаются и потом используются снова. В главе Object Pool Нистром объясняет, зачем это нужно: в C++ на консолях и мобильных устройствах главная проблема — фрагментация памяти, а в языках со сборкой мусора пулы встречаются реже, но всё равно полезны, чтобы не платить за создание и освобождение объектов, особенно на мобильных устройствах (Game Programming Patterns, проверено: ноябрь 2026).
Там же названы три подводных камня, которые полностью применимы к JavaScript:
- пул занимает память даже тогда, когда объекты не нужны, поэтому его размер считают по формуле из раздела 1;
- переиспользованный объект не очищается сам — каждое поле нужно задать заново при выдаче;
- пул может закончиться, и решение «что тогда» принимают заранее: пропустить создание, заменить самый старый объект или увеличить пул.
Чтобы не перебирать весь пул в поисках свободного места, Нистром предлагает хранить список свободных объектов. В JavaScript это обычный массив-стек:
// Пул со списком свободных объектов. Размер — из формулы «время жизни ÷ интервал».
function createPool(size, makeObject) {
const all = [];
const free = [];
for (let i = 0; i < size; i++) {
const o = makeObject();
o.active = false;
all.push(o);
free.push(o);
}
return {
all: all,
acquire: function () {
const o = free.pop(); // undefined, если пул пуст
if (o) o.active = true;
return o;
},
release: function (o) {
if (!o.active) return; // защита от двойного возврата
o.active = false;
free.push(o);
},
freeCount: function () { return free.length; },
};
}
function makeBullet() { return { x: 0, y: 0, vx: 0, life: 0, active: false }; }
// init задаёт ВСЕ поля: переиспользованный объект не очищается сам.
function initBullet(b, x, y, vx, life) {
b.x = x; b.y = y; b.vx = vx; b.life = life;
return b;
}
Практический сценарий: 10 секунд стрельбы
Задача: герой стреляет 4 раза в секунду, снаряд летит 600 px/с и живёт 2 с, игра идёт 60 кадров в секунду. Нужно убедиться, что за 10 секунд одновременно живёт не больше 8 снарядов, пул на 8 мест ни разу не заканчивается и новые объекты не создаются. Код ниже использует createPool, makeBullet и initBullet из предыдущего раздела.
const DT = 1 / 60;
const FIRE_INTERVAL = 0.25;
const LIFE = 2;
const POOL_SIZE = Math.ceil(LIFE / FIRE_INTERVAL); // 8
let created = 0;
const pool = createPool(POOL_SIZE, function () { created++; return makeBullet(); });
const createdAtStart = created;
const live = [];
let fireTimer = 0;
let maxAlive = 0;
let misses = 0;
for (let frame = 0; frame < 600; frame++) { // 600 кадров = 10 секунд
// 1. создание по интервалу, а не в каждом кадре
fireTimer += DT;
while (fireTimer >= FIRE_INTERVAL - 1e-9) {
fireTimer -= FIRE_INTERVAL;
const b = pool.acquire();
if (b) live.push(initBullet(b, 100, 360, 600, LIFE));
else misses++;
}
// 2. обновление: только пометка
for (let i = 0; i < live.length; i++) {
const b = live[i];
b.x += b.vx * DT;
b.life -= DT;
if (b.life <= 1e-9) b.dead = true;
}
// 3. уборка после обхода: swap-and-pop и возврат в пул
let i = 0;
while (i < live.length) {
if (live[i].dead) {
live[i].dead = false;
pool.release(live[i]);
live[i] = live[live.length - 1];
live.pop();
} else i++;
}
if (live.length > maxAlive) maxAlive = live.length;
}
console.log("размер пула:", POOL_SIZE); // 8
console.log("максимум одновременно:", maxAlive); // 8
console.log("отказов пула:", misses); // 0
console.log("создано объектов во время игры:", created - createdAtStart); // 0
В Node.js симуляция выдаёт: размер пула 8, максимум одновременно 8, отказов 0, новых объектов во время игры 0. Если уменьшить пул до 7, счётчик отказов становится больше нуля — так проверяют, что формула размера верна для вашей частоты стрельбы. Маленькие допуски 1e-9 нужны из-за того, что 1/60 не представляется в двоичной арифметике точно и сумма шагов накапливает погрешность.
Когда вам это не подойдёт
Три правила нужны не в каждой игре, и иногда они только усложняют код:
- Объектов мало и они создаются редко. Если на уровне десяток врагов, которые появляются при входе в комнату, обычного создания и удаления достаточно; пул тут лишний.
- Объекты сильно различаются по составу. Пул хорош для одинаковых объектов (снаряды, частицы); если каждый враг собран из разного набора свойств, переиспользование превращается в долгую очистку полей.
- Порядок объектов в массиве важен. swap-and-pop меняет порядок; если массив одновременно служит порядком отрисовки, используйте сдвиг с сохранением порядка или отдельный список для рисования.
- Нужна сохранённая история объектов. Если игра пишет повтор прохождения, удалённый объект нельзя просто вернуть в пул без записи его последнего состояния.
Мы разрабатываем J2DRU — браузерный 2D игровой движок с событийной логикой; он в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): Robert Nystrom, Game Programming Patterns, главы Update Method (удаление и добавление объектов во время обхода, флаг «мёртв» и уборка после обхода) и Object Pool (фрагментация, пулы в языках со сборкой мусора, очистка переиспользованных объектов, поведение при заполненном пуле, список свободных объектов), gameprogrammingpatterns.com. Формула максимума, расчёты в таблицах и код — наши, код проверен в Node.js.
Читайте также: