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:
+2898
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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; }
|
||||||
Reference in New Issue
Block a user