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'); // 从菜单重开时切回主题(结局重开为同首不重启)
|
||||
|
||||
@@ -136,6 +136,10 @@ class MockAudioContext {
|
||||
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
|
||||
}
|
||||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||||
// 向导层 earcon 运行时合成所需(guide=standard/full 时 guide.state/nudge 会创建)
|
||||
createOscillator() {
|
||||
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
|
||||
}
|
||||
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
|
||||
resume() { this.state = 'running'; return Promise.resolve(); }
|
||||
}
|
||||
@@ -394,6 +398,175 @@ realLog('== 看门狗路径(屏蔽 voice/n05_signal)==');
|
||||
blockedAssets.clear();
|
||||
}
|
||||
|
||||
// ================================================================ live round2 N1
|
||||
// (docs/design/13-playtest-criteria.md §16:真实在播的长语音不被 30s 看门狗掐断)
|
||||
|
||||
realLog('== live round2 N1:长语音看门狗续约不掐(§16)==');
|
||||
{
|
||||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
const { audio, playLog, game } = makeRig();
|
||||
await audio.unlock();
|
||||
// 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待)
|
||||
audio._cache.set('female|voice/n00_intro', Promise.resolve({
|
||||
duration: 40.0, numberOfChannels: 1, sampleRate: 44100,
|
||||
getChannelData: () => new Float32Array(16),
|
||||
}));
|
||||
const warnBefore = consoleWarns.length;
|
||||
const t0 = now;
|
||||
game.start();
|
||||
await flush();
|
||||
assert(game.currentVoiceId === 'voice/n00_intro', 'N1:40s 长 intro 开播');
|
||||
// 越过 30s 看门狗阈值:voice 真在播 → 不掐、不续链、无告警
|
||||
await advance(() => now - t0 >= 30000);
|
||||
assert(game.currentVoiceId === 'voice/n00_intro' && audio.isVoicePlaying()
|
||||
&& game.nodeId === 'n00',
|
||||
'N1:30s 看门狗触发时 voice 真在播 → 不续链(仍停在 intro)');
|
||||
assert(!consoleWarns.slice(warnBefore).some((w) => w.includes('看门狗')),
|
||||
'N1:在播长语音不触发看门狗告警');
|
||||
// 自然播完才续链下一句(skip_hint 不再顶掉 intro 末句——冷启动末句被吃的根因)
|
||||
await advance(() => game.currentVoiceId === 'voice/skip_hint');
|
||||
assert(now - t0 >= 40000, `N1:intro 自然播完才续链 skip_hint(实际 ${now - t0}ms)`);
|
||||
assert(voicePlays(playLog, 'voice/skip_hint') === 1, 'N1:skip_hint 在 intro 播完后播出');
|
||||
await idle();
|
||||
assert(!game.voicePlaying && game.nodeId === 'n00', 'N1:长链正常走完进入等待态');
|
||||
}
|
||||
|
||||
// ================================================================ live round1 修复断言
|
||||
// (docs/design/13-playtest-criteria.md §10:结局双击重听 / 结局状态播报;
|
||||
// §10 分支台词口径经 §16 N2 翻案——钢索路径恢复播 n18_dog)
|
||||
|
||||
realLog('== live round1 修复断言(§10+§16:结局重听 / n18 两路径 / 结局状态)==');
|
||||
{
|
||||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
const { audio, playLog, game } = makeRig();
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await idle(); // n00 链播完
|
||||
|
||||
// 钢索路径推进到 n18:n01→n02(right→n04)→n05→n06→n07(right→n09)→n10(right→n14)→n15
|
||||
await g(game, 'tap'); await idle(); // → n01
|
||||
await g(game, 'tap'); await idle(); // → n02(抉择)
|
||||
await g(game, 'right'); await idle(); // → n04
|
||||
await g(game, 'tap'); await idle(); // → n05
|
||||
await g(game, 'tap'); await idle(); // → n06
|
||||
await g(game, 'tap'); await idle(); // → n07(抉择)
|
||||
await g(game, 'right'); await idle(); // → n09
|
||||
await g(game, 'tap'); await idle(); // → n10(抉择)
|
||||
await g(game, 'right'); await idle(); // → n14
|
||||
await g(game, 'tap'); await idle(); // → n15(抉择)
|
||||
await g(game, 'right'); await idle(); // 钢索 → n17(无涵洞经历)
|
||||
assert(game.nodeId === 'n17' && game.viaCulvert === false,
|
||||
'n15 右滑 → 钢索 n17(viaCulvert === false)');
|
||||
|
||||
// round2 N2(§10 P2-2 矫枉过正翻案):钢索路径恢复播 n18_dog 对峙戏 + p6
|
||||
await g(game, 'tap'); await idle(); // → n18
|
||||
assert(game.nodeId === 'n18' && voicePlays(playLog, 'voice/n18_dog') === 1
|
||||
&& voicePlays(playLog, 'voice/p6') === 1,
|
||||
'N2:钢索路径 n18 恢复播 n18_dog(对峙戏)+ 抉择提示 p6(known-issue:涵洞句轻微穿帮待 TTS 分支句)');
|
||||
|
||||
// 推进到结局 e2:n19→n20(left→n21)→e2
|
||||
await g(game, 'left'); await idle(); // n18 交坐标 → n19
|
||||
await g(game, 'tap'); await idle(); // → n20(抉择)
|
||||
await g(game, 'left'); await idle(); // → n21
|
||||
await g(game, 'tap'); await idle(); // → e2
|
||||
assert(game.nodeId === 'e2' && game.awaitingRestart === true, '到达结局 e2 进入结算态');
|
||||
|
||||
// P2-1 结局双击重听:结算态双击 → 结局语音重播;重播中单击 = 跳句不重开
|
||||
const e2before = voicePlays(playLog, 'voice/e2_price');
|
||||
await g(game, 'doubletap');
|
||||
assert(game.voicePlaying && game.currentVoiceId === 'voice/e2_price'
|
||||
&& voicePlays(playLog, 'voice/e2_price') === e2before + 1,
|
||||
'P2-1:结局结算态双击 = 重播结局语音');
|
||||
assert(game.awaitingRestart === false, 'P2-1:结局重播期间离开结算态(单击回到跳句语义)');
|
||||
await g(game, 'tap'); // 重播中单击 = 跳过本句
|
||||
assert(game.nodeId === 'e2', 'P2-1:结局重播中单击跳过本句,不触发重开');
|
||||
await idle();
|
||||
assert(game.awaitingRestart === true && !game.voicePlaying, 'P2-1:结局重播链播完回到结算态');
|
||||
|
||||
// P3-2 结局后状态播报:help 后播当前结局语音(不再报 status3),播完回结算态
|
||||
const e2again = voicePlays(playLog, 'voice/e2_price');
|
||||
await g(game, 'up');
|
||||
assert(game.currentVoiceId === 'voice/help', 'P3-2:结局后上滑先播 help');
|
||||
await idle();
|
||||
assert(voicePlays(playLog, 'voice/e2_price') === e2again + 1
|
||||
&& voicePlays(playLog, 'voice/status3') === 0,
|
||||
'P3-2:结局后状态播报 = 当前结局语音(不再报 status3)');
|
||||
assert(game.awaitingRestart === true && !game.voicePlaying,
|
||||
'P3-2:状态播完直接回结算态(不二次重播节点)');
|
||||
}
|
||||
|
||||
// ================================================================ 静默兜底(standard 档)
|
||||
// (docs/design/13-playtest-criteria.md §10:13s gd_continue / 30s gd_again / 手势重置)
|
||||
|
||||
realLog('== 静默兜底(guide=standard,§10 P2-3)==');
|
||||
{
|
||||
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
const { audio, playLog, game } = makeRig();
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => !game.voicePlaying); // n00 链播完(精确停链尾,不越过看门狗)
|
||||
assert(game.nodeId === 'n00' && !game.voicePlaying, 'standard:n00 链播完进入等待态');
|
||||
|
||||
const tArm = now;
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_continue') >= 1);
|
||||
assert(now - tArm === 13000, `standard:13s 无操作播 gd_continue(实际 ${now - tArm}ms)`);
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_again') >= 1);
|
||||
assert(now - tArm === 30000, `standard:30s 升级播 gd_again(实际 ${now - tArm}ms)`);
|
||||
await idle(); // 两级用尽彻底静默(无更多定时器)
|
||||
|
||||
// 玩家手势后重置:新节点播完重新挂载,13s 再次提示
|
||||
await g(game, 'tap'); // → n01
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(game.nodeId === 'n01' && !game.voicePlaying, 'standard:手势后进入 n01 等待态');
|
||||
const gdBefore = voicePlays(playLog, 'voice/gd_continue');
|
||||
const tWait = now;
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_continue') > gdBefore);
|
||||
assert(now - tWait === 13000, `standard:手势重置后新等待点 13s 再提示(实际 ${now - tWait}ms)`);
|
||||
settings.set({ guide: 'off' });
|
||||
}
|
||||
|
||||
// ================================================================ 抉择节点静默兜底
|
||||
// (docs/design/13-playtest-criteria.md §16 N3:25s/50s 重播 p* 提示、手势复位)
|
||||
|
||||
realLog('== 抉择节点静默兜底(guide=standard,§16 N3)==');
|
||||
{
|
||||
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
const { audio, playLog, game } = makeRig();
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => !game.voicePlaying); // n00 播完
|
||||
await g(game, 'tap'); // → n01
|
||||
await advance(() => !game.voicePlaying);
|
||||
await g(game, 'tap'); // → n02(抉择:n02_linwan + p1)
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:n02 抉择链播完,等待抉择');
|
||||
|
||||
const p1first = voicePlays(playLog, 'voice/p1');
|
||||
const tArm = now;
|
||||
await advance(() => voicePlays(playLog, 'voice/p1') > p1first);
|
||||
assert(now - tArm === 25000, `N3:抉择播完 25s 无操作重播 p1(实际 ${now - tArm}ms)`);
|
||||
await advance(() => voicePlays(playLog, 'voice/p1') > p1first + 1);
|
||||
assert(now - tArm === 50000, `N3:50s 再复述一次 p1(实际 ${now - tArm}ms)`);
|
||||
await idle(); // 两级用尽彻底静默
|
||||
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:复述期间剧情不推进、仍待抉择');
|
||||
|
||||
// 空点复位:抉择等待态单击(无进展手势)重置计时,再 25s 复述
|
||||
await g(game, 'tap');
|
||||
const p1again = voicePlays(playLog, 'voice/p1');
|
||||
const tTap = now;
|
||||
await advance(() => voicePlays(playLog, 'voice/p1') > p1again);
|
||||
assert(now - tTap === 25000, `N3:空点后计时重置,25s 再复述(实际 ${now - tTap}ms)`);
|
||||
|
||||
// 玩家抉择后兜底终止:右滑 → n04,此后不再复述 p1
|
||||
await g(game, 'right');
|
||||
await advance(() => !game.voicePlaying);
|
||||
assert(game.nodeId === 'n04', 'N3:右滑抉择 → n04 剧情推进');
|
||||
const p1final = voicePlays(playLog, 'voice/p1');
|
||||
await idle();
|
||||
assert(voicePlays(playLog, 'voice/p1') === p1final, 'N3:抉择后兜底终止(不再复述)');
|
||||
settings.set({ guide: 'off' });
|
||||
}
|
||||
|
||||
// ================================================================ 资产引用校验
|
||||
|
||||
realLog('== 资产引用校验 ==');
|
||||
|
||||
Reference in New Issue
Block a user