#Программирование

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

Примитивный ИИ врагов

41
Читалка

Всем причастных с праздником!

Я тут вспомнил, что в прошлом посте забыл добавить планы на будущее. Планами было: добавить врагов.

Соответственно: точки спавна, их перемещение по карте, стрельба.

Давайте посмотрим на результат.

Появился новый класс, отвечающий за поведение врагов

export class EnemyAI {
    constructor(tank) {
        this.tank = tank;

        // Таймер смены направления
        this.changeDirectionTimer = 0;
        this.changeDirectionInterval = 60; // ~2 сек при 30 FPS

        // Cooldown стрельбы
        this.shootCooldown = 0;
        this.shootInterval = 30; // ~1 сек при 30 FPS

        // Флаг застревания (для смены направления при коллизии)
        this.wasBlocked = false;
    }

    randomDirection() {
        const directions = [Direction.UP, Direction.RIGHT, Direction.DOWN, Direction.LEFT];
        return directions[Math.floor(Math.random() * 4)];
    }

    update(allTanks, gameMap, bullets) {
        const tank = this.tank;

        if (tank.destroyed) return;

        // === 1. Логика смены направления ===
        this.changeDirectionTimer++;

        // Смена направления по таймеру
        if (this.changeDirectionTimer >= this.changeDirectionInterval) {
            tank.direction = this.randomDirection();
            this.changeDirectionTimer = 0;
            // Рандомизируем интервал (40-80 кадров)
            this.changeDirectionInterval = 40 + Math.floor(Math.random() * 40);
        }

        // === 2. Попытка движения ===
        const prevX = tank.x;
        const prevY = tank.y;

        tank.move(tank.direction);

        // Проверка: застрял ли танк (не сдвинулся)
        const stuck = (tank.x === prevX && tank.y === prevY && !tank.moving);

        if (stuck && !this.wasBlocked) {
            // Танк только что застрял — меняем направление
            tank.direction = this.randomDirection();
            this.wasBlocked = true;
            this.changeDirectionTimer = 0;
        } else if (!stuck) {
            this.wasBlocked = false;
        }

        // === 3. Автоматическая стрельба ===
        this.shootCooldown--;

        if (this.shootCooldown <= 0 && tank.canShoot()) {
            const bullet = tank.shoot();
            if (bullet) {
                bullets.push(bullet);
                // Рандомизируем cooldown (20-50 кадров)
                this.shootCooldown = 20 + Math.floor(Math.random() * 30);
            }
        }
    }
}

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

К файле main появились коллекции врагов и их "мозгов"

/** @type {Array<Tank>} */
let enemies = [];

/** @type {Array<EnemyAI>} */
let enemyAIs = [];

Спавн врагов при старте игры

spawnEnemy(0, 0);           // Левый угол
spawnEnemy(12 * 8, 0);      // Центр (96px)
spawnEnemy(24 * 8 - 16, 0); // Правый угол (176px, учитываем размер танка)
// Пока спавню сразу все три, а не по очереди, как в оригинале
function spawnEnemy(x, y) {
    const enemy = new Tank(x, y, TankType.ENEMY_BASIC);
    enemy.direction = Direction.DOWN; // Враги всегда смотрят вниз
    enemy.setMap(gameMap);

    // Создаём AI для врага
    const ai = new EnemyAI(enemy);

    enemies.push(enemy);
    enemyAIs.push(ai);

    console.log(`Enemy spawned at (${x}, ${y})`);
}

И рисуем это

for (const enemy of enemies) {
    if (!enemy.destroyed) {
      enemy.render();
    }
  }

Там ещё есть обновление ссылок чтоб чистить из памяти уничтоженные танки, но в целом ничего больше интересного.

Оптимистичный план на следующий этап вот такой:

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

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

Реакция на попадание снаряда

38
Читалка
Внезапно сразу результат

А теперь давайте посмотрим, что изменилось.

В первую очередь, конечно же, изменился метод checkTileCollision внутри класс патрона.

