Слияние feature/sound-and-maps

This commit is contained in:
2026-08-06 12:43:06 +05:00
5 changed files with 150 additions and 13 deletions
+76
View File
@@ -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)
);
}
+35 -4
View File
@@ -1,5 +1,6 @@
import { ClientMessage, ServerMessage } from './protocol.js'; import { ClientMessage, ServerMessage } from './protocol.js';
import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js'; import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js';
import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js';
const TILE_PX = 32; const TILE_PX = 32;
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform) // Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
@@ -13,6 +14,7 @@ let myPlayerId = null;
let stage = null; let stage = null;
let tankSprites = new Map(); let tankSprites = new Map();
let bulletSprites = new Map(); let bulletSprites = new Map();
let wallSprites = new Map(); // "x,y" (тайл) -> спрайт, для синка разрушенных стен
let baseSprite = null; let baseSprite = null;
let lastSentInput = null; let lastSentInput = null;
let seq = 0; let seq = 0;
@@ -133,13 +135,16 @@ function buildMap(map) {
if (tile === 0) { if (tile === 0) {
continue; continue;
} }
stage.insert( const sprite = new Q.Wall({
new Q.Wall({
x: x * TILE_PX + TILE_PX / 2, x: x * TILE_PX + TILE_PX / 2,
y: y * TILE_PX + TILE_PX / 2, y: y * TILE_PX + TILE_PX / 2,
tile, tile,
}) });
); stage.insert(sprite);
if (tile === 1) {
// только кирпич может быть разрушен — только его отслеживаем
wallSprites.set(`${x},${y}`, sprite);
}
} }
} }
} }
@@ -200,6 +205,8 @@ function onState(payload) {
}); });
stage.insert(sprite); stage.insert(sprite);
tankSprites.set(t.id, sprite); tankSprites.set(t.id, sprite);
} else if (!sprite.p.hidden && !t.alive) {
playExplosion();
} }
sprite.p.x = t.x * TILE_PX; sprite.p.x = t.x * TILE_PX;
sprite.p.y = t.y * 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 }); sprite = new Q.Bullet({ x: b.x * TILE_PX, y: b.y * TILE_PX });
stage.insert(sprite); stage.insert(sprite);
bulletSprites.set(b.id, sprite); bulletSprites.set(b.id, sprite);
playShot();
} }
sprite.p.x = b.x * TILE_PX; sprite.p.x = b.x * TILE_PX;
sprite.p.y = b.y * 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 (payload.base) {
if (!baseSprite) { if (!baseSprite) {
baseSprite = new Q.Base({ baseSprite = new Q.Base({
@@ -241,6 +263,9 @@ function onState(payload) {
}); });
stage.insert(baseSprite); stage.insert(baseSprite);
} else { } else {
if (baseSprite.p.alive && !payload.base.alive) {
playExplosion();
}
baseSprite.p.alive = payload.base.alive; baseSprite.p.alive = payload.base.alive;
} }
} }
@@ -248,6 +273,11 @@ function onState(payload) {
function onOver(payload) { function onOver(payload) {
polling = false; polling = false;
if (payload.result === 'win') {
playWin();
} else {
playLose();
}
const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' }; const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' };
const title = titles[payload.result] || payload.result.toUpperCase(); const title = titles[payload.result] || payload.result.toUpperCase();
const overlay = document.getElementById('battle-result-overlay'); const overlay = document.getElementById('battle-result-overlay');
@@ -309,6 +339,7 @@ export function startBattle(networkInstance, payload, ownPlayerId) {
Q.stageScene('battle', 0); Q.stageScene('battle', 0);
tankSprites = new Map(); tankSprites = new Map();
bulletSprites = new Map(); bulletSprites = new Map();
wallSprites = new Map();
baseSprite = null; baseSprite = null;
buildMap(payload.map); buildMap(payload.map);
+2
View File
@@ -1,6 +1,7 @@
import { Network, ServerMessage } from './network.js'; import { Network, ServerMessage } from './network.js';
import { ClientMessage } from './protocol.js'; import { ClientMessage } from './protocol.js';
import { startBattle } from './battle.js'; import { startBattle } from './battle.js';
import { unlockAudio } from './audio.js';
const container = document.getElementById('lobby'); const container = document.getElementById('lobby');
@@ -106,6 +107,7 @@ container?.addEventListener('click', (e) => {
net.joinRoom(btn.dataset.roomId); net.joinRoom(btn.dataset.roomId);
break; break;
case 'ready': case 'ready':
unlockAudio();
net.send(ClientMessage.GAME_READY); net.send(ClientMessage.GAME_READY);
break; break;
case 'leave': case 'leave':
+28 -1
View File
@@ -88,7 +88,7 @@ void Game::buildCoopMap() {
} }
} }
// немного укрытий по полю // немного укрытий по полю — несколько вариантов раскладки для разнообразия
auto block = [this](int x, int y, int w, int h, int tile) { auto block = [this](int x, int y, int w, int h, int tile) {
for (int dy = 0; dy < h; dy++) { for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) { for (int dx = 0; dx < w; dx++) {
@@ -96,11 +96,29 @@ void Game::buildCoopMap() {
} }
} }
}; };
switch (std::rand() % 3) {
case 0:
block(2, 5, 2, 2, kBrick); block(2, 5, 2, 2, kBrick);
block(11, 5, 2, 2, kBrick); block(11, 5, 2, 2, kBrick);
block(6, 4, 3, 1, kSteel); block(6, 4, 3, 1, kSteel);
block(4, 8, 2, 2, kBrick); block(4, 8, 2, 2, kBrick);
block(9, 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; int bx = kMapWidth / 2;
@@ -320,6 +338,7 @@ void Game::fireBullet(Tank &tank) {
} }
void Game::updateBullets() { void Game::updateBullets() {
destroyed_tiles_.clear();
double step = kBulletSpeed * kDt; double step = kBulletSpeed * kDt;
for (auto &b : bullets_) { for (auto &b : bullets_) {
@@ -361,6 +380,7 @@ void Game::updateBullets() {
} }
if (map_[ty][tx] == kBrick) { if (map_[ty][tx] == kBrick) {
map_[ty][tx] = kEmpty; map_[ty][tx] = kEmpty;
destroyed_tiles_.push_back({tx, ty});
dead[i] = true; dead[i] = true;
continue; continue;
} }
@@ -518,6 +538,13 @@ void Game::broadcastState() {
if (base_) { if (base_) {
payload["base"] = {{"x", base_->x}, {"y", base_->y}, {"alive", base_->alive}}; 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_) { for (const auto &t : tanks_) {
if (!t.is_ai) { if (!t.is_ai) {
sendTo(t.ws, "game.state", payload); sendTo(t.ws, "game.state", payload);
+1
View File
@@ -69,6 +69,7 @@ private:
std::vector<Bullet> bullets_; std::vector<Bullet> bullets_;
std::optional<Base> base_; std::optional<Base> base_;
std::vector<std::pair<double, double>> enemy_spawn_points_; std::vector<std::pair<double, double>> enemy_spawn_points_;
std::vector<std::pair<int, int>> destroyed_tiles_; // за последний тик — для звука/синка стен на клиенте
int enemies_spawned_total_ = 0; int enemies_spawned_total_ = 0;
int enemy_spawn_cooldown_ = 0; int enemy_spawn_cooldown_ = 0;
int next_bullet_id_ = 1; int next_bullet_id_ = 1;