From f2a565774001988b2dc9921c82f4e2931675bdf1 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Thu, 6 Aug 2026 12:42:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B2=D1=83=D0=BA=20(Web=20Audio=20API)?= =?UTF-8?q?=20+=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20coo?= =?UTF-8?q?p-=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;