第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿
This commit is contained in:
+51
-8
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。
|
||||
*
|
||||
* 手势映射:
|
||||
* - tap :继续剧情 / 结局后重新开始
|
||||
* 手势映射(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 结局。
|
||||
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
|
||||
* 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死——
|
||||
* 13 号文档 §1 边界断言)。
|
||||
*
|
||||
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
|
||||
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
|
||||
@@ -68,16 +75,25 @@ const ALL_SFX = [
|
||||
|
||||
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/;
|
||||
|
||||
export class StorylineGame extends Game {
|
||||
constructor(audio, root) {
|
||||
super(audio, root);
|
||||
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; // 带上了小满
|
||||
@@ -108,8 +124,10 @@ export class StorylineGame extends Game {
|
||||
switch (g) {
|
||||
case 'tap':
|
||||
if (this.awaitingRestart) this.restart();
|
||||
else if (!this.voicePlaying && node.next != null) this.playNode(this.resolve(node.next));
|
||||
// 对白播放中,单击不做任何事(避免误跳)
|
||||
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();
|
||||
@@ -166,6 +184,11 @@ export class StorylineGame extends Game {
|
||||
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);
|
||||
@@ -210,11 +233,28 @@ export class StorylineGame extends Game {
|
||||
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 (token === this.seqToken) step(i + 1); },
|
||||
onComplete: () => {
|
||||
if (advanced || token !== this.seqToken) return;
|
||||
advanced = true;
|
||||
clearTimeout(wd);
|
||||
step(i + 1);
|
||||
},
|
||||
});
|
||||
};
|
||||
step(0);
|
||||
@@ -225,8 +265,8 @@ export class StorylineGame extends Game {
|
||||
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;
|
||||
// 选择带来的剧情标记(注意此时 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));
|
||||
@@ -261,6 +301,7 @@ export class StorylineGame extends Game {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
this.audio.play('sfx/ui_menu');
|
||||
this.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2)
|
||||
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
|
||||
}
|
||||
|
||||
@@ -271,6 +312,7 @@ export class StorylineGame extends Game {
|
||||
}
|
||||
this.mode = MODE.STORY;
|
||||
this.audio.play('sfx/ui_confirm');
|
||||
this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone
|
||||
this.playNode(this.nodeId); // 回到故事,重播当前节点
|
||||
}
|
||||
|
||||
@@ -282,6 +324,7 @@ export class StorylineGame extends Game {
|
||||
this.betrayed = false;
|
||||
this.mode = MODE.STORY;
|
||||
this.audio.play('sfx/ui_confirm');
|
||||
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
|
||||
this.playNode('n00');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user