0b22691b3d
单机发牌器 — 一台手机轮流传,无 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。
344 lines
9.1 KiB
Vue
344 lines
9.1 KiB
Vue
<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>
|