From d47d50f4674dd7f63a9c3341036dbcf0a37e3b93 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 4 Aug 2026 13:13:29 +0500 Subject: [PATCH 1/3] =?UTF-8?q?=D0=9B=D0=BE=D0=B1=D0=B1=D0=B8:=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20"=D0=B6?= =?UTF-8?q?=D0=B4=D1=83=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0"=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D1=82=D0=B8=D1=85=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В pvp-комнате с одним игроком кнопка "готов" ничего не делала (сервер не стартует матч без 2 игроков) без единого объяснения. Теперь кнопка задизейблена и показывается статус, пока не зайдёт второй игрок. --- frontend/js/game/lobby.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js index 4ec0a90..f9164b5 100644 --- a/frontend/js/game/lobby.js +++ b/frontend/js/game/lobby.js @@ -50,10 +50,17 @@ function renderLobbyView() { } function renderRoomView() { - const readyBtn = - currentRoom.mode === 'pvp' - ? '' - : '

coop ещё не реализован (этап 5)

'; + const full = currentRoom.players.length >= 2; + let statusLine = ''; + let readyBtn = ''; + if (currentRoom.mode !== 'pvp') { + statusLine = '

coop ещё не реализован (этап 5)

'; + } else if (!full) { + statusLine = '

жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком

'; + readyBtn = ''; + } else { + readyBtn = ''; + } container.innerHTML = `
${errorBanner()} @@ -62,6 +69,7 @@ function renderRoomView() { + ${statusLine} ${readyBtn}
`; From 84da9b49e22b864b8ebf9afa4367ce4308202526 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Thu, 6 Aug 2026 11:39:09 +0500 Subject: [PATCH 2/3] =?UTF-8?q?Coop=20=D1=81=20AI/=D0=B1=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B9=20(=D1=81=D0=BE=D0=BB=D0=BE-=D1=81=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D1=82),=20=D1=80=D0=B5=D0=B2=D0=B0=D0=BD=D1=88,=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=8B=D0=B5=20URL,=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер (Game.hpp/cpp): - coop-режим: AI-противники (волна 8, до 3 одновременно), простое преследование базы с рандомизацией, база с укрытием из кирпича. - Комната стартует соло (1 игрок), второй может подключиться позже. - Fix: хитбокс пули по базе брал радиус танка вместо пули. - Fix: AI при столкновении со стеной теперь сразу перерешает направление, а не долбит в одну точку до 3с — без этого база падала за секунды. Клиент: - Танк: гусеницы, башня, заметный ствол (было — незаметный нубик). - Рендер базы (укрытие/флаг), AI-танки — отдельным оттенком зелёного. - Экран победы/поражения — крупный glitch-текст на весь оверлей вместо строки текста; кнопки "играть ещё" (без reload) и "в лобби". - Блок кнопок управления (D-pad + огонь) справа от игрового поля. - Лобби: coop стартует по кнопке "готов" в одиночку. nginx: try_files с fallback на .html — чистые URL (/projects, /game) без расширения, старые /projects.html и т.п. продолжают работать. --- frontend/css/game.css | 123 +++++++++++++-- frontend/game.html | 5 +- frontend/index.html | 4 +- frontend/js/game/battle.js | 158 +++++++++++++++++-- frontend/js/game/lobby.js | 23 ++- frontend/projects.html | 4 +- gameserver/src/Game.cpp | 312 +++++++++++++++++++++++++++++++------ gameserver/src/Game.hpp | 45 ++++-- gameserver/src/Lobby.cpp | 6 +- nginx/nginx.conf | 2 +- 10 files changed, 588 insertions(+), 94 deletions(-) diff --git a/frontend/css/game.css b/frontend/css/game.css index a11c1d8..3e56a90 100644 --- a/frontend/css/game.css +++ b/frontend/css/game.css @@ -90,32 +90,137 @@ } #battlecity-canvas-container { - max-width: 640px; + position: relative; + max-width: 900px; margin: 1.5rem 0; } -#battlecity-canvas-container canvas { +.battle-layout { + display: flex; + align-items: flex-start; + gap: 1.5rem; + flex-wrap: wrap; +} + +#battle-canvas-slot canvas { border: var(--border); background: var(--color-bg); display: block; } -#battle-result { - color: var(--color-accent); - font-size: 1.1rem; - margin: 0.75rem 0; +/* --- блок управления справа от поля --- */ +.battle-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + padding-top: 0.5rem; } -#battle-back { +.dpad { + display: grid; + grid-template-columns: repeat(3, 44px); + grid-template-rows: repeat(2, 44px); + gap: 4px; +} + +.dpad button { + background: var(--color-bg-raised); + border: 1px solid var(--color-accent-dim); + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 1.1rem; + cursor: pointer; + user-select: none; + touch-action: none; +} + +.dpad button:active { + background: var(--color-accent-dim); + color: var(--color-bg); +} + +.dpad__up { grid-column: 2; grid-row: 1; } +.dpad__left { grid-column: 1; grid-row: 2; } +.dpad__right { grid-column: 3; grid-row: 2; } +.dpad__down { grid-column: 2; grid-row: 2; } + +.fire-btn { + background: var(--color-bg-raised); + border: 1px solid var(--color-accent); + color: var(--color-accent); + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0.75rem 1.5rem; + cursor: pointer; + user-select: none; + touch-action: none; +} + +.fire-btn:active { + background: var(--color-accent); + color: var(--color-bg); +} + +/* --- экран победы/поражения --- */ +#battle-result-overlay { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + background: rgba(10, 14, 12, 0.9); + z-index: 5; +} + +.battle-result { + text-align: center; + padding: 2rem; + border: var(--border); + background: var(--color-bg-raised); +} + +.battle-result h2 { + font-size: 3rem; + margin: 0 0 1rem; + letter-spacing: 0.05em; +} + +.battle-result--win h2 { + color: var(--color-accent); + text-shadow: 0 0 16px var(--color-accent-dim); +} + +.battle-result--lose h2 { + color: var(--color-silver); +} + +.battle-result--draw h2 { + color: var(--color-silver); +} + +.battle-result p { + color: var(--color-fg); + margin: 0 0 1.5rem; +} + +.battle-result__actions { + display: flex; + gap: 1rem; + justify-content: center; +} + +.battle-result__actions button { background: transparent; border: 1px solid var(--color-accent-dim); color: var(--color-accent); font-family: var(--font-mono); - padding: 0.35rem 0.75rem; + padding: 0.5rem 1.25rem; cursor: pointer; } -#battle-back:hover { +.battle-result__actions button:hover { border-color: var(--color-accent); box-shadow: 0 0 6px var(--color-accent-dim); } diff --git a/frontend/game.html b/frontend/game.html index 5bef8ce..473dbc3 100644 --- a/frontend/game.html +++ b/frontend/game.html @@ -19,13 +19,12 @@
-

./battlecity

diff --git a/frontend/index.html b/frontend/index.html index f9b4488..9c41efe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,8 +20,8 @@ diff --git a/frontend/js/game/battle.js b/frontend/js/game/battle.js index 21af651..53196dc 100644 --- a/frontend/js/game/battle.js +++ b/frontend/js/game/battle.js @@ -1,4 +1,5 @@ import { ClientMessage, ServerMessage } from './protocol.js'; +import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js'; const TILE_PX = 32; // Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform) @@ -12,21 +13,46 @@ let myPlayerId = null; let stage = null; let tankSprites = new Map(); let bulletSprites = new Map(); +let baseSprite = null; let lastSentInput = null; let seq = 0; let polling = false; +function tankColor(p) { + if (p.isAi) return '#0a8f2c'; + return p.isMe ? '#00ff41' : '#9fa3a0'; +} + function setupEntities() { Q.Sprite.extend('Tank', { init(p) { - this._super(p, { w: 26, h: 26, renderAlways: true }); + this._super(p, { w: 24, h: 24, renderAlways: true }); }, draw(ctx) { const p = this.p; - ctx.fillStyle = p.isMe ? '#00ff41' : '#9fa3a0'; + const color = tankColor(p); + + // корпус + ctx.fillStyle = color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + + // гусеницы по бокам ctx.fillStyle = '#0a0e0c'; - ctx.fillRect(-3, -p.h / 2 - 8, 6, 12); + ctx.fillRect(-p.w / 2, -p.h / 2, 3, p.h); + ctx.fillRect(p.w / 2 - 3, -p.h / 2, 3, p.h); + + // башня + ctx.beginPath(); + ctx.arc(0, 0, p.w / 2 - 5, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = '#0a0e0c'; + ctx.stroke(); + + // ствол — явно торчит вперёд по направлению взгляда (локально «вверх») + ctx.fillStyle = '#0a0e0c'; + ctx.fillRect(-2.5, -p.h / 2 - 12, 5, 16); }, }); @@ -59,6 +85,26 @@ function setupEntities() { } }, }); + + Q.Sprite.extend('Base', { + init(p) { + this._super(p, { w: TILE_PX * 0.85, h: TILE_PX * 0.85, renderAlways: true }); + }, + draw(ctx) { + const p = this.p; + ctx.fillStyle = p.alive ? '#9fa3a0' : '#2a2d2b'; + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + ctx.strokeStyle = '#0a0e0c'; + ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2); + ctx.fillStyle = p.alive ? '#00ff41' : '#0a0e0c'; + ctx.beginPath(); + ctx.moveTo(0, -p.h / 2); + ctx.lineTo(-p.w / 4, 0); + ctx.lineTo(p.w / 4, 0); + ctx.closePath(); + ctx.fill(); + }, + }); } function ensureQuintus(cols, rows) { @@ -78,8 +124,6 @@ function ensureQuintus(cols, rows) { Q.scene('battle', (stageRef) => { stage = stageRef; }); - - document.getElementById('battlecity-canvas-container').appendChild(Q.el); } function buildMap(map) { @@ -100,6 +144,28 @@ function buildMap(map) { } } +function bindControlButtons(root) { + const press = (input, active) => { + if (Q) { + Q.inputs[input] = active; + } + }; + root.querySelectorAll('[data-input]').forEach((btn) => { + const input = btn.dataset.input; + btn.addEventListener('mousedown', () => press(input, true)); + btn.addEventListener('mouseup', () => press(input, false)); + btn.addEventListener('mouseleave', () => press(input, false)); + btn.addEventListener('touchstart', (e) => { + e.preventDefault(); + press(input, true); + }); + btn.addEventListener('touchend', (e) => { + e.preventDefault(); + press(input, false); + }); + }); +} + function pollInput() { if (!polling) { return; @@ -121,10 +187,17 @@ function pollInput() { } function onState(payload) { + const seenTanks = new Set(); for (const t of payload.tanks) { + seenTanks.add(t.id); let sprite = tankSprites.get(t.id); if (!sprite) { - sprite = new Q.Tank({ x: t.x * TILE_PX, y: t.y * TILE_PX, isMe: t.id === myPlayerId }); + sprite = new Q.Tank({ + x: t.x * TILE_PX, + y: t.y * TILE_PX, + isMe: t.id === myPlayerId, + isAi: !!t.is_ai, + }); stage.insert(sprite); tankSprites.set(t.id, sprite); } @@ -133,6 +206,12 @@ function onState(payload) { sprite.p.angle = DIR_DEGREES[t.direction] || 0; sprite.p.hidden = !t.alive; } + for (const [id, sprite] of tankSprites) { + if (!seenTanks.has(id)) { + sprite.destroy(); + tankSprites.delete(id); + } + } const seenBullets = new Set(); for (const b of payload.bullets) { @@ -152,14 +231,49 @@ function onState(payload) { bulletSprites.delete(id); } } + + if (payload.base) { + if (!baseSprite) { + baseSprite = new Q.Base({ + x: payload.base.x * TILE_PX, + y: payload.base.y * TILE_PX, + alive: payload.base.alive, + }); + stage.insert(baseSprite); + } else { + baseSprite.p.alive = payload.base.alive; + } + } } function onOver(payload) { polling = false; - const resultText = { win: 'Победа', lose: 'Поражение', draw: 'Ничья' }[payload.result] || payload.result; - const resultEl = document.getElementById('battle-result'); - resultEl.textContent = `${resultText} — ${payload.reason}`; - document.getElementById('battle-back').style.display = 'inline-block'; + const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' }; + const title = titles[payload.result] || payload.result.toUpperCase(); + const overlay = document.getElementById('battle-result-overlay'); + overlay.innerHTML = ` +
+

${title}

+

${payload.reason}

+
+ + +
+
`; + overlay.style.display = 'flex'; +} + +function onOverlayClick(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const overlay = document.getElementById('battle-result-overlay'); + overlay.style.display = 'none'; + overlay.innerHTML = ''; + if (btn.dataset.action === 'rematch') { + returnToLobbyContainer(); + } else { + leaveRoomAndReturn(); + } } export function startBattle(networkInstance, payload, ownPlayerId) { @@ -172,12 +286,30 @@ export function startBattle(networkInstance, payload, ownPlayerId) { document.getElementById('lobby').style.display = 'none'; const container = document.getElementById('battlecity-canvas-container'); container.style.display = 'block'; - container.innerHTML = '

'; + container.innerHTML = ` +
+
+
+
+ + + + +
+ +
+
+
`; ensureQuintus(cols, rows); + document.getElementById('battle-canvas-slot').appendChild(Q.el); + bindControlButtons(container.querySelector('.battle-controls')); + container.querySelector('#battle-result-overlay').addEventListener('click', onOverlayClick); + Q.stageScene('battle', 0); tankSprites = new Map(); bulletSprites = new Map(); + baseSprite = null; buildMap(payload.map); @@ -188,8 +320,4 @@ export function startBattle(networkInstance, payload, ownPlayerId) { seq = 0; polling = true; requestAnimationFrame(pollInput); - - document.getElementById('battle-back').addEventListener('click', () => { - location.reload(); - }); } diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js index f9164b5..5d86eb9 100644 --- a/frontend/js/game/lobby.js +++ b/frontend/js/game/lobby.js @@ -52,14 +52,12 @@ function renderLobbyView() { function renderRoomView() { const full = currentRoom.players.length >= 2; let statusLine = ''; - let readyBtn = ''; - if (currentRoom.mode !== 'pvp') { - statusLine = '

coop ещё не реализован (этап 5)

'; - } else if (!full) { + let readyBtn = ''; + if (currentRoom.mode === 'pvp' && !full) { statusLine = '

жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком

'; readyBtn = ''; - } else { - readyBtn = ''; + } else if (currentRoom.mode === 'coop' && !full) { + statusLine = '

можно начать соло, второй игрок сможет подключиться позже

'; } container.innerHTML = `
@@ -83,6 +81,19 @@ function render() { } } +// Вызывается из battle.js по кнопкам экрана победы/поражения. +export function returnToLobbyContainer() { + document.getElementById('battlecity-canvas-container').style.display = 'none'; + container.style.display = ''; + render(); +} + +export function leaveRoomAndReturn() { + currentRoom = null; + net.leaveRoom(); + returnToLobbyContainer(); +} + container?.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; diff --git a/frontend/projects.html b/frontend/projects.html index 01703af..cb3e014 100644 --- a/frontend/projects.html +++ b/frontend/projects.html @@ -20,8 +20,8 @@ diff --git a/gameserver/src/Game.cpp b/gameserver/src/Game.cpp index e9ff059..65fdb30 100644 --- a/gameserver/src/Game.cpp +++ b/gameserver/src/Game.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace { constexpr double kTankHalf = 0.4; @@ -13,16 +14,27 @@ constexpr double kBulletSpeed = 8.0; // тайлов/сек constexpr double kBulletHalf = 0.08; constexpr int kFireCooldownTicks = 10; // 0.5с при 20 Гц constexpr double kDt = Game::kTickMs / 1000.0; +constexpr double kBaseHalf = 0.45; } // namespace -Game::Game(Room &room, std::function onFinished) : onFinished_(std::move(onFinished)) { - buildMap(); +Game::Game(Room &room, std::function onFinished) + : mode_(room.mode), onFinished_(std::move(onFinished)) { + if (mode_ == "coop") { + buildCoopMap(); + } else { + buildPvpMap(); + } - for (int i = 0; i < 2; i++) { - Tank &t = tanks_[i]; - t.player_id = room.players[i].player_id; + for (size_t i = 0; i < room.players.size(); i++) { + Tank t; + t.id = room.players[i].player_id; t.ws = room.players[i].ws; - if (i == 0) { + if (mode_ == "coop") { + // оба человека стартуют у базы, чуть в стороны друг от друга + t.x = kMapWidth / 2.0 - 1.0 + i * 2.0; + t.y = kMapHeight - 2.5; + t.dir = Dir::Up; + } else if (i == 0) { t.x = 1.5; t.y = kMapHeight - 2.5; t.dir = Dir::Up; @@ -31,6 +43,7 @@ Game::Game(Room &room, std::function onFinished) : onFinished_(std::move t.y = 1.5; t.dir = Dir::Down; } + tanks_.push_back(t); } } @@ -40,7 +53,7 @@ Game::~Game() { } } -void Game::buildMap() { +void Game::buildPvpMap() { map_.assign(kMapHeight, std::vector(kMapWidth, kEmpty)); for (int y = 0; y < kMapHeight; y++) { for (int x = 0; x < kMapWidth; x++) { @@ -50,7 +63,6 @@ void Game::buildMap() { } } - // Симметричные (180°) кластеры кирпича/стали в интерьере карты. auto placeMirrored = [this](int x, int y, int w, int h, int tile) { for (int dy = 0; dy < h; dy++) { for (int dx = 0; dx < w; dx++) { @@ -66,6 +78,46 @@ void Game::buildMap() { placeMirrored(6, 6, 3, 3, kBrick); } +void Game::buildCoopMap() { + map_.assign(kMapHeight, std::vector(kMapWidth, kEmpty)); + for (int y = 0; y < kMapHeight; y++) { + for (int x = 0; x < kMapWidth; x++) { + if (x == 0 || y == 0 || x == kMapWidth - 1 || y == kMapHeight - 1) { + map_[y][x] = kSteel; + } + } + } + + // немного укрытий по полю + auto block = [this](int x, int y, int w, int h, int tile) { + for (int dy = 0; dy < h; dy++) { + for (int dx = 0; dx < w; dx++) { + map_[y + dy][x + dx] = tile; + } + } + }; + block(2, 5, 2, 2, kBrick); + block(11, 5, 2, 2, kBrick); + block(6, 4, 3, 1, kSteel); + block(4, 8, 2, 2, kBrick); + block(9, 8, 2, 2, kBrick); + + // база внизу по центру, укрыта кирпичом с трёх сторон + int bx = kMapWidth / 2; + int by = kMapHeight - 2; + base_ = Base{bx + 0.5, by + 0.5, true}; + block(bx - 1, by - 1, 3, 1, kBrick); // сверху + map_[by][bx - 1] = kBrick; // слева + map_[by][bx + 1] = kBrick; // справа + map_[by][bx] = kEmpty; // сама база — не стена + + enemy_spawn_points_ = { + {3.5, 1.5}, + {kMapWidth / 2.0 + 0.5, 1.5}, + {kMapWidth - 3.5, 1.5}, + }; +} + void Game::start() { timer_ = us_create_timer((us_loop_t *)uWS::Loop::get(), 0, sizeof(Game *)); *(Game **)us_timer_ext(timer_) = this; @@ -80,7 +132,7 @@ void Game::start() { void Game::handleInput(const std::string &player_id, const json &payload) { for (auto &t : tanks_) { - if (t.player_id == player_id) { + if (!t.is_ai && t.id == player_id) { t.up = payload.value("up", false); t.down = payload.value("down", false); t.left = payload.value("left", false); @@ -125,6 +177,13 @@ bool Game::rectHitsTank(double x, double y, const Tank &self) const { return false; } +bool Game::rectHitsBase(double x, double y, double half) const { + if (!base_ || !base_->alive) { + return false; + } + return std::abs(x - base_->x) < half + kBaseHalf && std::abs(y - base_->y) < half + kBaseHalf; +} + void Game::applyInput(Tank &tank) { if (!tank.alive) { return; @@ -161,7 +220,7 @@ void Game::applyInput(Tank &tank) { case Dir::Left: nx -= step; break; case Dir::Right: nx += step; break; } - if (!rectHitsSolid(nx, ny) && !rectHitsTank(nx, ny, tank)) { + if (!rectHitsSolid(nx, ny) && !rectHitsTank(nx, ny, tank) && !rectHitsBase(nx, ny, kTankHalf)) { tank.x = nx; tank.y = ny; } @@ -172,19 +231,80 @@ void Game::applyInput(Tank &tank) { } } +void Game::updateAi(Tank &tank) { + if (tank.ai_redecide_ticks > 0) { + tank.ai_redecide_ticks--; + } else { + double tx = base_ ? base_->x : tank.x; + double ty = base_ ? base_->y : tank.y; + double dx = tx - tank.x, dy = ty - tank.y; + + if (std::rand() % 5 == 0) { + // изредка идём в случайную сторону, чтобы враги не были предсказуемы + tank.ai_dir = static_cast(std::rand() % 4); + } else if (std::abs(dx) > std::abs(dy)) { + tank.ai_dir = dx > 0 ? Dir::Right : Dir::Left; + } else { + tank.ai_dir = dy > 0 ? Dir::Down : Dir::Up; + } + tank.ai_redecide_ticks = 30 + std::rand() % 30; // 1.5-3с + } + + tank.up = tank.ai_dir == Dir::Up; + tank.down = tank.ai_dir == Dir::Down; + tank.left = tank.ai_dir == Dir::Left; + tank.right = tank.ai_dir == Dir::Right; + tank.fire = (std::rand() % 15) == 0; +} + +void Game::spawnEnemiesIfNeeded() { + if (enemies_spawned_total_ >= kMaxEnemiesTotal) { + return; + } + if (enemy_spawn_cooldown_ > 0) { + enemy_spawn_cooldown_--; + return; + } + + int concurrent = 0; + for (const auto &t : tanks_) { + if (t.is_ai && t.alive) { + concurrent++; + } + } + if (concurrent >= kMaxConcurrentEnemies) { + return; + } + + const auto &sp = enemy_spawn_points_[enemies_spawned_total_ % enemy_spawn_points_.size()]; + Tank t; + t.id = "ai-" + std::to_string(next_enemy_id_++); + t.is_ai = true; + t.ws = nullptr; + t.x = sp.first; + t.y = sp.second; + t.dir = Dir::Down; + t.lives = 1; + tanks_.push_back(t); + + enemies_spawned_total_++; + enemy_spawn_cooldown_ = kEnemySpawnCooldownTicks; +} + void Game::fireBullet(Tank &tank) { if (tank.fire_cooldown > 0) { return; } bool hasOwnBullet = std::any_of(bullets_.begin(), bullets_.end(), - [&](const Bullet &b) { return b.owner_id == tank.player_id; }); + [&](const Bullet &b) { return b.owner_id == tank.id; }); if (hasOwnBullet) { return; } Bullet b; b.id = next_bullet_id_++; - b.owner_id = tank.player_id; + b.owner_id = tank.id; + b.owner_is_ai = tank.is_ai; b.dir = tank.dir; double offset = kTankHalf + kBulletHalf + 0.01; b.x = tank.x; @@ -211,7 +331,6 @@ void Game::updateBullets() { } } - // Пуля против пули: встречные уничтожают друг друга. std::vector dead(bullets_.size(), false); for (size_t i = 0; i < bullets_.size(); i++) { if (dead[i]) continue; @@ -224,8 +343,7 @@ void Game::updateBullets() { } } - int loserIndex = -1; - bool anyoneHit = false; + int pvpLoserIndex = -1; for (size_t i = 0; i < bullets_.size(); i++) { if (dead[i]) continue; @@ -246,21 +364,41 @@ void Game::updateBullets() { dead[i] = true; continue; } + if (rectHitsBase(b.x, b.y, kBulletHalf)) { + dead[i] = true; + if (base_) { + base_->alive = false; + } + continue; + } - for (int ti = 0; ti < 2; ti++) { + for (size_t ti = 0; ti < tanks_.size(); ti++) { Tank &t = tanks_[ti]; - if (!t.alive || t.player_id == b.owner_id) continue; + if (!t.alive || t.id == b.owner_id) continue; + if (mode_ == "coop" && t.is_ai == b.owner_is_ai) continue; // без дружественного огня + if (std::abs(b.x - t.x) < kTankHalf + kBulletHalf && std::abs(b.y - t.y) < kTankHalf + kBulletHalf) { dead[i] = true; t.lives--; - anyoneHit = true; if (t.lives <= 0) { t.alive = false; - loserIndex = ti; + if (mode_ == "pvp") { + pvpLoserIndex = (int)ti; + } } else { // респаун на стартовой позиции - t.x = (ti == 0) ? 1.5 : kMapWidth - 2.5; - t.y = (ti == 0) ? kMapHeight - 2.5 : 1.5; + if (mode_ == "coop") { + size_t humanIdx = 0; + for (auto &tt : tanks_) { + if (&tt == &t) break; + if (!tt.is_ai) humanIdx++; + } + t.x = kMapWidth / 2.0 - 1.0 + humanIdx * 2.0; + t.y = kMapHeight - 2.5; + } else { + t.x = (ti == 0) ? 1.5 : kMapWidth - 2.5; + t.y = (ti == 0) ? kMapHeight - 2.5 : 1.5; + } } break; } @@ -273,9 +411,29 @@ void Game::updateBullets() { } bullets_ = std::move(alive); - (void)anyoneHit; - if (loserIndex != -1) { - endGame(1 - loserIndex); + if (mode_ == "pvp" && pvpLoserIndex != -1) { + endGamePvp(1 - pvpLoserIndex); + } +} + +void Game::checkCoopEndConditions() { + if (finished_ || mode_ != "coop") { + return; + } + if (base_ && !base_->alive) { + endGameCoop(false, "база уничтожена"); + return; + } + bool anyHumanAlive = std::any_of(tanks_.begin(), tanks_.end(), + [](const Tank &t) { return !t.is_ai && t.alive; }); + if (!anyHumanAlive) { + endGameCoop(false, "все танки уничтожены"); + return; + } + bool anyEnemyAlive = std::any_of(tanks_.begin(), tanks_.end(), + [](const Tank &t) { return t.is_ai && t.alive; }); + if (enemies_spawned_total_ >= kMaxEnemiesTotal && !anyEnemyAlive) { + endGameCoop(true, "волна противника уничтожена"); } } @@ -286,9 +444,24 @@ void Game::tick() { tick_count_++; for (auto &t : tanks_) { + if (!t.alive) continue; + if (t.is_ai) { + updateAi(t); + } + double beforeX = t.x, beforeY = t.y; applyInput(t); + // Врезались в стену/базу — не долбим её до конца таймера, перерешаем сразу. + if (t.is_ai && t.x == beforeX && t.y == beforeY && (t.up || t.down || t.left || t.right)) { + t.ai_redecide_ticks = 0; + } } + + if (mode_ == "coop") { + spawnEnemiesIfNeeded(); + } + updateBullets(); + checkCoopEndConditions(); if (!finished_) { broadcastState(); @@ -307,12 +480,13 @@ json Game::dirToJson(Dir d) { json Game::serializeTank(const Tank &t) const { return { - {"id", t.player_id}, + {"id", t.id}, {"x", t.x}, {"y", t.y}, {"direction", dirToJson(t.dir)}, {"lives", t.lives}, {"alive", t.alive}, + {"is_ai", t.is_ai}, }; } @@ -327,6 +501,9 @@ json Game::serializeBullet(const Bullet &b) const { } void Game::sendTo(WS *ws, const std::string &type, json payload) { + if (!ws) { + return; + } json msg = {{"type", type}, {"payload", std::move(payload)}}; ws->send(msg.dump(), uWS::OpCode::TEXT); } @@ -338,8 +515,13 @@ void Game::broadcastState() { for (const auto &b : bullets_) bullets.push_back(serializeBullet(b)); json payload = {{"tick", tick_count_}, {"tanks", tanks}, {"bullets", bullets}}; + if (base_) { + payload["base"] = {{"x", base_->x}, {"y", base_->y}, {"alive", base_->alive}}; + } for (const auto &t : tanks_) { - sendTo(t.ws, "game.state", payload); + if (!t.is_ai) { + sendTo(t.ws, "game.state", payload); + } } } @@ -350,49 +532,71 @@ json Game::buildStartPayload(const std::string &mode) const { } json players = json::array(); for (const auto &t : tanks_) { - players.push_back({{"id", t.player_id}}); + players.push_back({{"id", t.id}}); } - return { + json payload = { {"map", mapJson}, {"mode", mode}, {"players", players}, {"tick_rate", 1000 / kTickMs}, }; + if (base_) { + payload["base"] = {{"x", base_->x}, {"y", base_->y}}; + } + return payload; } void Game::handleDisconnect(WS *ws) { if (finished_) { return; } - for (int i = 0; i < 2; i++) { - if (tanks_[i].ws == ws) { - int winnerIndex = 1 - i; - finished_ = true; - if (timer_) { - us_timer_close(timer_); - timer_ = nullptr; + + if (mode_ == "pvp") { + for (size_t i = 0; i < tanks_.size(); i++) { + if (tanks_[i].ws == ws) { + int winnerIndex = 1 - (int)i; + finished_ = true; + if (timer_) { + us_timer_close(timer_); + timer_ = nullptr; + } + sendTo(tanks_[winnerIndex].ws, "game.over", {{"result", "win"}, {"reason", "соперник отключился"}}); + if (onFinished_) { + onFinished_(); + } + return; } - sendTo(tanks_[winnerIndex].ws, "game.over", {{"result", "win"}, {"reason", "соперник отключился"}}); - if (onFinished_) { - onFinished_(); - } - return; } + return; + } + + // coop + for (auto &t : tanks_) { + if (!t.is_ai && t.ws == ws) { + t.alive = false; + t.ws = nullptr; + break; + } + } + bool anyHumanAlive = std::any_of(tanks_.begin(), tanks_.end(), + [](const Tank &t) { return !t.is_ai && t.alive; }); + if (!anyHumanAlive) { + endGameCoop(false, "все игроки отключились"); } } -void Game::endGame(int winnerIndex) { +void Game::endGamePvp(int winnerIndex) { finished_ = true; if (timer_) { us_timer_close(timer_); timer_ = nullptr; } - for (int i = 0; i < 2; i++) { - std::string result = (winnerIndex == -1) ? "draw" : (i == winnerIndex ? "win" : "lose"); + for (size_t i = 0; i < tanks_.size(); i++) { + std::string result = (winnerIndex == -1) ? "draw" : ((int)i == winnerIndex ? "win" : "lose"); std::string reason = (winnerIndex == -1) ? "оба танка уничтожены одновременно" - : (i == winnerIndex) ? "противник уничтожен" - : "танк уничтожен"; + : ((int)i == winnerIndex) ? "противник уничтожен" + : "танк уничтожен"; sendTo(tanks_[i].ws, "game.over", {{"result", result}, {"reason", reason}}); } @@ -400,3 +604,21 @@ void Game::endGame(int winnerIndex) { onFinished_(); } } + +void Game::endGameCoop(bool win, const std::string &reason) { + finished_ = true; + if (timer_) { + us_timer_close(timer_); + timer_ = nullptr; + } + + for (const auto &t : tanks_) { + if (!t.is_ai && t.ws) { + sendTo(t.ws, "game.over", {{"result", win ? "win" : "lose"}, {"reason", reason}}); + } + } + + if (onFinished_) { + onFinished_(); + } +} diff --git a/gameserver/src/Game.hpp b/gameserver/src/Game.hpp index 560537e..af463a6 100644 --- a/gameserver/src/Game.hpp +++ b/gameserver/src/Game.hpp @@ -2,72 +2,97 @@ #include "Lobby.hpp" -#include #include +#include #include #include struct us_timer_t; -// Authoritative-игра для одной комнаты в режиме pvp (этап 4 из TZ.md). -// Coop с AI — этап 5, наследует эту же тик-логику. +// Authoritative-игра для одной комнаты: pvp (этап 4) и coop с AI (этап 5). class Game { public: static constexpr int kTickMs = 50; // 20 Гц static constexpr int kMapWidth = 15; static constexpr int kMapHeight = 15; static constexpr int kStartLives = 3; + static constexpr int kMaxEnemiesTotal = 8; // размер волны в coop + static constexpr int kMaxConcurrentEnemies = 3; + static constexpr int kEnemySpawnCooldownTicks = 60; // 3с enum Tile { kEmpty = 0, kBrick = 1, kSteel = 2 }; enum class Dir { Up, Right, Down, Left }; struct Tank { - std::string player_id; - WS *ws; + std::string id; // player_id для людей, "ai-N" для AI + WS *ws = nullptr; // nullptr для AI + bool is_ai = false; double x, y; Dir dir = Dir::Up; int lives = kStartLives; int fire_cooldown = 0; bool up = false, down = false, left = false, right = false, fire = false; bool alive = true; + // состояние простого AI (используется только если is_ai) + Dir ai_dir = Dir::Down; + int ai_redecide_ticks = 0; }; struct Bullet { int id; std::string owner_id; + bool owner_is_ai; double x, y; Dir dir; }; - // room.players.size() должен быть == 2 на момент создания. + struct Base { + double x, y; + bool alive = true; + }; + + // room.players.size() — 2 для pvp, 1 или 2 для coop. Game(Room &room, std::function onFinished); ~Game(); void start(); void handleInput(const std::string &player_id, const json &payload); - // Технический проигрыш отключившегося — второй игрок побеждает. + // pvp: технический проигрыш отключившегося, второй побеждает. + // coop: игрок выбывает; если людей не осталось — поражение. void handleDisconnect(WS *ws); json buildStartPayload(const std::string &mode) const; private: + std::string mode_; std::vector> map_; - std::array tanks_; + std::vector tanks_; // сначала люди, затем (в coop) появляющиеся AI std::vector bullets_; + std::optional base_; + std::vector> enemy_spawn_points_; + int enemies_spawned_total_ = 0; + int enemy_spawn_cooldown_ = 0; int next_bullet_id_ = 1; + int next_enemy_id_ = 1; int tick_count_ = 0; bool finished_ = false; us_timer_t *timer_ = nullptr; std::function onFinished_; - void buildMap(); + void buildPvpMap(); + void buildCoopMap(); void tick(); void applyInput(Tank &tank); + void updateAi(Tank &tank); + void spawnEnemiesIfNeeded(); void updateBullets(); bool tileSolid(int tx, int ty) const; bool rectHitsSolid(double x, double y) const; bool rectHitsTank(double x, double y, const Tank &self) const; + bool rectHitsBase(double x, double y, double half) const; void fireBullet(Tank &tank); - void endGame(int winnerIndex); // -1 = ничья + void endGamePvp(int winnerIndex); // -1 = ничья + void endGameCoop(bool win, const std::string &reason); + void checkCoopEndConditions(); void broadcastState(); void sendTo(WS *ws, const std::string &type, json payload); diff --git a/gameserver/src/Lobby.cpp b/gameserver/src/Lobby.cpp index 65814ad..fff334c 100644 --- a/gameserver/src/Lobby.cpp +++ b/gameserver/src/Lobby.cpp @@ -250,7 +250,11 @@ void Lobby::maybeStartGame(const std::string &room_id) { return; } Room &room = it->second; - if (room.mode != "pvp" || room.state != "waiting" || room.players.size() != kMaxPlayersPerRoom) { + if (room.state != "waiting" || room.players.empty()) { + return; + } + // pvp — строго вдвоём; coop — можно соло, второй подключается позже. + if (room.mode == "pvp" && room.players.size() != kMaxPlayersPerRoom) { return; } bool allReady = std::all_of(room.players.begin(), room.players.end(), diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1c08e14..87b33b5 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -5,7 +5,7 @@ server { index index.html; location / { - try_files $uri $uri/ =404; + try_files $uri $uri.html $uri/ =404; } # без хешей в именах файлов — просим браузер всегда переспрашивать свежесть From f2a565774001988b2dc9921c82f4e2931675bdf1 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Thu, 6 Aug 2026 12:42:52 +0500 Subject: [PATCH 3/3] =?UTF-8?q?=D0=97=D0=B2=D1=83=D0=BA=20(Web=20Audio=20A?= =?UTF-8?q?PI)=20+=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=20coop-=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиент: audio.js синтезирует эффекты без внешних файлов (выстрел, взрыв, разрушение кирпича, победа/поражение) — вписывается в "чистый HTML/CSS/JS" стек. AudioContext разблокируется по клику "готов" (браузеры блокируют автовоспроизведение без жеста). Сервер: 3 варианта раскладки coop-карты (случайный выбор при старте матча) вместо одной фиксированной. Fix: сервер разрушал кирпичи в своей копии карты, но никогда не сообщал об этом клиенту — стена оставалась видимой навсегда, хотя сквозь неё уже можно было стрелять. Теперь game.state включает destroyed_tiles, клиент убирает соответствующий спрайт стены. --- frontend/js/game/audio.js | 76 ++++++++++++++++++++++++++++++++++++++ frontend/js/game/battle.js | 45 ++++++++++++++++++---- frontend/js/game/lobby.js | 2 + gameserver/src/Game.cpp | 39 ++++++++++++++++--- gameserver/src/Game.hpp | 1 + 5 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 frontend/js/game/audio.js diff --git a/frontend/js/game/audio.js b/frontend/js/game/audio.js new file mode 100644 index 0000000..e0da203 --- /dev/null +++ b/frontend/js/game/audio.js @@ -0,0 +1,76 @@ +// Простой синтез звука через Web Audio API — без внешних аудиофайлов. +let ctx = null; + +function ensureCtx() { + if (!ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + ctx = new AudioCtx(); + } + if (ctx.state === 'suspended') { + ctx.resume(); + } + return ctx; +} + +// Вызывать из обработчика реального пользовательского клика/нажатия — +// браузеры блокируют автозапуск звука без явного жеста. +export function unlockAudio() { + ensureCtx(); +} + +function beep({ freq, duration = 0.08, type = 'square', gain = 0.1, freqEnd = null }) { + const c = ensureCtx(); + const osc = c.createOscillator(); + const g = c.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, c.currentTime); + if (freqEnd !== null) { + osc.frequency.linearRampToValueAtTime(freqEnd, c.currentTime + duration); + } + g.gain.setValueAtTime(gain, c.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + duration); + osc.connect(g).connect(c.destination); + osc.start(); + osc.stop(c.currentTime + duration); +} + +function noiseBurst({ duration = 0.2, gain = 0.18 } = {}) { + const c = ensureCtx(); + const size = Math.max(1, Math.floor(c.sampleRate * duration)); + const buffer = c.createBuffer(1, size, c.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < size; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / size); + } + const source = c.createBufferSource(); + source.buffer = buffer; + const g = c.createGain(); + g.gain.setValueAtTime(gain, c.currentTime); + source.connect(g).connect(c.destination); + source.start(); +} + +export function playShot() { + beep({ freq: 660, freqEnd: 220, duration: 0.06, type: 'square', gain: 0.08 }); +} + +export function playWallHit() { + noiseBurst({ duration: 0.08, gain: 0.1 }); +} + +export function playExplosion() { + noiseBurst({ duration: 0.25, gain: 0.18 }); + beep({ freq: 120, freqEnd: 40, duration: 0.25, type: 'sawtooth', gain: 0.1 }); +} + +export function playWin() { + [523, 659, 784, 1047].forEach((freq, i) => + setTimeout(() => beep({ freq, duration: 0.15, type: 'square', gain: 0.1 }), i * 110) + ); +} + +export function playLose() { + [392, 330, 262, 196].forEach((freq, i) => + setTimeout(() => beep({ freq, duration: 0.2, type: 'sawtooth', gain: 0.1 }), i * 130) + ); +} diff --git a/frontend/js/game/battle.js b/frontend/js/game/battle.js index 53196dc..cb01449 100644 --- a/frontend/js/game/battle.js +++ b/frontend/js/game/battle.js @@ -1,5 +1,6 @@ import { ClientMessage, ServerMessage } from './protocol.js'; import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js'; +import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js'; const TILE_PX = 32; // Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform) @@ -13,6 +14,7 @@ let myPlayerId = null; let stage = null; let tankSprites = new Map(); let bulletSprites = new Map(); +let wallSprites = new Map(); // "x,y" (тайл) -> спрайт, для синка разрушенных стен let baseSprite = null; let lastSentInput = null; let seq = 0; @@ -133,13 +135,16 @@ function buildMap(map) { if (tile === 0) { continue; } - stage.insert( - new Q.Wall({ - x: x * TILE_PX + TILE_PX / 2, - y: y * TILE_PX + TILE_PX / 2, - tile, - }) - ); + const sprite = new Q.Wall({ + x: x * TILE_PX + TILE_PX / 2, + y: y * TILE_PX + TILE_PX / 2, + tile, + }); + stage.insert(sprite); + if (tile === 1) { + // только кирпич может быть разрушен — только его отслеживаем + wallSprites.set(`${x},${y}`, sprite); + } } } } @@ -200,6 +205,8 @@ function onState(payload) { }); stage.insert(sprite); tankSprites.set(t.id, sprite); + } else if (!sprite.p.hidden && !t.alive) { + playExplosion(); } sprite.p.x = t.x * TILE_PX; sprite.p.y = t.y * TILE_PX; @@ -221,6 +228,7 @@ function onState(payload) { sprite = new Q.Bullet({ x: b.x * TILE_PX, y: b.y * TILE_PX }); stage.insert(sprite); bulletSprites.set(b.id, sprite); + playShot(); } sprite.p.x = b.x * TILE_PX; sprite.p.y = b.y * TILE_PX; @@ -232,6 +240,20 @@ function onState(payload) { } } + if (payload.destroyed_tiles) { + for (const [tx, ty] of payload.destroyed_tiles) { + const key = `${tx},${ty}`; + const sprite = wallSprites.get(key); + if (sprite) { + sprite.destroy(); + wallSprites.delete(key); + } + } + if (payload.destroyed_tiles.length > 0) { + playWallHit(); + } + } + if (payload.base) { if (!baseSprite) { baseSprite = new Q.Base({ @@ -241,6 +263,9 @@ function onState(payload) { }); stage.insert(baseSprite); } else { + if (baseSprite.p.alive && !payload.base.alive) { + playExplosion(); + } baseSprite.p.alive = payload.base.alive; } } @@ -248,6 +273,11 @@ function onState(payload) { function onOver(payload) { polling = false; + if (payload.result === 'win') { + playWin(); + } else { + playLose(); + } const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' }; const title = titles[payload.result] || payload.result.toUpperCase(); const overlay = document.getElementById('battle-result-overlay'); @@ -309,6 +339,7 @@ export function startBattle(networkInstance, payload, ownPlayerId) { Q.stageScene('battle', 0); tankSprites = new Map(); bulletSprites = new Map(); + wallSprites = new Map(); baseSprite = null; buildMap(payload.map); diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js index 5d86eb9..f0a096f 100644 --- a/frontend/js/game/lobby.js +++ b/frontend/js/game/lobby.js @@ -1,6 +1,7 @@ import { Network, ServerMessage } from './network.js'; import { ClientMessage } from './protocol.js'; import { startBattle } from './battle.js'; +import { unlockAudio } from './audio.js'; const container = document.getElementById('lobby'); @@ -106,6 +107,7 @@ container?.addEventListener('click', (e) => { net.joinRoom(btn.dataset.roomId); break; case 'ready': + unlockAudio(); net.send(ClientMessage.GAME_READY); break; case 'leave': diff --git a/gameserver/src/Game.cpp b/gameserver/src/Game.cpp index 65fdb30..6551cd9 100644 --- a/gameserver/src/Game.cpp +++ b/gameserver/src/Game.cpp @@ -88,7 +88,7 @@ void Game::buildCoopMap() { } } - // немного укрытий по полю + // немного укрытий по полю — несколько вариантов раскладки для разнообразия auto block = [this](int x, int y, int w, int h, int tile) { for (int dy = 0; dy < h; dy++) { for (int dx = 0; dx < w; dx++) { @@ -96,11 +96,29 @@ void Game::buildCoopMap() { } } }; - block(2, 5, 2, 2, kBrick); - block(11, 5, 2, 2, kBrick); - block(6, 4, 3, 1, kSteel); - block(4, 8, 2, 2, kBrick); - block(9, 8, 2, 2, kBrick); + switch (std::rand() % 3) { + case 0: + block(2, 5, 2, 2, kBrick); + block(11, 5, 2, 2, kBrick); + block(6, 4, 3, 1, kSteel); + block(4, 8, 2, 2, kBrick); + block(9, 8, 2, 2, kBrick); + break; + case 1: + block(1, 4, 3, 1, kSteel); + block(11, 4, 3, 1, kSteel); + block(6, 6, 3, 3, kBrick); + block(2, 9, 2, 2, kBrick); + block(11, 9, 2, 2, kBrick); + break; + default: + block(4, 3, 1, 4, kBrick); + block(10, 3, 1, 4, kBrick); + block(6, 5, 3, 2, kSteel); + block(3, 9, 3, 1, kBrick); + block(9, 9, 3, 1, kBrick); + break; + } // база внизу по центру, укрыта кирпичом с трёх сторон int bx = kMapWidth / 2; @@ -320,6 +338,7 @@ void Game::fireBullet(Tank &tank) { } void Game::updateBullets() { + destroyed_tiles_.clear(); double step = kBulletSpeed * kDt; for (auto &b : bullets_) { @@ -361,6 +380,7 @@ void Game::updateBullets() { } if (map_[ty][tx] == kBrick) { map_[ty][tx] = kEmpty; + destroyed_tiles_.push_back({tx, ty}); dead[i] = true; continue; } @@ -518,6 +538,13 @@ void Game::broadcastState() { if (base_) { payload["base"] = {{"x", base_->x}, {"y", base_->y}, {"alive", base_->alive}}; } + if (!destroyed_tiles_.empty()) { + json destroyed = json::array(); + for (const auto &[tx, ty] : destroyed_tiles_) { + destroyed.push_back({tx, ty}); + } + payload["destroyed_tiles"] = destroyed; + } for (const auto &t : tanks_) { if (!t.is_ai) { sendTo(t.ws, "game.state", payload); diff --git a/gameserver/src/Game.hpp b/gameserver/src/Game.hpp index af463a6..f8641a3 100644 --- a/gameserver/src/Game.hpp +++ b/gameserver/src/Game.hpp @@ -69,6 +69,7 @@ private: std::vector bullets_; std::optional base_; std::vector> enemy_spawn_points_; + std::vector> destroyed_tiles_; // за последний тик — для звука/синка стен на клиенте int enemies_spawned_total_ = 0; int enemy_spawn_cooldown_ = 0; int next_bullet_id_ = 1;