Split card design into front+back, add sound effects & background music

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>
This commit is contained in:
soroush.asadi
2026-06-04 11:49:19 +03:30
parent db4eade619
commit ae239f4c51
18 changed files with 579 additions and 72 deletions
+198
View File
@@ -0,0 +1,198 @@
// 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();