feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官

四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
This commit is contained in:
xsl
2026-09-14 05:48:13 +08:00
parent c8843233b0
commit 4b39ae2e49
2676 changed files with 62789 additions and 1282 deletions
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# 《守塔人》BGM 后处理(批次 4):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。
# 用法: bash web/keeper/gen/gen_bgm.sh (flac 原件在 tools/mureka_out/keeper-*/,不入库)
#
# 工艺(tools/mureka_log.md 批次 1/2 同款):
# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri)
# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(21-keeper.md §5.1 三首主题曲同档)
# → aresample 44.1kHz → ogg libvorbis q3 + m4a aac 128k,落 web/keeper/audio/bgm/。
# 任务 id / 选用 choice / 成本见 tools/mureka_log.md「2026-09-14 批次 4」节;
# 无节奏关键曲目(氛围叙事曲),不需要 bpm_precise(radio/abyss/detective 先例)。
set -euo pipefail
cd /home/xsl/SoundGame
TMP=tools/bgm_post_wav/keeper # 中间 wav(不入库,可由本脚本复现)
DST=web/keeper/audio/bgm
mkdir -p "$TMP" "$DST"
post() { # post <flac> <track> <LUFS>
local src="$1" track="$2" lufs="$3"
local tmp="$TMP/$track.wav"
ffmpeg -y -v error -i "$src" -af \
"silenceremove=start_periods=1:start_threshold=-35dB:start_silence=0.02,atrim=end=93,asetpts=PTS-STARTPTS,asplit[a][b];[b]atrim=start=90,asetpts=PTS-STARTPTS[t];[a][t]acrossfade=d=3:c1=tri:c2=tri,loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=44100" \
"$tmp"
ffmpeg -y -v error -i "$tmp" -c:a libvorbis -q:a 3 "$DST/$track.ogg"
ffmpeg -y -v error -i "$tmp" -c:a aac -b:a 128k "$DST/$track.m4a"
echo "$track <- $src (I=${lufs})"
}
# 选用候选 = 时长更接近 90s 者(拿不准原则):storm=候选0(177.4s) memory=候选0(166.5s)
# dawn=候选1(185.7s vs 212.2s),详见 tools/mureka_log.md
post tools/mureka_out/keeper-storm/160725024833537_0.flac storm -20
post tools/mureka_out/keeper-memory/160725167505410_0.flac memory -20
post tools/mureka_out/keeper-dawn/160725310046209_1.flac dawn -20
echo "== keeper bgm done: $(ls "$DST" | wc -l) files =="
+125
View File
@@ -0,0 +1,125 @@
"""web/keeper 封面:1080x1920,孤岛灯塔主题——墨黑风暴海、孤塔双道光束、
雨丝与闪电微光,竖排标题《守塔人》。可复现脚本(固定 seed,无外部网络依赖)。
用法:python3 web/keeper/gen/gen_cover.py
(或 .venv-tts/bin/python web/keeper/gen/gen_cover.py)
"""
import math
import os
import random
from PIL import Image, ImageDraw, ImageFilter, ImageFont
W, H = 1080, 1920
FONT_CANDIDATES = [
"/home/xsl/SoundGame/web/rhythm/gen/NotoSansSC-Bold.ttf",
"/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf",
]
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png")
def load_font(size):
for p in FONT_CANDIDATES:
if os.path.exists(p):
return ImageFont.truetype(p, size)
raise SystemExit("未找到中文字体,请放一份 NotoSansSC-Bold.ttf 到 web/rhythm/gen/")
def main():
rng = random.Random(2114) # 固定 seed:可复现
img = Image.new("RGB", (W, H), (6, 10, 18))
d = ImageDraw.Draw(img)
# 背景竖向渐变:墨黑风暴 → 海面微光(灯塔下方最亮)
for y in range(H):
t = y / H
glow = max(0.0, 1.0 - abs(t - 0.62) * 3.2) * 26
d.line([(0, y), (W, y)],
fill=(int(6 + 8 * t), int(10 + 12 * t), int(18 + 22 * t + glow)))
# 雨丝(风暴签名)
rain = Image.new("RGBA", (W, H), (0, 0, 0, 0))
rd = ImageDraw.Draw(rain)
for _ in range(340):
x = rng.randint(-40, W + 40)
y = rng.randint(0, H)
ln = rng.randint(26, 64)
rd.line([(x, y), (x - ln // 4, y + ln)],
fill=(150, 175, 205, rng.randint(10, 30)), width=1)
img.paste(rain, (0, 0), rain)
d = ImageDraw.Draw(img)
# 灯塔(中下偏左):塔身条纹 + 灯室
cx, base_y = W // 2 - 60, 1250
top_y = 620
for i in range(7):
y0 = top_y + i * 90
y1 = y0 + 90
w0 = 46 + i * 7
w1 = 46 + (i + 1) * 7
col = (214, 218, 224) if i % 2 == 0 else (158, 42, 43)
d.polygon([(cx - w0, y0), (cx + w0, y0), (cx + w1, y1), (cx - w1, y1)], fill=col)
d.rectangle([cx - 40, top_y - 46, cx + 40, top_y], fill=(30, 34, 44))
d.rectangle([cx - 26, top_y - 38, cx + 26, top_y - 8], fill=(255, 214, 120))
d.polygon([(cx - 46, top_y - 46), (cx + 46, top_y - 46), (cx, top_y - 84)], fill=(24, 28, 38))
# 双道光束(旋转灯扫过雨夜,暖黄、模糊压暗)
beam = Image.new("RGBA", (W, H), (0, 0, 0, 0))
bd = ImageDraw.Draw(beam)
for ang, alpha in ((-18, 60), (162, 44)):
a = math.radians(ang)
bx, by = cx, top_y - 27
L = 950
ex, ey = bx + L * math.cos(a), by - L * math.sin(a) * 0.42
spread = 90
perp = (math.cos(a + math.pi / 2), -math.sin(a + math.pi / 2) * 0.42)
bd.polygon([(bx, by),
(ex + perp[0] * spread, ey + perp[1] * spread),
(ex - perp[0] * spread, ey - perp[1] * spread)],
fill=(255, 206, 110, alpha))
beam = beam.filter(ImageFilter.GaussianBlur(22))
img.paste(beam, (0, 0), beam)
d = ImageDraw.Draw(img)
# 海面(塔基下):暗色波纹横线
sea = Image.new("RGBA", (W, H), (0, 0, 0, 0))
sd = ImageDraw.Draw(sea)
for i in range(60):
y = base_y + rng.randint(-20, 330)
x0 = rng.randint(-60, W - 200)
ln = rng.randint(120, 400)
sd.line([(x0, y), (x0 + ln, y)], fill=(90, 120, 150, rng.randint(16, 44)), width=2)
img.paste(sea, (0, 0), sea)
d = ImageDraw.Draw(img)
# 标题与副题
f_title = load_font(180)
f_sub = load_font(52)
f_small = load_font(38)
def ctext(y, s, font, fill, spacing=8):
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(1430, "守塔人", f_title, (238, 242, 250), spacing=16)
ctext(1700, "风暴夜 · 一盏灯 · 一条频道", f_sub, (146, 162, 186))
# 手势图例(极简两行)
hints = ["单击 继续 / 跳句", "左右滑 抉择", "上滑 状态", "下滑 菜单", "双击 重听"]
for i, s in enumerate(hints):
col, row = i % 3, i // 3
d.text((110 + col * 300, 1800 + row * 60), s, font=f_small, fill=(104, 124, 152))
ctext(1180, "唱针落下是过去,风声回来是现在。", f_small, (96, 112, 140))
img.save(OUT)
print(OUT, img.size)
if __name__ == "__main__":
main()