Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

247 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《声命线》新手会话剧本(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('全程没有任何一次手势把游戏按死——结局链照样走完');
}