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:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 简单确定性 RNG:mulberry32,便于重现。
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user