本次定稿

第一版音效系统不追求“专业配乐”。它只解决一个问题:让 LoopTrain 的关键状态有声音反馈。

当前确定的范围是:

音效系统结构 + 6 个占位音效 + 游戏事件绑定

第一版需要让玩家感到:

列车在动。
危险在逼近。
我刚刚发现了线索。
失败是有冲击的。
循环是有记忆的。

暂时不做:

复杂配乐
角色主题曲
动态音乐系统
Web Audio API
完整拟音
多轨混音

这些能力以后可以做,但不应该进入第一版。

架构边界

LoopTrain 当前运行在 SLT(Standalone LoopTrain)里:

looptrain/standalone/
  engine.js          # 游戏裁判引擎
  server.js          # Express API
  public/app.js      # 前端主逻辑

音效系统不能破坏现有边界。

最终原则是:

Engine 不知道音效。
AudioManager 不知道剧情。
app.js 里的事件映射层负责翻译。

engine.js 继续只负责 AP、线索、对话、失败、循环继承等游戏规则。音效不会写进 engine,也不会反过来修改游戏状态。

第一版目录

音效作为公开素材,放在现有 assets 体系下:

looptrain/standalone/public/assets/audio/
  manifest.json
  LICENSES.md
  ambience/
    rail_loop_low.mp3
  tension/
    faint_ticking_loop.mp3
  sfx/
    button_tap.wav
    clue_found.wav
  cinematic/
    explosion_muffled.wav
    loop_rewind.wav

这样前端路径统一为:

/assets/audio/

不再沿用旧 ST extension 路径。

AudioManager 的职责

第一版新增一个前端音频管理器:

looptrain/standalone/public/audio-manager.js

它只负责音频能力:

init(manifestUrl)
unlock()
play(id)
stop(id)
fadeIn(id)
fadeOut(id)
setVolume(bus, value)
setMuted(value)
dispatch(audioEvent)

它不负责判断:

AP 是否过低
是否获得线索
是否失败爆炸
是否进入下一轮
是否试玩成功

这些都属于游戏语义,不应该进入 AudioManager。

第一版使用 HTMLAudioElement。理由很简单:现在只有 6 个音效,只需要播放、循环、淡入淡出和静音。Web Audio API 可以等到需要滤波、动态混音或复杂分层时再引入。

事件映射层

声音真正接入游戏的位置在 app.jshandleResponse(res, inDialogue)

所有关键游戏结果都会经过这里:

res.state
res.dialogue_outcome
res.loop_failure_outcome
res.trial_success
res.memory_node

所以第一版增加一个轻量映射函数:

function deriveAudioEvents(prevState, nextState, res) {
  const events = [];

  if (knownCluesIncreased(prevState, nextState)) {
    events.push({ action: 'play', id: 'clue_found' });
  }

  if (crossedLowApThreshold(prevState, nextState)) {
    events.push({ action: 'fadeIn', id: 'faint_ticking_loop' });
  }

  if (res.loop_failure_outcome) {
    events.push({ action: 'fadeOut', id: 'faint_ticking_loop' });
    events.push({ action: 'play', id: 'explosion_muffled' });
  }

  if (res.trial_success) {
    events.push({ action: 'fadeOut', id: 'faint_ticking_loop' });
  }

  return events;
}

这个函数的职责只有一个:

Game Event → Audio Event

第一版可以先放在 app.js 里。等事件变多,再拆成独立的 audio-events.js

第一批音效

第一批最少只需要 6 个文件:

文件用途
rail_loop_low.mp3进入游戏后淡入的列车环境底噪
faint_ticking_loop.mp3AP 过低或接近关键线索时出现的滴答声
button_tap.wav普通 UI 点击反馈
clue_found.wav获得线索时的提示音
explosion_muffled.wav失败爆炸时的闷爆冲击
loop_rewind.wav点击进入下一轮时的循环倒带声

素材来源优先:

Pixabay:列车环境、滴答声
Mixkit:按钮、提示、爆炸、倒带

暂不使用 BBC Sound Effects、ZapSplat、Freesound 作为第一批默认素材来源。原因不是质量问题,而是授权复杂度。第一版优先使用授权清楚、下载简单、最好不要求署名的素材。

无论是否要求署名,都必须记录来源:

public/assets/audio/LICENSES.md

记录字段:

文件名
来源站点
原始标题
原始作者
原始 URL
下载日期
许可证
是否需要署名

触发规则

第一版事件表如下:

游戏 / 界面事件音频事件说明
点击「进入第七节车厢」unlock() + fadeIn('rail_loop_low')不自动播放,必须由用户点击解锁
普通 UI 点击play('button_tap')排除开场、静音、下一轮等关键按钮
获得新线索play('clue_found')通过 known_clues 增长判断
AP 从 >3 到 <=3fadeIn('faint_ticking_loop')时间压力进入
AP 恢复到 >3fadeOut('faint_ticking_loop')下一轮或状态恢复后降低压力
失败结算fadeOut('faint_ticking_loop') + play('explosion_muffled')失败只播爆炸,不提前播倒带
点击进入下一轮play('loop_rewind')玩家确认循环重启时触发
试玩成功fadeOut('faint_ticking_loop')成功后停止压力音

失败和循环重启分开处理。

失败发生 → explosion_muffled
玩家点击进入下一轮 → loop_rewind

这样声音和交互语义一致。

静音与解锁

公网游戏必须有声音开关。

按钮放在 topbar:

🔊 / 🔇

状态独立保存:

localStorage.looptrain.audio.muted = true / false

不把音频偏好塞进 looptrain.standalone.v1,因为那是游戏进度。音频偏好是用户设置,应该独立保存。

音频不能自动播放。第一版只在用户点击「进入第七节车厢」之后调用:

unlockAudio()

然后再淡入列车环境音。

加载失败策略

音频失败不能影响游戏流程。

第一版规则:

manifest 加载失败 → AudioManager disabled
单个 track 加载失败 → 跳过该 track
play 失败 → console.warn,不阻断游戏

这是试玩版,不应该因为一个音效 404 让玩家无法继续游戏。

后续实现功能点

v0.5-audio-core

目标:先把骨架接上。

1. 新增 public/assets/audio/manifest.json
2. 新增 public/audio-manager.js
3. index.html 引入 audio-manager.js
4. topbar 增加声音开关
5. 静音状态写入 localStorage.looptrain.audio.muted
6. 点击「进入第七节车厢」后 unlock audio
7. rail_loop_low 淡入
8. 支持 play / loop / fadeIn / fadeOut / mute
9. 音频加载失败时静默降级

v0.5.1-audio-events

目标:把声音绑定到游戏事件。

1. 增加 deriveAudioEvents(prevState, nextState, res)
2. known_clues 增长 → clue_found
3. AP <= 3 → faint_ticking_loop 淡入
4. failure → explosion_muffled
5. nextLoop → loop_rewind
6. trial_success → tension fadeOut
7. 普通 UI 操作 → button_tap

v0.5.2-audio-polish

目标:调体验,而不是加复杂度。

1. 调整各 bus 默认音量
2. 手机端真机测试音量和解锁行为
3. 检查 rail_loop_low 与 faint_ticking_loop 的循环衔接
4. 根据具体线索触发 ticking,而不只依赖 AP
5. 增加 memory_flash 或 ear_ringing 等第二批音效
6. 完善 LICENSES.md,并同步到素材记录页面

当前结论

第一版音效系统的目标不是“听起来像完整商业游戏”。

它只需要做到:结构是对的,事件是准的,声音不烦人,后续可以替换素材。

只要这四点成立,LoopTrain 就能从“完全无声的文字试玩”进入“有时间压力和失败冲击的互动叙事试玩”。