checkTileCollision(newX, newY, gameMap) {
        const tiles = getTilesUnderEntity(newX, newY, BULLET_SIZE, BULLET_SIZE);

        if (tiles.length === 0) return false;

        // Находим тайлы на передней кромке по направлению движения
        let frontTiles;
        switch (this.direction) {
            case Direction.UP:
                // Минимальный Y = передняя кромка
                const minY = Math.min(...tiles.map(t => t.ty));
                frontTiles = tiles.filter(t => t.ty === minY);
                break;
            case Direction.DOWN:
                // Максимальный Y = передняя кромка
                const maxY = Math.max(...tiles.map(t => t.ty));
                frontTiles = tiles.filter(t => t.ty === maxY);
                break;
            case Direction.LEFT:
                // Минимальный X = передняя кромка
                const minX = Math.min(...tiles.map(t => t.tx));
                frontTiles = tiles.filter(t => t.tx === minX);
                break;
            case Direction.RIGHT:
                // Максимальный X = передняя кромка
                const maxX = Math.max(...tiles.map(t => t.tx));
                frontTiles = tiles.filter(t => t.tx === maxX);
                break;
            default:
                frontTiles = tiles;
        }

        let hasCollision = false;

        // Проверяем только тайлы на передней кромке
        for (const {tx, ty} of frontTiles) {
            const tileId = gameMap.getTile(tx, ty);
            const tileDef = TILE_DEFS[tileId];

            if (!tileDef) continue;

            // Пуля блокируется этим тайлом?
            if (tileDef.blocksBullet) {
                hasCollision = true;

                // Пытаемся разрушить тайл
                gameMap.damageTile(tx, ty, this.direction, this.power);
            }
        }

        if (hasCollision) {
            this.destroy();
            return true;
        }

        return false;
    }

Изменился внутренний мир объекта, отвечающего за направления

DamageState = Object.freeze({
//было
    FULL: 0b11,
    HALF_LEFT: 0b10,
    HALF_RIGHT: 0b01,
    DESTROYED: 0b00
//стало
    FULL: 0,
    HALF_LEFT: 1,   // Осталась левая половина (вертикальный срез)
    HALF_RIGHT: 2,  // Осталась правая половина (вертикальный срез)
    HALF_TOP: 3,    // Осталась верхняя половина (горизонтальный срез)
    HALF_BOTTOM: 4, // Осталась нижняя половина (горизонтальный срез)
    DESTROYED: 5
});

Эта побитовая маска оказалась весьма нестабильной историей на тестах.

Ну и как говорил в прошлом выпуске, мне не нравилось, как у меня был сделан инпут, поэтому я его переделал. Он больше не принимает на вход карту и коллекцию патронов, а делает только то, что должен: отслеживает инпут. Логика обработки вынесена уже в main.js

