Клон Divinity На Unity
Выпуск 12 | Поверхности
Разные виды врагов и бонусы
Игра почти готова, но не хватает ещё парочки нюансов)
Сразу смотрим результат, а я пока распишу, что же тут происходит
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);
}
Конфигурация эффектов так же содержит фолбэк
/**
* Конфигурация эффектов
*/
const EFFECT_CONFIG = {
[EffectType.EXPLOSION_SMALL]: {
maxFrames: EXPLOSION_SMALL_SPRITE.frameCount, // 3 кадра
frameTime: 3, // 3 игровых кадра на кадр анимации (100ms при 30 FPS)
size: TANK_SIZE, // 16x16
colors: ['#FFFF00', '#FFA500', '#FF4500'] // Fallback: Жёлтый → оранжевый → красный
},
[EffectType.EXPLOSION_BIG]: {
maxFrames: EXPLOSION_BIG_SPRITE.frameCount, // 4 кадра
frameTime: 3,
size: TANK_SIZE, // 16x16
colors: ['#FFFFFF', '#FFFF00', '#FFA500', '#FF4500'] // Fallback
},
[EffectType.SPAWN]: {
maxFrames: SPAWN_SPRITE.frameCount, // 5 кадров
frameTime: 4,
size: TANK_SIZE, // 16x16
colors: ['#FFFFFF', '#AAAAAA', '#FFFFFF', '#AAAAAA', '#FFFFFF'] // Fallback: Мигание
}
};
Размер тут, правда, как будто излишен, так как все спрайты 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') {
// Попадание в стену - маленький взрыв в позиции пули
...
}
}
//Не очень мне нравится это, скорее всего на финальном этапе полировки, перепишу
Внутри "коллизии" появился новый метод
export function checkBulletBaseCollision(bullet, base) {
if (!base || base.destroyed) return false;
const bulletBox = {
x: bullet.x,
y: bullet.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
const baseBox = base.getBounds();
return checkAABBCollision(bulletBox, baseBox);
}
В оригинальной игре квадратики справа от игрового поля показывали, сколько танков ещё должно родиться, я решил сделать, чтоб отображалось сколько танков ещё осталось убить для завершения уровня. Ну а сам спавн сделан вот таким образом:
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();
}
}
Я, честно признаться, не помню как там оно было на дэнди, поэтому решил, что начиная с этого этапа уже не буду пытаться повторить танки какими они были на самом деле, а буду делать такими, какими я их помню/вижу в своей голове. Так что тут могут начаться расхождения с каноном. Но, учитывая, что я в итоге собираюсь сделать с игрой, это меньшее зло))