/** * 《声命线》新手会话剧本(26 号重构版,radio/keeper 范式)。 * * 交互口径(web/storyline/game.js 26 号状态×手势表): * - 发言权管理器:整条旁白链 = SPEAKING(单击跳句由管道处理,剧本不抢话); * 链完 → yieldTurn:交接提示音 → 等待声 → 三档提示。 * - 单击 = 继续;抉择点单击 = 「再说一遍:」重听选项;结局后单击 = 重新开始。 * - 左/右滑 = 抉择(旁白没听完就滑,只跳去选项不当场盲选);上滑 = 「我在哪」 * (阶段 1 解锁);下滑 = 菜单(阶段 2,一局结束才解锁);双击 = 重听(阶段 3)。 * - 开场(§4.10):第一句 = critical 指令「点一下屏幕。」,砍 26 秒规则念白; * 第一下单击 → 旁白 + 「轮到你了」状态教学 → 第二下单击进正片。 * - 菜单:单击换一项、右滑确认(第 1 项重新开始 / 第 2 项回大厅 / 第 3 项返回故事)。 * 旧 voicePlaying/choiceArmed/awaitingRestart API 已废,话轮判据 = floor 等待点。 * * 剧本: * - `session`(主线):林晚的完整弧线——开场第一句就动手 → 逐节点听完再接话, * 七个抉择都给出人味理由(查收音机 / 听哨声 / 带不走孩子先藏好 / 走涵洞 / * 打过狗群 / 加密频段),以 E1「黎明」收尾:中途主动重听一次选项、上滑问一次 * 「我在哪」、走神一次听三档提示、演示一次语速 2.2 切换、结局后逛一圈菜单 * (下滑此刻才解锁),最后单击回到故事开始的地方。 * - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳节点、菜单里乱逛、 * 无效手势乱试探,一样要走到一个结局,全程零死锁、零静默死局。 * * 剧本失真红线(参照 radio/keeper 剧本先例): * - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN 且链已收,链级判据) * 之后才出手——voiceBusy/isVoicePlaying 是句级信号、链内句间会闪断,不能当门控; * - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示,链一停就接话; * - 等待点挂着三档提示定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待; * - 菜单里右滑=确认:只确认第 3 项「返回故事」,绝不碰「重新开始/回大厅」 * (回大厅会结束会话);开场前奏(nodeId 尚空)不出上下滑——那两个手势 * 在没有节点时会落进未定义状态。 */ // ---------------- 26 号 API:话轮判据(等待点 id:intro/continue/choice/settle/menu/reopen) const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN'; const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null); const atChoice = (game) => atWait(game) && wpId(game) === 'choice'; const atSettle = (game) => atWait(game) && wpId(game) === 'settle'; export function describe(game) { if (game.mode === 'MENU') { return '菜单里:单击换一项,右滑确认(第1项重新开始/第2项回大厅/第3项返回故事),上滑重听说明'; } if (game.mode === 'REOPEN') return '检测到存档:单击继续上次,右滑从头开始,上滑听「我在哪」'; if (game.prelude === 0) { return game.floor.speaking ? '开场指令正在播:「点一下屏幕。」' : '等待声在响:点一下,开始'; } if (game.nodeId == null) return '开场教学听完了:单击,正式进剧情'; if (game.floor.speaking) return `旁白正在播(${game.nodeId ?? '开场'}),先听完`; if (atChoice(game)) return `「请选择」念完了(节点 ${game.nodeId}):向左或向右滑做出你的决定`; if (atSettle(game)) return '结局听完了。单击回到故事开始的地方,下滑开菜单(刚解锁)'; return `剧情停下来了(节点 ${game.nodeId}):单击继续,上滑听「我在哪」`; } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击 await ctx.waitCond(() => atWait(game)); ctx.status('开场第一句就是指令,不是 26 秒规则念白'); ctx.note('心跳声里一句「点一下屏幕」——点'); await ctx.gesture('tap'); // 第一段旁白 + 「听到这个声音,就是轮到你了」状态教学 → 单击进正片 await ctx.waitCond(() => atWait(game)); ctx.status('旁白两句 + 状态教学:等待声就是「轮到你了」'); ctx.note('大停电第三十天——睁眼,单击继续'); await ctx.gesture('tap'); // → n00(链尾教「单击可跳过旁白」) let relistened = false; let askedWhere = false; let walkedAway = false; let menuVisited = false; let speedDemoDone = false; let speedBackDone = false; let choiceIdx = 0; // 抉择路径(E1 黎明):p1 查收音机 / p2 听哨声(知陷阱)/ p3 循哭声 / // p4 藏好孩子过江回来接 / p5 涵洞 / p6 打过狗群 / p8 加密频段 const choices = [ { dir: 'left', why: '先查收音机——林晨的暗号可能在频道里' }, { dir: 'right', why: '哨声不对劲。屏住呼吸,听它想干什么' }, { dir: 'left', why: '哭声。装作没听见,我做不到' }, { dir: 'right', why: '断桥扛不住两个人的重量——把她藏好,过江回来接' }, { dir: 'left', why: '涵洞又冷又臭,但狗不会跟下水' }, { dir: 'right', why: '狗群就在塔下。坐标在我脑子里,打过去' }, { dir: 'right', why: '猎狗在听公开频道——用加密频段' }, ]; for (let i = 0; i < 200 && !menuVisited; i++) { // 只等当前语音链播完、话轮交出;等待点挂着提示定时器,绝不用裸 waitIdle await ctx.waitCond(() => atWait(game)); // 结局:先逛一圈菜单(下滑此刻才真正解锁,教学句挂在结局链尾) if (atSettle(game)) { menuVisited = true; ctx.note('结局链尾教了「向下滑,可以打开菜单」——去看看'); await ctx.gesture('down'); await ctx.waitCond(() => atWait(game)); ctx.status('菜单第一项「重新开始」——单击换一项听听'); await ctx.gesture('tap'); // → 第 2 项「回大厅」 await ctx.waitCond(() => atWait(game)); ctx.status('「回大厅」——不去;再换'); await ctx.gesture('tap'); // → 第 3 项「返回故事」 await ctx.waitCond(() => atWait(game)); ctx.note('「返回故事」——右滑确认,回结局'); await ctx.gesture('right'); // 确认第 3 项 → 关菜单重播结局 await ctx.waitCond(() => atSettle(game)); ctx.status('菜单关了,结局又听了一遍——离开时什么都没碰坏'); break; } if (atChoice(game)) { const pick = choices[choiceIdx]; if (!pick) break; // 抉择走完(p8 之后应已进结局) // p2 起上滑已解锁:犹豫时点一下重听选项(原则 9 主动重听) if (!relistened && choiceIdx >= 1) { relistened = true; ctx.note(pick.why + '——没听清选项,点一下,让它「再说一遍」'); await ctx.gesture('tap'); await ctx.waitCond(() => atWait(game)); ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上'); await ctx.waitCond(() => atChoice(game)); } // F9:演示一次语速切换——2.2 档对下一句即时生效,听到断桥就调回 1.0 if (!speedDemoDone && game.nodeId === 'n10') { speedDemoDone = true; ctx.note('旁白有点慢,去设置把语速调到 2.2(下一句生效)'); ctx.settings.set({ speed: 2.2 }); } ctx.status('选项念完了,做决定'); ctx.note(pick.why); const atBridge = game.nodeId === 'n15'; // 滑出去之后节点就换了,先记住 await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档) if (choiceIdx === 0) { ctx.status('第一个小目标达成——下一段链尾教了「想知道自己在哪,就向上滑」'); ctx.note('做完第一件事才教下一件事,一句话只教一个'); } if (speedDemoDone && !speedBackDone && atBridge) { speedBackDone = true; // 听了两段(n11-n15),把语速调回 1.0 再过江 ctx.note('雨声听不清了——把语速调回 1.0'); ctx.settings.set({ speed: 1.0 }); } choiceIdx++; continue; } // 上滑 = 「我在哪、我该干嘛」(第一幕解锁后用一次):位置 → 目标 → 动作 if (!askedWhere && game.nodeId === 'n06') { askedWhere = true; ctx.note('向上一滑——我在哪、我该干嘛'); await ctx.gesture('up'); await ctx.waitCond(() => atWait(game)); ctx.status('「第一幕,地铁站。找林晨。单击继续。」——一句话说全了'); continue; } // 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。 // 注意不要在这里 waitVirtual 小憩——等待点挂着三档提示定时器,waitVirtual // 会一口气跳到下一档提示(18s full),剧本就变成「每句干晾到提示响」。 if (!walkedAway && game.nodeId === 'n05') { walkedAway = true; ctx.silence(18, '听到一半走了神,在想林晨的暗号'); await ctx.waitVirtual(18600); // 触发等待点提示 full 档(18s):「单击,继续。」 ctx.status('一句「单击,继续」把我拉回来——点'); await ctx.gesture('tap'); continue; } await ctx.gesture('tap'); // 链末单击 = 继续 } // 尾声:单击回到故事开始的地方(重开不再念教学——做对过的事不教第二遍) await ctx.waitCond(() => atSettle(game)); ctx.status('结局链走完——「这座城市,是从一个女人的声音开始,重新活过来的」'); ctx.note('单击,回到故事开始的地方'); await ctx.gesture('tap'); // settle → _restart → n00 await ctx.waitCond(() => atWait(game)); ctx.status('回到地铁站,开场教学没有再念一遍——会话收工(E1 黎明)'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样要走到一个结局。 // 剧本特征:不读提示、快速乱划、菜单里乱逛、无效手势乱试探,但保留「最终能通关」底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20261002; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const pick = (arr) => arr[Math.floor(rng() * arr.length)]; let menuOpenCount = 0; let choiceCount = 0; let noiseCount = 0; let settled = false; for (let i = 0; i < 400; i++) { await ctx.waitCond(() => atWait(game)); // 链级门控:不抢话 // 菜单:乱逛换项,最后停在第 3 项「返回故事」右滑确认退出 // (右滑=确认:绝不碰第 1 项「重新开始」之外的确认位与第 2 项「回大厅」) if (game.mode === 'MENU') { menuOpenCount++; while (game.mode === 'MENU' && game._menuIdx !== 2) { await ctx.gesture('tap'); // 单击换一项(乱逛) await ctx.waitCond(() => atWait(game) || game.mode !== 'MENU'); } await ctx.gesture('right'); // 确认第 3 项:关菜单回剧情(重播当前节点) await ctx.waitCond(() => atWait(game) || game.mode !== 'MENU'); continue; } if (atSettle(game)) { settled = true; break; } if (atChoice(game)) { choiceCount++; if (rng() < 0.35) { // 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/乱点) noiseCount++; await ctx.gesture(pick(['tap', 'up', 'doubletap'])); await ctx.waitCond(() => atWait(game) || game.mode === 'MENU'); continue; } await ctx.gesture(pick(['left', 'right'])); continue; } // 开场前奏(nodeId 尚空):只点/滑安全键——上滑下滑此刻会落进未定义状态 if (game.nodeId == null) { noiseCount++; await ctx.gesture(pick(['tap', 'tap', 'left'])); continue; } // 叙事等待态:乱划一下(无效手势也要出手,零静默;down 可能误入菜单,下轮兜住) noiseCount++; await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap'])); } await ctx.waitCond(() => atSettle(game)); ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、误入菜单 ${menuOpenCount} 次${settled ? '' : '(收尾补等结局)'}`); ctx.note('全程没有任何一次手势把游戏按死——结局链照样走完'); }