Files
cube/apps/articulate/frontend/src/App.vue
T
Fam Zheng 78f84d4225 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)。
2026-05-14 15:32:15 +01:00

271 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>