新增音效功能并更新构建产物
- src/sound.ts: 音效模块 - public/assets/sounds/: 5 个音效文件(click/complete/correct/wrong) - Prototype.tsx + prototype.css: 接入音效逻辑 - dist/: 重新构建,包含新音效资源
This commit is contained in:
File diff suppressed because one or more lines are too long
1
brain-h5/dist/client/assets/index-CWIhvJNC.css
vendored
Normal file
1
brain-h5/dist/client/assets/index-CWIhvJNC.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
brain-h5/dist/client/assets/index-Rrx9JkEp.js
vendored
Normal file
9
brain-h5/dist/client/assets/index-Rrx9JkEp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
brain-h5/dist/client/assets/sounds/click.wav
vendored
Normal file
BIN
brain-h5/dist/client/assets/sounds/click.wav
vendored
Normal file
Binary file not shown.
BIN
brain-h5/dist/client/assets/sounds/complete.wav
vendored
Normal file
BIN
brain-h5/dist/client/assets/sounds/complete.wav
vendored
Normal file
Binary file not shown.
BIN
brain-h5/dist/client/assets/sounds/correct.mp3
vendored
Normal file
BIN
brain-h5/dist/client/assets/sounds/correct.mp3
vendored
Normal file
Binary file not shown.
BIN
brain-h5/dist/client/assets/sounds/correct.wav
vendored
Normal file
BIN
brain-h5/dist/client/assets/sounds/correct.wav
vendored
Normal file
Binary file not shown.
BIN
brain-h5/dist/client/assets/sounds/wrong.wav
vendored
Normal file
BIN
brain-h5/dist/client/assets/sounds/wrong.wav
vendored
Normal file
Binary file not shown.
4
brain-h5/dist/client/index.html
vendored
4
brain-h5/dist/client/index.html
vendored
@@ -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>
|
||||||
|
|||||||
BIN
brain-h5/public/assets/sounds/click.wav
Normal file
BIN
brain-h5/public/assets/sounds/click.wav
Normal file
Binary file not shown.
BIN
brain-h5/public/assets/sounds/complete.wav
Normal file
BIN
brain-h5/public/assets/sounds/complete.wav
Normal file
Binary file not shown.
BIN
brain-h5/public/assets/sounds/correct.mp3
Normal file
BIN
brain-h5/public/assets/sounds/correct.mp3
Normal file
Binary file not shown.
BIN
brain-h5/public/assets/sounds/correct.wav
Normal file
BIN
brain-h5/public/assets/sounds/correct.wav
Normal file
Binary file not shown.
BIN
brain-h5/public/assets/sounds/wrong.wav
Normal file
BIN
brain-h5/public/assets/sounds/wrong.wav
Normal file
Binary file not shown.
@@ -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;
|
||||||
|
// 每轮都由 2~4 种不同面额组成,三轮保持相近难度。
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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
97
brain-h5/src/sound.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user