feat(poker): add synthesized sfx and generative background jazz
Web audio synth with zero audio assets: deal whooshes, chip clacks, turn chimes, showdown and win stingers, plus a lazy 92bpm jazz loop. Transitions drive playback from pure snapshot mappers with staggered delays for the blind sequence, a header mute and volume toggle persists, and everything no-ops safely without audio support. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
9b2b0db553
commit
56e3d253c9
5 changed files with 945 additions and 0 deletions
43
src/components/frontend/minigames/poker/PokerSoundToggle.tsx
Normal file
43
src/components/frontend/minigames/poker/PokerSoundToggle.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { Volume2, VolumeX } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PokerSoundToggleProps {
|
||||
readonly muted: boolean;
|
||||
readonly volume: number;
|
||||
readonly onToggleMuted: () => void;
|
||||
readonly onVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
export function PokerSoundToggle({
|
||||
muted,
|
||||
volume,
|
||||
onToggleMuted,
|
||||
onVolumeChange,
|
||||
}: PokerSoundToggleProps) {
|
||||
return (
|
||||
<div className="poker-sound-toggle" aria-label="Poker sound controls">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleMuted}
|
||||
aria-label={muted ? "Unmute poker sounds" : "Mute poker sounds"}
|
||||
title={muted ? "Unmute poker sounds" : "Mute poker sounds"}
|
||||
>
|
||||
{muted ? <VolumeX aria-hidden="true" /> : <Volume2 aria-hidden="true" />}
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
||||
aria-label="Poker sound volume"
|
||||
className="poker-sound-volume"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
src/components/frontend/minigames/poker/pokerSound.ts
Normal file
264
src/components/frontend/minigames/poker/pokerSound.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import type { PokerSoundEvent } from "./pokerSoundEvents";
|
||||
|
||||
export const DEFAULT_POKER_SOUND_VOLUME = 0.28;
|
||||
|
||||
type OscillatorShape = OscillatorType;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unknown poker sound event: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
export class PokerSoundEngine {
|
||||
private context: AudioContext | null = null;
|
||||
private masterGain: GainNode | null = null;
|
||||
private jazzTimer: number | null = null;
|
||||
private jazzRunning = false;
|
||||
private jazzNextAt = 0;
|
||||
private muted = false;
|
||||
private volume = DEFAULT_POKER_SOUND_VOLUME;
|
||||
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.setMasterGain();
|
||||
if (muted) {
|
||||
this.stopJazzSchedule();
|
||||
} else if (this.context) {
|
||||
this.startJazzSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = clampVolume(volume);
|
||||
this.setMasterGain();
|
||||
}
|
||||
|
||||
public unlock(): void {
|
||||
const context = this.ensureContext();
|
||||
if (!context) return;
|
||||
if (context.state === "suspended") {
|
||||
void context.resume().catch(() => undefined);
|
||||
}
|
||||
if (!this.muted) this.startJazzSchedule();
|
||||
}
|
||||
|
||||
public playUiClick(): void {
|
||||
this.unlock();
|
||||
const context = this.context;
|
||||
if (!context || this.muted) return;
|
||||
this.scheduleTone(720, 0.045, 0.045, context.currentTime, "triangle");
|
||||
}
|
||||
|
||||
public playEvent(event: PokerSoundEvent): void {
|
||||
const context = this.context;
|
||||
if (!context || this.muted) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "hand-start":
|
||||
this.playStreetDeal(2, context.currentTime);
|
||||
return;
|
||||
case "turn-gained":
|
||||
this.playTurnGained(context.currentTime);
|
||||
return;
|
||||
case "turn-lost":
|
||||
this.scheduleTone(330, 0.12, 0.07, context.currentTime, "sine");
|
||||
return;
|
||||
case "street-deal":
|
||||
this.playStreetDeal(event.cardCount, context.currentTime);
|
||||
return;
|
||||
case "wager":
|
||||
this.playWager(event.action, context.currentTime);
|
||||
return;
|
||||
case "fold":
|
||||
this.scheduleTone(180, 0.16, 0.08, context.currentTime, "triangle");
|
||||
return;
|
||||
case "check":
|
||||
this.scheduleTone(520, 0.07, 0.045, context.currentTime, "triangle");
|
||||
return;
|
||||
case "showdown":
|
||||
this.playShowdown(context.currentTime);
|
||||
return;
|
||||
case "hand-result":
|
||||
this.playHandResult(event.result, context.currentTime);
|
||||
return;
|
||||
case "game-complete":
|
||||
this.playGameComplete(context.currentTime);
|
||||
return;
|
||||
default:
|
||||
return assertNever(event);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.stopJazzSchedule();
|
||||
const context = this.context;
|
||||
this.context = null;
|
||||
this.masterGain = null;
|
||||
if (context && context.state !== "closed") void context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
private ensureContext(): AudioContext | null {
|
||||
if (this.context) return this.context;
|
||||
if (typeof window === "undefined" || typeof window.AudioContext !== "function") return null;
|
||||
|
||||
try {
|
||||
const context = new window.AudioContext();
|
||||
const masterGain = context.createGain();
|
||||
masterGain.connect(context.destination);
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.setMasterGain();
|
||||
return context;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setMasterGain(): void {
|
||||
if (!this.masterGain || !this.context) return;
|
||||
const target = this.muted ? 0 : this.volume;
|
||||
this.masterGain.gain.setTargetAtTime(target, this.context.currentTime, 0.035);
|
||||
}
|
||||
|
||||
private scheduleTone(
|
||||
frequency: number,
|
||||
duration: number,
|
||||
level: number,
|
||||
start: number,
|
||||
shape: OscillatorShape,
|
||||
detune = 0,
|
||||
): void {
|
||||
const context = this.context;
|
||||
const masterGain = this.masterGain;
|
||||
if (!context || !masterGain) return;
|
||||
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = shape;
|
||||
oscillator.frequency.setValueAtTime(frequency, start);
|
||||
oscillator.detune.setValueAtTime(detune, start);
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, level), start + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.04);
|
||||
}
|
||||
|
||||
private scheduleNoise(duration: number, level: number, start: number, highPass = 900): void {
|
||||
const context = this.context;
|
||||
const masterGain = this.masterGain;
|
||||
if (!context || !masterGain) return;
|
||||
|
||||
const frameCount = Math.max(1, Math.floor(context.sampleRate * duration));
|
||||
const buffer = context.createBuffer(1, frameCount, context.sampleRate);
|
||||
const channel = buffer.getChannelData(0);
|
||||
for (let index = 0; index < channel.length; index += 1) {
|
||||
channel[index] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
source.buffer = buffer;
|
||||
filter.type = "highpass";
|
||||
filter.frequency.setValueAtTime(highPass, start);
|
||||
gain.gain.setValueAtTime(level, start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
source.start(start);
|
||||
source.stop(start + duration + 0.04);
|
||||
}
|
||||
|
||||
private playTurnGained(start: number): void {
|
||||
this.scheduleTone(660, 0.16, 0.08, start, "sine");
|
||||
this.scheduleTone(880, 0.2, 0.07, start + 0.11, "sine");
|
||||
}
|
||||
|
||||
private playStreetDeal(cardCount: number, start: number): void {
|
||||
for (let index = 0; index < cardCount; index += 1) {
|
||||
const offset = index * 0.075;
|
||||
this.scheduleNoise(0.12, 0.035, start + offset, 1200);
|
||||
this.scheduleTone(440 + index * 35, 0.08, 0.025, start + offset, "triangle");
|
||||
}
|
||||
}
|
||||
|
||||
private playWager(action: "bet" | "call" | "raise", start: number): void {
|
||||
this.scheduleNoise(0.07, 0.08, start, 650);
|
||||
const frequency = action === "raise" ? 620 : action === "bet" ? 540 : 460;
|
||||
this.scheduleTone(frequency, 0.1, 0.055, start + 0.035, "triangle");
|
||||
}
|
||||
|
||||
private playShowdown(start: number): void {
|
||||
this.scheduleNoise(0.22, 0.045, start, 700);
|
||||
this.scheduleTone(392, 0.45, 0.05, start, "sine");
|
||||
this.scheduleTone(494, 0.45, 0.045, start + 0.04, "sine");
|
||||
this.scheduleTone(587, 0.5, 0.04, start + 0.08, "sine");
|
||||
}
|
||||
|
||||
private playHandResult(result: "win" | "loss", start: number): void {
|
||||
if (result === "win") {
|
||||
this.scheduleTone(523, 0.22, 0.06, start, "triangle");
|
||||
this.scheduleTone(659, 0.25, 0.06, start + 0.12, "triangle");
|
||||
this.scheduleTone(784, 0.34, 0.07, start + 0.24, "triangle");
|
||||
return;
|
||||
}
|
||||
this.scheduleTone(330, 0.24, 0.055, start, "sine");
|
||||
this.scheduleTone(247, 0.34, 0.05, start + 0.16, "sine");
|
||||
}
|
||||
|
||||
private playGameComplete(start: number): void {
|
||||
[392, 494, 587, 784].forEach((frequency, index) => {
|
||||
this.scheduleTone(frequency, 0.32, 0.055, start + index * 0.13, "triangle");
|
||||
});
|
||||
}
|
||||
|
||||
private startJazzSchedule(): void {
|
||||
if (this.jazzRunning || !this.context || this.muted) return;
|
||||
this.jazzRunning = true;
|
||||
this.jazzNextAt = Math.max(this.context.currentTime + 0.08, this.jazzNextAt);
|
||||
this.scheduleJazzBar();
|
||||
}
|
||||
|
||||
private scheduleJazzBar(): void {
|
||||
const context = this.context;
|
||||
if (!context || !this.jazzRunning || this.muted) return;
|
||||
|
||||
const beat = 60 / 92;
|
||||
const start = Math.max(context.currentTime + 0.04, this.jazzNextAt);
|
||||
const chordSets = [
|
||||
[261.63, 329.63, 392],
|
||||
[220, 277.18, 329.63],
|
||||
[246.94, 311.13, 369.99],
|
||||
[196, 246.94, 293.66],
|
||||
];
|
||||
const chord = chordSets[Math.floor(start / (beat * 4)) % chordSets.length] ?? chordSets[0];
|
||||
|
||||
chord.forEach((frequency, index) => {
|
||||
this.scheduleTone(frequency, beat * 2.8, 0.012, start, "sine", index * 4 - 4);
|
||||
});
|
||||
for (let beatIndex = 0; beatIndex < 4; beatIndex += 1) {
|
||||
const beatStart = start + beatIndex * beat;
|
||||
this.scheduleTone(82.41 + beatIndex * 3, beat * 0.7, 0.018, beatStart, "triangle");
|
||||
this.scheduleNoise(0.045, 0.012, beatStart + beat * 0.5, 3200);
|
||||
this.scheduleNoise(0.035, 0.009, beatStart + beat * 0.75, 4200);
|
||||
}
|
||||
|
||||
this.jazzNextAt = start + beat * 4;
|
||||
this.jazzTimer = window.setTimeout(() => this.scheduleJazzBar(), beat * 4 * 1000 - 80);
|
||||
}
|
||||
|
||||
private stopJazzSchedule(): void {
|
||||
if (this.jazzTimer !== null) window.clearTimeout(this.jazzTimer);
|
||||
this.jazzTimer = null;
|
||||
this.jazzRunning = false;
|
||||
}
|
||||
}
|
||||
234
src/components/frontend/minigames/poker/pokerSoundEvents.ts
Normal file
234
src/components/frontend/minigames/poker/pokerSoundEvents.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||
import type { PokerActionKind, PokerStreet, PublicPlayerView } from "@/lib/minigames/poker";
|
||||
|
||||
type PokerSoundView = NonNullable<PokerSnapshot["you"]>;
|
||||
|
||||
export type PokerSoundSnapshot = Pick<PokerSnapshot, "version"> & {
|
||||
readonly you: Pick<
|
||||
PokerSoundView,
|
||||
| "communityCards"
|
||||
| "currentBet"
|
||||
| "config"
|
||||
| "finalPlacements"
|
||||
| "handNumber"
|
||||
| "phase"
|
||||
| "players"
|
||||
| "showdown"
|
||||
| "smallBlindSeat"
|
||||
| "bigBlindSeat"
|
||||
| "street"
|
||||
| "toActSeat"
|
||||
| "viewerSeat"
|
||||
| "yourTurn"
|
||||
> | null;
|
||||
};
|
||||
|
||||
export type PokerSoundEvent =
|
||||
| { readonly type: "hand-start"; readonly key: string; readonly handNumber: number }
|
||||
| { readonly type: "turn-gained"; readonly key: string }
|
||||
| { readonly type: "turn-lost"; readonly key: string }
|
||||
| {
|
||||
readonly type: "street-deal";
|
||||
readonly key: string;
|
||||
readonly street: Extract<PokerStreet, "flop" | "turn" | "river">;
|
||||
readonly cardCount: number;
|
||||
}
|
||||
| {
|
||||
readonly type: "wager";
|
||||
readonly key: string;
|
||||
readonly action: Extract<PokerActionKind, "bet" | "call" | "raise">;
|
||||
readonly amount: number;
|
||||
readonly seatId: number;
|
||||
/** ms delay before playback (staggered blind sequence), default 0. */
|
||||
readonly delay?: number;
|
||||
}
|
||||
| { readonly type: "fold"; readonly key: string; readonly seatId: number }
|
||||
| { readonly type: "check"; readonly key: string; readonly seatId: number }
|
||||
| { readonly type: "showdown"; readonly key: string }
|
||||
| {
|
||||
readonly type: "hand-result";
|
||||
readonly key: string;
|
||||
readonly result: "win" | "loss";
|
||||
readonly netChange: number;
|
||||
}
|
||||
| { readonly type: "game-complete"; readonly key: string; readonly placement: number | null };
|
||||
|
||||
const STREET_CARD_COUNTS: Record<Extract<PokerStreet, "flop" | "turn" | "river">, number> = {
|
||||
flop: 3,
|
||||
turn: 1,
|
||||
river: 1,
|
||||
};
|
||||
|
||||
function playerBySeat(
|
||||
players: readonly PublicPlayerView[],
|
||||
seatId: number,
|
||||
): PublicPlayerView | null {
|
||||
return players.find((player) => player.seatId === seatId) ?? null;
|
||||
}
|
||||
|
||||
function wagerAction(
|
||||
previousBet: number,
|
||||
nextBet: number,
|
||||
): Extract<PokerActionKind, "bet" | "call" | "raise"> {
|
||||
if (previousBet === 0 && nextBet > 0) return "bet";
|
||||
if (nextBet > previousBet) return "raise";
|
||||
return "call";
|
||||
}
|
||||
|
||||
function resultForViewer(
|
||||
snapshot: PokerSoundSnapshot,
|
||||
): { readonly result: "win" | "loss"; readonly netChange: number } | null {
|
||||
const view = snapshot.you;
|
||||
if (!view) return null;
|
||||
const showdown = view.showdown;
|
||||
const result = showdown?.results.find((item) => item.seatId === view.viewerSeat);
|
||||
if (!result) return null;
|
||||
return {
|
||||
result: result.netChange > 0 ? "win" : "loss",
|
||||
netChange: result.netChange,
|
||||
};
|
||||
}
|
||||
|
||||
/** Gap between the small-blind and big-blind steps of the new-hand sequence. */
|
||||
export const BLIND_STAGGER_MS = 700;
|
||||
|
||||
export function soundEventsForTransition(
|
||||
previous: PokerSoundSnapshot | null,
|
||||
next: PokerSoundSnapshot,
|
||||
): readonly PokerSoundEvent[] {
|
||||
if (!previous || previous.version === next.version) return [];
|
||||
|
||||
const previousView = previous.you;
|
||||
const nextView = next.you;
|
||||
if (!nextView) return [];
|
||||
const events: PokerSoundEvent[] = [];
|
||||
const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
|
||||
|
||||
// The hand-start chime and staggered blind sequence also cover the very
|
||||
// first dealt hand of a game: the previous view is the waiting room (no
|
||||
// seat view yet), so only the blind seats and config are needed.
|
||||
if (
|
||||
nextView.phase === "betting" &&
|
||||
(previousView === null || nextView.handNumber > previousView.handNumber)
|
||||
) {
|
||||
events.push({
|
||||
type: "hand-start",
|
||||
key: `${transitionKey}:hand-start:${nextView.handNumber}`,
|
||||
handNumber: nextView.handNumber,
|
||||
});
|
||||
events.push({
|
||||
type: "wager",
|
||||
key: `${transitionKey}:blind-sb`,
|
||||
action: "bet",
|
||||
amount: nextView.config.smallBlind,
|
||||
seatId: nextView.smallBlindSeat,
|
||||
delay: 0,
|
||||
});
|
||||
events.push({
|
||||
type: "wager",
|
||||
key: `${transitionKey}:blind-bb`,
|
||||
action: "bet",
|
||||
amount: nextView.config.bigBlind,
|
||||
seatId: nextView.bigBlindSeat,
|
||||
delay: BLIND_STAGGER_MS,
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
if (!previousView) return events;
|
||||
|
||||
if (nextView.phase === "betting" && previousView.yourTurn !== nextView.yourTurn) {
|
||||
events.push({
|
||||
type: nextView.yourTurn ? "turn-gained" : "turn-lost",
|
||||
key: `${transitionKey}:turn:${nextView.yourTurn ? "gained" : "lost"}:${nextView.toActSeat ?? "none"}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
previousView.handNumber === nextView.handNumber &&
|
||||
previousView.street !== nextView.street &&
|
||||
nextView.street !== "preflop"
|
||||
) {
|
||||
events.push({
|
||||
type: "street-deal",
|
||||
key: `${transitionKey}:street:${nextView.communityCards.length}`,
|
||||
street: nextView.street,
|
||||
cardCount: STREET_CARD_COUNTS[nextView.street],
|
||||
});
|
||||
}
|
||||
|
||||
const sameHandAndStreet =
|
||||
previousView.handNumber === nextView.handNumber && previousView.street === nextView.street;
|
||||
if (sameHandAndStreet && previousView.phase === "betting" && nextView.phase === "betting") {
|
||||
for (const nextPlayer of nextView.players) {
|
||||
const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
|
||||
if (!previousPlayer) continue;
|
||||
|
||||
if (!previousPlayer.folded && nextPlayer.folded) {
|
||||
events.push({
|
||||
type: "fold",
|
||||
key: `${transitionKey}:fold:${nextPlayer.seatId}`,
|
||||
seatId: nextPlayer.seatId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextPlayer.folded) continue;
|
||||
|
||||
// Committed chips only grow from a player's own actions mid-street, so
|
||||
// any increase is a wager even when the player already acted (e.g. bet
|
||||
// the flop, then call a raise later in the same street).
|
||||
const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
|
||||
if (committedDelta > 0) {
|
||||
events.push({
|
||||
type: "wager",
|
||||
key: `${transitionKey}:wager:${nextPlayer.seatId}:${committedDelta}`,
|
||||
action: wagerAction(previousView.currentBet, nextView.currentBet),
|
||||
amount: committedDelta,
|
||||
seatId: nextPlayer.seatId,
|
||||
});
|
||||
} else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
|
||||
events.push({
|
||||
type: "check",
|
||||
key: `${transitionKey}:check:${nextPlayer.seatId}`,
|
||||
seatId: nextPlayer.seatId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextView.phase === "showdown" && previousView.phase !== "showdown") {
|
||||
events.push({ type: "showdown", key: `${transitionKey}:showdown` });
|
||||
}
|
||||
|
||||
// A hand ends in exactly one of showdown (cards revealed) or hand-complete
|
||||
// (everyone else folded), and both phases carry showdown results, so the
|
||||
// viewer win/loss jingle fires on entry to either one, never twice.
|
||||
const handEnded =
|
||||
(nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
|
||||
(nextView.phase === "showdown" && previousView.phase !== "showdown");
|
||||
if (handEnded) {
|
||||
const result = resultForViewer(next);
|
||||
if (result) {
|
||||
events.push({
|
||||
type: "hand-result",
|
||||
key: `${transitionKey}:result:${result.result}`,
|
||||
result: result.result,
|
||||
netChange: result.netChange,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nextView.phase === "game-complete" && previousView.phase !== "game-complete") {
|
||||
const placement =
|
||||
nextView.finalPlacements?.find((item) => item.seatId === nextView.viewerSeat)?.placement ??
|
||||
null;
|
||||
events.push({
|
||||
type: "game-complete",
|
||||
key: `${transitionKey}:game-complete:${placement ?? "unknown"}`,
|
||||
placement,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
162
src/components/frontend/minigames/poker/usePokerSounds.ts
Normal file
162
src/components/frontend/minigames/poker/usePokerSounds.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||
import { soundEventsForTransition, type PokerSoundSnapshot } from "./pokerSoundEvents";
|
||||
import { DEFAULT_POKER_SOUND_VOLUME, PokerSoundEngine } from "./pokerSound";
|
||||
|
||||
const SOUND_SETTINGS_KEY = "polaris:poker-sound-settings";
|
||||
|
||||
export interface PokerSoundSettings {
|
||||
readonly muted: boolean;
|
||||
readonly volume: number;
|
||||
}
|
||||
|
||||
export interface UsePokerSoundsResult extends PokerSoundSettings {
|
||||
readonly toggleMuted: () => void;
|
||||
readonly setVolume: (volume: number) => void;
|
||||
readonly playUiClick: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SOUND_SETTINGS: PokerSoundSettings = {
|
||||
muted: false,
|
||||
volume: DEFAULT_POKER_SOUND_VOLUME,
|
||||
};
|
||||
|
||||
export type PokerSoundControls = ReturnType<typeof usePokerSounds>;
|
||||
|
||||
function isStoredSettings(
|
||||
value: unknown,
|
||||
): value is { readonly muted: unknown; readonly volume: unknown } {
|
||||
return typeof value === "object" && value !== null && "muted" in value && "volume" in value;
|
||||
}
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function readStoredSettings(): PokerSoundSettings {
|
||||
if (typeof window === "undefined") return DEFAULT_SOUND_SETTINGS;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SOUND_SETTINGS_KEY);
|
||||
if (!raw) return DEFAULT_SOUND_SETTINGS;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
!isStoredSettings(parsed) ||
|
||||
typeof parsed.muted !== "boolean" ||
|
||||
typeof parsed.volume !== "number"
|
||||
) {
|
||||
return DEFAULT_SOUND_SETTINGS;
|
||||
}
|
||||
return { muted: parsed.muted, volume: clampVolume(parsed.volume) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return DEFAULT_SOUND_SETTINGS;
|
||||
return DEFAULT_SOUND_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSettings(settings: PokerSoundSettings): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(SOUND_SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function usePokerSounds(snapshot: PokerSnapshot | null): UsePokerSoundsResult {
|
||||
const [settings, setSettings] = useState<PokerSoundSettings>(DEFAULT_SOUND_SETTINGS);
|
||||
const engineRef = useRef<PokerSoundEngine | null>(null);
|
||||
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
|
||||
const playedEventKeysRef = useRef<Set<string>>(new Set());
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
const ensureEngine = useCallback((): PokerSoundEngine => {
|
||||
const engine = engineRef.current ?? new PokerSoundEngine();
|
||||
engineRef.current = engine;
|
||||
engine.setVolume(settings.volume);
|
||||
engine.setMuted(settings.muted);
|
||||
engine.unlock();
|
||||
return engine;
|
||||
}, [settings.muted, settings.volume]);
|
||||
|
||||
const playUiClick = useCallback(() => {
|
||||
ensureEngine().playUiClick();
|
||||
}, [ensureEngine]);
|
||||
|
||||
const toggleMuted = useCallback(() => {
|
||||
const nextMuted = !settings.muted;
|
||||
const engine = ensureEngine();
|
||||
engine.setMuted(nextMuted);
|
||||
setSettings((current) => ({ ...current, muted: nextMuted }));
|
||||
}, [ensureEngine, settings.muted]);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(volume: number) => {
|
||||
const nextVolume = clampVolume(volume);
|
||||
const engine = ensureEngine();
|
||||
engine.setVolume(nextVolume);
|
||||
setSettings((current) => ({ ...current, volume: nextVolume }));
|
||||
},
|
||||
[ensureEngine],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(readStoredSettings());
|
||||
setSettingsLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoaded) writeStoredSettings(settings);
|
||||
}, [settings, settingsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) {
|
||||
previousSnapshotRef.current = null;
|
||||
return;
|
||||
}
|
||||
const previousSnapshot = previousSnapshotRef.current;
|
||||
if (previousSnapshot) {
|
||||
const events = soundEventsForTransition(previousSnapshot, snapshot);
|
||||
const engine = engineRef.current;
|
||||
if (engine) {
|
||||
for (const event of events) {
|
||||
if (playedEventKeysRef.current.has(event.key)) continue;
|
||||
playedEventKeysRef.current.add(event.key);
|
||||
if (event.type === "wager" && event.delay && event.delay > 0) {
|
||||
timersRef.current.push(setTimeout(() => engine.playEvent(event), event.delay));
|
||||
} else {
|
||||
engine.playEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
while (playedEventKeysRef.current.size > 256) {
|
||||
const oldest = playedEventKeysRef.current.values().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
playedEventKeysRef.current.delete(oldest);
|
||||
}
|
||||
}
|
||||
previousSnapshotRef.current = snapshot;
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of timersRef.current) clearTimeout(timer);
|
||||
timersRef.current = [];
|
||||
engineRef.current?.dispose();
|
||||
engineRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
muted: settings.muted,
|
||||
volume: settings.volume,
|
||||
toggleMuted,
|
||||
setVolume,
|
||||
playUiClick,
|
||||
};
|
||||
}
|
||||
242
tests/int/poker-sound.int.spec.ts
Normal file
242
tests/int/poker-sound.int.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Card, PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
|
||||
import {
|
||||
soundEventsForTransition,
|
||||
type PokerSoundSnapshot,
|
||||
} from "@/components/frontend/minigames/poker/pokerSoundEvents";
|
||||
|
||||
const card = (rank: Card["rank"], suit: Card["suit"]): Card => ({ rank, suit });
|
||||
|
||||
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
|
||||
return {
|
||||
seatId,
|
||||
name: `Player ${seatId + 1}`,
|
||||
stack: 1000,
|
||||
committed: 0,
|
||||
totalCommitted: 0,
|
||||
folded: false,
|
||||
allIn: false,
|
||||
hasActed: false,
|
||||
eliminated: false,
|
||||
left: false,
|
||||
holeCards: null,
|
||||
cardCount: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function view(overrides: Partial<PokerSoundSnapshot["you"]> = {}): PokerSoundSnapshot["you"] {
|
||||
return {
|
||||
communityCards: [],
|
||||
config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: 30000 },
|
||||
currentBet: 0,
|
||||
smallBlindSeat: 0,
|
||||
bigBlindSeat: 1,
|
||||
finalPlacements: null,
|
||||
handNumber: 1,
|
||||
phase: "betting",
|
||||
players: [player(0), player(1)],
|
||||
showdown: null,
|
||||
street: "preflop",
|
||||
toActSeat: 0,
|
||||
viewerSeat: 0,
|
||||
yourTurn: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
version: number,
|
||||
nextView: PokerSoundSnapshot["you"] = view(),
|
||||
): PokerSoundSnapshot {
|
||||
return { version, you: nextView };
|
||||
}
|
||||
|
||||
function showdown(netChange: number): ShowdownOutcome {
|
||||
return {
|
||||
pots: [],
|
||||
results: [
|
||||
{
|
||||
seatId: 0,
|
||||
handRank: null,
|
||||
bestHand: null,
|
||||
chipsWon: 100,
|
||||
netChange,
|
||||
placement: netChange > 0 ? 1 : 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("poker sound transition events", () => {
|
||||
it("emits a street deal descriptor for flop, turn, and river transitions", () => {
|
||||
const flop = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(
|
||||
2,
|
||||
view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
|
||||
),
|
||||
);
|
||||
const turn = soundEventsForTransition(
|
||||
snapshot(
|
||||
2,
|
||||
view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
|
||||
),
|
||||
snapshot(
|
||||
3,
|
||||
view({
|
||||
street: "turn",
|
||||
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const river = soundEventsForTransition(
|
||||
snapshot(
|
||||
3,
|
||||
view({
|
||||
street: "turn",
|
||||
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
|
||||
}),
|
||||
),
|
||||
snapshot(
|
||||
4,
|
||||
view({
|
||||
street: "river",
|
||||
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c"), card(4, "h")],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(flop).toEqual([
|
||||
expect.objectContaining({ type: "street-deal", street: "flop", cardCount: 3 }),
|
||||
]);
|
||||
expect(turn).toEqual([
|
||||
expect.objectContaining({ type: "street-deal", street: "turn", cardCount: 1 }),
|
||||
]);
|
||||
expect(river).toEqual([
|
||||
expect.objectContaining({ type: "street-deal", street: "river", cardCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits turn gained and turn lost descriptors", () => {
|
||||
const gained = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
|
||||
);
|
||||
const lost = soundEventsForTransition(
|
||||
snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
|
||||
snapshot(3, view({ yourTurn: false, toActSeat: 1 })),
|
||||
);
|
||||
|
||||
expect(gained.map((event) => event.type)).toContain("turn-gained");
|
||||
expect(lost.map((event) => event.type)).toContain("turn-lost");
|
||||
});
|
||||
|
||||
it("emits wager, check, and fold descriptors from player transitions", () => {
|
||||
const previous = snapshot(1, view({ players: [player(0), player(1)] }));
|
||||
const next = snapshot(
|
||||
2,
|
||||
view({
|
||||
currentBet: 20,
|
||||
players: [
|
||||
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
|
||||
player(1, { folded: true, hasActed: true }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const wagerEvents = soundEventsForTransition(previous, next);
|
||||
const checkEvents = soundEventsForTransition(
|
||||
previous,
|
||||
snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
|
||||
);
|
||||
|
||||
expect(wagerEvents.map((event) => event.type)).toEqual(["wager", "fold"]);
|
||||
expect(checkEvents.map((event) => event.type)).toEqual(["check"]);
|
||||
});
|
||||
|
||||
it("emits win and loss results plus game completion placement", () => {
|
||||
const win = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(2, view({ phase: "hand-complete", showdown: showdown(40) })),
|
||||
);
|
||||
const loss = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(2, view({ phase: "hand-complete", showdown: showdown(-40) })),
|
||||
);
|
||||
const complete = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(2, view({ phase: "game-complete", finalPlacements: [{ seatId: 0, placement: 1 }] })),
|
||||
);
|
||||
|
||||
expect(win).toEqual([expect.objectContaining({ type: "hand-result", result: "win" })]);
|
||||
expect(loss).toEqual([expect.objectContaining({ type: "hand-result", result: "loss" })]);
|
||||
expect(complete).toEqual([expect.objectContaining({ type: "game-complete", placement: 1 })]);
|
||||
});
|
||||
|
||||
it("returns no events for an identical snapshot version", () => {
|
||||
const current = snapshot(7, view({ yourTurn: true }));
|
||||
expect(soundEventsForTransition(current, current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("announces a new hand with a two-card deal and a staggered blind sequence", () => {
|
||||
const events = soundEventsForTransition(snapshot(5), snapshot(6, view({ handNumber: 2 })));
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "hand-start", handNumber: 2 }),
|
||||
expect.objectContaining({
|
||||
type: "wager",
|
||||
amount: 10,
|
||||
seatId: 0,
|
||||
delay: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "wager",
|
||||
amount: 20,
|
||||
seatId: 1,
|
||||
delay: 700,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("plays the staggered blind sequence for the game's first hand out of the waiting room", () => {
|
||||
const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
|
||||
const events = soundEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["hand-start", "wager", "wager"]);
|
||||
expect(events[1]).toEqual(expect.objectContaining({ amount: 10, delay: 0 }));
|
||||
expect(events[2]).toEqual(expect.objectContaining({ amount: 20, delay: 700 }));
|
||||
});
|
||||
|
||||
it("plays the showdown reveal plus the win jingle on showdown entry", () => {
|
||||
const events = soundEventsForTransition(
|
||||
snapshot(1),
|
||||
snapshot(2, view({ phase: "showdown", showdown: showdown(180) })),
|
||||
);
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["showdown", "hand-result"]);
|
||||
expect(events[1]).toEqual(expect.objectContaining({ result: "win", netChange: 180 }));
|
||||
});
|
||||
|
||||
it("still clacks chips when the same player wagers again later in the street", () => {
|
||||
const before = snapshot(
|
||||
9,
|
||||
view({
|
||||
currentBet: 20,
|
||||
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
|
||||
}),
|
||||
);
|
||||
const after = snapshot(
|
||||
10,
|
||||
view({
|
||||
currentBet: 60,
|
||||
players: [
|
||||
player(0, { committed: 60, totalCommitted: 60, hasActed: true }),
|
||||
player(1, { committed: 40, totalCommitted: 40, hasActed: true }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const events = soundEventsForTransition(before, after);
|
||||
expect(events.filter((event) => event.type === "wager")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue