Как устроена браузерная игра «Танчики» — переосмысленный 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"
/>
Зачем такой изолят? Причины три:
- Игровой рантайм не подчиняется React-рендерингу —
requestAnimationFrameдолжен тикать стабильно на 60 fps независимо от Next.js гидратации. - Весь игровой движок можно тестировать как обычный Node-модуль без полноценного браузера.
- Файлы игры кешируются 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 минут бесплатно.
Вопросы или интерес к какому-то слою подробнее — пишите в комментариях!

Top comments (0)