ae239f4c51
Card design: - Separate cardFront + cardBack (each own/equip independently) - Fronts: classic (free), ivory/rosegold (buy), parchment/mint (earned) - Backs: classic (free), sapphire/emerald (buy), ruby/royal (earned) - PlayingCard `front` prop; table applies front to all faces, back to opponents - Profile has front + back pickers; shop has both sections Audio: - Web Audio synth engine (no asset files): SFX for card/deal/trump/trick, win/lose, message, notify, award, levelup, purchase, kot + ambient music - Toggles in profile (Audio) + mute button in game HUD; prefs persisted - Wired across game-store, rewards, daily, shop, chat Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
// Procedural sound engine (Web Audio) — no audio files.
|
|
// Synthesizes UI/game SFX and a gentle ambient background loop.
|
|
|
|
export type Sfx =
|
|
| "click"
|
|
| "cardPlay"
|
|
| "deal"
|
|
| "trump"
|
|
| "trickWin"
|
|
| "win"
|
|
| "lose"
|
|
| "message"
|
|
| "notify"
|
|
| "award"
|
|
| "levelUp"
|
|
| "purchase"
|
|
| "kot";
|
|
|
|
const LS_SFX = "hokm.sfx";
|
|
const LS_MUSIC = "hokm.music";
|
|
|
|
function loadBool(key: string, def = true): boolean {
|
|
if (typeof window === "undefined") return def;
|
|
const v = localStorage.getItem(key);
|
|
return v == null ? def : v === "1";
|
|
}
|
|
|
|
class SoundManager {
|
|
private ctx: AudioContext | null = null;
|
|
private master: GainNode | null = null;
|
|
private musicGain: GainNode | null = null;
|
|
private musicTimer: ReturnType<typeof setInterval> | null = null;
|
|
private step = 0;
|
|
|
|
sfxEnabled = loadBool(LS_SFX);
|
|
musicEnabled = loadBool(LS_MUSIC);
|
|
|
|
/** Must be called from a user gesture to unlock audio. */
|
|
init() {
|
|
if (typeof window === "undefined") return;
|
|
if (!this.ctx) {
|
|
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
if (!AC) return;
|
|
this.ctx = new AC();
|
|
this.master = this.ctx.createGain();
|
|
this.master.gain.value = 0.5;
|
|
this.master.connect(this.ctx.destination);
|
|
this.musicGain = this.ctx.createGain();
|
|
this.musicGain.gain.value = 0.12;
|
|
this.musicGain.connect(this.master);
|
|
}
|
|
if (this.ctx.state === "suspended") void this.ctx.resume();
|
|
if (this.musicEnabled) this.startMusic();
|
|
}
|
|
|
|
setSfxEnabled(b: boolean) {
|
|
this.sfxEnabled = b;
|
|
if (typeof window !== "undefined") localStorage.setItem(LS_SFX, b ? "1" : "0");
|
|
if (b) this.init();
|
|
}
|
|
|
|
setMusicEnabled(b: boolean) {
|
|
this.musicEnabled = b;
|
|
if (typeof window !== "undefined") localStorage.setItem(LS_MUSIC, b ? "1" : "0");
|
|
if (b) {
|
|
this.init();
|
|
this.startMusic();
|
|
} else {
|
|
this.stopMusic();
|
|
}
|
|
}
|
|
|
|
private tone(
|
|
freq: number,
|
|
start: number,
|
|
dur: number,
|
|
opts: { type?: OscillatorType; gain?: number; to?: number } = {}
|
|
) {
|
|
if (!this.ctx || !this.master) return;
|
|
const osc = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
osc.type = opts.type ?? "sine";
|
|
osc.frequency.setValueAtTime(freq, start);
|
|
if (opts.to) osc.frequency.exponentialRampToValueAtTime(opts.to, start + dur);
|
|
const peak = opts.gain ?? 0.3;
|
|
g.gain.setValueAtTime(0.0001, start);
|
|
g.gain.exponentialRampToValueAtTime(peak, start + 0.012);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
|
osc.connect(g);
|
|
g.connect(this.master);
|
|
osc.start(start);
|
|
osc.stop(start + dur + 0.02);
|
|
}
|
|
|
|
private seq(notes: [number, number][], gap = 0.11, opts?: { type?: OscillatorType; gain?: number }) {
|
|
if (!this.ctx) return;
|
|
const t0 = this.ctx.currentTime;
|
|
notes.forEach(([freq, dur], i) => this.tone(freq, t0 + i * gap, dur, opts));
|
|
}
|
|
|
|
play(name: Sfx) {
|
|
if (!this.sfxEnabled) return;
|
|
this.init();
|
|
if (!this.ctx) return;
|
|
const t = this.ctx.currentTime;
|
|
switch (name) {
|
|
case "click":
|
|
this.tone(520, t, 0.06, { type: "square", gain: 0.12 });
|
|
break;
|
|
case "cardPlay":
|
|
this.tone(360, t, 0.09, { type: "triangle", gain: 0.18, to: 220 });
|
|
break;
|
|
case "deal":
|
|
for (let i = 0; i < 4; i++)
|
|
this.tone(320 + i * 20, t + i * 0.08, 0.07, { type: "triangle", gain: 0.14, to: 200 });
|
|
break;
|
|
case "trump":
|
|
this.seq([[440, 0.12], [660, 0.18]], 0.1, { type: "sine", gain: 0.25 });
|
|
break;
|
|
case "trickWin":
|
|
this.seq([[880, 0.12], [1320, 0.16]], 0.08, { type: "sine", gain: 0.22 });
|
|
break;
|
|
case "win":
|
|
this.seq([[523, 0.16], [659, 0.16], [784, 0.16], [1047, 0.4]], 0.13, { type: "sine", gain: 0.3 });
|
|
break;
|
|
case "lose":
|
|
this.seq([[440, 0.2], [392, 0.2], [311, 0.45]], 0.16, { type: "triangle", gain: 0.25 });
|
|
break;
|
|
case "message":
|
|
this.tone(720, t, 0.1, { type: "sine", gain: 0.2, to: 900 });
|
|
break;
|
|
case "notify":
|
|
this.seq([[660, 0.12], [880, 0.14]], 0.1, { type: "sine", gain: 0.2 });
|
|
break;
|
|
case "award":
|
|
this.seq([[784, 0.1], [988, 0.1], [1319, 0.25]], 0.09, { type: "sine", gain: 0.25 });
|
|
break;
|
|
case "levelUp":
|
|
this.seq([[523, 0.1], [659, 0.1], [784, 0.1], [988, 0.1], [1319, 0.35]], 0.09, { type: "sine", gain: 0.28 });
|
|
break;
|
|
case "purchase":
|
|
this.seq([[1047, 0.08], [1319, 0.12]], 0.07, { type: "square", gain: 0.16 });
|
|
break;
|
|
case "kot":
|
|
this.seq([[330, 0.14], [262, 0.14], [196, 0.4]], 0.12, { type: "sawtooth", gain: 0.2 });
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Gentle ambient loop on a Persian-flavored scale (Dastgah-ish).
|
|
private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66];
|
|
|
|
startMusic() {
|
|
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
|
|
const playNote = () => {
|
|
if (!this.ctx || !this.musicGain) return;
|
|
const freq = this.MUSIC[this.step % this.MUSIC.length];
|
|
this.step++;
|
|
const osc = this.ctx.createOscillator();
|
|
const g = this.ctx.createGain();
|
|
const t = this.ctx.currentTime;
|
|
osc.type = "sine";
|
|
osc.frequency.value = freq;
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(0.5, t + 0.3);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6);
|
|
osc.connect(g);
|
|
g.connect(this.musicGain);
|
|
osc.start(t);
|
|
osc.stop(t + 1.7);
|
|
// soft fifth harmony every other note
|
|
if (this.step % 2 === 0) {
|
|
const o2 = this.ctx.createOscillator();
|
|
const g2 = this.ctx.createGain();
|
|
o2.type = "sine";
|
|
o2.frequency.value = freq * 1.5;
|
|
g2.gain.setValueAtTime(0.0001, t);
|
|
g2.gain.exponentialRampToValueAtTime(0.22, t + 0.3);
|
|
g2.gain.exponentialRampToValueAtTime(0.0001, t + 1.4);
|
|
o2.connect(g2);
|
|
g2.connect(this.musicGain);
|
|
o2.start(t);
|
|
o2.stop(t + 1.5);
|
|
}
|
|
};
|
|
playNote();
|
|
this.musicTimer = setInterval(playNote, 900);
|
|
}
|
|
|
|
stopMusic() {
|
|
if (this.musicTimer) {
|
|
clearInterval(this.musicTimer);
|
|
this.musicTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const sound = new SoundManager();
|