9901c5e6d4
- audio: default background music is now the santoor track (calm Persian), rebuilt as a real plucked-santoor loop — fast metallic attack, shimmer overtones, soft tonic drone, longer Dastgah-e-Shur phrase - site: marketing logo is now the app's card-fan icon (Logo.tsx + icon.svg); hero features the big logo with gold halo, floating suit motifs, and polished section dividers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
9.6 KiB
TypeScript
275 lines
9.6 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";
|
|
const LS_TRACK = "hokm.musicTrack";
|
|
|
|
export type MusicTrack = "santoor" | "playful";
|
|
|
|
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);
|
|
musicTrack: MusicTrack =
|
|
(typeof window !== "undefined" && (localStorage.getItem(LS_TRACK) as MusicTrack)) || "santoor";
|
|
|
|
/** 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();
|
|
}
|
|
}
|
|
|
|
/** Switch the background music style; restarts the loop if playing. */
|
|
setMusicTrack(track: MusicTrack) {
|
|
this.musicTrack = track;
|
|
if (typeof window !== "undefined") localStorage.setItem(LS_TRACK, track);
|
|
this.step = 0;
|
|
if (this.musicEnabled) {
|
|
this.stopMusic();
|
|
this.init();
|
|
this.startMusic();
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
/** Filtered noise burst with a downward sweep — a card "swish" / draw sound. */
|
|
private swish(start: number, dur = 0.13, opts: { gain?: number; from?: number; to?: number } = {}) {
|
|
if (!this.ctx || !this.master) return;
|
|
const ctx = this.ctx;
|
|
const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate);
|
|
const data = buf.getChannelData(0);
|
|
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = buf;
|
|
const bp = ctx.createBiquadFilter();
|
|
bp.type = "bandpass";
|
|
bp.Q.value = 0.9;
|
|
bp.frequency.setValueAtTime(opts.from ?? 3200, start);
|
|
bp.frequency.exponentialRampToValueAtTime(opts.to ?? 900, start + dur);
|
|
const g = ctx.createGain();
|
|
const peak = opts.gain ?? 0.28;
|
|
g.gain.setValueAtTime(0.0001, start);
|
|
g.gain.exponentialRampToValueAtTime(peak, start + 0.008);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
|
src.connect(bp);
|
|
bp.connect(g);
|
|
g.connect(this.master);
|
|
src.start(start);
|
|
src.stop(start + dur + 0.02);
|
|
}
|
|
|
|
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":
|
|
// Draw-card swish + a soft low tap as it lands on the felt.
|
|
this.swish(t, 0.13, { gain: 0.3, from: 3200, to: 800 });
|
|
this.tone(150, t + 0.08, 0.06, { type: "sine", gain: 0.12, to: 90 });
|
|
break;
|
|
case "deal":
|
|
// A flurry of card-draw swishes (dealing).
|
|
for (let i = 0; i < 4; i++)
|
|
this.swish(t + i * 0.1, 0.1, { gain: 0.22, from: 3400, to: 1000 });
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Two selectable loops:
|
|
// • santoor — calm Persian-flavored (Dastgah-ish) legato loop with fifth harmony.
|
|
// • playful — bouncy major-pentatonic staccato loop (UNO-like).
|
|
private TRACKS: Record<
|
|
MusicTrack,
|
|
{
|
|
notes: number[]; gap: number; type: OscillatorType; attack: number; dur: number;
|
|
peak: number; fifth: boolean;
|
|
shimmer?: boolean; // bright metallic overtones — santoor pluck character
|
|
bassHz?: number; // soft sustained tonic drone underneath
|
|
}
|
|
> = {
|
|
santoor: {
|
|
// Dastgah-e Shur on D — a calm phrase that rises then settles, looping
|
|
// seamlessly. Plucked (fast attack, long decay) with metallic shimmer.
|
|
notes: [
|
|
293.66, 349.23, 392.0, 440.0, 392.0, 349.23, 311.13, 293.66,
|
|
349.23, 392.0, 466.16, 440.0, 392.0, 349.23, 311.13, 293.66,
|
|
],
|
|
gap: 470, type: "triangle", attack: 0.004, dur: 1.15, peak: 0.4,
|
|
fifth: false, shimmer: true, bassHz: 73.42, // D2 drone
|
|
},
|
|
playful: {
|
|
notes: [523.25, 659.25, 784, 659.25, 587.33, 698.46, 880, 698.46, 587.33, 523.25],
|
|
gap: 360, type: "triangle", attack: 0.02, dur: 0.34, peak: 0.4, fifth: false,
|
|
},
|
|
};
|
|
|
|
/** Start the ambient loop for the current track (plays through musicGain so
|
|
* the in-game mute / music volume apply). Idempotent + needs an unlocked ctx. */
|
|
startMusic() {
|
|
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
|
|
const tick = () => {
|
|
if (!this.ctx || !this.musicGain) return;
|
|
const cfg = this.TRACKS[this.musicTrack];
|
|
const now = this.ctx.currentTime;
|
|
const idx = this.step % cfg.notes.length;
|
|
const freq = cfg.notes[idx];
|
|
this.step++;
|
|
const voice = (f: number, scale = 1, type = cfg.type, attack = cfg.attack, dur = cfg.dur) => {
|
|
const osc = this.ctx!.createOscillator();
|
|
const g = this.ctx!.createGain();
|
|
osc.type = type;
|
|
osc.frequency.value = f;
|
|
g.gain.setValueAtTime(0.0001, now);
|
|
g.gain.linearRampToValueAtTime(cfg.peak * scale, now + attack);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, now + dur);
|
|
osc.connect(g);
|
|
g.connect(this.musicGain!);
|
|
osc.start(now);
|
|
osc.stop(now + dur + 0.05);
|
|
};
|
|
voice(freq);
|
|
if (cfg.fifth) voice(freq * 1.5, 0.45); // soft fifth above
|
|
if (cfg.shimmer) {
|
|
// Metallic santoor overtones: a brighter octave + a faint third partial,
|
|
// both with quick decay so the pluck shimmers then fades.
|
|
voice(freq * 2, 0.16, "sine", 0.003, cfg.dur * 0.55);
|
|
voice(freq * 3, 0.06, "sine", 0.003, cfg.dur * 0.4);
|
|
}
|
|
// Soft sustained tonic drone re-struck once per phrase half.
|
|
if (cfg.bassHz && idx % (cfg.notes.length / 2) === 0) {
|
|
const span = (cfg.gap / 1000) * (cfg.notes.length / 2);
|
|
voice(cfg.bassHz, 0.5, "sine", 0.25, span * 0.95);
|
|
}
|
|
};
|
|
tick();
|
|
this.musicTimer = setInterval(tick, this.TRACKS[this.musicTrack].gap);
|
|
}
|
|
|
|
stopMusic() {
|
|
if (this.musicTimer) {
|
|
clearInterval(this.musicTimer);
|
|
this.musicTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const sound = new SoundManager();
|