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。
This commit is contained in:
Fam Zheng
2026-05-14 15:31:58 +01:00
parent cdbf8308d1
commit 0b22691b3d
13 changed files with 4257 additions and 0 deletions
+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;
}