В общем-то, сегодняшний пост будет совсем без кода, так как игра механически была готова ещё в прошлый раз, теперь же я хочу просто показать, что в итоге получилось и как оно работает.
Видео записывал для ютуба, так сказать, чтоб привлечь свежую кровь, поэтому тут много лишнего, но суть, думаю, по нему понять можно)
Ещё раз всем спасибо! Ушёл писать какую-нибудь очередную ерунду.
Пост совершенно незапланированный, а стихийный. Так уж получилось, что я написал свой движок для создания визуальных новелл. Сделал я это по весьма простой причине: главенствует на этом поприще РенПай. Но как я смог понять, там всё печально. Использовать Юнити, Годот, Анреал для таких целей – выстрел из пушки по воробьям.
Поэтому я решил, что попробую написать свой квест и элементами визуальной новеллы на самописном движке с рендером через HTML5 канвас.
Собирался сделать это чисто для себя и зарегистрировал на npm чисто чтобы было удобнее обновлять на реальных проектах. Но в итоге за двое суток существования движка набралась почти тысяча скачиваний
Немного неожиданное явление для меня, но я пришёл к выводу, что вопрос актуальный и востребованный, так что буду рад, если кто-то движок попробует и навалит на меня обратной связи.
Для создания шаблона игры достаточно выполнить команду:
npm create @variussoft/vn-game my-game
Обновить движок в уже существующем проекте можно командой:
npm i @variussoft/vn-engine
На текущий момент поддерживается минимальный базовый функционал: стартовая заставка; главное меню; сцены, на которых есть статичные (картинки) или динамичные (видео) фона сцен, анимированные объекты (покадровая анимация через спрайт-шиты), фоновая музыка, интерактивный объекты, кат-сцены через смену кадров, диалоговая система с условиями и последствиями, мини-игры (отдельная сущность для тех случаем, когда хочется сделать что-то уникальное). Мне для моего проекта этого достаточно, но я с радостью доработаю движок, если кто-то принесёт мне набор предложений по улучшению.
Обратную связь пишите здесь в комментариях или на почту: nick@variussoft.ru
Игра почти готова, но не хватает ещё парочки нюансов)
Сразу смотрим результат, а я пока распишу, что же тут происходит
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());
}
}
// это нам пригодится в будущем
Всё пучком. Я так понимаю тут и музыка соответствующая теме поста!
ЗЫ: к тому же, карательная кулинария сегодня уже была))
я тут нисмочь(
свиное пузико :)