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
+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>