Refactor PK page and add Trellis setup

This commit is contained in:
2026-06-30 21:03:33 +08:00
parent 908bad097c
commit 1d566bd3c0
119 changed files with 17053 additions and 538 deletions
+7
View File
@@ -0,0 +1,7 @@
import type { AppSettings } from "./types";
export async function loadSettings() {
const response = await fetch("/api/settings", { cache: "no-store" });
const data = (await response.json()) as { settings: Partial<AppSettings> };
return data.settings;
}
+42
View File
@@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
type AdaptiveWordTextProps = {
text: string;
variant: "hero" | "option";
};
export function AdaptiveWordText({ text, variant }: AdaptiveWordTextProps) {
const displayText = text.trim().replace(/\s+/g, "\n");
const compactLength = text.replace(/\s+/g, "").length;
const isVeryLongSingleWord = compactLength > 16 && !/\s/.test(text.trim());
const size = compactLength <= 6 ? "large" : compactLength <= 10 ? "medium" : "small";
const sizeClass = {
hero: {
large: "text-[clamp(52px,7vw,82px)]",
medium: "text-[clamp(42px,5.6vw,64px)]",
small: "text-[clamp(28px,4.2vw,46px)]",
},
option: {
large: "text-[clamp(30px,4vw,48px)]",
medium: "text-[clamp(25px,3.2vw,38px)]",
small: "text-[clamp(20px,2.6vw,30px)]",
},
}[variant][size];
return (
<span
className={cn(
"mx-auto block max-w-full whitespace-pre-line text-center font-black leading-[1.05] tracking-normal",
"pk-word-text",
variant === "hero" && "pk-word-text-hero mt-4 text-blue-500 [text-shadow:3px_3px_0_#dbeafe]",
variant === "option" && "pk-word-text-option mt-3 text-slate-900",
`pk-word-size-${size}`,
sizeClass,
isVeryLongSingleWord && "max-w-[min(100%,14ch)] [overflow-wrap:anywhere]",
)}
>
{displayText}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { pkPageCursorClass } from "../constants";
import type { Team } from "../types";
type Winner = Team | "draw";
type EndScreenProps = {
winner: Winner;
scoreRed: number;
scoreBlue: number;
onRestart: () => void;
};
export function EndScreen({ winner, scoreRed, scoreBlue, onRestart }: EndScreenProps) {
return (
<main
className={cn(
pkPageCursorClass,
"pk-end-stage mx-auto grid min-h-[calc(100vh-60px)] max-w-5xl place-items-center px-4 py-4",
)}
>
<section className="pk-end-card w-full rounded-[28px] bg-white p-[clamp(28px,6vw,72px)] text-center shadow-soft">
<div className="pk-end-visual mb-5 text-[clamp(72px,10vw,118px)] leading-none">{winner === "draw" ? "🤝" : "🏆"}</div>
<h1 className="pk-end-title text-[clamp(32px,5vw,54px)] font-black text-rose-500">PK结束</h1>
<p
className={cn(
"pk-end-result mt-4 text-[clamp(26px,4vw,44px)] font-black",
winner === "red" && "text-red-600",
winner === "blue" && "text-blue-600",
winner === "draw" && "text-amber-500",
)}
>
{winner === "red" ? "🔴 红队获胜!" : winner === "blue" ? "🔵 蓝队获胜!" : "平局!势均力敌!"}
</p>
<div className="pk-end-score-grid mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4">
<div className="pk-end-score-card rounded-lg bg-red-50 p-5 text-red-700">
<div className="font-bold"></div>
<div className="pk-end-score text-4xl font-black">{scoreRed}</div>
</div>
<div className="pk-end-score-card rounded-lg bg-blue-50 p-5 text-blue-700">
<div className="font-bold"></div>
<div className="pk-end-score text-4xl font-black">{scoreBlue}</div>
</div>
</div>
<button
onClick={onRestart}
className="pk-end-button mt-8 inline-flex items-center gap-2 rounded-full bg-rose-500 px-10 py-4 text-lg font-bold text-white shadow-lg transition hover:bg-rose-600"
>
<RotateCcw className="size-5" />
</button>
</section>
</main>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { Trophy, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { pkPageCursorClass } from "../constants";
import type { AnswerState, AppSettings, Grade, Team, WordItem } from "../types";
import { formatWordDisplay } from "../utils";
import { AdaptiveWordText } from "./AdaptiveWordText";
import { TeamCard } from "./TeamCard";
import { WordImage } from "./WordImage";
type GameScreenProps = {
selectedGrade: Grade;
selectedClassTypeName?: string;
roundWords: WordItem[];
currentWord: WordItem;
options: WordItem[];
optionCount: number;
questionIndex: number;
currentTeam: Team;
scoreRed: number;
scoreBlue: number;
answerState: AnswerState;
selectedWord: number | null;
speechTip: string;
settings: AppSettings;
onSpeakWord: (word: WordItem) => void;
onCheckAnswer: (word: WordItem) => void;
onNextQuestion: () => void;
};
export function GameScreen({
selectedGrade,
selectedClassTypeName,
roundWords,
currentWord,
options,
optionCount,
questionIndex,
currentTeam,
scoreRed,
scoreBlue,
answerState,
selectedWord,
speechTip,
settings,
onSpeakWord,
onCheckAnswer,
onNextQuestion,
}: GameScreenProps) {
const progress = roundWords.length > 0 ? (questionIndex / roundWords.length) * 100 : 0;
return (
<main className={cn(pkPageCursorClass, "pk-game-stage mx-auto min-h-[calc(100vh-60px)] max-w-7xl px-4 py-4 sm:px-6 sm:py-4")}>
<section className="pk-game-card relative overflow-hidden rounded-[28px] bg-white p-[clamp(18px,3vw,34px)] shadow-soft">
{answerState !== "idle" && (
<div
className={cn(
"pk-answer-feedback z-10 mb-4 flex flex-col items-center gap-3 rounded-lg border bg-white/95 px-4 py-3 text-center text-[clamp(16px,1.8vw,22px)] font-black shadow-lg backdrop-blur md:absolute md:right-[clamp(18px,3vw,34px)] md:top-[clamp(18px,3vw,34px)] md:mb-0 md:max-w-[360px]",
answerState === "correct" && "border-emerald-100 text-emerald-600",
answerState === "wrong" && "border-red-100 text-red-500",
)}
>
<div>
{answerState === "correct" && `🎉 太棒了!答对啦!+${settings.scorePerCorrect}`}
{answerState === "wrong" &&
(settings.scorePerWrong > 0
? `答错啦,扣${settings.scorePerWrong}分,轮到下一队!`
: "答错啦,轮到下一队!")}
</div>
<button
onClick={onNextQuestion}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-7 py-2.5 text-base font-bold text-white shadow-lg transition hover:bg-slate-700"
>
<Trophy className="size-5" />
</button>
</div>
)}
<div className="pk-game-header mb-[clamp(12px,2vw,22px)] text-center">
<h1 className="pk-game-title text-[clamp(26px,3vw,38px)] font-black text-rose-500">PK大作战</h1>
<p className="pk-game-meta mt-2 text-sm font-medium text-slate-500">
{selectedGrade} / {selectedClassTypeName ?? "班型"} / {roundWords.length}
</p>
</div>
<div className="pk-game-grid grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
<TeamCard team="blue" active={currentTeam === "blue"} score={scoreBlue} />
</div>
<div
className={cn(
"pk-current-turn mt-4 rounded-lg px-4 py-3 text-center text-[clamp(18px,2vw,23px)] font-black",
currentTeam === "red" ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
)}
>
{currentTeam === "red" ? "🔴 红队请作答" : "🔵 蓝队请作答"}
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="pk-listen-area flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button
onClick={() => onSpeakWord(currentWord)}
className="pk-speak-button mt-4 grid size-[clamp(62px,6vw,80px)] place-items-center rounded-full bg-rose-400 text-white shadow-lg transition hover:scale-105 hover:bg-rose-500"
aria-label="重新朗读当前单词"
>
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
</div>
</div>
<div className="flex min-w-0 flex-col justify-center">
<div
className={cn(
"pk-answer-grid grid grid-cols-1 gap-[clamp(14px,2vw,22px)] sm:grid-cols-2",
optionCount >= 6 && "xl:grid-cols-3",
)}
>
{options.map((option) => {
const correct = option.id === currentWord.id;
const selected = selectedWord === option.id;
const revealCorrect = answerState !== "idle" && correct;
return (
<button
key={option.id}
onClick={() => onCheckAnswer(option)}
className={cn(
"pk-option-button flex min-h-[clamp(104px,18vh,188px)] flex-col items-center justify-center rounded-[20px] border-4 bg-white p-4 text-[clamp(22px,3vw,34px)] font-black transition hover:-translate-y-1 hover:border-sky-300 hover:shadow-lg",
answerState === "idle" && "border-slate-200",
revealCorrect && "border-emerald-400 bg-emerald-50 text-emerald-700",
selected && answerState === "wrong" && "border-red-400 bg-red-50 text-red-700",
)}
>
<WordImage value={option.image} label={option.english} />
<AdaptiveWordText text={formatWordDisplay(option.english, settings.wordDisplayMode)} variant="option" />
</button>
);
})}
</div>
</div>
</div>
</section>
</main>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { cn } from "@/lib/utils";
import { grades, pkPageCursorClass } from "../constants";
import type { ClassType, Grade } from "../types";
type StartScreenProps = {
selectedGrade: Grade;
classTypes: ClassType[];
selectedClassTypeId: number;
selectedClassTypeName?: string;
availableWordCount: number;
plannedRoundCount: number;
loadingSetup: boolean;
scorePerCorrect: number;
scorePerWrong: number;
onSelectGrade: (grade: Grade) => void;
onSelectClassType: (classTypeId: number) => void;
onStartGame: () => void;
};
export function StartScreen({
selectedGrade,
classTypes,
selectedClassTypeId,
selectedClassTypeName,
availableWordCount,
plannedRoundCount,
loadingSetup,
scorePerCorrect,
scorePerWrong,
onSelectGrade,
onSelectClassType,
onStartGame,
}: StartScreenProps) {
return (
<main
className={cn(
pkPageCursorClass,
"pk-start-stage mx-auto grid min-h-[calc(100vh-60px)] max-w-5xl place-items-center px-4 py-4",
)}
>
<section className="pk-start-card w-full rounded-[24px] bg-white p-[clamp(18px,3vw,36px)] text-center shadow-soft">
<div className="pk-start-visual mb-2 text-[clamp(54px,8vw,92px)] leading-none">🦊 VS 🦓</div>
<h1 className="pk-start-title text-balance text-[clamp(28px,4vw,44px)] font-black text-rose-500">PK赛</h1>
<p className="pk-start-subtitle mt-2 text-[clamp(15px,1.6vw,19px)] text-slate-600">
{scorePerCorrect}
{scorePerWrong > 0 ? `,答错扣${scorePerWrong}分。` : ",答错不扣分。"}
</p>
<div className="pk-start-panel mx-auto mt-5 grid max-w-3xl gap-3 rounded-2xl border bg-slate-50 p-4 text-left">
<div>
<div className="mb-2 text-xs font-semibold text-slate-600"></div>
<div className="grid grid-cols-3 gap-3">
{grades.map((grade) => (
<button
key={grade}
onClick={() => onSelectGrade(grade)}
className={cn(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 text-center text-base font-bold transition hover:border-rose-300",
selectedGrade === grade && "border-rose-500 bg-rose-50 text-rose-700 shadow-sm",
)}
>
{grade}
</button>
))}
</div>
</div>
<div>
<div className="mb-2 text-xs font-semibold text-slate-600"></div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{classTypes.map((classType) => (
<button
key={classType.id}
onClick={() => onSelectClassType(classType.id)}
className={cn(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 text-center text-base font-bold transition hover:border-blue-300",
selectedClassTypeId === classType.id && "border-blue-500 bg-blue-50 text-blue-700 shadow-sm",
)}
>
{classType.name}
</button>
))}
</div>
</div>
<div className="pk-start-summary rounded-lg bg-white px-4 py-2 text-center text-sm text-slate-600">
<span className="font-bold text-slate-900">{selectedGrade}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{selectedClassTypeName ?? "未选择班型"}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-emerald-600">{plannedRoundCount}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{availableWordCount}</span>
</div>
</div>
{!loadingSetup && availableWordCount === 0 && (
<p className="pk-start-warning mt-4 text-sm font-medium text-red-500"></p>
)}
<button
onClick={onStartGame}
disabled={loadingSetup || availableWordCount === 0}
className="pk-start-button mt-5 rounded-full bg-rose-500 px-10 py-3 text-[clamp(17px,1.8vw,21px)] font-bold text-white shadow-lg transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
>
{loadingSetup ? "加载中..." : "开始PK"}
</button>
</section>
</main>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import type { Team } from "../types";
type TeamCardProps = {
team: Team;
active: boolean;
score: number;
};
export function TeamCard({ team, active, score }: TeamCardProps) {
const red = team === "red";
return (
<div
className={cn(
"pk-team-card flex-1 rounded-lg border-4 p-[clamp(10px,1.6vw,16px)] text-center transition",
red ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
active && (red ? "border-red-400 shadow-lg" : "border-blue-400 shadow-lg"),
!active && "border-transparent",
)}
>
<div className="pk-team-label text-[clamp(16px,1.8vw,22px)] font-bold">{red ? "🔴 红队" : "🔵 蓝队"}</div>
<div className="pk-team-score text-[clamp(30px,4vw,48px)] font-black leading-tight">{score}</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { isImageSource } from "../utils";
type WordImageProps = {
value: string;
label: string;
};
export function WordImage({ value, label }: WordImageProps) {
if (!value) {
return null;
}
if (isImageSource(value)) {
return (
<img
src={value}
alt={label}
className="pk-word-image size-[clamp(54px,7vw,92px)] rounded-lg object-contain"
/>
);
}
return <span className="pk-word-emoji text-[clamp(44px,6vw,76px)] leading-none">{value}</span>;
}
+16
View File
@@ -0,0 +1,16 @@
import type { AppSettings, Grade } from "./types";
export const grades: Grade[] = ["小班", "中班", "大班"];
export const pkPageCursorClass = "cursor-hammer";
export const defaultSettings: AppSettings = {
ttsVoice: "en-US-EmmaMultilingualNeural",
ttsRate: "-10%",
scorePerCorrect: 10,
scorePerWrong: 0,
autoSpeak: true,
wordDisplayMode: "uppercase",
pkWordCount: 0,
pkOptionCount: 4,
};
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import type { WordItem } from "../types";
type UsePkAudioOptions = {
onGeneratedAudio: (word: WordItem) => void;
};
export function usePkAudio({ onGeneratedAudio }: UsePkAudioOptions) {
const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。");
const audioContextRef = useRef<AudioContext | null>(null);
function ensureAudioContext() {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
return audioContextRef.current;
}
async function playTone(correct: boolean) {
const audioContext = ensureAudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const duration = correct ? 0.4 : 0.3;
if (correct) {
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
} else {
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
}
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + duration);
}
async function speakWord(word: WordItem) {
if (word.audio) {
const audio = new Audio(word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
return;
}
try {
setSpeechTip(`正在生成语音:${word.english}`);
const response = await fetch(`/api/words/${word.id}/generate-audio`, { method: "POST" });
if (!response.ok) throw new Error("generate failed");
const data = (await response.json()) as { word: WordItem };
const audio = new Audio(data.word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
onGeneratedAudio(data.word);
} catch {
speakSystemWord(word.english);
}
}
function speakSystemWord(word: string) {
if (!("speechSynthesis" in window)) {
setSpeechTip("当前浏览器不支持英文朗读,请老师带读。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
const voices = window.speechSynthesis.getVoices();
utterance.voice =
voices.find((voice) => /^en[-_]?US/i.test(voice.lang)) ??
voices.find((voice) => /^en/i.test(voice.lang)) ??
null;
utterance.lang = utterance.voice?.lang ?? "en-US";
utterance.rate = 0.85;
utterance.pitch = 1;
window.speechSynthesis.speak(utterance);
setSpeechTip(`正在朗读:${word}`);
}
return {
speechTip,
setSpeechTip,
playTone,
speakWord,
};
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { loadSettings } from "../api";
import { defaultSettings } from "../constants";
import type { AppSettings, ClassType, Grade, WordItem } from "../types";
import { mergeSettings } from "../utils";
export function usePkSetup() {
const [selectedGrade, setSelectedGrade] = useState<Grade>("中班");
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [selectedClassTypeId, setSelectedClassTypeId] = useState<number>(0);
const [availableWords, setAvailableWords] = useState<WordItem[]>([]);
const [loadingSetup, setLoadingSetup] = useState(true);
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
useEffect(() => {
async function loadInitialSetup() {
setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
}
window.addEventListener("focus", refreshSettingsOnFocus);
return () => window.removeEventListener("focus", refreshSettingsOnFocus);
}, []);
useEffect(() => {
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
return;
}
const params = new URLSearchParams({
grade: selectedGrade,
classTypeId: String(selectedClassTypeId),
enabledOnly: "true",
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
}
void loadWords();
}, [selectedGrade, selectedClassTypeId]);
return {
selectedGrade,
setSelectedGrade,
classTypes,
selectedClassTypeId,
setSelectedClassTypeId,
availableWords,
setAvailableWords,
loadingSetup,
settings,
};
}
+65 -537
View File
@@ -1,82 +1,26 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { RotateCcw, Trophy, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
type Team = "red" | "blue";
type AnswerState = "idle" | "correct" | "wrong";
type Grade = "小班" | "中班" | "大班";
type ClassType = {
id: number;
name: string;
enabled: boolean;
};
type WordItem = {
id: number;
grade: Grade;
classTypeId: number;
classTypeName: string;
english: string;
chinese: string;
image: string;
audio: string;
enabled: boolean;
};
type AppSettings = {
ttsVoice: string;
ttsRate: string;
scorePerCorrect: number;
scorePerWrong: number;
autoSpeak: boolean;
wordDisplayMode: "uppercase" | "capitalize" | "input";
pkWordCount: number;
pkOptionCount: number;
};
const grades: Grade[] = ["小班", "中班", "大班"];
const pkPageCursorClass = "cursor-hammer";
function shuffleArray<T>(array: T[]) {
const next = [...array];
for (let i = next.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j], next[i]];
}
return next;
}
function TeamCard({
team,
active,
score,
}: {
team: Team;
active: boolean;
score: number;
}) {
const red = team === "red";
return (
<div
className={cn(
"pk-team-card flex-1 rounded-lg border-4 p-[clamp(10px,1.6vw,16px)] text-center transition",
red ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
active && (red ? "border-red-400 shadow-lg" : "border-blue-400 shadow-lg"),
!active && "border-transparent",
)}
>
<div className="pk-team-label text-[clamp(16px,1.8vw,22px)] font-bold">{red ? "🔴 红队" : "🔵 蓝队"}</div>
<div className="pk-team-score text-[clamp(30px,4vw,48px)] font-black leading-tight">{score}</div>
</div>
);
}
import { useMemo, useState } from "react";
import { EndScreen } from "./components/EndScreen";
import { GameScreen } from "./components/GameScreen";
import { StartScreen } from "./components/StartScreen";
import { usePkAudio } from "./hooks/usePkAudio";
import { usePkSetup } from "./hooks/usePkSetup";
import type { AnswerState, Team, WordItem } from "./types";
import { getFairRoundCount, normalizeOptionCount, shuffleArray } from "./utils";
export default function PkPage() {
const [selectedGrade, setSelectedGrade] = useState<Grade>("中班");
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [selectedClassTypeId, setSelectedClassTypeId] = useState<number>(0);
const [availableWords, setAvailableWords] = useState<WordItem[]>([]);
const [loadingSetup, setLoadingSetup] = useState(true);
const {
selectedGrade,
setSelectedGrade,
classTypes,
selectedClassTypeId,
setSelectedClassTypeId,
availableWords,
setAvailableWords,
loadingSetup,
settings,
} = usePkSetup();
const [started, setStarted] = useState(false);
const [ended, setEnded] = useState(false);
const [roundWords, setRoundWords] = useState<WordItem[]>([]);
@@ -86,64 +30,13 @@ export default function PkPage() {
const [scoreBlue, setScoreBlue] = useState(0);
const [answerState, setAnswerState] = useState<AnswerState>("idle");
const [selectedWord, setSelectedWord] = useState<number | null>(null);
const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。");
const [settings, setSettings] = useState<AppSettings>({
ttsVoice: "en-US-EmmaMultilingualNeural",
ttsRate: "-10%",
scorePerCorrect: 10,
scorePerWrong: 0,
autoSpeak: true,
wordDisplayMode: "uppercase",
pkWordCount: 0,
pkOptionCount: 4,
const { speechTip, setSpeechTip, playTone, speakWord } = usePkAudio({
onGeneratedAudio: (word) => {
setRoundWords((items) => items.map((item) => (item.id === word.id ? word : item)));
setAvailableWords((items) => items.map((item) => (item.id === word.id ? word : item)));
},
});
const audioContextRef = useRef<AudioContext | null>(null);
useEffect(() => {
async function loadInitialSetup() {
setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
}
window.addEventListener("focus", refreshSettingsOnFocus);
return () => window.removeEventListener("focus", refreshSettingsOnFocus);
}, []);
useEffect(() => {
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
return;
}
const params = new URLSearchParams({
grade: selectedGrade,
classTypeId: String(selectedClassTypeId),
enabledOnly: "true",
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
}
void loadWords();
}, [selectedGrade, selectedClassTypeId]);
const selectedClassType = classTypes.find((classType) => classType.id === selectedClassTypeId);
const currentWord = roundWords[questionIndex] ?? availableWords[0];
@@ -153,92 +46,9 @@ export default function PkPage() {
const wrongOptions = availableWords.filter((word) => word.id !== currentWord.id);
return shuffleArray([currentWord, ...shuffleArray(wrongOptions).slice(0, optionCount - 1)]);
}, [currentWord, availableWords, optionCount]);
const progress = roundWords.length > 0 ? (questionIndex / roundWords.length) * 100 : 0;
const roundWordLimit = Math.max(0, Math.floor(settings.pkWordCount || 0));
const plannedRoundCount = getFairRoundCount(availableWords.length, roundWordLimit);
const winner =
scoreRed > scoreBlue ? "red" : scoreBlue > scoreRed ? "blue" : "draw";
function ensureAudioContext() {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
return audioContextRef.current;
}
async function playTone(correct: boolean) {
const audioContext = ensureAudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const duration = correct ? 0.4 : 0.3;
if (correct) {
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
} else {
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
}
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + duration);
}
async function speakWord(word: WordItem) {
if (word.audio) {
const audio = new Audio(word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
return;
}
try {
setSpeechTip(`正在生成语音:${word.english}`);
const response = await fetch(`/api/words/${word.id}/generate-audio`, { method: "POST" });
if (!response.ok) throw new Error("generate failed");
const data = (await response.json()) as { word: WordItem };
const audio = new Audio(data.word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
setRoundWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
setAvailableWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
} catch {
speakSystemWord(word.english);
}
}
function speakSystemWord(word: string) {
if (!("speechSynthesis" in window)) {
setSpeechTip("当前浏览器不支持英文朗读,请老师带读。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
const voices = window.speechSynthesis.getVoices();
utterance.voice =
voices.find((voice) => /^en[-_]?US/i.test(voice.lang)) ??
voices.find((voice) => /^en/i.test(voice.lang)) ??
null;
utterance.lang = utterance.voice?.lang ?? "en-US";
utterance.rate = 0.85;
utterance.pitch = 1;
window.speechSynthesis.speak(utterance);
setSpeechTip(`正在朗读:${word}`);
}
const winner = scoreRed > scoreBlue ? "red" : scoreBlue > scoreRed ? "blue" : "draw";
function startGame() {
const nextRound = shuffleArray(availableWords).slice(0, plannedRoundCount);
@@ -262,7 +72,7 @@ export default function PkPage() {
const correct = option.id === currentWord.id;
setSelectedWord(option.id);
setAnswerState(correct ? "correct" : "wrong");
playTone(correct);
void playTone(correct);
if (correct) {
if (currentTeam === "red") setScoreRed((score) => score + settings.scorePerCorrect);
@@ -293,328 +103,46 @@ export default function PkPage() {
if (!started) {
return (
<main className={cn(pkPageCursorClass, "pk-start-stage mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-4")}>
<section className="pk-start-card w-full rounded-[24px] bg-white p-[clamp(18px,3vw,36px)] text-center shadow-soft">
<div className="pk-start-visual mb-2 text-[clamp(54px,8vw,92px)] leading-none">🦊 VS 🦓</div>
<h1 className="pk-start-title text-balance text-[clamp(28px,4vw,44px)] font-black text-rose-500">PK赛</h1>
<p className="pk-start-subtitle mt-2 text-[clamp(15px,1.6vw,19px)] text-slate-600">
{settings.scorePerCorrect}
{settings.scorePerWrong > 0 ? `,答错扣${settings.scorePerWrong}分。` : ",答错不扣分。"}
</p>
<div className="pk-start-panel mx-auto mt-5 grid max-w-3xl gap-3 rounded-2xl border bg-slate-50 p-4 text-left">
<div>
<div className="mb-2 text-xs font-semibold text-slate-600"></div>
<div className="grid grid-cols-3 gap-3">
{grades.map((grade) => (
<button
key={grade}
onClick={() => setSelectedGrade(grade)}
className={cn(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 text-center text-base font-bold transition hover:border-rose-300",
selectedGrade === grade && "border-rose-500 bg-rose-50 text-rose-700 shadow-sm",
)}
>
{grade}
</button>
))}
</div>
</div>
<div>
<div className="mb-2 text-xs font-semibold text-slate-600"></div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{classTypes.map((classType) => (
<button
key={classType.id}
onClick={() => setSelectedClassTypeId(classType.id)}
className={cn(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 text-center text-base font-bold transition hover:border-blue-300",
selectedClassTypeId === classType.id && "border-blue-500 bg-blue-50 text-blue-700 shadow-sm",
)}
>
{classType.name}
</button>
))}
</div>
</div>
<div className="pk-start-summary rounded-lg bg-white px-4 py-2 text-center text-sm text-slate-600">
<span className="font-bold text-slate-900">{selectedGrade}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{selectedClassType?.name ?? "未选择班型"}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-emerald-600">{plannedRoundCount}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{availableWords.length}</span>
</div>
</div>
{!loadingSetup && availableWords.length === 0 && (
<p className="pk-start-warning mt-4 text-sm font-medium text-red-500"></p>
)}
<button
onClick={startGame}
disabled={loadingSetup || availableWords.length === 0}
className="pk-start-button mt-5 rounded-full bg-rose-500 px-10 py-3 text-[clamp(17px,1.8vw,21px)] font-bold text-white shadow-lg transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
>
{loadingSetup ? "加载中..." : "开始PK"}
</button>
</section>
</main>
);
}
if (ended) {
return (
<main className={cn(pkPageCursorClass, "pk-end-stage mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8")}>
<section className="pk-end-card w-full rounded-[28px] bg-white p-[clamp(28px,6vw,72px)] text-center shadow-soft">
<div className="pk-end-visual mb-5 text-[clamp(72px,10vw,118px)] leading-none">{winner === "draw" ? "🤝" : "🏆"}</div>
<h1 className="pk-end-title text-[clamp(32px,5vw,54px)] font-black text-rose-500">PK结束</h1>
<p
className={cn(
"pk-end-result mt-4 text-[clamp(26px,4vw,44px)] font-black",
winner === "red" && "text-red-600",
winner === "blue" && "text-blue-600",
winner === "draw" && "text-amber-500",
)}
>
{winner === "red" ? "🔴 红队获胜!" : winner === "blue" ? "🔵 蓝队获胜!" : "平局!势均力敌!"}
</p>
<div className="pk-end-score-grid mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4">
<div className="pk-end-score-card rounded-lg bg-red-50 p-5 text-red-700">
<div className="font-bold"></div>
<div className="pk-end-score text-4xl font-black">{scoreRed}</div>
</div>
<div className="pk-end-score-card rounded-lg bg-blue-50 p-5 text-blue-700">
<div className="font-bold"></div>
<div className="pk-end-score text-4xl font-black">{scoreBlue}</div>
</div>
</div>
<button
onClick={startGame}
className="pk-end-button mt-8 inline-flex items-center gap-2 rounded-full bg-rose-500 px-10 py-4 text-lg font-bold text-white shadow-lg transition hover:bg-rose-600"
>
<RotateCcw className="size-5" />
</button>
</section>
</main>
);
}
return (
<main className={cn(pkPageCursorClass, "pk-game-stage mx-auto min-h-[calc(100vh-56px)] max-w-7xl px-4 py-5 sm:px-6 sm:py-8")}>
<section className="pk-game-card relative overflow-hidden rounded-[28px] bg-white p-[clamp(18px,3vw,34px)] shadow-soft">
{answerState !== "idle" && (
<div
className={cn(
"pk-answer-feedback z-10 mb-4 flex flex-col items-center gap-3 rounded-lg border bg-white/95 px-4 py-3 text-center text-[clamp(16px,1.8vw,22px)] font-black shadow-lg backdrop-blur md:absolute md:right-[clamp(18px,3vw,34px)] md:top-[clamp(18px,3vw,34px)] md:mb-0 md:max-w-[360px]",
answerState === "correct" && "border-emerald-100 text-emerald-600",
answerState === "wrong" && "border-red-100 text-red-500",
)}
>
<div>
{answerState === "correct" && `🎉 太棒了!答对啦!+${settings.scorePerCorrect}`}
{answerState === "wrong" &&
(settings.scorePerWrong > 0
? `答错啦,扣${settings.scorePerWrong}分,轮到下一队!`
: "答错啦,轮到下一队!")}
</div>
<button
onClick={nextQuestion}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-7 py-2.5 text-base font-bold text-white shadow-lg transition hover:bg-slate-700"
>
<Trophy className="size-5" />
</button>
</div>
)}
<div className="pk-game-header mb-[clamp(12px,2vw,22px)] text-center">
<h1 className="pk-game-title text-[clamp(26px,3vw,38px)] font-black text-rose-500">PK大作战</h1>
<p className="pk-game-meta mt-2 text-sm font-medium text-slate-500">
{selectedGrade} / {selectedClassType?.name ?? "班型"} / {roundWords.length}
</p>
</div>
<div className="pk-game-grid grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
<TeamCard team="blue" active={currentTeam === "blue"} score={scoreBlue} />
</div>
<div
className={cn(
"pk-current-turn mt-4 rounded-lg px-4 py-3 text-center text-[clamp(18px,2vw,23px)] font-black",
currentTeam === "red" ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
)}
>
{currentTeam === "red" ? "🔴 红队请作答" : "🔵 蓝队请作答"}
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="pk-listen-area flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button
onClick={() => void speakWord(currentWord)}
className="pk-speak-button mt-4 grid size-[clamp(62px,6vw,80px)] place-items-center rounded-full bg-rose-400 text-white shadow-lg transition hover:scale-105 hover:bg-rose-500"
aria-label="重新朗读当前单词"
>
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
</div>
</div>
<div className="flex min-w-0 flex-col justify-center">
<div
className={cn(
"pk-answer-grid grid grid-cols-1 gap-[clamp(14px,2vw,22px)] sm:grid-cols-2",
optionCount >= 6 && "xl:grid-cols-3",
)}
>
{options.map((option) => {
const correct = option.id === currentWord.id;
const selected = selectedWord === option.id;
const revealCorrect = answerState !== "idle" && correct;
return (
<button
key={option.id}
onClick={() => checkAnswer(option)}
className={cn(
"pk-option-button flex min-h-[clamp(104px,18vh,188px)] flex-col items-center justify-center rounded-[20px] border-4 bg-white p-4 text-[clamp(22px,3vw,34px)] font-black transition hover:-translate-y-1 hover:border-sky-300 hover:shadow-lg",
answerState === "idle" && "border-slate-200",
revealCorrect && "border-emerald-400 bg-emerald-50 text-emerald-700",
selected && answerState === "wrong" && "border-red-400 bg-red-50 text-red-700",
)}
>
<WordImage value={option.image} label={option.english} />
<AdaptiveWordText text={formatWordDisplay(option.english, settings.wordDisplayMode)} variant="option" />
</button>
);
})}
</div>
</div>
</div>
</section>
</main>
);
}
function AdaptiveWordText({ text, variant }: { text: string; variant: "hero" | "option" }) {
const displayText = text.trim().replace(/\s+/g, "\n");
const compactLength = text.replace(/\s+/g, "").length;
const isVeryLongSingleWord = compactLength > 16 && !/\s/.test(text.trim());
const size =
compactLength <= 6
? "large"
: compactLength <= 10
? "medium"
: "small";
const sizeClass = {
hero: {
large: "text-[clamp(52px,7vw,82px)]",
medium: "text-[clamp(42px,5.6vw,64px)]",
small: "text-[clamp(28px,4.2vw,46px)]",
},
option: {
large: "text-[clamp(30px,4vw,48px)]",
medium: "text-[clamp(25px,3.2vw,38px)]",
small: "text-[clamp(20px,2.6vw,30px)]",
},
}[variant][size];
return (
<span
className={cn(
"mx-auto block max-w-full whitespace-pre-line text-center font-black leading-[1.05] tracking-normal",
"pk-word-text",
variant === "hero" && "pk-word-text-hero mt-4 text-blue-500 [text-shadow:3px_3px_0_#dbeafe]",
variant === "option" && "pk-word-text-option mt-3 text-slate-900",
`pk-word-size-${size}`,
sizeClass,
isVeryLongSingleWord && "max-w-[min(100%,14ch)] [overflow-wrap:anywhere]",
)}
>
{displayText}
</span>
);
}
async function loadSettings() {
const response = await fetch("/api/settings", { cache: "no-store" });
const data = (await response.json()) as { settings: Partial<AppSettings> };
return data.settings;
}
function mergeSettings(settings: Partial<AppSettings>): AppSettings {
return {
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
ttsRate: settings.ttsRate || "-10%",
scorePerCorrect: Number(settings.scorePerCorrect ?? 10),
scorePerWrong: Number(settings.scorePerWrong ?? 0),
autoSpeak: settings.autoSpeak ?? true,
wordDisplayMode:
settings.wordDisplayMode === "capitalize" || settings.wordDisplayMode === "input" || settings.wordDisplayMode === "uppercase"
? settings.wordDisplayMode
: "uppercase",
pkWordCount: Math.max(0, Math.floor(Number(settings.pkWordCount ?? 0))),
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount ?? 4)),
};
}
function getFairRoundCount(availableCount: number, limit: number) {
const requestedCount = limit > 0 ? Math.min(limit, availableCount) : availableCount;
if (requestedCount < 2) return requestedCount;
return requestedCount % 2 === 0 ? requestedCount : requestedCount - 1;
}
function formatWordDisplay(text: string, mode: AppSettings["wordDisplayMode"]) {
if (mode === "input") return text;
if (mode === "capitalize") {
return text
.split(/(\s+)/)
.map((part) => {
if (/^\s+$/.test(part) || !part) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join("");
}
return text.toUpperCase();
}
function normalizeOptionCount(value: number) {
const count = Math.floor(value);
if (!Number.isFinite(count)) return 4;
if (count < 2) return 2;
if (count > 8) return 8;
return count % 2 === 0 ? count : count + 1;
}
function WordImage({ value, label }: { value: string; label: string }) {
if (!value) {
return null;
}
if (/^(https?:\/\/|\/|data:image\/)/i.test(value)) {
return (
<img
src={value}
alt={label}
className="pk-word-image size-[clamp(54px,7vw,92px)] rounded-lg object-contain"
<StartScreen
selectedGrade={selectedGrade}
classTypes={classTypes}
selectedClassTypeId={selectedClassTypeId}
selectedClassTypeName={selectedClassType?.name}
availableWordCount={availableWords.length}
plannedRoundCount={plannedRoundCount}
loadingSetup={loadingSetup}
scorePerCorrect={settings.scorePerCorrect}
scorePerWrong={settings.scorePerWrong}
onSelectGrade={setSelectedGrade}
onSelectClassType={setSelectedClassTypeId}
onStartGame={startGame}
/>
);
}
return <span className="pk-word-emoji text-[clamp(44px,6vw,76px)] leading-none">{value}</span>;
if (ended) {
return <EndScreen winner={winner} scoreRed={scoreRed} scoreBlue={scoreBlue} onRestart={startGame} />;
}
return (
<GameScreen
selectedGrade={selectedGrade}
selectedClassTypeName={selectedClassType?.name}
roundWords={roundWords}
currentWord={currentWord}
options={options}
optionCount={optionCount}
questionIndex={questionIndex}
currentTeam={currentTeam}
scoreRed={scoreRed}
scoreBlue={scoreBlue}
answerState={answerState}
selectedWord={selectedWord}
speechTip={speechTip}
settings={settings}
onSpeakWord={(word) => void speakWord(word)}
onCheckAnswer={checkAnswer}
onNextQuestion={nextQuestion}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
export type Team = "red" | "blue";
export type AnswerState = "idle" | "correct" | "wrong";
export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
name: string;
enabled: boolean;
};
export type WordItem = {
id: number;
grade: Grade;
classTypeId: number;
classTypeName: string;
english: string;
chinese: string;
image: string;
audio: string;
enabled: boolean;
};
export type AppSettings = {
ttsVoice: string;
ttsRate: string;
scorePerCorrect: number;
scorePerWrong: number;
autoSpeak: boolean;
wordDisplayMode: "uppercase" | "capitalize" | "input";
pkWordCount: number;
pkOptionCount: number;
};
+61
View File
@@ -0,0 +1,61 @@
import { defaultSettings } from "./constants";
import type { AppSettings } from "./types";
export function shuffleArray<T>(array: T[]) {
const next = [...array];
for (let i = next.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j], next[i]];
}
return next;
}
export function mergeSettings(settings: Partial<AppSettings>): AppSettings {
return {
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,
ttsRate: settings.ttsRate || defaultSettings.ttsRate,
scorePerCorrect: Number(settings.scorePerCorrect ?? defaultSettings.scorePerCorrect),
scorePerWrong: Number(settings.scorePerWrong ?? defaultSettings.scorePerWrong),
autoSpeak: settings.autoSpeak ?? defaultSettings.autoSpeak,
wordDisplayMode:
settings.wordDisplayMode === "capitalize" ||
settings.wordDisplayMode === "input" ||
settings.wordDisplayMode === "uppercase"
? settings.wordDisplayMode
: defaultSettings.wordDisplayMode,
pkWordCount: Math.max(0, Math.floor(Number(settings.pkWordCount ?? defaultSettings.pkWordCount))),
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount ?? defaultSettings.pkOptionCount)),
};
}
export function getFairRoundCount(availableCount: number, limit: number) {
const requestedCount = limit > 0 ? Math.min(limit, availableCount) : availableCount;
if (requestedCount < 2) return requestedCount;
return requestedCount % 2 === 0 ? requestedCount : requestedCount - 1;
}
export function formatWordDisplay(text: string, mode: AppSettings["wordDisplayMode"]) {
if (mode === "input") return text;
if (mode === "capitalize") {
return text
.split(/(\s+)/)
.map((part) => {
if (/^\s+$/.test(part) || !part) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join("");
}
return text.toUpperCase();
}
export function normalizeOptionCount(value: number) {
const count = Math.floor(value);
if (!Number.isFinite(count)) return defaultSettings.pkOptionCount;
if (count < 2) return 2;
if (count > 8) return 8;
return count % 2 === 0 ? count : count + 1;
}
export function isImageSource(value: string) {
return /^(https?:\/\/|\/|data:image\/)/i.test(value);
}