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
This commit is contained in:
Fam Zheng
2026-05-10 21:40:19 +01:00
parent 5674be1cfd
commit cdbf8308d1
81 changed files with 5899 additions and 0 deletions
@@ -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,加权随机抽一个
* - 抽完从池里扣除
*
* 返回 listindex 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
}
+46
View File
@@ -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'
}