四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付: - cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档 - teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0 - keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4 - bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行 公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、 gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00) 验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿 遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
/**
|
||
* 《暗码》新手会话剧本。
|
||
*
|
||
* 剧本:
|
||
* - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获
|
||
* 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词),
|
||
* 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电),
|
||
* 听完尾声逐封点名,以完整 E1「最后一电」收尾。
|
||
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本,
|
||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||
*
|
||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||
* - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗;
|
||
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
||
* - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按;
|
||
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环),
|
||
* 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。
|
||
*/
|
||
|
||
const TEL_LABEL = ['一', '二', '三', '四'];
|
||
|
||
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 === 'RX') {
|
||
return game._rxGuard
|
||
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过`
|
||
: `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`;
|
||
}
|
||
if (game.phase === 'DECODE') {
|
||
if (game.confirming) return '进确认了:再单击就定下来,左右滑可以取消';
|
||
return game.voicePlaying
|
||
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词/电文`
|
||
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听候选词,双击重听电文,长按翻密码本`;
|
||
}
|
||
if (game.choiceArmed) {
|
||
return '抉择念完了:向左或向右滑动做出你的决定';
|
||
}
|
||
if (game.phase === 'RELAY') {
|
||
return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
|
||
}
|
||
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
|
||
if (game.phase === 'ENDING') {
|
||
return game.awaitingRestart
|
||
? '结局和尾声点名都听完了。单击再来一局,下滑开菜单'
|
||
: '结局播报中';
|
||
}
|
||
return '……';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 有界推进助手
|
||
|
||
/** 等当前语音链播完(有界等待,绝不裸空转)。 */
|
||
async function waitVoice(ctx) {
|
||
await ctx.waitCond(() => !ctx.game.voicePlaying);
|
||
}
|
||
|
||
/** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */
|
||
async function untilChoice(ctx, maxSteps = 40) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
if (game.choiceArmed) return;
|
||
if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)');
|
||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问');
|
||
if (game.voicePlaying) { await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying); continue; }
|
||
await ctx.gesture('tap'); // 链末 = 继续
|
||
await ctx.waitCond(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY');
|
||
}
|
||
throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
|
||
/** 从任意叙事态单击推进到 DECODE 浏览武装(穿越 RX/NIGHT 自动链与 RELAY 等待段)。 */
|
||
async function toDecode(ctx, maxSteps = 60) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
if (game.phase === 'DECODE' && game.browseArmed) return;
|
||
if (game.choiceArmed) throw new Error('toDecode 遇未应答抉择');
|
||
if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问');
|
||
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
|
||
await ctx.gesture('tap');
|
||
await ctx.waitCond(() => game.voicePlaying || game.phase !== 'RX'); // 首播保护窗内 tap 被吞,等真实推进
|
||
}
|
||
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
|
||
/** 从 p9 抉择后单击推进到 ENDING 结算态(d9 结果链含链末等待段)。 */
|
||
async function toEnding(ctx, maxSteps = 40) {
|
||
const { game } = ctx;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
if (game.phase === 'ENDING' && game.awaitingRestart) return;
|
||
if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
|
||
if (game.phase !== 'RELAY' && game.phase !== 'ENDING') {
|
||
await ctx.waitCond(() => game.phase === 'ENDING' || game.voicePlaying);
|
||
continue;
|
||
}
|
||
await ctx.gesture('tap');
|
||
await ctx.waitCond(() => game.voicePlaying || game.phase === 'ENDING');
|
||
}
|
||
throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主线剧本
|
||
|
||
/** DECODE 小局:慢放听清 → 切候选听词义 → 选 idx → 两段确认。 */
|
||
async function decodeWord(ctx, idx, why) {
|
||
const { game } = ctx;
|
||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying);
|
||
ctx.note(`${why}——先双击听电文,再双击放慢逐字母听`);
|
||
await ctx.gesture('doubletap');
|
||
await waitVoice(ctx);
|
||
await ctx.gesture('doubletap'); // 慢放 0.5×
|
||
await ctx.waitVirtual(400); // 慢放播一会(摩尔斯走合成不占语音时钟)
|
||
// 逐候选听词义(从当前候选循环到目标)
|
||
const cur = game.candIdx;
|
||
const delta = (idx - cur + 3) % 3;
|
||
for (let i = 0; i < delta; i++) {
|
||
ctx.note('左右滑,听密码本上的候选词');
|
||
await ctx.gesture('right');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
}
|
||
ctx.note('就这个词——单击选定');
|
||
await ctx.gesture('tap'); // 进确认
|
||
await waitVoice(ctx);
|
||
ctx.note('再单击,定下来');
|
||
await ctx.gesture('tap'); // 定下判定
|
||
await waitVoice(ctx);
|
||
}
|
||
|
||
/** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */
|
||
async function relayChoice(ctx, dir, why) {
|
||
const { game } = ctx;
|
||
await untilChoice(ctx);
|
||
ctx.note(why);
|
||
await ctx.gesture(dir);
|
||
await waitVoice(ctx);
|
||
}
|
||
|
||
export async function session(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE
|
||
await ctx.waitCond(() => game.phase === 'RX');
|
||
ctx.status('开场白播完了,第一封电文进频道了');
|
||
ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它');
|
||
await toDecode(ctx);
|
||
ctx.status('轮到我选词了:双击重听、左右滑听候选');
|
||
|
||
// T1 船期(教学封:BAY)
|
||
await decodeWord(ctx, 0, '教学封:先学规矩——听不清就再听一遍');
|
||
// 教学封若误选会回浏览(剧本内不会,保险循环)
|
||
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重新选');
|
||
await relayChoice(ctx, 'left', '药品船丑时过江——报给北风,江上有人等这船药');
|
||
ctx.status('第一封处置完了,夜里结算');
|
||
|
||
// T2 名单(TOP)
|
||
await toDecode(ctx);
|
||
ctx.status('第二封电文:内部通报,手法很急');
|
||
await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员');
|
||
await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重');
|
||
ctx.status('第二封处置完了');
|
||
|
||
// T3 交接(HAND)
|
||
await toDecode(ctx);
|
||
ctx.status('第三封电文:很短,手法有点抖');
|
||
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答,幸好放慢了');
|
||
await relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟');
|
||
ctx.status('第三封处置完了');
|
||
|
||
// T4 处置令(SPY · 高潮)
|
||
await toDecode(ctx);
|
||
ctx.status('第四封电文:快手七的手法——门外的脚步声都近了');
|
||
await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我');
|
||
await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做');
|
||
ctx.status('处长的试探应付过去了,频道要收了');
|
||
|
||
// 签收:g9_in → g9_knot → p9
|
||
await untilChoice(ctx);
|
||
await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错');
|
||
await toEnding(ctx);
|
||
ctx.status('结局播完了:尾声把今晚每一封都点了名');
|
||
ctx.note('单击,收工——但下一班夜班,还想再来');
|
||
await ctx.gesture('tap');
|
||
await waitVoice(ctx);
|
||
ctx.status('新的夜班开始了(再来一局)');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 乱按压力剧本
|
||
|
||
export async function sessionStress(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// 开场就乱按:tap/doubletap/方向全来一遍(RX 首播保护窗内也应零死锁)
|
||
await ctx.waitCond(() => game.phase === 'RX' && game._rxGuard);
|
||
ctx.note('好奇宝宝:首播中乱点乱滑——都应该是轻震,不判无效');
|
||
for (const gg of ['tap', 'left', 'right', 'doubletap', 'tap']) {
|
||
await ctx.gesture(gg);
|
||
await ctx.waitVirtual(300);
|
||
}
|
||
await toDecode(ctx);
|
||
|
||
// T1:翻密码本翻到手软(免费页 + 3 页 + 翻空),再译对
|
||
ctx.note('先把密码本翻烂:长按翻页,翻到一页不剩');
|
||
for (let i = 0; i < 5; i++) {
|
||
await ctx.gesture('longpress');
|
||
await waitVoice(ctx);
|
||
await ctx.waitVirtual(200);
|
||
}
|
||
ctx.note('乱切候选二十次');
|
||
for (let i = 0; i < 20; i++) {
|
||
await ctx.gesture(i % 3 === 0 ? 'left' : 'right');
|
||
await ctx.waitVirtual(250);
|
||
}
|
||
ctx.note('双击重听连打到慢放');
|
||
for (let i = 0; i < 4; i++) {
|
||
await ctx.gesture('doubletap');
|
||
await ctx.waitVirtual(300);
|
||
}
|
||
// 菜单乱逛
|
||
ctx.note('下滑开菜单,乱切几项再关掉');
|
||
await ctx.gesture('down');
|
||
await ctx.waitCond(() => game.phase === 'MENU');
|
||
for (let i = 0; i < 6; i++) {
|
||
await ctx.gesture('tap');
|
||
await ctx.waitVirtual(250);
|
||
}
|
||
await ctx.gesture('up'); // 菜单内帮助
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
await ctx.gesture('down'); // 关闭(不管停在哪个项,下滑永远安全)
|
||
await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed);
|
||
|
||
// 两段确认中途取消再重选
|
||
ctx.note('单击进确认,再左滑取消,折腾两回');
|
||
for (let i = 0; i < 2; i++) {
|
||
await ctx.gesture('tap');
|
||
await waitVoice(ctx);
|
||
await ctx.gesture('left');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
}
|
||
// 译对(免费提示早用过了,凭耳朵+慢放兜底)
|
||
await decodeWord(ctx, 0, '翻到提示也用完了,凭慢放听出来的:货船');
|
||
for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重选');
|
||
await relayChoice(ctx, 'right', '压进值机日志——这船的事得看清楚再说');
|
||
ctx.status('第一封处置完,压力测试过了第一道');
|
||
|
||
// 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 10 次)
|
||
const picks = [0, 0, 0];
|
||
const dirs = ['left', 'right', 'right'];
|
||
for (let tel = 2; tel <= 4; tel++) {
|
||
await toDecode(ctx);
|
||
for (let i = 0; i < 10; i++) {
|
||
await ctx.gesture('right');
|
||
await ctx.waitVirtual(200);
|
||
}
|
||
await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`);
|
||
await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉');
|
||
}
|
||
// 签收 p9(stress 也发最后一电)
|
||
await untilChoice(ctx);
|
||
ctx.note('开机,把今晚发出去——乱按一晚,正经事不含糊');
|
||
await ctx.gesture('left');
|
||
await waitVoice(ctx);
|
||
await toEnding(ctx);
|
||
ctx.status('乱按一整晚也走到了结局,零死锁零静默死局');
|
||
await ctx.gesture('tap'); // 再来一局
|
||
await waitVoice(ctx);
|
||
}
|