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

237 lines
11 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.
/**
* 《深海回音》新手会话剧本。
*
* 剧本:
* - `session`(主线):一位深潜救援员的完整弧线——听完甲板简报、四潜全下
* (每潜:单击发脉冲 → 听回波 → 通讯 → 抉择)、上浮结算与补给照单全收、
* 走双载第四潜,最后用省下的气下去取船钟,以完整 E1「海燕归钟」收尾
* (全右省气 + p9 左 = §4.5 的 P4 友好默认路径)。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)或抉择武装后才出手;
* - 玩家不干晾自己:只在 COMMS 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
* - 搜索等待挂着看门狗定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - 通讯里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
*/
const DIVE_LABEL = ['一', '二', '三', '四'];
function diveLabel(n) {
return n >= 1 && n <= 4 ? DIVE_LABEL[n - 1] : '·';
}
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') {
return game.voicePlaying ? '甲板简报正在播(就一句目标)' : '简报播完了,等单击下潜';
}
if (game.phase === 'DIVE') {
if (game.searchArmed) return '声呐静了,轮到你了:单击发一枚脉冲';
return game.voicePlaying ? `第${diveLabel(game.diveN)}潜下潜中,调度在报位` : '……';
}
if (game.choiceArmed) {
return `「请选择」念完了(第${diveLabel(game.diveN)}潜):向左或向右滑做出你的决定`;
}
if (game.voicePlaying) return `第${diveLabel(game.diveN)}潜通讯中,正在听对方说话`;
if (game.phase === 'COMMS') return '对方说完了,等我接话(链末单击继续)';
if (game.phase === 'SURFACE') {
return game.voicePlaying ? '上浮结算播报中(扣账/补给/档位)' : '结算链间隙,等单击继续';
}
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和逐人回响都听完了。单击再来一局,下滑开菜单'
: '结局播报中';
}
return '……';
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
ctx.note('开局:第一句就让我点屏幕——点完听目标,第一潜开始后声呐会静下来');
let firstPing = false;
let firstEcho = false;
let invalidTried = false;
let menuVisited = false;
let walkedAway = false;
let choiceIdx = 0;
// 抉择路径(全右省气 + p9 左取船钟 → e1 海燕归钟):
// p1 直接上 / p2 不等日志 / p3 快切 / p4 直接上浮 / p5 先带小郑 /
// p6 约定来接 / p7 快浮 / p8 不问 / p9 用最后的气下去取钟
const choices = [
{ dir: 'right', why: '小舟先上来,暗号回头再说' },
{ dir: 'right', why: '日志可以补,人不能等' },
{ dir: 'right', why: '切割枪快切,老白憋住气' },
{ dir: 'right', why: '阀门交给母船,腿要紧' },
{ dir: 'right', why: '小郑重伤先走,陈医官下一潜就是你的' },
{ dir: 'right', why: '四小时内我下来,等我' },
{ dir: 'right', why: '来不及了,直浮!医疗舱的灯先开好' },
{ dir: 'right', why: '纪律就是纪律,拉他上浮' },
{ dir: 'left', why: '最后一潜。不为救人,为个交代——下去取船钟' },
];
for (let i = 0; i < 300 && !game.leaving; i++) {
// 只等当前语音链播完 / 抉择武装 / 搜索轮到自己;绝不用裸 waitIdle
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (game.leaving) break;
if (game.searchArmed && game.phase === 'DIVE') {
if (!firstPing) {
firstPing = true;
ctx.status(`第${diveLabel(game.diveN)}潜到深度了,声呐静了`);
ctx.note('调度说了:单击,发一枚脉冲——耳朵交给声呐');
} else {
ctx.status(`第${diveLabel(game.diveN)}潜,再点一枚`);
}
await ctx.gesture('tap');
if (!firstEcho) {
firstEcho = true;
await ctx.waitCond(() => game.phase === 'COMMS' || !game.voicePlaying);
if (game.phase === 'COMMS') ctx.status('金属回波后面有杂音——电台接通了,小舟的声音进来了');
}
continue;
}
if (game.choiceArmed) {
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 || !game.voicePlaying);
if (!game.choiceArmed) continue; // 没回到武装态就再等一轮
}
ctx.status('选项念完了,做决定');
ctx.note(pick.why);
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
choiceIdx++;
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart && !game.voicePlaying);
if (!menuVisited) {
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
menuVisited = true;
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
await ctx.gesture('down');
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('菜单说明和第一项「回到水下」报完了');
await ctx.gesture('right'); // 右滑下一项报名(不单击,绝不误触确认)
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('听完「重新开始这次救援」的报名,不动它');
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
await ctx.waitCond(() => game.awaitingRestart && !game.voicePlaying);
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
}
break; // 会话收尾(结局听完、菜单逛过)
}
if (game.phase === 'SURFACE') {
// 上浮结算自动链(扣账/补给/档位)——播完自动进下一潜;签收链中段
// (g9_knot → p9)是链末等待,单击继续
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (!game.voicePlaying && !game.choiceArmed && !game.searchArmed) {
await ctx.gesture('tap'); // 链真停了(非句间):签收链中段单击继续
}
continue;
}
// COMMS 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
// 不要在这里 waitVirtual 小憩——等待点挂着静默提示定时器,waitVirtual 会
// 一口气跳到下一档提示(13s full),剧本就变成「每句干晾到提示响」。
if (!walkedAway && game.phase === 'COMMS' && !game.choiceArmed) {
walkedAway = true;
ctx.silence(14, '听到一半走了神,在想氧气还剩几格');
await ctx.waitVirtual(14600); // 触发等待点第一档提示:「单击,继续。」
ctx.status('听到一句轻提「单击,继续」,马上点');
await ctx.gesture('tap');
continue;
}
await ctx.gesture('tap'); // 链末单击 = 继续
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart && !game.voicePlaying);
ctx.status('结局链走完,尾声逐人点到我每一潜的救与错过');
ctx.note('船钟出水,五个人都在甲板上——会话结束(E1 海燕归钟)');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但保留「最终能通关」底线。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260910;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
let menuOpenCount = 0;
let choiceCount = 0;
let pingCount = 0;
let noiseCount = 0;
for (let i = 0; i < 500; i++) {
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (game.leaving) break;
if (game.searchArmed && game.phase === 'DIVE') {
pingCount++;
await ctx.gesture('tap'); // 声呐静着就一直点——单击恒=发脉冲
continue;
}
if (game.phase === 'MENU') {
menuOpenCount++;
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名,不确认防误触)
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('down'); // 下滑关闭(不单击确认,避免误触「重开/回大厅」)
continue;
}
if (game.choiceArmed) {
choiceCount++;
if (rng() < 0.35) {
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/无效)
noiseCount++;
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
}
await ctx.gesture(pick(['left', 'right']));
continue;
}
if (game.phase === 'ENDING') {
await ctx.waitCond(() => game.awaitingRestart);
break;
}
if (game.phase === 'SURFACE') {
// 结算自动链:等它自己走完;签收链中段链末等待同样乱点一下(零静默)
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
if (!game.voicePlaying && !game.choiceArmed && !game.searchArmed) {
await ctx.gesture('tap');
}
continue;
}
// COMMS 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
noiseCount++;
await ctx.gesture(pick(gestures));
}
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
ctx.status(`乱按剧本收尾:脉冲 ${pingCount} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}