round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)
This commit is contained in:
+94
-29
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* 向导层无头测试(web/harness/guide.test.mjs)。
|
||||
*
|
||||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为:
|
||||
* ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音
|
||||
* (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input
|
||||
* 10s → gd_relisten、25s → 免扣机会自动重听一次;
|
||||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏 + Guide 直连断言:
|
||||
* ① 看门狗礼仪(round2 F1):storyline 进入等待态 25s → nudge + gd_continue,
|
||||
* 50s 升级只重申手势(gd_again),**不再重播 help/进度/当前节点**,两级用尽
|
||||
* 彻底静默;simon input 10s → gd_relisten,25s 升级 = confirm 震 + turn
|
||||
* earcon 纯标记(**取消自动重播序列**);
|
||||
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
|
||||
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
|
||||
* ④ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||||
* ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(事件带 skipped:true、
|
||||
* 不入队),空闲时正常播;turn 语音保持 queue 不受影响;
|
||||
* ⑤ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||||
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/harness/guide.test.mjs
|
||||
@@ -143,6 +146,7 @@ globalThis.fetch = async (u) => {
|
||||
|
||||
const { WebAudioEngine } = await import('../engine/audio.js');
|
||||
const { settings } = await import('../engine/settings.js');
|
||||
const { Guide } = await import('../engine/guide.js');
|
||||
const { StorylineGame } = await import('../storyline/game.js');
|
||||
const { SimonGame } = await import('../simon/game.js');
|
||||
|
||||
@@ -172,44 +176,50 @@ const waitChainDone = (game) => advance(() => !game.voicePlaying);
|
||||
|
||||
// ================================================================ ① storyline 看门狗
|
||||
|
||||
realLog('== ① storyline:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
|
||||
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
|
||||
{
|
||||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await waitChainDone(game); // n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
|
||||
// F8(round2):开场链最前插 gd_brief(brief→intro)
|
||||
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'F8:开场链最前播 gd_brief');
|
||||
assert(playLog.find((e) => e.id.startsWith('voice/'))?.id === 'voice/gd_brief',
|
||||
'F8:gd_brief 是本局第一句语音');
|
||||
await waitChainDone(game); // gd_brief + n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
|
||||
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
|
||||
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
|
||||
|
||||
const tArm = now;
|
||||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||||
assert(now - tArm === 8000, `8s 后 nudge 触发(实际 ${now - tArm}ms)`);
|
||||
assert(now - tArm === 25000, `25s 后 nudge 触发(实际 ${now - tArm}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
|
||||
'看门狗第 1 级发 gd_continue 语音事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
|
||||
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
|
||||
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/help'));
|
||||
assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`);
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/status1'));
|
||||
assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)');
|
||||
assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)');
|
||||
await advance(() => events.filter((e) => e.type === 'watchdog').length >= 2);
|
||||
assert(now - tArm === 50000, `50s 升级触发(实际 ${now - tArm}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_again'),
|
||||
'升级只发 gd_again(重申手势)语音事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_again') === 1, 'gd_again 语音播放');
|
||||
// F1.1:升级不再重播 help + 进度 + 当前节点
|
||||
assert(!playLog.some((e) => e.id === 'voice/help' || e.id.startsWith('voice/status')),
|
||||
'F1:升级不重播 help/进度');
|
||||
assert(voicePlays(playLog, 'voice/n00_intro') === 1, 'F1:升级不重播当前节点(n00_intro 仅开场一次)');
|
||||
|
||||
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
|
||||
// 两级用尽彻底静默:链走完后无任何向导定时器 → idle
|
||||
await idle();
|
||||
const nudges = events.filter((e) => e.type === 'nudge').length;
|
||||
await idle();
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudges, '两级用尽彻底静默(无后续提醒)');
|
||||
|
||||
// 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次
|
||||
await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped)
|
||||
await waitChainDone(game);
|
||||
await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 n01
|
||||
// 玩家手势后重新挂载:n01 链播完重新挂上,且 25s 节奏到点触发一次
|
||||
await gesture(game, 'tap'); // → n01,链播
|
||||
await waitChainDone(game);
|
||||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
const tWait = now;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
|
||||
assert(now - tWait === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${now - tWait}ms)`);
|
||||
assert(now - tWait === 25000, `重新挂载后仍按 25s 节奏触发(实际 ${now - tWait}ms)`);
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
@@ -251,18 +261,28 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
|
||||
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
|
||||
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
|
||||
|
||||
// ①b input 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听
|
||||
// ①b input 看门狗:10s nudge+gd_relisten
|
||||
const tInput = now;
|
||||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||||
assert(now - tInput === 10000, `input 10s 后 nudge 触发(实际 ${now - tInput}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
|
||||
'input 10s 无输入发 gd_relisten 事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
|
||||
assert(game.chances === 1, '看门狗提示不消耗重听机会');
|
||||
|
||||
const chancesBefore = game.chances;
|
||||
await advance(() => game.phase === 'playback');
|
||||
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
|
||||
assert(game.chances === chancesBefore, '自动重听不扣机会');
|
||||
await advance(() => game.phase === 'input');
|
||||
// 25s 升级(round2 F1.2):取消自动重播序列 → 只发 confirm 震 + turn earcon(纯标记)
|
||||
const nudgesBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
const turnsBefore = events.filter((e) => e.type === 'turn').length;
|
||||
const vibBefore = vibrateLog.length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgesBefore);
|
||||
assert(now - tInput === 25000, `25s 升级触发(实际 ${now - tInput}ms)`);
|
||||
assert(game.phase === 'input' && game.round === 1, '升级不再自动重播序列(仍停在 input 等玩家)');
|
||||
assert(events.filter((e) => e.type === 'turn').length === turnsBefore + 1, '升级发 turn earcon 事件(纯标记)');
|
||||
assert(vibrateLog.slice(vibBefore).some((p) => JSON.stringify(p) === JSON.stringify([20])),
|
||||
'升级发 confirm 轻震');
|
||||
assert(voicePlays(playLog, 'voice/round_01') === 1, '序列未重播(round_01 仍只有第 1 轮一次)');
|
||||
await idle(); // 级数用尽:看门狗自动停止
|
||||
assert(game.phase === 'input', '升级后仍等待输入(免费泄题已废除)');
|
||||
|
||||
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
|
||||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
@@ -271,13 +291,58 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
|
||||
await advance(() => game.phase === 'input' && game.round === 2);
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
|
||||
'答题手势复位看门狗:第 2 轮开启前无 nudge');
|
||||
assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度');
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ④ guide=off 全静
|
||||
// ================================================================ ④ F1.4 idle-only
|
||||
|
||||
realLog('== ④ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
|
||||
realLog('== ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(skipped 事件、不入队),turn 保持 queue ==');
|
||||
{
|
||||
currentGameDir = GAME_DIRS.StorylineGame;
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
const playLog = [];
|
||||
const origPlay = audio.play.bind(audio);
|
||||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
|
||||
await audio.unlock();
|
||||
const guide = new Guide(audio);
|
||||
const events = [];
|
||||
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||||
|
||||
// watchdog:对白在播时到点 → 只发 skipped 事件,语音不入队
|
||||
guide.watchdog({ arm: true, at: [1000], say: ['gd_continue'] });
|
||||
audio.play('voice/n00_intro'); // 长语音占住通道(mock 句长 2s)
|
||||
await flush();
|
||||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||||
await advance(() => events.some((e) => e.type === 'watchdog'));
|
||||
const wd = events.find((e) => e.type === 'watchdog');
|
||||
assert(wd && wd.voiceId === 'gd_continue' && wd.skipped === true,
|
||||
'watchdog 语音被 idle-skip(事件带 skipped:true)');
|
||||
assert(voicePlays(playLog, 'voice/gd_continue') === 0, 'watchdog 语音未入队播放');
|
||||
|
||||
// error:对白在播 → 同规则只发事件;空闲 → 正常播放
|
||||
guide.error({ voice: 'gd_invalid' });
|
||||
assert(events.some((e) => e.type === 'error' && e.skipped === true),
|
||||
'error 语音对白在播时跳过(skipped 事件)');
|
||||
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'error 语音未入队播放');
|
||||
await advance(() => !audio.isVoicePlaying()); // 等在播对白自然结束
|
||||
guide.error({ voice: 'gd_invalid' });
|
||||
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '空闲时 error 语音正常播放');
|
||||
assert(events.filter((e) => e.type === 'error' && !e.skipped).length === 1,
|
||||
'空闲播放的 error 事件不带 skipped 标记');
|
||||
|
||||
// turn:保持 queue,不受 idle-only 影响(对白在播时照常排队)
|
||||
audio.play('voice/n01_wake');
|
||||
await flush();
|
||||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||||
guide.turn({ voice: 'gd_answer' });
|
||||
assert(voicePlays(playLog, 'voice/gd_answer') === 1, 'turn 语音不受 idle-only 影响(照常入队)');
|
||||
assert(events.some((e) => e.type === 'turn'), 'turn 事件照常发出');
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑤ guide=off 全静
|
||||
|
||||
realLog('== ⑤ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
|
||||
{
|
||||
settings.set({ guide: 'off' });
|
||||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function session(ctx, mock) {
|
||||
ctx.silence(3, '辨了辨方向');
|
||||
await move('up');
|
||||
await move('up');
|
||||
await move('up', '北边是墙,撞了一下');
|
||||
await move('up', '北边是墙,撞了一下——「那边是墙」后面还跟了句方向(向上推的是北墙)');
|
||||
await move('up', '又撞了一下——听到游戏主动来问我了');
|
||||
await ctx.waitVirtual(5200); // 等卡关轮询的 5s tick:连续撞墙 → 提前一级的暗示
|
||||
|
||||
|
||||
@@ -40,15 +40,16 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitCond(() => game.phase === 'explore');
|
||||
ctx.status('进村了,先听场景描述和选项');
|
||||
|
||||
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈再对准「去小路」出发
|
||||
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈,回绕到首项再出发。
|
||||
// 确认(tap)前必须等选项名念完——voice 播放中单击是「跳过本句」,会把手势吃掉
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1800); // 听选项名
|
||||
}
|
||||
ctx.status('选项听了一圈,往林间小路去');
|
||||
await ctx.waitIdle(); // 选项名播完(无 interval,可安全等待空闲)
|
||||
await ctx.gesture('right'); // 转回首个选项「去小路」
|
||||
await ctx.waitVirtual(1500);
|
||||
ctx.status('选项听了一圈');
|
||||
await ctx.gesture('right'); // 从末项回绕到首个选项——听到「回到第一个」(round2 F7)
|
||||
ctx.note('滑到头再滑就兜回来了,有人提醒「回到第一个」,不用自己数');
|
||||
await ctx.waitCond(() => !ctx.voiceBusy()); // 选项名 + 回绕提示念完
|
||||
await ctx.gesture('tap'); // → path
|
||||
|
||||
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
|
||||
@@ -57,17 +58,18 @@ export async function session(ctx, mock) {
|
||||
await ctx.gesture('right'); // 调查四周
|
||||
await ctx.waitVirtual(1800);
|
||||
ctx.note('翻找了一阵,捡到一点钱');
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(2400);
|
||||
await ctx.waitCond(() => !ctx.voiceBusy()); // 捡钱播报念完
|
||||
await ctx.gesture('left'); // 回「去岔路口」
|
||||
await ctx.waitVirtual(1500);
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap'); // → fork
|
||||
|
||||
// 岔路口(去枯树洞/去溪流/返回):右滑一次到「去溪流」
|
||||
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'fork');
|
||||
ctx.status('岔路口,水声在东边');
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1500);
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
await ctx.gesture('tap'); // go_stream → 溪流 → 触发教学战
|
||||
|
||||
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
|
||||
@@ -95,10 +97,10 @@ export async function session(ctx, mock) {
|
||||
ctx.note('单击出手,攻击');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// T2:格挡(前摇窗口内双击)
|
||||
// T2:格挡(前摇窗口内双击)——挡住会先听到「挡得漂亮」(round2 F4)再进防御教学
|
||||
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
|
||||
ctx.status('前摇声响起了');
|
||||
ctx.note('双击格挡');
|
||||
ctx.note('双击格挡——这回干净挡下了,听到「挡得漂亮」');
|
||||
await ctx.gesture('doubletap');
|
||||
|
||||
// T3:防御一次
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
|
||||
* 第 1 轮答对、第 2 轮中途忘序列僵住 11 秒(触发向导「双击可重听」提示)、
|
||||
* 第 3 轮故意答错第一下(消耗机会自动重播),然后答对完成第 3 轮收工。
|
||||
* 收尾听完机会经济句(免费重听两次、用完扣机会);第 1 轮直接答对;第 2 轮开局
|
||||
* 忘序列僵住 10 秒,听到向导「双击可以重新听」提示后 5 秒内照做——用掉一次免费
|
||||
* 重听(round2:25s 升级已不再自动重播序列,坐着等答案重念的路子废了);第 3 轮
|
||||
* 凭记忆答对,在**整轮边界**收工(round2 剧本修正:不在轮中退出——round1 转写
|
||||
* 「第 4 轮报数后再没人说话」与「干晾 85 秒」都是剧本失真,不是游戏失真)。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
|
||||
@@ -14,7 +17,7 @@ export function describe(game) {
|
||||
case 'announce': return `第 ${game.round} 轮报数呢,听好了`;
|
||||
case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`;
|
||||
case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势`;
|
||||
case 'roundend': return game.chances < (game.round > 4 ? 2 : 1) ? '答错了,正在重播刚才的序列' : '刚答完,播报中';
|
||||
case 'roundend': return '刚答完一轮,播报中';
|
||||
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
|
||||
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
|
||||
default: return '开场,稍等一下';
|
||||
@@ -35,36 +38,50 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
}
|
||||
await ctx.gesture(G[i]); // 照指令做对
|
||||
await ctx.waitIdle();
|
||||
if (i === G.length - 1) {
|
||||
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 input 就接手,
|
||||
// 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源)
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'
|
||||
|| game.phase === 'gameover');
|
||||
} else {
|
||||
await ctx.waitIdle();
|
||||
}
|
||||
}
|
||||
ctx.status('教学讲完了,机会经济也交代清楚了:免费重听两次,用完要扣机会');
|
||||
|
||||
// 正式对局:打到完成第 3 轮
|
||||
let wrongDone = false;
|
||||
let stallDone = false;
|
||||
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover' && game.round < 4) {
|
||||
// 正式对局:完整打完第 3 轮,在轮边界收工
|
||||
let relistenDone = false;
|
||||
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover') {
|
||||
if (game.phase === 'input') {
|
||||
// 第 2 轮开局先失忆僵住 11 秒:触发向导「双击可以重新听一遍序列」提示
|
||||
if (game.round === 2 && game.inputIdx === 0 && !stallDone) {
|
||||
stallDone = true;
|
||||
// 第 2 轮先失忆僵住 10 秒:听向导「双击可以重新听一遍序列」,5 秒内照做。
|
||||
//(期望输入恰为双击时先答这一下,下一步再演示重听——双击语境判定)
|
||||
if (game.round === 2 && !relistenDone
|
||||
&& game.inputIdx <= 1 && game.sequence[game.inputIdx] !== 'doubletap') {
|
||||
relistenDone = true;
|
||||
ctx.status('序列有点长,先僵一会儿');
|
||||
ctx.silence(11, '想不起来序列了');
|
||||
await ctx.waitVirtual(11000);
|
||||
ctx.silence(10, '想不起来序列了');
|
||||
await ctx.waitVirtual(10600); // 10s 看门狗:nudge + gd_relisten
|
||||
ctx.status('听到提示:需要的话,双击可以重新听一遍序列');
|
||||
}
|
||||
// 第 2 轮第二拍故意答错:触发"用一次机会,再听一遍"(复活重听)
|
||||
if (game.round === 2 && game.inputIdx === 1 && !wrongDone) {
|
||||
wrongDone = true;
|
||||
ctx.note('这一下故意点错,听听游戏怎么说');
|
||||
await ctx.gesture(G[(G.indexOf(game.sequence[game.inputIdx]) + 1) % G.length]);
|
||||
await ctx.waitIdle(); // retry_use + 自动重播同一序列
|
||||
ctx.note('照提示双击,用掉一次免费重听');
|
||||
await ctx.gesture('doubletap');
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
|
||||
continue;
|
||||
}
|
||||
ctx.status('轮到我复现了');
|
||||
if (game.round === 3 && game.inputIdx === game.sequence.length - 1) {
|
||||
ctx.status('最后一轮最后一个音,打完这轮就收');
|
||||
} else {
|
||||
ctx.status('轮到我复现了');
|
||||
}
|
||||
await ctx.gesture(game.sequence[game.inputIdx]);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitIdle();
|
||||
// 整轮边界收尾:第 3 轮答完(roundend),不等第 4 轮报数
|
||||
if (game.phase === 'roundend' && game.round === 3) break;
|
||||
// announce/playback/roundend:等阶段推进即可,不裸 waitIdle(防吞掉 input 窗口)
|
||||
const ph = game.phase;
|
||||
const r = game.round;
|
||||
await ctx.waitCond(() => game.phase !== ph || game.round !== r);
|
||||
}
|
||||
|
||||
ctx.status(`收工:完成了 ${game.round - 1} 轮`);
|
||||
ctx.status(`收工:完整打完 ${game.round} 轮,在轮与轮的边界停手(免费重听剩 ${game.chances} 次)`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
|
||||
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏一颗
|
||||
* (触发归因句 + 向导「同向滑动就能拨开」指令)→ 再正常躲几颗收工。
|
||||
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏两颗
|
||||
* (第 1 次听归因+向导「同向滑动就能拨开」,第 2 次听 gd_last_life「最后一次机会」
|
||||
* ——round2 F2.2:受击经济透明)→ 之后全部躲开,保护期结束后再撑 20 秒以上收工
|
||||
* (不再像 round1 那样在保护期后 8 秒就撤,也没有第三次被撞)。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
|
||||
@@ -45,46 +47,55 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
ctx.status('演练做完了,要出发了');
|
||||
|
||||
// 正式对局:保护期 60s + 结束后再躲一小段就收工(不再模拟,避免挂机撞毁)。
|
||||
// 每颗陨石生成(呼啸声起)就立即同向拨开——比盯着 1.5s 判定窗口更稳。
|
||||
// 正式对局:保护期 60s + 结束后再撑 20s 以上收工。
|
||||
// 每颗陨石生成(呼啸声起)就在判定窗口将开时同向拨开——比盯着 1.5s 判定窗口更稳。
|
||||
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
|
||||
await ctx.waitCond(() => game.phase === 'playing');
|
||||
ctx.status('出发。引擎声起来了,保护期里先练手');
|
||||
const dodged = new Set();
|
||||
let victim = null; // 保护期结束后故意不躲的那颗
|
||||
let eaten = false;
|
||||
const victims = []; // 保护期结束后故意不躲的(前两颗:听归因 + gd_last_life)
|
||||
let eaten = 0;
|
||||
while ((game.phase === 'playing' || game.phase === 'feature')
|
||||
&& performance.now() < game.protectionUntil + 8000) {
|
||||
&& performance.now() < game.protectionUntil + 21000) {
|
||||
if (game.phase === 'feature') {
|
||||
ctx.status('游戏停下来教新功能');
|
||||
await ctx.waitIdle(); // 引入句听完(feature 态无刷怪链,可安全等待空闲)
|
||||
// 引入句听完就照做(不裸 waitIdle——feature 态之后的刷怪链是自驱动定时器,
|
||||
// 裸等会把整局对局连本带利烧穿到坠毁)
|
||||
await ctx.waitCond(() => !ctx.voiceBusy());
|
||||
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
|
||||
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
|
||||
continue;
|
||||
}
|
||||
if (victim && !game.obstacles.has(victim)) {
|
||||
ctx.note('被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
|
||||
victim = null;
|
||||
// 被撞的那颗消失 → 归因链听完了,继续
|
||||
const eatenIdx = victims.findIndex((v) => !game.obstacles.has(v));
|
||||
if (eatenIdx >= 0) {
|
||||
victims.splice(eatenIdx, 1);
|
||||
ctx.note(eaten === 2 && victims.length === 0
|
||||
? '第二次被撞:先听方位归因,又听到「这是最后一次机会,撑住」——这就是全部家底了'
|
||||
: '被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
|
||||
continue;
|
||||
}
|
||||
// 只在判定窗口将开未开时出手(警报第一响 = 撞击前 1s,窗口内)
|
||||
const target = [...game.obstacles].find((o) => !dodged.has(o)
|
||||
&& o.impactAt - performance.now() < 1300);
|
||||
if (target) {
|
||||
if (!eaten && performance.now() >= game.protectionUntil) {
|
||||
eaten = true;
|
||||
victim = target;
|
||||
if (eaten < 2 && performance.now() >= game.protectionUntil) {
|
||||
eaten++;
|
||||
victims.push(target);
|
||||
dodged.add(target); // 标记 = 故意不躲
|
||||
ctx.status('保护期结束了,这颗我故意没动');
|
||||
ctx.status(eaten === 1
|
||||
? '保护期结束了,这颗我故意没动'
|
||||
: '又有一颗,也故意不躲——想听听第二次被撞会说什么');
|
||||
continue;
|
||||
}
|
||||
dodged.add(target);
|
||||
await ctx.gesture(target.dir);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitCond(() => (game.phase !== 'playing' && game.phase !== 'feature')
|
||||
|| (victim && !game.obstacles.has(victim))
|
||||
await ctx.waitCond(() => game.phase !== 'playing'
|
||||
|| victims.some((v) => !game.obstacles.has(v))
|
||||
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() < 1300));
|
||||
}
|
||||
ctx.status('躲过保护期、挨过一次撞,心跳有点快,先到这');
|
||||
const aliveSec = Math.round((performance.now() - game.runStart) / 1000);
|
||||
ctx.status(`保护期结束后撑了 ${aliveSec - 60} 秒、挨了两下还活着(警报也只起一波了),先到这`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗)、
|
||||
* 在非抉择节点试一次无效的左滑(触发向导纠错)、菜单进去逛一圈再出来、
|
||||
* 抉择点一律选左,一路打到结局。
|
||||
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗,
|
||||
* 听到轻响后 5 秒内回应——round2 修正剧本失真:round1 的「干晾 85 秒」是剧本
|
||||
* 问题不是游戏问题)、在非抉择节点试一次无效的左滑(触发向导纠错)、中途演示
|
||||
* 一次语速 2.2 切换(F9:设置下一句即时生效,转写状态行可见语速档)、菜单逛
|
||||
* 一圈再出来、抉择点一律选左,一路打到结局。
|
||||
*/
|
||||
export function describe(game) {
|
||||
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
|
||||
@@ -15,17 +17,21 @@ export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
let speedDemoDone = false;
|
||||
let speedBackDone = false;
|
||||
|
||||
// 开场:n00 全听完,愣一会儿——向导看门狗先说话
|
||||
await ctx.waitIdle();
|
||||
ctx.status('开场旁白播完了,屏幕黑着,有点不知所措');
|
||||
ctx.silence(8, '新手没反应过来');
|
||||
await ctx.waitVirtual(8600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻响和「轻触屏幕,继续」,试着点一下');
|
||||
await ctx.gesture('tap');
|
||||
// 开场:gd_brief 先给操作口令(round2 F8),n00 全听完,再愣一会儿——看门狗 25s 才说话
|
||||
await ctx.waitCond(() => !game.voicePlaying); // 只等链播完(不空烧看门狗窗口)
|
||||
ctx.status('开场口令和旁白播完了,屏幕黑着,走神想剧情去了');
|
||||
ctx.silence(25, '在入戏,不是迷路(round2:剧情类等待默认低频)');
|
||||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻响和「轻触屏幕,继续」,马上点一下');
|
||||
await ctx.gesture('tap'); // 听到提示 ≤5s 内反应
|
||||
|
||||
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
|
||||
await ctx.waitIdle(); // 等当前语音链播完(真人不会抢话)
|
||||
// 只等语音链播完(真人不会抢话)。不能裸 waitIdle——会把等待态看门狗的两级
|
||||
// 提醒整段空烧完才动手(round1 剧本失真根源),链一停就该轮到玩家了
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
|
||||
if (game.awaitingRestart) break;
|
||||
|
||||
@@ -45,6 +51,27 @@ export async function session(ctx, mock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// F9:演示一次语速切换——2.2 档对下一句即时生效,听两段再调回 1.0
|
||||
if (!speedDemoDone && game.nodeId === 'n10') {
|
||||
speedDemoDone = true;
|
||||
ctx.note('旁白有点慢,去设置把语速调到 2.2(下一句生效)');
|
||||
ctx.settings.set({ speed: 2.2 });
|
||||
if (game.choiceArmed) {
|
||||
ctx.note('选了左边(循声而去)——后面几步都是 2.2 档');
|
||||
await ctx.gesture('left'); // n10 抉择 → n11,2.2 倍速播
|
||||
} else {
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
await ctx.waitIdle();
|
||||
ctx.status('语速 2.2 听着赶趟');
|
||||
continue;
|
||||
}
|
||||
if (speedDemoDone && !speedBackDone && game.nodeId === 'n12') {
|
||||
speedBackDone = true;
|
||||
ctx.note('听了两段,把语速调回 1.0');
|
||||
ctx.settings.set({ speed: 1.0 });
|
||||
}
|
||||
|
||||
if (game.choiceArmed) {
|
||||
ctx.status('抉择点,提示音念完了');
|
||||
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
|
||||
@@ -59,19 +86,19 @@ export async function session(ctx, mock) {
|
||||
invalidTried = true;
|
||||
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitIdle();
|
||||
await ctx.waitVirtual(2000); // 让纠错句播完(不空烧看门狗窗口)
|
||||
} else if (i % 4 === 2) {
|
||||
ctx.silence(8, '走神想剧情去了');
|
||||
await ctx.waitVirtual(8600); // 偶尔挂机,触发看门狗轻提醒
|
||||
ctx.status('又听到那声轻响,想起该点屏幕了');
|
||||
ctx.silence(25, '又走神想剧情去了');
|
||||
await ctx.waitVirtual(25600); // 偶尔挂机,触发看门狗轻提醒(25s 才响,不轰炸)
|
||||
ctx.status('又听到那声轻响,5 秒内想起该点屏幕了');
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
|
||||
await ctx.waitIdle();
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('结局');
|
||||
ctx.note('听完结局,单击重开一次看看别的走向');
|
||||
await ctx.gesture('tap'); // 结算态单击重开
|
||||
await ctx.waitIdle();
|
||||
await ctx.waitCond(() => !game.voicePlaying);
|
||||
ctx.status('回到开头,会话结束');
|
||||
}
|
||||
|
||||
@@ -93,7 +93,11 @@ function capture(audio) {
|
||||
pending.push(`${fmtT()} ♫ BGM 切换 → ${id}${opts?.fadeMs ? `(淡入 ${opts.fadeMs}ms)` : ''}`);
|
||||
}
|
||||
} else if (isVoice && id.startsWith('voice/gd_')) {
|
||||
// 向导语音经事件钩子渲染(▶ 向导语音),此处不重复记行
|
||||
// 向导语音(round2):实际播出的在本行渲染(含全文);被 idle-skip 跳过的
|
||||
// 不产生 play 调用,由事件钩子单独标注(见 captureGuide)
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 voice/${id.slice(6)}(无台词表文本)`);
|
||||
} else if (isVoice) {
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 语音:「${t}」`
|
||||
@@ -105,15 +109,17 @@ function capture(audio) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 向导实例接入事件流(每个 earcon/插播语音都转写成玩家可感知的一行)。 */
|
||||
/** 向导实例接入事件流(每个 earcon / 被 idle-skip 的语音都转写成玩家可感知的一行;
|
||||
* 实际播出的向导语音已由 audio.play 捕获渲染,此处不再重复记行)。 */
|
||||
function captureGuide(game) {
|
||||
if (!game.guide) return;
|
||||
game.guide.setEventHook((evt) => {
|
||||
if (evt.voiceId) {
|
||||
if (!evt.skipped) return; // 播出的由 capture() 渲染,避免重复
|
||||
const t = textOf(`voice/${evt.voiceId}`);
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(无台词表文本)`);
|
||||
? `${fmtT()} ▶ 向导语音(对白在播,本句按 idle-only 跳过未播):「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(idle-only 跳过未播)`);
|
||||
} else {
|
||||
const label = GUIDE_LABEL[evt.name] || '';
|
||||
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
|
||||
@@ -122,8 +128,11 @@ function captureGuide(game) {
|
||||
}
|
||||
|
||||
function drain() {
|
||||
// 震动/资产缺失按「实际触发时刻」落行(v2 起不再用 drain 时刻近似——
|
||||
// round2 审计指标要核 danger 震 ≥400ms 去抖、警报 600ms 去重,必须看真实时间)
|
||||
for (const m of mock.vibrateLog.splice(0)) {
|
||||
pending.push(`${fmtT()} 〰 震动:${hapticName(m.pattern)}`);
|
||||
const t = `t+${(m.t / 1000).toFixed(1).padStart(6)}s`;
|
||||
pending.push(`${t} 〰 震动:${hapticName(m.pattern)}`);
|
||||
}
|
||||
// 引擎按 ogg→m4a→mp3→wav、性别目录→平铺 逐个探测;只有全部路径都 404,
|
||||
// 玩家才是真的什么都没听到(角色音探测性别目录 404 后平铺成功属正常回退)
|
||||
@@ -131,7 +140,7 @@ function drain() {
|
||||
const key = m.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/');
|
||||
const everOk = mock.fetchedLog.some((f) => f.ok
|
||||
&& f.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/') === key);
|
||||
if (!everOk) pending.push(`${fmtT()} ⚠ 资产缺失 ${key} → 玩家此刻听到的是静默`);
|
||||
if (!everOk) pending.push(`t+${(m.t / 1000).toFixed(1).padStart(6)}s ⚠ 资产缺失 ${key} → 玩家此刻听到的是静默`);
|
||||
}
|
||||
pending.sort((a, b) => parseFloat(a.match(/t\+(\s*)(\d+)/)?.[2] ?? 0) - parseFloat(b.match(/t\+(\s*)(\d+)/)?.[2] ?? 0));
|
||||
lines.push(...pending);
|
||||
@@ -164,7 +173,8 @@ function makeCtx(audio, game, describe) {
|
||||
status: (tag) => {
|
||||
const d = describe ? describe(game) : '';
|
||||
const v = voiceBusy(audio) ? '语音播放中' : (audio._bgm ? `BGM=${audio._bgm?.id ?? '无'}` : '无 BGM');
|
||||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v})`);
|
||||
// 语速档随状态行输出(round2 F9:转写需可见语速切换生效)
|
||||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v},语速${settings.get().speed})`);
|
||||
},
|
||||
silence: (sec, why) => {
|
||||
lines.push(`${fmtT()} ……… 静默 ${sec}s${why ? `(${why})` : ',游戏在等待玩家输入'} ………`);
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_continue": "轻触屏幕,继续。",
|
||||
"gd_invalid": "这个操作在这里用不上。",
|
||||
"gd_brief": "单击继续剧情。请选择的时候,左右滑动。",
|
||||
"gd_again": "还在吗?单击继续。",
|
||||
"n02_linwan": "咳……咳咳……我还活着。晨晨,你在哪儿……你说过,会在北区等我。姐一定,一定找到你。",
|
||||
"n03_radio": "收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……",
|
||||
"n03_msg": "姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。",
|
||||
@@ -199,7 +201,8 @@
|
||||
"menu_home": "回大厅。",
|
||||
"menu_back": "返回游戏。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_relisten": "需要的话,双击可以重新听一遍序列。"
|
||||
"gd_relisten": "需要的话,双击可以重新听一遍序列。",
|
||||
"gd_economy": "记不住就双击重新听,免费机会有两次,用完要扣一次机会。"
|
||||
},
|
||||
"rhythm": {
|
||||
"welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。",
|
||||
@@ -235,7 +238,10 @@
|
||||
"menu_lobby": "回大厅。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_answer": "现在跟着我打。",
|
||||
"gd_miss": "慢了半拍,下一小节跟上我就行。"
|
||||
"gd_miss": "慢了半拍,下一小节跟上我就行。",
|
||||
"gd_miss_early": "抢拍了。等声音落下再滑。",
|
||||
"gd_miss_late": "慢了半拍。跟着节拍走。",
|
||||
"gd_miss_wrong": "方向不对。跟着音色滑。"
|
||||
},
|
||||
"space": {
|
||||
"drill_welcome": "欢迎登上盲航。你只有一双耳朵。",
|
||||
@@ -291,7 +297,8 @@
|
||||
"settings_hint": "音色、语速、背景音乐,请回大厅修改。",
|
||||
"paused": "已暂停。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。"
|
||||
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。",
|
||||
"gd_last_life": "这是最后一次机会,撑住。"
|
||||
},
|
||||
"rpg": {
|
||||
"intro_1": "你的眼睛看不见了。但你的耳朵,比谁都灵。",
|
||||
@@ -414,6 +421,9 @@
|
||||
"boss1_warn": "里面就是黑风寨主。你现在回去练练再来,也来得及。",
|
||||
"gd_parry_miss": "前摇声起的时候双击,才能格挡。",
|
||||
"gd_stall": "想好了吗?左右滑动选择,单击出手。",
|
||||
"tut_b2b": "挡得漂亮。",
|
||||
"gd_wrap_first": "回到第一个。",
|
||||
"gd_wrap_last": "最后一个了。",
|
||||
"npc_headman_1": "外乡人,林子里不太平。这五十金,拿着防身。",
|
||||
"npc_headman_2": "商店就在村里,缺什么就去看看。",
|
||||
"npc_headman_3": "听说你是用耳朵走路的剑客?保重啊。",
|
||||
|
||||
Reference in New Issue
Block a user