初始提交: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 |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.SoundGame"
|
||||
android:allowBackup="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.soundgame
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.soundgame.engine.AudioEngine
|
||||
import com.soundgame.engine.DefaultAudioEngine
|
||||
import com.soundgame.engine.Game
|
||||
import com.soundgame.engine.gestureDetector
|
||||
import com.soundgame.game.GameFactory
|
||||
|
||||
/**
|
||||
* 唯一 Activity:全屏静态封面 + 手势路由。
|
||||
*
|
||||
* 封面图由 flavor 提供 `res/drawable/cover.png`;游戏实现由 flavor
|
||||
* 提供 `com.soundgame.game.GameFactory`。
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var audioEngine: AudioEngine
|
||||
private lateinit var game: Game
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 沉浸式全屏 + 屏幕常亮(音频游戏不应息屏)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
audioEngine = DefaultAudioEngine(applicationContext)
|
||||
game = GameFactory.create(audioEngine)
|
||||
|
||||
setContent {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.gestureDetector { gesture -> game.onGesture(gesture) }
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.cover),
|
||||
// TalkBack 无障碍描述:说明这是封面、游戏靠手势操作
|
||||
contentDescription = "游戏封面。本游戏为纯音频游戏,通过上滑、下滑、左滑、右滑、单击、双击操作。",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
game.onStart()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
game.onStop()
|
||||
audioEngine.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.soundgame.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.SoundPool
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 分层音频引擎。
|
||||
*
|
||||
* 资产统一放在 `app/src/<flavor>/assets/audio/` 下,[id] 为相对 `audio/` 的路径,
|
||||
* 按前缀分三层:
|
||||
* - `bgm/...` 背景音乐:ExoPlayer 播放,切换时交叉淡入淡出。
|
||||
* - `voice/...` 长对白/旁白:ExoPlayer 播放,播放期间自动压低(duck)BGM,结束后恢复。
|
||||
* - `sfx/...` 短音效:SoundPool 播放,低延迟即时触发,可叠加。
|
||||
*
|
||||
* [id] 可以不带扩展名,引擎会依次尝试 `.ogg`、`.mp3`、`.wav`。
|
||||
*/
|
||||
interface AudioEngine {
|
||||
/** 统一入口:按 id 前缀分发到 [playBgm] / [playVoice] / [playSfx]。 */
|
||||
fun play(id: String)
|
||||
|
||||
/** 播放/切换 BGM。已有 BGM 在播放时执行交叉淡入淡出。 */
|
||||
fun playBgm(id: String, loop: Boolean = true, fadeMs: Long = 800)
|
||||
|
||||
/** 停止 BGM,淡出 [fadeOutMs] 毫秒。 */
|
||||
fun stopBgm(fadeOutMs: Long = 800)
|
||||
|
||||
/** 播放对白。[duckBgm] 为 true 时播放期间压低 BGM 音量,结束后恢复。 */
|
||||
fun playVoice(id: String, duckBgm: Boolean = true, onComplete: (() -> Unit)? = null)
|
||||
|
||||
/** 停止当前对白。 */
|
||||
fun stopVoice()
|
||||
|
||||
/** 播放短音效(低延迟,可叠加)。 */
|
||||
fun playSfx(id: String, volume: Float = 1f)
|
||||
|
||||
/** 直接设置 BGM 基准音量(0f~1f)。 */
|
||||
fun setBgmVolume(volume: Float)
|
||||
|
||||
/** 释放全部资源,Activity 销毁时必须调用。 */
|
||||
fun release()
|
||||
}
|
||||
|
||||
private const val TAG = "AudioEngine"
|
||||
|
||||
class DefaultAudioEngine(
|
||||
private val context: Context,
|
||||
/** ducking 时 BGM 被压到的音量比例。 */
|
||||
private val duckVolume: Float = 0.2f,
|
||||
) : AudioEngine {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// ---- SFX 层(SoundPool) ----
|
||||
private val soundPool = SoundPool.Builder()
|
||||
.setMaxStreams(8)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
private val sfxCache = mutableMapOf<Int, Int>() // soundId cache: assetPath.hashCode -> soundId
|
||||
|
||||
// ---- BGM 层(双 ExoPlayer 交叉淡入淡出) ----
|
||||
private var bgmPlayer: ExoPlayer? = null
|
||||
private var bgmFadeJob: Job? = null
|
||||
private var bgmBaseVolume = 1f
|
||||
|
||||
// ---- 对白层(ExoPlayer) ----
|
||||
private var voicePlayer: ExoPlayer? = null
|
||||
private var ducked = false
|
||||
|
||||
override fun play(id: String) {
|
||||
when {
|
||||
id.startsWith("bgm/") -> playBgm(id)
|
||||
id.startsWith("voice/") -> playVoice(id)
|
||||
id.startsWith("sfx/") -> playSfx(id)
|
||||
else -> {
|
||||
Log.w(TAG, "play(): 无法识别的 id 前缀(应为 bgm/ voice/ sfx/ 之一): $id")
|
||||
playSfx(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun playBgm(id: String, loop: Boolean, fadeMs: Long) {
|
||||
val path = resolveAssetPath(id) ?: return
|
||||
val old = bgmPlayer
|
||||
val next = ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri("asset:///$path"))
|
||||
repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
||||
volume = 0f
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
bgmPlayer = next
|
||||
|
||||
bgmFadeJob?.cancel()
|
||||
bgmFadeJob = scope.launch {
|
||||
fadeVolume(next, from = 0f, to = currentBgmTargetVolume(), durationMs = fadeMs)
|
||||
}
|
||||
if (old != null) {
|
||||
scope.launch {
|
||||
fadeVolume(old, from = old.volume, to = 0f, durationMs = fadeMs)
|
||||
old.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopBgm(fadeOutMs: Long) {
|
||||
val player = bgmPlayer ?: return
|
||||
bgmPlayer = null
|
||||
bgmFadeJob?.cancel()
|
||||
scope.launch {
|
||||
fadeVolume(player, from = player.volume, to = 0f, durationMs = fadeOutMs)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playVoice(id: String, duckBgm: Boolean, onComplete: (() -> Unit)?) {
|
||||
val path = resolveAssetPath(id) ?: return
|
||||
stopVoice()
|
||||
|
||||
val player = ExoPlayer.Builder(context).build()
|
||||
voicePlayer = player
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
unduckBgm()
|
||||
player.release()
|
||||
if (voicePlayer === player) voicePlayer = null
|
||||
onComplete?.invoke()
|
||||
}
|
||||
}
|
||||
})
|
||||
player.setMediaItem(MediaItem.fromUri("asset:///$path"))
|
||||
player.prepare()
|
||||
player.play()
|
||||
|
||||
if (duckBgm) duckBgm()
|
||||
}
|
||||
|
||||
override fun stopVoice() {
|
||||
voicePlayer?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
voicePlayer = null
|
||||
unduckBgm()
|
||||
}
|
||||
|
||||
override fun playSfx(id: String, volume: Float) {
|
||||
val path = resolveAssetPath(id) ?: return
|
||||
val key = path.hashCode()
|
||||
val soundId = sfxCache.getOrPut(key) { soundPool.load(context.assets.openFd(path), 1) }
|
||||
// SoundPool 加载是异步的;若尚未加载完成,等 onLoadComplete 后播放由调用方重试即可。
|
||||
// 这里直接 play,未加载完成时 SoundPool 内部会排队失败并返回 0,游戏层无感知。
|
||||
soundPool.play(soundId, volume, volume, 1, 0, 1f)
|
||||
}
|
||||
|
||||
override fun setBgmVolume(volume: Float) {
|
||||
bgmBaseVolume = volume.coerceIn(0f, 1f)
|
||||
if (!ducked) bgmPlayer?.volume = bgmBaseVolume
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
scope.coroutineContext[Job]?.cancel()
|
||||
bgmPlayer?.release()
|
||||
bgmPlayer = null
|
||||
voicePlayer?.release()
|
||||
voicePlayer = null
|
||||
soundPool.release()
|
||||
sfxCache.clear()
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
private fun currentBgmTargetVolume(): Float =
|
||||
if (ducked) bgmBaseVolume * duckVolume else bgmBaseVolume
|
||||
|
||||
private fun duckBgm() {
|
||||
if (ducked) return
|
||||
ducked = true
|
||||
val player = bgmPlayer ?: return
|
||||
scope.launch { fadeVolume(player, player.volume, currentBgmTargetVolume(), 300) }
|
||||
}
|
||||
|
||||
private fun unduckBgm() {
|
||||
if (!ducked) return
|
||||
ducked = false
|
||||
val player = bgmPlayer ?: return
|
||||
scope.launch { fadeVolume(player, player.volume, currentBgmTargetVolume(), 300) }
|
||||
}
|
||||
|
||||
private suspend fun fadeVolume(player: ExoPlayer, from: Float, to: Float, durationMs: Long) {
|
||||
val steps = (durationMs / 30).toInt().coerceAtLeast(1)
|
||||
for (i in 1..steps) {
|
||||
player.volume = from + (to - from) * i / steps
|
||||
delay(durationMs / steps)
|
||||
}
|
||||
player.volume = to
|
||||
}
|
||||
|
||||
/**
|
||||
* 把逻辑 id(如 "sfx/click" 或 "sfx/click.ogg")解析为 assets 下真实路径
|
||||
* "audio/sfx/click.ogg";找不到时打日志并返回 null。
|
||||
*/
|
||||
private fun resolveAssetPath(id: String): String? {
|
||||
val candidates = if (id.substringAfterLast('.', "").let { it != id && it.length <= 4 }) {
|
||||
listOf("audio/$id")
|
||||
} else {
|
||||
listOf("audio/$id.ogg", "audio/$id.mp3", "audio/$id.wav", "audio/$id")
|
||||
}
|
||||
for (path in candidates) {
|
||||
try {
|
||||
context.assets.openFd(path).close()
|
||||
return path
|
||||
} catch (_: Exception) {
|
||||
// 尝试下一个候选
|
||||
}
|
||||
}
|
||||
Log.e(TAG, "找不到音频资产: $id (尝试了 $candidates)")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.soundgame.engine
|
||||
|
||||
/**
|
||||
* 一款游戏的统一入口。每个 flavor 提供一个实现,
|
||||
* 并在 flavor 源码集的 `GameFactory` 里注册(见 docs/engine-api.md)。
|
||||
*/
|
||||
interface Game {
|
||||
/** 收到手势事件。引擎保证只在主线程调用。 */
|
||||
fun onGesture(gesture: Gesture)
|
||||
|
||||
/** Activity onCreate 完成后调用,可在此播开场 BGM/旁白。 */
|
||||
fun onStart() {}
|
||||
|
||||
/** Activity 销毁前调用,做收尾(一般不用动音频,引擎统一 release)。 */
|
||||
fun onStop() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态机驱动的 Game 基类:剧情/关卡类游戏的标准模式。
|
||||
*
|
||||
* 用法:定义一个状态类型 [S](sealed class / enum 均可),实现
|
||||
* [transition] 描述"状态 + 手势 -> 新状态",在 [onEnterState] 里
|
||||
* 播放进入新状态时的音频。状态没变(返回原状态)则不触发 [onEnterState]。
|
||||
*
|
||||
* ```
|
||||
* sealed interface Scene { data object Intro : Scene; data object Room : Scene }
|
||||
*
|
||||
* class MyGame(audio: AudioEngine) : GameEngine<Scene>(audio) {
|
||||
* override val initialState = Scene.Intro
|
||||
* override fun transition(state: Scene, gesture: Gesture): Scene = when (state) {
|
||||
* Scene.Intro -> if (gesture == Gesture.TAP) Scene.Room else state
|
||||
* Scene.Room -> state
|
||||
* }
|
||||
* override fun onEnterState(state: Scene, previous: Scene?) {
|
||||
* if (state == Scene.Room) audio.play("voice/enter_room")
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
abstract class GameEngine<S>(protected val audio: AudioEngine) : Game {
|
||||
|
||||
protected abstract val initialState: S
|
||||
|
||||
private var currentState: S? = null
|
||||
|
||||
/** 当前状态;未发生过转移时为 [initialState]。 */
|
||||
protected val state: S
|
||||
get() = currentState ?: initialState
|
||||
|
||||
/** 状态转移函数:给定当前状态和手势,返回新状态(无转移就返回 [state] 本身)。 */
|
||||
protected abstract fun transition(state: S, gesture: Gesture): S
|
||||
|
||||
/** 进入新状态后回调,在这里播放该状态的音频/台词。 */
|
||||
protected open fun onEnterState(state: S, previous: S?) {}
|
||||
|
||||
final override fun onGesture(gesture: Gesture) {
|
||||
val previous = currentState
|
||||
val next = transition(state, gesture)
|
||||
if (next != state) {
|
||||
currentState = next
|
||||
onEnterState(next, previous)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
onEnterState(initialState, null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.soundgame.engine
|
||||
|
||||
/**
|
||||
* 引擎支持的 6 种手势。
|
||||
*/
|
||||
enum class Gesture {
|
||||
SWIPE_UP,
|
||||
SWIPE_DOWN,
|
||||
SWIPE_LEFT,
|
||||
SWIPE_RIGHT,
|
||||
TAP,
|
||||
DOUBLE_TAP,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.soundgame.engine
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 手势识别参数。
|
||||
*
|
||||
* @param minSwipeDistance 滑动的最小位移,低于该值不识别为滑动(防误触)。
|
||||
* @param minSwipeVelocityPxPerSec 滑动的最小速度(px/s),缓慢拖动不触发。
|
||||
*/
|
||||
data class GestureConfig(
|
||||
val minSwipeDistance: Dp = 64.dp,
|
||||
val minSwipeVelocityPxPerSec: Float = 400f,
|
||||
)
|
||||
|
||||
/**
|
||||
* 在任意 Compose 界面上识别 6 种手势并回调 [onGesture]。
|
||||
*
|
||||
* - 四向滑动:位移超过 [GestureConfig.minSwipeDistance] 且平均速度超过
|
||||
* [GestureConfig.minSwipeVelocityPxPerSec] 才触发,方向取位移较大的轴。
|
||||
* - 单击/双击:由 detectTapGestures 区分。注意注册了双击回调后,
|
||||
* 单击回调会延迟一个双击判定窗口(约 300ms)才触发,属正常行为。
|
||||
*
|
||||
* 用法:
|
||||
* ```
|
||||
* Box(Modifier.fillMaxSize().gestureDetector { gesture -> game.onGesture(gesture) })
|
||||
* ```
|
||||
*/
|
||||
fun Modifier.gestureDetector(
|
||||
config: GestureConfig = GestureConfig(),
|
||||
onGesture: (Gesture) -> Unit,
|
||||
): Modifier = composed {
|
||||
val minSwipeDistancePx = with(LocalDensity.current) { config.minSwipeDistance.toPx() }
|
||||
|
||||
this
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onGesture(Gesture.TAP) },
|
||||
onDoubleTap = { onGesture(Gesture.DOUBLE_TAP) },
|
||||
)
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
var accumulated = Offset.Zero
|
||||
var startTimeMs = 0L
|
||||
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
accumulated = Offset.Zero
|
||||
startTimeMs = SystemClock.uptimeMillis()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
accumulated += dragAmount
|
||||
},
|
||||
onDragCancel = {
|
||||
accumulated = Offset.Zero
|
||||
},
|
||||
onDragEnd = {
|
||||
val dx = accumulated.x
|
||||
val dy = accumulated.y
|
||||
val distance = kotlin.math.sqrt(dx * dx + dy * dy)
|
||||
val elapsedSec =
|
||||
(SystemClock.uptimeMillis() - startTimeMs).coerceAtLeast(1) / 1000f
|
||||
val velocity = distance / elapsedSec
|
||||
|
||||
if (distance >= minSwipeDistancePx &&
|
||||
velocity >= config.minSwipeVelocityPxPerSec
|
||||
) {
|
||||
val gesture = if (abs(dx) > abs(dy)) {
|
||||
if (dx > 0) Gesture.SWIPE_RIGHT else Gesture.SWIPE_LEFT
|
||||
} else {
|
||||
if (dy > 0) Gesture.SWIPE_DOWN else Gesture.SWIPE_UP
|
||||
}
|
||||
onGesture(gesture)
|
||||
}
|
||||
accumulated = Offset.Zero
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SoundGame</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.SoundGame" parent="android:Theme.Material.NoActionBar.Fullscreen">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user