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
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:
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user