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

115 lines
5.8 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 号 playbook 重构版):首局完整演练 D1-D4(第二颗故意
* 超时听「没拨到」真话归因 + 当场重教,第三颗错向听纠错)→ 正式对局躲过保护期
* (新手期无双击/无加速:单击只听一次「现在只要滑。」;挥空听 whiff 轻叹与首次
* 解释)→ 保护期结束后故意漏两颗(第 1/2 次被锁血吸收仍给方位归因;第 3 次起
* 实打实掉血听 hit_<dir>,第 4 次追加 hit_last「最后一命」)→ 之后每颗都同向拨开,
* 再撑 20 秒以上收工(撑到结算的话单击再飞)。
*/
export function describe(game) {
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
switch (game.phase) {
case 'reopen': return '问我继续上次的航程还是重开:单击继续,右滑重开';
case 'ask': return '问我直接出发还是重听演练:单击出发,上滑重听';
case 'drill': return game.drillOb
? `演练:${DIR[game.drillOb.dir]}有陨石在逼近,${DIR[game.drillOb.dir]}滑把它拨开`
: '演练指令在播,先听';
case 'playing': {
const n = game.obstacles.size;
const protect = performance.now() < game.protectionUntil;
return n
? `对局中:${n} 颗陨石在逼近,听方位同向滑开${protect ? '(保护期,撞了也不掉血)' : ''}`
: `对局中:引擎声平稳,等下一颗${protect ? '(保护期)' : ''}`;
}
case 'over': return '坠毁了,存活数据播报中。单击再飞一次,下滑开菜单';
case 'menu': return '暂停菜单:左右滑切换,单击确认';
case 'leaving': return '回大厅确认句在播';
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
// 演练 D1-D4:逼近音一响就同向拨开。
// D2 故意不出手(听「没拨到。声音一响就滑。」+ 当场重教);
// D3 先滑错方向(听纠错句),重教后马上同向滑。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
for (let i = 0; i < 4; i++) {
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.status(`演练 ${i + 1}/4:${{ left: '左', right: '右', up: '上', down: '下' }[game.drillOb.dir]}侧有陨石`);
if (i === 1) {
ctx.note('这颗犹豫了,没拨开——听真话归因「没拨到」和当场重教');
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → miss + retry
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.note('重教听完,这回马上同向滑');
await ctx.gesture(game.drillOb.dir);
continue;
}
if (i === 2) {
ctx.note('先滑了个反方向——听一句纠错');
await ctx.gesture('left'); // D3 是 up:错向当场重教
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb);
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
}
ctx.note('逼近音一响,同向滑开');
await ctx.gesture(game.drillOb.dir);
}
ctx.status('演练做完了,要出发了');
// 正式对局:保护期 60s + 结束后再撑 20s 以上收工。
// 新手期单击/双击都不可用:单击只听一次「现在只要滑。」;乱滑的方向听 whiff
// 轻叹与首次解释;保护期撞了听方位教学(不掉血)。
await ctx.waitCond(() => game.phase === 'playing');
ctx.status('出发。引擎声起来了,保护期里先练手');
if (!ctx.voiceBusy()) {
ctx.note('手贱点了一下屏幕——一声「现在只要滑。」,明白了,只管滑');
await ctx.gesture('tap');
}
const dodged = new Set();
const victims = []; // 保护期结束后故意不躲的(前两次锁血 + 后两次实撞)
let eaten = 0;
while ((game.phase === 'playing' || game.phase === 'over')
&& performance.now() < game.protectionUntil + 21000) {
if (game.phase === 'over') break;
// 被撞的那颗消失 → 归因链听完了,继续
const eatenIdx = victims.findIndex((v) => !game.obstacles.has(v));
if (eatenIdx >= 0) {
victims.splice(eatenIdx, 1);
ctx.note(eaten <= 2
? '被撞了但没掉血(新手锁血)——方位归因照样说真话,告诉该往哪边滑'
: '实打实挨了一下:方位归因 + 「船体只剩最后一次」——这是全部家底了');
continue;
}
// 呼啸一响就同向拨开(呼喊「左!/右!…」与呼啸同窗,round4 R1)
const target = [...game.obstacles].find((o) => !dodged.has(o));
if (target) {
if (eaten < 4 && performance.now() >= game.protectionUntil) {
eaten++;
victims.push(target);
dodged.add(target); // 标记 = 故意不躲
ctx.status(eaten <= 2
? '保护期结束了,这颗我故意不动(新手锁血,听归因)'
: '又一颗,也不躲——想听听真的挨撞会说什么');
continue;
}
dodged.add(target);
ctx.note('呼啸一起,同向滑开');
await ctx.gesture(target.dir);
continue;
}
await ctx.waitCond(() => game.phase !== 'playing'
|| victims.some((v) => !game.obstacles.has(v))
|| [...game.obstacles].some((o) => !dodged.has(o)));
}
if (game.phase === 'over') {
ctx.status('坠毁了——结算在播:存活时段、躲避段、单击再飞一次');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
ctx.note('单击,再飞一次');
await ctx.gesture('tap');
return;
}
const aliveSec = Math.round((performance.now() - game.runStart) / 1000);
ctx.status(`保护期结束后撑了 ${aliveSec - 60} 秒、挨了四下还活着;每次撞击都有方位真话,先到这`);
}