guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿
- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白 - settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题 - 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名 - 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性 - 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 《暗室》新手会话剧本:老玩家二周目不算——这里清空过进度走首局引导(G0-G7),
|
||||
* 每步听清再做;进 PLAY 后先连撞两次墙(触发向导提前一级的卡关提示),
|
||||
* 然后找到储物间拿钥匙、去铁柜房开柜(解到第二间),收工。
|
||||
*/
|
||||
export function describe(game) {
|
||||
switch (game.phase) {
|
||||
case 'REOPEN': return '有人问我回不回来看过引导:单击直接开始,上滑重听引导';
|
||||
case 'GUIDE': return game._guideBusy ? '引导在说话,照它说的做就行' : `引导第 ${game.guideStep + 1}/8 步,等我做手势`;
|
||||
case 'PLAY': return `探索中,我在 (${game.x},${game.y})。四向滑挪步,单击摸一遍房间,双击用物品`;
|
||||
case 'ENDING': return '逃脱了,结算播报中。播完单击再来,下滑开菜单';
|
||||
case 'MENU': return '菜单里:单击切选项,双击确认,下滑关闭';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 首局:交互式引导 G0-G7(n = 引导步号 guideStep)。
|
||||
// 注意:卡关轮询 interval 常驻,虚拟时钟永不"空闲"——等待一律用有界/带条件等待。
|
||||
ctx.status('黑屏,只有引子的声音,先听完');
|
||||
await ctx.waitCond(() => game.phase === 'GUIDE' && !game._guideBusy); // g0_intro 播完
|
||||
const steps = [
|
||||
{ n: 0, do: async () => { ctx.note('照引导单击了一下'); await ctx.gesture('tap'); } },
|
||||
{ n: 1, do: async () => { ctx.note('随便滑了一步,有脚步声'); await ctx.gesture('up'); } },
|
||||
{
|
||||
n: 2,
|
||||
do: async () => {
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitVirtual(1600);
|
||||
ctx.note('又滑一步,又撞墙了——墙的方位听出来了');
|
||||
await ctx.gesture('right');
|
||||
},
|
||||
},
|
||||
{ n: 3, do: async () => { ctx.note('这回往左,走通了一步'); await ctx.gesture('left'); } },
|
||||
{ n: 4, do: async () => { ctx.note('往下滑,进了储物间'); await ctx.gesture('down'); } },
|
||||
{ n: 5, do: async () => { ctx.note('单击摸一遍房间,摸到钥匙了'); await ctx.gesture('tap'); } },
|
||||
{ n: 6, do: async () => { ctx.note('双击看了看背包'); await ctx.gesture('doubletap'); } },
|
||||
];
|
||||
for (const s of steps) {
|
||||
await ctx.waitCond(() => game.phase === 'GUIDE' && game.guideStep === s.n && !game._guideBusy);
|
||||
ctx.status(`引导第 ${s.n + 1}/8 步`);
|
||||
await s.do();
|
||||
await ctx.waitVirtual(800); // 缓一缓再继续(推进链由语音 onComplete 驱动)
|
||||
}
|
||||
|
||||
// PLAY:自由探索。引导结束时我站在储物间 (0,2),手里已有钥匙
|
||||
await ctx.waitCond(() => game.phase === 'PLAY');
|
||||
ctx.status('引导完毕,正式开始。我在储物间');
|
||||
const move = async (g, why) => {
|
||||
await ctx.gesture(g);
|
||||
await ctx.waitVirtual(1600); // 听脚步/环境音/描述
|
||||
if (why) ctx.note(why);
|
||||
};
|
||||
|
||||
// 先往北乱撞探探边界:两步走到 (0,0),再连撞两次北墙——向导把卡关提示提前带上来
|
||||
ctx.silence(3, '辨了辨方向');
|
||||
await move('up');
|
||||
await move('up');
|
||||
await move('up', '北边是墙,撞了一下');
|
||||
await move('up', '又撞了一下——听到游戏主动来问我了');
|
||||
await ctx.waitVirtual(5200); // 等卡关轮询的 5s tick:连续撞墙 → 提前一级的暗示
|
||||
|
||||
// 去铁柜房:(0,0) → 右×2 = (2,0) → 下 = (2,1),双击用钥匙
|
||||
await move('right', '往东走,风声换了个方向');
|
||||
await move('right');
|
||||
await move('down', '到了铁柜房,柜子锁着');
|
||||
await ctx.gesture('doubletap'); // 用钥匙 → unlock_cabinet(第二间解完)
|
||||
await ctx.waitVirtual(3000);
|
||||
ctx.status('柜子开了,拿到保险丝——解到第二间,收工');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 《节拍行者》新手会话剧本:首局完整走教学 T1-T3 → 曲目一(90 BPM);
|
||||
* 曲中下滑呼出菜单逛一圈再恢复;第一乐句故意整句漏掉(连 2 Miss 触发向导
|
||||
* 「慢了半拍」提示),之后跟着领奏把一曲打完到结算。
|
||||
*/
|
||||
export function describe(game) {
|
||||
switch (game.phase) {
|
||||
case 'intro': return '开场旁白在播,单击可以跳过';
|
||||
case 'tut': return game._cues.length
|
||||
? `教学:听领奏,下一小节照样打(${game._cues.map((c) => c.gesture).join('→')})`
|
||||
: '教学准备中,听鼓点';
|
||||
case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅';
|
||||
case 'main': {
|
||||
const left = game._cues.filter((c) => !c.judged).length;
|
||||
return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了`;
|
||||
}
|
||||
case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏';
|
||||
case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
|
||||
default: return '开场,稍等';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game, audio } = ctx;
|
||||
const t = () => audio.ctx.currentTime;
|
||||
|
||||
// 开场:welcome 听完进教学
|
||||
await ctx.waitCond(() => game.phase === 'tut');
|
||||
ctx.status('教学开始了,先听领奏');
|
||||
|
||||
// 教学 3 步:等领奏排程好,在应答窗口内跟打
|
||||
//(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle)
|
||||
for (let step = 0; step < 3; step++) {
|
||||
await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0);
|
||||
ctx.status(`教学第 ${step + 1}/3 句`);
|
||||
for (const cue of game._cues) {
|
||||
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); // tap 补偿引擎 ~300ms 延迟
|
||||
await ctx.waitCond(() => t() >= at || cue.judged);
|
||||
if (cue.judged) continue;
|
||||
await ctx.gesture(cue.gesture);
|
||||
}
|
||||
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
|
||||
}
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
ctx.status('教学完毕,曲目一《启程》90 拍开始');
|
||||
|
||||
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
|
||||
await ctx.waitVirtual(2500);
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitCond(() => game.phase === 'menu');
|
||||
ctx.status('曲中暂停,进了菜单');
|
||||
await ctx.gesture('tap'); // 切换选项(听到「回大厅」)
|
||||
await ctx.waitIdle();
|
||||
ctx.note('还是想打完这首,下滑回去');
|
||||
await ctx.gesture('down'); // 恢复
|
||||
await ctx.waitCond(() => game.phase === 'main');
|
||||
|
||||
// 第一乐句故意整句不打:连 2 次 Miss → 向导 gd_miss
|
||||
const firstPhrase = game._cues.filter((c) => c.phrase === 0);
|
||||
ctx.silence(5, '还在数拍子,第一句没敢出手');
|
||||
await ctx.waitCond(() => firstPhrase.every((c) => c.judged));
|
||||
ctx.status('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」');
|
||||
|
||||
// 之后逐音跟打(tap 注入留出引擎 ~300ms 回调延迟)
|
||||
for (const cue of game._cues) {
|
||||
if (cue.judged) continue;
|
||||
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04);
|
||||
await ctx.waitCond(() => t() >= at || cue.judged);
|
||||
if (cue.judged) continue;
|
||||
await ctx.gesture(cue.gesture);
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'end');
|
||||
await ctx.waitIdle();
|
||||
ctx.status('曲子打完了,结算播报中');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 《剑与声》新手会话剧本:听完序章进村 → 村里把选项听一圈 → 出村去小路
|
||||
* (顺路调查一次捡钱)→ 岔路口转去溪流触发教学战 → 完整打一场
|
||||
* (教学四步:识招→攻击→格挡→防御,再自由击杀),战后收工。
|
||||
*
|
||||
* 种子 RNG:让教学狼首击必不掉(HP 6、伤害恒 5),保证四步教学完整走完。
|
||||
*/
|
||||
Math.random = () => 0.4;
|
||||
|
||||
export function describe(game) {
|
||||
const SCENE_NAME = {
|
||||
village: '雾口村', path: '林间小路', fork: '岔路口', chest: '枯树洞', stream: '溪流',
|
||||
hunter: '猎人小屋', deepwood: '密林深处', gate: '黑风寨门前', boss1: '聚义厅',
|
||||
};
|
||||
const BAND = { high: '八成以上', mid: '五成上下', low: '三成以下' };
|
||||
if (game.phase === 'battle' && game._battle) {
|
||||
const b = game._battle;
|
||||
if (b.state === 'PLAYER_SELECT' && b.enemy) {
|
||||
return `战斗中:你的血量${BAND[game._hpBand(game.player.hp, game.maxHp)]},`
|
||||
+ `敌方血量${BAND[game._hpBand(b.enemy.hp, b.enemy.maxHp)]},等你选招式(左右滑切换,单击出手,下滑逃跑)`;
|
||||
}
|
||||
if (b.state === 'ENEMY_TELEGRAPH') return '战斗中:敌方前摇声响起了——双击,格挡!';
|
||||
return '战斗播报中,先听着';
|
||||
}
|
||||
switch (game.phase) {
|
||||
case 'ask': return '检测到旅程记录:单击继续,右滑开新档';
|
||||
case 'intro': return '序章旁白在播,讲我怎么用耳朵走路';
|
||||
case 'explore': return `【${SCENE_NAME[game.scene] ?? game.scene}】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单`;
|
||||
case 'shop': return '商店里:左右滑看货,单击买,下滑离开';
|
||||
case 'menu': return '菜单里:左右滑切选项,单击确认';
|
||||
default: return '……';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
const CMDS = ['attack', 'skill', 'defend', 'item'];
|
||||
|
||||
// 序章听完 → 进村
|
||||
await ctx.waitCond(() => game.phase === 'explore');
|
||||
ctx.status('进村了,先听场景描述和选项');
|
||||
|
||||
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈再对准「去小路」出发
|
||||
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);
|
||||
await ctx.gesture('tap'); // → path
|
||||
|
||||
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
|
||||
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'path');
|
||||
ctx.status('林间小路');
|
||||
await ctx.gesture('right'); // 调查四周
|
||||
await ctx.waitVirtual(1800);
|
||||
ctx.note('翻找了一阵,捡到一点钱');
|
||||
await ctx.gesture('tap');
|
||||
await ctx.waitVirtual(2400);
|
||||
await ctx.gesture('left'); // 回「去岔路口」
|
||||
await ctx.waitVirtual(1500);
|
||||
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.gesture('tap'); // go_stream → 溪流 → 触发教学战
|
||||
|
||||
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
|
||||
await ctx.waitCond(() => game.phase === 'battle' && game._battle?.state === 'PLAYER_SELECT');
|
||||
ctx.status('遭遇教学狼,战斗开始');
|
||||
|
||||
const selectCmd = async (cmd) => {
|
||||
const b = game._battle;
|
||||
while (b && b.cmdIdx !== CMDS.indexOf(cmd)) {
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1300); // 听指令名
|
||||
}
|
||||
};
|
||||
|
||||
// T0:左右滑各切一次(听到指令名就算过关)
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1300);
|
||||
await ctx.gesture('right');
|
||||
await ctx.waitVirtual(1300);
|
||||
ctx.status('招式名听熟了');
|
||||
|
||||
// T1:攻击一次
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
|
||||
await selectCmd('attack');
|
||||
ctx.note('单击出手,攻击');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// T2:格挡(前摇窗口内双击)
|
||||
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
|
||||
ctx.status('前摇声响起了');
|
||||
ctx.note('双击格挡');
|
||||
await ctx.gesture('doubletap');
|
||||
|
||||
// T3:防御一次
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
|
||||
await selectCmd('defend');
|
||||
ctx.note('这回合先防御,减半伤害');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
// 自由阶段:攻击到狼死(中间照常听前摇、双击格挡)
|
||||
for (let i = 0; i < 8 && game.phase === 'battle'; i++) {
|
||||
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT'
|
||||
|| game._battle?.state === 'ENEMY_TELEGRAPH' || game.phase !== 'battle');
|
||||
if (game.phase !== 'battle') break;
|
||||
if (game._battle.state === 'ENEMY_TELEGRAPH') {
|
||||
ctx.note('双击格挡');
|
||||
await ctx.gesture('doubletap');
|
||||
continue;
|
||||
}
|
||||
await selectCmd('attack');
|
||||
ctx.note('单击,攻击');
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
|
||||
await ctx.waitCond(() => game.phase === 'explore');
|
||||
ctx.status('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工');
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
|
||||
* 第 1 轮答对、第 2 轮中途忘序列僵住 11 秒(触发向导「双击可重听」提示)、
|
||||
* 第 3 轮故意答错第一下(消耗机会自动重播),然后答对完成第 3 轮收工。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
|
||||
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
|
||||
switch (game.phase) {
|
||||
case 'tutorial':
|
||||
return game.teachAwait
|
||||
? `教学:该做「${GN[GESTURES[game.teachIdx]]}」了`
|
||||
: '教学旁白在播,先听着';
|
||||
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 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
|
||||
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
|
||||
default: return '开场,稍等一下';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
const G = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
|
||||
|
||||
// 教学 T0-T6:教一个练一个
|
||||
for (let i = 0; i < G.length; i++) {
|
||||
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
ctx.status(`教学第 ${i + 1}/6 个音色`);
|
||||
if (i === 2) {
|
||||
ctx.note('把「左滑」记混了,先来了个右滑');
|
||||
await ctx.gesture('right'); // 听纠错语音(t_wrong_left)
|
||||
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
}
|
||||
await ctx.gesture(G[i]); // 照指令做对
|
||||
await ctx.waitIdle();
|
||||
}
|
||||
|
||||
// 正式对局:打到完成第 3 轮
|
||||
let wrongDone = false;
|
||||
let stallDone = false;
|
||||
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover' && game.round < 4) {
|
||||
if (game.phase === 'input') {
|
||||
// 第 2 轮开局先失忆僵住 11 秒:触发向导「双击可以重新听一遍序列」提示
|
||||
if (game.round === 2 && game.inputIdx === 0 && !stallDone) {
|
||||
stallDone = true;
|
||||
ctx.status('序列有点长,先僵一会儿');
|
||||
ctx.silence(11, '想不起来序列了');
|
||||
await ctx.waitVirtual(11000);
|
||||
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 + 自动重播同一序列
|
||||
continue;
|
||||
}
|
||||
ctx.status('轮到我复现了');
|
||||
await ctx.gesture(game.sequence[game.inputIdx]);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitIdle();
|
||||
}
|
||||
|
||||
ctx.status(`收工:完成了 ${game.round - 1} 轮`);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
|
||||
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏一颗
|
||||
* (触发归因句 + 向导「同向滑动就能拨开」指令)→ 再正常躲几颗收工。
|
||||
*/
|
||||
export function describe(game) {
|
||||
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
|
||||
switch (game.phase) {
|
||||
case 'ask': return '问我直接出发还是重听演练:单击出发,上滑重听';
|
||||
case 'drill': return game.drillOb
|
||||
? `演练:${DIR[game.drillOb.dir]}有陨石在逼近,${DIR[game.drillOb.dir]}滑把它拨开`
|
||||
: '演练旁白在播,先听';
|
||||
case 'playing': {
|
||||
const n = game.obstacles.size;
|
||||
const protect = performance.now() < game.protectionUntil;
|
||||
return n
|
||||
? `对局中:${n} 颗陨石在逼近,听方位同向滑开${protect ? '(保护期,撞了也不掉血)' : ''}`
|
||||
: `对局中:引擎声平稳,等下一颗${protect ? '(保护期)' : ''}`;
|
||||
}
|
||||
case 'feature': return game.featureKind === 'boost' ? '暂停了刷怪教新功能:单击试试加速' : '暂停了刷怪教新功能:双击展开护盾';
|
||||
case 'menu': return '暂停菜单:单击切选项,双击确认,下滑恢复';
|
||||
case 'over': return `坠毁了,存活数据播报中。单击再飞一次,下滑回大厅`;
|
||||
default: return '开场,稍等';
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
|
||||
// 演练 D1-D4:逼近音一响就同向拨开(第二颗故意不出手,听当场重教)。
|
||||
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
|
||||
ctx.status(`演练 ${i + 1}/4:${{ left: '左', right: '右', up: '上', down: '下' }[game.drillOb.dir]}侧有陨石`);
|
||||
if (i === 1) {
|
||||
ctx.note('这颗犹豫了,没拨开——听当场重教');
|
||||
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → drill_retry
|
||||
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
|
||||
ctx.note('重教听完,这回马上同向滑');
|
||||
await ctx.gesture(game.drillOb.dir);
|
||||
continue;
|
||||
}
|
||||
ctx.note('逼近音一响,同向滑开');
|
||||
await ctx.gesture(game.drillOb.dir);
|
||||
}
|
||||
ctx.status('演练做完了,要出发了');
|
||||
|
||||
// 正式对局:保护期 60s + 结束后再躲一小段就收工(不再模拟,避免挂机撞毁)。
|
||||
// 每颗陨石生成(呼啸声起)就立即同向拨开——比盯着 1.5s 判定窗口更稳。
|
||||
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
|
||||
await ctx.waitCond(() => game.phase === 'playing');
|
||||
ctx.status('出发。引擎声起来了,保护期里先练手');
|
||||
const dodged = new Set();
|
||||
let victim = null; // 保护期结束后故意不躲的那颗
|
||||
let eaten = false;
|
||||
while ((game.phase === 'playing' || game.phase === 'feature')
|
||||
&& performance.now() < game.protectionUntil + 8000) {
|
||||
if (game.phase === 'feature') {
|
||||
ctx.status('游戏停下来教新功能');
|
||||
await ctx.waitIdle(); // 引入句听完(feature 态无刷怪链,可安全等待空闲)
|
||||
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
|
||||
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
|
||||
continue;
|
||||
}
|
||||
if (victim && !game.obstacles.has(victim)) {
|
||||
ctx.note('被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
|
||||
victim = null;
|
||||
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;
|
||||
dodged.add(target); // 标记 = 故意不躲
|
||||
ctx.status('保护期结束了,这颗我故意没动');
|
||||
continue;
|
||||
}
|
||||
dodged.add(target);
|
||||
await ctx.gesture(target.dir);
|
||||
continue;
|
||||
}
|
||||
await ctx.waitCond(() => (game.phase !== 'playing' && game.phase !== 'feature')
|
||||
|| (victim && !game.obstacles.has(victim))
|
||||
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() < 1300));
|
||||
}
|
||||
ctx.status('躲过保护期、挨过一次撞,心跳有点快,先到这');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗)、
|
||||
* 在非抉择节点试一次无效的左滑(触发向导纠错)、菜单进去逛一圈再出来、
|
||||
* 抉择点一律选左,一路打到结局。
|
||||
*/
|
||||
export function describe(game) {
|
||||
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
|
||||
if (game.awaitingRestart) return '结局播完了。单击屏幕,可以回到故事开始的地方';
|
||||
if (game.choiceArmed) return `「请选择」念完了(节点 ${game.nodeId}):左滑或右滑,做出你的决定`;
|
||||
if (game.voicePlaying) return '旁白正在播。单击跳过本句,双击重听整个节点';
|
||||
return `剧情等着你(节点 ${game.nodeId}):单击继续,双击重听`;
|
||||
}
|
||||
|
||||
export async function session(ctx, mock) {
|
||||
const { game } = ctx;
|
||||
let invalidTried = false;
|
||||
let menuDone = false;
|
||||
|
||||
// 开场:n00 全听完,愣一会儿——向导看门狗先说话
|
||||
await ctx.waitIdle();
|
||||
ctx.status('开场旁白播完了,屏幕黑着,有点不知所措');
|
||||
ctx.silence(8, '新手没反应过来');
|
||||
await ctx.waitVirtual(8600); // 触发看门狗:nudge + gd_continue
|
||||
ctx.status('听到一声轻响和「轻触屏幕,继续」,试着点一下');
|
||||
await ctx.gesture('tap');
|
||||
|
||||
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
|
||||
await ctx.waitIdle(); // 等当前语音链播完(真人不会抢话)
|
||||
|
||||
if (game.awaitingRestart) break;
|
||||
|
||||
// 中途进一次菜单,逛一圈再出来
|
||||
if (!menuDone && game.nodeId === 'n04') {
|
||||
menuDone = true;
|
||||
ctx.note('想看看还有什么可按的,下滑呼出菜单');
|
||||
await ctx.gesture('down');
|
||||
await ctx.waitIdle();
|
||||
ctx.status('菜单里');
|
||||
await ctx.gesture('up'); // 听帮助 + 进度
|
||||
await ctx.waitIdle();
|
||||
await ctx.gesture('doubletap'); // 重听菜单项
|
||||
await ctx.waitIdle();
|
||||
ctx.note('听懂了,先回去继续剧情');
|
||||
await ctx.gesture('tap'); // 关菜单(回剧情并重播当前节点)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (game.choiceArmed) {
|
||||
ctx.status('抉择点,提示音念完了');
|
||||
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
|
||||
ctx.note('选了左边');
|
||||
await ctx.gesture('left'); // 抉择点一律选左
|
||||
continue;
|
||||
}
|
||||
|
||||
// W1:叙事节点播完,静默等待
|
||||
ctx.status('剧情停下来了,在等我');
|
||||
if (!invalidTried) {
|
||||
invalidTried = true;
|
||||
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
|
||||
await ctx.gesture('left');
|
||||
await ctx.waitIdle();
|
||||
} else if (i % 4 === 2) {
|
||||
ctx.silence(8, '走神想剧情去了');
|
||||
await ctx.waitVirtual(8600); // 偶尔挂机,触发看门狗轻提醒
|
||||
ctx.status('又听到那声轻响,想起该点屏幕了');
|
||||
}
|
||||
await ctx.gesture('tap');
|
||||
}
|
||||
|
||||
await ctx.waitIdle();
|
||||
ctx.status('结局');
|
||||
ctx.note('听完结局,单击重开一次看看别的走向');
|
||||
await ctx.gesture('tap'); // 结算态单击重开
|
||||
await ctx.waitIdle();
|
||||
ctx.status('回到开头,会话结束');
|
||||
}
|
||||
Reference in New Issue
Block a user