Files
cube/apps/werewolf/frontend/src/App.vue
T
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

344 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>