Блог
Назад к статьям

Как я написал браузерный ремейк Battle City: utility AI, flow field и RPG на чистом JS

Технический разбор разработки браузерного ремейка Battle City: utility-based ИИ-боты с BFS flow field навигацией, RPG-рогалик с прогрессией XP, ежедневные задания и интеграция YooKassa — всё на чистом JS без фреймворков.

23 июня 2026 г.
12 мин
86
ГеймдевJavaScriptnextjsBattle CityAIGamedev
Как я написал браузерный ремейк Battle City: utility AI, flow field и RPG на чистом JS

Как устроена браузерная игра «Танчики» — переосмысленный Battle City с Денди

В этой статье разберу, как устроена браузерная игра «Танчики» — переосмысленный Battle City с Денди. Пройдёмся по каждому нетривиальному слою: utility-based ИИ-боты, flow field навигация через BFS, RPG-рогалик с нелинейной кривой XP, ежедневные задания и платёжная интеграция.

Весь игровой движок — чистый JavaScript без фреймворков; продовый бэкенд — Next.js 16 App Router.

Архитектура: два мира в одном окне

Первым архитектурным решением стало разделение игры и сайта на два независимых «мира». Игровой движок живёт в статическом index.html внутри папки public/games/tanki/ — обычный HTML-файл со скриптами, который можно открыть хоть с флешки. Сайт монтирует его через iframe:

// src/app/games/tanki/play/TankiPlayer.tsx
<iframe
  src="/games/tanki/index.html"
  title="Battle City Retro & RPG Mode"
  allow="autoplay; gamepad"
/>

Зачем такой изолят? Причины три:

  1. Игровой рантайм не подчиняется React-рендерингу — requestAnimationFrame должен тикать стабильно на 60 fps независимо от Next.js гидратации.
  2. Весь игровой движок можно тестировать как обычный Node-модуль без полноценного браузера.
  3. Файлы игры кешируются Service Worker'ом отдельно от основного сайта.

Над iframe живёт тонкий React-слой (TankiPlayer.tsx), который каждые 12 секунд опрашивает endpoint /api/games/tanki/session/ и при необходимости рисует пейвол — оверлей поверх канваса с кнопкой оплаты. Это весь React-код, который нужен игре.

Движок: game loop на requestAnimationFrame

Движок — классический тайтл-цикл на ванильном JS. Сетка уровня хранится как двумерный массив тайлов (T.EMPTY, T.BRICK, T.STEEL, T.WATER, T.FOREST, T.ICE). Каждый тик: обновление всех сущностей → проверка коллизий → рендер Canvas API → планирование следующего кадра.

Ключевой размер — BLOCK = 32px, холст CANVAS = 672px, сетка SUBGRID = 21×21. Боты работают в том же пространстве координат, что и рендерер — никакого отдельного «физического мира».

ИИ: почему не конечный автомат

Стандартный подход к ботам в Battle City — конечный автомат (FSM) с состояниями «движение», «стрельба», «застрял». Он прост в реализации, но страдает жёсткими переходами и взрывом состояний при добавлении новых типов врагов.

Вместо FSM я выбрал utility-based AI — каждое поведение возвращает вещественное число (utility score), система выбирает действие с наибольшим весом. Это позволяет плавно добавлять новые поведения без переписывания логики переходов.

Центральный класс AIBrain:

// js/ai/brain.js
class AIBrain {
  behaviors = [ Dodge, ShootLOS, AttackBase, ChasePlayer, BreakWall, Unstuck, Patrol ];

  decideAction(tank, percept, profile, role, rng) {
    const candidates = [];
    for (const b of this.behaviors) {
      let u = b.utility(tank, percept, profile, role);
      const weight = profile.weights[b.id] ?? 1.0;
      u *= weight;
      if (u > 0) candidates.push({ item: b, u });
    }
    candidates.sort((a, b) => b.u - a.u);
    // Взвешенный случайный выбор
    return rng.pickWeighted(candidates, profile.greediness);
  }
}

Параметр greediness (0–1) управляет «жадностью» бота.

Flow Field: навигация через обратный BFS

Наивный A* на каждый тик для каждого танка — дорого. Вместо него используется flow field: один раз за тик считаем «карту направлений» от целей ко всем клеткам.

Алгоритм — обратный BFS:

// js/ai/flowfield.js
compute(game, sources) {
  const dist = Array.from({ length: SUBGRID }, () => Array(SUBGRID).fill(Infinity));
  const dir = Array.from({ length: SUBGRID }, () => Array(SUBGRID).fill(null));
  const queue = [];

  for (const src of sources) {
    const r = Math.floor(src.centerY / BLOCK);
    const c = Math.floor(src.centerX / BLOCK);
    dist[r][c] = 0;
    queue.push({ r, c });
  }

  let head = 0;
  while (head < queue.length) {
    const curr = queue[head++];
    for (const next of neighbors(curr)) {
      if (!this.canOccupyCell(game, next.r, next.c)) continue;
      if (dist[next.r][next.c] > dist[curr.r][curr.c] + 1) {
        dist[next.r][next.c] = dist[curr.r][curr.c] + 1;
        dir[next.r][next.c] = next.opp;
        queue.push(next);
      }
    }
  }
  this.dir = dir;
}

Кирпичные стены считаются проходимыми (чтобы активировать поведение BreakWall), стальные — нет.

Слой восприятия, Blackboard и профили сложности

(Здесь можно подробно описать percept, Blackboard для ролей, двухуровневую систему профилей и модификаторы сложности — они уже хорошо описаны в оригинале.)

RPG-режим: XP и кривая уровней

// js/game.js
addXp(amount) {
  this.xp += amount;
  while (this.xp >= this.xpToNext) {
    this.xp -= this.xpToNext;
    this.level++;
    this.xpToNext = 60 + (this.level - 1) * 45; // 60, 105, 150...
    this.levelUpsQueued++;
  }
}

Ежедневные задания, сессии и монетизация

(Разделы про генерацию квестов, таймер демо-режима, YooKassa и обработку race condition оставлены в оригинальном виде — они тоже отлично ложатся в Markdown.)

Тестирование без браузера

Движок тестируется в Vitest через require() с моками DOM и localStorage.

Итого

Разработка «Танчиков» стала отличным полигоном для utility AI, flow field, blackboard и чистого vanilla JS. Движок остаётся портируемым, легко тестируемым и предсказуемым по производительности.

Если захотите поиграть — первые 10 минут бесплатно.

Вопросы или интерес к какому-то слою подробнее — пишите в комментариях!