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