Files
SoundGame/web/storyline/game.js
T
SoundGame Dev 79dffac01e guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿
- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
2026-08-30 22:46:05 +08:00

377 lines
15 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 :重听当前节点的全部台词
* - 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/;
// 向导层(schemes.md §2):W1 静默看门狗阈值——8s 轻提醒+gd_continue,20s 升级重播帮助
const GUIDE_WATCHDOG_AT = [8000, 20000];
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; // 向猎狗交出过坐标
// 向导层:升级帮助播过后不再自动重挂看门狗(L4"之后停止,不轰炸"),
// 玩家下一次手势即复位
this._guideHelped = 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)
this._guideHelped = false; // 玩家有动作,恢复看门狗自动挂载
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];
// 开场教学: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 静默等待看门狗(向导层,schemes §2):8s nudge+gd_continue;
// 20s 升级 = 复用 playHelpAndResume(帮助+进度+重播当前节点),升级后不再
// 自动重挂(防循环轰炸),玩家下一次手势恢复。抉择态与语音播放中不挂。
if (node.prompt == null && !this._guideHelped) {
this.guide.watchdog({
arm: true,
at: GUIDE_WATCHDOG_AT,
say: ['gd_continue', null],
onEscalate: (n) => {
if (n !== 2 || this.mode !== MODE.STORY) return;
this._guideHelped = true;
this.playHelpAndResume();
},
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;
}
}