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 置完成。
This commit is contained in:
xsl
2026-10-02 12:33:20 +08:00
parent 9e4d67aa84
commit ee5d4d2544
6992 changed files with 30127 additions and 20451 deletions
+91 -65
View File
@@ -1,42 +1,54 @@
/**
* 《不眠频率》新手会话剧本。
* 《不眠频率》新手会话剧本(26 号重构版:叙事型,storyline 范式)。
*
* 剧本:
* - `session`(主线):一位夜间主播的完整弧线——听完开场白、接完五通电话、
* 每个抉择点都做选择(C1 走心 / C2 劝上车 / C3 问她+真话 / C4 带话+留在直播 /
* C5 陪聊 / p9 直面),听完五句尾声回响,以完整 E1「天亮之前」收尾。
* - `session`(主线):一位夜间主播的完整弧线——开场第一句「点一下屏幕」就动手 →
* 听热线灯教学接第一通 → 五通电话每个抉择都做选择(C1 走心 / C2 劝上车 /
* C3 问她+真话 / C4 带话+留在直播 / C5 陪聊 / p9 直面),中途主动重听一次选项、
* 走神一次听三档提示、结局后逛一圈菜单(阶段 2 下滑此时才真解锁),
* 以完整 E1「天亮之前」收尾。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 storyline/escape 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
* - RING/BRIDGE 有铃声/喘息定时器循环,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* 剧本失真红线(参照 storyline/rpg 剧本先例):
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
* 出手——voiceBusy 是句级信号、链内句间会闪断,不能当门控;BUSY 演出段
* (危机拍前置窗/幕间)真机里是 busy 回执,剧本同样等它走完;
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次等待点提示;
* - RING/BRIDGE 有铃声循环/喘息定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
*/
const CALL_LABEL = ['一', '二', '三', '四', '五'];
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
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) => game.phase === 'ENDING' && wpId(game) === 'settle';
export function describe(game) {
if (game.leaving) return '已确认保存并回大厅';
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜晚」的询问';
if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的夜晚」的询问';
if (game.phase === 'MENU') {
const names = ['回到直播', '重新开始今晚', '保存并回大厅'];
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
}
if (game.phase === 'INTRO') {
return game.voicePlaying ? '台标和规矩正在播' : '开场白播完了,等第一声铃';
return game.floor.speaking
? '开场指令正在播:「点一下屏幕。」'
: '等待声在响:点一下,进第一通';
}
if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:单击接听`;
if (game.choiceArmed) {
return `「请选择」念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑动做出你的决定`;
if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:铃声就是提示,点一下接听`;
if (atChoice(game)) {
return `选项念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑做出你的决定`;
}
if (game.voicePlaying) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
if (game.floor.speaking) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
if (game.phase === 'ONAIR') return `对方说完了,等我接话(第 ${CALL_LABEL[game.callN - 1]} 通):单击继续`;
if (game.phase === 'BRIDGE') return '幕间喘息,垫乐播放中,马上接下一通';
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和五条回响都听完了。单击再来一局,下滑开菜单'
return atSettle(game)
? '结局和五条回响都听完了。单击开新的夜班,下滑开菜单'
: '结局播报中';
}
return '……';
@@ -47,18 +59,20 @@ export function describe(game) {
export async function session(ctx, mock) {
const { game } = ctx;
// 开场:INTRO 自动流(台标口播 + 一句规矩 + 今晚目标)→ 第一声铃
await ctx.waitCond(() => game.phase === 'RING');
ctx.status('开场口令和台标播完了,第一通电话响了');
ctx.note('铃一响就接——接听是单击');
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('开场第一句就是指令,不是规则念白');
ctx.note('第一句就让我动手——点一下');
await ctx.gesture('tap');
ctx.status('线路接通了');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('导播报完线,老人开口了');
await ctx.waitCond(() => game.phase === 'RING');
ctx.status('「热线灯亮了。点一下,接听。」听完,铃声就是等待声');
await ctx.gesture('tap');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('线路接通了,导播报完线,老人开口了');
let invalidTried = false;
let menuDone = false;
let relistened = false;
let walkedAway = false;
let menuVisited = false;
let choiceIdx = 0;
// 抉择路径:C1 走心、C2 劝上车、C3 问她+真话、C4 带话+留在直播、C5 陪聊、p9 直面
@@ -76,47 +90,55 @@ export async function session(ctx, mock) {
for (let i = 0; i < 200 && !game.leaving; i++) {
// 只等当前语音链播完或抉择武装;RING/BRIDGE 有定时器,绝不用裸 waitIdle
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
if (game.leaving) break;
if (game.phase === 'RING') {
ctx.status(`第 ${CALL_LABEL[game.callN - 1]} 通电话响了,接`);
await ctx.gesture('tap');
await ctx.waitCond(() => !game.voicePlaying);
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
continue;
}
if (game.choiceArmed) {
if (atChoice(game)) {
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);
// p2 起上滑已解锁(阶段 1):犹豫时点一下重听选项(原则 9 主动重听)
if (!relistened && choiceIdx >= 1) {
relistened = true;
ctx.note(pick.why + '——没听清,先点一下,让它「再说一遍」');
await ctx.gesture('tap');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
await ctx.waitCond(() => atChoice(game));
}
ctx.status('选项念完了,做决定');
ctx.note(pick.why);
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
if (choiceIdx === 0) {
ctx.status('链头一声「对。」,链尾教了「想知道自己在哪,就向上滑」');
ctx.note('做完第一件事才教下一件事,一句话只教一个');
}
choiceIdx++;
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
if (!menuVisited && atSettle(game)) {
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
menuVisited = true;
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
await ctx.gesture('down');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('菜单名和第一项「回到直播」报完了');
await ctx.gesture('right'); // 下一项报名
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.status('听完「重新开始今晚」的报名,不动它');
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
await ctx.waitCond(() => atSettle(game));
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
continue;
}
await ctx.waitCond(() => atSettle(game));
continue;
}
if (game.phase === 'BRIDGE') {
@@ -125,20 +147,20 @@ export async function session(ctx, mock) {
continue;
}
// ONAIR 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
if (!walkedAway && game.phase === 'ONAIR' && !game.choiceArmed) {
// 注意不要在这里 waitVirtual 小憩——等待态挂着提示定时器,waitVirtual
// 会一口气跳到下一个定时器 tick(18s 提示档),剧本就变成「每句干晾 18s」。
if (!walkedAway && game.phase === 'ONAIR') {
walkedAway = true;
ctx.silence(25, '听到一半走了神,在想老人的老花镜');
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
await ctx.waitVirtual(25600); // 触发等待点提示 full 档(18s):「单击,继续。」
ctx.status('听到一声「单击,继续」,马上点');
await ctx.gesture('tap');
continue;
}
await ctx.gesture('tap'); // 链末单击 = 继续
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game));
ctx.status('结局链走完,五条回响一句句点到我今晚的每一次选择');
ctx.note('听完尾声那笔账——今晚的人都接住了,会话结束(E1 天亮之前)');
}
@@ -163,8 +185,12 @@ export async function sessionStress(ctx, mock) {
let answered = 0;
let noiseCount = 0;
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap');
for (let i = 0; i < 300; i++) {
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
if (game.leaving) break;
if (game.phase === 'RING') {
@@ -174,18 +200,18 @@ export async function sessionStress(ctx, mock) {
}
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture('tap'); // 乱切换
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('down'); // 下滑关闭(不单击确认,避免误触「重开/回大厅」)
continue;
}
if (game.choiceArmed) {
if (atChoice(game)) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/无效)
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
}
await ctx.gesture(pick(['left', 'right']));
continue;
@@ -195,7 +221,7 @@ export async function sessionStress(ctx, mock) {
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
await ctx.waitCond(() => atSettle(game));
break;
}
// ONAIR 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
@@ -203,7 +229,7 @@ export async function sessionStress(ctx, mock) {
await ctx.gesture(pick(gestures));
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game));
ctx.status(`乱按剧本收尾:接听 ${answered} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}