live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿
This commit is contained in:
+109
-19
@@ -5,7 +5,8 @@
|
||||
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
|
||||
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
|
||||
* 结局后 = 重新开始
|
||||
* - doubletap :重听当前节点的全部台词
|
||||
* - doubletap :重听当前节点的全部台词(含结局节点——重播期间单击=跳句,
|
||||
* 播完回到结算态;live round1 P2-1)
|
||||
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
|
||||
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
|
||||
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
|
||||
@@ -15,8 +16,8 @@
|
||||
*
|
||||
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
|
||||
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
|
||||
* 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死——
|
||||
* 13 号文档 §1 边界断言)。
|
||||
* 单句看门狗(VOICE_WATCHDOG_MS 超时且引擎确认 voice 未在播 = 资产缺失,
|
||||
* 才自动续链不卡死;真实在播的长语音续约不掐——13 号文档 §1 + §16 N1)。
|
||||
*
|
||||
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
|
||||
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
|
||||
@@ -76,17 +77,37 @@ const ALL_SFX = [
|
||||
|
||||
const MODE = { STORY: 'STORY', MENU: 'MENU' };
|
||||
|
||||
/** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */
|
||||
/** 单句语音看门狗:超时未 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):剧情类等待默认低频——25s 轻提醒
|
||||
// +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
|
||||
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)
|
||||
// 向导层(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);
|
||||
@@ -106,6 +127,7 @@ export class StorylineGame extends Game {
|
||||
this.companion = false; // 带上了小满
|
||||
this.knowTrap = false; // 装死时听到北区陷阱的情报
|
||||
this.betrayed = false; // 向猎狗交出过坐标
|
||||
this.viaCulvert = false; // n15 左滑走了涵洞(分支经历标记,保留给 TTS 分支句启用)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Game
|
||||
@@ -137,6 +159,8 @@ export class StorylineGame extends Game {
|
||||
// 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 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();
|
||||
@@ -197,7 +221,7 @@ export class StorylineGame extends Game {
|
||||
this.pendingChoice = null;
|
||||
this.awaitingRestart = false;
|
||||
if (node.sfx) this.audio.play(node.sfx);
|
||||
const seq = [...node.voices];
|
||||
const seq = this.nodeVoices(id);
|
||||
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
|
||||
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
|
||||
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
|
||||
@@ -214,7 +238,9 @@ export class StorylineGame extends Game {
|
||||
}
|
||||
|
||||
replayNode() {
|
||||
if (this.awaitingRestart) return;
|
||||
// 结局节点同样兑现"双击:重听刚才的台词"(live round1 P2-1:结局播完双击
|
||||
// 只有震动不重播)。重播期间清结算态——单击回到"跳过本句"而非重开;
|
||||
// 播完由 onNodeSeqDone 重新进入结算态。
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
// 重播当前节点(不重复进入音效之外的状态重置)
|
||||
@@ -222,12 +248,28 @@ export class StorylineGame extends Game {
|
||||
if (!node) return;
|
||||
this.choiceArmed = false;
|
||||
this.pendingChoice = null;
|
||||
const seq = [...node.voices];
|
||||
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;
|
||||
@@ -237,24 +279,53 @@ export class StorylineGame extends Game {
|
||||
const g = this.pendingChoice;
|
||||
this.pendingChoice = null;
|
||||
this.choose(g);
|
||||
} else {
|
||||
// 抉择提示播完即可选(W2 契约:播完当刻 choiceArmed 即真、零插播)。
|
||||
// 在此之上挂静默兜底:25s 无操作重播 p* 提示(N3,见 CHOICE_HINT_AT 注释)
|
||||
this.armChoiceHint(node);
|
||||
}
|
||||
// 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
|
||||
} else if (node.ending) {
|
||||
this.awaitingRestart = true;
|
||||
}
|
||||
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue;
|
||||
// 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽
|
||||
// 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。
|
||||
// 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: GUIDE_WATCHDOG_AT,
|
||||
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;
|
||||
@@ -270,13 +341,22 @@ export class StorylineGame extends Game {
|
||||
this.currentVoiceId = ids[i];
|
||||
this.promptPlaying = PROMPT_RE.test(ids[i]);
|
||||
let advanced = false;
|
||||
// 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)
|
||||
const wd = setTimeout(() => {
|
||||
// 看门狗:资产缺失时引擎不触发 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] 语音看门狗超时,自动续链:', ids[i]);
|
||||
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;
|
||||
@@ -297,6 +377,7 @@ export class StorylineGame extends Game {
|
||||
// 选择带来的剧情标记(注意此时 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));
|
||||
}
|
||||
@@ -313,12 +394,20 @@ export class StorylineGame extends Game {
|
||||
playHelpAndResume(backToMenu = false) {
|
||||
this.seqToken++;
|
||||
this.audio.stopVoice();
|
||||
const node = NODES[this.nodeId];
|
||||
const ending = !!(node && node.ending);
|
||||
const act = this.actOf(this.nodeId);
|
||||
const status = act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
|
||||
// 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); // 重播当前剧情节点
|
||||
}
|
||||
@@ -353,6 +442,7 @@ export class StorylineGame extends Game {
|
||||
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'); // 从菜单重开时切回主题(结局重开为同首不重启)
|
||||
|
||||
Reference in New Issue
Block a user