HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构

- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+297
View File
@@ -0,0 +1,297 @@
/**
* 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。
*
* 手势映射:
* - tap :继续剧情 / 结局后重新开始
* - doubletap :重听当前节点的全部台词
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
*
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局。
*
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
*/
import { Game } from '../engine/game.js';
/** 剧情节点。voices 顺序播放;prompt 非空表示这是一个选择节点。 */
function node(voices, opts = {}) {
return { voices, next: null, prompt: null, left: null, right: null, sfx: null, ending: false, ...opts };
}
const NODES = {
// 第一幕:地铁站与街面
n00: node(['voice/n00_intro'], { next: 'n01' }),
n01: node(['voice/n01_wake'], { next: 'n02', sfx: 'sfx/heartbeat' }),
n02: node(['voice/n02_linwan'], { prompt: 'voice/p1', left: 'n03', right: 'n04' }),
n03: node(['voice/n03_radio', 'voice/n03_msg'], { next: 'n05', sfx: 'sfx/radio_static' }),
n04: node(['voice/n04_exit'], { next: 'n05', sfx: 'sfx/footsteps' }),
n05: node(['voice/n05_signal', 'voice/n05_bcast'], { next: 'n06', sfx: 'sfx/radio_static' }),
n06: node(['voice/n06_whistle'], { next: 'n07', sfx: 'sfx/whistle' }),
n07: node(['voice/n07_dog'], { prompt: 'voice/p2', left: 'n08', right: 'n09' }),
n08: node(['voice/n08_vent'], { next: 'n10', sfx: 'sfx/rubble' }),
n09: node(['voice/n09_dead'], { next: 'n10' }),
// 第二幕:雨夜与断桥
n10: node(['voice/n10_rain'], { prompt: 'voice/p3', left: 'n11', right: 'n14', sfx: 'sfx/wind' }),
n11: node(['voice/n11_child'], { prompt: 'voice/p4', left: 'n12', right: 'n13' }),
n12: node(['voice/n12_take'], { next: 'n15' }),
n13: node(['voice/n13_leave'], { next: 'n15', sfx: 'sfx/footsteps' }),
n14: node(['voice/n14_ignore'], { next: 'n15', sfx: 'sfx/footsteps' }),
n15: node(['voice/n15_bridge'], { prompt: 'voice/p5', left: 'v_culvert', right: 'v_cable', sfx: 'sfx/wind' }),
n16: node(['voice/n16_culvert'], { next: 'n18', sfx: 'sfx/water' }),
n16b: node(['voice/n16b_culvert_c'], { next: 'n18', sfx: 'sfx/water' }),
n17: node(['voice/n17_cable'], { next: 'n18', sfx: 'sfx/wind' }),
n17b: node(['voice/n17b_cable_c'], { next: 'n18', sfx: 'sfx/wind' }),
// 第三幕:广播塔
n18: node(['voice/n18_dog'], { prompt: 'voice/p6', left: 'n19', right: 'n22', sfx: 'sfx/footsteps' }),
n19: node(['voice/n19_betray'], { next: 'n20' }),
n20: node(['voice/n20_convoy'], { prompt: 'voice/p7', left: 'n21', right: 'e3' }),
n21: node(['voice/n21_redeem'], { next: 'e2', sfx: 'sfx/footsteps' }),
n22: node(['voice/n22_fight'], { next: 'n23', sfx: 'sfx/gunshot' }),
n23: node(['voice/n23_tower'], { prompt: 'voice/p8', left: 'n24', right: 'n25', sfx: 'sfx/signal_beep' }),
n24: node(['voice/n24_open'], { next: 'e4', sfx: 'sfx/signal_beep' }),
n25: node(['voice/n25_secure'], { next: 'e1' }),
// 结局
e1: node(['voice/e1_dawn'], { ending: true }), // 好结局:黎明(带小满时追加彩蛋)
e2: node(['voice/e2_price'], { ending: true }), // 结局:代价
e3: node(['voice/e3_alone'], { ending: true, sfx: 'sfx/radio_static' }), // 坏结局:孤声
e4: node(['voice/e4_static'], { ending: true, sfx: 'sfx/struggle' }), // 结局:静默
};
const ALL_SFX = [
'sfx/footsteps', 'sfx/gunshot', 'sfx/heartbeat', 'sfx/radio_static',
'sfx/rubble', 'sfx/signal_beep', 'sfx/struggle', 'sfx/ui_confirm',
'sfx/ui_menu', 'sfx/water', 'sfx/whistle', 'sfx/wind',
];
const MODE = { STORY: 'STORY', MENU: 'MENU' };
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
this.mode = MODE.STORY;
this.nodeId = 'n00';
this.voicePlaying = false;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
this.seqToken = 0;
// 剧情标记
this.companion = false; // 带上了小满
this.knowTrap = false; // 装死时听到北区陷阱的情报
this.betrayed = false; // 向猎狗交出过坐标
}
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/dark_drone');
// 预解码全部短音效(等价 Android 的 SoundPool 音量 0 预热)
this.audio.prewarm(ALL_SFX);
this.playNode('n00');
}
onGesture(g) {
console.log(`[StorylineGame] onGesture: ${g} mode=${this.mode} node=${this.nodeId} armed=${this.choiceArmed} playing=${this.voicePlaying}`);
if (this.mode === MODE.MENU) this.onMenuGesture(g);
else this.onStoryGesture(g);
}
// ---------------------------------------------------------------- 手势
onStoryGesture(g) {
const node = NODES[this.nodeId];
if (!node) return;
switch (g) {
case 'tap':
if (this.awaitingRestart) this.restart();
else if (!this.voicePlaying && node.next != null) this.playNode(this.resolve(node.next));
// 对白播放中,单击不做任何事(避免误跳)
break;
case 'doubletap':
this.replayNode();
break;
case 'left':
case 'right': {
if (node.prompt == null) return;
if (this.choiceArmed) this.choose(g);
else if (this.voicePlaying) this.pendingChoice = g; // 提示音播放中,缓存抉择
break;
}
case 'up':
this.playHelpAndResume();
break;
case 'down':
this.openMenu();
break;
}
}
onMenuGesture(g) {
switch (g) {
case 'left':
this.restart();
break;
case 'right':
case 'tap':
this.closeMenu();
break;
case 'up':
this.playHelpAndResume(true);
break;
case 'doubletap':
if (!this.voicePlaying) {
this.seqToken++;
this.audio.stopVoice();
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
}
break;
}
}
// ---------------------------------------------------------------- 剧情驱动
playNode(id) {
const node = NODES[id];
if (!node) {
console.error('[StorylineGame] 未知节点:', id);
return;
}
this.nodeId = id;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
if (node.sfx) this.audio.play(node.sfx);
const seq = [...node.voices];
// 好结局彩蛋:带着小满抵达黎明时追加他的台词
if (id === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.playSeq(seq, () => this.onNodeSeqDone(node));
}
replayNode() {
if (this.awaitingRestart) return;
this.seqToken++;
this.audio.stopVoice();
// 重播当前节点(不重复进入音效之外的状态重置)
const node = NODES[this.nodeId];
if (!node) return;
this.choiceArmed = false;
this.pendingChoice = null;
const seq = [...node.voices];
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.playSeq(seq, () => this.onNodeSeqDone(node));
}
/** playNode / replayNode 的语音链自然播完后的公共收尾。 */
onNodeSeqDone(node) {
this.voicePlaying = false;
if (node.prompt != null) {
this.choiceArmed = true;
if (this.pendingChoice) {
const g = this.pendingChoice;
this.pendingChoice = null;
this.choose(g);
}
} else if (node.ending) {
this.awaitingRestart = true;
}
// 否则等待单击继续
}
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
playSeq(ids, onDone) {
const token = ++this.seqToken;
this.voicePlaying = true;
const step = (i) => {
if (token !== this.seqToken) return;
if (i >= ids.length) {
onDone();
return;
}
this.audio.play(ids[i], {
onComplete: () => { if (token === this.seqToken) step(i + 1); },
});
};
step(0);
}
choose(g) {
const node = NODES[this.nodeId];
if (!node || node.left == null || node.right == null) return;
this.choiceArmed = false;
this.audio.play('sfx/ui_confirm');
// 选择带来的剧情标记
if (this.nodeId === 'n09' && g === 'right') this.knowTrap = true;
if (this.nodeId === 'n11' && g === 'left') this.companion = true;
if (this.nodeId === 'n18' && g === 'left') this.betrayed = true;
this.playNode(this.resolve(g === 'left' ? node.left : node.right));
}
/** 虚拟目标:根据剧情标记解析到实际节点。 */
resolve(target) {
if (target === 'v_culvert') return this.companion ? 'n16b' : 'n16';
if (target === 'v_cable') return this.companion ? 'n17b' : 'n17';
return target;
}
// ---------------------------------------------------------------- 帮助 / 菜单 / 重开
playHelpAndResume(backToMenu = false) {
this.seqToken++;
this.audio.stopVoice();
const act = this.actOf(this.nodeId);
const status = act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
this.playSeq(['voice/help', status], () => {
this.voicePlaying = false;
if (backToMenu) {
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
} else {
this.playNode(this.nodeId); // 重播当前剧情节点
}
});
}
openMenu() {
this.mode = MODE.MENU;
this.seqToken++;
this.audio.stopVoice();
this.audio.play('sfx/ui_menu');
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
}
closeMenu() {
if (this.voicePlaying) {
this.seqToken++;
this.audio.stopVoice();
}
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.playNode(this.nodeId); // 回到故事,重播当前节点
}
restart() {
this.seqToken++;
this.audio.stopVoice();
this.companion = false;
this.knowTrap = false;
this.betrayed = false;
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.playNode('n00');
}
actOf(id) {
if (id.startsWith('e')) return 3;
const m = /^n(\d+)/.exec(id);
const num = m ? parseInt(m[1], 10) : NaN;
if (Number.isNaN(num)) return 1;
if (num < 10) return 1;
if (num < 18) return 2;
return 3;
}
}