export function getPlayerActions() {
    const actions = {
        movement: null,  // Direction.UP/DOWN/LEFT/RIGHT или null
        shoot: false
    };

    // Собираем ввод с клавиатуры
    collectKeyboardActions(actions);

    // Собираем ввод с геймпада
    collectGamepadActions(actions);

    return actions;
}
function update(dt) {
  // 1. Получаем действия игрока из input
  const actions = getPlayerActions();

  // 2. Применяем действия к игроку
  if (player && !player.destroyed) {
    // Движение
    if (actions.movement !== null) {
      player.move(actions.movement);
    }

    // Стрельба
    if (actions.shoot && player.canShoot()) {
      const bullet = player.shoot();
      if (bullet) {
        bullets.push(bullet);
      }
    }

    player.update(dt);
  }

  // 3. Обновление пуль
  for (const bullet of bullets) {
    bullet.update(gameMap);
  }

  // 4. Удаление неактивных пуль
  bullets = bullets.filter(b => b.active);

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

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

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

Внутри карты поменялось получение и редактирование карты урона

getDamage(x, y) {
    const key = `${x},${y}`;
    // Если нет в маске — значит либо FULL, либо не разрушаемый
    if (!this.damageMask.has(key)) {
      return DamageState.FULL;
    }
    return this.damageMask.get(key);
  }
setDamage(x, y, state) {
    const key = `${x},${y}`;
    if (state === DamageState.DESTROYED) {
      this.damageMask.delete(key);
      this.tileGrid[y][x] = TileType.EMPTY;
    } else if (state === DamageState.FULL) {
      // FULL — удаляем из маски (дефолтное состояние)
      this.damageMask.delete(key);
    } else {
      this.damageMask.set(key, state);
    }
    this.dirty = true;
  }

И появился большой новый метод, который и занимается непосредственно разрушением:

damageTile(x, y, direction, power) {
    if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE) return false;

    const tileId = this.getTile(x, y);
    const tileDef = TILE_DEFS[tileId];

    if (!tileDef) return false;

    // Проверяем можно ли разрушить этот тайл
    if (!canBulletDestroy(tileDef, power)) {
      return false;
    }

    // Получаем текущее состояние повреждения
    const currentDamage = this.getDamage(x, y);

    // Если уже разрушен — ничего не делаем
    if (currentDamage === DamageState.DESTROYED) {
      return false;
    }

    // Определяем новое состояние в зависимости от направления пули
    let newDamage;

    if (currentDamage === DamageState.FULL) {
      // Первое попадание — создаём половину
      // Пуля убирает ту часть, в которую летит
      switch (direction) {
        case Direction.UP:
          // Пуля летит вверх → убирает нижнюю часть → остаётся верхняя
          newDamage = DamageState.HALF_TOP;
          break;
        case Direction.DOWN:
          // Пуля летит вниз → убирает верхнюю часть → остаётся нижняя
          newDamage = DamageState.HALF_BOTTOM;
          break;
        case Direction.LEFT:
          // Пуля летит влево → убирает правую часть → остаётся левая
          newDamage = DamageState.HALF_LEFT;
          break;
        case Direction.RIGHT:
          // Пуля летит вправо → убирает левую часть → остаётся правая
          newDamage = DamageState.HALF_RIGHT;
          break;
      }
    } else {
      // Второе попадание — полное разрушение
      newDamage = DamageState.DESTROYED;
    }

    // Обновляем состояние
    this.setDamage(x, y, newDamage);

    return true;
  }

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


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

Клон Divinity на Unity

3. Перемещаем персонажа

39

Начал переносить видео на фоксфрейм. Качество в результате переноса пострадало, но лучше так, чем никак...

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

Стрельба | Снаряды

40
Читалка

В прошлой серии вы видели: стены более непротицаемы.

В этой серии мы научим наш так шмалять белыми квадратами по сторонам! И будем убеждать себя, что это снаряды.

В первую очередь мы, получается, должны создать сущность снаряда. Оборачиваем это безобразие в класс:

export class Bullet {
    constructor(x, y, direction, owner, power = 0) {
        this.x = x;
        this.y = y;
        this.direction = direction;
        this.speed = Speed.BULLET;
        this.power = power;
        this.owner = owner;

        // Состояние
        this.active = true;
    }
}

Сущность пули помнит, кто её владелец, знает свою скорость и мощность. Мощность нам понадобится на следующих этапах. Например, стандартная пуля может только «брить» кирпичи, более мощная сможет уничтожать уже и бетонные/стальные блоки. В каких-то версиях пиратских танков была возможность даже сбривать кусты.

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

