/** * 《守塔人》新手会话剧本。 * * 剧本: * - `session`(主线):一位守塔人的完整风暴夜——听完开场(塔名/规矩/目标)、 * 三个幕内接满电台抉择(全走心线:接住人→翻暗格→口诀→真话→堵漏→读完缺页 * →出海接人→循渔歌→说出真相),三段回忆插叙全听,以完整 E1「灯与归人」收尾。 * - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛, * 一样要能走到 ENDING,全程零死锁、零静默死局。 * * 剧本失真红线(参照 radio/abyss 剧本先例): * - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手; * - 玩家不干晾自己:只在 WATCH 链末等待态偶尔走神触发一次看门狗,链一停就接话; * - CALM 幕间有喘息定时器、MEMORY 有进入拍定时器,禁止裸 waitIdle 空等, * 必须 waitCond 有界等待; * - 叙事链每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。 */ const ACT_LABEL = ['一', '二', '三']; export function describe(game) { if (game.leaving) return '已确认保存并回大厅'; if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的风暴夜」的询问'; if (game.phase === 'MENU') { const names = ['回到值守', '重新开始今夜', '保存并回大厅']; return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`; } if (game.phase === 'INTRO') { return game.voicePlaying ? '塔名、规矩和今晚目标正在播' : '开场白播完了,等我单击开始值守'; } if (game.phase === 'MEMORY') { const era = game.timeline === 'mem_f' ? '父亲的最后一晚(十二年前)' : '祖父的年代(四十年前)'; return game.voicePlaying ? `回忆插叙中(${era}):唱针落下,老唱片底噪,听完自动回到现在` : `回忆插叙的静默拍(${era})`; } if (game.phase === 'CALM') { return `第${ACT_LABEL[(game.act || 1) - 1]}幕结束,幕间喘息:雾角 + 岸台通报${game.voicePlaying ? '正在播' : ',等我单击或自动进下一幕'}`; } if (game.choiceArmed) { return `「请选择」念完了(第${ACT_LABEL[(game.act || 1) - 1]}幕):向左或向右滑动做出你的决定`; } if (game.voicePlaying) return `第${ACT_LABEL[(game.act || 1) - 1]}幕值守中,正在听电台/岸台说话`; if (game.phase === 'WATCH') return `对方说完了,等我接话(第${ACT_LABEL[(game.act || 1) - 1]}幕):单击继续`; if (game.phase === 'ENDING') { return game.awaitingRestart ? '结局和尾声回响都听完了。单击再来一夜,下滑开菜单' : '结局播报中(岸台记一笔:这一晚后来怎样了)'; } return '……'; } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // 开场:INTRO 自动流(塔名 + 规矩 + 目标)→ 链末单击 → C1 await ctx.waitCond(() => game.phase === 'WATCH' || game.choiceArmed); ctx.status('开场口令和塔名播完了,等我单击开始值守'); ctx.note('雾角两长一短——塔在,灯在。单击,接过这一夜'); await ctx.gesture('tap'); ctx.status('进入第一幕,电台里有动静'); await ctx.waitCond(() => !game.voicePlaying); ctx.status('岸台说频道里有动静,像隔着整片海'); let invalidTried = false; let menuDone = false; let walkedAway = false; let helpDone = false; let choiceIdx = 0; // 抉择路径:全走心线(接住人/翻暗格/口诀/真话/堵漏/读完/出海/循渔歌/说出 → e1) const choices = [ { dir: 'left', why: '他的声音在抖——先接住这个人,船位待会儿再要' }, { dir: 'left', why: '他提到四十年前……去翻灯座下的暗格,旧账该见光了' }, { dir: 'left', why: '他快撑不住了——用守塔人的口诀带他稳船' }, { dir: 'left', why: '他问起我的人……告诉他真话:我父亲也是守塔人' }, { dir: 'left', why: '船板裂了——让他回舱堵漏,守住电台' }, { dir: 'left', why: '祖父的最后一页就在灯下——今晚读完它' }, { dir: 'left', why: '浪墙压过来了——给灯上满发条,驾接驳艇去接他' }, { dir: 'left', why: '海上没有灯——循着他哼的渔歌找他' }, { dir: 'left', why: '他问起四十年前的灯……把缺页的事,连根说给他' }, ]; for (let i = 0; i < 300 && !game.leaving; i++) { // 只等当前语音链播完或抉择武装;CALM/MEMORY 有定时器,绝不用裸 waitIdle await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed); if (game.leaving) break; if (game.choiceArmed) { const pick = choices[choiceIdx]; if (!pick) break; // 抉择走完(理论上到 p9 后应已进 ENDING) if (!invalidTried) { invalidTried = true; ctx.note(pick.why + '——但先上滑听一遍现在在哪、他信我几分'); await ctx.gesture('up'); await ctx.waitCond(() => !game.voicePlaying); ctx.status('听完状态链(第几幕/信任档/真相度),游戏自动重播了选项'); await ctx.waitCond(() => game.choiceArmed); } if (!menuDone) { menuDone = true; ctx.note('下滑看看菜单有什么'); await ctx.gesture('down'); await ctx.waitCond(() => !game.voicePlaying); ctx.status('菜单里,切一项听听'); await ctx.gesture('tap'); // 切一项听听(报名接在当前报名之后,不截断) await ctx.waitCond(() => !game.voicePlaying); ctx.note('听完了,下滑关闭菜单,回到选项'); await ctx.gesture('down'); // 下滑关闭(不依赖菜单项序号,绝不误触「重开/回大厅」) await ctx.waitCond(() => game.choiceArmed); } ctx.status('选项念完了,做决定'); ctx.note(pick.why); await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗) choiceIdx++; continue; } if (game.phase === 'ENDING') { await ctx.waitCond(() => game.awaitingRestart); continue; } if (game.phase === 'CALM') { ctx.status(`第${ACT_LABEL[(game.act || 1) - 1]}幕过去,雾角报平安,岸台通报风暴进程`); ctx.note('幕间喘口气,听风往哪走'); await ctx.waitVirtual(2600); continue; } if (game.phase === 'MEMORY') { ctx.note('唱针落下了……这是回忆,别慌,听完风声就回来'); await ctx.waitCond(() => game.phase !== 'MEMORY'); ctx.status('风声回来了,回到现在'); continue; } // WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。 // 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual // 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。 if (!walkedAway && game.phase === 'WATCH' && !game.choiceArmed) { walkedAway = true; ctx.silence(25, '听到一半走了神,在想父亲那支手电'); await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue ctx.status('听到一声轻提醒和「单击屏幕,继续」,马上点'); await ctx.gesture('tap'); continue; } if (!helpDone && game.nodeId === 'amo_c') { helpDone = true; ctx.note('他爷爷四十年前在这片海没的……这句话有分量,双击重听一遍'); await ctx.gesture('doubletap'); await ctx.waitCond(() => !game.voicePlaying); ctx.status('重听完,接着值守'); } await ctx.gesture('tap'); // 链末单击 = 继续 } await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart); ctx.status('结局链走完,尾声把这一夜逐项点名——阿澈、灯的记录簿、祖父的日志、父亲的班次'); ctx.note('听完岸台那笔账——人和真相都接住了(E1 灯与归人),会话结束'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样能走到 ENDING。 // 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20260914; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap']; const pick = (arr) => arr[Math.floor(rng() * arr.length)]; let menuOpenCount = 0; let choiceCount = 0; let answered = 0; let noiseCount = 0; let memCount = 0; for (let i = 0; i < 400; i++) { await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed); if (game.leaving) break; if (game.phase === 'MENU') { menuOpenCount++; await ctx.gesture('tap'); // 乱切换 await ctx.waitCond(() => !game.voicePlaying); await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」) continue; } if (game.choiceArmed) { choiceCount++; if (rng() < 0.35) { // 好奇宝宝:犹豫时乱试一个不推进剧情的手势 noiseCount++; await ctx.gesture(pick(['up', 'tap', 'doubletap'])); await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying); } await ctx.gesture(pick(['left', 'right'])); continue; } if (game.phase === 'CALM') { await ctx.waitVirtual(2600); continue; } if (game.phase === 'MEMORY') { memCount++; if (rng() < 0.4) { // 回忆里也手痒:双击重听当前回忆节点 / 下滑开菜单再关 noiseCount++; await ctx.gesture(rng() < 0.5 ? 'doubletap' : 'down'); await ctx.waitCond(() => !game.voicePlaying || game.phase !== 'MEMORY'); if (game.phase === 'MENU') { await ctx.gesture('down'); // 关掉,回忆还在原地等我 await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH'); } } await ctx.waitCond(() => game.phase !== 'MEMORY'); continue; } if (game.phase === 'ENDING') { await ctx.waitCond(() => game.awaitingRestart); break; } // WATCH 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默) noiseCount++; answered++; await ctx.gesture(pick(gestures)); } await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart); ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次、穿过 ${memCount} 段回忆`); ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完'); }