#Battle City

«Танчики» на html5

Отображение карты уровня

41

Следующим этапом сделаем систему отображения тайлов на игровом поле. Мы предполагаем, что у нас есть тайлы разных типов: кирпичи там, бетон, вода, лёд, кусты.

И чтобы эти самые блоки рисовать на игровом поле, нужно ввести сущность карты и самих блоков.

Сущность карты:

class GameMap {
  constructor() {
    // Сетка 26x26 субтайлов
    this.tileGrid = [];
    for (let y = 0; y < GRID_SIZE; y++) {
      this.tileGrid[y] = new Array(GRID_SIZE).fill(TILE_EMPTY);
    }

    // Карта для хранения состояния повреждений разрушаемых тайлов
    this.damageMask = new Map();

    // Флаг необходимости перерисовки фона
    this.dirty = true;
  }
}

Ну и в нашем основном классе надо карту создать:

/** @type {GameMap} */
let gameMap = null;
...
// в методе init
// Создаём и загружаем карту
  gameMap = new GameMap();
  gameMap.loadLevel(TEST_LEVEL_1);

  // Рисуем фон (карта + рамка)
  renderBackground(gameMap);
  gameMap.dirty = false;

При это тестовый уровень сейчас выглядит вот так:

export const TEST_LEVEL_1 = [
  '..........................',  // 0   верхний ряд пустой (под спавн врагов)
  '..........................',  // 1
  '..BB..BB..BB..BB..BB..BB..',  // 2   кирпичные стены
  '..BB..BB..BB..BB..BB..BB..',  // 3
  '..BB..BB..BB..BB..BB..BB..',  // 4
  '..BB..BB..SS..BB..BB..BB..',  // 5   сталь/бетон
  '..BB..FF..SS..FF..BB..BB..',  // 6   лес
  '..BB..FF......FF..BB..BB..',  // 7
  '..BB..BB..BB..BB..BB..BB..',  // 8
  '..BB..BB..BB..BB..BB..BB..',  // 9
  '..........BB..BB..........',  // 10  
  '..........BB..BB..........',  // 11
  '..BB..BB..........BB..BB..',  // 12  
  '..BB..BB..........BB..BB..',  // 13
  '..BB..BB..BB..BB..BB..BB..',  // 14
  '..BB..BB..BB..BB..BB..BB..',  // 15
  '......WW..BB..BB..WW......',  // 16  вода
  '......WW..BB..BB..WW......',  // 17
  '..BB..BB..II..II..BB..BB..',  // 18  лёд
  '..BB..BB..II..II..BB..BB..',  // 19
  '..BB..BB..BB..BB..BB..BB..',  // 20
  '..BB..BB..BB..BB..BB..BB..',  // 21
  '..........BBBBBB..........',  // 22  защита базы
  '..........BBBBBB..........',  // 23
  '..........BBEEBB..........',  // 24  база
  '..........BBEEBB..........',  // 25
];

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

Рисование блоков достаточно просто:

export function renderBackground(gameMap) {
  const ctx = backgroundCtx;

  // Очищаем весь фон
  clearBackground();

  // Рамка игрового поля
  renderGameFieldBorder();

  // Рисуем все непустые тайлы
  for (let y = 0; y < GRID_SIZE; y++) {
    for (let x = 0; x < GRID_SIZE; x++) {
      const tileId = gameMap.getTile(x, y);
      if (tileId === TILE_EMPTY) continue;

      const tileDef = TILE_DEFS[tileId];
      if (!tileDef) continue;

      // Логические координаты → физические координаты на canvas
      const px = GAME_FIELD_X + x * TILE_SIZE * GAME_SCALE;
      const py = GAME_FIELD_Y + y * TILE_SIZE * GAME_SCALE;
      const size = TILE_SIZE * GAME_SCALE;

      ctx.fillStyle = tileDef.color; // цвет в самих тайлах пока храню
      ctx.fillRect(px, py, size, size); // потом буду спрайты рисовать
    }
  }
}

