- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
130 lines
4.7 KiB
Python
130 lines
4.7 KiB
Python
"""《回声地窟》cover.png 生成:1080x1920,深色洞窟底 + 声波脉冲/等高线意象 + 标题。
|
||
|
||
可复现脚本(random.seed 固定)。PIL 探测顺序:系统 python3 → .venv-tts/bin/python,
|
||
都没有则 uv pip install pillow --python .venv-tts/bin/python(阿里云镜像)。
|
||
"""
|
||
import math
|
||
import os
|
||
import random
|
||
import subprocess
|
||
import sys
|
||
|
||
W, H = 1080, 1920
|
||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png")
|
||
FONT_CANDIDATES = [
|
||
"/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf",
|
||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||
]
|
||
|
||
|
||
def find_font():
|
||
for p in FONT_CANDIDATES:
|
||
if os.path.exists(p):
|
||
return p
|
||
raise SystemExit("no CJK font found; add path to FONT_CANDIDATES")
|
||
|
||
|
||
def ensure_pil():
|
||
try:
|
||
import PIL # noqa: F401
|
||
return
|
||
except ImportError:
|
||
pass
|
||
venv_py = "/home/xsl/SoundGame/.venv-tts/bin/python"
|
||
if os.path.exists(venv_py):
|
||
subprocess.run([venv_py, "-m", "pip", "install", "pillow",
|
||
"--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True)
|
||
else:
|
||
subprocess.run([sys.executable, "-m", "pip", "install", "pillow",
|
||
"--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True)
|
||
|
||
|
||
ensure_pil()
|
||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont # noqa: E402
|
||
|
||
random.seed(42)
|
||
|
||
img = Image.new("RGB", (W, H))
|
||
px = img.load()
|
||
# 深色洞窟竖向渐变:上 #06070c -> 下 #14121c(近黑的岩壁,底部微暖)
|
||
top, bottom = (6, 7, 12), (20, 18, 28)
|
||
for y in range(H):
|
||
t = y / (H - 1)
|
||
for x in range(W):
|
||
px[x, y] = tuple(int(a + (b - a) * t) for a, b in zip(top, bottom))
|
||
|
||
d = ImageDraw.Draw(img)
|
||
|
||
# 等高线意象:一组以画面中心偏下为圆心的不规则闭合环(洞窟剖面)
|
||
cx, cy = W * 0.5, H * 0.46
|
||
for ring in range(9):
|
||
rr = 90 + ring * 78
|
||
pts = []
|
||
for i in range(72):
|
||
ang = i / 72 * math.tau
|
||
wob = 1 + 0.10 * math.sin(ang * 3 + ring) + 0.06 * math.cos(ang * 5 - ring * 2)
|
||
pts.append((cx + math.cos(ang) * rr * wob, cy + math.sin(ang) * rr * wob * 0.82))
|
||
shade = 26 + ring * 7
|
||
d.line(pts + [pts[0]], fill=(shade, shade + 4, shade + 14), width=3)
|
||
|
||
# 声波脉冲:从洞窟中心向外的一圈圈脉冲环(越远越淡 = 回声衰减)
|
||
pulse = Image.new("RGB", (W, H), (0, 0, 0))
|
||
pd = ImageDraw.Draw(pulse)
|
||
for ring in range(5):
|
||
rr = 60 + ring * 130
|
||
alpha = 90 - ring * 16
|
||
pd.ellipse([cx - rr, cy - rr * 0.82, cx + rr, cy + rr * 0.82],
|
||
outline=(alpha, alpha + 30, alpha + 60), width=6)
|
||
pulse = pulse.filter(ImageFilter.GaussianBlur(7))
|
||
img = ImageChops.add(img, pulse)
|
||
d = ImageDraw.Draw(img)
|
||
|
||
# 玩家光点 + 两侧回声亮斑(左右声道预 pan 意象)
|
||
d.ellipse([cx - 10, cy - 10, cx + 10, cy + 10], fill=(235, 240, 255))
|
||
glow = Image.new("RGB", (W, H), (0, 0, 0))
|
||
gd = ImageDraw.Draw(glow)
|
||
gd.ellipse([cx - 320, cy - 40, cx - 180, cy + 60], fill=(70, 110, 170)) # 左声道
|
||
gd.ellipse([cx + 190, cy - 120, cx + 330, cy - 10], fill=(70, 110, 170)) # 右声道
|
||
gd.ellipse([cx - 60, cy - 300, cx + 60, cy - 180], fill=(60, 100, 160)) # 正前远墙
|
||
glow = glow.filter(ImageFilter.GaussianBlur(30))
|
||
img = ImageChops.add(img, glow)
|
||
d = ImageDraw.Draw(img)
|
||
|
||
FONT = find_font()
|
||
|
||
|
||
def text_center(y, s, size, fill, glow_color=None):
|
||
f = ImageFont.truetype(FONT, size)
|
||
bbox = d.textbbox((0, 0), s, font=f)
|
||
x = (W - (bbox[2] - bbox[0])) / 2 - bbox[0]
|
||
if glow_color:
|
||
tg = Image.new("RGB", (W, H), (0, 0, 0))
|
||
ImageDraw.Draw(tg).text((x, y), s, font=f, fill=glow_color)
|
||
tg = tg.filter(ImageFilter.GaussianBlur(22))
|
||
img.paste(ImageChops.add(img.crop((0, 0, W, H)), tg).crop((0, 0, W, H)), (0, 0))
|
||
d.text((x, y), s, font=f, fill=fill)
|
||
else:
|
||
d.text((x, y), s, font=f, fill=fill)
|
||
|
||
|
||
title = "回 声 地 窟"
|
||
f = ImageFont.truetype(FONT, 210)
|
||
bbox = d.textbbox((0, 0), title, font=f)
|
||
tx = (W - (bbox[2] - bbox[0])) / 2 - bbox[0]
|
||
ty = 1210
|
||
tg = Image.new("RGB", (W, H), (0, 0, 0))
|
||
ImageDraw.Draw(tg).text((tx, ty), title, font=f, fill=(40, 90, 150))
|
||
tg = tg.filter(ImageFilter.GaussianBlur(20))
|
||
img = ImageChops.add(img, tg)
|
||
d = ImageDraw.Draw(img)
|
||
d.text((tx, ty), title, font=f, fill=(230, 240, 252))
|
||
|
||
text_center(1490, "ECHO CAVERN", 42, (96, 128, 176))
|
||
text_center(1600, "伸手不见五指,声音就是你的眼睛", 56, (206, 220, 244))
|
||
text_center(1700, "单击脉冲回声看路 · 按住前进 · 左右滑转身", 40, (128, 142, 170))
|
||
text_center(1770, "双击再听 · 上滑状态 · 下滑菜单", 40, (128, 142, 170))
|
||
|
||
img.save(OUT)
|
||
print("saved", os.path.abspath(OUT), img.size)
|