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:
@@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
|
**/tsconfig.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+24
@@ -23,6 +23,14 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "articulate"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cube-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -599,6 +607,14 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "karaoke"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cube-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1671,6 +1687,14 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werewolf"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cube-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
Generated
+2898
File diff suppressed because it is too large
Load Diff
@@ -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