update(gameMap) {
        if (!this.active) return;

        // Вычисляем смещение
        let dx = 0;
        let dy = 0;

        switch (this.direction) {
            case Direction.UP:
                dy = -this.speed;
                break;
            case Direction.DOWN:
                dy = this.speed;
                break;
            case Direction.LEFT:
                dx = -this.speed;
                break;
            case Direction.RIGHT:
                dx = this.speed;
                break;
        }

        // Новая позиция
        const newX = this.x + dx;
        const newY = this.y + dy;

        // Проверка границ карты
        if (newX < 0 || newX + BULLET_SIZE > LOGICAL_FIELD_SIZE ||
            newY < 0 || newY + BULLET_SIZE > LOGICAL_FIELD_SIZE) {
            this.destroy();
            return;
        }

        // Проверка коллизий с тайлами
        if (gameMap && this.checkTileCollision(newX, newY, gameMap)) {
            return; // Пуля уничтожена в checkTileCollision
        }

        this.x = newX;
        this.y = newY;
    }

    checkTileCollision(newX, newY, gameMap) {
        const tiles = getTilesUnderEntity(newX, newY, BULLET_SIZE, BULLET_SIZE);

        for (const {tx, ty} of tiles) {
            const tileId = gameMap.getTile(tx, ty);
            const tileDef = TILE_DEFS[tileId];

            if (!tileDef) continue;

            // Пуля блокируется этим тайлом?
            if (tileDef.blocksBullet) {
                this.destroy();
                // TODO: Разрушение тайла (на следующем этапе)
                return true;
            }
        }

        return false;
    }

    destroy() {
        if (!this.active) return;
        this.active = false;

        if (this.owner) {
            this.owner.activeBullets = Math.max(0, this.owner.activeBullets - 1);
        }

        // TODO: Создание эффекта взрыва (Не скоро, ещё этапа через три)
    }

    render() {
        if (!this.active) return;

        const ctx = foregroundCtx;

        // Конвертация логических координат в физические
        const px = GAME_FIELD_X + this.x * GAME_SCALE;
        const py = GAME_FIELD_Y + this.y * GAME_SCALE;
        const size = BULLET_SIZE * GAME_SCALE;

        // Рисуем пулю как белый квадрат
        ctx.fillStyle = Colors.BULLET;
        ctx.fillRect(px, py, size, size);
    }

    getBounds() {
        return {
            x: this.x,
            y: this.y,
            width: BULLET_SIZE,
            height: BULLET_SIZE
        };
    }

В самом танке обновляем метод стрельбы. Раньше там не было нифига, теперь вот:

shoot() {
        if (this.activeBullets >= this.bulletCount) {
            return null;
        }
        //return null; - так было

        // так стало: 
        // Вычисляем позицию пули (центр передней части танка)
        let bulletX, bulletY;
        const centerOffset = (TANK_SIZE - BULLET_SIZE) / 2;

        switch (this.direction) {
            case Direction.UP:
                bulletX = this.x + centerOffset;
                bulletY = this.y - BULLET_SIZE;
                break;
            case Direction.DOWN:
                bulletX = this.x + centerOffset;
                bulletY = this.y + TANK_SIZE;
                break;
            case Direction.LEFT:
                bulletX = this.x - BULLET_SIZE;
                bulletY = this.y + centerOffset;
                break;
            case Direction.RIGHT:
                bulletX = this.x + TANK_SIZE;
                bulletY = this.y + centerOffset;
                break;
        }

        // Создаём пулю
        const bullet = new Bullet(bulletX, bulletY, this.direction, this, this.bulletLevel);
        this.activeBullets++;

        return bullet;
    }

Изменилась обработка пользовательского ввода

function processInput(player, gameMap = null, bullets = []) {
    if (!player || player.destroyed) return;

    // Обрабатываем клавиатуру
    processKeyboardInput(player, gameMap, bullets);

    // Обрабатываем геймпад
    processGamepadInput(player, gameMap, bullets);
}

И вот тут я прям серьёзно задумался: «А не хуйню ли я делаю?». Как будто передавать карту и массив снарядов в обработку инпутов – гавно идея...

К следующему посту поправлю.

А пока вот так:

if (keys['Space'] || keys['Enter']) {
        if (player.canShoot()) {
            const bullet = player.shoot();
            if (bullet) {
                bullets.push(bullet);
            }
        }
    }

В основном методе update теперь вызываем и апдейт всех пуль

for (const bullet of bullets) {
    bullet.update(gameMap);
  }

И в основном рендере их рисуем

// Рендер пуль
  for (const bullet of bullets) {
    bullet.render();
  }

Если есть идеи по улучшению кода, рад буду почитать

Результат

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

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

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

Коллизии

33

В прошлый раз мы нарисовали наш танк и научили его передвигаться. Теперь необходимо научить его врезаться в стены/воду.

Для этого создаём сущность коллизии и после инициализации уровня храним в памяти все коллизии и в момент попытки двигаться проверяем столкновение коллизий.

Это некая базовая версия физического движка. Нужно понимать логику: то, что игрок видит на экране не имеет никакого значения. Физика просто работает и говорит можно ехать дальше или нет. В какой-то мере это почти тоже самое, что происходит, например, в Unity. Но там система сложнее и она работает скорее подобно тому, как у меня сделано в тетрисе:

  • Двигаем
  • Проверяем пересечение
  • Если оно есть, то двигаем обратно

