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

118 lines
6.7 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 号深度重构版,docs/design/26-apply-rhythm.md):
* 首局完整走「开场指令 → 摸鼓认区(六区摸遍,区名每区一次)→ 教学 T1-T3 跟打 →
* 选曲等待点(单击开始)→ 曲目一(90 BPM)」。曲中下滑逛菜单(左右滑换项、
* 单击确认;恢复 = gd_count + 4 拍底鼓 count-in 进场)、正片第一乐句故意整句漏掉
* (连 2 Miss → gd_miss 一次),之后逐音跟打(含终曲加花)到结算。
* 分区敲击带坐标注入(引擎坐标透传契约,24 号文档 §0.3)。
*/
const ZC = {
TL: { x: 50, y: 100 }, TC: { x: 150, y: 100 }, TR: { x: 250, y: 100 },
BL: { x: 50, y: 500 }, BC: { x: 150, y: 500 }, BR: { x: 250, y: 500 },
};
export function describe(game) {
switch (game.phase) {
case 'intro': return '开场指令「点一下屏幕。」在播(critical,抢拍只给 busy 回执)';
case 'tut':
if (game._tutStage === 'probe') {
return `摸鼓认区(ACTION):随便摸,摸到 3 个不同的地方就开始(已摸 ${game._probeHeard.size} 区)`;
}
if (game._tutStage === 'idle') return '区名播完,教学第一步即将开始';
return game._cues.length
? `教学跟打:听领奏,下一小节照样敲(${game._cues.map((c) => c.zone).join('→')})${game._tutRate < 1 ? '(领奏已放慢 25%)' : ''}`
: '教学准备中,听数拍';
case 'select': return '选曲(YOUR_TURN):左右滑换曲听报名,单击开始当前曲';
case 'main': {
const left = game._cues.filter((c) => !c.judged).length;
const fillNext = game._timed.some((e) => e.fn && game._song?.units?.some((u) => u.fill));
return `演奏中(${game._song?.title ?? ''},ACTION):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了${fillNext ? ';有一段加花要连打' : ''}`;
}
case 'menu': return '暂停菜单(YOUR_TURN):左右滑换项听报名,单击确认,下滑关闭恢复';
case 'end': return '结算(YOUR_TURN):单击同曲再来,右滑下一首(未解锁会说明)';
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game, audio } = ctx;
const t = () => audio.ctx.currentTime;
// transcribe 的 root 是 {}:补上布局让分区坐标生效(引擎坐标契约的游戏层语义)
game.root = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 600 }) };
const hit = async (zone) => {
game.onGesture('tap', ZC[zone]);
await mock.flush();
};
// 开场(§4.10):第一句就是 critical 指令「点一下屏幕。」→ 摸鼓探针(ACTION)
await ctx.waitCond(() => game.phase === 'tut' && game._tutStage === 'probe');
ctx.status('听到「点一下屏幕。」——整面屏幕都是鼓,随便摸');
ctx.note('背景《启程》无鼓伴奏轻声循环——拍子网格的底,鼓由我来打');
// T0 探索:六区全摸一遍(区名每区一次;重复摸只出声),摸到 3 区进阶
for (const zone of ['TL', 'TC', 'TR', 'BL', 'BC', 'BR']) await hit(zone);
ctx.status('六面鼓摸遍了:左上踩镲、上中高嗵、右上吊镲、左下军鼓、下中底鼓、右下低嗵');
await hit('TL'); // 重复摸:只出声不报名
await hit('BR');
ctx.status('又摸了两下重复的,只响不报名');
await ctx.waitCond(() => game._tutStage === 'step' && game._cues.length > 0);
ctx.status('摸到三个不同的地方,自动开始教学第一步');
// 教学 3 步:等领奏排程好,在应答窗口内跟敲
//(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle)
for (let step = 0; step < 3; step++) {
await ctx.waitCond(() => game._tutIdx === step && game.phase === 'tut'
&& game._cues.length > 0 && game._tutAttempts.length === 0);
ctx.status(`教学第 ${step + 1}/3 步:听数拍和领奏(示范期点按只给 busy 回执),下一小节照着敲`);
for (const cue of game._cues) {
await ctx.waitCond(() => t() >= cue.expected || cue.judged);
if (cue.judged) continue;
await hit(cue.zone);
}
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
}
await ctx.waitCond(() => game.phase === 'select');
ctx.status('教学完毕,进选曲:左右滑换曲听报名,单击开始');
ctx.note('左右滑换曲、单击开始——新手期没有双击手势');
// 选曲:单击开始曲目一
await ctx.gesture('tap');
await ctx.waitCond(() => game.phase === 'main');
ctx.status('曲目一《启程》90 拍开始。先听四下底鼓数拍;首个应答窗前会教「轮到你了」这个声音');
ctx.note('《启程》无鼓伴奏 BGM 起,四下底鼓数拍对齐小节;演奏期 = ACTION,点按即敲');
// 曲中逛菜单:下滑呼出(模态暂停)→ 左右滑换项听报名 → 下滑恢复
//(恢复 = gd_count「四拍之后,继续」+ 4 拍底鼓 count-in 进场)
await ctx.waitCond(() => t() >= game._bgmT0 + 1.5);
await ctx.gesture('down');
await ctx.waitCond(() => game.phase === 'menu');
ctx.status('曲中暂停,进了菜单(左右滑换项,单击确认)');
ctx.note('BGM 在断点冻住——谱面、判定、音乐全部暂停');
await ctx.waitVirtual(1100); // 菜单 1s 去抖独占期
await ctx.gesture('left'); // 换到「回大厅」听报名
ctx.note('还是想打完这首,右滑切回「继续演奏」,再下滑回去');
await ctx.gesture('right');
await ctx.gesture('down'); // 关闭恢复
await ctx.waitCond(() => game.phase === 'main');
ctx.status('听到「四拍之后,继续」,跟着四下底鼓数拍进来了');
ctx.note('BGM 从断点续播,节拍网格整体后移了一小节');
// 第一乐句故意整句不打:连 2 次 Miss → gd_miss 一次(同曲一次,曲尾 3s 不插)
const firstPhrase = game._cues.filter((c) => c.phrase === 0);
ctx.silence(5, '还在数拍子,第一句没敢出手');
await ctx.waitCond(() => firstPhrase.every((c) => c.judged));
ctx.status('第一句全漏了,听到一次「慢了半拍,下一小节跟上我就行」');
// 之后逐音跟打(含终曲加花)
for (const cue of game._cues) {
if (cue.judged) continue;
await ctx.waitCond(() => t() >= cue.expected || cue.judged);
if (cue.judged) continue;
await hit(cue.zone);
}
await ctx.waitCond(() => game.phase === 'end');
ctx.status('曲子打完了(加花收尾是一记吊镲),结算播报中(单击同曲再来,右滑下一首)');
ctx.note('曲目 BGM 淡出,结算语音链播完会淡入大厅音乐,然后交出结算等待点');
}