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:
@@ -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>
|
||||
Reference in New Issue
Block a user