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 置完成。
349 lines
17 KiB
JavaScript
349 lines
17 KiB
JavaScript
/**
|
||
* 《星环舰长》新手会话剧本(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('压力测试收工');
|
||
}
|