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

349 lines
17 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 范式)。
*
* 剧本:
* - `session`(主线):一位新舰长的首次巡逻全弧线——开场第一句「点一下屏幕」就
* 动手 → D0 上滑巡检(四系统音+账目档+解锁句)→ D1 听求救检测链 → D2 预览+下令
* 驰援(第一次成功 pa_chime+报酬)→ D3 再巡检 → 段末靠港启航 → S2 航线选择+
* 陨石迎战 → S3 海盗(大副支援)→ S4 辐射规避 → 结算链 → 单击返航;危机前
* 选项过完耳朵、犹豫时主动重听一次选项(阶段 3 口径由阶梯解锁状态决定)。
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、检测链乱滑
* (被解释并续完)、菜单里只换项不乱确认、危机挂到超时走默认结局——一路走到
* 结算(或弃舰),全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio/dungeon 剧本先例):
* - 玩家不抢话:出手前等真正等待态(floor.state === 'YOUR_TURN' 或玩法 ACTION 态
* 静默),检测链(纯聆听)绝不出手打断了事;voiceBusy 是句级信号不作门控;
* - 玩家靠耳朵玩:危机先听检测链(签名→类型→方位),下令前左右滑把选项过完
* 耳朵,巡检靠上滑不靠背;
* - 危机倒计时是真的:决策窗内动作紧凑,宁可超时也不抢检测链;
* - 菜单里只「下滑关闭」,破坏项(放弃巡逻)绝不确认(防误触结束会话)。
*/
const DIR_LABEL = { port: '左舷', star: '右舷', bow: '舰首', stern: '舰尾' };
const SYS_LABEL = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' };
const OPT_LABEL = { evade: '规避机动', fight: '迎战', breach: '硬闯', aid: '驰援', ignore: '按兵不动' };
/** 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);
export function describe(game) {
if (game._goingHome) return '已确认保存并回大厅';
if (game._menu.open) {
const names = ['回到舰桥', '保存并回大厅', '放弃本次巡逻'];
return `菜单里,当前项「${names[game._menu.idx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
}
if (game._tourState) return '巡检链播报中(四系统音+账目档):单击跳段,上滑重播全链';
if (game.state === 'CRISIS' && game._crisis) {
const c = game._crisis;
if (c.subphase === 'detect') return '检测链播报中:听签名音、类型、方位(先听完再动)';
if (c.subphase === 'decide') {
const opt = { meteor: ['evade', 'fight', 'breach'], pirate: ['fight', 'evade', 'ignore'], distress: ['aid', 'ignore'], radiation: ['evade', 'breach'] }[c.type][c.optIdx];
return `决策窗(当前预览:${OPT_LABEL[opt]}):左右滑听选项,单击下令,倒计时要紧`;
}
return '危机结算链播报中,先听着';
}
switch (game.state) {
case 'TITLE': return game.run
? '检测到未完成的巡逻:单击继续,右滑进星港,上滑听舰船概况'
: '标题页:单击开始新巡逻,右滑进星港,上滑听概况';
case 'PORT': return '星港里:左右滑看改装,单击购买,末项返回';
case 'ROUTE': return game._route.idx === 0
? '选航线:稳妥线(采集平平)。右滑听矿富线,单击选定'
: '选航线:矿富线(采得多磨损也多)。左滑回稳妥线,单击选定';
case 'CRUISE': return game._drill
? `教学第 ${game._drill.step} 步(${game._drill.step === 0 || game._drill.step === 3 ? '等上滑巡检' : '听/做'})`
: '巡航中:上滑巡检全舰(我在哪),单击重播最近告警';
case 'DOCK': return `靠港整备:${game._dock.items.map((i) => (i.kind === 'go' ? '启航' : '修理' + SYS_LABEL[i.sys])).join(' / ')}——左右滑看项目,单击执行`;
case 'REPORT': return game._report && !game._report.done
? '结算链播报中,手势全被吞(链锁)'
: '结算完了:单击返航,上滑重听评级';
default: return '……';
}
}
// ---------------------------------------------------------------- 会话工具
/** 有界等待:条件满足或超时即返回。 */
async function waitStep(ctx, mock, cond, maxMs = 30000) {
const t0 = mock.now;
let spins = 0;
const spinCap = Math.max(60, Math.floor(maxMs / 50));
while (!cond() && mock.now - t0 < maxMs && spins < spinCap) {
spins++;
await ctx.waitVirtual(100);
}
}
/** 等链收/巡检收尾(有界)。 */
async function waitQuiet(ctx, mock, game, maxMs = 60000) {
await waitStep(ctx, mock, () => !game.audio.isVoicePlaying() && !game._tourState, maxMs);
}
/** 等真正的等待态(话轮已交)或巡航 ACTION 静默。 */
async function waitTurn(ctx, mock, game, maxMs = 60000) {
await waitStep(ctx, mock, () => atWait(game) || (game.state === 'CRUISE' && !game._tourState && !game.floor.speaking), maxMs);
}
/** 决策窗内把选项循环到目标下标(每步听一句报名)。 */
async function navOpt(ctx, game, targetIdx, nOpts) {
let guard = 0;
while (game._crisis && game._crisis.optIdx !== targetIdx && guard++ < 6) {
const d = (targetIdx - game._crisis.optIdx + nOpts) % nOpts;
await ctx.gesture(d * 2 <= nOpts ? 'right' : 'left');
await ctx.waitVirtual(1400);
}
}
/** 危机决策:等检测链落决策窗 → 选项过耳朵 → 单击下令。 */
async function decide(ctx, mock, game, targetIdx, note) {
const c = game._crisis;
const nOpts = c.type === 'meteor' || c.type === 'pirate' ? 3 : 2;
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
await ctx.waitVirtual(1500); // 首选项预览落声
await navOpt(ctx, game, targetIdx, nOpts);
ctx.note(note);
await ctx.gesture('tap');
}
/** 一次完整危机:从巡航等检测链到结算链收尾。 */
async function rideCrisis(ctx, mock, game, targetIdx, plan) {
await waitStep(ctx, mock, () => game.state === 'CRISIS', 150000);
const c = game._crisis;
ctx.status(`检测链:签名音→「${plan.type}」→方位${DIR_LABEL[c.dir]}${c.trap ? '——医师提醒:求救码后面有引擎声!' : ''}`);
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
await decide(ctx, mock, game, targetIdx, plan.note);
await waitStep(ctx, mock, () => game._crisis === null, 60000);
await waitQuiet(ctx, mock, game, 40000);
}
/** 巡航到段末:途中巡检一次(我在哪),然后等危机。 */
async function cruiseAndCrisis(ctx, mock, game, tourNote, plan) {
await waitStep(ctx, mock, () => game.state === 'CRUISE', 30000);
await waitTurn(ctx, mock, game, 20000);
ctx.note(tourNote);
await ctx.gesture('up'); // 巡检(四系统音+舰体档+一个动作)
await ctx.waitVirtual(20000);
await rideCrisis(ctx, mock, game, plan.optIdx, plan);
}
/** DOCK 整备:修一遍能修的,然后启航。 */
async function dockAndGo(ctx, mock, game) {
await waitStep(ctx, mock, () => game.state === 'DOCK', 60000);
ctx.status('靠港整备:左右滑听听项目');
const items = game._dock.items;
for (let i = 0; i < items.length; i++) {
const it = game._dock.items[game._dock.idx];
if (it.kind !== 'go') {
ctx.note(`修理${SYS_LABEL[it.sys]}(升一级,两资源)`);
await ctx.gesture('tap');
await ctx.waitVirtual(4000);
}
await ctx.gesture('right');
await ctx.waitVirtual(1200);
if (game._dock.items[game._dock.idx]?.kind === 'go') break;
}
ctx.note('启航,下一段巡航');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'ROUTE' || game.state === 'CRUISE' || game.state === 'REPORT', 20000);
}
// ---------------------------------------------------------------- 主线剧本
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');
// —— D0:等教学链(轮到你了+巡检指令)→ 上滑巡检 ——
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000);
ctx.note('上滑——听全舰状态(四个音都稳)');
await ctx.gesture('up');
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000);
ctx.status('巡检学会了:四个系统各 2 秒,顺序固定;链尾教了「想知道自己在哪,就向上滑」');
// —— D1/D2:求救检测链(纯聆听)→ 预览 → 下令驰援 ——
await rideCrisis(ctx, mock, game, 0, {
type: '求救信号', optIdx: 0,
note: '驰援靠扫描——我们的扫描稳得很。单击,下令驰援!',
});
await waitStep(ctx, mock, () => game._drill && game._drill.step === 3, 40000);
ctx.status('驰援成功:资源+2,医师和大副记下了——听声辨型后下令,就是这样');
// —— D3:再巡检(听船员三档)→ D4 完成 ——
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 20000);
ctx.note('上滑——再巡检一遍,听听船员档位');
await ctx.gesture('up');
await waitStep(ctx, mock, () => game.state === 'DOCK', 90000);
ctx.status('教学完了,本段结束,靠港整备');
await dockAndGo(ctx, mock, game);
// —— S2 陨石:听听矿富线再回稳妥,迎战轰开 ——
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
ctx.note('矿富航线听听——算了,稳妥线。单击');
await ctx.gesture('right');
await ctx.waitVirtual(1500);
await ctx.gesture('left');
await ctx.waitVirtual(1200);
await ctx.gesture('tap');
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:磨损的扫描在左舷发失锁双哔', {
type: '陨石群', optIdx: 1, note: '迎战,靠武器——单击,轰开它',
});
await dockAndGo(ctx, mock, game);
// —— S3 海盗:迎战(可能触发大副支援)——
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
ctx.note('稳妥航线,太平——单击选定');
await ctx.gesture('tap');
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:舰体带伤没有?资源还够用', {
type: '海盗', optIdx: 0, note: '敌船引擎声,右舷来的——迎战!单击',
});
await dockAndGo(ctx, mock, game);
// —— S4 辐射:规避 → 结算 ——
await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000);
ctx.note('末段了。矿富线搏一把——单击选定');
await ctx.gesture('right');
await ctx.waitVirtual(1500);
await ctx.gesture('tap');
await cruiseAndCrisis(ctx, mock, game, '上滑巡检:倒计时最紧的一段,听清楚再动', {
type: '辐射云', optIdx: 0, note: '计数急响——规避机动,靠引擎。单击!',
});
// —— 结算链 → 单击返航 ——
await waitStep(ctx, mock, () => game.state === 'REPORT', 90000);
ctx.status('巡逻归来:评级与功勋在播,手势先别动(链锁=吞)');
await waitStep(ctx, mock, () => game._report && game._report.done, 60000);
ctx.status(`评级 ${game._report.rate}——星港期待下一次巡逻`);
ctx.note('单击,返航');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
ctx.status('回到标题页:首次巡逻闭环');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到结算。
// 剧本特征:不读提示、快速跳句、检测链乱滑(被打断后续完)、菜单只换项下滑关、
// 危机挂到超时走默认结局——但保留「最终能通关」的底线。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20261002;
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
// ① 开场指令句是 critical:抢拍只会拿到 busy 回执,等它播完再动手
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap');
// ② D0 教学:乱按几下(tap/滑都是 invalid 回执+一次解释),最后照做上滑
await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000);
for (let i = 0; i < 4; i++) {
await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]);
await ctx.waitVirtual(450);
}
ctx.note('被咚了几下,还有一句「先向上滑」——照做');
await ctx.gesture('up');
await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000);
// ③ S1 求救:检测链乱滑(invalid+解释+续完剩余句,保护链);决策窗不下令 → 超时
await waitStep(ctx, mock, () => game.state === 'CRISIS', 120000);
ctx.status('检测链:乱滑也不掐链——它解释一句,把剩下的类型方位接着讲完');
await ctx.gesture('left');
await ctx.waitVirtual(600);
await ctx.gesture('right');
await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000);
ctx.status('求救来了也不下令——等它超时(信号消失,零损失)');
await waitStep(ctx, mock, () => game._crisis === null, 60000);
await waitStep(ctx, mock, () => game.state === 'DOCK', 90000); // 教学收束(D3 兜底替播巡检)
await dockAndGo(ctx, mock, game);
// ④ S2 起:一半危机干等超时,一半随手点第一项;巡航乱按乱滑听回执
let seg = 2;
let dockRounds = 0;
let routeRounds = 0;
while (game.state !== 'REPORT' && mock.now < 40 * 60000) {
if (game.state === 'CRISIS' && game._crisis) {
const c = game._crisis;
if (c.subphase === 'detect') {
await waitStep(ctx, mock, () => !game._crisis || game._crisis.subphase !== 'detect', 60000);
continue;
}
if (c.subphase === 'decide') {
if (rng() < 0.5) {
ctx.note('这次不下令,等超时默认结局');
await waitStep(ctx, mock, () => game._crisis === null, 60000);
} else {
await ctx.gesture('tap'); // 预览到哪个就下哪令(鲁莽但合法)
await waitStep(ctx, mock, () => game._crisis === null, 60000);
}
await waitQuiet(ctx, mock, game, 40000);
continue;
}
await ctx.waitVirtual(1500);
continue;
}
if (game.state === 'DOCK') {
// 乱按修理/启航(无资源时 rep_poor 真话账);逛几轮仍出不去就走收敛整备
if (dockRounds++ < 3) {
ctx.note('乱按修理/启航');
for (let i = 0; i < 3; i++) { await ctx.gesture('right'); await ctx.waitVirtual(800); }
await ctx.gesture('tap');
await ctx.waitVirtual(2500);
continue;
}
dockRounds = 0;
ctx.note('瞎逛够了,按项目顺序整备一遍');
await dockAndGo(ctx, mock, game);
continue;
}
if (game.state === 'ROUTE') {
// 乱划航线(每次报名);逛两轮后在话轮上单击选定(报名播完 tap 才是选定)
routeRounds++;
if (routeRounds < 3) {
await ctx.gesture(rng() < 0.5 ? 'right' : 'left');
await ctx.waitVirtual(1200);
continue;
}
routeRounds = 0;
await waitStep(ctx, mock, () => atWait(game), 30000);
await ctx.gesture('tap');
await ctx.waitVirtual(1500);
continue;
}
if (game.state === 'CRUISE') {
if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; }
// 巡航乱按:单击重播告警、乱滑吃 invalid 回执、偶尔开菜单只敢下滑关
const roll = rng();
if (roll < 0.1 && !game._tourState) {
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
await ctx.gesture('down');
await ctx.waitVirtual(2000);
if (game._menu.open) await ctx.gesture('down');
await ctx.waitVirtual(1200);
continue;
}
await ctx.gesture(['tap', 'up', 'left', 'right'][Math.floor(rng() * 4)]);
await ctx.waitVirtual(1500);
continue;
}
if (game.state === 'REPORT') break;
await ctx.waitVirtual(1500);
}
await waitStep(ctx, mock, () => game.state === 'REPORT' && game._report?.done, 180000);
if (game._report) {
ctx.status(`乱按局也走完了:评级 ${game._report.rate},全程零死锁`);
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
} else {
ctx.status(`乱按局超出虚拟时长上限(${Math.round(mock.now / 60000)} 分钟),当前状态 ${game.state}——如实记录收尾`);
}
ctx.status('压力测试收工');
}