#GameDev

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

Уничтожение врагов

40
Читалка

Что ж. Намеченный план был выполнен полностью.

Было три итерации, сначала сделал так, чтоб танки могли крошить всех (в том числе и враги уничтожали друг друга).

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

Давайте посмотрим, что там по коду интересного и не очень.

Из нового: появился файлик effects.js, который отвечает за всякие красивости, из самого заметного: взрывы)

const EFFECT_CONFIG = {
    [EffectType.EXPLOSION_SMALL]: {
        maxFrames: 3,
        frameTime: 3,  // 3 игровых кадра на кадр анимации (100ms при 30 FPS)
        size: BULLET_SIZE * 2,
        colors: ['#FFFF00', '#FFA500', '#FF4500']  // Жёлтый → оранжевый → красный
    },
    [EffectType.EXPLOSION_BIG]: {
        maxFrames: 5,
        frameTime: 3,
        size: TANK_SIZE * 1.5,
        colors: ['#FFFFFF', '#FFFF00', '#FFA500', '#FF4500', '#8B0000']  // Белый → жёлтый → оранжевый → красный → тёмно-красный
    },
    [EffectType.SPAWN]: {
        maxFrames: 4,
        frameTime: 4,
        size: TANK_SIZE,
        colors: ['#FFFFFF', '#888888', '#FFFFFF', '#888888']  // Мигание
    }
};

И сам класс эффекта:

export class Effect {
    /**
     * @param {number} x - Логическая координата X (центр эффекта)
     * @param {number} y - Логическая координата Y (центр эффекта)
     * @param {string} type - Тип эффекта (EffectType)
     */
    constructor(x, y, type = EffectType.EXPLOSION_SMALL) {
        this.x = x;
        this.y = y;
        this.type = type;

        // Получаем конфигурацию для этого типа эффекта
        const config = EFFECT_CONFIG[type] || EFFECT_CONFIG[EffectType.EXPLOSION_SMALL];

        this.maxFrames = config.maxFrames;
        this.frameTime = config.frameTime;
        this.size = config.size;
        this.colors = config.colors;

        // Состояние анимации
        this.frame = 0;
        this.age = 0;
    }

    /**
     * Обновление состояния эффекта
     * @param {number} dt - Время кадра (не используется при fixed timestep)
     */
    update(dt) {
        this.age++;

        // Переход к следующему кадру анимации
        if (this.age >= this.frameTime) {
            this.frame++;
            this.age = 0;
        }
    }

    /**
     * Проверка завершения анимации
     * @returns {boolean} true если анимация закончилась
     */
    isDone() {
        return this.frame >= this.maxFrames;
    }

    /**
     * Отрисовка эффекта
     */
    render() {
        if (this.isDone()) return;

        const ctx = foregroundCtx;

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

        // Размер эффекта меняется в зависимости от кадра
        const progress = this.frame / (this.maxFrames - 1);
        const currentSize = this.size * GAME_SCALE * (0.5 + progress * 0.5);

        // Цвет из массива цветов
        const colorIndex = Math.min(this.frame, this.colors.length - 1);
        const color = this.colors[colorIndex];

        // Рисуем взрыв как круг
        ctx.beginPath();
        ctx.arc(px, py, currentSize / 2, 0, Math.PI * 2);
        ctx.fillStyle = color;
        ctx.fill();

        // Внутренний круг (ядро взрыва)
        if (this.type === EffectType.EXPLOSION_BIG && this.frame < this.maxFrames - 1) {
            ctx.beginPath();
            ctx.arc(px, py, currentSize / 4, 0, Math.PI * 2);
            ctx.fillStyle = '#FFFFFF';
            ctx.fill();
        }
    }
}

Часть этого кода перепишется, когда я переведу игру на спрайты, оставив только смену кадров и "возраст", а все остальные визуальности удалив.

В файле коллизий появилась ещё пара новых методов:

export function checkBulletTankCollision(bullet, tanks) {
    const bulletBox = {
        x: bullet.x,
        y: bullet.y,
        width: BULLET_SIZE,
        height: BULLET_SIZE
    };

    // Определяем, является ли владелец пули врагом
    const ownerIsEnemy = bullet.owner && bullet.owner.type !== TankType.PLAYER;

    for (const tank of tanks) {
        // Пропускаем владельца пули и уничтоженных
        if (tank === bullet.owner || tank.destroyed) continue;

        // Вражеские пули пролетают сквозь других врагов (как в оригинале)
        if (ownerIsEnemy && tank.type !== TankType.PLAYER) continue;

        const tankBox = tank.getBounds();
        if (checkAABBCollision(bulletBox, tankBox)) {
            return tank; // Попадание в танк
        }
    }

    return null;
}

export function checkBulletBulletCollisions(bullets) {
    const collisions = [];

    for (let i = 0; i < bullets.length; i++) {
        const bullet1 = bullets[i];
        if (!bullet1.active) continue;

        const box1 = {
            x: bullet1.x,
            y: bullet1.y,
            width: BULLET_SIZE,
            height: BULLET_SIZE
        };

        // Определяем, принадлежит ли пуля игроку
        const isPlayer1 = bullet1.owner && bullet1.owner.type === TankType.PLAYER;

        for (let j = i + 1; j < bullets.length; j++) {
            const bullet2 = bullets[j];
            if (!bullet2.active) continue;

            const isPlayer2 = bullet2.owner && bullet2.owner.type === TankType.PLAYER;

            // Сталкиваются только пули разных команд (игрок vs враг)
            if (isPlayer1 === isPlayer2) continue;

            const box2 = {
                x: bullet2.x,
                y: bullet2.y,
                width: BULLET_SIZE,
                height: BULLET_SIZE
            };

            if (checkAABBCollision(box1, box2)) {
                collisions.push({ bullet1, bullet2 });
            }
        }
    }

    return collisions;
}

В целом, тут можно было бы выбрать и иные решения, коллизия и методы их проверки могли бы стать более универсальными, как это сделано, скажем, в Unity. Навесить на все объекты box collider'ы и просто проверять их столкновения без отдельных методов (проверить ТанкТанк, проверить ТанкПуля, проверить ПуляПуля). В таком случае логичнее было бы добавить возможность делать коллизии триггерными, для отработки логики уже внутри самих объектов. Но в таком случае для такой простой игры значительно усложнилась бы архитектура. Поэтому в жертву универсальности я пошёл по более прямолинейному пути.

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

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

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

«Танчики» на 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);

Результат:

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

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

Игровые новости #6

Game Pulse

42

Объявлена дата релиза новой Готики

Вернуться в долину рудников мы сможем уже 5 июня!


Сделал игру и окупил за три дня

Эдмунд МакМиллен вместе с Тайлером Глейеном запили игру про котиком, где надо их снашать, рожать и потом мутузить ими других котиков.

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


Классическая дилогия Dino Crisis вышла в Стим

Год тому назад нежданно негаданно в ГОГ вышли порты классической дилогии. Все удивились, кто-то порадовался, ну да и ладно. И тут вдруг эти же порты упали на полки парового магазина. Там куча технических фишек, типа поддержки современных мониторов, вшитой локализации на шесть языков, просто картинка поправлена. В общем, как будто стоит брать.


Новый хоррор от создателей «Дожить до рассвета»

Directive 8020 – именно так называется игра от Supermassive Games, анонсировали её давно уже, так что факт существования игры не новость. А вот новость то, что стала известна дата релиза оной.

Свет ужастик увидит уже 12-го мая!

«Танчики» на 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: Здесь будет рендер врагов, пуль, эффектов

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

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