Хранятся тайлы у меня вот таким образом:

TILE_DEFS = {
  [TILE_EMPTY]: {
    id: TILE_EMPTY,
    name: 'empty',
    blocksTank: false,
    blocksBullet: false,
    destructible: false,
    overlay: false,
    color: COLOR_EMPTY // эта вся фигня в константах забита
  },
...

У сущности карты есть метод для загрузки из вот того текстового бреда, который чуть выше скинут

loadLevel(levelData) {
    for (let y = 0; y < GRID_SIZE; y++) {
      for (let x = 0; x < GRID_SIZE; x++) {
        const char = levelData[y]?.[x] || '.';
        this.tileGrid[y][x] = charToTile(char);
      }
    }

    // Инициализируем damageMask для всех разрушаемых тайлов
    this.damageMask.clear();
    for (let y = 0; y < GRID_SIZE; y++) {
      for (let x = 0; x < GRID_SIZE; x++) {
        const tileDef = TILE_DEFS[this.tileGrid[y][x]];
        if (tileDef && tileDef.destructible) {
          this.damageMask.set(`${x},${y}`, DAMAGE_FULL);
        }
      }
    }

    this.dirty = true;
    console.log(`Level loaded. Destructible tiles: ${this.damageMask.size}`);
  }

function charToTile(char) {
  switch (char) {
    case 'B': return TILE_BRICK;
    case 'S': return TILE_STEEL;
    case 'W': return TILE_WATER;
    case 'F': return TILE_FOREST;
    case 'I': return TILE_ICE;
    case 'E': return TILE_BASE;
    default:  return TILE_EMPTY;
  }
}

И вот такой вот получается итог

В целом, уже похоже на правду и с этим можно работать.

план на следующий этап:

«Танчики» на html5

Пишем свой игровой движок

36

После прошлого поста я что-то так проникся происходящим, что решил воссоздать ещё одну любовь детства. Но как говорил Маркус Персон: «Если вы не можете написать свой движок, то гавно вы, а не разработчики». Там, скорее всего, было как-о иначе, но суть такая. И да, с этой мыслью я в корне не согласен, но написать свой движок – задача, как минимум, интересная. Я решил, что аркадная игра для этого подходит, как ничто другое. По сути, некий аналог движка уже был реализован в тетрисе, но здесь нужна будет и какая-никакая физическая модель, и интеллект врагов и рендер всего этого безобразия в несколько слоёв и с ФПС побольше, чем 2 :)

Так что, приступим. Что нам нужно:

  • Просчёт физики
  • Работа ИИ агентов
  • Считывание действий игрока
  • Отрисовка результата работы предыдущих пунктов.

Для одного поста такая задачка звучит жирновато, поэтому, видимо, будет серия.

Я решил разделить рисования окружения (статичных объектов), врагов и игроков (динамических объектов) и UI на три разных канваса, которые просто повещены один поверх другого. Там можно будет проще и меньше перерисовывать.

Начинается наш код с проверки, готова ли страница к явлению миру нашего движка.

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

Если готова, то давай же скорее всё проинициализируем

Тут всё просто:

function init() {
  console.log('Battle City Remake - Initializing...');

  // Инициализация рендерера
  initRenderer();

  // Очищаем все слои
  clearBackground();
  clearForeground();
  clearUI();

  // Рисуем границу игрового поля и отладочную сетку
  renderGameFieldBorder();
  renderDebugGrid();

  console.log('Initialization complete. Starting game loop...');

  // Запускаем game loop
  requestAnimationFrame(gameLoop);
}

Но понятное дело, что кода тут мало, потому что всё вынесено в отдельные методы.

Есть ли среди них хоть что-то интересное?

Ну, renderDebugGrid и renderGameFieldBorder одним названием уже говорят, что там происходит.

Логично, что самое интересное происходит где-то тут requestAnimationFrame(gameLoop);

Но давайте сначала заглянем в инициализацию рендера

