373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
/**
|
||
* 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。
|
||
*
|
||
* 手势映射(12 号文档 §1 裁定):
|
||
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
|
||
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
|
||
* 结局后 = 重新开始
|
||
* - doubletap :重听当前节点的全部台词
|
||
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
|
||
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
|
||
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
|
||
*
|
||
* 状态 BGM(10 号文档 §2):剧情/结局 = bgm/dark_drone 不切;菜单 = bgm/menu
|
||
* (进菜单 crossfade 500ms,关菜单淡回 dark_drone 800ms)。
|
||
*
|
||
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
|
||
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
|
||
* 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死——
|
||
* 13 号文档 §1 边界断言)。
|
||
*
|
||
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
|
||
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.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' };
|
||
|
||
/** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */
|
||
const VOICE_WATCHDOG_MS = 30000;
|
||
|
||
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
|
||
const PROMPT_RE = /^voice\/p\d/;
|
||
|
||
// 向导层(14 号文档 + round2 synthesis F1.1):剧情类等待默认低频——25s 轻提醒
|
||
// +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
|
||
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)
|
||
const GUIDE_WATCHDOG_AT = [25000, 50000];
|
||
|
||
export class StorylineGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
|
||
this.mode = MODE.STORY;
|
||
this.nodeId = 'n00';
|
||
this.voicePlaying = false;
|
||
this.currentVoiceId = null; // 链上当前句 id
|
||
this.promptPlaying = false; // 当前句是抉择提示 p*
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null;
|
||
this.awaitingRestart = false;
|
||
this.seqToken = 0;
|
||
this.skipHintShown = false; // 开场"单击可跳过旁白"提示每局只播一次
|
||
|
||
// 剧情标记
|
||
this.companion = false; // 带上了小满
|
||
this.knowTrap = false; // 装死时听到北区陷阱的情报
|
||
this.betrayed = false; // 向猎狗交出过坐标
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Game
|
||
|
||
start() {
|
||
this.audio.play('bgm/dark_drone');
|
||
this.guide.state('explore'); // 状态签名:剧情/探索(与状态 BGM 同点,10 号文档)
|
||
// 预解码全部短音效(等价 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}`);
|
||
this.guide.disarm(); // 任何手势复位静默看门狗(guide=off 时 no-op)
|
||
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) {
|
||
// 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 p* 不可跳
|
||
if (!this.promptPlaying) this.audio.skipVoice();
|
||
} else if (node.next != null) this.playNode(this.resolve(node.next));
|
||
break;
|
||
case 'doubletap':
|
||
this.replayNode();
|
||
break;
|
||
case 'left':
|
||
case 'right': {
|
||
if (node.prompt == null) {
|
||
// 无效手势零反馈是全 6 款最盲点(清点 §1.4):向导纠错——error 震 +
|
||
// full 档 gd_invalid(onceKey 同局只播一次;off 档整段 no-op)
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'storyline_invalid' });
|
||
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];
|
||
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
|
||
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
|
||
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
|
||
if (id === 'n00' && this.guide.level() === 'full') seq.unshift('voice/gd_brief');
|
||
// 开场教学:intro 早期追加一次"单击可跳过旁白"提示(每局只播一次,重开不重播)
|
||
if (id === 'n00' && !this.skipHintShown) {
|
||
this.skipHintShown = true;
|
||
seq.push('voice/skip_hint');
|
||
}
|
||
// 好结局彩蛋:带着小满抵达黎明时追加他的台词
|
||
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);
|
||
}
|
||
// 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
|
||
} else if (node.ending) {
|
||
this.awaitingRestart = true;
|
||
}
|
||
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue;
|
||
// 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽
|
||
// 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。
|
||
if (node.prompt == null) {
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: GUIDE_WATCHDOG_AT,
|
||
say: ['gd_continue', 'gd_again'],
|
||
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
|
||
playSeq(ids, onDone) {
|
||
const token = ++this.seqToken;
|
||
this.voicePlaying = true;
|
||
const step = (i) => {
|
||
if (token !== this.seqToken) return;
|
||
if (i >= ids.length) {
|
||
this.currentVoiceId = null;
|
||
this.promptPlaying = false;
|
||
onDone();
|
||
return;
|
||
}
|
||
this.currentVoiceId = ids[i];
|
||
this.promptPlaying = PROMPT_RE.test(ids[i]);
|
||
let advanced = false;
|
||
// 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)
|
||
const wd = setTimeout(() => {
|
||
if (advanced || token !== this.seqToken) return;
|
||
advanced = true;
|
||
console.warn('[StorylineGame] 语音看门狗超时,自动续链:', ids[i]);
|
||
step(i + 1);
|
||
}, VOICE_WATCHDOG_MS);
|
||
this.audio.play(ids[i], {
|
||
onComplete: () => {
|
||
if (advanced || token !== this.seqToken) return;
|
||
advanced = true;
|
||
clearTimeout(wd);
|
||
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');
|
||
// 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标)
|
||
if (this.nodeId === 'n07' && 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.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2)
|
||
this.guide.state('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.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone
|
||
this.guide.state('explore');
|
||
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.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
|
||
this.guide.state('explore');
|
||
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;
|
||
}
|
||
}
|