78f84d4225
中英猜词派对 — 选主题 / 难度 / 词数 → 大字模式描述给队友猜,无 room / 无 ws。 15 个 preset 主题(wordlists 已在 scaffold 时就位)+ 3 档难度 + 已看词跨场 记忆(localStorage,cap 5000)+ Enter/Space/Esc 键盘。pickWords 优先未看过 再 fallback 见过的。logic 层 24 个 vitest(解析 / 抽词 / 确定性 rng)。
271 lines
8.4 KiB
Vue
271 lines
8.4 KiB
Vue
<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>
|