articulate(app): port single-device word game from partiverse + tests
中英猜词派对 — 选主题 / 难度 / 词数 → 大字模式描述给队友猜,无 room / 无 ws。 15 个 preset 主题(wordlists 已在 scaffold 时就位)+ 3 档难度 + 已看词跨场 记忆(localStorage,cap 5000)+ Enter/Space/Esc 键盘。pickWords 优先未看过 再 fallback 见过的。logic 层 24 个 vitest(解析 / 抽词 / 确定性 rng)。
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { Word } from './wordlist'
|
||||
|
||||
export interface GameConfig {
|
||||
topic: string | null
|
||||
difficulty: 1 | 2 | 3
|
||||
totalWords: number
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
config: GameConfig
|
||||
queue: Word[]
|
||||
currentIndex: number
|
||||
correctCount: number
|
||||
passCount: number
|
||||
}
|
||||
|
||||
interface PersistedState {
|
||||
game: GameState | null
|
||||
seenWords: string[] // wordKey list
|
||||
}
|
||||
|
||||
const KEY = 'articulate:v1'
|
||||
const MAX_SEEN = 5000
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
if (!isBrowser()) return { game: null, seenWords: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return { game: null, seenWords: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedState>
|
||||
return {
|
||||
game: parsed.game ?? null,
|
||||
seenWords: parsed.seenWords ?? [],
|
||||
}
|
||||
} catch {
|
||||
return { game: null, seenWords: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: PersistedState): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
// 限制 seen list 大小
|
||||
const seen = state.seenWords.slice(-MAX_SEEN)
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ ...state, seenWords: seen }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function addSeen(seen: string[], keys: string[]): string[] {
|
||||
const set = new Set(seen)
|
||||
const out = [...seen]
|
||||
for (const k of keys) {
|
||||
if (!set.has(k)) {
|
||||
set.add(k)
|
||||
out.push(k)
|
||||
}
|
||||
}
|
||||
return out.slice(-MAX_SEEN)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// preset topic 列表与 wordlist 文件路径。
|
||||
|
||||
export interface PresetTopic {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const PRESET_TOPICS: PresetTopic[] = [
|
||||
{ value: 'animals', label: '动物' },
|
||||
{ value: 'food', label: '食物' },
|
||||
{ value: 'places', label: '地点' },
|
||||
{ value: 'objects', label: '物品' },
|
||||
{ value: 'actions', label: '动作' },
|
||||
{ value: 'colors', label: '颜色' },
|
||||
{ value: 'emotions', label: '情感' },
|
||||
{ value: 'sports', label: '运动' },
|
||||
{ value: 'professions', label: '职业' },
|
||||
{ value: 'nature', label: '自然' },
|
||||
{ value: 'body', label: '身体' },
|
||||
{ value: 'clothing', label: '服装' },
|
||||
{ value: 'vehicles', label: '交通工具' },
|
||||
{ value: 'music', label: '音乐' },
|
||||
{ value: 'technology', label: '科技' },
|
||||
]
|
||||
|
||||
export function wordlistUrl(topic: string): string {
|
||||
return `/wordlists/${encodeURIComponent(topic)}.txt`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Wordlist 解析 + 抽词。纯函数,可单测。
|
||||
|
||||
export interface Word {
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
english: string
|
||||
chinese: string
|
||||
}
|
||||
|
||||
export interface Rng {
|
||||
next(): number
|
||||
}
|
||||
export const defaultRng: Rng = { next: () => Math.random() }
|
||||
|
||||
/** 解析一行 "easy - run - 跑"。空行或格式错误返回 null。 */
|
||||
export function parseWordlistLine(line: string): Word | null {
|
||||
const s = line.trim()
|
||||
if (!s) return null
|
||||
const parts = s.split(' - ').map((p) => p.trim())
|
||||
if (parts.length !== 3) return null
|
||||
const [difficulty, english, chinese] = parts
|
||||
if (difficulty !== 'easy' && difficulty !== 'medium' && difficulty !== 'hard') return null
|
||||
if (!english || !chinese) return null
|
||||
return { difficulty, english, chinese }
|
||||
}
|
||||
|
||||
/** 解析一整个 wordlist 文件文本。跳过空行 / 格式错误行。 */
|
||||
export function parseWordlist(text: string): Word[] {
|
||||
return text.split(/\r?\n/).map(parseWordlistLine).filter((w): w is Word => w !== null)
|
||||
}
|
||||
|
||||
/** difficulty 数字 → 包含的难度等级。 */
|
||||
export function difficultyLevels(d: 1 | 2 | 3): Word['difficulty'][] {
|
||||
if (d === 1) return ['easy']
|
||||
if (d === 2) return ['easy', 'medium']
|
||||
return ['easy', 'medium', 'hard']
|
||||
}
|
||||
|
||||
function wordKey(w: Word): string {
|
||||
return `${w.english}|${w.chinese}`
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[], rng: Rng): T[] {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng.next() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 pool 抽 count 个词,优先未见过的。
|
||||
* 如果未见过的不够,用见过的补足,避免凑不齐。
|
||||
*/
|
||||
export function pickWords(
|
||||
pool: Word[],
|
||||
difficulty: 1 | 2 | 3,
|
||||
count: number,
|
||||
seen: Set<string>,
|
||||
rng: Rng = defaultRng,
|
||||
): Word[] {
|
||||
const levels = new Set(difficultyLevels(difficulty))
|
||||
const filtered = pool.filter((w) => levels.has(w.difficulty))
|
||||
const unseen = filtered.filter((w) => !seen.has(wordKey(w)))
|
||||
const seenShuffled = shuffle(
|
||||
filtered.filter((w) => seen.has(wordKey(w))),
|
||||
rng,
|
||||
)
|
||||
const unseenShuffled = shuffle(unseen, rng)
|
||||
|
||||
const picked: Word[] = []
|
||||
for (const w of unseenShuffled) {
|
||||
if (picked.length >= count) break
|
||||
picked.push(w)
|
||||
}
|
||||
for (const w of seenShuffled) {
|
||||
if (picked.length >= count) break
|
||||
picked.push(w)
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
export function wordKeyOf(w: Word): string {
|
||||
return wordKey(w)
|
||||
}
|
||||
Reference in New Issue
Block a user