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:
Fam Zheng
2026-05-14 15:32:15 +01:00
parent 0b22691b3d
commit 78f84d4225
11 changed files with 3869 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import NewGameModal from './components/NewGameModal.vue'
import WordModal from './components/WordModal.vue'
import { PRESET_TOPICS, wordlistUrl } from './logic/topics'
import { parseWordlist, pickWords, wordKeyOf, type Word } from './logic/wordlist'
import { addSeen, loadState, saveState, type GameState } from './logic/storage'
const game = ref<GameState | null>(null)
const seenWords = ref<string[]>([])
const showConfig = ref(false)
const showWord = ref(false)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
onMounted(() => {
const s = loadState()
game.value = s.game
seenWords.value = s.seenWords
})
watch(
[game, seenWords],
() => saveState({ game: game.value, seenWords: seenWords.value }),
{ deep: true },
)
const currentWord = computed<Word | null>(() => {
if (!game.value) return null
if (game.value.currentIndex >= game.value.queue.length) return null
return game.value.queue[game.value.currentIndex]
})
const isComplete = computed(() => {
if (!game.value) return false
return game.value.currentIndex >= game.value.queue.length
})
const remaining = computed(() => {
if (!game.value) return 0
return Math.max(0, game.value.queue.length - game.value.currentIndex)
})
function difficultyLabel(d: 1 | 2 | 3): string {
return d === 1 ? '简单' : d === 2 ? '中等' : '困难'
}
async function fetchTopic(topic: string): Promise<Word[]> {
const res = await fetch(wordlistUrl(topic))
if (!res.ok) throw new Error(`无法加载主题 ${topic}${res.status}`)
return parseWordlist(await res.text())
}
async function loadPool(topic: string | null): Promise<Word[]> {
if (topic) {
return await fetchTopic(topic)
}
const all = await Promise.all(PRESET_TOPICS.map((t) => fetchTopic(t.value).catch(() => [] as Word[])))
return all.flat()
}
async function onStart(cfg: { topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }) {
loading.value = true
errorMsg.value = null
try {
const pool = await loadPool(cfg.topic)
if (pool.length === 0) {
errorMsg.value = `主题 "${cfg.topic ?? 'any'}" 没有可用单词`
return
}
const seenSet = new Set(seenWords.value)
const picked = pickWords(pool, cfg.difficulty, cfg.totalWords, seenSet)
if (picked.length === 0) {
errorMsg.value = '无法生成单词,请换一个主题或难度'
return
}
game.value = {
config: { topic: cfg.topic, difficulty: cfg.difficulty, totalWords: cfg.totalWords },
queue: picked,
currentIndex: 0,
correctCount: 0,
passCount: 0,
}
seenWords.value = addSeen(seenWords.value, picked.map(wordKeyOf))
showConfig.value = false
} catch (e) {
errorMsg.value = (e as Error).message
} finally {
loading.value = false
}
}
function onCorrect() {
if (!game.value || isComplete.value) return
game.value = {
...game.value,
correctCount: game.value.correctCount + 1,
currentIndex: game.value.currentIndex + 1,
}
if (isComplete.value) showWord.value = false
}
function onPass() {
if (!game.value || isComplete.value) return
game.value = {
...game.value,
passCount: game.value.passCount + 1,
currentIndex: game.value.currentIndex + 1,
}
if (isComplete.value) showWord.value = false
}
function reset() {
if (!confirm('确定重置游戏?')) return
game.value = null
}
</script>
<template>
<main>
<header class="topbar">
<h1>🎴 Articulate</h1>
<div class="actions">
<button v-if="game" class="ghost" @click="reset">重置</button>
<button class="primary" @click="showConfig = true">{{ game ? '新一轮' : '开始游戏' }}</button>
</div>
</header>
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<section v-if="loading" class="hint-block">
<p>加载词库中...</p>
</section>
<section v-else-if="!game" class="hint-block">
<p>中英猜词游戏选好主题难度词数 一人描述全队猜</p>
<p class="dim">看到中文不能说英文 / 看到英文不能说中文猜对按 跳过按 </p>
</section>
<section v-else class="board">
<div v-if="!isComplete && currentWord" class="word" @click="showWord = true">
<div class="zh">{{ currentWord.chinese }}</div>
<div class="en">{{ currentWord.english }}</div>
</div>
<div v-else class="done">
<div class="emoji">🎉</div>
<h2>游戏结束</h2>
<p>所有单词已完成</p>
</div>
<div class="actions-row">
<button class="ok" :disabled="isComplete" @click="onCorrect">
<span class="ic"></span> 猜对了
</button>
<button class="warn" :disabled="isComplete" @click="onPass">
<span class="ic"></span> 跳过
</button>
</div>
<div class="stats">
<div class="stat ok"><div class="lbl">猜对</div><div class="val">{{ game.correctCount }}</div></div>
<div class="stat warn"><div class="lbl">跳过</div><div class="val">{{ game.passCount }}</div></div>
<div class="stat info"><div class="lbl">剩余</div><div class="val">{{ remaining }}</div></div>
</div>
<div class="info">
<span v-if="game.config.topic">主题{{ game.config.topic }}</span>
<span>难度{{ difficultyLabel(game.config.difficulty) }}</span>
<span>已记忆 {{ seenWords.length }} </span>
</div>
</section>
<WordModal
:show="showWord"
:word="currentWord"
:is-complete="isComplete"
@close="showWord = false"
@correct="onCorrect"
@pass="onPass"
/>
<NewGameModal
:show="showConfig"
:initial="game?.config"
@close="showConfig = false"
@start="onStart"
/>
</main>
</template>
<style scoped>
main {
max-width: 600px;
margin: 0 auto;
padding: 16px 16px 40px;
}
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.5rem; }
.actions { display: flex; gap: 8px; }
button.primary { background: var(--accent); border: none; color: white; padding: 10px 16px; border-radius: 8px; font-weight: bold; }
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--fg); padding: 10px 14px; border-radius: 8px; }
.error { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.4); color: #ff8080; padding: 10px 14px; border-radius: 8px; }
.hint-block { padding: 30px; text-align: center; background: var(--bg-soft); border-radius: 12px; color: var(--fg-dim); }
.hint-block .dim { font-size: 0.85rem; opacity: 0.7; margin-top: 12px; }
.word {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 28px 20px;
text-align: center;
cursor: pointer;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
}
.zh { font-size: 3rem; font-weight: bold; line-height: 1.1; margin-bottom: 8px; }
.en { font-size: 2rem; opacity: 0.9; line-height: 1.1; }
.done {
text-align: center;
background: var(--bg-soft);
border-radius: 16px;
padding: 40px;
margin-bottom: 20px;
}
.done .emoji { font-size: 60px; margin-bottom: 8px; }
.done h2 { margin: 4px 0; }
.actions-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;
}
.actions-row button {
padding: 18px;
border: none;
border-radius: 12px;
color: white;
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.actions-row button.ok { background: linear-gradient(135deg, #4CAF50, #45a049); }
.actions-row button.warn { background: linear-gradient(135deg, #FF9800, #F57C00); }
.actions-row button:disabled { background: #444; color: #888; }
.ic { font-size: 1.1rem; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.stat {
background: var(--bg-soft);
border-radius: 8px;
padding: 10px 12px;
border-left: 4px solid var(--border);
}
.stat.ok { border-left-color: var(--accent); }
.stat.warn { border-left-color: var(--warn); }
.stat.info { border-left-color: #2196F3; }
.lbl { font-size: 0.78rem; color: var(--fg-dim); }
.val { font-size: 1.2rem; font-weight: bold; }
.info {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.85rem;
color: var(--fg-dim);
padding: 12px;
background: var(--bg-soft);
border-radius: 8px;
justify-content: center;
}
</style>
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { addSeen } from '../logic/storage'
describe('addSeen', () => {
it('appends new keys to the end', () => {
expect(addSeen(['a', 'b'], ['c', 'd'])).toEqual(['a', 'b', 'c', 'd'])
})
it('deduplicates existing keys', () => {
expect(addSeen(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c'])
})
it('handles all-duplicate addition', () => {
expect(addSeen(['a', 'b'], ['a', 'b'])).toEqual(['a', 'b'])
})
it('respects MAX_SEEN cap (5000) by trimming oldest', () => {
const existing = Array.from({ length: 5000 }, (_, i) => `k${i}`)
const out = addSeen(existing, ['new1', 'new2'])
expect(out).toHaveLength(5000)
expect(out[out.length - 1]).toBe('new2')
expect(out[out.length - 2]).toBe('new1')
// 最旧的 'k0' 和 'k1' 被挤出
expect(out.includes('k0')).toBe(false)
expect(out.includes('k1')).toBe(false)
})
it('empty input', () => {
expect(addSeen([], [])).toEqual([])
expect(addSeen([], ['a'])).toEqual(['a'])
expect(addSeen(['a'], [])).toEqual(['a'])
})
})
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
difficultyLevels,
parseWordlist,
parseWordlistLine,
pickWords,
wordKeyOf,
type Rng,
type Word,
} from '../logic/wordlist'
function mulberry32(seed: number): Rng {
let a = seed
return {
next() {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
},
}
}
describe('parseWordlistLine', () => {
it('parses a valid easy line', () => {
expect(parseWordlistLine('easy - run - 跑')).toEqual({
difficulty: 'easy',
english: 'run',
chinese: '跑',
})
})
it('parses medium / hard', () => {
expect(parseWordlistLine('medium - hop - 单脚跳')?.difficulty).toBe('medium')
expect(parseWordlistLine('hard - perambulate - 漫步')?.difficulty).toBe('hard')
})
it('rejects empty / whitespace lines', () => {
expect(parseWordlistLine('')).toBeNull()
expect(parseWordlistLine(' ')).toBeNull()
})
it('rejects wrong field count', () => {
expect(parseWordlistLine('easy - run')).toBeNull()
expect(parseWordlistLine('easy - run - 跑 - extra')).toBeNull()
})
it('rejects unknown difficulty', () => {
expect(parseWordlistLine('insane - run - 跑')).toBeNull()
})
it('rejects empty english/chinese', () => {
expect(parseWordlistLine('easy - - 跑')).toBeNull()
expect(parseWordlistLine('easy - run - ')).toBeNull()
})
})
describe('parseWordlist', () => {
it('parses multi-line text and skips bad lines', () => {
const text = ['easy - run - 跑', '', 'invalid line', 'medium - hop - 单脚跳', 'easy - walk - 走'].join('\n')
const out = parseWordlist(text)
expect(out).toHaveLength(3)
expect(out.map((w) => w.english)).toEqual(['run', 'hop', 'walk'])
})
it('handles CRLF line endings', () => {
const text = 'easy - run - 跑\r\neasy - walk - 走\r\n'
expect(parseWordlist(text)).toHaveLength(2)
})
})
describe('difficultyLevels', () => {
it('1 = easy only', () => {
expect(difficultyLevels(1)).toEqual(['easy'])
})
it('2 = easy + medium', () => {
expect(difficultyLevels(2)).toEqual(['easy', 'medium'])
})
it('3 = all three', () => {
expect(difficultyLevels(3)).toEqual(['easy', 'medium', 'hard'])
})
})
describe('pickWords', () => {
const pool: Word[] = [
{ difficulty: 'easy', english: 'run', chinese: '跑' },
{ difficulty: 'easy', english: 'walk', chinese: '走' },
{ difficulty: 'easy', english: 'jump', chinese: '跳' },
{ difficulty: 'medium', english: 'hop', chinese: '单脚跳' },
{ difficulty: 'hard', english: 'perambulate', chinese: '漫步' },
]
it('respects difficulty level filter', () => {
const out = pickWords(pool, 1, 10, new Set(), mulberry32(1))
expect(out.every((w) => w.difficulty === 'easy')).toBe(true)
expect(out).toHaveLength(3)
})
it('includes easy + medium for difficulty 2', () => {
const out = pickWords(pool, 2, 10, new Set(), mulberry32(1))
expect(out).toHaveLength(4)
expect(out.some((w) => w.difficulty === 'hard')).toBe(false)
})
it('prefers unseen words', () => {
const seen = new Set(['walk|走', 'jump|跳'])
const out = pickWords(pool, 1, 1, seen, mulberry32(1))
// 唯一未见过的 easy 词是 'run'
expect(out).toHaveLength(1)
expect(out[0].english).toBe('run')
})
it('falls back to seen words when unseen pool runs out', () => {
const seen = new Set(['run|跑', 'walk|走', 'jump|跳'])
const out = pickWords(pool, 1, 3, seen, mulberry32(1))
expect(out).toHaveLength(3) // all from seen since no unseen left
})
it('returns at most `count` words', () => {
const out = pickWords(pool, 3, 2, new Set(), mulberry32(1))
expect(out).toHaveLength(2)
})
it('handles empty pool', () => {
const out = pickWords([], 2, 10, new Set(), mulberry32(1))
expect(out).toEqual([])
})
it('is deterministic given a deterministic rng', () => {
const a = pickWords(pool, 3, 3, new Set(), mulberry32(7))
const b = pickWords(pool, 3, 3, new Set(), mulberry32(7))
expect(a).toEqual(b)
})
})
describe('wordKeyOf', () => {
it('produces stable english|chinese key', () => {
expect(wordKeyOf({ difficulty: 'easy', english: 'run', chinese: '跑' })).toBe('run|跑')
})
})
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PRESET_TOPICS } from '../logic/topics'
const props = defineProps<{
show: boolean
initial?: { topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }
}>()
const emit = defineEmits<{
close: []
start: [{ topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }]
}>()
const topic = ref<string>(props.initial?.topic ?? '')
const difficulty = ref<1 | 2 | 3>(props.initial?.difficulty ?? 2)
const wordCount = ref<number>(props.initial?.totalWords ?? 30)
const wordCountOptions = [5, 10, 15, 20, 25, 30]
const randomFlag = ref(false)
watch(
() => props.show,
(s) => {
if (s && props.initial) {
topic.value = props.initial.topic ?? ''
difficulty.value = props.initial.difficulty
wordCount.value = props.initial.totalWords
randomFlag.value = false
}
},
)
const isRandomSelected = computed(() => randomFlag.value && PRESET_TOPICS.some((t) => t.value === topic.value))
const summaryTopic = computed(() => {
if (!topic.value) return '任意(所有主题)'
const preset = PRESET_TOPICS.find((t) => t.value === topic.value)
return preset ? preset.label : topic.value
})
function selectPreset(v: string) {
topic.value = v
randomFlag.value = false
}
function selectRandom() {
topic.value = PRESET_TOPICS[Math.floor(Math.random() * PRESET_TOPICS.length)].value
randomFlag.value = true
}
function selectAny() {
topic.value = ''
randomFlag.value = false
}
function difficultyLabel(d: 1 | 2 | 3): string {
return d === 1 ? '简单' : d === 2 ? '中等' : '困难'
}
function start() {
emit('start', {
topic: topic.value || null,
difficulty: difficulty.value,
totalWords: wordCount.value,
})
}
</script>
<template>
<div v-if="show" class="overlay" @click.self="$emit('close')">
<div class="modal">
<header>
<h2>Articulate · 新游戏</h2>
<button class="x" @click="$emit('close')"></button>
</header>
<section>
<label>主题</label>
<div class="row">
<button
v-for="p in PRESET_TOPICS"
:key="p.value"
:class="['chip', { selected: topic === p.value && !isRandomSelected }]"
@click="selectPreset(p.value)"
>
{{ p.label }}
</button>
<button :class="['chip', { selected: isRandomSelected }]" @click="selectRandom">随机</button>
<button :class="['chip', { selected: topic === '' && !isRandomSelected }]" @click="selectAny">任意</button>
</div>
<input
v-model="topic"
type="text"
placeholder="或输入自定义主题名(如 'animals'"
class="text"
/>
</section>
<section>
<label>难度</label>
<div class="row">
<button
v-for="d in ([1, 2, 3] as const)"
:key="d"
:class="['chip', { selected: difficulty === d }]"
@click="difficulty = d"
>
{{ difficultyLabel(d) }}
</button>
</div>
</section>
<section>
<label>单词数量</label>
<div class="row">
<button
v-for="n in wordCountOptions"
:key="n"
:class="['chip', { selected: wordCount === n }]"
@click="wordCount = n"
>
{{ n }}
</button>
</div>
</section>
<div class="summary">
<div>主题<b>{{ summaryTopic }}</b></div>
<div>难度<b>{{ difficultyLabel(difficulty) }}</b></div>
<div>词数<b>{{ wordCount }}</b></div>
</div>
<footer>
<button @click="$emit('close')" class="cancel">取消</button>
<button @click="start" class="ok">开始游戏</button>
</footer>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex; align-items: flex-start; justify-content: center;
z-index: 1500; padding: 16px; overflow-y: auto;
}
.modal {
background: #1a2027;
border: 1px solid var(--border);
border-radius: 12px;
width: 100%; max-width: 600px;
padding: 16px;
margin: 16px 0;
}
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
h2 { margin: 0; font-size: 1.3rem; }
section { margin-bottom: 18px; }
label { display: block; margin-bottom: 8px; color: var(--fg-dim); font-weight: 500; }
.row { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
padding: 8px 12px;
border: 2px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
border-radius: 8px;
font-size: 0.9rem;
}
.chip.selected { background: var(--accent); border-color: var(--accent); color: white; }
.text {
width: 100%;
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
font-size: 0.95rem;
}
.summary {
background: var(--bg-soft);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
button.x {
width: 32px; height: 32px;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
border: 1px solid var(--border);
}
button.cancel {
background: var(--bg-soft);
border: 1px solid var(--border);
color: var(--fg);
padding: 10px 18px;
border-radius: 8px;
}
button.ok {
background: var(--accent);
border: none;
color: white;
padding: 10px 18px;
border-radius: 8px;
font-weight: bold;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import type { Word } from '../logic/wordlist'
const props = defineProps<{
show: boolean
word: Word | null
isComplete: boolean
}>()
const emit = defineEmits<{
close: []
correct: []
pass: []
}>()
function onKey(e: KeyboardEvent) {
if (!props.show) return
if (e.key === 'Enter') {
e.preventDefault()
emit('correct')
} else if (e.key === ' ') {
e.preventDefault()
emit('pass')
} else if (e.key === 'Escape') {
e.preventDefault()
emit('close')
}
}
watch(
() => props.show,
(s) => {
if (s) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
},
)
onMounted(() => {
if (props.show) window.addEventListener('keydown', onKey)
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div v-if="show" class="overlay" @click="$emit('close')">
<button class="close" @click.stop="$emit('close')"></button>
<div class="word" v-if="!isComplete && word">
<div class="chinese">{{ word.chinese }}</div>
<div class="english">{{ word.english }}</div>
</div>
<div v-else class="done">
<div class="emoji">🎉</div>
<p>所有单词已完成</p>
</div>
<div class="hint">Enter 猜对 · Space 跳过 · Esc 关闭</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
flex-direction: column;
}
.close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.15);
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
font-size: clamp(20px, 3vw, 32px);
padding: 10px 18px;
border-radius: 8px;
}
.word {
text-align: center;
padding: 0 20px;
max-width: 100%;
}
.chinese {
font-size: clamp(56px, 20vw, 300px);
font-weight: bold;
line-height: 1.1;
margin-bottom: clamp(16px, 5vh, 60px);
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
word-break: break-word;
}
.english {
font-size: clamp(36px, 12vw, 200px);
font-weight: 600;
opacity: 0.9;
line-height: 1.1;
word-break: break-word;
}
.done { text-align: center; }
.done .emoji { font-size: 80px; margin-bottom: 12px; }
.hint {
position: absolute;
bottom: 16px;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
}
@media (max-width: 768px) {
.hint { font-size: 12px; }
}
</style>
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -0,0 +1,21 @@
:root {
color-scheme: dark;
--bg: #0f1419;
--bg-soft: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.15);
--fg: rgba(255, 255, 255, 0.92);
--fg-dim: rgba(255, 255, 255, 0.6);
--accent: #4caf50;
--warn: #ff9800;
--danger: #ef4444;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0; padding: 0; min-height: 100vh;
background: var(--bg);
color: var(--fg);
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }