Спавн и удаление объектов в 2D-игре: время жизни, отложенное удаление и пулы

Коротко

Снаряды, монеты и частицы появляются во время выполнения игры и должны так же надёжно исчезать. Три правила закрывают почти все ошибки: у каждого объекта есть время жизни, поэтому максимум одновременно живущих объектов считается заранее; удаление откладывается до конца обхода, чтобы цикл не пропускал соседей; частые объекты берутся из пула вместо создания заново. Ниже — формула, таблица ошибок и код на JavaScript, проверенный в Node.js.

Содержание

  1. Время жизни: сколько объектов будет одновременно
  2. Почему удаление во время обхода ломает игру
  3. Отложенное удаление: флаг, уборка и swap-and-pop
  4. Пул объектов: когда нужен и как устроен
  5. Практический сценарий: 10 секунд стрельбы
  6. Когда вам это не подойдёт

Время жизни: сколько объектов будет одновременно

Число одновременно живущих объектов определяется двумя числами: временем жизни и интервалом создания. Если объекты появляются равномерно, а исчезают по таймеру, формула такая:

максимум одновременно = ⌈ время жизни ÷ интервал создания ⌉

время жизни снаряда, который должен улететь за экран:
время жизни = расстояние ÷ скорость
Примеры расчёта
ОбъектВремя жизниИнтервал созданияМаксимум одновременно
Снаряд героя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.

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

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

Почему нельзя удалять объект из массива прямо в цикле обновления?

При удалении элемента через splice все следующие элементы сдвигаются на одну позицию назад, а индекс цикла всё равно увеличивается, поэтому объект, стоявший сразу за удалённым, в этом кадре не обновится. Роберт Нистром в книге Game Programming Patterns описывает эту ошибку и два решения: обходить список с конца или помечать объект как мёртвый и убирать после обхода.

Сколько снарядов будет на экране одновременно?

Не больше, чем время жизни снаряда, делённое на интервал между выстрелами, с округлением вверх. Снаряд живёт 2 секунды, стрельба 4 раза в секунду (интервал 0,25 с) — значит, одновременно живут максимум 8 снарядов.

Нужен ли пул объектов в JavaScript, если есть сборщик мусора?

Не всегда. Нистром отмечает, что в языках со сборкой мусора пулы встречаются реже, но остаются полезными, чтобы не платить за создание и освобождение объектов, особенно на мобильных устройствах. Начинайте без пула и вводите его, когда объекты создаются десятками в секунду.

Что делать, если пул закончился?

Есть три варианта: не создавать новый объект (подходит для частиц, потерю одной искры никто не заметит), заменить самый старый или наименее заметный объект, либо увеличить пул. Для звуков и важных снарядов молча пропускать создание нельзя.