Читать дальше...
А теперь давайте посмотрим, что изменилось.
В первую очередь, конечно же, изменился метод 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;
}
Изменилась обработка пользовательского ввода
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();
}
Если есть идеи по улучшению кода, рад буду почитать
План на следующий этап
Всем хороших книг!
В прошлый раз мы нарисовали наш танк и научили его передвигаться. Теперь необходимо научить его врезаться в стены/воду.
Для этого создаём сущность коллизии и после инициализации уровня храним в памяти все коллизии и в момент попытки двигаться проверяем столкновение коллизий.
Это некая базовая версия физического движка. Нужно понимать логику: то, что игрок видит на экране не имеет никакого значения. Физика просто работает и говорит можно ехать дальше или нет. В какой-то мере это почти тоже самое, что происходит, например, в 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);
Результат:
Далее по плану научить танк стрелять и, в идеале, отрабатывать попадания снарядов в стены.
Вернуться в долину рудников мы сможем уже 5 июня!
Эдмунд МакМиллен вместе с Тайлером Глейеном запили игру про котиком, где надо их снашать, рожать и потом мутузить ими других котиков.
Так вот за три дня количество проданных копий превысили 500 тысяч экземпляров, что в итоге полностью позволило окупить затраченный на игру бюджет.
Год тому назад нежданно негаданно в ГОГ вышли порты классической дилогии. Все удивились, кто-то порадовался, ну да и ладно. И тут вдруг эти же порты упали на полки парового магазина. Там куча технических фишек, типа поддержки современных мониторов, вшитой локализации на шесть языков, просто картинка поправлена. В общем, как будто стоит брать.
Directive 8020 – именно так называется игра от Supermassive Games, анонсировали её давно уже, так что факт существования игры не новость. А вот новость то, что стала известна дата релиза оной.
Свет ужастик увидит уже 12-го мая!
У нас есть заготовка для рендера и отрисовка игрового уровня. Пора по нему побегать.
В инициализации появилась пара новых строчек
// Инициализация обработчика ввода
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: Здесь будет рендер врагов, пуль, эффектов
Результат всего этого безобразия
Танк ездит сквозь стены, потому что "физического" движка ещё нет. Проверка коллизий как раз на очереди.
От имени своего журнала @VariusSoft выкладывал посты про то, как тетрис запилить и сейчас серия постов о том как воссоздать танчики. В процессе разработки осознал, что в них надо играть вдвоём, а как же это делать, если сесть за одну денди и взять в руки два джойстика нет возможности?
Ну, онлайн, чё тупить. Но как организовать онлайн? В общем покумекал я немного и решил, что для этого необходим сервер. Но писать полноценный сервер для игры в танчики – это, как будто, прям из пушки по воробьям. Поэтому я решил написать универсальный сервер, который одновременно может обслуживать несколько игр, каждая на пару-тройку десятков одновременных игроков.
Выглядит это безобразие так:
Написан он на Шарпе и скомпилирован под 8 дот нет, чтоб была возможность запустить и на маках и на линях. Но ещё, для пущей уверенности, завернул к херам в докер это безобразие.
Каков же алгоритм работы?
Первый игрок, который хочет с кем-то поиграть, нажимает кнопочку «Создать комнату» в интерфейсе игры, игры стучится на сервер и получает идентификатор комнаты. Дальше и эта игра и другие клиенты подключаются к этой же комнате. Первый клиент выступает в роли хоста. То есть вся обработка геймплея и игрового мира происходит на нём. Как это всегда было в КС, Халфе и прочих сессионных зарубах.
Также написал SDK на JS для встраивания работы с сервером в любую HTML5 игру.
Математически при хорошем канале данный сервер способен вытянуть одну игру на 400-500 человек или 10 игр по 40. Как на практике будет работать, посмотрим. Хотя, сэмулировать нагрузку в 500 человек я даже если очень хочу, не смогу)
Но! Если вы вдруг увлекаетесь игронаписанием, я могу вам выдать сервак на растерзание, ну или просто для своей игры себе чтоб подняли и использовали.
И да, я уже предвижу комментарии из цикла: «Нахер изобретать велосипед», но мне насрать, серьёзно. Я хотел написать, я написал.
Всем хороших игр!
Следующим этапом сделаем систему отображения тайлов на игровом поле. Мы предполагаем, что у нас есть тайлы разных типов: кирпичи там, бетон, вода, лёд, кусты.
И чтобы эти самые блоки рисовать на игровом поле, нужно ввести сущность карты и самих блоков.
Сущность карты:
class GameMap {
constructor() {
// Сетка 26x26 субтайлов
this.tileGrid = [];
for (let y = 0; y < GRID_SIZE; y++) {
this.tileGrid[y] = new Array(GRID_SIZE).fill(TILE_EMPTY);
}
// Карта для хранения состояния повреждений разрушаемых тайлов
this.damageMask = new Map();
// Флаг необходимости перерисовки фона
this.dirty = true;
}
}
Ну и в нашем основном классе надо карту создать:
/** @type {GameMap} */
let gameMap = null;
...
// в методе init
// Создаём и загружаем карту
gameMap = new GameMap();
gameMap.loadLevel(TEST_LEVEL_1);
// Рисуем фон (карта + рамка)
renderBackground(gameMap);
gameMap.dirty = false;
При это тестовый уровень сейчас выглядит вот так:
export const TEST_LEVEL_1 = [
'..........................', // 0 верхний ряд пустой (под спавн врагов)
'..........................', // 1
'..BB..BB..BB..BB..BB..BB..', // 2 кирпичные стены
'..BB..BB..BB..BB..BB..BB..', // 3
'..BB..BB..BB..BB..BB..BB..', // 4
'..BB..BB..SS..BB..BB..BB..', // 5 сталь/бетон
'..BB..FF..SS..FF..BB..BB..', // 6 лес
'..BB..FF......FF..BB..BB..', // 7
'..BB..BB..BB..BB..BB..BB..', // 8
'..BB..BB..BB..BB..BB..BB..', // 9
'..........BB..BB..........', // 10
'..........BB..BB..........', // 11
'..BB..BB..........BB..BB..', // 12
'..BB..BB..........BB..BB..', // 13
'..BB..BB..BB..BB..BB..BB..', // 14
'..BB..BB..BB..BB..BB..BB..', // 15
'......WW..BB..BB..WW......', // 16 вода
'......WW..BB..BB..WW......', // 17
'..BB..BB..II..II..BB..BB..', // 18 лёд
'..BB..BB..II..II..BB..BB..', // 19
'..BB..BB..BB..BB..BB..BB..', // 20
'..BB..BB..BB..BB..BB..BB..', // 21
'..........BBBBBB..........', // 22 защита базы
'..........BBBBBB..........', // 23
'..........BBEEBB..........', // 24 база
'..........BBEEBB..........', // 25
];
Не знаю, пока, на сколько такой формат хранения данных корректен и будет удобен в использовании в дальнейшем, но сейчас он по крайне мере очень нагляден и удобен для ручного левел дизайна.
Рисование блоков достаточно просто:
export function renderBackground(gameMap) {
const ctx = backgroundCtx;
// Очищаем весь фон
clearBackground();
// Рамка игрового поля
renderGameFieldBorder();
// Рисуем все непустые тайлы
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const tileId = gameMap.getTile(x, y);
if (tileId === TILE_EMPTY) continue;
const tileDef = TILE_DEFS[tileId];
if (!tileDef) continue;
// Логические координаты → физические координаты на canvas
const px = GAME_FIELD_X + x * TILE_SIZE * GAME_SCALE;
const py = GAME_FIELD_Y + y * TILE_SIZE * GAME_SCALE;
const size = TILE_SIZE * GAME_SCALE;
ctx.fillStyle = tileDef.color; // цвет в самих тайлах пока храню
ctx.fillRect(px, py, size, size); // потом буду спрайты рисовать
}
}
}
Хранятся тайлы у меня вот таким образом:
TILE_DEFS = {
[TILE_EMPTY]: {
id: TILE_EMPTY,
name: 'empty',
blocksTank: false,
blocksBullet: false,
destructible: false,
overlay: false,
color: COLOR_EMPTY // эта вся фигня в константах забита
},
...
У сущности карты есть метод для загрузки из вот того текстового бреда, который чуть выше скинут
loadLevel(levelData) {
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const char = levelData[y]?.[x] || '.';
this.tileGrid[y][x] = charToTile(char);
}
}
// Инициализируем damageMask для всех разрушаемых тайлов
this.damageMask.clear();
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const tileDef = TILE_DEFS[this.tileGrid[y][x]];
if (tileDef && tileDef.destructible) {
this.damageMask.set(`${x},${y}`, DAMAGE_FULL);
}
}
}
this.dirty = true;
console.log(`Level loaded. Destructible tiles: ${this.damageMask.size}`);
}
function charToTile(char) {
switch (char) {
case 'B': return TILE_BRICK;
case 'S': return TILE_STEEL;
case 'W': return TILE_WATER;
case 'F': return TILE_FOREST;
case 'I': return TILE_ICE;
case 'E': return TILE_BASE;
default: return TILE_EMPTY;
}
}
И вот такой вот получается итог
В целом, уже похоже на правду и с этим можно работать.
план на следующий этап:
После прошлого поста я что-то так проникся происходящим, что решил воссоздать ещё одну любовь детства. Но как говорил Маркус Персон: «Если вы не можете написать свой движок, то гавно вы, а не разработчики». Там, скорее всего, было как-о иначе, но суть такая. И да, с этой мыслью я в корне не согласен, но написать свой движок – задача, как минимум, интересная. Я решил, что аркадная игра для этого подходит, как ничто другое. По сути, некий аналог движка уже был реализован в тетрисе, но здесь нужна будет и какая-никакая физическая модель, и интеллект врагов и рендер всего этого безобразия в несколько слоёв и с ФПС побольше, чем 2 :)
Так что, приступим. Что нам нужно:
Для одного поста такая задачка звучит жирновато, поэтому, видимо, будет серия.
Я решил разделить рисования окружения (статичных объектов), врагов и игроков (динамических объектов) и UI на три разных канваса, которые просто повещены один поверх другого. Там можно будет проще и меньше перерисовывать.
Начинается наш код с проверки, готова ли страница к явлению миру нашего движка.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
Если готова, то давай же скорее всё проинициализируем
Тут всё просто:
function init() {
console.log('Battle City Remake - Initializing...');
// Инициализация рендерера
initRenderer();
// Очищаем все слои
clearBackground();
clearForeground();
clearUI();
// Рисуем границу игрового поля и отладочную сетку
renderGameFieldBorder();
renderDebugGrid();
console.log('Initialization complete. Starting game loop...');
// Запускаем game loop
requestAnimationFrame(gameLoop);
}
Но понятное дело, что кода тут мало, потому что всё вынесено в отдельные методы.
Есть ли среди них хоть что-то интересное?
Ну, renderDebugGrid и renderGameFieldBorder одним названием уже говорят, что там происходит.
Логично, что самое интересное происходит где-то тут requestAnimationFrame(gameLoop);
Но давайте сначала заглянем в инициализацию рендера
export function initRenderer() {
// Получаем canvas элементы
backgroundCanvas = document.getElementById('background-canvas');
foregroundCanvas = document.getElementById('foreground-canvas');
uiCanvas = document.getElementById('ui-canvas');
// Получаем контексты
backgroundCtx = backgroundCanvas.getContext('2d');
foregroundCtx = foregroundCanvas.getContext('2d');
uiCtx = uiCanvas.getContext('2d');
// Определяем DPR для HiDPI экранов
dpr = window.devicePixelRatio || 1;
// Настраиваем все три canvas
setupCanvas(backgroundCanvas, backgroundCtx);
setupCanvas(foregroundCanvas, foregroundCtx);
setupCanvas(uiCanvas, uiCtx);
console.log(`Renderer initialized (DPR: ${dpr})`);
}
function setupCanvas(canvas, ctx) {
// Физический размер canvas
canvas.width = CANVAS_WIDTH * dpr;
canvas.height = CANVAS_HEIGHT * dpr;
// Масштабируем контекст для компенсации DPR
ctx.scale(dpr, dpr);
// Отключаем сглаживание для пиксельной графики
ctx.imageSmoothingEnabled = false;
console.log(`Canvas ${canvas.id} setup: ${canvas.width}×${canvas.height} (logical: ${CANVAS_WIDTH}×${CANVAS_HEIGHT})`);
}
CANVAS_WIDTH и CANVAS_HEIGHT — это константы, они у меня в отдельном файлике лежат, там просто 800 на 600.
В целом, страничка готова к тому, чтоб рисовать всякое
Дёргаем requestAnimationFrame и отдаём её наш gameLoop
function gameLoop(currentTime) {
// Вычисляем deltaTime
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// Накапливаем время
accumulator += deltaTime;
// Fixed timestep - обновляем логику с фиксированным шагом
while (accumulator >= FRAME_TIME) {
update(FRAME_TIME);
accumulator -= FRAME_TIME;
}
// Рендерим независимо от update
render();
// Продолжаем loop
requestAnimationFrame(gameLoop);
}
Я решил фиксировать ФПС на 30
export const FPS = 30; // Фиксированный FPS
export const FRAME_TIME = 1000 / FPS; // ~33.33ms на кадр
Метод update содержит (ну, будет содержать) всю нашу игровую логику. Пока он только считает фпс и выводит его на экран для отладки.
function update(dt) {
// Пока пусто - здесь будет логика игры
// Подсчёт FPS для отладки
frameCount++;
fpsTimer += dt;
if (fpsTimer >= 1000) {
fps = frameCount;
frameCount = 0;
fpsTimer = 0;
// Обновляем FPS на странице
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = fps;
}
}
}
Ну и когда всё закончено, отрисовываем
function render() {
// Background canvas - перерисовывается только при изменении карты
// (пока только отладочная сетка, нарисованная в init)
// Foreground canvas - очищаем и рисуем сущности каждый кадр
clearForeground();
// TODO: Здесь будет рендер танков, пуль, эффектов
// UI canvas - очищаем и рисуем UI каждый кадр
clearUI();
renderUI();
}
То есть, по сути, ничего интересного, просто рисую полосочки и квадратики для UI
Результат выглядит вот так
Следующий пост будет посвящён второму этапу
И да, я упоролся и расписал себе полноценный план реализации на 13 этапов. Без ДизДока последнее время вообще не представляю как работать. А раз уж я в команде один, то сам себе и ТЗ пишу... Но лучше так, чем лепить полную отсебятину.
Вопросы замечания предложения в комментариях жду с нетерпением)
А пойду пилить обновление для Лиспублики)
принял, не знала)
сворачиваем в еду:D
Ну, в гречку я пихну овощей, а куру сделаю в виде котлет и суну в сметану с томатной пастой. Я ж не враг себе)))
Класс! У меня мама делает на двух чугунных сковородках и точно так же смазывает их салом. А потом каждый блин маслом (после чего есть их становится невозможно, но кого это волнует - маме главное чтобы...