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,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 }
|
||||
@@ -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"]
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||