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
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "werewolf"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "werewolf.famzheng.me — 狼人杀单机发牌器(一台手机轮流传),从 partiverse 移植"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
tokio = { workspace = true }
+7
View File
@@ -0,0 +1,7 @@
# werewolf — werewolf.famzheng.me
# Build context = repo root,路径都是 apps/werewolf/...
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/werewolf /werewolf
COPY apps/werewolf/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/werewolf"]
+13
View File
@@ -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>
+22
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

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,加权随机抽一个
* - 抽完从池里扣除
*
* 返回 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'
}
+22
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -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',
},
})
+82
View File
@@ -0,0 +1,82 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-werewolf
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: werewolf
namespace: cube-werewolf
labels:
app: werewolf
spec:
replicas: 1
selector:
matchLabels:
app: werewolf
template:
metadata:
labels:
app: werewolf
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: werewolf
image: registry.famzheng.me/mochi/werewolf:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: werewolf
namespace: cube-werewolf
spec:
selector:
app: werewolf
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: werewolf
namespace: cube-werewolf
spec:
ingressClassName: traefik
rules:
- host: werewolf.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: werewolf
port:
number: 80
+9
View File
@@ -0,0 +1,9 @@
//! werewolf.famzheng.me — 狼人杀单机发牌器。纯静态前端,无 API。
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("WEREWOLF_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let app = cube_core::base(dist);
cube_core::serve(app, 8080).await
}