Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcdf6c6ba4 | |||
| fbd6e3cb9c | |||
| 78f84d4225 | |||
| 0b22691b3d |
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
Generated
+24
@@ -23,6 +23,14 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "articulate"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cube-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -599,6 +607,14 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "karaoke"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cube-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1671,6 +1687,14 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werewolf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cube-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
+2898
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addSeen } from '../logic/storage'
|
||||
|
||||
describe('addSeen', () => {
|
||||
it('appends new keys to the end', () => {
|
||||
expect(addSeen(['a', 'b'], ['c', 'd'])).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('deduplicates existing keys', () => {
|
||||
expect(addSeen(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('handles all-duplicate addition', () => {
|
||||
expect(addSeen(['a', 'b'], ['a', 'b'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('respects MAX_SEEN cap (5000) by trimming oldest', () => {
|
||||
const existing = Array.from({ length: 5000 }, (_, i) => `k${i}`)
|
||||
const out = addSeen(existing, ['new1', 'new2'])
|
||||
expect(out).toHaveLength(5000)
|
||||
expect(out[out.length - 1]).toBe('new2')
|
||||
expect(out[out.length - 2]).toBe('new1')
|
||||
// 最旧的 'k0' 和 'k1' 被挤出
|
||||
expect(out.includes('k0')).toBe(false)
|
||||
expect(out.includes('k1')).toBe(false)
|
||||
})
|
||||
|
||||
it('empty input', () => {
|
||||
expect(addSeen([], [])).toEqual([])
|
||||
expect(addSeen([], ['a'])).toEqual(['a'])
|
||||
expect(addSeen(['a'], [])).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
difficultyLevels,
|
||||
parseWordlist,
|
||||
parseWordlistLine,
|
||||
pickWords,
|
||||
wordKeyOf,
|
||||
type Rng,
|
||||
type Word,
|
||||
} from '../logic/wordlist'
|
||||
|
||||
function mulberry32(seed: number): Rng {
|
||||
let a = seed
|
||||
return {
|
||||
next() {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseWordlistLine', () => {
|
||||
it('parses a valid easy line', () => {
|
||||
expect(parseWordlistLine('easy - run - 跑')).toEqual({
|
||||
difficulty: 'easy',
|
||||
english: 'run',
|
||||
chinese: '跑',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses medium / hard', () => {
|
||||
expect(parseWordlistLine('medium - hop - 单脚跳')?.difficulty).toBe('medium')
|
||||
expect(parseWordlistLine('hard - perambulate - 漫步')?.difficulty).toBe('hard')
|
||||
})
|
||||
|
||||
it('rejects empty / whitespace lines', () => {
|
||||
expect(parseWordlistLine('')).toBeNull()
|
||||
expect(parseWordlistLine(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects wrong field count', () => {
|
||||
expect(parseWordlistLine('easy - run')).toBeNull()
|
||||
expect(parseWordlistLine('easy - run - 跑 - extra')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unknown difficulty', () => {
|
||||
expect(parseWordlistLine('insane - run - 跑')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty english/chinese', () => {
|
||||
expect(parseWordlistLine('easy - - 跑')).toBeNull()
|
||||
expect(parseWordlistLine('easy - run - ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWordlist', () => {
|
||||
it('parses multi-line text and skips bad lines', () => {
|
||||
const text = ['easy - run - 跑', '', 'invalid line', 'medium - hop - 单脚跳', 'easy - walk - 走'].join('\n')
|
||||
const out = parseWordlist(text)
|
||||
expect(out).toHaveLength(3)
|
||||
expect(out.map((w) => w.english)).toEqual(['run', 'hop', 'walk'])
|
||||
})
|
||||
|
||||
it('handles CRLF line endings', () => {
|
||||
const text = 'easy - run - 跑\r\neasy - walk - 走\r\n'
|
||||
expect(parseWordlist(text)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('difficultyLevels', () => {
|
||||
it('1 = easy only', () => {
|
||||
expect(difficultyLevels(1)).toEqual(['easy'])
|
||||
})
|
||||
it('2 = easy + medium', () => {
|
||||
expect(difficultyLevels(2)).toEqual(['easy', 'medium'])
|
||||
})
|
||||
it('3 = all three', () => {
|
||||
expect(difficultyLevels(3)).toEqual(['easy', 'medium', 'hard'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickWords', () => {
|
||||
const pool: Word[] = [
|
||||
{ difficulty: 'easy', english: 'run', chinese: '跑' },
|
||||
{ difficulty: 'easy', english: 'walk', chinese: '走' },
|
||||
{ difficulty: 'easy', english: 'jump', chinese: '跳' },
|
||||
{ difficulty: 'medium', english: 'hop', chinese: '单脚跳' },
|
||||
{ difficulty: 'hard', english: 'perambulate', chinese: '漫步' },
|
||||
]
|
||||
|
||||
it('respects difficulty level filter', () => {
|
||||
const out = pickWords(pool, 1, 10, new Set(), mulberry32(1))
|
||||
expect(out.every((w) => w.difficulty === 'easy')).toBe(true)
|
||||
expect(out).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('includes easy + medium for difficulty 2', () => {
|
||||
const out = pickWords(pool, 2, 10, new Set(), mulberry32(1))
|
||||
expect(out).toHaveLength(4)
|
||||
expect(out.some((w) => w.difficulty === 'hard')).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers unseen words', () => {
|
||||
const seen = new Set(['walk|走', 'jump|跳'])
|
||||
const out = pickWords(pool, 1, 1, seen, mulberry32(1))
|
||||
// 唯一未见过的 easy 词是 'run'
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].english).toBe('run')
|
||||
})
|
||||
|
||||
it('falls back to seen words when unseen pool runs out', () => {
|
||||
const seen = new Set(['run|跑', 'walk|走', 'jump|跳'])
|
||||
const out = pickWords(pool, 1, 3, seen, mulberry32(1))
|
||||
expect(out).toHaveLength(3) // all from seen since no unseen left
|
||||
})
|
||||
|
||||
it('returns at most `count` words', () => {
|
||||
const out = pickWords(pool, 3, 2, new Set(), mulberry32(1))
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles empty pool', () => {
|
||||
const out = pickWords([], 2, 10, new Set(), mulberry32(1))
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('is deterministic given a deterministic rng', () => {
|
||||
const a = pickWords(pool, 3, 3, new Set(), mulberry32(7))
|
||||
const b = pickWords(pool, 3, 3, new Set(), mulberry32(7))
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wordKeyOf', () => {
|
||||
it('produces stable english|chinese key', () => {
|
||||
expect(wordKeyOf({ difficulty: 'easy', english: 'run', chinese: '跑' })).toBe('run|跑')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Word } from './wordlist'
|
||||
|
||||
export interface GameConfig {
|
||||
topic: string | null
|
||||
difficulty: 1 | 2 | 3
|
||||
totalWords: number
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
config: GameConfig
|
||||
queue: Word[]
|
||||
currentIndex: number
|
||||
correctCount: number
|
||||
passCount: number
|
||||
}
|
||||
|
||||
interface PersistedState {
|
||||
game: GameState | null
|
||||
seenWords: string[] // wordKey list
|
||||
}
|
||||
|
||||
const KEY = 'articulate:v1'
|
||||
const MAX_SEEN = 5000
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
if (!isBrowser()) return { game: null, seenWords: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return { game: null, seenWords: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedState>
|
||||
return {
|
||||
game: parsed.game ?? null,
|
||||
seenWords: parsed.seenWords ?? [],
|
||||
}
|
||||
} catch {
|
||||
return { game: null, seenWords: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: PersistedState): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
// 限制 seen list 大小
|
||||
const seen = state.seenWords.slice(-MAX_SEEN)
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ ...state, seenWords: seen }))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function addSeen(seen: string[], keys: string[]): string[] {
|
||||
const set = new Set(seen)
|
||||
const out = [...seen]
|
||||
for (const k of keys) {
|
||||
if (!set.has(k)) {
|
||||
set.add(k)
|
||||
out.push(k)
|
||||
}
|
||||
}
|
||||
return out.slice(-MAX_SEEN)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// preset topic 列表与 wordlist 文件路径。
|
||||
|
||||
export interface PresetTopic {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const PRESET_TOPICS: PresetTopic[] = [
|
||||
{ value: 'animals', label: '动物' },
|
||||
{ value: 'food', label: '食物' },
|
||||
{ value: 'places', label: '地点' },
|
||||
{ value: 'objects', label: '物品' },
|
||||
{ value: 'actions', label: '动作' },
|
||||
{ value: 'colors', label: '颜色' },
|
||||
{ value: 'emotions', label: '情感' },
|
||||
{ value: 'sports', label: '运动' },
|
||||
{ value: 'professions', label: '职业' },
|
||||
{ value: 'nature', label: '自然' },
|
||||
{ value: 'body', label: '身体' },
|
||||
{ value: 'clothing', label: '服装' },
|
||||
{ value: 'vehicles', label: '交通工具' },
|
||||
{ value: 'music', label: '音乐' },
|
||||
{ value: 'technology', label: '科技' },
|
||||
]
|
||||
|
||||
export function wordlistUrl(topic: string): string {
|
||||
return `/wordlists/${encodeURIComponent(topic)}.txt`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Wordlist 解析 + 抽词。纯函数,可单测。
|
||||
|
||||
export interface Word {
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
english: string
|
||||
chinese: string
|
||||
}
|
||||
|
||||
export interface Rng {
|
||||
next(): number
|
||||
}
|
||||
export const defaultRng: Rng = { next: () => Math.random() }
|
||||
|
||||
/** 解析一行 "easy - run - 跑"。空行或格式错误返回 null。 */
|
||||
export function parseWordlistLine(line: string): Word | null {
|
||||
const s = line.trim()
|
||||
if (!s) return null
|
||||
const parts = s.split(' - ').map((p) => p.trim())
|
||||
if (parts.length !== 3) return null
|
||||
const [difficulty, english, chinese] = parts
|
||||
if (difficulty !== 'easy' && difficulty !== 'medium' && difficulty !== 'hard') return null
|
||||
if (!english || !chinese) return null
|
||||
return { difficulty, english, chinese }
|
||||
}
|
||||
|
||||
/** 解析一整个 wordlist 文件文本。跳过空行 / 格式错误行。 */
|
||||
export function parseWordlist(text: string): Word[] {
|
||||
return text.split(/\r?\n/).map(parseWordlistLine).filter((w): w is Word => w !== null)
|
||||
}
|
||||
|
||||
/** difficulty 数字 → 包含的难度等级。 */
|
||||
export function difficultyLevels(d: 1 | 2 | 3): Word['difficulty'][] {
|
||||
if (d === 1) return ['easy']
|
||||
if (d === 2) return ['easy', 'medium']
|
||||
return ['easy', 'medium', 'hard']
|
||||
}
|
||||
|
||||
function wordKey(w: Word): string {
|
||||
return `${w.english}|${w.chinese}`
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[], rng: Rng): T[] {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng.next() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 pool 抽 count 个词,优先未见过的。
|
||||
* 如果未见过的不够,用见过的补足,避免凑不齐。
|
||||
*/
|
||||
export function pickWords(
|
||||
pool: Word[],
|
||||
difficulty: 1 | 2 | 3,
|
||||
count: number,
|
||||
seen: Set<string>,
|
||||
rng: Rng = defaultRng,
|
||||
): Word[] {
|
||||
const levels = new Set(difficultyLevels(difficulty))
|
||||
const filtered = pool.filter((w) => levels.has(w.difficulty))
|
||||
const unseen = filtered.filter((w) => !seen.has(wordKey(w)))
|
||||
const seenShuffled = shuffle(
|
||||
filtered.filter((w) => seen.has(wordKey(w))),
|
||||
rng,
|
||||
)
|
||||
const unseenShuffled = shuffle(unseen, rng)
|
||||
|
||||
const picked: Word[] = []
|
||||
for (const w of unseenShuffled) {
|
||||
if (picked.length >= count) break
|
||||
picked.push(w)
|
||||
}
|
||||
for (const w of seenShuffled) {
|
||||
if (picked.length >= count) break
|
||||
picked.push(w)
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
export function wordKeyOf(w: Word): string {
|
||||
return wordKey(w)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,21 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1419;
|
||||
--bg-soft: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.15);
|
||||
--fg: rgba(255, 255, 255, 0.92);
|
||||
--fg-dim: rgba(255, 255, 255, 0.6);
|
||||
--accent: #4caf50;
|
||||
--warn: #ff9800;
|
||||
--danger: #ef4444;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app {
|
||||
margin: 0; padding: 0; min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button { font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@@ -51,4 +51,25 @@ export const apps: App[] = [
|
||||
url: 'https://pyroblem.famzheng.me',
|
||||
status: 'tbd',
|
||||
},
|
||||
{
|
||||
slug: 'werewolf',
|
||||
name: 'werewolf',
|
||||
description: '狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。',
|
||||
url: 'https://werewolf.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'articulate',
|
||||
name: 'articulate',
|
||||
description: '中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。',
|
||||
url: 'https://articulate.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'karaoke',
|
||||
name: 'karaoke',
|
||||
description: '卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。',
|
||||
url: 'https://karaoke.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
|
||||
Generated
+2898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import AddSongModal from './components/AddSongModal.vue'
|
||||
import { addSong, deleteSong, moveSong, youtubeSearchUrl, type Song } from './logic/playlist'
|
||||
import { loadState, saveState } from './logic/storage'
|
||||
|
||||
const playlist = ref<Song[]>([])
|
||||
const showAdd = ref(false)
|
||||
const pendingDeletes = ref<Record<number, number>>({}) // songId -> timeoutId
|
||||
const DELETE_DELAY_MS = 10_000
|
||||
|
||||
onMounted(() => {
|
||||
playlist.value = loadState().playlist
|
||||
})
|
||||
|
||||
watch(playlist, () => saveState({ playlist: playlist.value }), { deep: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const id of Object.values(pendingDeletes.value)) clearTimeout(id)
|
||||
})
|
||||
|
||||
function onAdd(payload: { singer: string; title: string }) {
|
||||
playlist.value = addSong(playlist.value, payload.singer, payload.title)
|
||||
showAdd.value = false
|
||||
}
|
||||
|
||||
function onMove(songId: number, direction: 'up' | 'down' | 'first') {
|
||||
playlist.value = moveSong(playlist.value, songId, direction)
|
||||
}
|
||||
|
||||
function startDelete(songId: number) {
|
||||
pendingDeletes.value[songId] = window.setTimeout(() => {
|
||||
playlist.value = deleteSong(playlist.value, songId)
|
||||
delete pendingDeletes.value[songId]
|
||||
}, DELETE_DELAY_MS)
|
||||
}
|
||||
|
||||
function cancelDelete(songId: number) {
|
||||
const t = pendingDeletes.value[songId]
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
delete pendingDeletes.value[songId]
|
||||
}
|
||||
}
|
||||
|
||||
function isPending(songId: number): boolean {
|
||||
return pendingDeletes.value[songId] !== undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>🎤 Karaoke 点歌</h1>
|
||||
<button class="primary" @click="showAdd = true">+ 添加歌曲</button>
|
||||
</header>
|
||||
|
||||
<section v-if="playlist.length === 0" class="empty">
|
||||
<p>点歌单空空如也</p>
|
||||
<p class="dim">点击 "添加歌曲" 把歌排进队</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="list">
|
||||
<article
|
||||
v-for="(song, idx) in playlist"
|
||||
:key="song.id"
|
||||
:class="['item', { pending: isPending(song.id) }]"
|
||||
>
|
||||
<div class="meta">
|
||||
<span class="idx">{{ idx + 1 }}</span>
|
||||
<div class="text">
|
||||
<div class="title">{{ song.title }}</div>
|
||||
<div class="singer">{{ song.singer }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a
|
||||
:href="youtubeSearchUrl(song)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="action yt"
|
||||
title="在 YouTube 搜索"
|
||||
>YT</a>
|
||||
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'first')" title="置顶">⇈</button>
|
||||
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'up')" title="上移">↑</button>
|
||||
<button class="action" :disabled="idx === playlist.length - 1 || isPending(song.id)" @click="onMove(song.id, 'down')" title="下移">↓</button>
|
||||
<button v-if="!isPending(song.id)" class="action danger" @click="startDelete(song.id)" title="删除">✕</button>
|
||||
<button v-else class="action cancel-delete" @click="cancelDelete(song.id)">撤销</button>
|
||||
</div>
|
||||
<div v-if="isPending(song.id)" class="progress">
|
||||
<div class="bar" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<AddSongModal :show="showAdd" @close="showAdd = false" @add="onAdd" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
max-width: 720px;
|
||||
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; background: linear-gradient(135deg, #fff, var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
button.primary { background: var(--accent); border: none; color: white; padding: 10px 16px; border-radius: 8px; font-weight: bold; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 60px 20px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.empty .dim { font-size: 0.9rem; opacity: 0.7; margin-top: 6px; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.item {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.item.pending { opacity: 0.55; background: rgba(239, 68, 68, 0.1); }
|
||||
.meta { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.idx {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(236, 72, 153, 0.2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.text { min-width: 0; flex: 1; }
|
||||
.title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.singer { color: var(--fg-dim); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.action {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
padding: 0 8px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.action.yt { color: #ff4d4d; }
|
||||
.action.danger { color: var(--danger); border-color: rgba(239, 68, 68, 0.4); }
|
||||
.action.cancel-delete { background: var(--accent-2); border-color: var(--accent-2); color: #000; font-size: 0.8rem; }
|
||||
.progress { height: 3px; background: rgba(239, 68, 68, 0.2); border-radius: 2px; overflow: hidden; }
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: var(--danger);
|
||||
animation: shrink 10s linear forwards;
|
||||
}
|
||||
@keyframes shrink { from { width: 100%; } to { width: 0; } }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.actions { gap: 4px; }
|
||||
.action { min-width: 32px; height: 32px; font-size: 0.85rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addSong,
|
||||
deleteSong,
|
||||
moveSong,
|
||||
nextId,
|
||||
youtubeSearchUrl,
|
||||
type Song,
|
||||
} from '../logic/playlist'
|
||||
|
||||
const sample = (): Song[] => [
|
||||
{ id: 1, singer: '周杰伦', title: '七里香' },
|
||||
{ id: 2, singer: '林俊杰', title: '江南' },
|
||||
{ id: 3, singer: '陶喆', title: '小镇姑娘' },
|
||||
]
|
||||
|
||||
describe('nextId', () => {
|
||||
it('returns 1 for empty playlist', () => {
|
||||
expect(nextId([])).toBe(1)
|
||||
})
|
||||
it('returns max + 1', () => {
|
||||
expect(nextId(sample())).toBe(4)
|
||||
})
|
||||
it('handles gaps correctly', () => {
|
||||
expect(nextId([{ id: 7, singer: 'a', title: 'b' }])).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSong', () => {
|
||||
it('appends to the end with next id', () => {
|
||||
const out = addSong(sample(), '邓紫棋', '泡沫')
|
||||
expect(out).toHaveLength(4)
|
||||
expect(out[3]).toEqual({ id: 4, singer: '邓紫棋', title: '泡沫' })
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const out = addSong([], ' Adele ', ' Hello ')
|
||||
expect(out[0]).toEqual({ id: 1, singer: 'Adele', title: 'Hello' })
|
||||
})
|
||||
|
||||
it('rejects empty singer or title (returns unchanged)', () => {
|
||||
const list = sample()
|
||||
expect(addSong(list, '', 'Hello')).toBe(list)
|
||||
expect(addSong(list, 'Adele', '')).toBe(list)
|
||||
expect(addSong(list, ' ', ' ')).toBe(list)
|
||||
})
|
||||
|
||||
it('does not mutate input', () => {
|
||||
const list = sample()
|
||||
addSong(list, 'a', 'b')
|
||||
expect(list).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSong', () => {
|
||||
it('removes by id', () => {
|
||||
const out = deleteSong(sample(), 2)
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out.map((s) => s.id)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is noop for missing id', () => {
|
||||
const out = deleteSong(sample(), 999)
|
||||
expect(out).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not mutate input', () => {
|
||||
const list = sample()
|
||||
deleteSong(list, 1)
|
||||
expect(list).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveSong', () => {
|
||||
it("moves 'up'", () => {
|
||||
const out = moveSong(sample(), 2, 'up')
|
||||
expect(out.map((s) => s.id)).toEqual([2, 1, 3])
|
||||
})
|
||||
|
||||
it("moves 'down'", () => {
|
||||
const out = moveSong(sample(), 2, 'down')
|
||||
expect(out.map((s) => s.id)).toEqual([1, 3, 2])
|
||||
})
|
||||
|
||||
it("moves 'first'", () => {
|
||||
const out = moveSong(sample(), 3, 'first')
|
||||
expect(out.map((s) => s.id)).toEqual([3, 1, 2])
|
||||
})
|
||||
|
||||
it("'up' on first item is noop", () => {
|
||||
const out = moveSong(sample(), 1, 'up')
|
||||
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("'down' on last item is noop", () => {
|
||||
const out = moveSong(sample(), 3, 'down')
|
||||
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("'first' on first item is noop", () => {
|
||||
const out = moveSong(sample(), 1, 'first')
|
||||
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('handles missing id', () => {
|
||||
const out = moveSong(sample(), 999, 'up')
|
||||
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('preserves the song count', () => {
|
||||
for (const dir of ['up', 'down', 'first'] as const) {
|
||||
for (const id of [1, 2, 3]) {
|
||||
const out = moveSong(sample(), id, dir)
|
||||
expect(out).toHaveLength(3)
|
||||
expect(out.map((s) => s.id).sort()).toEqual([1, 2, 3])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mutate input', () => {
|
||||
const list = sample()
|
||||
moveSong(list, 2, 'up')
|
||||
expect(list.map((s) => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('youtubeSearchUrl', () => {
|
||||
it('builds an encoded search URL', () => {
|
||||
const url = youtubeSearchUrl({ id: 1, singer: '周杰伦', title: '七里香' })
|
||||
expect(url.startsWith('https://www.youtube.com/results?search_query=')).toBe(true)
|
||||
expect(url).toContain(encodeURIComponent('周杰伦 七里香'))
|
||||
})
|
||||
|
||||
it('encodes special characters', () => {
|
||||
const url = youtubeSearchUrl({ id: 1, singer: 'A&B', title: 'C/D' })
|
||||
expect(url).toContain('A%26B')
|
||||
expect(url).toContain('C%2FD')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
add: [{ singer: string; title: string }]
|
||||
}>()
|
||||
|
||||
const singer = ref('')
|
||||
const title = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(s) => {
|
||||
if (s) {
|
||||
singer.value = ''
|
||||
title.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const canAdd = () => singer.value.trim() !== '' && title.value.trim() !== ''
|
||||
|
||||
function submit() {
|
||||
if (!canAdd()) return
|
||||
emit('add', { singer: singer.value.trim(), title: title.value.trim() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<header>
|
||||
<h2>添加歌曲</h2>
|
||||
<button class="x" @click="$emit('close')">✕</button>
|
||||
</header>
|
||||
<section>
|
||||
<label>歌手</label>
|
||||
<input v-model="singer" type="text" placeholder="周杰伦" @keydown.enter="submit" />
|
||||
</section>
|
||||
<section>
|
||||
<label>歌名</label>
|
||||
<input v-model="title" type="text" placeholder="七里香" @keydown.enter="submit" />
|
||||
</section>
|
||||
<footer>
|
||||
<button class="cancel" @click="$emit('close')">取消</button>
|
||||
<button class="ok" :disabled="!canAdd()" @click="submit">添加</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1500; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #232336;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 100%; max-width: 480px;
|
||||
padding: 16px;
|
||||
}
|
||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
h2 { margin: 0; font-size: 1.3rem; }
|
||||
section { margin-bottom: 16px; }
|
||||
label { display: block; margin-bottom: 6px; color: var(--fg-dim); font-weight: 500; font-size: 0.9rem; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--accent); }
|
||||
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,49 @@
|
||||
// Playlist 不可变操作。所有函数纯,返回新数组。
|
||||
|
||||
export interface Song {
|
||||
id: number
|
||||
singer: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type Direction = 'up' | 'down' | 'first'
|
||||
|
||||
export function nextId(playlist: Song[]): number {
|
||||
let max = 0
|
||||
for (const s of playlist) if (s.id > max) max = s.id
|
||||
return max + 1
|
||||
}
|
||||
|
||||
export function addSong(playlist: Song[], singer: string, title: string): Song[] {
|
||||
const s = singer.trim()
|
||||
const t = title.trim()
|
||||
if (!s || !t) return playlist
|
||||
return [...playlist, { id: nextId(playlist), singer: s, title: t }]
|
||||
}
|
||||
|
||||
export function deleteSong(playlist: Song[], songId: number): Song[] {
|
||||
return playlist.filter((s) => s.id !== songId)
|
||||
}
|
||||
|
||||
export function moveSong(playlist: Song[], songId: number, direction: Direction): Song[] {
|
||||
const idx = playlist.findIndex((s) => s.id === songId)
|
||||
if (idx === -1) return playlist
|
||||
const next = [...playlist]
|
||||
const [song] = next.splice(idx, 1)
|
||||
if (direction === 'first') {
|
||||
next.unshift(song)
|
||||
} else if (direction === 'up') {
|
||||
next.splice(Math.max(0, idx - 1), 0, song)
|
||||
} else {
|
||||
// 'down': insert at idx + 1 of original. After splice, original idx + 1
|
||||
// becomes position idx in `next`. So inserting at idx puts the song before
|
||||
// the element that *was* at idx + 1 — we want *after* it, hence idx + 1.
|
||||
next.splice(Math.min(next.length, idx + 1), 0, song)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function youtubeSearchUrl(song: Song): string {
|
||||
const q = encodeURIComponent(`${song.singer} ${song.title}`)
|
||||
return `https://www.youtube.com/results?search_query=${q}`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Song } from './playlist'
|
||||
|
||||
interface PersistedState {
|
||||
playlist: Song[]
|
||||
}
|
||||
|
||||
const KEY = 'karaoke:v1'
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
if (!isBrowser()) return { playlist: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return { playlist: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedState>
|
||||
return { playlist: parsed.playlist ?? [] }
|
||||
} catch {
|
||||
return { playlist: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: PersistedState): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,23 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #1a1a2e;
|
||||
--bg-soft: rgba(255, 255, 255, 0.06);
|
||||
--bg-card: rgba(255, 255, 255, 0.08);
|
||||
--border: rgba(255, 255, 255, 0.15);
|
||||
--fg: rgba(255, 255, 255, 0.92);
|
||||
--fg-dim: rgba(255, 255, 255, 0.6);
|
||||
--accent: #ec4899;
|
||||
--accent-2: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--ok: #4caf50;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app {
|
||||
margin: 0; padding: 0; min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button { font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
Generated
+2898
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import RoleCard from './components/RoleCard.vue'
|
||||
import RevealModal from './components/RevealModal.vue'
|
||||
import NewGameModal from './components/NewGameModal.vue'
|
||||
import { roleImageUrl, backImageUrl } from './logic/roles'
|
||||
import {
|
||||
assignRolesWithPreferences,
|
||||
rolesListToDict,
|
||||
type Preferences,
|
||||
} from './logic/assignment'
|
||||
import {
|
||||
loadState,
|
||||
saveState,
|
||||
addHistory,
|
||||
type GameState,
|
||||
type HistoryEntry,
|
||||
} from './logic/storage'
|
||||
|
||||
const game = ref<GameState | null>(null)
|
||||
const history = ref<HistoryEntry[]>([])
|
||||
const showModal = ref(false)
|
||||
const revealedSet = ref<Set<number>>(new Set())
|
||||
const viewing = ref<number | null>(null)
|
||||
const flipped = ref<Record<number, boolean>>({})
|
||||
|
||||
onMounted(() => {
|
||||
const s = loadState()
|
||||
game.value = s.game
|
||||
history.value = s.history
|
||||
})
|
||||
|
||||
watch(
|
||||
[game, history],
|
||||
() => {
|
||||
saveState({ game: game.value, history: history.value })
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const pendingPlayers = computed<number[]>(() => game.value?.pendingConfirm ?? [])
|
||||
const confirmedPlayers = computed<number[]>(() => {
|
||||
if (!game.value) return []
|
||||
const all = game.value.playerRoles.length
|
||||
const pending = new Set(game.value.pendingConfirm)
|
||||
const out: number[] = []
|
||||
for (let i = 1; i <= all; i++) if (!pending.has(i)) out.push(i)
|
||||
return out
|
||||
})
|
||||
const allRevealed = computed(
|
||||
() => game.value != null && game.value.playerRoles.length > 0 && game.value.pendingConfirm.length === 0,
|
||||
)
|
||||
const allFlipped = computed(() => {
|
||||
const roles = game.value?.playerRoles ?? []
|
||||
if (roles.length === 0) return false
|
||||
return roles.every((_, i) => flipped.value[i + 1] === true)
|
||||
})
|
||||
|
||||
const currentRole = computed<string | null>(() => {
|
||||
if (viewing.value == null) return null
|
||||
return game.value?.playerRoles[viewing.value - 1] ?? null
|
||||
})
|
||||
|
||||
function isDead(pid: number): boolean {
|
||||
return !!game.value?.deadPlayers.includes(pid)
|
||||
}
|
||||
function isRevealed(pid: number): boolean {
|
||||
return revealedSet.value.has(pid)
|
||||
}
|
||||
function onReveal(pid: number) {
|
||||
if (isRevealed(pid)) return
|
||||
revealedSet.value.add(pid)
|
||||
viewing.value = pid
|
||||
}
|
||||
function onConfirm(pid: number) {
|
||||
if (!game.value) return
|
||||
if (!isRevealed(pid)) return
|
||||
// remove from pendingConfirm + revealed set
|
||||
const next = game.value.pendingConfirm.filter((p) => p !== pid)
|
||||
game.value = { ...game.value, pendingConfirm: next }
|
||||
revealedSet.value.delete(pid)
|
||||
viewing.value = null
|
||||
}
|
||||
function onModalConfirm() {
|
||||
if (viewing.value != null) onConfirm(viewing.value)
|
||||
}
|
||||
function toggleFlip(pid: number) {
|
||||
flipped.value = { ...flipped.value, [pid]: !flipped.value[pid] }
|
||||
}
|
||||
function toggleAllFlip() {
|
||||
const shouldFlip = !allFlipped.value
|
||||
const next: Record<number, boolean> = {}
|
||||
game.value?.playerRoles.forEach((_, i) => {
|
||||
next[i + 1] = shouldFlip
|
||||
})
|
||||
flipped.value = next
|
||||
}
|
||||
function toggleDead(pid: number) {
|
||||
if (!game.value) return
|
||||
const cur = game.value.deadPlayers
|
||||
const next = cur.includes(pid) ? cur.filter((p) => p !== pid) : [...cur, pid]
|
||||
game.value = { ...game.value, deadPlayers: next }
|
||||
}
|
||||
|
||||
function startGame(payload: { rolesList: string[]; preferences: Preferences }) {
|
||||
const assigned = assignRolesWithPreferences(payload.rolesList, payload.preferences)
|
||||
const n = assigned.length
|
||||
game.value = {
|
||||
playerRoles: assigned,
|
||||
pendingConfirm: Array.from({ length: n }, (_, i) => i + 1),
|
||||
deadPlayers: [],
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
history.value = addHistory(history.value, {
|
||||
playerCount: n,
|
||||
roles: rolesListToDict(assigned),
|
||||
})
|
||||
revealedSet.value = new Set()
|
||||
flipped.value = {}
|
||||
viewing.value = null
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
if (!confirm('确定结束当前游戏?')) return
|
||||
game.value = null
|
||||
revealedSet.value = new Set()
|
||||
flipped.value = {}
|
||||
viewing.value = null
|
||||
}
|
||||
|
||||
const lastPlayerCount = computed(() => game.value?.playerRoles.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>🐺 狼人杀</h1>
|
||||
<div class="actions">
|
||||
<button v-if="game" class="ghost" @click="endGame">结束游戏</button>
|
||||
<button class="primary" @click="showModal = true">{{ game ? '新一局' : '开始游戏' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="!game" class="empty">
|
||||
<p>点击"开始游戏"配置角色 → 一台手机轮流传,每人 swipe 查看自己的角色</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!allRevealed" class="board">
|
||||
<div v-if="pendingPlayers.length" class="group">
|
||||
<h3>待翻阅</h3>
|
||||
<div class="cards">
|
||||
<RoleCard
|
||||
v-for="pid in pendingPlayers"
|
||||
:key="pid"
|
||||
:player-id="pid"
|
||||
:is-dead="isDead(pid)"
|
||||
:is-revealed="isRevealed(pid)"
|
||||
:confirmed="false"
|
||||
@reveal="onReveal"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="confirmedPlayers.length" class="group">
|
||||
<h3>已翻阅</h3>
|
||||
<div class="cards">
|
||||
<RoleCard
|
||||
v-for="pid in confirmedPlayers"
|
||||
:key="pid"
|
||||
:player-id="pid"
|
||||
:is-dead="isDead(pid)"
|
||||
:is-revealed="true"
|
||||
:confirmed="true"
|
||||
@reveal="onReveal"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="board final">
|
||||
<div class="controls">
|
||||
<button class="primary" @click="toggleAllFlip">{{ allFlipped ? '全部翻回' : '全部翻开' }}</button>
|
||||
</div>
|
||||
<div class="cards final-cards">
|
||||
<div
|
||||
v-for="(role, idx) in game.playerRoles"
|
||||
:key="idx + 1"
|
||||
:class="['flip-card', { dead: isDead(idx + 1), flipped: flipped[idx + 1] }]"
|
||||
>
|
||||
<div class="flip-inner" @click="toggleFlip(idx + 1)">
|
||||
<div class="flip-side front">
|
||||
<img :src="backImageUrl()" alt="back" />
|
||||
<div class="label">玩家 {{ idx + 1 }}</div>
|
||||
</div>
|
||||
<div class="flip-side back">
|
||||
<img :src="roleImageUrl(role)" :alt="role" />
|
||||
<div class="label">玩家 {{ idx + 1 }} · {{ role }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dead-btn" :class="{ alive: isDead(idx + 1) }" @click.stop="toggleDead(idx + 1)">
|
||||
{{ isDead(idx + 1) ? '标记存活' : '标记死亡' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RevealModal :role="currentRole" @confirm="onModalConfirm" />
|
||||
|
||||
<NewGameModal
|
||||
:show="showModal"
|
||||
:history="history"
|
||||
:initial-player-count="lastPlayerCount"
|
||||
@close="showModal = false"
|
||||
@start="startGame"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fff, var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-soft);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.group { margin-bottom: 28px; }
|
||||
.group h3 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.final-cards {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
/* 3D flip */
|
||||
.flip-card {
|
||||
perspective: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.flip-card.dead .flip-side img { filter: grayscale(100%); }
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
transform-style: preserve-3d;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flip-side {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transition: transform 0.6s;
|
||||
}
|
||||
.flip-side img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.flip-side .label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.flip-side.front { transform: rotateY(0deg); }
|
||||
.flip-side.back { transform: rotateY(180deg); }
|
||||
.flip-card.flipped .flip-side.front { transform: rotateY(-180deg); }
|
||||
.flip-card.flipped .flip-side.back { transform: rotateY(0deg); }
|
||||
|
||||
.dead-btn {
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dead-btn.alive { background: rgba(76, 175, 80, 0.3); }
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.final-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assignRolesWithPreferences,
|
||||
expandRolesToList,
|
||||
rolesListToDict,
|
||||
shuffle,
|
||||
type Rng,
|
||||
} from '../logic/assignment'
|
||||
|
||||
// 可控 RNG:按预设序列返回。next() 越界后从头循环。
|
||||
function seqRng(values: number[]): Rng {
|
||||
let i = 0
|
||||
return {
|
||||
next() {
|
||||
const v = values[i % values.length]
|
||||
i++
|
||||
return v
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 简单确定性 RNG:mulberry32,便于重现。
|
||||
function mulberry32(seed: number): Rng {
|
||||
let a = seed
|
||||
return {
|
||||
next() {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('expandRolesToList', () => {
|
||||
it('expands a dict into a flat list', () => {
|
||||
const out = expandRolesToList({ 狼人: 2, 村民: 1 })
|
||||
// 顺序不严格保证,只检查 counts
|
||||
expect(out.sort()).toEqual(['村民', '狼人', '狼人'].sort())
|
||||
})
|
||||
|
||||
it('passes a list through unchanged', () => {
|
||||
expect(expandRolesToList(['狼人', '村民'])).toEqual(['狼人', '村民'])
|
||||
})
|
||||
|
||||
it('handles empty inputs', () => {
|
||||
expect(expandRolesToList({})).toEqual([])
|
||||
expect(expandRolesToList([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rolesListToDict', () => {
|
||||
it('counts roles', () => {
|
||||
expect(rolesListToDict(['狼人', '狼人', '村民'])).toEqual({ 狼人: 2, 村民: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('does not lose elements', () => {
|
||||
const input = [1, 2, 3, 4, 5]
|
||||
const out = shuffle(input, mulberry32(42))
|
||||
expect(out.sort()).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [1, 2, 3, 4, 5]
|
||||
shuffle(input, mulberry32(7))
|
||||
expect(input).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it('is deterministic given a deterministic rng', () => {
|
||||
const a = shuffle([1, 2, 3, 4, 5, 6, 7, 8], mulberry32(123))
|
||||
const b = shuffle([1, 2, 3, 4, 5, 6, 7, 8], mulberry32(123))
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assignRolesWithPreferences', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(assignRolesWithPreferences([], {})).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a permutation of the input roles (no preferences)', () => {
|
||||
const roles = ['狼人', '狼人', '女巫', '猎人', '村民', '村民', '守卫', '骑士']
|
||||
const out = assignRolesWithPreferences(roles, {}, mulberry32(1))
|
||||
expect(out.length).toBe(roles.length)
|
||||
expect(out.sort()).toEqual([...roles].sort())
|
||||
})
|
||||
|
||||
it('always assigns when player count equals 1', () => {
|
||||
expect(assignRolesWithPreferences(['狼人'], {}, mulberry32(0))).toEqual(['狼人'])
|
||||
})
|
||||
|
||||
it('respects preferences with high probability over many trials', () => {
|
||||
// 玩家 1 想当狼人。池子 8 人 2 狼。无 preferences 时狼人命中率约 2/8 = 25%。
|
||||
// 4x 加权后命中率应该明显高于 25%。
|
||||
const roles = ['狼人', '狼人', '女巫', '猎人', '村民', '村民', '守卫', '骑士']
|
||||
const TRIALS = 2000
|
||||
let hits = 0
|
||||
for (let i = 0; i < TRIALS; i++) {
|
||||
const out = assignRolesWithPreferences(roles, { 1: ['狼人'] }, mulberry32(i + 1))
|
||||
if (out[0] === '狼人') hits++
|
||||
}
|
||||
const rate = hits / TRIALS
|
||||
// 25% baseline → 期望 > 40%(实测约 55-60%)。给个比较宽的下界。
|
||||
expect(rate).toBeGreaterThan(0.4)
|
||||
})
|
||||
|
||||
it('without preferences, role distribution is roughly uniform', () => {
|
||||
const roles = ['狼人', '狼人', '村民', '村民', '村民', '村民', '村民', '村民']
|
||||
const TRIALS = 2000
|
||||
let p1Wolf = 0
|
||||
let p4Wolf = 0
|
||||
for (let i = 0; i < TRIALS; i++) {
|
||||
const out = assignRolesWithPreferences(roles, {}, mulberry32(i + 100))
|
||||
if (out[0] === '狼人') p1Wolf++
|
||||
if (out[3] === '狼人') p4Wolf++
|
||||
}
|
||||
// 每人是狼概率约 25%;4% 容忍区间
|
||||
expect(Math.abs(p1Wolf / TRIALS - 0.25)).toBeLessThan(0.05)
|
||||
expect(Math.abs(p4Wolf / TRIALS - 0.25)).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('preserves role counts across all output positions', () => {
|
||||
const roles = ['狼人', '狼人', '狼人', '守卫', '守卫', '女巫', '猎人', '骑士', '白狼王', '村民']
|
||||
for (let seed = 0; seed < 20; seed++) {
|
||||
const out = assignRolesWithPreferences(roles, { 1: ['狼人'], 5: ['女巫'] }, mulberry32(seed))
|
||||
expect(out.length).toBe(roles.length)
|
||||
expect(out.sort()).toEqual([...roles].sort())
|
||||
}
|
||||
})
|
||||
|
||||
it('uses sequence rng deterministically', () => {
|
||||
// 玩家顺序 shuffle 用 rng,加权抽取也用 rng — 不依赖具体内部顺序,但同 rng 两次必相同。
|
||||
const roles = ['狼人', '女巫', '村民', '猎人']
|
||||
const a = assignRolesWithPreferences(roles, {}, seqRng([0.1, 0.5, 0.9, 0.2, 0.7, 0.3, 0.8]))
|
||||
const b = assignRolesWithPreferences(roles, {}, seqRng([0.1, 0.5, 0.9, 0.2, 0.7, 0.3, 0.8]))
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ALL_ROLES, DEFAULT_PRESETS, sortedRoles, roleImageUrl, backImageUrl } from '../logic/roles'
|
||||
|
||||
describe('ALL_ROLES', () => {
|
||||
it('contains all 30 partiverse roles', () => {
|
||||
expect(ALL_ROLES).toHaveLength(30)
|
||||
// 几个 spot check
|
||||
expect(ALL_ROLES).toContain('狼人')
|
||||
expect(ALL_ROLES).toContain('女巫')
|
||||
expect(ALL_ROLES).toContain('村民')
|
||||
expect(ALL_ROLES).toContain('预言家')
|
||||
})
|
||||
|
||||
it('has unique role names', () => {
|
||||
expect(new Set(ALL_ROLES).size).toBe(ALL_ROLES.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_PRESETS', () => {
|
||||
it.each([8, 9, 10, 12])('preset for %d players sums to player count', (count) => {
|
||||
const preset = DEFAULT_PRESETS[count]
|
||||
const sum = Object.values(preset).reduce((a, b) => a + b, 0)
|
||||
expect(sum).toBe(count)
|
||||
})
|
||||
|
||||
it('only references roles from ALL_ROLES', () => {
|
||||
for (const preset of Object.values(DEFAULT_PRESETS)) {
|
||||
for (const role of Object.keys(preset)) {
|
||||
expect(ALL_ROLES).toContain(role)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortedRoles', () => {
|
||||
it('puts preset-frequent roles first', () => {
|
||||
const sorted = sortedRoles()
|
||||
// 狼人/女巫/猎人/守卫/骑士/白狼王 都出现在所有 preset 里,应排在最前
|
||||
const top6 = sorted.slice(0, 6)
|
||||
for (const r of ['狼人', '女巫', '猎人', '守卫', '骑士', '白狼王']) {
|
||||
expect(top6).toContain(r)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a permutation of ALL_ROLES', () => {
|
||||
expect(sortedRoles().sort()).toEqual([...ALL_ROLES].sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('roleImageUrl', () => {
|
||||
it('encodes Chinese role names', () => {
|
||||
expect(roleImageUrl('狼人')).toBe(`/werewolf/roles/${encodeURIComponent('狼人')}.JPG`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backImageUrl', () => {
|
||||
it('returns the back image path', () => {
|
||||
expect(backImageUrl()).toBe('/werewolf/back.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addHistory, type HistoryEntry } from '../logic/storage'
|
||||
|
||||
describe('addHistory', () => {
|
||||
it('inserts new entries at the front', () => {
|
||||
const h: HistoryEntry[] = [{ playerCount: 8, roles: { 狼人: 2 } }]
|
||||
const out = addHistory(h, { playerCount: 9, roles: { 狼人: 3 } })
|
||||
expect(out[0]).toEqual({ playerCount: 9, roles: { 狼人: 3 } })
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates same (playerCount, roles)', () => {
|
||||
const h: HistoryEntry[] = [
|
||||
{ playerCount: 8, roles: { 狼人: 2, 村民: 6 } },
|
||||
{ playerCount: 9, roles: { 狼人: 3, 村民: 6 } },
|
||||
]
|
||||
const out = addHistory(h, { playerCount: 8, roles: { 狼人: 2, 村民: 6 } })
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[0]).toEqual({ playerCount: 8, roles: { 狼人: 2, 村民: 6 } })
|
||||
// 9 人配置应该还在
|
||||
expect(out[1]).toEqual({ playerCount: 9, roles: { 狼人: 3, 村民: 6 } })
|
||||
})
|
||||
|
||||
it('distinguishes entries with same playerCount but different role counts', () => {
|
||||
const h: HistoryEntry[] = [{ playerCount: 8, roles: { 狼人: 2 } }]
|
||||
const out = addHistory(h, { playerCount: 8, roles: { 狼人: 3 } })
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('caps history at 50 entries', () => {
|
||||
let h: HistoryEntry[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
h = addHistory(h, { playerCount: 8, roles: { 狼人: i } })
|
||||
}
|
||||
expect(h).toHaveLength(50)
|
||||
// 最新的(i=99)在最前
|
||||
expect(h[0]).toEqual({ playerCount: 8, roles: { 狼人: 99 } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { sortedRoles, DEFAULT_PRESETS, roleImageUrl } from '../logic/roles'
|
||||
import type { Preferences } from '../logic/assignment'
|
||||
import type { HistoryEntry } from '../logic/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
history: HistoryEntry[]
|
||||
initialPlayerCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
start: [payload: { rolesList: string[]; preferences: Preferences }]
|
||||
}>()
|
||||
|
||||
const playerCount = ref(props.initialPlayerCount ?? 8)
|
||||
const currentRoles = ref<string[]>([])
|
||||
const preferences = ref<{ player: number; roles: string[] }[]>([])
|
||||
const showAddPref = ref(false)
|
||||
const newPref = ref<{ player: number | null; roles: string[] }>({ player: null, roles: [] })
|
||||
|
||||
const allRoles = sortedRoles()
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(s) => {
|
||||
if (s) {
|
||||
// reset preferences only; keep currentRoles + count for repeated games
|
||||
preferences.value = []
|
||||
showAddPref.value = false
|
||||
newPref.value = { player: null, roles: [] }
|
||||
if (props.initialPlayerCount) playerCount.value = props.initialPlayerCount
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const canAddRole = computed(() => currentRoles.value.length < playerCount.value)
|
||||
const canStart = computed(() => currentRoles.value.length === playerCount.value && playerCount.value >= 6)
|
||||
|
||||
const matchingPresets = computed(() => {
|
||||
const matches: { key: string; isHistory: boolean; roles: Record<string, number> }[] = []
|
||||
if (DEFAULT_PRESETS[playerCount.value]) {
|
||||
matches.push({ key: `preset-${playerCount.value}`, isHistory: false, roles: DEFAULT_PRESETS[playerCount.value] })
|
||||
}
|
||||
let count = 0
|
||||
for (let i = 0; i < props.history.length && count < 3; i++) {
|
||||
const h = props.history[i]
|
||||
if (h.playerCount === playerCount.value) {
|
||||
matches.push({ key: `history-${i}`, isHistory: true, roles: h.roles })
|
||||
count++
|
||||
}
|
||||
}
|
||||
return matches
|
||||
})
|
||||
|
||||
const configuredRoles = computed(() => {
|
||||
return Array.from(new Set(currentRoles.value))
|
||||
})
|
||||
|
||||
function decPlayers() {
|
||||
if (playerCount.value <= 6) return
|
||||
playerCount.value -= 1
|
||||
if (currentRoles.value.length > playerCount.value) {
|
||||
currentRoles.value = currentRoles.value.slice(0, playerCount.value)
|
||||
}
|
||||
}
|
||||
function incPlayers() {
|
||||
if (playerCount.value < 20) playerCount.value += 1
|
||||
}
|
||||
|
||||
function addRole(role: string) {
|
||||
if (canAddRole.value) currentRoles.value.push(role)
|
||||
}
|
||||
function removeRole(role: string) {
|
||||
const idx = currentRoles.value.indexOf(role)
|
||||
if (idx >= 0) currentRoles.value.splice(idx, 1)
|
||||
}
|
||||
function roleCount(role: string): number {
|
||||
return currentRoles.value.filter((r) => r === role).length
|
||||
}
|
||||
|
||||
function applyPreset(roles: Record<string, number>) {
|
||||
const list: string[] = []
|
||||
for (const [role, n] of Object.entries(roles)) {
|
||||
for (let i = 0; i < n; i++) list.push(role)
|
||||
}
|
||||
currentRoles.value = list
|
||||
preferences.value = []
|
||||
showAddPref.value = false
|
||||
}
|
||||
|
||||
function selectPrefPlayer(p: number) {
|
||||
if (newPref.value.player === p) {
|
||||
newPref.value = { player: null, roles: [] }
|
||||
return
|
||||
}
|
||||
newPref.value.player = p
|
||||
const existing = preferences.value.find((x) => x.player === p)
|
||||
newPref.value.roles = existing ? [...existing.roles].filter((r) => configuredRoles.value.includes(r)) : []
|
||||
}
|
||||
|
||||
function togglePrefRole(role: string) {
|
||||
const idx = newPref.value.roles.indexOf(role)
|
||||
if (idx >= 0) newPref.value.roles.splice(idx, 1)
|
||||
else newPref.value.roles.push(role)
|
||||
}
|
||||
|
||||
function confirmPref() {
|
||||
if (newPref.value.player == null || newPref.value.roles.length === 0) return
|
||||
const valid = newPref.value.roles.filter((r) => configuredRoles.value.includes(r))
|
||||
if (valid.length === 0) return
|
||||
const idx = preferences.value.findIndex((p) => p.player === newPref.value.player)
|
||||
const entry = { player: newPref.value.player!, roles: valid }
|
||||
if (idx >= 0) preferences.value[idx] = entry
|
||||
else preferences.value.push(entry)
|
||||
cancelPref()
|
||||
}
|
||||
function cancelPref() {
|
||||
showAddPref.value = false
|
||||
newPref.value = { player: null, roles: [] }
|
||||
}
|
||||
function removePref(idx: number) {
|
||||
preferences.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!canStart.value) return
|
||||
const prefDict: Preferences = {}
|
||||
for (const p of preferences.value) prefDict[p.player] = [...p.roles]
|
||||
emit('start', { rolesList: [...currentRoles.value], preferences: prefDict })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<header>
|
||||
<h2>开始新游戏</h2>
|
||||
<button class="x" @click="$emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<label>玩家数量</label>
|
||||
<div class="counter">
|
||||
<button @click="decPlayers" :disabled="playerCount <= 6">−</button>
|
||||
<span>{{ playerCount }} 人</span>
|
||||
<button @click="incPlayers" :disabled="playerCount >= 20">+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label>当前配置 · {{ currentRoles.length }} / {{ playerCount }}</label>
|
||||
<div v-if="currentRoles.length === 0" class="empty">从下方选择角色</div>
|
||||
<div v-else class="grid">
|
||||
<button
|
||||
v-for="(role, idx) in currentRoles"
|
||||
:key="`${role}-${idx}`"
|
||||
class="role-tile"
|
||||
@click="removeRole(role)"
|
||||
>
|
||||
<img :src="roleImageUrl(role)" :alt="role" />
|
||||
<span>{{ role }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="matchingPresets.length > 0">
|
||||
<label>预设</label>
|
||||
<div class="preset-row">
|
||||
<button
|
||||
v-for="p in matchingPresets"
|
||||
:key="p.key"
|
||||
:class="['preset-btn', { history: p.isHistory }]"
|
||||
@click="applyPreset(p.roles)"
|
||||
>
|
||||
<div class="preset-title">{{ p.isHistory ? '历史' : `${playerCount}人` }}</div>
|
||||
<div class="preset-tags">
|
||||
<span v-for="(n, r) in p.roles" :key="r">{{ r }}×{{ n }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label>可用角色{{ canAddRole ? '' : '(已选满)' }}</label>
|
||||
<div class="grid available">
|
||||
<button
|
||||
v-for="role in allRoles"
|
||||
:key="role"
|
||||
:disabled="!canAddRole"
|
||||
:class="['role-tile', { selected: roleCount(role) > 0 }]"
|
||||
@click="addRole(role)"
|
||||
>
|
||||
<img :src="roleImageUrl(role)" :alt="role" />
|
||||
<span>{{ role }}</span>
|
||||
<em v-if="roleCount(role) > 0">×{{ roleCount(role) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="configuredRoles.length > 0">
|
||||
<label>角色偏好(可选)— 偏好角色 4x 权重</label>
|
||||
<div v-if="preferences.length > 0" class="pref-list">
|
||||
<div v-for="(p, idx) in preferences" :key="p.player" class="pref-row">
|
||||
<span>玩家 {{ p.player }} → {{ p.roles.join('、') }}</span>
|
||||
<button class="x" @click="removePref(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!showAddPref" class="add-pref" @click="showAddPref = true">+ 添加偏好</button>
|
||||
|
||||
<div v-if="showAddPref" class="add-pref-form">
|
||||
<div class="sub-label">选择玩家</div>
|
||||
<div class="player-row">
|
||||
<button
|
||||
v-for="i in playerCount"
|
||||
:key="i"
|
||||
:class="['player-btn', { selected: newPref.player === i }]"
|
||||
@click="selectPrefPlayer(i)"
|
||||
>
|
||||
玩家 {{ i }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="newPref.player != null" class="sub-label">选择角色(可多选)</div>
|
||||
<div v-if="newPref.player != null" class="grid">
|
||||
<button
|
||||
v-for="role in configuredRoles"
|
||||
:key="role"
|
||||
:class="['role-tile', { selected: newPref.roles.includes(role) }]"
|
||||
@click="togglePrefRole(role)"
|
||||
>
|
||||
<img :src="roleImageUrl(role)" :alt="role" />
|
||||
<span>{{ role }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pref-actions">
|
||||
<button @click="cancelPref" class="cancel">取消</button>
|
||||
<button @click="confirmPref" :disabled="newPref.player == null || newPref.roles.length === 0" class="ok">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<button @click="$emit('close')" class="cancel">取消</button>
|
||||
<button @click="start" :disabled="!canStart" class="start">开始游戏</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: #232336;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
padding: 16px;
|
||||
margin: 16px 0 32px;
|
||||
}
|
||||
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
h2 { margin: 0; font-size: 1.4rem; }
|
||||
section { margin-bottom: 18px; }
|
||||
label { display: block; margin-bottom: 8px; color: var(--fg-dim); font-weight: 500; }
|
||||
.sub-label { color: var(--fg-dim); margin: 10px 0 6px; font-size: 0.9rem; }
|
||||
.empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
.counter span { font-size: 1.3rem; font-weight: bold; min-width: 80px; text-align: center; }
|
||||
.counter button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.grid.available .role-tile { border: 2px solid rgba(124, 58, 237, 0.4); }
|
||||
.role-tile {
|
||||
background: var(--bg-soft);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.role-tile.selected { border-color: var(--ok); }
|
||||
.role-tile img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.role-tile span {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 2px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
}
|
||||
.role-tile em {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: var(--ok);
|
||||
font-style: normal;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.preset-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.preset-btn {
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--fg);
|
||||
text-align: left;
|
||||
}
|
||||
.preset-btn.history { background: rgba(76, 175, 80, 0.12); border-color: rgba(76, 175, 80, 0.35); }
|
||||
.preset-title { font-weight: bold; }
|
||||
.preset-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; font-size: 0.78rem; }
|
||||
.preset-tags span { background: rgba(255, 255, 255, 0.08); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
.pref-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pref-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.add-pref {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #4ea7f7;
|
||||
border: 1px solid rgba(33, 150, 243, 0.4);
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.add-pref-form {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.player-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.player-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
.player-btn.selected { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.pref-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 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);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
button.cancel {
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
button.start, button.ok {
|
||||
background: var(--ok);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
button.start:disabled, button.ok:disabled { background: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.4); }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { roleImageUrl } from '../logic/roles'
|
||||
|
||||
defineProps<{
|
||||
role: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="role" class="overlay" @click="$emit('confirm')">
|
||||
<div class="content" @click.stop="$emit('confirm')">
|
||||
<img :src="roleImageUrl(role)" :alt="role" class="role-img" />
|
||||
<p class="role-name">{{ role }}</p>
|
||||
<p class="hint">点击确认</p>
|
||||
</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;
|
||||
padding: 16px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
.role-img {
|
||||
max-width: 100%;
|
||||
max-height: 65vh;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.role-name {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
.hint {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.7 } 50% { opacity: 1 } }
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { backImageUrl } from '../logic/roles'
|
||||
|
||||
const props = defineProps<{
|
||||
playerId: number
|
||||
isDead: boolean
|
||||
isRevealed: boolean
|
||||
confirmed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reveal: [playerId: number]
|
||||
confirm: [playerId: number]
|
||||
}>()
|
||||
|
||||
interface SwipeState {
|
||||
startX: number
|
||||
startY: number
|
||||
currentX: number
|
||||
currentY: number
|
||||
isSwiping: boolean
|
||||
}
|
||||
|
||||
const swipe = ref<SwipeState>({ startX: 0, startY: 0, currentX: 0, currentY: 0, isSwiping: false })
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
swipe.value = { startX: t.clientX, startY: t.clientY, currentX: t.clientX, currentY: t.clientY, isSwiping: false }
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
swipe.value.currentX = t.clientX
|
||||
swipe.value.currentY = t.clientY
|
||||
const dx = swipe.value.currentX - swipe.value.startX
|
||||
const dy = Math.abs(swipe.value.currentY - swipe.value.startY)
|
||||
if (Math.abs(dx) > 10 && Math.abs(dx) > dy) {
|
||||
swipe.value.isSwiping = true
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = swipe.value.currentX - swipe.value.startX
|
||||
const dy = Math.abs(swipe.value.currentY - swipe.value.startY)
|
||||
if (swipe.value.isSwiping && dx > 50 && Math.abs(dx) > dy) {
|
||||
if (!props.isRevealed) emit('reveal', props.playerId)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
swipe.value = { startX: e.clientX, startY: e.clientY, currentX: e.clientX, currentY: e.clientY, isSwiping: false }
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
swipe.value.currentX = e.clientX
|
||||
swipe.value.currentY = e.clientY
|
||||
const dx = swipe.value.currentX - swipe.value.startX
|
||||
const dy = Math.abs(swipe.value.currentY - swipe.value.startY)
|
||||
if (Math.abs(dx) > 10 && Math.abs(dx) > dy) {
|
||||
swipe.value.isSwiping = true
|
||||
}
|
||||
}
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
const dx = swipe.value.currentX - swipe.value.startX
|
||||
const dy = Math.abs(swipe.value.currentY - swipe.value.startY)
|
||||
if (swipe.value.isSwiping && dx > 50 && Math.abs(dx) > dy) {
|
||||
if (!props.isRevealed) emit('reveal', props.playerId)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (props.isRevealed) emit('confirm', props.playerId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['card', { dead: isDead, revealed: isRevealed, confirmed }]"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@click="onClick"
|
||||
>
|
||||
<img :src="backImageUrl()" alt="back" class="back-img" />
|
||||
<div class="overlay">
|
||||
<span class="player-num">玩家 {{ playerId }}</span>
|
||||
<svg v-if="!isRevealed" class="hint swipe" viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="rgba(255,255,255,.95)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<svg v-else-if="!confirmed" class="hint tap" viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="rgba(255,255,255,.95)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12l7-7 7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
background: #000;
|
||||
}
|
||||
.card.dead { filter: grayscale(100%); }
|
||||
.card.confirmed { opacity: 0.55; }
|
||||
|
||||
.back-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
padding: 14px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.player-num { font-size: 2rem; line-height: 1; }
|
||||
.hint.swipe { animation: swipe-move 1.5s ease-in-out infinite; }
|
||||
.hint.tap { animation: tap-pulse 1.5s ease-in-out infinite; }
|
||||
|
||||
@keyframes swipe-move {
|
||||
0%, 100% { transform: translateX(-14px); opacity: 0.8; }
|
||||
50% { transform: translateX(14px); opacity: 1; }
|
||||
}
|
||||
@keyframes tap-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
// localStorage 包装。SSR safe。
|
||||
|
||||
export interface GameState {
|
||||
playerRoles: string[]
|
||||
pendingConfirm: number[]
|
||||
deadPlayers: number[]
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
playerCount: number
|
||||
roles: Record<string, number>
|
||||
}
|
||||
|
||||
interface PersistedState {
|
||||
game: GameState | null
|
||||
history: HistoryEntry[]
|
||||
}
|
||||
|
||||
const KEY = 'werewolf:v1'
|
||||
const MAX_HISTORY = 50
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
if (!isBrowser()) return { game: null, history: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return { game: null, history: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedState>
|
||||
return {
|
||||
game: parsed.game ?? null,
|
||||
history: parsed.history ?? [],
|
||||
}
|
||||
} catch {
|
||||
return { game: null, history: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: PersistedState): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// 配额满 / 隐私模式,静默
|
||||
}
|
||||
}
|
||||
|
||||
/** 加一条历史,去重(同 playerCount + 同 roles dict)+ 截断到 MAX_HISTORY。 */
|
||||
export function addHistory(history: HistoryEntry[], entry: HistoryEntry): HistoryEntry[] {
|
||||
const filtered = history.filter(
|
||||
(h) => !(h.playerCount === entry.playerCount && rolesEqual(h.roles, entry.roles)),
|
||||
)
|
||||
return [entry, ...filtered].slice(0, MAX_HISTORY)
|
||||
}
|
||||
|
||||
function rolesEqual(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
const ka = Object.keys(a)
|
||||
const kb = Object.keys(b)
|
||||
if (ka.length !== kb.length) return false
|
||||
for (const k of ka) {
|
||||
if (a[k] !== b[k]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,35 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #1a1a2e;
|
||||
--bg-soft: rgba(255, 255, 255, 0.06);
|
||||
--bg-card: rgba(255, 255, 255, 0.08);
|
||||
--border: rgba(255, 255, 255, 0.15);
|
||||
--fg: rgba(255, 255, 255, 0.92);
|
||||
--fg-dim: rgba(255, 255, 255, 0.6);
|
||||
--accent: #7c3aed;
|
||||
--accent-2: #06b6d4;
|
||||
--danger: #ef4444;
|
||||
--ok: #4caf50;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
Reference in New Issue
Block a user