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>
|