В общем-то, сегодняшний пост будет совсем без кода, так как игра механически была готова ещё в прошлый раз, теперь же я хочу просто показать, что в итоге получилось и как оно работает.
Видео записывал для ютуба, так сказать, чтоб привлечь свежую кровь, поэтому тут много лишнего, но суть, думаю, по нему понять можно)
Ещё раз всем спасибо! Ушёл писать какую-нибудь очередную ерунду.
Игра почти готова, но не хватает ещё парочки нюансов)
Сразу смотрим результат, а я пока распишу, что же тут происходит
1. Я добавил разные типы врагов. Как и в оригинале, это базовые, быстроходные и бронированные. Последние плюсом ко всему имеют бронебойные патроны.
2. Я добавил новый вид бонуса: «Строитель». Поднимая мастерок игрок может построить несколько бетонных и несколько кирпичных блоков. Именно для баланса этого бонуса и введены вражеские танки, которые могут уничтожать в том числе и бетонные стены.
Давайте смотреть
export class Bonus {
/**
* По всему коду натыкал как можно больше комментов, чтоб понятно было куда собака зарыта
*
* @param {number} x - Логическая координата X
* @param {number} y - Логическая координата Y
* @param {string} type - Тип бонуса (BonusType.STAR, BonusType.GRENADE, etc.)
*/
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
// Время жизни бонуса
this.lifetime = Duration.BONUS_LIFETIME;
this.blinkTime = Duration.BONUS_BLINK_TIME;
// Состояние
this.collected = false;
this.expired = false;
// Анимация
this.animationFrame = 0;
this.animationTimer = 0;
this.animationSpeed = 6; // Кадров игры между сменой кадра анимации
// Мигание перед исчезновением
this.blinkTimer = 0;
this.blinkSpeed = 4; // Быстрое мигание
this.visible = true;
}
/**
* Обновление состояния бонуса
*/
update() {
if (this.collected || this.expired) return;
// Уменьшаем время жизни
this.lifetime--;
// Проверка истечения времени
if (this.lifetime <= 0) {
this.expired = true;
return;
}
// Анимация спрайта
this.animationTimer++;
if (this.animationTimer >= this.animationSpeed) {
this.animationTimer = 0;
this.animationFrame = (this.animationFrame + 1) % BONUS_SPRITE.frameCount;
}
// Мигание перед исчезновением
if (this.lifetime <= (Duration.BONUS_LIFETIME - this.blinkTime)) {
this.blinkTimer++;
if (this.blinkTimer >= this.blinkSpeed) {
this.blinkTimer = 0;
this.visible = !this.visible;
}
}
}
/**
* Сбор бонуса игроком
*/
collect() {
this.collected = true;
}
/**
* Проверка, истёк ли бонус
*/
isExpired() {
return this.expired || this.collected;
}
/**
* Получить bounding box в логических координатах
*/
getBounds() {
return {
x: this.x,
y: this.y,
width: BONUS_SIZE,
height: BONUS_SIZE
};
}
/**
* Отрисовка бонуса
*/
render() {
if (this.collected || this.expired) return;
if (!this.visible) return; // Мигание
const ctx = foregroundCtx;
// Конвертация логических координат в физические
const px = GAME_FIELD_X + this.x * GAME_SCALE;
const py = GAME_FIELD_Y + this.y * GAME_SCALE;
const size = BONUS_SIZE * GAME_SCALE;
const sprite = getBonusSprite(this.type);
if (sprite) {
const frameX = this.animationFrame * BONUS_SPRITE.frameWidth;
ctx.drawImage(
sprite,
frameX, 0,
BONUS_SPRITE.frameWidth, BONUS_SPRITE.frameHeight,
px, py,
size, size
);
} else {
// Fallback: цветной квадрат
this.renderFallback(ctx, px, py, size);
}
}
/**
* Fallback отрисовка (цветной квадрат с буквой)
*/
renderFallback(ctx, px, py, size) {
// Цвета для разных типов бонусов
const colors = {
[BonusType.STAR]: '#FFFF00',
[BonusType.GRENADE]: '#FF4500',
[BonusType.TIMER]: '#00BFFF',
[BonusType.SHOVEL]: '#8B4513',
[BonusType.TANK]: '#00FF00',
[BonusType.HELMET]: '#C0C0C0',
[BonusType.GUN]: '#FF00FF',
[BonusType.BUILDER]: '#334455'
};
const letters = {
[BonusType.STAR]: 'S',
[BonusType.GRENADE]: 'G',
[BonusType.TIMER]: 'T',
[BonusType.SHOVEL]: 'L',
[BonusType.TANK]: '+',
[BonusType.HELMET]: 'H',
[BonusType.GUN]: 'P',
[BonusType.BUILDER]: 'B'
};
ctx.fillStyle = colors[this.type] || '#FFFFFF';
ctx.fillRect(px + 2, py + 2, size - 4, size - 4);
ctx.fillStyle = '#000000';
ctx.font = `bold ${size * 0.6}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(letters[this.type] || '?', px + size / 2, py + size / 2);
}
}
Где и как учитываются бонусы?
Во-первый в main.js я сразу создаю бонус для строительства
// DEBUG: Тестовый бонус Builder рядом с игроком
const testBonus = new Bonus(PLAYER_SPAWN_POINT.x + 32, PLAYER_SPAWN_POINT.y - 32, BonusType.BUILDER);
bonuses.push(testBonus);
Это сделано для тестов, но, возможно, я даже оставлю в конечном варианте
Дальше в кор геймплее
const collectedBonus = checkTankBonusCollision(player, bonuses);
if (collectedBonus) {
applyBonusEffect(collectedBonus);
}
// И дальше по коду сам метод
function applyBonusEffect(bonus) {
switch (bonus.type) {
case BonusType.STAR:
// Апгрейд оружия (до уровня 3 максимум)
if (player && player.bulletLevel < 3) {
player.bulletLevel++;
// Уровень 1+ даёт 2 пули
if (player.bulletLevel >= 1) {
player.bulletCount = 2;
}
}
break;
case BonusType.GUN:
// Мгновенный максимальный апгрейд
if (player) {
player.bulletLevel = 3;
player.bulletCount = 2;
}
break;
case BonusType.TANK:
// Дополнительная жизнь
playerLives++;
break;
case BonusType.HELMET:
// Временная неуязвимость
if (player) {
player.hasShield = true;
player.shieldDuration = Duration.EFFECT_SHIELD;
}
break;
case BonusType.TIMER:
// Заморозка врагов
freezeTimer = Duration.EFFECT_TIMER;
break;
case BonusType.GRENADE:
// Уничтожение всех врагов на экране
for (const enemy of enemies) {
if (!enemy.destroyed) {
enemy.destroyed = true;
// Создаём взрывы
const centerX = enemy.x + TANK_SIZE / 2;
const centerY = enemy.y + TANK_SIZE / 2;
effects.push(createBigExplosion(centerX, centerY));
}
}
break;
case BonusType.SHOVEL:
// Укрепление базы
shovelTimer = Duration.EFFECT_SHOVEL;
fortifyBase();
break;
case BonusType.BUILDER:
// Режим строителя: даёт блоки для размещения
if (player) {
player.builderBricks += 8;
player.builderSteel += 4;
}
break;
}
bonus.collect();
}
Далее все таймеры эффекты глобальные сбрасываются вот тут
function updateGlobalEffects() {
// Таймер заморозки
if (freezeTimer > 0) {
freezeTimer--;
}
// Таймер укрепления базы
if (shovelTimer > 0) {
shovelTimer--;
if (shovelTimer <= 0 && baseFortified) {
restoreBase();
}
}
}
Вообще в данном случае было бы логично вынести логику сброса эффекта бонуса в сам бонус. Чтоб он там в фоне считался, а ядру просто отдавал событие или менял внутри себя флаг. Как будто там будет почище. Подумал об этом только сейчас, отрефакторю к следующему выпуску.
Следующий этап уже не про новые фичи, а про полировку, оптимизацию и доработку.
Писал я план ещё в самом начале, так что часть из этих пунктов уже сделана, но всё равно нужно будет пройтись по всем.
Изначально этим этапом должны были быть только состояния (начало пауза, геймовер), но так как два из трёх состояний я сделал ещё в прошлом этапе, то тут я добавил паузу, заготовку для главного меню и решил, что этого мало и перешёл сразу к следующему этапу, а именно замена временных квадратов на спрайты. Я решил не использовать то, что выдаёт там интернет, но постарался сам нарисовать близко по стилистике
По спецификациям спрайтов: сделал по два кадра на все танки, чтоб сделать анимацию гусениц. три кадра на воду. остальные по одному кадру
базовый враг
кусты лёд
вода
Вообще вот столько спрайтшитов отрисовал:
Результат сейчас выглядит вот так
В процессе записи понял, что забыл реализовать возрождение игрока после смерти :)
Для водички отдельная функция появилась:
export function updateWaterAnimation() {
waterAnimationFrame = (waterAnimationFrame + 1) % WATER_SPRITE.frameCount;
}
//Ну и в рисовании фона вода теперь принимает непосредственное участие в отрисовки фона
renderBackground(gameMap, getWaterAnimationFrame());
//И внутри рендера ещё добавлен фолбек, чтобы в случае чего рисовать квадратики, как раньше.
//Чтоб не получилось что и-за какой-то ошибки на экране будет пустой квадрат
// Попробуем отрисовать спрайт
const spriteDrawn = renderTileSprite(
ctx, tileId, x, y, px, py, size,
gameMap, wallsSprite, waterSprite, forestIceSprite, waterFrame
);
// Если спрайт не нарисован - fallback на цветной квадрат
if (!spriteDrawn) {
renderTileFallback(ctx, tileDef, x, y, px, py, size, gameMap);
}
Размер тут, правда, как будто излишен, так как все спрайты 16 на 16 и не пока не вижу необходимости делать другие, но посмотрю, может появился необходимость делать что-то больше чем 1 клетка размером, если нет, удалю.
Дальше по состояниям. В main.js появился метод и много его вызовов:
if (uiActions.pause) {
setState(GameState.PAUSED);
}
export function setState(newState) {
if (currentState === newState) return;
const oldState = currentState;
console.log(`State: ${oldState} -> ${newState}`);
currentState = newState;
stateTimer = 0;
// Вызываем callback если установлен
if (onStateChangeCallback) {
onStateChangeCallback(newState, oldState);
}
}
export function setOnStateChange(callback) {
onStateChangeCallback = callback;
}
// Последний метод вызывается один раз при инициализации:
setOnStateChange(handleStateChange);
function handleStateChange(newState, oldState) {
// При переходе из STAGE_COMPLETE в STAGE_INTRO (по таймеру) — инициализируем уровень
if (oldState === GameState.STAGE_COMPLETE && newState === GameState.STAGE_INTRO) {
initializeLevel(getCurrentStage());
}
}
// это нам пригодится в будущем
class Base {
constructor(x, y, gameMap = null) {
this.x = x;
this.y = y;
this.width = BASE_SIZE;
this.height = BASE_SIZE;
// Состояние
this.destroyed = false;
// Временная защита бетоном (бонус "лопата")
this.fortified = false;
this.fortifyTimeLeft = 0;
// Ссылка на карту
this.gameMap = gameMap;
}
}
Там на самом деле много служебных методов, типа рендера и получить границы. Но их сюда не пихаю, так как там ничего интересного. Есть ещё метод takeDamage, который просто удаляет визуальное отображение базы с экрана и помечает базу как уничтоженную.
В main.js появились такие нюансы:
for (const bullet of bullets) {
if (!bullet.active) continue;
bullet.update(gameMap, allTanks);
// Создаём эффекты при попадании
if (bullet.hitResult === 'base') {
// Попадание в базу - большой взрыв и мгновенный Game Over
if (base && !base.destroyed) {
const baseCenterX = base.x + base.width / 2;
const baseCenterY = base.y + base.height / 2;
effects.push(createBigExplosion(baseCenterX, baseCenterY));
base.takeDamage();
gameOver = true;
}
} else if (bullet.hitResult === 'tank' && bullet.hitTarget) {
// Попадание в танк - большой взрыв в центре танка
...
} else if (bullet.hitResult === 'wall') {
// Попадание в стену - маленький взрыв в позиции пули
...
}
}
//Не очень мне нравится это, скорее всего на финальном этапе полировки, перепишу
В оригинальной игре квадратики справа от игрового поля показывали, сколько танков ещё должно родиться, я решил сделать, чтоб отображалось сколько танков ещё осталось убить для завершения уровня. Ну а сам спавн сделан вот таким образом:
function updateEnemySpawn() {
// Не спавним если игра окончена или уровень пройден
if (gameOver || stageComplete) return;
// Не спавним если больше нет врагов
if (enemiesRemaining <= 0) return;
// Не спавним если на экране максимум врагов
if (enemies.length >= SpawnSettings.MAX_ON_SCREEN) return;
// Уменьшаем таймер
spawnCooldown--;
// Если таймер истёк - спавним врага
if (spawnCooldown <= 0) {
// Получаем текущую точку спавна
const spawnPoint = ENEMY_SPAWN_POINTS[spawnPointIndex];
// Спавним врага
spawnEnemy(spawnPoint.x, spawnPoint.y);
// Уменьшаем счётчик оставшихся врагов
enemiesRemaining--;
// Переключаемся на следующую точку (по кругу)
spawnPointIndex = (spawnPointIndex + 1) % ENEMY_SPAWN_POINTS.length;
// Сбрасываем таймер
spawnCooldown = SpawnSettings.SPAWN_COOLDOWN;
// Обновляем ссылки на танки
updateAllTanksReferences();
}
}
Я, честно признаться, не помню как там оно было на дэнди, поэтому решил, что начиная с этого этапа уже не буду пытаться повторить танки какими они были на самом деле, а буду делать такими, какими я их помню/вижу в своей голове. Так что тут могут начаться расхождения с каноном. Но, учитывая, что я в итоге собираюсь сделать с игрой, это меньшее зло))
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'ы и просто проверять их столкновения без отдельных методов (проверить ТанкТанк, проверить ТанкПуля, проверить ПуляПуля). В таком случае логичнее было бы добавить возможность делать коллизии триггерными, для отработки логики уже внутри самих объектов. Но в таком случае для такой простой игры значительно усложнилась бы архитектура. Поэтому в жертву универсальности я пошёл по более прямолинейному пути.
План на следующий этап:
Ну и, наверное, если с базой быстро разберусь, добавлю автоспавн врагов, как в оригинальной игре.
Я тут вспомнил, что в прошлом посте забыл добавить планы на будущее. Планами было: добавить врагов.
Соответственно: точки спавна, их перемещение по карте, стрельба.
Давайте посмотрим на результат.
Появился новый класс, отвечающий за поведение врагов
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();
}
}
Там ещё есть обновление ссылок чтоб чистить из памяти уничтоженные танки, но в целом ничего больше интересного.
В первую очередь, конечно же, изменился метод 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;
}
Пока складывается впечатление, что начинаю наворачивать мрак и ужас. Поэтому со следующего выпуска начну помимо введения новых фишек смотреть, что можно упростить и улучшить по архитектуре и коду. Как я уже в этот раз сделал с системой ввода.
В прошлой серии вы видели: стены более непротицаемы.
В этой серии мы научим наш так шмалять белыми квадратами по сторонам! И будем убеждать себя, что это снаряды.
В первую очередь мы, получается, должны создать сущность снаряда. Оборачиваем это безобразие в класс:
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;
}
В прошлый раз мы нарисовали наш танк и научили его передвигаться. Теперь необходимо научить его врезаться в стены/воду.
Для этого создаём сущность коллизии и после инициализации уровня храним в памяти все коллизии и в момент попытки двигаться проверяем столкновение коллизий.
Это некая базовая версия физического движка. Нужно понимать логику: то, что игрок видит на экране не имеет никакого значения. Физика просто работает и говорит можно ехать дальше или нет. В какой-то мере это почти тоже самое, что происходит, например, в 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 не поддерживает, поэтому делаем костыль через замороженные объекты.
Проверка самих коллизий происходит в методе передвижения танка
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);
Результат:
Далее по плану научить танк стрелять и, в идеале, отрабатывать попадания снарядов в стены.
Изначально я хотел добавить поддержку геймпада только на финальном этапе разработки, но получилось так, что это делается достаточно просто, поэтому засунул сразу.
Пойдём по порядку
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: Здесь будет рендер врагов, пуль, эффектов
Результат всего этого безобразия
Танк ездит сквозь стены, потому что "физического" движка ещё нет. Проверка коллизий как раз на очереди.
Ой, всё!
Таки шолом!
Какая классная иллюстрация) реально и Шагал и Булычев)) а еще меня терзают смутные сомнения, что я узнаю руку автора)))