export function initRenderer() {
  // Получаем canvas элементы
  backgroundCanvas = document.getElementById('background-canvas');
  foregroundCanvas = document.getElementById('foreground-canvas');
  uiCanvas = document.getElementById('ui-canvas');

  // Получаем контексты
  backgroundCtx = backgroundCanvas.getContext('2d');
  foregroundCtx = foregroundCanvas.getContext('2d');
  uiCtx = uiCanvas.getContext('2d');

  // Определяем DPR для HiDPI экранов
  dpr = window.devicePixelRatio || 1;

  // Настраиваем все три canvas
  setupCanvas(backgroundCanvas, backgroundCtx);
  setupCanvas(foregroundCanvas, foregroundCtx);
  setupCanvas(uiCanvas, uiCtx);

  console.log(`Renderer initialized (DPR: ${dpr})`);
}

function setupCanvas(canvas, ctx) {
  // Физический размер canvas
  canvas.width = CANVAS_WIDTH * dpr;
  canvas.height = CANVAS_HEIGHT * dpr;

  // Масштабируем контекст для компенсации DPR
  ctx.scale(dpr, dpr);

  // Отключаем сглаживание для пиксельной графики
  ctx.imageSmoothingEnabled = false;

  console.log(`Canvas ${canvas.id} setup: ${canvas.width}×${canvas.height} (logical: ${CANVAS_WIDTH}×${CANVAS_HEIGHT})`);
}

CANVAS_WIDTH и CANVAS_HEIGHT — это константы, они у меня в отдельном файлике лежат, там просто 800 на 600.

В целом, страничка готова к тому, чтоб рисовать всякое

Дёргаем requestAnimationFrame и отдаём её наш gameLoop

function gameLoop(currentTime) {
  // Вычисляем deltaTime
  const deltaTime = currentTime - lastTime;
  lastTime = currentTime;

  // Накапливаем время
  accumulator += deltaTime;

  // Fixed timestep - обновляем логику с фиксированным шагом
  while (accumulator >= FRAME_TIME) {
    update(FRAME_TIME);
    accumulator -= FRAME_TIME;
  }

  // Рендерим независимо от update
  render();

  // Продолжаем loop
  requestAnimationFrame(gameLoop);
}

Я решил фиксировать ФПС на 30

export const FPS = 30;                    // Фиксированный FPS
export const FRAME_TIME = 1000 / FPS;     // ~33.33ms на кадр

Метод update содержит (ну, будет содержать) всю нашу игровую логику. Пока он только считает фпс и выводит его на экран для отладки.

function update(dt) {
  // Пока пусто - здесь будет логика игры

  // Подсчёт FPS для отладки
  frameCount++;
  fpsTimer += dt;

  if (fpsTimer >= 1000) {
    fps = frameCount;
    frameCount = 0;
    fpsTimer = 0;

    // Обновляем FPS на странице
    const fpsElement = document.getElementById('fps');
    if (fpsElement) {
      fpsElement.textContent = fps;
    }
  }
}

Ну и когда всё закончено, отрисовываем

function render() {
  // Background canvas - перерисовывается только при изменении карты
  // (пока только отладочная сетка, нарисованная в init)

  // Foreground canvas - очищаем и рисуем сущности каждый кадр
  clearForeground();
  // TODO: Здесь будет рендер танков, пуль, эффектов

  // UI canvas - очищаем и рисуем UI каждый кадр
  clearUI();
  renderUI();
}

То есть, по сути, ничего интересного, просто рисую полосочки и квадратики для UI

Результат выглядит вот так

Следующий пост будет посвящён второму этапу

И да, я упоролся и расписал себе полноценный план реализации на 13 этапов. Без ДизДока последнее время вообще не представляю как работать. А раз уж я в команде один, то сам себе и ТЗ пишу... Но лучше так, чем лепить полную отсебятину.

Вопросы замечания предложения в комментариях жду с нетерпением)

А пойду пилить обновление для Лиспублики)


Всем хороших игр!