Здесь же я просчёт сделал по принципу экстраполяции (С Юнити тоже есть такой вариант у коллизий), когда просчёт пересечений происходит как бы с взглядом в будущее, просчёт потенциального пересечение ещё до того, как оно произошло.

Давайте же посмотрим, как это выглядит.

Коллизия – это не класс как таковой, это просто набор общедоступных методов.

/**
 * Получить список субтайлов, покрываемых прямоугольником
 *
 * @returns {Array<{tx: number, ty: number}>} Массив координат субтайлов
 */
export function getTilesUnderEntity(x, y, width, height) {
  const left = Math.floor(x / TILE_SIZE);
  const right = Math.floor((x + width - 1) / TILE_SIZE);
  const top = Math.floor(y / TILE_SIZE);
  const bottom = Math.floor((y + height - 1) / TILE_SIZE);

  const tiles = [];
  for (let ty = top; ty <= bottom; ty++) {
    for (let tx = left; tx <= right; tx++) {
      // Проверяем границы сетки
      if (tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE) {
        tiles.push({ tx, ty });
      }
    }
  }

  return tiles;
}

/**
 * Проверить, может ли танк войти на данный тайл
 *
 * @returns {boolean} true если танк может войти
 */
export function canTankEnter(tileDef, tank) {
  if (!tileDef) return true;

  // Вода: проходима только с бонусом "лодка"
  if (tileDef.id === TileType.WATER) {
    return tank && tank.hasBoat;
  }

  return !tileDef.blocksTank;
}

/**
 * Проверить коллизию танка с картой при движении в новую позицию
 * та самая экстраполяция
 *
 * @returns {boolean} true если коллизия есть (нельзя двигаться)
 */
export function checkTankMapCollision(tank, newX, newY, gameMap) {
  // Получаем субтайлы под новой позицией танка
  const tiles = getTilesUnderEntity(newX, newY, TANK_SIZE, TANK_SIZE);

  // Проверяем каждый субтайл
  for (const { tx, ty } of tiles) {
    const tileId = gameMap.getTile(tx, ty);
    const tileDef = TILE_DEFS[tileId];

    if (!canTankEnter(tileDef, tank)) {
      return true; // Есть пересечение
    }
  }

  return false; // Нет пересечений
}

/**
 * Проверить коллизию двух AABB (Axis-Aligned Bounding Box)
 *
 * @returns {boolean} true если боксы пересекаются
 */
export function checkAABBCollision(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  );
}

/**
 * Проверить коллизию двух танков
 *
 * @returns {boolean} true если есть коллизия с другим танком
 */
export function checkTankTankCollision(tank, newX, newY, tanks) {
  const tankBox = {
    x: newX,
    y: newY,
    width: TANK_SIZE,
    height: TANK_SIZE
  };

  for (const other of tanks) {
    // Пропускаем себя и уничтоженных
    if (other === tank || other.destroyed) continue;

    const otherBox = other.getBounds();
    if (checkAABBCollision(tankBox, otherBox)) {
      return true; // Коллизия с другим танком
    }
  }

  return false;
}

А ещё с прошлого поста был проведён рефакторинг. У меня чесалось нёбо от неудобных некрасивых констант, с которыми были вездесущие сравнения. В целом, не магические числа – уже хорошо, но тем не менее. Енамки JS не поддерживает, поэтому делаем костыль через замороженные объекты.

// Было
export const TILE_EMPTY = 0;
export const TILE_BRICK = 1;
export const TILE_STEEL = 2;
export const TILE_WATER = 3;
export const TILE_FOREST = 4;
export const TILE_ICE = 5;
export const TILE_BASE = 6;
// Стало
export const TileType = Object.freeze({
  EMPTY: 0,
  BRICK: 1,
  STEEL: 2,
  WATER: 3,
  FOREST: 4,
  ICE: 5,
  BASE: 6
});

Проверка самих коллизий происходит в методе передвижения танка

newX = Math.max(0, Math.min(newX, LOGICAL_FIELD_SIZE - TANK_SIZE));
        newY = Math.max(0, Math.min(newY, LOGICAL_FIELD_SIZE - TANK_SIZE));

        if (gameMap && checkTankMapCollision(this, newX, newY, gameMap)) {
            return; // не применяем результат передвижения, просто выходим
        }

