storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
247 lines
13 KiB
JavaScript
247 lines
13 KiB
JavaScript
/**
|
||
* 《声命线》新手会话剧本(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('全程没有任何一次手势把游戏按死——结局链照样走完');
|
||
}
|