HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构

- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+48
View File
@@ -0,0 +1,48 @@
"""资产清单校验:game.js 引用的每个音频 id 都必须有 .ogg 和 .m4a。
- 提取所有 'bgm/xxx' 'voice/xxx' 'sfx/xxx' 字面量 id;
- numIds() 里动态拼的 voice/num_${k}(k=0..9)特判展开。
用法:python3 gen/check_assets.py
"""
import os
import re
import sys
HERE = os.path.dirname(__file__)
GAME_DIR = os.path.normpath(os.path.join(HERE, ".."))
AUDIO = os.path.join(GAME_DIR, "audio")
src = ""
for js in ("game.js", "index.html"):
with open(os.path.join(GAME_DIR, js), encoding="utf-8") as f:
src += f.read()
ids = set(re.findall(r"['\"]((?:bgm|voice|sfx)/[a-z0-9_]+)['\"]", src))
# 动态拼接的数字语音 voice/num_0 .. voice/num_9
if re.search(r"voice/num_\$\{", src) or "voice/num_" in src:
ids.update(f"voice/num_{k}" for k in range(10))
missing = []
for aid in sorted(ids):
for ext in (".ogg", ".m4a"):
if not os.path.isfile(os.path.join(AUDIO, aid + ext)):
missing.append(aid + ext)
print(f"引用音频 id 数: {len(ids)}")
if missing:
print("缺失文件:")
for m in missing:
print(" ", m)
sys.exit(1)
# 反向检查:目录里有但代码没引用的资产(提示,不失败)
unreferenced = []
for layer in ("bgm", "voice", "sfx"):
d = os.path.join(AUDIO, layer)
for f in os.listdir(d):
if f.endswith(".ogg") and f"{layer}/{f[:-4]}" not in ids:
unreferenced.append(f"{layer}/{f}")
if unreferenced:
print("未被引用的资产(提示):", ", ".join(sorted(unreferenced)))
print("OK:全部引用 id 均有 .ogg 与 .m4a")
+76
View File
@@ -0,0 +1,76 @@
"""web/simon 封面:1080x1920,深色底 + 游戏名 + 玩法提示 + 手势图例。"""
import math
import os
import random
from PIL import Image, ImageDraw, ImageFilter, ImageFont
W, H = 1080, 1920
FONT = "/home/ubuntu/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf"
OUT = os.path.join(os.path.dirname(__file__), "..", "cover.png")
img = Image.new("RGB", (W, H), (10, 12, 26))
d = ImageDraw.Draw(img)
# 背景:竖向微渐变 + 随机声波弧线
for y in range(H):
t = y / H
d.line([(0, y), (W, y)], fill=(int(10 + 14 * t), int(12 + 10 * t), int(26 + 30 * t)))
wave = Image.new("RGBA", (W, H), (0, 0, 0, 0))
wd = ImageDraw.Draw(wave)
rng = random.Random(42)
colors = [(94, 234, 212), (129, 140, 248), (244, 114, 182), (250, 204, 21)]
for i in range(28):
cx, cy = rng.randint(0, W), rng.randint(0, H)
r = rng.randint(60, 320)
c = colors[i % len(colors)]
a = rng.randint(10, 36)
wd.ellipse([cx - r, cy - r, cx + r, cy + r], outline=c + (a,), width=rng.randint(2, 5))
wave = wave.filter(ImageFilter.GaussianBlur(1.5))
img.paste(wave, (0, 0), wave)
d = ImageDraw.Draw(img)
# 中央:六边形"声波脉冲"标志
cx, cy = W // 2, 640
pulse_cols = [(94, 234, 212), (129, 140, 248), (244, 114, 182)]
for i, r in enumerate([120, 200, 280]):
col = pulse_cols[i]
alpha = 200 - i * 55
ring = Image.new("RGBA", (W, H), (0, 0, 0, 0))
rd = ImageDraw.Draw(ring)
rd.ellipse([cx - r, cy - r, cx + r, cy + r], outline=col + (alpha,), width=10 - i * 2)
img.paste(ring, (0, 0), ring)
d = ImageDraw.Draw(img)
d.ellipse([cx - 52, cy - 52, cx + 52, cy + 52], fill=(94, 234, 212))
# 标题
f_title = ImageFont.truetype(FONT, 190)
f_sub = ImageFont.truetype(FONT, 56)
f_small = ImageFont.truetype(FONT, 40)
def ctext(y, s, font, fill, spacing=0):
bbox = d.textbbox((0, 0), s, font=font)
w = bbox[2] - bbox[0]
x = (W - w) / 2
for ch in s:
d.text((x, y), ch, font=font, fill=fill)
bb = d.textbbox((0, 0), ch, font=font)
x += (bb[2] - bb[0]) + spacing
ctext(980, "音序回响", f_title, (240, 244, 255), spacing=12)
ctext(1230, "听音辨序 · 复现手势", f_sub, (148, 163, 184))
# 手势图例(两行三列)
hints = ["↑ 上滑 上行琶音", "↓ 下滑 下行低音", "← 左滑 左侧声道",
"→ 右滑 右侧声道", "· 单击 短促鼓点", "·· 双击 两声连响"]
hx, hy = [60, 370, 680], 1400
for i, s in enumerate(hints):
col = i % 3
row = i // 3
d.text((hx[col], hy + row * 84), s, font=f_small, fill=(110, 130, 160))
ctext(1700, "从 2 个音开始,每轮 +1,看你能坚持几轮", f_small, (90, 105, 135))
img.save(OUT)
print(OUT, img.size)
+112
View File
@@ -0,0 +1,112 @@
"""web/simon SFX 合成(numpy,44.1kHz 立体声 wav,后续 ffmpeg 转 ogg/m4a)。
6 种手势特征音 + 4 个反馈音:
tone_up 上行琶音 C5-E5-G5-C6(明亮弹拨)
tone_down 下行低音 A2-F2-D2-A1(浑厚)
tone_left E4 弹拨,声像极左
tone_right G4 弹拨,声像极右
tone_tap 短促底鼓
tone_doubletap 两声连响(木琴质感)
correct 上行双音和弦(答对)
wrong 失谐锯齿低鸣(答错)
newrecord 快速号角琶音(新纪录)
gameover 下行三音(游戏结束)
"""
import os
import numpy as np
import soundfile as sf
SR = 44100
OUT = os.path.join(os.path.dirname(__file__), "..", "audio", "sfx", "wav_tmp")
os.makedirs(OUT, exist_ok=True)
def env_adsr(n, a=0.005, d=0.05, s=0.3, r=0.08):
"""a/d/r 为秒,s 为 sustain 电平(0~1)。"""
na, nd, nr = int(a * SR), int(d * SR), int(r * SR)
ns = max(0, n - na - nd - nr)
e = np.concatenate([
np.linspace(0, 1, na, endpoint=False),
np.linspace(1, s, nd, endpoint=False),
np.full(ns, s),
np.linspace(s, 0, nr),
])
return e[:n] if len(e) >= n else np.pad(e, (0, n - len(e)))
def pluck(freq, dur, brightness=4, decay=6.0):
"""类弹拨/木琴:基频 + 递减泛音,指数衰减。"""
n = int(dur * SR)
t = np.arange(n) / SR
sig = np.zeros(n)
for h in range(1, brightness + 1):
sig += (1.0 / h) * np.sin(2 * np.pi * freq * h * t)
sig *= np.exp(-decay * t)
return sig
def kick(dur=0.18):
"""底鼓:正弦扫频 150→45Hz + 起始咔哒噪声。"""
n = int(dur * SR)
t = np.arange(n) / SR
f = 45 + (150 - 45) * np.exp(-30 * t)
phase = 2 * np.pi * np.cumsum(f) / SR
sig = np.sin(phase) * np.exp(-18 * t)
click = np.random.default_rng(7).standard_normal(n) * np.exp(-400 * t) * 0.3
return sig + click
def saw(freq, dur, detune=1.01):
n = int(dur * SR)
t = np.arange(n) / SR
def s(f):
# 加法合成锯齿(前 12 次泛音)
out = np.zeros(n)
for h in range(1, 13):
out += (1.0 / h) * np.sin(2 * np.pi * f * h * t)
return out
sig = (s(freq) + s(freq * detune)) * 0.5
return sig * env_adsr(n, a=0.01, d=0.1, s=0.6, r=0.15)
def stereo(mid, pan=0.0):
"""pan: -1 极左 ~ +1 极右。"""
mid = mid / (np.max(np.abs(mid)) + 1e-9) * 0.85
l = mid * np.sqrt((1 - pan) / 2) * np.sqrt(2)
r = mid * np.sqrt((1 + pan) / 2) * np.sqrt(2)
return np.stack([l, r], axis=1)
def seq(notes, gap=0.0, **kw):
"""音符序列拼接:notes = [(freq, dur), ...],gap 为音符间隔秒。"""
parts = []
gapbuf = np.zeros(int(gap * SR))
for f, d in notes:
parts.append(pluck(f, d, **kw))
parts.append(gapbuf)
return np.concatenate(parts[:-1]) if parts else np.zeros(1)
def save(name, sig):
sig = sig / (np.max(np.abs(sig)) + 1e-9) * 0.85
if sig.ndim == 1:
sig = np.stack([sig, sig], axis=1)
path = os.path.join(OUT, name + ".wav")
sf.write(path, sig, SR)
print(path, f"{len(sig)/SR:.2f}s")
# ---- 6 手势音 ----
save("tone_up", stereo(seq([(523.25, .14), (659.25, .14), (783.99, .14), (1046.50, .30)], decay=8)))
save("tone_down", stereo(seq([(110.0, .18), (87.31, .18), (73.42, .18), (55.0, .40)], brightness=3, decay=5)))
save("tone_left", stereo(pluck(329.63, 0.4, decay=7), pan=-0.93))
save("tone_right", stereo(pluck(392.0, 0.4, decay=7), pan=0.93))
save("tone_tap", stereo(kick()))
dbl = np.concatenate([pluck(880, 0.10, decay=14), np.zeros(int(0.09 * SR)), pluck(1174.66, 0.16, decay=12)])
save("tone_doubletap", stereo(dbl))
# ---- 反馈音 ----
save("correct", stereo(seq([(783.99, .14), (1318.51, .30)], decay=9)))
save("wrong", stereo(saw(110, 0.6)))
save("newrecord", stereo(seq([(523.25, .10), (659.25, .10), (783.99, .10), (1046.50, .10), (1318.51, .35)], decay=8)))
save("gameover", stereo(seq([(392.0, .22), (311.13, .22), (246.94, .5)], brightness=3, decay=5)))
+72
View File
@@ -0,0 +1,72 @@
"""web/simon 语音批量生成(Qwen3-TTS CustomVoice,Serena 音色)。
生成后写入 audio/voice/wav_tmp/*.wav,由 shell 统一 ffmpeg 转 ogg/m4a。
每条断言:时长 > 0.3s(不足时尾部补静音到 0.35s)、峰值 > 0.05(统一峰值归一 0.8)。
"""
import os
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
OUT = os.path.join(os.path.dirname(__file__), "..", "audio", "voice", "wav_tmp")
os.makedirs(OUT, exist_ok=True)
LINES = {
# 开场 + 教学
"intro": "欢迎来到音序回响。我会用六种声音演奏一段序列,请你记住顺序,再用同样的手势复现出来。先来认识这六种声音。",
"teach_up": "向上滑,是上行琶音。",
"teach_down": "向下滑,是低沉的下行音。",
"teach_left": "向左滑,声音在你的左边。",
"teach_right": "向右滑,声音在你的右边。",
"teach_tap": "单击,是短促的鼓点。",
"teach_doubletap": "双击,是两声连响。",
"teach_end": "好了,记住这些声音。序列会越来越长,看你能坚持几轮。",
# 流程提示
"listen": "听好了。",
"your_turn": "轮到你了。",
# 反馈
"correct": "答对了!",
"wrong": "错了。",
"new_record": "新纪录!",
"gameover": "游戏结束。",
"replay": "单击屏幕,再来一局。",
# 播报拼接片段
"score_pre": "你坚持了",
"best_pre": "历史最高纪录,",
"di": "第",
"lun": "轮",
"num_shi": "十",
"num_0": "零", "num_1": "一", "num_2": "二", "num_3": "三", "num_4": "四",
"num_5": "五", "num_6": "六", "num_7": "七", "num_8": "八", "num_9": "九",
}
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
)
names = list(LINES.keys())
texts = [LINES[n] for n in names]
wavs, sr = model.generate_custom_voice(
text=texts,
language=["Chinese"] * len(names),
speaker="Serena",
instruct="用清晰、沉稳而有引导感的声音,语速适中,像一个耐心的游戏主持人",
)
for name, wav in zip(names, wavs):
wav = np.asarray(wav, dtype=np.float32)
peak = np.max(np.abs(wav))
assert peak > 0.05, f"{name} 峰值过小: {peak}"
wav = wav / peak * 0.8
dur = len(wav) / sr
if dur < 0.35: # 单字片段补静音,保证可听清且满足时长断言
wav = np.concatenate([wav, np.zeros(int((0.35 - dur) * sr), dtype=np.float32)])
dur = len(wav) / sr
assert dur > 0.3, f"{name} 时长过短: {dur}"
path = os.path.join(OUT, name + ".wav")
sf.write(path, wav, sr)
print(f"{name}: {dur:.2f}s peak={np.max(np.abs(wav)):.2f}")
print("done", len(names), "clips")