Ну и в сам метод передаётся информация о карте в инпут менеджере

if (gp.buttons[12]?.pressed) player.move(Direction.UP, gameMap);
if (gp.buttons[13]?.pressed) player.move(Direction.DOWN, gameMap);
if (gp.buttons[14]?.pressed) player.move(Direction.LEFT, gameMap);
if (gp.buttons[15]?.pressed) player.move(Direction.RIGHT, gameMap);

А сам gameMap у нас и главного скрипта, а там он формируется как и раньше в

loadLevel(TEST_LEVEL_1);

Результат:

Далее по плану научить танк стрелять и, в идеале, отрабатывать попадания снарядов в стены.

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

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

Контроллер пользовательского ввода и перемещение персонажа

40

У нас есть заготовка для рендера и отрисовка игрового уровня. Пора по нему побегать.

В инициализации появилась пара новых строчек

// Инициализация обработчика ввода
  initInput();

Так внутри всё достаточно просто

export function initInput() {
  // Слушатели клавиатуры
  window.addEventListener('keydown', handleKeyDown);
  window.addEventListener('keyup', handleKeyUp);

  // Слушатели геймпада
  window.addEventListener('gamepadconnected', handleGamepadConnected);
  window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);

  console.log('Input handler initialized');
}

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

Пойдём по порядку

function handleKeyDown(e) {
  keys[e.code] = true; // аккумулируем все нажатия, чтоб потом обработать

  // Предотвращаем прокрутку страницы стрелками и пробелом
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) {
    e.preventDefault();
  }
}
function handleKeyUp(e) {
  keys[e.code] = false; // "забываем" отпущенную кнопку
}
function handleGamepadConnected(e) {
  console.log(`Gamepad connected: ${e.gamepad.id}`);
  gamepadIndex = e.gamepad.index;
}

function handleGamepadDisconnected(e) {
  console.log(`Gamepad disconnected: ${e.gamepad.id}`);
  if (gamepadIndex === e.gamepad.index) {
    gamepadIndex = null;
  }
}

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

Дальше заспавним наш "танк" на игровой сцене

const spawnX = 4 * CELL_SIZE;  // 4 клетки от левого края = 64 px
  const spawnY = 24 * 8 - 32;    // Чуть выше базы = 160 px
  player = new Tank(spawnX, spawnY, TANK_PLAYER);

Класс танка достаточно большой, он ко конструкторе принимает значения координат спавна и типа танка (танк игрока или тип врага). Ему устанавливается "здоровье" (сколько раз в него надо попасть, чтобы убить), сколько активных патронов есть и т.д.

constructor(x, y, type = TANK_PLAYER) {
    this.x = x;
    this.y = y;
    // Направление (0=вверх, 1=вправо, 2=вниз, 3=влево)
    this.direction = DIR_UP;
    this.speed = TANK_SPEED_NORMAL; // в пекселях за кадр

    // Тип танка
    this.type      = type;
    this.health    = 1;
    this.destroyed = false;

    // Флаг движения в текущем кадре
    this.moving = false; // Пока только задаётся, но нигде не используется. Есть мысли на будущее, но если не понадобится, удалю

    // Апгрейды (для игрока)
    this.bulletLevel = 0;    // 0=обычная, 1=быстрая, 2=усиленная
    this.bulletCount = 1;    // Макс. пуль на экране
    this.hasShield = false;  // Временная защита
    this.hasBoat = false;    // Движение по воде

    // Активные пули (для подсчёта лимита)
    this.activeBullets = 0;
  }

Непосредственно движение выгляди таким образом:

