Compare commits

..

4 Commits

Author SHA1 Message Date
Fam Zheng bcdf6c6ba4 cube(portal): list werewolf / articulate / karaoke (pending)
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy articulate / build-and-deploy (push) Successful in 1m26s
deploy karaoke / build-and-deploy (push) Successful in 1m22s
deploy music / build-and-deploy (push) Successful in 2m32s
deploy simpleasm / build-and-deploy (push) Successful in 1m35s
deploy werewolf / build-and-deploy (push) Successful in 59s
三个 partiverse 移植 app 入口,先标 pending — CI 跑过 + k8s rollout
成功后再改 live。
2026-05-14 15:32:28 +01:00
Fam Zheng fbd6e3cb9c karaoke(app): port single-device playlist from partiverse + tests
点歌单本地管理 — 添加/上移/下移/置顶/删除 + 10 秒撤销倒计时 + YouTube 一键
搜,无 room / 无 ws。删掉了 partiverse 那套 yopu 和弦抓取 / LLM 聊天点歌 /
QR 码(依赖后端,对单机无意义)。logic 全 immutable,21 个 vitest 覆盖
边界(首位上移 noop / 末位下移 noop / 缺失 id / 不变性)。
2026-05-14 15:32:22 +01:00
Fam Zheng 78f84d4225 articulate(app): port single-device word game from partiverse + tests
中英猜词派对 — 选主题 / 难度 / 词数 → 大字模式描述给队友猜,无 room / 无 ws。
15 个 preset 主题(wordlists 已在 scaffold 时就位)+ 3 档难度 + 已看词跨场
记忆(localStorage,cap 5000)+ Enter/Space/Esc 键盘。pickWords 优先未看过
再 fallback 见过的。logic 层 24 个 vitest(解析 / 抽词 / 确定性 rng)。
2026-05-14 15:32:15 +01:00
Fam Zheng 0b22691b3d werewolf(app): port single-device dealer from partiverse + tests
单机发牌器 — 一台手机轮流传,无 room / 无 ws。30 个角色 + 4 档默认预设
(8/9/10/12 人) + 配置历史(dedup + cap 50)+ 4x 偏好加权 + swipe-to-reveal
+ tap-to-confirm + 3D card flip + 死亡标记,全部本地 localStorage。
RNG 注入,logic 层 29 个 vitest(含 2000 次蒙特卡洛验证偏好命中率 > 40%、
均匀分布 ±5%)。也把 *.tsbuildinfo 加进 .gitignore。
2026-05-14 15:31:58 +01:00
33 changed files with 11561 additions and 0 deletions
+1
View File
@@ -1,4 +1,5 @@
/target /target
**/node_modules **/node_modules
**/dist **/dist
**/tsconfig.tsbuildinfo
.DS_Store .DS_Store
Generated
+24
View File
@@ -23,6 +23,14 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "articulate"
version = "0.1.0"
dependencies = [
"cube-core",
"tokio",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -599,6 +607,14 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "karaoke"
version = "0.1.0"
dependencies = [
"cube-core",
"tokio",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1671,6 +1687,14 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "werewolf"
version = "0.1.0"
dependencies = [
"cube-core",
"tokio",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
File diff suppressed because it is too large Load Diff
+270
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -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; }
+21
View File
@@ -51,4 +51,25 @@ export const apps: App[] = [
url: 'https://pyroblem.famzheng.me', url: 'https://pyroblem.famzheng.me',
status: 'tbd', 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',
},
] ]
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+23
View File
@@ -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; }
File diff suppressed because it is too large Load Diff
+343
View File
@@ -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
},
}
}
// 简单确定性 RNGmulberry32,便于重现。
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
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+35
View File
@@ -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;
}