Files
SoundGame/web/storyline/game.js
T

463 lines
21 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)。
*
* 手势映射(12 号文档 §1 裁定):
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
* 结局后 = 重新开始
* - doubletap :重听当前节点的全部台词(含结局节点——重播期间单击=跳句,
* 播完回到结算态;live round1 P2-1)
* - 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 + §16 N1)。
*
* 与 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 且引擎确认 voice 未在播才视为资产缺失,
* 自动推进链(不卡死);voice 真在播(长句)则续约等待,不得掐断(N1)。 */
const VOICE_WATCHDOG_MS = 30000;
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
const PROMPT_RE = /^voice\/p\d/;
// 向导层(14 号文档 + round2 synthesis F1.1):full 档 25s 轻提醒+gd_continue,
// 50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)。
// full 档节奏由 web/harness/guide.test.mjs 锁定,不可改。
const GUIDE_WATCHDOG_AT = [25000, 50000];
// live round1 P2-3(静默无兜底):standard 档 guide 看门狗只发 nudge earcon、
// 无语音(14 号文档 #2),玩家等不到可听提示(老陈开局 45s 空白)。复用同一
// 看门狗(计时/复位/升级/取消都在向导层,不另造定时器):standard 档提前到
// 13s/30s,由 onEscalate 补播同 id 提示句(游戏反馈层,gd_economy 全档位先例);
// off 档看门狗整体不挂(既定全静契约 + 6 playtest 零干扰红线)。
const IDLE_HINT_AT = [13000, 30000];
// live round2 N3(抉择节点无静默兜底):p6/p8 实测 35~39s 无操作零提醒——
// 抉择点恰恰最容易漏听选项、最需要复述。14 号文档原裁定「抉择态(p* 播完)
// 不插语音不挂看门狗」的本义是"提示播完立即可选、不插语音延迟抉择"(W2
// 契约,保留:播完当刻 choiceArmed 即真、零插播);本兜底与之不冲突——只在
// 播完 25s 完全无操作后重播当前 p* 提示句本身(复用既有资产,不新增语音
// id),50s 再复述一次后彻底静默(与继续节点同一两级上限礼仪);玩家任何
// 手势经 guide.disarm 复位。新裁定依据 = round2 玩家复测证据(13 号文档 §16)。
// full/standard 档提示句都由游戏层播(p* 是剧情内容不是向导句,standard 档
// 玩家同样必须能复述选项);off 档整体不挂(既定全静契约)。
const CHOICE_HINT_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; // 向猎狗交出过坐标
this.viaCulvert = false; // n15 左滑走了涵洞(分支经历标记,保留给 TTS 分支句启用)
}
// ---------------------------------------------------------------- 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));
// 抉择等待态空点 = 无进展手势:重新挂载兜底(25s 计时重置,N3「操作即复位」)
else if (node.prompt != null && this.choiceArmed) this.armChoiceHint(node);
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 = this.nodeVoices(id);
// 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() {
// 结局节点同样兑现"双击:重听刚才的台词"(live round1 P2-1:结局播完双击
// 只有震动不重播)。重播期间清结算态——单击回到"跳过本句"而非重开;
// 播完由 onNodeSeqDone 重新进入结算态。
this.seqToken++;
this.audio.stopVoice();
// 重播当前节点(不重复进入音效之外的状态重置)
const node = NODES[this.nodeId];
if (!node) return;
this.choiceArmed = false;
this.pendingChoice = null;
this.awaitingRestart = false;
const seq = this.nodeVoices(this.nodeId);
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.playSeq(seq, () => this.onNodeSeqDone(node));
}
/**
* 节点基础语音链。
* known-issue(live round2 N2,13 号文档 §16):round1 修 P2-2「涵洞也拦不住
* 你」穿帮时按 !viaCulvert 整段滤掉 n18_dog,结果钢索路径猎狗对峙戏蒸发、
* p6 抉择(交出坐标还是拒绝)失去对手戏上下文——玩家裁定这比穿帮更糟。
* 现恢复涵洞/钢索两条路径都播 n18_dog:钢索路径接受该句的轻微穿帮,待
* TTS 补录分支句后按 viaCulvert 选播(voice_text.json 无替代资产,台词
* 文本不可改、不新增语音 id)。
*/
nodeVoices(id) {
const node = NODES[id];
if (!node) return [];
return [...node.voices];
}
/** 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 {
// 抉择提示播完即可选(W2 契约:播完当刻 choiceArmed 即真、零插播)。
// 在此之上挂静默兜底:25s 无操作重播 p* 提示(N3,见 CHOICE_HINT_AT 注释)
this.armChoiceHint(node);
}
} else if (node.ending) {
this.awaitingRestart = true;
}
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):full 档 25s nudge+
// gd_continue、50s 升级 gd_again——**不重播 help+进度+当前节点**,两级用尽
// 彻底静默。live round1 P2-3:standard 档 guide 只发 earcon 无语音,同一
// 看门狗换用 IDLE_HINT_AT(13s/30s)并由 onEscalate 补播同 id 提示句
// (游戏反馈层,off 档整体不挂)。玩家手势后由 guide.disarm 复位,下一
// 节点播完重新挂载。语音播放中不挂;抉择态改挂 armChoiceHint(N3 兜底)。
if (node.prompt == null) {
this.guide.watchdog({
arm: true,
at: this.guide.level() === 'full' ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
say: ['gd_continue', 'gd_again'],
onEscalate: (n) => {
// full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播
if (this.guide.level() === 'full') return;
this.audio.play(`voice/${n === 1 ? 'gd_continue' : 'gd_again'}`, { queue: true });
},
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
});
}
}
/**
* N3 抉择节点静默兜底:25s/50s 无操作重播当前 p* 提示句(复用既有资产),
* 两级用尽彻底静默;玩家任何手势经 guide.disarm 复位(抉择态空点由
* onStoryGesture 重新挂载 = 计时重置;已缓存 pendingChoice 直接推进不挂)。
* guide=off 时 guide.watchdog 整体 no-op(不挂定时器,全静契约)。
*/
armChoiceHint(node) {
this.guide.watchdog({
arm: true,
at: CHOICE_HINT_AT,
onEscalate: () => {
if (node.prompt) this.audio.play(node.prompt, { queue: true });
},
// 已抉择(choiceArmed=false)/离开剧情态/新链在播 → 到点静默取消
cond: () => this.mode === MODE.STORY && this.choiceArmed && !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,超时自动续链(不卡死)。
// live round2 N1:固定 30s 阈值会把真实在播的长语音误判为卡死——冷启动
// n00_intro(71.7s,2.2x 也要 32.6s)在 30s 处被续链的下一句 skip_hint
// 顶掉,末句「现在,单击屏幕,睁开眼睛」被吃(菜单重开时链上无下一句
// 可顶,故只在冷启动咬人);常速下 e1/e2/e4(48~51s)同样越线。判定
// 对齐真实播放状态:引擎 isVoicePlaying() 为真 = 资产在播(自然播完
// 必触发 onComplete),续约再等一轮,不得掐;为假才是资产缺失(引擎
// 静默跳过、永不回调),照旧告警续链(13 号文档 §1 不卡死断言不变)。
const armWd = () => setTimeout(() => {
if (advanced || token !== this.seqToken) return;
if (this.audio.isVoicePlaying()) { wd = armWd(); return; }
advanced = true;
console.warn('[StorylineGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
step(i + 1);
}, VOICE_WATCHDOG_MS);
let wd = armWd();
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 === 'n15') this.viaCulvert = g === 'left'; // 涵洞/钢索(分支经历标记)
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 node = NODES[this.nodeId];
const ending = !!(node && node.ending);
const act = this.actOf(this.nodeId);
// live round1 P3-2:结局后状态播报 = 当前结局语音(句尾自带"单击重新开始"),
// 不再报"这是最后一段路了"(status3)。结局句本身已是节点重播,播完直接
// 回结算态,不二次重播节点。
const status = ending ? node.voices[0]
: 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 if (ending) {
this.onNodeSeqDone(node); // 回结算态(awaitingRestart + 静默兜底重挂)
} 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.viaCulvert = 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;
}
}