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:
@@ -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 =="
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user