Слияние feature/sound-and-maps
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
+28
-1
@@ -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() {
|
||||
}
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -69,6 +69,7 @@ private:
|
||||
std::vector<Bullet> bullets_;
|
||||
std::optional<Base> base_;
|
||||
std::vector<std::pair<double, double>> enemy_spawn_points_;
|
||||
std::vector<std::pair<int, int>> destroyed_tiles_; // за последний тик — для звука/синка стен на клиенте
|
||||
int enemies_spawned_total_ = 0;
|
||||
int enemy_spawn_cooldown_ = 0;
|
||||
int next_bullet_id_ = 1;
|
||||
|
||||
Reference in New Issue
Block a user