Files
SoundGame/web/storyline/game.js
T
SoundGame Dev cc0f8c008d 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 文档
2026-08-29 21:55:44 +08:00

298 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《声命线》——末世互动广播剧(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;
}
}