music(player): 变速播放 + AB Loop
deploy articulate / build-and-deploy (push) Failing after 1m42s
deploy cube / build-and-deploy (push) Successful in 2m5s
deploy karaoke / build-and-deploy (push) Failing after 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 2m21s
deploy music / build-and-deploy (push) Successful in 4m2s
deploy werewolf / build-and-deploy (push) Failing after 58s
- 变速:底部 1× 圆形按钮循环切 0.5/0.75/1/1.25/1.5;preservesPitch=true(浏览器 native 保音高);localStorage 持久化全局
- AB Loop:A B 两按钮在当前位置打点,🔁 开关;进度条上绿色高亮 A↔B 区段;timeupdate 触发 ≥B 跳回 A;切歌自动清 A/B
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<title>狼人杀发牌器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "werewolf",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 697 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 746 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
After Width: | Height: | Size: 722 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 741 KiB |
|
After Width: | Height: | Size: 731 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 689 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 657 KiB |
|
After Width: | Height: | Size: 692 KiB |
@@ -0,0 +1,102 @@
|
||||
// 角色分配 + role list 展开。RNG 注入,便于测试。
|
||||
|
||||
export type RoleDict = Record<string, number>
|
||||
export type RoleList = string[]
|
||||
export type Preferences = Record<number, string[]> // player_id (1-based) -> 偏好角色列表
|
||||
|
||||
export interface Rng {
|
||||
// [0, 1)
|
||||
next(): number
|
||||
}
|
||||
|
||||
export const defaultRng: Rng = { next: () => Math.random() }
|
||||
|
||||
/** 把 {狼人: 2, 村民: 1} 展开成 ['狼人', '狼人', '村民']。已是 list 则原样返回。 */
|
||||
export function expandRolesToList(roles: RoleDict | RoleList): RoleList {
|
||||
if (Array.isArray(roles)) return [...roles]
|
||||
const out: RoleList = []
|
||||
for (const [role, count] of Object.entries(roles)) {
|
||||
for (let i = 0; i < count; i++) out.push(role)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 缩回 dict,方便存历史。 */
|
||||
export function rolesListToDict(roles: RoleList): RoleDict {
|
||||
const out: RoleDict = {}
|
||||
for (const r of roles) out[r] = (out[r] || 0) + 1
|
||||
return out
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle,用注入的 rng 保证可测。
|
||||
export function shuffle<T>(arr: T[], rng: Rng = defaultRng): T[] {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng.next() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
function weightedPick(weighted: string[], rng: Rng): string {
|
||||
// weighted 是已经按权重展开的 list,直接随机选
|
||||
const idx = Math.floor(rng.next() * weighted.length)
|
||||
return weighted[idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按偏好分配角色。
|
||||
*
|
||||
* 算法(从 partiverse python 同步):
|
||||
* - 先随机化玩家顺序(保证公平 — 谁都可能先选)
|
||||
* - 维护剩余角色池
|
||||
* - 对每个玩家:偏好角色 4x 权重,其他 1x,加权随机抽一个
|
||||
* - 抽完从池里扣除
|
||||
*
|
||||
* 返回 list:index 0 = player 1 的角色。
|
||||
*/
|
||||
export function assignRolesWithPreferences(
|
||||
rolesList: RoleList,
|
||||
preferences: Preferences,
|
||||
rng: Rng = defaultRng,
|
||||
): RoleList {
|
||||
const numPlayers = rolesList.length
|
||||
if (numPlayers === 0) return []
|
||||
|
||||
// 池:role -> 剩余 count
|
||||
const pool: Record<string, number> = {}
|
||||
for (const r of rolesList) pool[r] = (pool[r] || 0) + 1
|
||||
|
||||
const playerOrder = shuffle(
|
||||
Array.from({ length: numPlayers }, (_, i) => i + 1),
|
||||
rng,
|
||||
)
|
||||
|
||||
const assignments: Record<number, string> = {}
|
||||
|
||||
for (const playerId of playerOrder) {
|
||||
const preferred = new Set(preferences[playerId] || [])
|
||||
|
||||
const weighted: string[] = []
|
||||
for (const [role, count] of Object.entries(pool)) {
|
||||
if (count <= 0) continue
|
||||
const w = preferred.has(role) ? count * 4 : count
|
||||
for (let i = 0; i < w; i++) weighted.push(role)
|
||||
}
|
||||
|
||||
if (weighted.length === 0) {
|
||||
// 不该发生,兜底
|
||||
assignments[playerId] = rolesList[playerId - 1]
|
||||
continue
|
||||
}
|
||||
|
||||
const picked = weightedPick(weighted, rng)
|
||||
assignments[playerId] = picked
|
||||
pool[picked] -= 1
|
||||
if (pool[picked] === 0) delete pool[picked]
|
||||
}
|
||||
|
||||
const out: RoleList = []
|
||||
for (let i = 1; i <= numPlayers; i++) out.push(assignments[i])
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 全部 30 个角色,名字 = public/werewolf/roles/{name}.JPG 的 stem
|
||||
export const ALL_ROLES = [
|
||||
'企鹅', '厚皮狼', '大公鸡', '女巫', '守卫', '守夜人', '小女孩', '小绵羊',
|
||||
'憎恶猎人', '暴狼', '月夜祭司', '机械狼', '村民', '炸弹师', '熊', '爱神',
|
||||
'狼人', '狼美人', '猎人', '白狼王', '白痴', '盗宝大师', '石像鬼', '禁锢之影',
|
||||
'织梦人', '老酒鬼', '通灵师', '野孩子', '预言家', '骑士',
|
||||
] as const
|
||||
|
||||
export type RoleName = (typeof ALL_ROLES)[number]
|
||||
|
||||
// 默认预设。从 partiverse backend/activities/werewolf.py 同步。
|
||||
export const DEFAULT_PRESETS: Record<number, Record<string, number>> = {
|
||||
8: { 狼人: 2, 守卫: 2, 女巫: 1, 猎人: 1, 骑士: 1, 白狼王: 1 },
|
||||
9: { 狼人: 3, 守卫: 2, 女巫: 1, 猎人: 1, 骑士: 1, 白狼王: 1 },
|
||||
10: { 狼人: 3, 守卫: 3, 女巫: 1, 猎人: 1, 骑士: 1, 白狼王: 1 },
|
||||
12: { 狼人: 4, 守卫: 3, 女巫: 1, 猎人: 1, 骑士: 1, 白狼王: 1, 村民: 1 },
|
||||
}
|
||||
|
||||
// preset 里出现过的角色排前面(频次降序,再按名字)
|
||||
function presetFrequency(): Record<string, number> {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const preset of Object.values(DEFAULT_PRESETS)) {
|
||||
for (const role of Object.keys(preset)) {
|
||||
counts[role] = (counts[role] || 0) + 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function sortedRoles(): RoleName[] {
|
||||
const counts = presetFrequency()
|
||||
return [...ALL_ROLES].sort((a, b) => {
|
||||
const da = counts[a] || 0
|
||||
const db = counts[b] || 0
|
||||
if (da !== db) return db - da
|
||||
return a.localeCompare(b, 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
|
||||
export function roleImageUrl(role: string): string {
|
||||
return `/werewolf/roles/${encodeURIComponent(role)}.JPG`
|
||||
}
|
||||
|
||||
export function backImageUrl(): string {
|
||||
return '/werewolf/back.jpg'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
target: 'es2020',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||