初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线
- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频 (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine 状态机接口、MainActivity 全屏封面 + TalkBack - 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局 - 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速 - 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链 - 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/ asset-pipeline(资产流水线)/ games/*(三款游戏设计文档) - 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账, 全部 ogg 资产入库可复现 - env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《暗室》封面图:1080x1920 深色底 + 标题大字 + 手势提示。"""
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
FONT = os.path.join(BASE, "font.otf")
|
||||
OUT = os.path.join(BASE, "..", "res", "drawable", "cover.png")
|
||||
|
||||
W, H = 1080, 1920
|
||||
|
||||
img = Image.new("RGB", (W, H))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 深色径向渐变底(中心略亮的深蓝黑)
|
||||
cx, cy = W / 2, H * 0.42
|
||||
for y in range(H):
|
||||
for_x = None
|
||||
img = Image.new("RGB", (W, H))
|
||||
px = img.load()
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / (W * 0.75)
|
||||
v = max(0.0, 1.0 - dist)
|
||||
px[x, y] = (int(8 + 18 * v), int(10 + 22 * v), int(16 + 34 * v))
|
||||
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 门框剪影(一道微开的门缝透光)
|
||||
door_w, door_h = 420, 760
|
||||
dx0, dy0 = (W - door_w) // 2, int(H * 0.30)
|
||||
d.rectangle([dx0, dy0, dx0 + door_w, dy0 + door_h], outline=(60, 70, 95), width=10)
|
||||
slit_x = dx0 + door_w - 46
|
||||
d.rectangle([slit_x, dy0 + 12, slit_x + 18, dy0 + door_h - 12], fill=(220, 230, 200))
|
||||
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.rectangle([slit_x - 60, dy0, slit_x + 80, dy0 + door_h], fill=(200, 215, 170, 60))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(40))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([slit_x, dy0 + 12, slit_x + 18, dy0 + door_h - 12], fill=(235, 242, 210))
|
||||
|
||||
# 标题
|
||||
f_title = ImageFont.truetype(FONT, 230)
|
||||
f_sub = ImageFont.truetype(FONT, 46)
|
||||
f_hint = ImageFont.truetype(FONT, 40)
|
||||
|
||||
title = "暗 室"
|
||||
bb = d.textbbox((0, 0), title, font=f_title)
|
||||
tw = bb[2] - bb[0]
|
||||
d.text(((W - tw) / 2, H * 0.11), title, font=f_title, fill=(225, 228, 235))
|
||||
|
||||
sub = "纯 音 频 密 室 逃 脱"
|
||||
bb = d.textbbox((0, 0), sub, font=f_sub)
|
||||
d.text(((W - (bb[2] - bb[0])) / 2, H * 0.275), sub, font=f_sub, fill=(130, 140, 160))
|
||||
|
||||
# 手势提示
|
||||
hints = ["四 向 滑 动 · 移 动", "单 击 · 检 查 双 击 · 使 用"]
|
||||
yy = H * 0.78
|
||||
for line in hints:
|
||||
bb = d.textbbox((0, 0), line, font=f_hint)
|
||||
d.text(((W - (bb[2] - bb[0])) / 2, yy), line, font=f_hint, fill=(150, 158, 175))
|
||||
yy += 78
|
||||
|
||||
tip = "请 佩 戴 耳 机 游 玩"
|
||||
bb = d.textbbox((0, 0), tip, font=f_sub)
|
||||
d.text(((W - (bb[2] - bb[0])) / 2, H * 0.92), tip, font=f_sub, fill=(100, 108, 128))
|
||||
|
||||
img.save(OUT)
|
||||
print("saved", os.path.abspath(OUT), img.size)
|
||||
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《暗室》SFX / BGM 程序合成脚本(纯 CPU,无需 GPU lock)。
|
||||
|
||||
用 numpy 合成全部音效与氛围 BGM,输出 wav 到 gen/wav/{sfx,bgm}/。
|
||||
合成手法:
|
||||
- 脚步:低频正弦 + 噪声包络
|
||||
- 撞墙:60Hz 闷响衰减 + 噪声冲击
|
||||
- 拾取/确认:上行双音正弦 blip
|
||||
- 开锁:金属咔哒(噪声脉冲 + 2kHz 谐振)
|
||||
- 火花:高通随机脉冲串
|
||||
- 通电:50Hz 工频谐波爬升
|
||||
- 大门开启:低频研磨轰鸣
|
||||
- 环境音:滴水(短促正弦衰减+回声)、风声(滤波棕噪声 LFO)、机器嗡鸣(谐波 hum)
|
||||
- BGM:低频 drone(失谐低频正弦层 + 噪声床),首尾交叉淡化实现无缝循环
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
SR = 44100
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT_SFX = os.path.join(BASE, "wav", "sfx")
|
||||
OUT_BGM = os.path.join(BASE, "wav", "bgm")
|
||||
os.makedirs(OUT_SFX, exist_ok=True)
|
||||
os.makedirs(OUT_BGM, exist_ok=True)
|
||||
|
||||
rng = np.random.default_rng(20260829)
|
||||
|
||||
|
||||
def t(dur):
|
||||
return np.arange(int(SR * dur)) / SR
|
||||
|
||||
|
||||
def env_exp(n, tau):
|
||||
return np.exp(-np.arange(n) / (tau * SR))
|
||||
|
||||
|
||||
def lowpass(x, alpha):
|
||||
"""一阶低通,alpha 越小截止越低。"""
|
||||
y = np.empty_like(x)
|
||||
acc = 0.0
|
||||
for i in range(len(x)):
|
||||
acc += alpha * (x[i] - acc)
|
||||
y[i] = acc
|
||||
return y
|
||||
|
||||
|
||||
def highpass(x, alpha=0.98):
|
||||
y = np.empty_like(x)
|
||||
acc = 0.0
|
||||
prev = 0.0
|
||||
for i in range(len(x)):
|
||||
acc = alpha * (acc + x[i] - prev)
|
||||
prev = x[i]
|
||||
y[i] = acc
|
||||
return y
|
||||
|
||||
|
||||
def norm(x, peak=0.85):
|
||||
m = np.max(np.abs(x))
|
||||
return (x / m * peak).astype(np.float32) if m > 0 else x.astype(np.float32)
|
||||
|
||||
|
||||
def save(outdir, name, x, peak=0.85):
|
||||
x = norm(x, peak)
|
||||
dur = len(x) / SR if x.ndim == 1 else len(x) / SR
|
||||
assert len(x) / SR > 0.05
|
||||
assert np.max(np.abs(x)) > 0.05
|
||||
sf.write(os.path.join(outdir, name + ".wav"), x, SR)
|
||||
print(f"[ok] {name}.wav {len(x)/SR:.2f}s", flush=True)
|
||||
|
||||
|
||||
def stereo(l, r):
|
||||
return np.stack([l, r], axis=1)
|
||||
|
||||
|
||||
# ---------- SFX ----------
|
||||
|
||||
def step():
|
||||
d = 0.28
|
||||
tt = t(d)
|
||||
body = np.sin(2 * np.pi * 85 * tt) * env_exp(len(tt), 0.045)
|
||||
noise = rng.standard_normal(len(tt)) * env_exp(len(tt), 0.02)
|
||||
noise = lowpass(noise, 0.15)
|
||||
return 0.7 * body + 0.5 * noise
|
||||
|
||||
|
||||
def wall():
|
||||
d = 0.45
|
||||
tt = t(d)
|
||||
thud = np.sin(2 * np.pi * 58 * tt) * env_exp(len(tt), 0.09)
|
||||
impact = lowpass(rng.standard_normal(len(tt)), 0.08) * env_exp(len(tt), 0.03)
|
||||
return 0.9 * thud + 0.6 * impact
|
||||
|
||||
|
||||
def pickup():
|
||||
d = 0.35
|
||||
tt = t(d)
|
||||
a = np.sin(2 * np.pi * 660 * tt) * env_exp(len(tt), 0.06)
|
||||
b = np.sin(2 * np.pi * 990 * tt) * env_exp(len(tt), 0.06)
|
||||
b[: int(0.12 * SR)] = 0
|
||||
return 0.6 * (a + b)
|
||||
|
||||
|
||||
def ui_deny():
|
||||
d = 0.28
|
||||
tt = t(d)
|
||||
f = np.linspace(320, 180, len(tt))
|
||||
ph = np.cumsum(2 * np.pi * f / SR)
|
||||
return 0.5 * np.sign(np.sin(ph)) * env_exp(len(tt), 0.09)
|
||||
|
||||
|
||||
def unlock():
|
||||
d = 0.5
|
||||
x = np.zeros(int(SR * d))
|
||||
# 两段金属咔哒
|
||||
for start in (0.0, 0.18):
|
||||
i0 = int(start * SR)
|
||||
n = int(0.06 * SR)
|
||||
click = rng.standard_normal(n) * env_exp(n, 0.008)
|
||||
ping = np.sin(2 * np.pi * 2100 * np.arange(n) / SR) * env_exp(n, 0.02)
|
||||
x[i0:i0 + n] += 0.8 * click + 0.5 * ping
|
||||
return x
|
||||
|
||||
|
||||
def door_creak():
|
||||
d = 1.4
|
||||
tt = t(d)
|
||||
mod = 1 + 0.6 * np.sin(2 * np.pi * 3.1 * tt) + 0.3 * np.sin(2 * np.pi * 7.3 * tt)
|
||||
x = lowpass(rng.standard_normal(len(tt)), 0.05) * mod
|
||||
x *= np.minimum(1, np.arange(len(tt)) / (0.1 * SR)) # fade in
|
||||
x *= env_exp(len(tt), 0.6)
|
||||
return x
|
||||
|
||||
|
||||
def spark():
|
||||
d = 0.9
|
||||
x = np.zeros(int(SR * d))
|
||||
for _ in range(28):
|
||||
i0 = int(rng.uniform(0, d - 0.02) * SR)
|
||||
n = int(rng.uniform(0.004, 0.015) * SR)
|
||||
x[i0:i0 + n] += highpass(rng.standard_normal(n)) * env_exp(n, 0.003) * rng.uniform(0.4, 1.0)
|
||||
return x
|
||||
|
||||
|
||||
def power_on():
|
||||
d = 2.2
|
||||
tt = t(d)
|
||||
rise = np.minimum(1, tt / 1.2) ** 2
|
||||
x = np.zeros(len(tt))
|
||||
for h, amp in ((50, 1.0), (100, 0.5), (150, 0.28), (250, 0.12)):
|
||||
f = h * (1 + 0.02 * np.exp(-tt / 0.3)) # 起始轻微滑音
|
||||
x += amp * np.sin(2 * np.pi * np.cumsum(f) / SR)
|
||||
zzz = highpass(rng.standard_normal(len(tt))) * env_exp(len(tt), 0.15) * 0.15
|
||||
return 0.4 * x * rise + zzz
|
||||
|
||||
|
||||
def gate_open():
|
||||
d = 2.8
|
||||
tt = t(d)
|
||||
rumble = lowpass(rng.standard_normal(len(tt)), 0.03)
|
||||
grind = lowpass(rng.standard_normal(len(tt)), 0.09) * (1 + 0.5 * np.sin(2 * np.pi * 2.2 * tt))
|
||||
env = np.minimum(1, tt / 0.4) * env_exp(len(tt), 1.2)
|
||||
return (0.9 * rumble + 0.5 * grind) * env
|
||||
|
||||
|
||||
def win_chime():
|
||||
d = 1.6
|
||||
x = np.zeros(int(SR * d))
|
||||
for k, f in enumerate((523.25, 659.25, 783.99, 1046.5)):
|
||||
i0 = int(k * 0.16 * SR)
|
||||
n = int(1.0 * SR)
|
||||
tone = np.sin(2 * np.pi * f * np.arange(n) / SR) * env_exp(n, 0.35)
|
||||
x[i0:i0 + n] += 0.5 * tone
|
||||
return x
|
||||
|
||||
|
||||
# ---------- 环境音(立体声,进入房间时播放一次的线索音) ----------
|
||||
|
||||
def amb_drip():
|
||||
d = 5.0
|
||||
l = np.zeros(int(SR * d))
|
||||
r = np.zeros(int(SR * d))
|
||||
# 多次滴水:短促正弦衰减 + 两次反射回声,方位偏左
|
||||
for start, pan in ((0.6, -0.6), (1.9, -0.5), (3.4, -0.7), (4.3, -0.4)):
|
||||
for echo, gain in ((0.0, 1.0), (0.09, 0.45), (0.19, 0.2)):
|
||||
i0 = int((start + echo) * SR)
|
||||
n = int(0.18 * SR)
|
||||
f = rng.uniform(1100, 1500)
|
||||
drip = np.sin(2 * np.pi * f * np.arange(n) / SR) * env_exp(n, 0.02) * gain
|
||||
gl, gr = (1.0, 0.35) if pan < 0 else (0.35, 1.0)
|
||||
l[i0:i0 + n] += drip * gl
|
||||
r[i0:i0 + n] += drip * gr
|
||||
bed = lowpass(rng.standard_normal(len(l)), 0.01) * 0.04
|
||||
return stereo(l + bed, r + bed)
|
||||
|
||||
|
||||
def amb_wind():
|
||||
d = 6.0
|
||||
tt = t(d)
|
||||
lfo = 0.55 + 0.45 * np.sin(2 * np.pi * 0.23 * tt + 1.0)
|
||||
wl = lowpass(rng.standard_normal(len(tt)), 0.02)
|
||||
wr = lowpass(rng.standard_normal(len(tt)), 0.02)
|
||||
l = wl * lfo
|
||||
r = wr * (0.55 + 0.45 * np.sin(2 * np.pi * 0.19 * tt)) # 相位错开出立体宽度
|
||||
return stereo(l, r)
|
||||
|
||||
|
||||
def amb_hum():
|
||||
d = 5.0
|
||||
tt = t(d)
|
||||
mod = 1 + 0.08 * np.sin(2 * np.pi * 1.7 * tt)
|
||||
base = np.zeros(len(tt))
|
||||
for h, amp in ((60, 1.0), (120, 0.45), (180, 0.22), (240, 0.1)):
|
||||
base += amp * np.sin(2 * np.pi * h * tt)
|
||||
base *= 0.25 * mod
|
||||
# 轻微右偏:右声道多一层高频电流嘶声
|
||||
hiss = highpass(rng.standard_normal(len(tt))) * 0.03
|
||||
return stereo(base, base * 0.9 + hiss)
|
||||
|
||||
|
||||
# ---------- BGM:低频 drone,首尾交叉淡化无缝循环 ----------
|
||||
|
||||
def bgm_drone():
|
||||
d = 50.0
|
||||
tt = t(d)
|
||||
lfo1 = 0.75 + 0.25 * np.sin(2 * np.pi * 0.05 * tt)
|
||||
lfo2 = 0.75 + 0.25 * np.sin(2 * np.pi * 0.037 * tt + 2.0)
|
||||
x = (np.sin(2 * np.pi * 55 * tt) * lfo1
|
||||
+ 0.8 * np.sin(2 * np.pi * 55.6 * tt) * lfo2 # 失谐拍频
|
||||
+ 0.5 * np.sin(2 * np.pi * 82.4 * tt)
|
||||
+ 0.25 * np.sin(2 * np.pi * 110.3 * tt) * lfo1)
|
||||
bed = lowpass(rng.standard_normal(len(tt)), 0.008) * 0.5
|
||||
mono = 0.28 * x + bed * 0.35
|
||||
# 立体声:右声道做轻微失谐复本
|
||||
r = 0.28 * (np.sin(2 * np.pi * 55.3 * tt) * lfo2
|
||||
+ 0.8 * np.sin(2 * np.pi * 54.7 * tt) * lfo1
|
||||
+ 0.5 * np.sin(2 * np.pi * 82.9 * tt)
|
||||
+ 0.25 * np.sin(2 * np.pi * 109.8 * tt) * lfo2) \
|
||||
+ lowpass(rng.standard_normal(len(tt)), 0.008) * 0.18
|
||||
x2 = stereo(mono, r)
|
||||
# 无缝循环:尾部 4s 与头部 4s 等功率交叉淡化
|
||||
xf = int(4 * SR)
|
||||
head, tail = x2[:xf].copy(), x2[-xf:].copy()
|
||||
w = np.linspace(0, 1, xf)[:, None]
|
||||
x2[:xf] = head * w + tail * (1 - w)
|
||||
return x2[:-xf]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
save(OUT_SFX, "step", step())
|
||||
save(OUT_SFX, "wall", wall())
|
||||
save(OUT_SFX, "pickup", pickup())
|
||||
save(OUT_SFX, "ui_deny", ui_deny())
|
||||
save(OUT_SFX, "unlock", unlock())
|
||||
save(OUT_SFX, "door_creak", door_creak())
|
||||
save(OUT_SFX, "spark", spark())
|
||||
save(OUT_SFX, "power_on", power_on())
|
||||
save(OUT_SFX, "gate_open", gate_open())
|
||||
save(OUT_SFX, "win_chime", win_chime())
|
||||
save(OUT_SFX, "amb_drip", amb_drip(), peak=0.7)
|
||||
save(OUT_SFX, "amb_wind", amb_wind(), peak=0.55)
|
||||
save(OUT_SFX, "amb_hum", amb_hum(), peak=0.6)
|
||||
save(OUT_BGM, "drone", bgm_drone(), peak=0.7)
|
||||
print("ALL DONE")
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""《暗室》(escape flavor) TTS 批量生成脚本。
|
||||
|
||||
音色分配:全部旁白使用 Uncle_Fu(低沉醇厚的中年男声,契合黑暗密室氛围),
|
||||
通过 instruct 区分情绪(旁白/提示/如释重负)。
|
||||
|
||||
用法(必须 flock 串行,共享 GPU):
|
||||
flock /home/ubuntu/SoundGame/.gpu.lock .venv-tts/bin/python app/src/escape/gen/gen_voice.py
|
||||
|
||||
输出 wav 到 app/src/escape/gen/wav/voice/,随后由 convert_audio.sh 转 ogg。
|
||||
每个文件 assert 时长 > 0.3s 且峰值 > 0.05。
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wav", "voice")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
SPEAKER = "Uncle_Fu"
|
||||
NARRATE = "低沉平稳的男声旁白,带一点神秘和紧张感,语速适中"
|
||||
HINT = "清晰、耐心地提示,语气平和"
|
||||
RELIEF = "如释重负,语气明亮一些,带一点激动"
|
||||
|
||||
# (文件名, 文本, instruct)
|
||||
LINES = [
|
||||
("intro", "黑暗。无边的黑暗。你在一间废弃的地下设施中醒来,头痛欲裂,完全不记得自己是怎么来到这里的。"
|
||||
"空气里弥漫着铁锈和潮湿的气味,远处隐约有水滴声。你必须找到出路——趁你还撑得住。", NARRATE),
|
||||
("tutorial", "记住操作方式:向上、下、左、右滑动,在相邻的房间之间移动。单击,检查你所在的房间。"
|
||||
"双击,使用你身上的物品。如果没有可用的目标,双击会告诉你背包里有什么,以及下一步的提示。"
|
||||
"现在,开始寻找出口吧。", HINT),
|
||||
("start_room", "你站在设施中央的房间里。四面都有通道,黑暗中没有方向。先摸索着移动吧。", NARRATE),
|
||||
("wall", "一堵冰冷坚硬的墙。此路不通。", NARRATE),
|
||||
("found_key", "你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。", NARRATE),
|
||||
("inspect_cabinet_locked", "这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。", NARRATE),
|
||||
("unlock_cabinet", "钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。", NARRATE),
|
||||
("inspect_box_dead", "墙上是一只电箱,箱盖敞开着,里面的保险丝座空空如也。没有电,这里的大门永远打不开。", NARRATE),
|
||||
("box_fixed", "电箱运转正常,电流的嗡鸣声平稳而持续。", NARRATE),
|
||||
("fix_box", "你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!", NARRATE),
|
||||
("inspect_gate", "面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。", NARRATE),
|
||||
("inspect_gate_ready", "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。", NARRATE),
|
||||
("escape", "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。"
|
||||
"自由了。你逃出来了。", RELIEF),
|
||||
("use_fail", "这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。", HINT),
|
||||
("inv_empty", "你摸了摸身上,什么也没有。", NARRATE),
|
||||
("inv_key", "你身上带着一把钥匙。", NARRATE),
|
||||
("inv_fuse", "你身上带着一枚保险丝。", NARRATE),
|
||||
("inv_both", "你身上带着一把钥匙和一枚保险丝。", NARRATE),
|
||||
("hint_key", "仔细搜索每一个房间。堆杂物的储物间里,也许藏着能用的东西。单击,检查你所在的房间。", HINT),
|
||||
("hint_cabinet", "钥匙应该能打开某处的锁。去找到那个上锁的柜子。", HINT),
|
||||
("hint_box", "保险丝属于电箱。找到电箱,恢复供电。", HINT),
|
||||
("hint_gate", "电力已经恢复。去找到那扇铁门,双击打开它。", HINT),
|
||||
("time_prefix", "你本次逃脱,用时", RELIEF),
|
||||
("n0", "零", HINT), ("n1", "一", HINT), ("n2", "二", HINT), ("n3", "三", HINT), ("n4", "四", HINT),
|
||||
("n5", "五", HINT), ("n6", "六", HINT), ("n7", "七", HINT), ("n8", "八", HINT), ("n9", "九", HINT),
|
||||
("nshi", "十", HINT),
|
||||
("unit_min", "分", HINT),
|
||||
("unit_sec", "秒", HINT),
|
||||
("again", "想再挑战一次吗?单击屏幕,重新开始。", HINT),
|
||||
("replay", "黑暗再次合拢。你回到了中央的牢房。这一次,你会更快。", NARRATE),
|
||||
("empty_1", "房间里空空荡荡,只有你自己的回声。", NARRATE),
|
||||
("empty_2", "你伸出手,摸到的只有冰冷粗糙的墙壁。", NARRATE),
|
||||
("empty_3", "除了远处的水滴声,这里什么都没有。", NARRATE),
|
||||
]
|
||||
|
||||
CHUNK = 8
|
||||
|
||||
|
||||
def main():
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
device_map="cuda:0",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
for i in range(0, len(LINES), CHUNK):
|
||||
chunk = LINES[i:i + CHUNK]
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=[t for _, t, _ in chunk],
|
||||
language=["Chinese"] * len(chunk),
|
||||
speaker=[SPEAKER] * len(chunk),
|
||||
instruct=[ins for _, _, ins in chunk],
|
||||
)
|
||||
for (name, _, _), wav in zip(chunk, wavs):
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
peak = float(np.max(np.abs(wav)))
|
||||
assert peak > 0.05, f"{name}: peak too low ({peak})"
|
||||
wav = wav / peak * 0.89 # 统一峰值归一
|
||||
dur = len(wav) / sr
|
||||
assert dur > 0.3, f"{name}: too short ({dur:.2f}s)"
|
||||
path = os.path.join(OUT, f"{name}.wav")
|
||||
sf.write(path, wav, sr)
|
||||
print(f"[ok] {name}.wav {dur:.2f}s peak={peak:.3f}", flush=True)
|
||||
print("ALL DONE:", len(LINES), "files ->", OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.soundgame.game
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.soundgame.engine.AudioEngine
|
||||
import com.soundgame.engine.Game
|
||||
import com.soundgame.engine.Gesture
|
||||
|
||||
/**
|
||||
* 《暗室》—— 3x3 黑暗房间网格密室逃脱。
|
||||
*
|
||||
* 手势映射(设计说明:四向滑动全部用于房间间移动——3x3 网格必须占用四个方向;
|
||||
* 原需求中"上滑=背包 / 下滑=方位提示"与四向移动冲突,故背包清单与目标提示
|
||||
* 合并到"双击且无可用目标"的兜底反馈中):
|
||||
* - 四向滑动:在相邻房间间移动,撞墙播放闷响
|
||||
* - 单击:检查当前房间 / 拾取物品
|
||||
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
|
||||
* 无可用目标时播报背包清单 + 当前目标提示
|
||||
*
|
||||
* 解谜链:储物间找钥匙 → 钥匙开铁柜得保险丝 → 保险丝修电箱恢复供电 → 开最终大门逃脱。
|
||||
*
|
||||
* 房间布局(x: 0西..2东, y: 0北..2南):
|
||||
* (0,0) 电箱房·嗡鸣 (1,0) 风道 (2,0) 空房·滴水
|
||||
* (0,1) 空房·风 (1,1) 起点·滴水 (2,1) 铁柜房·嗡鸣
|
||||
* (0,2) 储物间·滴水 (1,2) 空房·嗡鸣 (2,2) 大门·风
|
||||
*/
|
||||
class EscapeGame(private val audio: AudioEngine) : Game {
|
||||
|
||||
private enum class Phase { INTRO, PLAY, ENDING }
|
||||
|
||||
private var phase = Phase.INTRO
|
||||
/** 开场阶段:1=旁白 2=教学,用于单击跳过与 onComplete 推进的去重。 */
|
||||
private var introStep = 0
|
||||
|
||||
private var x = 1
|
||||
private var y = 1
|
||||
private var hasKey = false
|
||||
private var hasFuse = false
|
||||
private var cabinetOpen = false
|
||||
private var powerOn = false
|
||||
private var wallVoicePlayed = false
|
||||
private var startElapsedMs = 0L
|
||||
|
||||
// 预热用的短音效(开场以音量 0 各播一遍,填充 SoundPool 缓存)
|
||||
private val prewarmSfx = listOf(
|
||||
"sfx/step", "sfx/wall", "sfx/pickup", "sfx/ui_deny",
|
||||
"sfx/unlock", "sfx/spark", "sfx/power_on", "sfx/gate_open",
|
||||
)
|
||||
|
||||
// ---------- Game ----------
|
||||
|
||||
override fun onStart() {
|
||||
prewarmSfx.forEach { audio.playSfx(it, 0f) }
|
||||
audio.playBgm("bgm/drone")
|
||||
phase = Phase.INTRO
|
||||
introStep = 1
|
||||
audio.playVoice("voice/intro") {
|
||||
if (phase == Phase.INTRO && introStep == 1) playTutorial()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGesture(gesture: Gesture) {
|
||||
Log.i(TAG, "onGesture: $gesture phase=$phase pos=($x,$y)")
|
||||
when (phase) {
|
||||
Phase.INTRO -> if (gesture == Gesture.TAP) skipIntro()
|
||||
Phase.PLAY -> onPlayGesture(gesture)
|
||||
Phase.ENDING -> if (gesture == Gesture.TAP) restart()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 开场 ----------
|
||||
|
||||
private fun playTutorial() {
|
||||
introStep = 2
|
||||
audio.playVoice("voice/tutorial") {
|
||||
if (phase == Phase.INTRO && introStep == 2) startPlay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipIntro() {
|
||||
audio.stopVoice()
|
||||
if (introStep == 1) playTutorial() else startPlay()
|
||||
}
|
||||
|
||||
private fun startPlay() {
|
||||
if (phase != Phase.INTRO) return
|
||||
phase = Phase.PLAY
|
||||
startElapsedMs = SystemClock.elapsedRealtime()
|
||||
audio.playVoice("voice/start_room")
|
||||
playAmbient()
|
||||
}
|
||||
|
||||
// ---------- 探索 ----------
|
||||
|
||||
private fun onPlayGesture(gesture: Gesture) {
|
||||
when (gesture) {
|
||||
Gesture.SWIPE_UP -> move(0, -1)
|
||||
Gesture.SWIPE_DOWN -> move(0, 1)
|
||||
Gesture.SWIPE_LEFT -> move(-1, 0)
|
||||
Gesture.SWIPE_RIGHT -> move(1, 0)
|
||||
Gesture.TAP -> inspect()
|
||||
Gesture.DOUBLE_TAP -> useItem()
|
||||
}
|
||||
}
|
||||
|
||||
private fun move(dx: Int, dy: Int) {
|
||||
val nx = x + dx
|
||||
val ny = y + dy
|
||||
if (nx !in 0..2 || ny !in 0..2) {
|
||||
audio.playSfx("sfx/wall")
|
||||
if (!wallVoicePlayed) {
|
||||
wallVoicePlayed = true
|
||||
audio.playVoice("voice/wall")
|
||||
}
|
||||
return
|
||||
}
|
||||
x = nx
|
||||
y = ny
|
||||
audio.playSfx("sfx/step", 0.8f)
|
||||
playAmbient()
|
||||
}
|
||||
|
||||
/** 进入房间时播放一次性环境线索音(方位感已烘焙成立体声)。 */
|
||||
private fun playAmbient() {
|
||||
val amb = when {
|
||||
x == 0 && y == 0 -> "sfx/amb_hum" // 电箱房
|
||||
x == 2 && y == 1 -> "sfx/amb_hum" // 铁柜房
|
||||
x == 1 && y == 2 -> "sfx/amb_hum"
|
||||
x == 2 && y == 2 -> "sfx/amb_wind" // 大门
|
||||
x == 1 && y == 0 -> "sfx/amb_wind"
|
||||
x == 0 && y == 1 -> "sfx/amb_wind"
|
||||
else -> "sfx/amb_drip" // 起点/储物间/(2,0)
|
||||
}
|
||||
audio.playSfx(amb, 0.9f)
|
||||
}
|
||||
|
||||
private fun inspect() {
|
||||
when {
|
||||
x == 0 && y == 2 -> { // 储物间
|
||||
if (!hasKey && !cabinetOpen) {
|
||||
hasKey = true
|
||||
audio.playSfx("sfx/pickup")
|
||||
audio.playVoice("voice/found_key")
|
||||
} else {
|
||||
audio.playVoice("voice/empty_2")
|
||||
}
|
||||
}
|
||||
x == 2 && y == 1 -> { // 铁柜房
|
||||
audio.playVoice(if (cabinetOpen) "voice/empty_1" else "voice/inspect_cabinet_locked")
|
||||
}
|
||||
x == 0 && y == 0 -> { // 电箱房
|
||||
audio.playVoice(if (powerOn) "voice/box_fixed" else "voice/inspect_box_dead")
|
||||
}
|
||||
x == 2 && y == 2 -> { // 大门
|
||||
audio.playVoice(if (powerOn) "voice/inspect_gate_ready" else "voice/inspect_gate")
|
||||
}
|
||||
x == 1 && y == 1 -> audio.playVoice("voice/start_room")
|
||||
x == 1 && y == 0 -> audio.playVoice("voice/empty_1")
|
||||
x == 0 && y == 1 -> audio.playVoice("voice/empty_2")
|
||||
else -> audio.playVoice("voice/empty_3") // (1,2)、(2,0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun useItem() {
|
||||
when {
|
||||
// 钥匙开铁柜 -> 得保险丝(钥匙消耗)
|
||||
x == 2 && y == 1 && hasKey && !cabinetOpen -> {
|
||||
cabinetOpen = true
|
||||
hasKey = false
|
||||
hasFuse = true
|
||||
audio.playSfx("sfx/unlock")
|
||||
audio.playSfx("sfx/door_creak", 0.7f)
|
||||
audio.playVoice("voice/unlock_cabinet")
|
||||
}
|
||||
// 保险丝修电箱 -> 恢复供电(保险丝消耗)
|
||||
x == 0 && y == 0 && hasFuse && !powerOn -> {
|
||||
powerOn = true
|
||||
hasFuse = false
|
||||
audio.playSfx("sfx/spark")
|
||||
audio.playSfx("sfx/power_on", 0.8f)
|
||||
audio.playVoice("voice/fix_box")
|
||||
}
|
||||
// 通电后开大门 -> 逃脱
|
||||
x == 2 && y == 2 && powerOn -> escape()
|
||||
// 兜底:播报背包 + 当前目标提示
|
||||
else -> {
|
||||
audio.playSfx("sfx/ui_deny", 0.7f)
|
||||
speak(listOf(inventoryVoice(), hintVoice()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun inventoryVoice(): String = when {
|
||||
hasKey && hasFuse -> "voice/inv_both"
|
||||
hasKey -> "voice/inv_key"
|
||||
hasFuse -> "voice/inv_fuse"
|
||||
else -> "voice/inv_empty"
|
||||
}
|
||||
|
||||
private fun hintVoice(): String = when {
|
||||
!hasKey && !cabinetOpen -> "voice/hint_key"
|
||||
!cabinetOpen -> "voice/hint_cabinet"
|
||||
!powerOn -> "voice/hint_box"
|
||||
else -> "voice/hint_gate"
|
||||
}
|
||||
|
||||
// ---------- 结局 ----------
|
||||
|
||||
private fun escape() {
|
||||
phase = Phase.ENDING
|
||||
audio.playSfx("sfx/gate_open")
|
||||
audio.playSfx("sfx/win_chime", 0.8f)
|
||||
audio.stopBgm(2500)
|
||||
audio.playVoice("voice/escape") {
|
||||
speak(timeReportVoices())
|
||||
}
|
||||
}
|
||||
|
||||
/** 用时播报:prefix + [分钟 + 分] + 秒 + "秒" + 重开提示。 */
|
||||
private fun timeReportVoices(): List<String> {
|
||||
val totalSec = ((SystemClock.elapsedRealtime() - startElapsedMs) / 1000).toInt()
|
||||
val min = totalSec / 60
|
||||
val sec = totalSec % 60
|
||||
val ids = mutableListOf("voice/time_prefix")
|
||||
if (min > 0) {
|
||||
ids += numberVoices(min)
|
||||
ids += "voice/unit_min"
|
||||
}
|
||||
ids += numberVoices(sec)
|
||||
ids += "voice/unit_sec"
|
||||
ids += "voice/again"
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 0..99 的中文数字播报(v_n0..v_n9 + v_nshi 组合)。 */
|
||||
private fun numberVoices(n: Int): List<String> {
|
||||
val value = n.coerceIn(0, 99)
|
||||
return when {
|
||||
value < 10 -> listOf("voice/n$value")
|
||||
value < 20 -> listOf("voice/nshi") + listOfNotNull(
|
||||
if (value % 10 > 0) "voice/n${value % 10}" else null
|
||||
)
|
||||
else -> listOf("voice/n${value / 10}", "voice/nshi") + listOfNotNull(
|
||||
if (value % 10 > 0) "voice/n${value % 10}" else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restart() {
|
||||
audio.stopVoice()
|
||||
x = 1
|
||||
y = 1
|
||||
hasKey = false
|
||||
hasFuse = false
|
||||
cabinetOpen = false
|
||||
powerOn = false
|
||||
wallVoicePlayed = false
|
||||
startElapsedMs = SystemClock.elapsedRealtime()
|
||||
audio.playBgm("bgm/drone")
|
||||
phase = Phase.PLAY
|
||||
audio.playVoice("voice/replay")
|
||||
playAmbient()
|
||||
}
|
||||
|
||||
// ---------- 工具 ----------
|
||||
|
||||
/** 对白排队依次播放(onComplete 链式触发)。 */
|
||||
private fun speak(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
audio.playVoice(ids.first()) {
|
||||
if (ids.size > 1) speak(ids.drop(1))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "EscapeGame"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.soundgame.game
|
||||
|
||||
import com.soundgame.engine.AudioEngine
|
||||
import com.soundgame.engine.Game
|
||||
|
||||
/** flavor 注册点:MainActivity 通过它拿到当前 flavor 的 Game 实现。 */
|
||||
object GameFactory {
|
||||
fun create(audio: AudioEngine): Game = EscapeGame(audio)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Reference in New Issue
Block a user