DEV Community

Андрей Викулов (VProger)
Андрей Викулов (VProger)

Posted on • Originally published at viku-lov.ru on

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

Как я написал браузерный ремейк 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"
/>
Enter fullscreen mode Exit fullscreen mode

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

  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);
}
}
Enter fullscreen mode Exit fullscreen mode

Параметр 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;
}
Enter fullscreen mode Exit fullscreen mode

Кирпичные стены считаются проходимыми (чтобы активировать поведение 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++;
}
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Итого

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

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

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

Read more on Viku-Lov Studio

Top comments (0)