第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿

This commit is contained in:
SoundGame Dev
2026-08-30 16:45:42 +08:00
parent cc0f8c008d
commit 9a915d62c0
1163 changed files with 6346 additions and 208 deletions
+51 -8
View File
@@ -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');
}