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 置完成。
319 lines
16 KiB
JavaScript
319 lines
16 KiB
JavaScript
/**
|
||
* 《暗码》新手会话剧本(26 号重构版:叙事+解码型,radio 范式)。
|
||
*
|
||
* 剧本:
|
||
* - `session`(主线):一位夜班报务员的完整弧线——开场第一句「点一下屏幕。」就动手 →
|
||
* 「轮到你了」状态教学 → 第一封电文摩尔斯首播(BUSY 不可跳)→ 浏览候选
|
||
* (左右滑自动预听对照档 → 静默一拍 → 词义句)→ 单击进确认(电文原速重播)→
|
||
* 再单击定下;四封电文各做一次处置抉择(p2 报北风 / p4 呈报 / p6 留书 /
|
||
* p8 装傻 / p9 发最后一电),中途主动重听一次选项(「再说一遍:」)、
|
||
* 上滑问一次状态(第几封 → 密码本余页 → 暴露档)、走神一次听三档提示、
|
||
* 结局后逛一圈菜单(阶段 2 下滑此时才真解锁),以完整 E1「最后一电」收尾。
|
||
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、确认态反复进出、菜单里乱逛、
|
||
* 阶段 3 双击把电文听到慢放,一样要能走到 ENDING,全程零死锁、零静默死局。
|
||
*
|
||
* 剧本失真红线(参照 radio/storyline 剧本先例):
|
||
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
||
* 出手——BUSY 演出段(电文摩尔斯首播 / T4 danger 拍前置 2s / 确认电文重播)
|
||
* 真机里是 busy 回执,剧本同样等它走完;
|
||
* - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示;
|
||
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||
* - DECODE 里每步都是真输入:切候选听对照、进确认、定下,像真人一样先听后按
|
||
* (旧「双击重听/慢放、长按翻密码本」口径已废止:双击=阶段 3,翻本走菜单项);
|
||
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(有界循环),抉择则等提示句
|
||
* 播完武装后再滑(盲玩公平,不抢话)。
|
||
*/
|
||
|
||
const TEL_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' && game.awaitingRestart;
|
||
|
||
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') {
|
||
if (game.floor.speaking) {
|
||
return game.runs <= 1 ? '开场指令正在播:「点一下屏幕。」' : '新夜班口播正在播';
|
||
}
|
||
return '等待声在响:点一下,接第一封电文';
|
||
}
|
||
if (game.phase === 'RX') {
|
||
return game._rxGuard
|
||
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳(手势只有 busy 回执)`
|
||
: `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`;
|
||
}
|
||
if (game.phase === 'DECODE') {
|
||
if (game.confirming) return '进确认了:电文正在原速重播,再单击就定下来,左右滑可以取消';
|
||
return game.floor.speaking
|
||
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词(摩尔斯对照档 → 一拍 → 词义)`
|
||
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听对照档,单击进确认`;
|
||
}
|
||
if (atChoice(game)) {
|
||
return `抉择念完了(第 ${TEL_LABEL[game.tel - 1]} 封处置):向左或向右滑做出你的决定`;
|
||
}
|
||
if (game.phase === 'RELAY') {
|
||
return game.floor.speaking ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
|
||
}
|
||
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
|
||
if (game.phase === 'ENDING') {
|
||
return atSettle(game)
|
||
? '结局和尾声点名都听完了。单击开新夜班,下滑开菜单'
|
||
: '结局播报中';
|
||
}
|
||
return '……';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 有界推进助手
|
||
|
||
/** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */
|
||
async function untilChoice(ctx, maxSteps = 40) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
if (atChoice(game)) return;
|
||
if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)');
|
||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问');
|
||
await ctx.waitCond(() => atChoice(game) || atWait(game));
|
||
if (atChoice(game)) return;
|
||
await ctx.gesture('tap'); // 链末 = 继续
|
||
}
|
||
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
|
||
/**
|
||
* 从任意叙事态推进到 DECODE 浏览武装(穿越 RX 首播摩尔斯 / NIGHT 自动链 /
|
||
* RELAY 继续等待段;T4 danger 拍前置 2s 的 BUSY 演出窗也在这里等过去)。
|
||
*/
|
||
async function toDecode(ctx, maxSteps = 60) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps && !(game.phase === 'DECODE' && game.browseArmed); i++) {
|
||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问');
|
||
if (game.phase === 'ENDING') throw new Error('toDecode 遇 ENDING');
|
||
if (game.phase === 'DECODE') { await ctx.waitCond(() => game.browseArmed); continue; }
|
||
if (atWait(game)) { await ctx.gesture('tap'); continue; }
|
||
// 自动流(摩尔斯首播/夜链/danger 前置):话轮不在手上,有界等推进或回到等待态
|
||
await ctx.waitCond(() => (game.phase === 'DECODE' && game.browseArmed) || atWait(game));
|
||
}
|
||
if (!(game.phase === 'DECODE' && game.browseArmed)) {
|
||
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
}
|
||
|
||
/** 从 p9 抉择后推进到 ENDING 结算态(d9 结果链 → 结局链 → 尾声点名,自动流转)。 */
|
||
async function toEnding(ctx, maxSteps = 40) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps && !atSettle(game); i++) {
|
||
if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); continue; }
|
||
if (atWait(game)) { await ctx.gesture('tap'); continue; }
|
||
await ctx.waitCond(() => atSettle(game) || atWait(game) || game.phase === 'ENDING');
|
||
}
|
||
if (!atSettle(game)) throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
|
||
/** 等候选词义句听完(自动预听链:候选摩尔斯 → 静默一拍 → 词义句;被让路则自然跳过)。 */
|
||
async function listenCand(ctx) {
|
||
const { game } = ctx;
|
||
await ctx.waitCond(() => game.floor.speaking || atWait(game));
|
||
await ctx.waitCond(() => atWait(game));
|
||
}
|
||
|
||
/**
|
||
* DECODE 小局(26 号口径):听完当前候选预听 → 左右滑把候选对到 idx(每切自动
|
||
* 预听对照档)→ 单击进确认(电文原速重播)→ 再单击定下。
|
||
*/
|
||
async function decodeWord(ctx, idx, why) {
|
||
const { game } = ctx;
|
||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && atWait(game));
|
||
ctx.note(`${why}——先把候选一个一个对耳朵`);
|
||
await listenCand(ctx);
|
||
const delta = (idx - game.candIdx + 3) % 3;
|
||
for (let i = 0; i < delta; i++) {
|
||
await ctx.gesture('right'); // 切候选:候选摩尔斯对照档(620Hz)→ 一拍 → 词义句
|
||
await listenCand(ctx);
|
||
}
|
||
ctx.note('就这个词——单击,进确认(电文会再拍一遍原速)');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitCond(() => wpId(game) === 'confirm' && atWait(game));
|
||
ctx.note('对上了。再单击,定下来');
|
||
await ctx.gesture('tap');
|
||
}
|
||
|
||
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
|
||
async function relayChoice(ctx, dir, why) {
|
||
await untilChoice(ctx);
|
||
ctx.note(why);
|
||
await ctx.gesture(dir);
|
||
const { game } = ctx;
|
||
await ctx.waitCond(() => game.floor.speaking || !game.phase.startsWith('RELAY') || atWait(game));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主线剧本
|
||
|
||
export async function session(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 风铃 + 等待声 → 第一下单击
|
||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||
ctx.status('开场第一句就是指令「点一下屏幕。」,没有规则念白');
|
||
ctx.note('等待声就是「轮到你了」——点一下,接第一封');
|
||
await ctx.gesture('tap');
|
||
ctx.status('「听到这个声音,就是轮到你了。」说完,调谐声里进来了第一封电文');
|
||
await toDecode(ctx);
|
||
ctx.status('第一封摩尔斯拍完了,教程只教一句:向右滑,听下一个词');
|
||
|
||
// T1 船期(教学封:BAY;译错=即时归因+无限重选零惩罚,本线一次译对)
|
||
await decodeWord(ctx, 0, '教学封 BAY:货船。B,一长三短');
|
||
ctx.status('「货船,译对了。」链尾还教了「想知道自己在哪,就向上滑」');
|
||
await untilChoice(ctx);
|
||
ctx.note('药品船丑时过江——先点一下,让它把选项再说一遍');
|
||
await ctx.gesture('tap'); // 抉择点空点 = 主动重听(「再说一遍:」+ 完整句)
|
||
await ctx.waitCond(() => atWait(game));
|
||
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
||
await ctx.waitCond(() => atChoice(game));
|
||
ctx.note('报给北风,江上有人等这船药');
|
||
await ctx.gesture('left');
|
||
ctx.status('第一封处置完了,夜结算把开场没讲的规矩补在链首');
|
||
|
||
// T2 名单(TOP)
|
||
await toDecode(ctx);
|
||
ctx.status('第二封电文:内部通报,手法很急');
|
||
ctx.note('上滑问一句「我在哪」——第几封、密码本还剩几页、今晚危不危险');
|
||
await ctx.gesture('up');
|
||
await ctx.waitCond(() => atWait(game) || game.phase !== 'DECODE');
|
||
await ctx.waitCond(() => atWait(game));
|
||
ctx.status('状态链念完:第二封 · 密码本三页 · 还没人看我第二眼');
|
||
await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员');
|
||
await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重');
|
||
ctx.status('第二封处置完了,卷宗里落了笔');
|
||
|
||
// T3 交接(HAND):走神一次,听三档提示的第一档
|
||
await toDecode(ctx);
|
||
ctx.status('第三封电文:很短,手法有点抖');
|
||
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答');
|
||
await untilChoice(ctx);
|
||
ctx.silence(15, '犹豫要不要亲自跑一趟,在想值班室空岗的后果');
|
||
await ctx.waitVirtual(15600); // continue 等待点 13s 档:「单击,继续。」
|
||
ctx.status('听到一声「单击,继续」,马上点');
|
||
await ctx.gesture('tap');
|
||
ctx.note('留书暗号,让雨替我跑这一趟');
|
||
await ctx.gesture('right');
|
||
ctx.status('第三封处置完了,交接赶上了');
|
||
|
||
// T4 处置令(SPY · 高潮:danger 拍前置 2s,脚步声渐强,手势全是 busy)
|
||
await toDecode(ctx);
|
||
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
|
||
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
|
||
ctx.note('揭晓前的两秒,只有渐强的脚步声:这是危机拍,什么手势都打断不了');
|
||
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
|
||
ctx.status('处长的试探应付过去了,频道要收了');
|
||
|
||
// 签收:g9_in → g9_knot → p9
|
||
await untilChoice(ctx);
|
||
ctx.note('开机,发最后一电——三年前我译错了一封,这次不能错');
|
||
await ctx.gesture('left');
|
||
await toEnding(ctx);
|
||
ctx.status('结局播完了:尾声把今晚每一封都点了名');
|
||
|
||
// 第一局打完:下滑菜单此刻才真解锁(教学句挂在结局链尾),进去逛一圈
|
||
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
||
await ctx.gesture('down');
|
||
await ctx.waitCond(() => game.phase === 'MENU' && atWait(game));
|
||
ctx.status('菜单名和第一项「回到电波」报完了');
|
||
await ctx.gesture('right'); // 下一项报名:「翻密码本」(长按捷径废止后的正门)
|
||
await ctx.waitCond(() => atWait(game));
|
||
ctx.status('听完「翻密码本」的报名,不动它');
|
||
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
||
await ctx.waitCond(() => atSettle(game));
|
||
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
||
|
||
ctx.note('单击,收工——但下一班夜班,还想再来');
|
||
await ctx.gesture('tap');
|
||
await ctx.waitCond(() => game.floor.speaking || atWait(game));
|
||
ctx.status('新的夜班开始了(再来一局,不再教「点一下屏幕」)');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 乱按压力剧本
|
||
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
||
// 剧本特征:不读提示、快速跳句、确认态反复进出、菜单里乱逛、错手势试探,
|
||
// 但仍保留「最终能通关」的底线(菜单里绝不单击确认,避免误触「重开/回大厅」)。
|
||
|
||
export async function sessionStress(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
let seed = 20260902;
|
||
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 decodeCount = 0;
|
||
let noiseCount = 0;
|
||
let browseIdle = 0; // 浏览态连续没进展的步数(防乱按卡死在某一封)
|
||
|
||
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
|
||
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
||
await ctx.gesture('tap');
|
||
|
||
for (let i = 0; i < 400 && !game.leaving; i++) {
|
||
// 话轮不在手上(摩尔斯首播/danger 前置/自动链):有界等,绝不空转
|
||
await ctx.waitCond(() => atWait(game) || game.phase === 'ENDING');
|
||
|
||
if (game.leaving) break;
|
||
if (game.phase === 'MENU') {
|
||
menuOpenCount++;
|
||
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
|
||
await ctx.waitCond(() => atWait(game));
|
||
await ctx.gesture('down'); // 下滑关闭(不单击确认,绝不误触「重开/回大厅」)
|
||
continue;
|
||
}
|
||
if (atSettle(game)) break;
|
||
if (game.phase === 'ENDING') {
|
||
await ctx.waitCond(() => atSettle(game));
|
||
continue;
|
||
}
|
||
if (atChoice(game)) {
|
||
choiceCount++;
|
||
if (rng() < 0.35) {
|
||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听选项/问状态/无效)
|
||
noiseCount++;
|
||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||
await ctx.waitCond(() => atWait(game));
|
||
}
|
||
await ctx.gesture(pick(['left', 'right'])); // 乱选也要选,剧情照走
|
||
continue;
|
||
}
|
||
if (game.phase === 'DECODE') {
|
||
// 浏览:切候选/问状态/阶段 3 双击重听电文原速→慢放,都可乱来;
|
||
// 确认态:单击=定下(乱按也会收敛——T1 译错即时归因无限重选)
|
||
decodeCount++;
|
||
if (game.confirming || browseIdle >= 4 || rng() < 0.4) {
|
||
browseIdle = 0;
|
||
await ctx.gesture('tap');
|
||
} else {
|
||
browseIdle++;
|
||
await ctx.gesture(pick(['left', 'right', 'up', 'doubletap', 'tap']));
|
||
}
|
||
// 电文重播/预听链是有界自动流:等它落回等待态或进下一阶段
|
||
await ctx.waitCond(() => atWait(game) || game.phase === 'RELAY' || game.phase === 'NIGHT');
|
||
continue;
|
||
}
|
||
// 叙事等待态 / RX 外的静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||
noiseCount++;
|
||
await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap']));
|
||
}
|
||
|
||
await ctx.waitCond(() => atSettle(game) || game.leaving);
|
||
ctx.status(`乱按剧本收尾:解码小局 ${decodeCount} 步、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
||
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||
}
|