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>