move(direction) {
    // Сначала поворачиваем, если нужно
    if (this.direction !== direction) {
      this.turn(direction);
      return; // В оригинале, если повернуть, танк не двигается в этом кадре, может уберу, если плейтесты покажут необходимость 
    }

    // Вычисляем смещение по направлению
    let dx = 0;
    let dy = 0;

    switch (direction) {
      case DIR_UP:    dy = -this.speed; break;
      case DIR_DOWN:  dy = this.speed;  break;
      case DIR_LEFT:  dx = -this.speed; break;
      case DIR_RIGHT: dx = this.speed;  break;
    }

    // Новая позиция
    let newX = this.x + dx;
    let newY = this.y + dy;

    // Ограничение границами игрового поля
    newX = Math.max(0, Math.min(newX, LOGICAL_FIELD_SIZE - TANK_SIZE));
    newY = Math.max(0, Math.min(newY, LOGICAL_FIELD_SIZE - TANK_SIZE));

    // TODO: Проверка коллизий с тайлами (Этап 4)
    // TODO: Проверка коллизий с другими танками (Этап 7)

    // Применяем новую позицию
    this.x = newX;
    this.y = newY;
    this.moving = true;
  }

Ну и рисование танка на игровом поле

render() {
    if (this.destroyed) return;

    const ctx = foregroundCtx;

    // Конвертация логических координат в физические
    const px   = GAME_FIELD_X + this.x * GAME_SCALE;
    const py   = GAME_FIELD_Y + this.y * GAME_SCALE;
    const size = TANK_SIZE * GAME_SCALE;

    // Цвет танка в зависимости от типа, пока всего два, потом будут новые типы и новые цвета
    // Основной квадрат танка
    ctx.fillStyle = this.type === TANK_PLAYER ? COLOR_TANK_PLAYER : COLOR_TANK_ENEMY;
    ctx.fillRect(px, py, size, size);

    // Индикатор направления (треугольник)
    this.renderDirectionIndicator(ctx, px, py, size); // понял, что просто квадрато недостаточно, поэтому добавил "морду"
  }

Рендер танка вызывается в методе основного рендера после очистки всего динамического слоя

// Foreground canvas — очищаем и рисуем сущности каждый кадр
  clearForeground();

  // Рендер танка игрока
  if (player) {
    player.render();
  }
  // TODO: Здесь будет рендер врагов, пуль, эффектов

Результат всего этого безобразия

Танк ездит сквозь стены, потому что "физического" движка ещё нет. Проверка коллизий как раз на очереди.

Универсальный игровой сервер

для любой несложной игры

40
Читалка

От имени своего журнала @VariusSoft выкладывал посты про то, как тетрис запилить и сейчас серия постов о том как воссоздать танчики. В процессе разработки осознал, что в них надо играть вдвоём, а как же это делать, если сесть за одну денди и взять в руки два джойстика нет возможности?

Ну, онлайн, чё тупить. Но как организовать онлайн? В общем покумекал я немного и решил, что для этого необходим сервер. Но писать полноценный сервер для игры в танчики – это, как будто, прям из пушки по воробьям. Поэтому я решил написать универсальный сервер, который одновременно может обслуживать несколько игр, каждая на пару-тройку десятков одновременных игроков.

Выглядит это безобразие так:

Да, вот такая скукотища, просто буковки
Да, вот такая скукотища, просто буковки

Написан он на Шарпе и скомпилирован под 8 дот нет, чтоб была возможность запустить и на маках и на линях. Но ещё, для пущей уверенности, завернул к херам в докер это безобразие.

Каков же алгоритм работы?

Первый игрок, который хочет с кем-то поиграть, нажимает кнопочку «Создать комнату» в интерфейсе игры, игры стучится на сервер и получает идентификатор комнаты. Дальше и эта игра и другие клиенты подключаются к этой же комнате. Первый клиент выступает в роли хоста. То есть вся обработка геймплея и игрового мира происходит на нём. Как это всегда было в КС, Халфе и прочих сессионных зарубах.

Также написал SDK на JS для встраивания работы с сервером в любую HTML5 игру.

Математически при хорошем канале данный сервер способен вытянуть одну игру на 400-500 человек или 10 игр по 40. Как на практике будет работать, посмотрим. Хотя, сэмулировать нагрузку в 500 человек я даже если очень хочу, не смогу)

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

И да, я уже предвижу комментарии из цикла: «Нахер изобретать велосипед», но мне насрать, серьёзно. Я хотел написать, я написал.

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

«Танчики» на 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 этапов. Без ДизДока последнее время вообще не представляю как работать. А раз уж я в команде один, то сам себе и ТЗ пишу... Но лучше так, чем лепить полную отсебятину.

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

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


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