round4: H1 escape目标带实时方位 + H2 simon轮次占用预告(根治手势两义) + H3 重听账目真实化(诊断=初始发放1次vs承诺2次) + H4 input看门狗5s + H5 归因真话(gd_miss_none) + 20语音 + guide120断言全绿
This commit is contained in:
+190
-3
@@ -25,6 +25,7 @@ const GAME_DIRS = {
|
||||
SimonGame: path.join(ROOT, 'web', 'simon'),
|
||||
RhythmGame: path.join(ROOT, 'web', 'rhythm'),
|
||||
SpaceGame: path.join(ROOT, 'web', 'space'),
|
||||
EscapeGame: path.join(ROOT, 'web', 'escape'),
|
||||
};
|
||||
let currentGameDir = GAME_DIRS.StorylineGame;
|
||||
|
||||
@@ -169,6 +170,7 @@ const { StorylineGame } = await import('../storyline/game.js');
|
||||
const { SimonGame } = await import('../simon/game.js');
|
||||
const { RhythmGame } = await import('../rhythm/game.js');
|
||||
const { SpaceGame } = await import('../space/game.js');
|
||||
const { EscapeGame } = await import('../escape/game.js');
|
||||
|
||||
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
|
||||
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
@@ -275,6 +277,12 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
|
||||
await advance(() => game.teachAwait && game.phase === 'tutorial');
|
||||
await gesture(game, g);
|
||||
}
|
||||
// round4 H2/H4 前提锚定:announce 时点固定第 1 轮序列为无 up/doubletap 的两音
|
||||
//(guide.test 的 Math.random 是真随机——不固定则 gd_relisten 断言会随占用预告
|
||||
// 动态置空而偶发跳过;固定后本块行为与 round3 基线一致)
|
||||
await advance(() => game.phase === 'announce');
|
||||
game.sequence[0] = 'down';
|
||||
game.sequence[1] = 'tap';
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.round === 1, '教学走完进入第 1 轮 input');
|
||||
|
||||
@@ -283,12 +291,12 @@ 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
|
||||
// ①b input 看门狗:round4 H4 首级 10s→5s,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(now - tInput === 5000, `H4:input 首级 5s 后 nudge 触发(实际 ${now - tInput}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
|
||||
'input 10s 无输入发 gd_relisten 事件');
|
||||
'input 5s 无输入发 gd_relisten 事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
|
||||
assert(game.chances === 1, '看门狗提示不消耗重听机会');
|
||||
|
||||
@@ -599,6 +607,185 @@ realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑩ H1~H5(round4)
|
||||
|
||||
realLog('== ⑩a H2/H3/H4 simon:占用预告三句、被占用轮无重听建议、5s 看门狗、余量句 ==');
|
||||
async function driveSimonToAnnounce(rig) {
|
||||
const { audio, game } = rig;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
|
||||
await advance(() => game.teachAwait && game.phase === 'tutorial');
|
||||
await gesture(game, g);
|
||||
}
|
||||
await advance(() => game.phase === 'announce');
|
||||
return game;
|
||||
}
|
||||
/** 测试钩子(13 号文档允许直改实例字段):固定当前轮序列;未指定位用无 up/dt 填充。 */
|
||||
function fixSequence(game, recipe) {
|
||||
const fill = ['down', 'right', 'tap', 'left'];
|
||||
for (let i = 0; i < game.sequence.length; i++) {
|
||||
game.sequence[i] = i < recipe.length ? recipe[i] : fill[i % fill.length];
|
||||
}
|
||||
}
|
||||
{
|
||||
const rig = makeRig(SimonGame);
|
||||
const { playLog, game, events } = rig;
|
||||
await driveSimonToAnnounce(rig);
|
||||
|
||||
// R1(含 up):序列播放完成后、「轮到你」提示前播 gd_occ_up
|
||||
fixSequence(game, ['up', 'tap']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_up') === 1, 'H2:含 up 轮次播 gd_occ_up(序列播放完成后)');
|
||||
const iOcc = playLog.findIndex((e) => e.id === 'voice/gd_occ_up');
|
||||
const iTurn = playLog.findIndex((e) => e.id === 'voice/your_turn');
|
||||
assert(iOcc > -1 && iTurn > iOcc, 'H2:占用预告在「轮到你」提示之前(插在轮次链中)');
|
||||
|
||||
// 被占用轮 input 看门狗:H4 首级 5s 只发 nudge,不播重听建议句(say 动态置空)
|
||||
const tInput = now;
|
||||
const rl0 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb0 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb0);
|
||||
assert(now - tInput === 5000, `H4:input 看门狗首级 5s(实际 ${now - tInput}ms)`);
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl0,
|
||||
'H2:up 被占用轮看门狗只发 nudge earcon,不播重听建议句');
|
||||
|
||||
// 期望恰为 up 时上滑=作答(00 §5.3 豁免同款)→ 清第 1 轮
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 2);
|
||||
|
||||
// R2(仅含 doubletap):gd_occ_dt;重听建议不受影响(建议只关联 up 占用)
|
||||
fixSequence(game, ['down', 'doubletap', 'left']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_dt') === 1, 'H2:含 doubletap 轮次播 gd_occ_dt');
|
||||
const t2 = now;
|
||||
const rl1 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb1 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb1);
|
||||
assert(now - t2 === 5000, 'H4:看门狗首级恒 5s(升级级 25s 与级数不变)');
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl1 + 1,
|
||||
'H2:仅 doubletap 占用不影响上滑重听建议');
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 3);
|
||||
|
||||
// R3(up+doubletap 双占用):gd_occ_both;看门狗同样静默只 nudge
|
||||
fixSequence(game, ['up', 'doubletap', 'left', 'right']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_both') === 1, 'H2:up+doubletap 双占用播 gd_occ_both');
|
||||
const rl2 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb2 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb2);
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl2, 'H2:双占用轮看门狗同样不播重听建议');
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 4);
|
||||
|
||||
// H3:余量句与真实余量一致——第 1 次免费重听用掉(2→1)播 left1,第 2 次(1→0)播 left0
|
||||
fixSequence(game, []); // 无占用填充轮
|
||||
await advance(() => game.phase === 'input');
|
||||
game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2)
|
||||
const rel0 = voicePlays(playLog, 'voice/relisten');
|
||||
const l1_0 = voicePlays(playLog, 'voice/gd_chances_left1');
|
||||
await gesture(game, 'up'); // 期望非 up → 主动重听①:2→1
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1); // 余量句在 relisten 链尾
|
||||
assert(game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1,
|
||||
'H3:第 1 次免费重听用掉播 gd_chances_left1(余量句与真实余量一致)');
|
||||
assert(voicePlays(playLog, 'voice/relisten') === rel0 + 1, 'H3:重听照常播 relisten 并重播序列');
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.inputIdx === 0, 'H3:重听后输入进度清零');
|
||||
const l0_0 = voicePlays(playLog, 'voice/gd_chances_left0');
|
||||
await gesture(game, 'up'); // 重听②:1→0
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1);
|
||||
assert(game.chances === 0 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1,
|
||||
'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在真实余量为 0 时出现)');
|
||||
await advance(() => game.phase === 'input');
|
||||
const co0 = voicePlays(playLog, 'voice/chances_out');
|
||||
await gesture(game, 'up'); // 余 0 再求重听:拒绝路径(机会账)行为不变
|
||||
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
|
||||
'H3:余 0 再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
|
||||
while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]);
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
realLog('== ⑩b H5 rhythm:教学应答窗口无任何尝试 → gd_miss_none(none 优先级最高)==');
|
||||
{
|
||||
const rig = makeRig(RhythmGame);
|
||||
const { audio, playLog, game } = rig;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => audio.isVoicePlaying()); // welcome 开播
|
||||
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学 T1
|
||||
await advance(() => game.phase === 'tut' && game._cues.length > 0);
|
||||
// T1 整句不出手(窗口关闭时该音未判定)→ 首次失败归因 gd_miss_none
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_miss_none') === 1);
|
||||
assert(game._tutFails === 1, '前提:T1 首次失败(整句不出手)');
|
||||
assert(voicePlays(playLog, 'voice/gd_miss_none') === 1, 'H5:无尝试播 gd_miss_none(这拍没接)');
|
||||
assert(voicePlays(playLog, 'voice/gd_miss_late') === 0 && voicePlays(playLog, 'voice/gd_miss_wrong') === 0,
|
||||
'H5:没出手不再被归因为「慢了半拍」(归因真话律:宁可粗不可假)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
realLog('== ⑩c H1 escape:目标句后按房间图播 gd_room_*(同房间不播,移动坐标系)==');
|
||||
{
|
||||
// 方向映射按图校验(直连单元断言)
|
||||
const rig0 = makeRig(EscapeGame);
|
||||
const { game: g0 } = rig0;
|
||||
await rig0.audio.unlock();
|
||||
g0.x = 1; g0.y = 1;
|
||||
assert(g0._roomDirVoice([0, 1]) === 'voice/gd_room_l', 'H1:目标在西 → gd_room_l');
|
||||
assert(g0._roomDirVoice([2, 1]) === 'voice/gd_room_r', 'H1:目标在东 → gd_room_r');
|
||||
assert(g0._roomDirVoice([1, 0]) === 'voice/gd_room_u', 'H1:目标在北(上面的房间)→ gd_room_u');
|
||||
assert(g0._roomDirVoice([1, 2]) === 'voice/gd_room_d', 'H1:目标在南(下面的房间)→ gd_room_d');
|
||||
assert(g0._roomDirVoice([1, 1]) === null, 'H1:目标在同一房间不播');
|
||||
g0.stop();
|
||||
resetTimers();
|
||||
|
||||
// 三处目标句接线点:found_key → cabinet(2,1);gd_next_fuse → box(0,0);fix_box → gate(2,2)
|
||||
const rig = makeRig(EscapeGame);
|
||||
const { audio, playLog, game } = rig;
|
||||
storageData.set('soundgame.escape.guideDone', '1'); // 二周目直达 REOPEN→PLAY
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'REOPEN');
|
||||
await advance(() => !audio.isVoicePlaying()); // reopen_prompt 播完
|
||||
await gesture(game, 'tap'); // 直接开始 → PLAY (1,1)
|
||||
await advance(() => game.phase === 'PLAY' && !audio.isVoicePlaying());
|
||||
assert(game.x === 1 && game.y === 1 && !game.hasKey, '前提:二周目 PLAY 起点、身上无钥匙');
|
||||
|
||||
// ① found_key:储物间拾钥匙 → 铁柜房在东(右面的房间)
|
||||
await gesture(game, 'left'); // (0,1)
|
||||
await gesture(game, 'down'); // (0,2) 储物间(首到播 room_storage)
|
||||
await advance(() => !audio.isVoicePlaying()); // 房间描述播完再单击(否则单击=跳句)
|
||||
await gesture(game, 'tap'); // 检查 → found_key(800ms 后)→ 方位句
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_room_r'));
|
||||
const ifk = playLog.findIndex((e) => e.id === 'voice/found_key');
|
||||
assert(ifk > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_r') > ifk,
|
||||
'H1:found_key 后接播 gd_room_r(目标铁柜房按图在东)');
|
||||
|
||||
// ② gd_next_fuse:开柜得保险丝 → 电箱房在西(左面的房间)
|
||||
await gesture(game, 'right'); await gesture(game, 'right'); // (1,2) (2,2)
|
||||
await gesture(game, 'up'); // (2,1) 铁柜房
|
||||
await gesture(game, 'doubletap'); // 开柜
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_room_l'));
|
||||
const inf = playLog.findIndex((e) => e.id === 'voice/gd_next_fuse');
|
||||
assert(inf > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_l') > inf,
|
||||
'H1:gd_next_fuse 后接播 gd_room_l(目标电箱房按图在西)');
|
||||
|
||||
// ③ fix_box:修电箱通电 → 大门在东(右面的房间)
|
||||
await gesture(game, 'left'); await gesture(game, 'left'); // (1,1) (0,1)
|
||||
await gesture(game, 'up'); // (0,0) 电箱房
|
||||
await gesture(game, 'doubletap'); // 修电箱
|
||||
await advance(() => playLog.filter((e) => e.id === 'voice/gd_room_r').length >= 2);
|
||||
const ifx = playLog.findIndex((e) => e.id === 'voice/fix_box');
|
||||
assert(ifx > -1 && playLog.map((e) => e.id).lastIndexOf('voice/gd_room_r') > ifx,
|
||||
'H1:fix_box 后接播 gd_room_r(目标大门按图在东)');
|
||||
assert(game.powerOn, '前提:电箱已通电(接线点均在真实解谜链上)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 《暗室》新手会话剧本:老玩家二周目不算——这里清空过进度走首局引导(G0-G7),
|
||||
* 每步听清再做;进 PLAY 后先连撞两次墙(触发向导提前一级的卡关提示),
|
||||
* 然后找到储物间拿钥匙、去铁柜房开柜(解到第二间),收工。
|
||||
* 然后找到铁柜房开柜拿保险丝、照 round4 H1 的目标方位句跨四个房间修电箱、
|
||||
* 再摸到大门收工(目标句后 3s 内都能听到方位句)。
|
||||
*/
|
||||
export function describe(game) {
|
||||
switch (game.phase) {
|
||||
@@ -66,11 +67,23 @@ export async function session(ctx, mock) {
|
||||
await move('right', '往东走,风声换了个方向');
|
||||
await move('right');
|
||||
await move('down', '到了铁柜房,柜子锁着');
|
||||
await ctx.gesture('doubletap'); // 用钥匙 → unlock_cabinet + gd_next_fuse(round3 G4)
|
||||
await ctx.waitVirtual(4200);
|
||||
ctx.status('柜子开了,听到「保险丝到手。接下来,去找电箱」——目标又有了');
|
||||
// round3 G4 剧本:拿到保险丝后再走两步(朝西边的电箱房方向),目标链不断
|
||||
await move('left', '照新目标往西摸');
|
||||
await move('left', '再走一步,西边隐隐有电流声——电箱在那边');
|
||||
ctx.status('解到第二间、照下一目标句又摸了两步,收工');
|
||||
// 用钥匙 → unlock_cabinet + gd_next_fuse + gd_room_l(round4 H1:目标句带方位,
|
||||
// 从铁柜房 (2,1) 看电箱房 (0,0) 在西 = 左面的房间)
|
||||
await ctx.gesture('doubletap');
|
||||
await ctx.waitVirtual(5600);
|
||||
ctx.status('柜子开了。「保险丝到手。接下来,去找电箱。」后面还跟了一句「左面的房间。」——这回知道该往哪边摸了');
|
||||
// round4 H1 剧本:照方位句跨房间去电箱房——(2,1) → 左×2 = (0,1) → 上 = (0,0)
|
||||
await move('left', '照方位句往左(西)摸');
|
||||
await move('left');
|
||||
await move('up', '电流声越来越清晰——电箱房到了');
|
||||
// 修电箱 → fix_box + gd_room_r(round4 H1:从 (0,0) 看大门 (2,2) 在东 = 右面的房间)
|
||||
await ctx.gesture('doubletap');
|
||||
await ctx.waitVirtual(5600);
|
||||
ctx.status('电箱修好了。目标句后面照样跟着方位——「右面的房间。」大门在东边');
|
||||
// 照方位句多跨两个房间摸向大门:(0,0) → 右×2 = (2,0) → 下×2 = (2,2)
|
||||
await move('right', '照方位句往右(东)摸');
|
||||
await move('right');
|
||||
await move('down');
|
||||
await move('down', '脚步声停下来——面前这间就是大门');
|
||||
ctx.status('解到通电、照目标句的方位跨了四个房间摸到大门,收工');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 《节拍行者》新手会话剧本:首局完整走教学 T1-T3(第 2 句故意连错两遍,
|
||||
* 《节拍行者》新手会话剧本:首局完整走教学 T1-T3(第 2 句先整拍不出手——
|
||||
* round4 H5 归因真话律:听到的是「这拍没接」而不是「慢了半拍」;再连错一遍
|
||||
* 触发 round3 G2 的「降速 25% 重演 + gd_slow」,跟着慢速领奏做对后恢复正常)
|
||||
* → 曲目一(90 BPM);曲中下滑呼出菜单逛一圈再恢复(round3 G3:恢复先听
|
||||
* gd_count「四拍之后,继续」,再跟 4 拍底鼓 count-in 进场;首个应答窗口前
|
||||
* 整整 1 小节有 gd_answer+turn 交接标记);第一乐句故意整句漏掉(连 2 Miss
|
||||
* 触发向导「慢了半拍」提示),之后跟着领奏把一曲打完到结算。
|
||||
* 整整 1 小节有 gd_answer+turn 交接标记);正片第一乐句故意整句漏掉(连 2 Miss
|
||||
* 触发向导 gd_miss「慢了半拍」——正片规则不变),之后跟着领奏把一曲打完到结算。
|
||||
*/
|
||||
export function describe(game) {
|
||||
switch (game.phase) {
|
||||
@@ -37,11 +38,12 @@ export async function session(ctx, mock) {
|
||||
await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0);
|
||||
ctx.status(`教学第 ${step + 1}/3 句`);
|
||||
if (step === 1) {
|
||||
// round3 G2 剧本:这一句故意连错两遍(整句不出手),听它放慢一些再带着打。
|
||||
// round4 H5 剧本:这一句先整拍不出手——归因必须是「这拍没接」(gd_miss_none),
|
||||
// 不是「慢了半拍」;再连错一遍(整句不出手)听它放慢一些再带着打(G2)。
|
||||
// 注意 _tutEvaluate 判定即把 _phaseEnd 置 Infinity 防重入——等待一律挂
|
||||
// 「持久里程碑」条件(_tutFails/_tutSlowUsed),别挂阶段末时刻。
|
||||
await ctx.waitCond(() => game._tutFails === 1); // 失败①:没出手
|
||||
ctx.status('第一遍没敢出手,先听它归因');
|
||||
ctx.status('第一遍整拍没出手——听到的是「这拍没接。我停下的时候,就轮到你了」,不是「慢了半拍」');
|
||||
await ctx.waitCond(() => game._phaseEnd < 1e8 && t() < game._phaseEnd - 1); // 纠正链播完、重新领奏
|
||||
ctx.status('又重新领奏了一遍,还是没敢出手');
|
||||
await ctx.waitCond(() => game._tutSlowUsed === true); // 失败② → 触发降速重演
|
||||
|
||||
+104
-63
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
|
||||
* 收尾听完机会经济句(round3 G5 重录口径:记不住就向上滑重新听);第 1 轮
|
||||
* 直接答对(途中有双击音就演示「双击=作答」);第 2 轮开局忘序列僵住 10 秒,
|
||||
* 听到向导「向上滑可以重新听」提示后照做——用掉一次免费重听(round3 G5:
|
||||
* input 双击永远=作答,重听改绑上滑);第 3 轮凭记忆答对,在**整轮边界**收工
|
||||
* (round2 剧本修正:不在轮中退出)。
|
||||
* 《音序回响》新手会话剧本(round4):教学六音一步一步学(中间故意做错一次听纠错)、
|
||||
* 收尾听完机会经济句;对局段制造 round4 H 组断言点——
|
||||
* - 含 up 的轮次(R1):序列播完先听占用预告 gd_occ_up,僵 5s 验证被占用轮看门狗
|
||||
* 只发 nudge 不建议重听(H2),期望恰为 up 时上滑=作答;
|
||||
* - 含 doubletap 的轮次(R2)与 up+doubletap 双占用轮(R6):gd_occ_dt / gd_occ_both;
|
||||
* - 无尝试超时(R5/R7):干净轮僵 5s → 看门狗建议上滑重听 → 用掉两次免费重听,
|
||||
* 第 1 次听到「免费重听还剩一次」(H3:余量句与真实余量一致——第 4 轮完成机会
|
||||
* +1 到 2 后才重听),第 2 次听到「免费重听用完了」;整轮边界收工。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
|
||||
@@ -16,7 +18,13 @@ 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 'input': {
|
||||
const occ = [];
|
||||
if (game.sequence.includes('up')) occ.push('上滑');
|
||||
if (game.sequence.includes('doubletap')) occ.push('双击');
|
||||
const occNote = occ.length ? `(这轮${occ.join('和')}是作答,占用了)` : '';
|
||||
return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势${occNote}(要重听就向上滑)`;
|
||||
}
|
||||
case 'roundend': return '刚答完一轮,播报中';
|
||||
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
|
||||
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
|
||||
@@ -39,69 +47,102 @@ export async function session(ctx, mock) {
|
||||
}
|
||||
await ctx.gesture(G[i]); // 照指令做对
|
||||
if (i === G.length - 1) {
|
||||
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 input 就接手,
|
||||
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 announce 就接手,
|
||||
// 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源)
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'
|
||||
|| game.phase === 'gameover');
|
||||
await ctx.waitCond(() => game.phase === 'announce' || game.phase === 'playback'
|
||||
|| game.phase === 'input' || game.phase === 'gameover');
|
||||
} else {
|
||||
await ctx.waitIdle();
|
||||
}
|
||||
}
|
||||
ctx.status('教学讲完了:免费重听两次、用完扣机会,想重听就向上滑');
|
||||
|
||||
// 正式对局:完整打完第 3 轮,在轮边界收工
|
||||
let relistenDone = false;
|
||||
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover') {
|
||||
if (game.phase === 'input') {
|
||||
// 第 1 轮开局先失忆僵 10 秒(还没动过手,input 看门狗在挂):听向导
|
||||
// 「向上滑可以重新听一遍序列」,照做——round3 G5:重听已改绑上滑。
|
||||
// 测试钩子把序列摆成无 up 的两音,保证上滑=重听不被「期望恰为 up」豁免干扰。
|
||||
if (game.round === 1 && !relistenDone && game.inputIdx === 0) {
|
||||
relistenDone = true;
|
||||
if (game.sequence.includes('up')) { game.sequence[0] = 'left'; game.sequence[1] = 'tap'; }
|
||||
ctx.status('第 1 轮有点懵,先僵一会儿');
|
||||
ctx.silence(10, '想不起来序列了');
|
||||
// 10s 看门狗到点:nudge + gd_relisten。直接等提示语音开播(input 态无
|
||||
// BGM、无别的语音,voiceBusy=提示在播;别用「等满 10.6s」——定时刻度会
|
||||
// 恰好迈过语音结束点,等成第二次升级)
|
||||
await ctx.waitCond(() => ctx.voiceBusy());
|
||||
ctx.status('听到提示:需要的话,向上滑可以重新听一遍序列');
|
||||
ctx.note('照提示向上滑,用掉一次免费重听');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
|
||||
continue;
|
||||
}
|
||||
// 第 2 轮开头演示「期望恰为 up 时上滑=作答」(00 §5.3 豁免同款口径)
|
||||
if (game.round === 2 && !game.__upDemoDone && game.inputIdx === 0) {
|
||||
game.__upDemoDone = true;
|
||||
game.sequence[0] = 'up';
|
||||
ctx.status('这个是上行音——上滑作答(期望恰为 up 时上滑=作答)');
|
||||
await ctx.gesture('up');
|
||||
continue;
|
||||
}
|
||||
if (game.round === 3 && game.inputIdx === game.sequence.length - 1) {
|
||||
// round3 G5 演示钩子(13 号文档允许直读/改实例字段):把最后一个期望音
|
||||
// 摆成 doubletap,保证转写演示到「input 双击永远=作答」
|
||||
if (game.sequence[game.inputIdx] !== 'doubletap' && !game.__dtDemoDone) {
|
||||
game.__dtDemoDone = true;
|
||||
game.sequence[game.inputIdx] = 'doubletap';
|
||||
}
|
||||
ctx.status(game.sequence[game.inputIdx] === 'doubletap'
|
||||
? '这个是双击音——双击作答(round3:input 双击永远=作答)'
|
||||
: '最后一轮最后一个音,打完这轮就收');
|
||||
} else {
|
||||
ctx.status('轮到我复现了');
|
||||
}
|
||||
await ctx.gesture(game.sequence[game.inputIdx]);
|
||||
continue;
|
||||
// 测试钩子(13 号文档允许直改实例字段):每轮报数时点把序列摆成剧本需要的
|
||||
// 形状——占用音(up/doubletap)摆进指定轮,其余用不含占用音的手势补齐。
|
||||
const fixSeq = (highlights) => {
|
||||
const fill = ['down', 'right', 'tap', 'left'];
|
||||
for (let i = 0; i < game.sequence.length; i++) {
|
||||
game.sequence[i] = i < highlights.length ? highlights[i] : fill[i % fill.length];
|
||||
}
|
||||
// 整轮边界收尾:第 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);
|
||||
}
|
||||
};
|
||||
const waitAnnounce = () => ctx.waitCond(() => game.phase === 'announce' || game.phase === 'gameover');
|
||||
const answerRound = async () => {
|
||||
await ctx.waitCond(() => game.phase === 'input' || game.phase === 'gameover');
|
||||
while (game.phase === 'input') await ctx.gesture(game.sequence[game.inputIdx]);
|
||||
};
|
||||
// 「无尝试超时」剧本点(干净轮):僵到 input 看门狗 5s 首级的提示语音开播
|
||||
//(input 态无 BGM 无别的语音,voiceBusy=提示在播;别盲等固定时长——harness 的
|
||||
// advance 没有时间上限,会恰好迈过提示语音结束点、一路空烧到 25s 升级)
|
||||
const stallForAdvice = async (why) => {
|
||||
ctx.silence(5, why);
|
||||
await ctx.waitCond(() => ctx.voiceBusy());
|
||||
};
|
||||
|
||||
ctx.status(`收工:完整打完 ${game.round} 轮,在轮与轮的边界停手(免费重听剩 ${game.chances} 次)`);
|
||||
// R1:含 up 的轮次——占用预告 + 被占用轮看门狗只 nudge(H2)+ 上滑恰为作答
|
||||
await waitAnnounce();
|
||||
fixSeq(['up', 'tap']);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('序列一播完就听到「这一轮,向上滑是作答」——重听键这轮被占了');
|
||||
ctx.silence(5, '第一轮先不动手,看看它催不催');
|
||||
// 被占用轮 5s 级没有提示语音可等:僵到 25s 升级(turn+confirm 震)作为里程碑
|
||||
await ctx.waitVirtual(25600);
|
||||
ctx.note('5 秒一声轻提醒、25 秒又一声加确认震——两级提醒都没建议「向上滑重听」,它知道这轮上滑是作答');
|
||||
ctx.status('先来一发上滑——期望恰为 up 时上滑就是作答');
|
||||
await answerRound();
|
||||
|
||||
// R2:含 doubletap 的轮次——占用预告 dt(重听建议不受影响,不僵)
|
||||
await waitAnnounce();
|
||||
fixSeq(['down', 'doubletap', 'left']);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('这轮预告的是「双击是作答」');
|
||||
await answerRound();
|
||||
|
||||
// R3/R4:无占用轮直答(第 4 轮完成 → 机会 +1,免费重听凑满两次)
|
||||
await waitAnnounce();
|
||||
fixSeq([]);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('这轮开场没有占用预告——上滑还能当重听键用');
|
||||
await answerRound();
|
||||
await waitAnnounce();
|
||||
fixSeq([]);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('机会加一之后,免费重听真的有两次了');
|
||||
await answerRound();
|
||||
|
||||
// R5:无尝试超时 + 第 1 次免费重听(余量如实报「还剩一次」,H3)
|
||||
await waitAnnounce();
|
||||
fixSeq([]);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('第 5 轮序列有点长,先不动手');
|
||||
await stallForAdvice('想不起来序列了');
|
||||
ctx.status('5 秒就来了提示:需要的话,向上滑可以重新听一遍序列');
|
||||
ctx.note('照提示向上滑——第 1 次免费重听用掉,跟着听到「免费重听还剩一次」');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
|
||||
await answerRound();
|
||||
|
||||
// R6:up+doubletap 双占用轮——gd_occ_both + 被占用轮看门狗只 nudge
|
||||
await waitAnnounce();
|
||||
fixSeq(['up', 'doubletap']);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
ctx.status('这轮说「向上滑和双击都是作答」——两个键都被占用了');
|
||||
ctx.silence(5, '轮到我了,先心里过一遍序列');
|
||||
await ctx.waitVirtual(25600); // 同 R1:两级提醒都只发 earcon/标记,无重听建议
|
||||
ctx.note('又是两级提醒都没有重听建议——占用轮里那句建议自动闭嘴');
|
||||
await answerRound();
|
||||
|
||||
// R7:无尝试超时 + 第 2 次免费重听(余量归 0,H3)
|
||||
await waitAnnounce();
|
||||
fixSeq([]);
|
||||
await ctx.waitCond(() => game.phase === 'input');
|
||||
await stallForAdvice('长序列,又想不起来了');
|
||||
ctx.status('又是那句提示:向上滑可以重新听一遍序列');
|
||||
ctx.note('向上滑——第 2 次免费重听用掉,这回听到「免费重听用完了。之后重听会花一次机会」');
|
||||
await ctx.gesture('up');
|
||||
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
|
||||
await answerRound();
|
||||
|
||||
// 轮边界收工
|
||||
await ctx.waitCond(() => game.phase === 'roundend' || game.phase === 'gameover');
|
||||
ctx.status(`收工:打完 ${game.round} 轮在轮边界停手(免费重听剩 ${game.chances} 次,账目和播的对上了)`);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,11 @@
|
||||
"menu_home": "回大厅。",
|
||||
"menu_back": "返回游戏。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_next_fuse": "保险丝到手。接下来,去找电箱。"
|
||||
"gd_next_fuse": "保险丝到手。接下来,去找电箱。",
|
||||
"gd_room_l": "左面的房间。",
|
||||
"gd_room_r": "右面的房间。",
|
||||
"gd_room_u": "上面的房间。",
|
||||
"gd_room_d": "下面的房间。"
|
||||
},
|
||||
"simon": {
|
||||
"t0_welcome": "欢迎来到音序回响。先认识六种声音。",
|
||||
@@ -205,7 +209,12 @@
|
||||
"gd_relisten": "需要的话,向上滑可以重新听一遍序列。",
|
||||
"gd_economy": "记不住就向上滑,重新听一遍。免费机会有两次,用完要扣一次机会。",
|
||||
"gd_clear": "漂亮,过了。",
|
||||
"gd_record": "破纪录了。"
|
||||
"gd_record": "破纪录了。",
|
||||
"gd_occ_up": "这一轮,向上滑是作答。",
|
||||
"gd_occ_dt": "这一轮,双击是作答。",
|
||||
"gd_occ_both": "这一轮,向上滑和双击都是作答。",
|
||||
"gd_chances_left1": "免费重听还剩一次。",
|
||||
"gd_chances_left0": "免费重听用完了。之后重听会花一次机会,别怕,慢慢来。"
|
||||
},
|
||||
"rhythm": {
|
||||
"welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。",
|
||||
@@ -246,7 +255,8 @@
|
||||
"gd_miss_late": "慢了半拍。跟着节拍走。",
|
||||
"gd_miss_wrong": "方向不对。跟着音色滑。",
|
||||
"gd_slow": "我放慢一些,跟着试试。",
|
||||
"gd_count": "四拍之后,继续。"
|
||||
"gd_count": "四拍之后,继续。",
|
||||
"gd_miss_none": "这拍没接。我停下的时候,就轮到你了。"
|
||||
},
|
||||
"space": {
|
||||
"drill_welcome": "欢迎登上盲航。你只有一双耳朵。",
|
||||
|
||||
Reference in New Issue
Block a user