2026-06-10 14:26:58 +02:00
|
|
|
// Procedural sound effects — pure WebAudio synthesis, no asset files.
|
|
|
|
|
let ac = null, master = null, muted = false;
|
|
|
|
|
|
|
|
|
|
export function initAudio() {
|
|
|
|
|
if (!ac) {
|
|
|
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
|
|
|
if (!AC) return;
|
|
|
|
|
ac = new AC();
|
|
|
|
|
master = ac.createGain();
|
|
|
|
|
master.gain.value = 0.35;
|
|
|
|
|
master.connect(ac.destination);
|
|
|
|
|
}
|
|
|
|
|
if (ac.state === 'suspended') ac.resume();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function toggleMute() { muted = !muted; return muted; }
|
|
|
|
|
|
|
|
|
|
function tone(freq, dur, type = 'square', vol = 0.18, slide = 0) {
|
|
|
|
|
const t = ac.currentTime;
|
|
|
|
|
const o = ac.createOscillator(), g = ac.createGain();
|
|
|
|
|
o.type = type;
|
|
|
|
|
o.frequency.setValueAtTime(freq, t);
|
|
|
|
|
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur);
|
|
|
|
|
g.gain.setValueAtTime(vol, t);
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
|
|
|
|
o.connect(g); g.connect(master);
|
|
|
|
|
o.start(t); o.stop(t + dur + 0.02);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noise(dur, vol = 0.25, filterFreq = 1200, q = 1) {
|
|
|
|
|
const t = ac.currentTime;
|
|
|
|
|
const len = Math.max(1, Math.floor(ac.sampleRate * dur));
|
|
|
|
|
const buf = ac.createBuffer(1, len, ac.sampleRate);
|
|
|
|
|
const d = buf.getChannelData(0);
|
|
|
|
|
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
|
|
|
|
|
const src = ac.createBufferSource();
|
|
|
|
|
src.buffer = buf;
|
|
|
|
|
const f = ac.createBiquadFilter();
|
|
|
|
|
f.type = 'lowpass'; f.frequency.value = filterFreq; f.Q.value = q;
|
|
|
|
|
const g = ac.createGain();
|
|
|
|
|
g.gain.setValueAtTime(vol, t);
|
|
|
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
|
|
|
|
src.connect(f); f.connect(g); g.connect(master);
|
|
|
|
|
src.start(t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sfx(name) {
|
|
|
|
|
if (!ac || muted) return;
|
|
|
|
|
switch (name) {
|
|
|
|
|
case 'jump': tone(300, 0.12, 'square', 0.12, 250); break;
|
|
|
|
|
case 'airjump': tone(380, 0.1, 'square', 0.1, 280); break;
|
|
|
|
|
case 'land': noise(0.08, 0.12, 500); break;
|
|
|
|
|
case 'bark': tone(170, 0.07, 'sawtooth', 0.2, -60); tone(340, 0.05, 'square', 0.08, -100); break;
|
|
|
|
|
case 'hit': noise(0.12, 0.3, 900); tone(110, 0.15, 'triangle', 0.25, -50); break;
|
|
|
|
|
case 'block': tone(520, 0.1, 'sine', 0.15, 100); break;
|
|
|
|
|
case 'dash': noise(0.1, 0.12, 2500); tone(600, 0.08, 'sine', 0.06, 400); break;
|
|
|
|
|
case 'spike': tone(200, 0.2, 'sawtooth', 0.2, -140); break;
|
|
|
|
|
case 'saw': noise(0.15, 0.25, 1800, 4); break;
|
|
|
|
|
case 'pad': tone(180, 0.22, 'sine', 0.22, 380); break;
|
|
|
|
|
case 'mine': tone(900 + Math.random() * 300, 0.05, 'square', 0.1); break;
|
|
|
|
|
case 'break': tone(700, 0.07, 'square', 0.12, 300); tone(1050, 0.12, 'square', 0.1, 400); break;
|
|
|
|
|
case 'pickup': tone(660, 0.07, 'square', 0.12); tone(990, 0.1, 'square', 0.1); break;
|
|
|
|
|
case 'throw': noise(0.06, 0.1, 3000); break;
|
|
|
|
|
case 'boom': noise(0.5, 0.4, 350, 0.5); tone(60, 0.4, 'sine', 0.3, -25); break;
|
|
|
|
|
case 'shield': tone(440, 0.25, 'sine', 0.15, 220); break;
|
|
|
|
|
case 'boost': tone(330, 0.1, 'square', 0.12, 200); tone(500, 0.12, 'square', 0.1, 300); break;
|
|
|
|
|
case 'heal': tone(520, 0.12, 'sine', 0.12, 160); tone(780, 0.15, 'sine', 0.1, 100); break;
|
|
|
|
|
case 'grapple': tone(800, 0.06, 'square', 0.12, -300); break;
|
|
|
|
|
case 'whiff': tone(250, 0.08, 'sine', 0.08, -100); break;
|
|
|
|
|
case 'blink': tone(1000, 0.12, 'sine', 0.1, 500); break;
|
|
|
|
|
case 'jet': noise(0.07, 0.07, 700); break;
|
|
|
|
|
case 'boing': tone(140, 0.25, 'sine', 0.22, 320); break;
|
|
|
|
|
case 'charge': tone(120, 0.2, 'sawtooth', 0.18, 80); break;
|
|
|
|
|
case 'thud': noise(0.1, 0.25, 400); break;
|
|
|
|
|
case 'pound': noise(0.25, 0.35, 500); tone(70, 0.3, 'sine', 0.3, -20); break;
|
|
|
|
|
case 'ko': tone(500, 0.5, 'sawtooth', 0.25, -420); noise(0.4, 0.3, 800); break;
|
|
|
|
|
case 'respawn': tone(400, 0.15, 'sine', 0.12, 300); break;
|
|
|
|
|
case 'go': tone(523, 0.12, 'square', 0.15); tone(784, 0.25, 'square', 0.15); break;
|
|
|
|
|
case 'count': tone(440, 0.1, 'square', 0.12); break;
|
|
|
|
|
case 'over': tone(523, 0.15, 'square', 0.15); tone(659, 0.15, 'square', 0.15); tone(784, 0.3, 'square', 0.15); break;
|
|
|
|
|
case 'drop': tone(880, 0.1, 'sine', 0.08, -200); break;
|
feat: top-down 'Dogpit' arena mode (dota-like view)
Second game mode selectable at dog select (host-authoritative online):
- js/sim_arena.js: top-down sim, same export surface as sim.js — x/y plane
plus a z hop axis (Space / comma); KOs by knocking dogs into abyss pits
- js/mapgen_arena.js: mirrored symmetric arenas — walls, pits, center
clearing, guaranteed spawn-to-center corridors, crystals/spikes/launch
pads, mirrored saw pairs; fallback pit pair since pits are the only KO
mechanic
- specials re-imagined per dog: zoom dash, ram + hop-slam, zip-leash
slingshot, blink, hover (floats over pits/saws/spikes)
- render: arena tiles with wall shadows + void pits, top-down dog drawing
with z-lift over a ground shadow; new fall event/sfx
- B.HOP input bit so 'up' can mean north in top-down (Space also jumps in
side view); mode toggle UI + mode carried in start/rematch net messages
Tested: arena mapgen determinism/symmetry/connectivity + chaotic matches +
pit/hop scenarios headless; local chaos + full P2P arena match (host mode
flip syncs to client) in two headless browsers with zero page errors.
2026-06-10 15:23:22 +02:00
|
|
|
case 'fall': tone(420, 0.45, 'sawtooth', 0.2, -380); break;
|
2026-06-10 14:26:58 +02:00
|
|
|
case 'ui': tone(600, 0.05, 'square', 0.08); break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map sim events → sfx, with a per-frame cap so explosions don't clip.
|
|
|
|
|
const EVENT_SFX = {
|
|
|
|
|
jump: 'jump', land: 'land', bark: 'bark', hit: 'hit', block: 'block',
|
|
|
|
|
dash: 'dash', spike: 'spike', saw: 'saw', pad: 'pad', crack: 'mine',
|
|
|
|
|
tile: 'break', pickup: 'pickup', throw: 'throw', boom: 'boom',
|
|
|
|
|
shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple',
|
|
|
|
|
whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing',
|
|
|
|
|
charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko',
|
feat: top-down 'Dogpit' arena mode (dota-like view)
Second game mode selectable at dog select (host-authoritative online):
- js/sim_arena.js: top-down sim, same export surface as sim.js — x/y plane
plus a z hop axis (Space / comma); KOs by knocking dogs into abyss pits
- js/mapgen_arena.js: mirrored symmetric arenas — walls, pits, center
clearing, guaranteed spawn-to-center corridors, crystals/spikes/launch
pads, mirrored saw pairs; fallback pit pair since pits are the only KO
mechanic
- specials re-imagined per dog: zoom dash, ram + hop-slam, zip-leash
slingshot, blink, hover (floats over pits/saws/spikes)
- render: arena tiles with wall shadows + void pits, top-down dog drawing
with z-lift over a ground shadow; new fall event/sfx
- B.HOP input bit so 'up' can mean north in top-down (Space also jumps in
side view); mode toggle UI + mode carried in start/rematch net messages
Tested: arena mapgen determinism/symmetry/connectivity + chaotic matches +
pit/hop scenarios headless; local chaos + full P2P arena match (host mode
flip syncs to client) in two headless browsers with zero page errors.
2026-06-10 15:23:22 +02:00
|
|
|
respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', fall: 'fall',
|
2026-06-10 14:26:58 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function playEvents(events) {
|
|
|
|
|
let n = 0;
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
if (n >= 8) break;
|
|
|
|
|
let name = EVENT_SFX[ev.t];
|
|
|
|
|
if (ev.t === 'jump' && ev.air) name = 'airjump';
|
|
|
|
|
if (name) { sfx(name); n++; }
|
|
|
|
|
}
|
|
|
|
|
}
|