新增音效功能并更新构建产物

- src/sound.ts: 音效模块
- public/assets/sounds/: 5 个音效文件(click/complete/correct/wrong)
- Prototype.tsx + prototype.css: 接入音效逻辑
- dist/: 重新构建,包含新音效资源
This commit is contained in:
MingNian
2026-07-28 15:24:26 +08:00
parent e7d99b2adb
commit 54f43cc7e3
18 changed files with 440 additions and 38 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>脑晴测</title> <title>脑晴测</title>
<script type="module" crossorigin src="/assets/index-8xOYLbW9.js"></script> <script type="module" crossorigin src="/assets/index-Rrx9JkEp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ouTen0sP.css"> <link rel="stylesheet" crossorigin href="/assets/index-CWIhvJNC.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -14,6 +14,8 @@ import {
Minus, Minus,
Path, Path,
Sparkle, Sparkle,
SpeakerHigh,
SpeakerSlash,
Speedometer, Speedometer,
SquaresFour, SquaresFour,
Star, Star,
@@ -21,12 +23,14 @@ import {
Timer, Timer,
X, X,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { getSoundTheme, isSoundEnabled, playSound, preloadSounds, previewSound, setSoundEnabled, setSoundTheme, SOUND_THEMES, type SoundTheme } from "./sound";
type Screen = "home" | "profile" | "overview" | "symbol" | "trail" | "attention" | "memory" | "money" | "complete" | "report"; type Screen = "home" | "profile" | "overview" | "symbol" | "trail" | "attention" | "memory" | "money" | "complete" | "report";
type Game = "symbol" | "trail" | "attention" | "memory" | "money"; type Game = "symbol" | "trail" | "attention" | "memory" | "money";
type Direction = "up" | "right" | "down" | "left"; type Direction = "up" | "right" | "down" | "left";
type MetricKey = "speed" | "executive" | "attention" | "memory" | "calculation"; type MetricKey = "speed" | "executive" | "attention" | "memory" | "calculation";
type Metric = { score: number; correct: number; total: number; errors: number; completion: number }; type Metric = { score: number; correct: number; total: number; errors: number; completion: number };
type MoneyRound = { price: number; payment: number; target: number; seconds: number };
type Plane = { id: number; color: "blue" | "orange"; direction: Direction; lane: number; duration: number; delay: number; target: boolean }; type Plane = { id: number; color: "blue" | "orange"; direction: Direction; lane: number; duration: number; delay: number; target: boolean };
const SYMBOL_IMAGES = [ const SYMBOL_IMAGES = [
@@ -80,11 +84,7 @@ const MONEY_OPTIONS = [
{ value: 20, image: "/assets/money/money-20.png" }, { value: 20, image: "/assets/money/money-20.png" },
] as const; ] as const;
const MEMORY_ROUND_SECONDS = [0, 35, 40, 45]; const MEMORY_ROUND_SECONDS = [0, 35, 40, 45];
const MONEY_ROUNDS = [ const SYMBOL_EXPECTED_TRIALS = 15;
{ price: 13, payment: 20, target: 7, seconds: 40 },
{ price: 26.5, payment: 40, target: 13.5, seconds: 45 },
{ price: 41.5, payment: 60, target: 18.5, seconds: 50 },
] as const;
function moneyImage(value: number) { function moneyImage(value: number) {
return MONEY_OPTIONS.find((option) => option.value === value)?.image ?? MONEY_OPTIONS[0].image; return MONEY_OPTIONS.find((option) => option.value === value)?.image ?? MONEY_OPTIONS[0].image;
@@ -112,6 +112,29 @@ function shuffle<T>(items: readonly T[]) {
return copy; return copy;
} }
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function createMoneyRounds(): MoneyRound[] {
const prices = [7.5, 9, 11.5, 13, 16.5, 19, 22.5, 27, 31.5, 36];
const rounds: MoneyRound[] = [];
const targets: number[] = [];
let attempts = 0;
while (rounds.length < 3 && attempts < 100) {
attempts += 1;
// 每轮都由 24 种不同面额组成,三轮保持相近难度。
const count = 2 + Math.floor(Math.random() * 3);
const combination = shuffle(MONEY_OPTIONS.map((option) => option.value)).slice(0, count);
const target = roundMoney(combination.reduce((sum, value) => sum + value, 0));
if (targets.includes(target)) continue;
const price = prices[Math.floor(Math.random() * prices.length)];
targets.push(target);
rounds.push({ price, payment: roundMoney(price + target), target, seconds: 45 });
}
return rounds;
}
function randomDirection(): Direction { function randomDirection(): Direction {
return DIRECTIONS[Math.floor(Math.random() * DIRECTIONS.length)]; return DIRECTIONS[Math.floor(Math.random() * DIRECTIONS.length)];
} }
@@ -162,13 +185,14 @@ function Brand() {
); );
} }
function PrimaryButton({ children, onClick, disabled, secondary = false }: { function PrimaryButton({ children, onClick, disabled, secondary = false, feedbackSound = false }: {
children: React.ReactNode; children: React.ReactNode;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
secondary?: boolean; secondary?: boolean;
feedbackSound?: boolean;
}) { }) {
return <button className={`primary-button ${secondary ? "secondary" : ""}`} onClick={onClick} disabled={disabled}>{children}</button>; return <button className={`primary-button ${secondary ? "secondary" : ""}`} onClick={onClick} disabled={disabled} data-feedback-sound={feedbackSound ? "true" : undefined}>{children}</button>;
} }
function GameHeader({ seconds, onBack }: { seconds: number; onBack: () => void }) { function GameHeader({ seconds, onBack }: { seconds: number; onBack: () => void }) {
@@ -180,6 +204,30 @@ function GameHeader({ seconds, onBack }: { seconds: number; onBack: () => void }
); );
} }
function SoundControl() {
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(isSoundEnabled);
const [theme, setTheme] = useState<SoundTheme>(getSoundTheme);
const toggle = () => {
const next = !enabled;
setEnabled(next);
setSoundEnabled(next);
if (next) playSound("tap", theme);
};
return <div className={`sound-control ${open ? "open" : ""}`}>
<button className="sound-toggle" onClick={() => setOpen((value) => !value)} aria-label="声音设置" aria-expanded={open}>
{enabled ? <SpeakerHigh weight="fill" /> : <SpeakerSlash weight="fill" />}<span>{enabled ? "声音" : "静音"}</span>
</button>
{open && <div className="sound-panel">
<div className="sound-panel-head"><b></b><button onClick={toggle}>{enabled ? "关闭" : "开启"}</button></div>
<p></p>
<div className="sound-options">{SOUND_THEMES.map((option) => <button key={option.id} className={theme === option.id ? "selected" : ""} onClick={() => { setTheme(option.id); setSoundTheme(option.id); previewSound(option.id); }}>
<span className={`sound-orb orb-${option.id}`}><SpeakerHigh weight="fill" /></span><span><b>{option.label}</b><small>{option.note}</small></span><i></i>
</button>)}</div>
</div>}
</div>;
}
function GameIntro({ game, onStart }: { game: Game; onStart: () => void }) { function GameIntro({ game, onStart }: { game: Game; onStart: () => void }) {
const info = { const info = {
symbol: { no: "01", title: "符号速配", text: "记住固定符号表,看到目标后选择对应数字。", rules: ["映射表全程固定", "准确优先,再提高速度", "完成练习后开始"] }, symbol: { no: "01", title: "符号速配", text: "记住固定符号表,看到目标后选择对应数字。", rules: ["映射表全程固定", "准确优先,再提高速度", "完成练习后开始"] },
@@ -349,6 +397,7 @@ export default function Prototype() {
const [beltOrder, setBeltOrder] = useState<number[]>(() => shuffle([0, 1, 2, 3, 4, 5, 6, 7])); const [beltOrder, setBeltOrder] = useState<number[]>(() => shuffle([0, 1, 2, 3, 4, 5, 6, 7]));
const [listOpen, setListOpen] = useState(false); const [listOpen, setListOpen] = useState(false);
const [moneyRound, setMoneyRound] = useState(1); const [moneyRound, setMoneyRound] = useState(1);
const [moneyRounds, setMoneyRounds] = useState<MoneyRound[]>(createMoneyRounds);
const [moneyAttempts, setMoneyAttempts] = useState(0); const [moneyAttempts, setMoneyAttempts] = useState(0);
const [cash, setCash] = useState<number[]>([]); const [cash, setCash] = useState<number[]>([]);
const memoryCorrect = useRef(0); const memoryCorrect = useRef(0);
@@ -375,7 +424,13 @@ export default function Prototype() {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}, [screen]); }, [screen]);
useEffect(() => {
void preloadSounds();
}, []);
const flash = (value: "correct" | "wrong") => { const flash = (value: "correct" | "wrong") => {
// 交替连线是第二个游戏,答题过程中保持安静;教程操作也不播放反馈音。
if (!intro) playSound(value);
setFeedback(value); setFeedback(value);
window.setTimeout(() => setFeedback(null), 620); window.setTimeout(() => setFeedback(null), 620);
}; };
@@ -421,7 +476,7 @@ export default function Prototype() {
const startGame = (game: Game) => { const startGame = (game: Game) => {
setIntro(null); setIntro(null);
setSeconds(game === "trail" ? 90 : game === "memory" ? MEMORY_ROUND_SECONDS[1] : 60); setSeconds(game === "symbol" ? 30 : game === "trail" ? 45 : game === "memory" ? MEMORY_ROUND_SECONDS[1] : 60);
if (game === "symbol") { if (game === "symbol") {
let nextTarget = Math.floor(Math.random() * 10); let nextTarget = Math.floor(Math.random() * 10);
while (nextTarget === symbolTarget) nextTarget = Math.floor(Math.random() * 10); while (nextTarget === symbolTarget) nextTarget = Math.floor(Math.random() * 10);
@@ -475,10 +530,12 @@ export default function Prototype() {
listOpenedAt.current = null; listOpenedAt.current = null;
} }
if (game === "money") { if (game === "money") {
const nextMoneyRounds = createMoneyRounds();
setMoneyRound(1); setMoneyRound(1);
setMoneyRounds(nextMoneyRounds);
setMoneyAttempts(0); setMoneyAttempts(0);
setCash([]); setCash([]);
setSeconds(MONEY_ROUNDS[0].seconds); setSeconds(nextMoneyRounds[0].seconds);
calculationCorrect.current = 0; calculationCorrect.current = 0;
calculationTotal.current = 0; calculationTotal.current = 0;
calculationErrors.current = 0; calculationErrors.current = 0;
@@ -486,6 +543,7 @@ export default function Prototype() {
}; };
const finishGame = (key: MetricKey, metric: Metric, next: Screen) => { const finishGame = (key: MetricKey, metric: Metric, next: Screen) => {
playSound("complete");
setMetrics((current) => ({ ...current, [key]: metric })); setMetrics((current) => ({ ...current, [key]: metric }));
setCompletedGame(screen as Game); setCompletedGame(screen as Game);
setCompletionNext(next); setCompletionNext(next);
@@ -498,13 +556,13 @@ export default function Prototype() {
if (correct) calculationCorrect.current += 1; if (correct) calculationCorrect.current += 1;
setCash([]); setCash([]);
setMoneyAttempts(0); setMoneyAttempts(0);
if (moneyRound < MONEY_ROUNDS.length) { if (moneyRound < moneyRounds.length) {
const nextRound = moneyRound + 1; const nextRound = moneyRound + 1;
setMoneyRound(nextRound); setMoneyRound(nextRound);
setSeconds(MONEY_ROUNDS[nextRound - 1].seconds); setSeconds(moneyRounds[nextRound - 1].seconds);
return; return;
} }
finishGame("calculation", scoreMetric(calculationCorrect.current, calculationTotal.current, calculationErrors.current, MONEY_ROUNDS.length), "report"); finishGame("calculation", scoreMetric(calculationCorrect.current, calculationTotal.current, calculationErrors.current, moneyRounds.length), "report");
}; };
const continueAfterTime = () => { const continueAfterTime = () => {
@@ -554,6 +612,7 @@ export default function Prototype() {
memoryListViewMs.current = 0; memoryListViewMs.current = 0;
listOpenedAt.current = null; listOpenedAt.current = null;
setMoneyRound(1); setMoneyRound(1);
setMoneyRounds(createMoneyRounds());
setMoneyAttempts(0); setMoneyAttempts(0);
setCash([]); setCash([]);
calculationCorrect.current = 0; calculationCorrect.current = 0;
@@ -580,8 +639,8 @@ export default function Prototype() {
if (correct) symbolCorrect.current += 1; if (correct) symbolCorrect.current += 1;
else symbolErrors.current += 1; else symbolErrors.current += 1;
flash(correct ? "correct" : "wrong"); flash(correct ? "correct" : "wrong");
if (symbolTrial >= 29) { if (symbolTrial >= SYMBOL_EXPECTED_TRIALS - 1) {
finishGame("speed", scoreMetric(symbolCorrect.current, 30, symbolErrors.current, 30), "trail"); finishGame("speed", scoreMetric(symbolCorrect.current, SYMBOL_EXPECTED_TRIALS, symbolErrors.current, SYMBOL_EXPECTED_TRIALS), "trail");
return; return;
} }
let next = Math.floor(Math.random() * 10); let next = Math.floor(Math.random() * 10);
@@ -646,14 +705,14 @@ export default function Prototype() {
setPlanes(createPlanes(nextTrial, nextColor, nextDirection)); setPlanes(createPlanes(nextTrial, nextColor, nextDirection));
}; };
const finishMemoryRound = (selected: number[]) => { const finishMemoryRound = (selected: number[], showRoundFeedback = true) => {
clearBeltRetireTimers(); clearBeltRetireTimers();
const hits = memoryTargets.filter((item) => selected.includes(item)).length; const hits = memoryTargets.filter((item) => selected.includes(item)).length;
const wrong = selected.filter((item) => !memoryTargets.includes(item)).length; const wrong = selected.filter((item) => !memoryTargets.includes(item)).length;
memoryCorrect.current += hits; memoryCorrect.current += hits;
memoryTotal.current += memoryTargets.length; memoryTotal.current += memoryTargets.length;
memoryErrors.current += wrong + memoryTargets.length - hits; memoryErrors.current += wrong + memoryTargets.length - hits;
flash(wrong === 0 && hits === memoryTargets.length ? "correct" : "wrong"); if (showRoundFeedback) flash(wrong === 0 && hits === memoryTargets.length ? "correct" : "wrong");
if (memoryRound < 3) { if (memoryRound < 3) {
const nextRound = memoryRound + 1; const nextRound = memoryRound + 1;
setMemoryRound(nextRound); setMemoryRound(nextRound);
@@ -681,7 +740,7 @@ export default function Prototype() {
setMemorySelected(next); setMemorySelected(next);
flash("correct"); flash("correct");
if (memoryTargets.every((target) => next.includes(target))) { if (memoryTargets.every((target) => next.includes(target))) {
window.setTimeout(() => finishMemoryRound(next), 180); window.setTimeout(() => finishMemoryRound(next, false), 180);
return; return;
} }
if (!element) return; if (!element) return;
@@ -697,7 +756,7 @@ export default function Prototype() {
beltRetireTimers.current.push(timer); beltRetireTimers.current.push(timer);
}; };
const moneyQuestion = MONEY_ROUNDS[moneyRound - 1]; const moneyQuestion = moneyRounds[moneyRound - 1];
const changeTarget = moneyQuestion.target; const changeTarget = moneyQuestion.target;
const cashTotal = cash.reduce((sum, item) => sum + item, 0); const cashTotal = cash.reduce((sum, item) => sum + item, 0);
const submitChange = () => { const submitChange = () => {
@@ -827,7 +886,13 @@ export default function Prototype() {
: null; : null;
return ( return (
<div className={`web-app screen-${screen}`}> <div className={`web-app screen-${screen}`} onClick={(event) => {
const button = (event.target as HTMLElement).closest("button");
// 教程按钮只负责引导,不制造额外声音;试听按钮由面板自己控制声音。
if (!button || intro || button.closest(".sound-control")) return;
if (button.dataset.feedbackSound === "true" || (screen === "money" && button.classList.contains("primary-button"))) return;
playSound("tap");
}}>
{screen === "home" && ( {screen === "home" && (
<main className="landing-shell"> <main className="landing-shell">
<nav><Brand /><span className="home-duration"><Timer weight="fill" /> 10 </span></nav> <nav><Brand /><span className="home-duration"><Timer weight="fill" /> 10 </span></nav>
@@ -906,7 +971,7 @@ export default function Prototype() {
<div className="target-zone"><div className="target-object" key={symbolTrial}><img src={SYMBOL_IMAGES[symbolTarget]} alt="" /></div></div> <div className="target-zone"><div className="target-object" key={symbolTrial}><img src={SYMBOL_IMAGES[symbolTarget]} alt="" /></div></div>
<div className="symbol-console"> <div className="symbol-console">
<LayoutGroup id="symbol-map"><div className="symbol-map">{symbolMap.map((symbol, number) => <div key={number} data-symbol={symbol}><b>{number}</b><motion.img layoutId={`symbol-${symbol}`} transition={{ type: "spring", stiffness: 240, damping: 25 }} src={SYMBOL_IMAGES[symbol]} alt="" /></div>)}</div></LayoutGroup> <LayoutGroup id="symbol-map"><div className="symbol-map">{symbolMap.map((symbol, number) => <div key={number} data-symbol={symbol}><b>{number}</b><motion.img layoutId={`symbol-${symbol}`} transition={{ type: "spring", stiffness: 240, damping: 25 }} src={SYMBOL_IMAGES[symbol]} alt="" /></div>)}</div></LayoutGroup>
<div className="keypad">{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((number) => <button data-number={number} key={number} onClick={() => answerSymbol(number)}>{number}</button>)}</div> <div className="keypad">{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((number) => <button data-number={number} data-feedback-sound="true" key={number} onClick={() => answerSymbol(number)}>{number}</button>)}</div>
</div> </div>
</section> </section>
</main> </main>
@@ -920,7 +985,7 @@ export default function Prototype() {
{TRAIL_NODES.map((node, index) => { {TRAIL_NODES.map((node, index) => {
const position = trailPositions[index]; const position = trailPositions[index];
const visibleIndex = intro === "trail" ? coachStep : trailIndex; const visibleIndex = intro === "trail" ? coachStep : trailIndex;
return <button data-index={index} key={index} onClick={() => tapTrail(index)} className={`trail-node ${node.kind} ${index < visibleIndex ? "done" : ""} ${index === visibleIndex ? "current" : ""}`} style={{ left: `${position.x}%`, top: `${position.y}%`, "--slice": `${node.rank * 10}%` } as React.CSSProperties}>{node.kind === "number" ? node.rank : <span />}</button>; return <button data-index={index} data-feedback-sound="true" key={index} onClick={() => tapTrail(index)} className={`trail-node ${node.kind} ${index < visibleIndex ? "done" : ""} ${index === visibleIndex ? "current" : ""}`} style={{ left: `${position.x}%`, top: `${position.y}%`, "--slice": `${node.rank * 10}%` } as React.CSSProperties}>{node.kind === "number" ? node.rank : <span />}</button>;
})} })}
</section> </section>
</main> </main>
@@ -935,7 +1000,7 @@ export default function Prototype() {
{intro === "attention" {intro === "attention"
? <img className={`coach-plane ${targetColor}`} src={PLANE_IMAGE[targetColor]} alt="教学目标飞机" style={{ "--angle": `${DIRECTION_ANGLE[attentionDirection]}deg` } as React.CSSProperties} /> ? <img className={`coach-plane ${targetColor}`} src={PLANE_IMAGE[targetColor]} alt="教学目标飞机" style={{ "--angle": `${DIRECTION_ANGLE[attentionDirection]}deg` } as React.CSSProperties} />
: planes.map((plane) => <img key={plane.id} className={`moving-plane ${plane.color} fly-${plane.direction}`} src={PLANE_IMAGE[plane.color]} alt={plane.target ? "目标飞机" : "干扰飞机"} style={{ "--lane": `${plane.lane}%`, "--angle": `${DIRECTION_ANGLE[plane.direction]}deg`, animationDuration: `${plane.duration}s`, animationDelay: `${plane.delay}s` } as React.CSSProperties} />)} : planes.map((plane) => <img key={plane.id} className={`moving-plane ${plane.color} fly-${plane.direction}`} src={PLANE_IMAGE[plane.color]} alt={plane.target ? "目标飞机" : "干扰飞机"} style={{ "--lane": `${plane.lane}%`, "--angle": `${DIRECTION_ANGLE[plane.direction]}deg`, animationDuration: `${plane.duration}s`, animationDelay: `${plane.delay}s` } as React.CSSProperties} />)}
<div className="direction-console">{DIRECTIONS.map((direction) => { const Icon = DIRECTION_ICONS[direction]; return <button data-direction={direction} className={`direction-${direction}`} key={direction} onClick={() => answerAttention(direction)} aria-label={DIRECTION_LABELS[direction]}><Icon weight="bold" /><span>{DIRECTION_LABELS[direction]}</span></button>; })}</div> <div className="direction-console">{DIRECTIONS.map((direction) => { const Icon = DIRECTION_ICONS[direction]; return <button data-direction={direction} data-feedback-sound="true" className={`direction-${direction}`} key={direction} onClick={() => answerAttention(direction)} aria-label={DIRECTION_LABELS[direction]}><Icon weight="bold" /><span>{DIRECTION_LABELS[direction]}</span></button>; })}</div>
</section> </section>
</main> </main>
)} )}
@@ -952,7 +1017,7 @@ export default function Prototype() {
{intro !== "memory" && memoryPhase === "conveyor" && <section className={`market-stage ${listOpen ? "list-open" : ""}`}> {intro !== "memory" && memoryPhase === "conveyor" && <section className={`market-stage ${listOpen ? "list-open" : ""}`}>
<button className={`shopping-tab ${listOpen ? "open" : ""}`} onClick={toggleMemoryList}><b></b><span>{listOpen ? "收起" : "回看会影响评分"}</span>{listOpen && <div className="shopping-list-preview">{memoryTargets.map((index) => { const selected = memorySelected.includes(index); return <span className={selected ? "selected" : ""} key={index}><img src={FOODS[index].image} alt="" />{selected && <i><Check weight="bold" /></i>}</span>; })}</div>}</button> <button className={`shopping-tab ${listOpen ? "open" : ""}`} onClick={toggleMemoryList}><b></b><span>{listOpen ? "收起" : "回看会影响评分"}</span>{listOpen && <div className="shopping-list-preview">{memoryTargets.map((index) => { const selected = memorySelected.includes(index); return <span className={selected ? "selected" : ""} key={index}><img src={FOODS[index].image} alt="" />{selected && <i><Check weight="bold" /></i>}</span>; })}</div>}</button>
{listOpen && <button className="market-list-shade" aria-label="收起购物清单" onClick={closeMemoryList} />} {listOpen && <button className="market-list-shade" aria-label="收起购物清单" onClick={closeMemoryList} />}
<div className="conveyor"><div className="belt-lines" />{beltOrder.map((index, order) => { if (memoryRetired.includes(index)) return null; const item = FOODS[index]; const selected = memorySelected.includes(index); return <button className={`belt-item ${item.tone} ${selected ? "selected" : ""}`} key={index} style={{ animationDelay: `${order * 3.5}s` }} onClick={(event) => selectMemoryItem(index, event.currentTarget)}><img src={item.image} alt={item.name} />{selected && <i><Check weight="bold" /></i>}</button>; })}</div> <div className="conveyor"><div className="belt-lines" />{beltOrder.map((index, order) => { if (memoryRetired.includes(index)) return null; const item = FOODS[index]; const selected = memorySelected.includes(index); return <button data-feedback-sound="true" className={`belt-item ${item.tone} ${selected ? "selected" : ""}`} key={index} style={{ animationDelay: `${order * 3.5}s` }} onClick={(event) => selectMemoryItem(index, event.currentTarget)}><img src={item.image} alt={item.name} />{selected && <i><Check weight="bold" /></i>}</button>; })}</div>
</section>} </section>}
</main> </main>
)} )}
@@ -961,7 +1026,7 @@ export default function Prototype() {
<main className={`game-shell money-shell ${intro === "money" ? "tutorial-active" : ""}`}> <main className={`game-shell money-shell ${intro === "money" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} /> <GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
<section className={`change-stage ${intro === "money" ? "demo-change-stage" : ""}`}> <section className={`change-stage ${intro === "money" ? "demo-change-stage" : ""}`}>
<div className="change-summary"><p className="eyebrow">{intro === "money" ? "找零试玩" : `EXACT CHANGE · ROUND ${moneyRound} / ${MONEY_ROUNDS.length}`}</p><h2></h2><div><span> <b>¥{intro === "money" ? 7 : moneyQuestion.price}</b></span><span> <b>¥{intro === "money" ? 10 : moneyQuestion.payment}</b></span></div><strong>{intro === "money" ? "应找零 ¥3.00" : "请自行计算应找金额"}</strong></div> <div className="change-summary"><p className="eyebrow">{intro === "money" ? "找零试玩" : `EXACT CHANGE · ROUND ${moneyRound} / ${moneyRounds.length}`}</p><h2></h2><div><span> <b>¥{intro === "money" ? 7 : moneyQuestion.price}</b></span><span> <b>¥{intro === "money" ? 10 : moneyQuestion.payment}</b></span></div><strong>{intro === "money" ? "应找零 ¥3.00" : "请自行计算应找金额"}</strong></div>
<div className="cash-desk"><div className="cash-display"><span></span><b>¥{cashTotal.toFixed(2)}</b><div>{cash.map((value, index) => <button data-cash-index={index} key={`${value}-${index}`} onClick={() => removeCash(index)} aria-label={`移除 ${value}`}><img src={moneyImage(value)} alt="" /><i className="cash-remove"><Minus weight="bold" /></i></button>)}</div></div><div className="money-pad">{MONEY_OPTIONS.map((option) => <button data-value={option.value} key={option.value} onClick={() => intro === "money" ? completeMoneyDemo(option.value) : setCash((items) => [...items, option.value])} aria-label={`${option.value}`}><img src={option.image} alt="" /></button>)}</div>{intro !== "money" && <PrimaryButton disabled={!cash.length} onClick={submitChange}></PrimaryButton>}</div> <div className="cash-desk"><div className="cash-display"><span></span><b>¥{cashTotal.toFixed(2)}</b><div>{cash.map((value, index) => <button data-cash-index={index} key={`${value}-${index}`} onClick={() => removeCash(index)} aria-label={`移除 ${value}`}><img src={moneyImage(value)} alt="" /><i className="cash-remove"><Minus weight="bold" /></i></button>)}</div></div><div className="money-pad">{MONEY_OPTIONS.map((option) => <button data-value={option.value} key={option.value} onClick={() => intro === "money" ? completeMoneyDemo(option.value) : setCash((items) => [...items, option.value])} aria-label={`${option.value}`}><img src={option.image} alt="" /></button>)}</div>{intro !== "money" && <PrimaryButton disabled={!cash.length} onClick={submitChange}></PrimaryButton>}</div>
</section> </section>
</main> </main>

View File

@@ -127,6 +127,20 @@ nav { height: 92px; display: flex; align-items: center; justify-content: space-b
.feedback-pop { position: fixed; z-index: 80; left: 50%; top: 50%; min-width: 110px; height: 70px; padding: 0 22px; transform: translate(-50%,-50%); border-radius: 22px; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; font-weight: 850; box-shadow: 0 25px 60px rgba(24,38,67,.25); animation: feedback-in .62s ease both; } .feedback-pop { position: fixed; z-index: 80; left: 50%; top: 50%; min-width: 110px; height: 70px; padding: 0 22px; transform: translate(-50%,-50%); border-radius: 22px; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; font-weight: 850; box-shadow: 0 25px 60px rgba(24,38,67,.25); animation: feedback-in .62s ease both; }
.feedback-pop.correct { background: linear-gradient(135deg, #13bda6, #268ee9); }.feedback-pop.wrong { background: linear-gradient(135deg, #ff8d6d, #e85e7e); } .feedback-pop.correct { background: linear-gradient(135deg, #13bda6, #268ee9); }.feedback-pop.wrong { background: linear-gradient(135deg, #ff8d6d, #e85e7e); }
.sound-control { position: fixed; z-index: 120; right: 22px; bottom: 22px; }
.sound-toggle { min-height: 44px; padding: 0 15px; border: 1px solid rgba(255,255,255,.95); border-radius: 99px; display: flex; align-items: center; gap: 7px; color: #35506c; background: rgba(255,255,255,.84); box-shadow: 0 12px 30px rgba(31,55,98,.14); backdrop-filter: blur(16px); font-size: 12px; font-weight: 850; }
.sound-toggle:hover { transform: translateY(-2px); }
.sound-panel { position: absolute; right: 0; bottom: 56px; width: min(320px, calc(100vw - 32px)); padding: 18px; border: 1px solid white; border-radius: 24px; background: rgba(255,255,255,.96); box-shadow: 0 24px 60px rgba(31,55,98,.22); backdrop-filter: blur(18px); }
.sound-panel-head { display: flex; align-items: center; justify-content: space-between; color: var(--ink); }
.sound-panel-head > button { padding: 6px 10px; border: 0; border-radius: 99px; color: #168b7b; background: #e9fbf6; font-size: 11px; font-weight: 850; }
.sound-panel > p { margin: 6px 0 12px; color: var(--muted); font-size: 11px; }
.sound-options { display: grid; gap: 7px; }
.sound-options > button { padding: 8px; border: 1px solid transparent; border-radius: 15px; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 9px; text-align: left; background: #f7f9fc; }
.sound-options > button:hover, .sound-options > button.selected { border-color: rgba(35,160,148,.3); background: #effcf8; }
.sound-options > button > span:nth-child(2) { display: grid; gap: 2px; }.sound-options small { color: var(--muted); font-size: 10px; }.sound-options i { color: #168b7b; font-size: 10px; font-style: normal; font-weight: 850; }
.sound-orb { width: 34px; height: 34px; border-radius: 12px; display: grid; place-items: center; color: white; }.orb-spark { background: linear-gradient(135deg,#21c6ac,#3878ee); }.orb-bubble { background: linear-gradient(135deg,#f49aa6,#b86ce3); }.orb-arcade { background: linear-gradient(135deg,#f3af42,#e65d86); }.orb-calm { background: linear-gradient(135deg,#6d94d5,#5d72ad); }
@media (max-width: 560px) { .sound-control { right: 14px; bottom: 14px; }.sound-toggle span { display: none; }.sound-toggle { width: 44px; padding: 0; justify-content: center; } }
/* Symbol */ /* Symbol */
.symbol-layout { min-height: calc(100vh - 118px); display: grid; grid-template-columns: .82fr 1.18fr; gap: 34px; padding: 20px 2% 46px; align-items: center; } .symbol-layout { min-height: calc(100vh - 118px); display: grid; grid-template-columns: .82fr 1.18fr; gap: 34px; padding: 20px 2% 46px; align-items: center; }
.target-zone { min-height: 620px; padding: 54px; border: 1px solid white; border-radius: 42px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(145deg, rgba(255,255,255,.84), rgba(225,247,241,.68)); box-shadow: var(--shadow); } .target-zone { min-height: 620px; padding: 54px; border: 1px solid white; border-radius: 42px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(145deg, rgba(255,255,255,.84), rgba(225,247,241,.68)); box-shadow: var(--shadow); }
@@ -180,7 +194,7 @@ nav { height: 92px; display: flex; align-items: center; justify-content: space-b
.market-status { width: min(1500px, 95%); margin: 20px auto; display: grid; grid-template-columns: auto auto 1fr; gap: 14px; align-items: center; }.market-status > span { color: #e95666; }.market-status > i { height: 7px; border-radius: 99px; background: rgba(123,74,47,.1); overflow: hidden; }.market-status > i span { height: 100%; display:block;background:linear-gradient(90deg,#ef9251,#d76a25);transition:width .3s ease} .market-status { width: min(1500px, 95%); margin: 20px auto; display: grid; grid-template-columns: auto auto 1fr; gap: 14px; align-items: center; }.market-status > span { color: #e95666; }.market-status > i { height: 7px; border-radius: 99px; background: rgba(123,74,47,.1); overflow: hidden; }.market-status > i span { height: 100%; display:block;background:linear-gradient(90deg,#ef9251,#d76a25);transition:width .3s ease}
.conveyor { position: relative; height: 390px; margin: 28px -22px; border-top: 38px solid #85808a; border-bottom: 38px solid #85808a; background: #34373a; overflow: hidden; box-shadow: inset 0 18px 30px rgba(0,0,0,.2), inset 0 -18px 30px rgba(0,0,0,.2); } .conveyor { position: relative; height: 390px; margin: 28px -22px; border-top: 38px solid #85808a; border-bottom: 38px solid #85808a; background: #34373a; overflow: hidden; box-shadow: inset 0 18px 30px rgba(0,0,0,.2), inset 0 -18px 30px rgba(0,0,0,.2); }
.belt-lines { position: absolute; inset: -38px 0; opacity: .3; background: repeating-linear-gradient(90deg, transparent 0 42px, #b5b1bb 42px 62px); } .belt-lines { position: absolute; inset: -38px 0; opacity: .3; background: repeating-linear-gradient(90deg, transparent 0 42px, #b5b1bb 42px 62px); }
.belt-item { position: absolute; z-index: 3; left: -180px; width: 150px; height: 150px; border: 0; border-radius: 40px; display: grid; place-items: center; align-content: center; gap: 5px; color: #fff; background: transparent; animation: conveyor-move 12s linear infinite; }.belt-item img { width: 118px; height: 118px; object-fit: contain; filter: drop-shadow(0 16px 10px rgba(0,0,0,.28)); }.belt-item svg { width: 76px; height: 76px; filter: drop-shadow(0 12px 8px rgba(0,0,0,.24)); }.belt-item span { padding: 3px 8px; border-radius: 99px; background: rgba(0,0,0,.25); font-size: 10px; opacity: 0; transition: opacity .2s ease; }.belt-item:hover span { opacity: 1; }.belt-item > i { position: absolute; right: 5px; top: 5px; width: 36px; height: 36px; border: 4px solid white; border-radius: 50%; display: grid; place-items: center; color: white; background: #25b65e; box-shadow: 0 8px 18px rgba(0,0,0,.25); }.belt-item.selected::before { position:absolute;inset:4px;border-radius:50%;background:rgba(29,135,123,.22);content:"";z-index:-1} .belt-item { position: absolute; z-index: 3; left: -180px; width: 150px; height: 150px; border: 0; border-radius: 40px; display: grid; place-items: center; align-content: center; gap: 5px; color: #fff; background: transparent; animation: conveyor-move 9.8s linear infinite; }.belt-item img { width: 118px; height: 118px; object-fit: contain; filter: drop-shadow(0 16px 10px rgba(0,0,0,.28)); }.belt-item svg { width: 76px; height: 76px; filter: drop-shadow(0 12px 8px rgba(0,0,0,.24)); }.belt-item span { padding: 3px 8px; border-radius: 99px; background: rgba(0,0,0,.25); font-size: 10px; opacity: 0; transition: opacity .2s ease; }.belt-item:hover span { opacity: 1; }.belt-item > i { position: absolute; right: 5px; top: 5px; width: 36px; height: 36px; border: 4px solid white; border-radius: 50%; display: grid; place-items: center; color: white; background: #25b65e; box-shadow: 0 8px 18px rgba(0,0,0,.25); }.belt-item.selected::before { position:absolute;inset:4px;border-radius:50%;background:rgba(29,135,123,.22);content:"";z-index:-1}
.belt-item.amber,.belt-item.orange{color:#f6a337}.belt-item.coral,.belt-item.pink{color:#f47b89}.belt-item.yellow{color:#f3cf4d}.belt-item.violet{color:#9e77ee}.belt-item.blue,.belt-item.cyan{color:#49c6e2} .belt-item.amber,.belt-item.orange{color:#f6a337}.belt-item.coral,.belt-item.pink{color:#f47b89}.belt-item.yellow{color:#f3cf4d}.belt-item.violet{color:#9e77ee}.belt-item.blue,.belt-item.cyan{color:#49c6e2}
.memory-submit { width: min(1100px, 94%); margin: auto; display: flex; justify-content: space-between; align-items: center; }.memory-submit > span { color: var(--muted); font-size: 12px; } .memory-submit { width: min(1100px, 94%); margin: auto; display: flex; justify-content: space-between; align-items: center; }.memory-submit > span { color: var(--muted); font-size: 12px; }
.change-summary > div { margin: 28px 0 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }.change-summary > div span { padding: 16px; border: 1px solid white; border-radius: 18px; display: grid; gap: 5px; color: var(--muted); background: rgba(255,255,255,.65); }.change-summary > div b { color: var(--ink); font-size: 24px; }.change-summary > strong { color: #d76d25; font-size: 32px; } .change-summary > div { margin: 28px 0 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }.change-summary > div span { padding: 16px; border: 1px solid white; border-radius: 18px; display: grid; gap: 5px; color: var(--muted); background: rgba(255,255,255,.65); }.change-summary > div b { color: var(--ink); font-size: 24px; }.change-summary > strong { color: #d76d25; font-size: 32px; }
@@ -1682,3 +1696,229 @@ nav { height: 92px; display: flex; align-items: center; justify-content: space-b
animation: none; animation: none;
} }
} }
/* Large-type accessibility pass for older readers. Keep the layout fluid. */
.tutorial-head > span { font-size: 13px; letter-spacing: .08em; }
.tutorial-head h2 { font-size: clamp(34px, 4vw, 46px); }
.tutorial-head p { font-size: clamp(17px, 1.5vw, 20px); line-height: 1.7; }
.coach-hint span { font-size: clamp(16px, 1.35vw, 19px); line-height: 1.45; }
.coach-hint b { font-size: clamp(18px, 1.5vw, 21px); }
.practice-trigger { min-height: 64px; font-size: 17px; }
.practice-panel button { font-size: 18px; }
.attention-title > span { font-size: 14px; }
.attention-title strong { font-size: clamp(30px, 2.8vw, 38px); }
.direction-console span { font-size: 15px; }
.study-stage > div > p:last-child { font-size: 18px; line-height: 1.8; }
.memory-study-copy h2 { font-size: clamp(42px, 5vw, 68px); }
.shopping-tab b { font-size: 18px; }
.shopping-tab span { font-size: 15px; }
.shopping-tab div { font-size: 16px; }
.change-summary > div span { font-size: 16px; }
.change-summary > div b { font-size: 28px; }
.change-summary > strong { font-size: clamp(30px, 3vw, 38px); }
.money-shell .change-summary h2 { font-size: clamp(42px, 5vw, 68px); }
.feedback-pop { min-width: 150px; height: 82px; font-size: 22px; }
@media (max-width: 760px) {
.tutorial-card { padding: 24px; }
.tutorial-head h2 { font-size: 34px; }
.tutorial-head p { font-size: 17px; }
.coach-hint span { font-size: 16px; }
.coach-hint b { font-size: 18px; }
.direction-console { gap: 7px; padding: 9px; }
.direction-console button { height: 82px; }
.direction-console span { display: block; font-size: 14px; }
.study-stage > div > p:last-child { font-size: 17px; }
.memory-study-copy h2, .money-shell .change-summary h2 { font-size: 38px; line-height: 1.12; }
.change-summary > div span { font-size: 15px; }
.change-summary > div b { font-size: 24px; }
.change-summary > strong { font-size: 28px; }
}
@media (max-width: 420px) {
.tutorial-card { padding: 18px; }
.tutorial-head h2 { font-size: 30px; }
.tutorial-head p { font-size: 16px; }
.coach-hint span { font-size: 15px; }
.practice-trigger { min-height: 58px; font-size: 16px; }
.direction-console { width: calc(100% - 12px); gap: 4px; padding: 6px; }
.direction-console button { height: 76px; border-radius: 14px; }
.direction-console svg { width: 25px; height: 25px; }
.direction-console span { font-size: 12px; }
.memory-study-copy h2, .money-shell .change-summary h2 { font-size: 32px; }
.study-stage > div > p:last-child { font-size: 16px; line-height: 1.6; }
.change-summary > div span { padding: 12px; font-size: 14px; }
.change-summary > div b { font-size: 22px; }
.change-summary > strong { font-size: 25px; }
}
/* Final readability overrides */
.tutorial-head h2 { font-size: clamp(38px, 4.8vw, 52px) !important; line-height: 1.15 !important; }
.tutorial-head p { font-size: clamp(19px, 1.7vw, 22px) !important; line-height: 1.75 !important; }
.tutorial-head > span { font-size: 14px !important; }
.coach-hint span { font-size: clamp(18px, 1.55vw, 21px) !important; line-height: 1.5 !important; }
.coach-hint b { font-size: clamp(20px, 1.7vw, 24px) !important; }
.coach-bubble b { font-size: 17px !important; }
.coach-bubble span { font-size: 17px !important; line-height: 1.6 !important; }
.practice-trigger { min-height: 68px !important; font-size: 18px !important; }
.attention-title > span { font-size: 15px !important; }
.attention-title strong { font-size: clamp(34px, 3.2vw, 42px) !important; }
.direction-console span { display: block !important; font-size: 16px !important; }
.memory-study-copy h2, .money-shell .change-summary h2 { font-size: clamp(44px, 5.5vw, 74px) !important; line-height: 1.15 !important; }
.memory-study-copy > p:last-child, .study-stage > div > p:last-child { font-size: 20px !important; line-height: 1.8 !important; }
.shopping-tab b { font-size: 20px !important; }
.shopping-tab span { font-size: 16px !important; }
.change-summary > div span { font-size: 18px !important; }
.change-summary > div b { font-size: 30px !important; }
.change-summary > strong { font-size: clamp(32px, 3.4vw, 42px) !important; }
.feedback-pop {
width: 116px !important;
min-width: 116px !important;
height: 116px !important;
padding: 0 !important;
border-radius: 50% !important;
gap: 0 !important;
}
.feedback-pop svg { width: 52px !important; height: 52px !important; }
@media (max-width: 760px) {
.tutorial-head h2 { font-size: 36px !important; }
.tutorial-head p { font-size: 18px !important; }
.coach-hint span { font-size: 17px !important; }
.coach-hint b { font-size: 19px !important; }
.coach-bubble span { font-size: 16px !important; }
.memory-study-copy h2, .money-shell .change-summary h2 { font-size: 40px !important; }
.memory-study-copy > p:last-child, .study-stage > div > p:last-child { font-size: 18px !important; }
.direction-console span { font-size: 14px !important; }
}
@media (max-width: 420px) {
.tutorial-head h2 { font-size: 32px !important; }
.tutorial-head p { font-size: 17px !important; }
.coach-hint span { font-size: 16px !important; }
.coach-bubble span { font-size: 15px !important; }
.memory-study-copy h2, .money-shell .change-summary h2 { font-size: 34px !important; }
.memory-study-copy > p:last-child, .study-stage > div > p:last-child { font-size: 17px !important; }
.direction-console span { font-size: 12px !important; }
.feedback-pop { width: 104px !important; min-width: 104px !important; height: 104px !important; }
.feedback-pop svg { width: 46px !important; height: 46px !important; }
}
/* WeChat/iOS WebView viewport fix: use the stable visible height and safe areas. */
@media (max-width: 680px) {
.web-app.screen-attention,
.web-app.screen-attention .attention-shell {
height: 100svh;
min-height: 0;
overflow: hidden;
}
.web-app.screen-attention .attention-shell {
padding: 0 8px max(8px, env(safe-area-inset-bottom));
}
.web-app.screen-attention .game-header {
height: 58px;
}
.web-app.screen-attention .sky-stage {
height: calc(100svh - 58px - env(safe-area-inset-bottom));
max-height: calc(100svh - 58px - env(safe-area-inset-bottom));
min-height: 0;
}
.web-app.screen-attention .direction-console {
bottom: max(12px, env(safe-area-inset-bottom));
}
}
@supports not (height: 100svh) {
@media (max-width: 680px) {
.web-app.screen-attention,
.web-app.screen-attention .attention-shell { height: 100vh; }
.web-app.screen-attention .sky-stage {
height: calc(100vh - 58px);
max-height: calc(100vh - 58px);
}
}
}
/* Attention game: full-bleed sky with a floating right-side control rail. */
.attention-shell {
width: 100% !important;
max-width: none !important;
min-height: 100dvh !important;
padding: 0 !important;
background: linear-gradient(#cfe2ff, #eaf3ff) !important;
overflow: hidden;
}
.attention-shell .game-header {
width: 100%;
max-width: none;
height: 86px;
padding: 0 22px;
}
.attention-shell .sky-stage {
height: calc(100dvh - 86px) !important;
min-height: 0 !important;
max-height: none !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow: hidden;
}
.attention-shell .direction-console {
left: 50%;
right: auto;
top: auto;
bottom: max(18px, env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 20;
}
@media (max-width: 680px) {
.attention-shell {
min-height: 100svh !important;
height: 100svh;
}
.attention-shell .game-header {
height: 58px;
padding: 0 8px;
}
.attention-shell .sky-stage {
height: calc(100svh - 58px) !important;
max-height: calc(100svh - 58px) !important;
}
.attention-shell .direction-console {
left: 50%;
right: auto;
top: auto;
bottom: max(12px, env(safe-area-inset-bottom));
transform: translateX(-50%);
width: 210px !important;
height: 171px !important;
grid-template-columns: repeat(3, 1fr) !important;
grid-template-rows: repeat(2, 82px) !important;
grid-auto-rows: 82px;
gap: 7px !important;
padding: 0 !important;
overflow: visible !important;
}
.attention-shell .direction-console button {
width: auto !important;
height: 82px !important;
transition: transform .12s ease, background .12s ease, box-shadow .12s ease;
min-height: 0;
}
}
/* Touch browsers keep :hover after a tap; do not let a direction key stay shifted. */
@media (hover: none) {
.attention-shell .direction-console button:hover {
transform: none !important;
color: #28526d !important;
background: #fff !important;
}
.attention-shell .direction-console button:active {
transform: scale(.94) !important;
background: #eef7ff !important;
box-shadow: 0 4px 10px rgba(44,93,133,.13) !important;
}
}

97
brain-h5/src/sound.ts Normal file
View File

@@ -0,0 +1,97 @@
export type SoundTheme = "spark" | "bubble" | "arcade" | "calm";
export const SOUND_THEMES: Array<{ id: SoundTheme; label: string; note: string }> = [
{ id: "spark", label: "按钮点击", note: "arcade-game-jump-coin" },
{ id: "bubble", label: "答对反馈", note: "correct-answer-tone" },
{ id: "arcade", label: "答错反馈", note: "game-show-buzz-in" },
{ id: "calm", label: "游戏完成", note: "fairy-arcade-sparkle" },
];
const STORAGE_KEY = "brain-h5-sound-theme";
const ENABLED_KEY = "brain-h5-sound-enabled";
const SOUND_FILES = {
tap: "/assets/sounds/click.wav",
correct: "/assets/sounds/correct.wav",
wrong: "/assets/sounds/wrong.wav",
complete: "/assets/sounds/complete.wav",
countdown: "/assets/sounds/click.wav",
} as const;
const audioCache = new Map<string, HTMLAudioElement>();
let audioContext: AudioContext | null = null;
const bufferCache = new Map<string, AudioBuffer>();
function getAudio(path: string) {
const cached = audioCache.get(path);
if (cached) return cached;
const audio = new Audio(path);
audio.preload = "auto";
audioCache.set(path, audio);
return audio;
}
export async function preloadSounds() {
if (typeof window === "undefined") return;
if (!audioContext) audioContext = new AudioContext();
await Promise.all(Object.values(SOUND_FILES).map(async (path) => {
if (bufferCache.has(path)) return;
try {
const response = await fetch(path);
const data = await response.arrayBuffer();
const buffer = await audioContext!.decodeAudioData(data);
bufferCache.set(path, buffer);
} catch {
// HTMLAudioElement fallback in playSound handles slow/unsupported devices.
}
}));
}
export function getSoundTheme(): SoundTheme {
const value = window.localStorage.getItem(STORAGE_KEY);
return SOUND_THEMES.some((theme) => theme.id === value) ? value as SoundTheme : "spark";
}
export function setSoundTheme(theme: SoundTheme) {
window.localStorage.setItem(STORAGE_KEY, theme);
}
export function isSoundEnabled() {
return window.localStorage.getItem(ENABLED_KEY) !== "false";
}
export function setSoundEnabled(enabled: boolean) {
window.localStorage.setItem(ENABLED_KEY, String(enabled));
}
export function playSound(kind: "tap" | "correct" | "wrong" | "complete" | "countdown", _theme = getSoundTheme()) {
if (!isSoundEnabled()) return;
const path = SOUND_FILES[kind];
const buffer = bufferCache.get(path);
if (buffer && audioContext) {
if (audioContext.state === "suspended") void audioContext.resume();
const source = audioContext.createBufferSource();
const gain = audioContext.createGain();
gain.gain.value = kind === "wrong" ? 0.58 : kind === "complete" ? 0.72 : 0.66;
source.buffer = buffer;
source.connect(gain).connect(audioContext.destination);
source.start(0);
return;
}
const audio = getAudio(path);
audio.currentTime = 0;
audio.volume = kind === "wrong" ? 0.58 : kind === "complete" ? 0.72 : 0.66;
void audio.play().catch(() => undefined);
}
export function previewSound(theme: SoundTheme) {
setSoundEnabled(true);
setSoundTheme(theme);
const previewKind: Record<SoundTheme, "tap" | "correct" | "wrong" | "complete"> = {
spark: "tap",
bubble: "correct",
arcade: "wrong",
calm: "complete",
};
playSound(previewKind[theme], theme);
}