四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
239 lines
11 KiB
JavaScript
239 lines
11 KiB
JavaScript
/**
|
||
* 《守塔人》新手会话剧本。
|
||
*
|
||
* 剧本:
|
||
* - `session`(主线):一位守塔人的完整风暴夜——听完开场(塔名/规矩/目标)、
|
||
* 三个幕内接满电台抉择(全走心线:接住人→翻暗格→口诀→真话→堵漏→读完缺页
|
||
* →出海接人→循渔歌→说出真相),三段回忆插叙全听,以完整 E1「灯与归人」收尾。
|
||
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
|
||
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
||
*
|
||
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
||
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
||
* - 玩家不干晾自己:只在 WATCH 链末等待态偶尔走神触发一次看门狗,链一停就接话;
|
||
* - CALM 幕间有喘息定时器、MEMORY 有进入拍定时器,禁止裸 waitIdle 空等,
|
||
* 必须 waitCond 有界等待;
|
||
* - 叙事链每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
||
*/
|
||
|
||
const ACT_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 === 'MEMORY') {
|
||
const era = game.timeline === 'mem_f' ? '父亲的最后一晚(十二年前)' : '祖父的年代(四十年前)';
|
||
return game.voicePlaying
|
||
? `回忆插叙中(${era}):唱针落下,老唱片底噪,听完自动回到现在`
|
||
: `回忆插叙的静默拍(${era})`;
|
||
}
|
||
if (game.phase === 'CALM') {
|
||
return `第${ACT_LABEL[(game.act || 1) - 1]}幕结束,幕间喘息:雾角 + 岸台通报${game.voicePlaying ? '正在播' : ',等我单击或自动进下一幕'}`;
|
||
}
|
||
if (game.choiceArmed) {
|
||
return `「请选择」念完了(第${ACT_LABEL[(game.act || 1) - 1]}幕):向左或向右滑动做出你的决定`;
|
||
}
|
||
if (game.voicePlaying) return `第${ACT_LABEL[(game.act || 1) - 1]}幕值守中,正在听电台/岸台说话`;
|
||
if (game.phase === 'WATCH') return `对方说完了,等我接话(第${ACT_LABEL[(game.act || 1) - 1]}幕):单击继续`;
|
||
if (game.phase === 'ENDING') {
|
||
return game.awaitingRestart
|
||
? '结局和尾声回响都听完了。单击再来一夜,下滑开菜单'
|
||
: '结局播报中(岸台记一笔:这一晚后来怎样了)';
|
||
}
|
||
return '……';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 主线剧本
|
||
|
||
export async function session(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
// 开场:INTRO 自动流(塔名 + 规矩 + 目标)→ 链末单击 → C1
|
||
await ctx.waitCond(() => game.phase === 'WATCH' || game.choiceArmed);
|
||
ctx.status('开场口令和塔名播完了,等我单击开始值守');
|
||
ctx.note('雾角两长一短——塔在,灯在。单击,接过这一夜');
|
||
await ctx.gesture('tap');
|
||
ctx.status('进入第一幕,电台里有动静');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
ctx.status('岸台说频道里有动静,像隔着整片海');
|
||
|
||
let invalidTried = false;
|
||
let menuDone = false;
|
||
let walkedAway = false;
|
||
let helpDone = false;
|
||
let choiceIdx = 0;
|
||
|
||
// 抉择路径:全走心线(接住人/翻暗格/口诀/真话/堵漏/读完/出海/循渔歌/说出 → e1)
|
||
const choices = [
|
||
{ dir: 'left', why: '他的声音在抖——先接住这个人,船位待会儿再要' },
|
||
{ dir: 'left', why: '他提到四十年前……去翻灯座下的暗格,旧账该见光了' },
|
||
{ dir: 'left', why: '他快撑不住了——用守塔人的口诀带他稳船' },
|
||
{ dir: 'left', why: '他问起我的人……告诉他真话:我父亲也是守塔人' },
|
||
{ dir: 'left', why: '船板裂了——让他回舱堵漏,守住电台' },
|
||
{ dir: 'left', why: '祖父的最后一页就在灯下——今晚读完它' },
|
||
{ dir: 'left', why: '浪墙压过来了——给灯上满发条,驾接驳艇去接他' },
|
||
{ dir: 'left', why: '海上没有灯——循着他哼的渔歌找他' },
|
||
{ dir: 'left', why: '他问起四十年前的灯……把缺页的事,连根说给他' },
|
||
];
|
||
|
||
for (let i = 0; i < 300 && !game.leaving; i++) {
|
||
// 只等当前语音链播完或抉择武装;CALM/MEMORY 有定时器,绝不用裸 waitIdle
|
||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||
|
||
if (game.leaving) break;
|
||
if (game.choiceArmed) {
|
||
const pick = choices[choiceIdx];
|
||
if (!pick) break; // 抉择走完(理论上到 p9 后应已进 ENDING)
|
||
|
||
if (!invalidTried) {
|
||
invalidTried = true;
|
||
ctx.note(pick.why + '——但先上滑听一遍现在在哪、他信我几分');
|
||
await ctx.gesture('up');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
ctx.status('听完状态链(第几幕/信任档/真相度),游戏自动重播了选项');
|
||
await ctx.waitCond(() => game.choiceArmed);
|
||
}
|
||
if (!menuDone) {
|
||
menuDone = true;
|
||
ctx.note('下滑看看菜单有什么');
|
||
await ctx.gesture('down');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
ctx.status('菜单里,切一项听听');
|
||
await ctx.gesture('tap'); // 切一项听听(报名接在当前报名之后,不截断)
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
ctx.note('听完了,下滑关闭菜单,回到选项');
|
||
await ctx.gesture('down'); // 下滑关闭(不依赖菜单项序号,绝不误触「重开/回大厅」)
|
||
await ctx.waitCond(() => game.choiceArmed);
|
||
}
|
||
ctx.status('选项念完了,做决定');
|
||
ctx.note(pick.why);
|
||
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧看门狗)
|
||
choiceIdx++;
|
||
continue;
|
||
}
|
||
if (game.phase === 'ENDING') {
|
||
await ctx.waitCond(() => game.awaitingRestart);
|
||
continue;
|
||
}
|
||
if (game.phase === 'CALM') {
|
||
ctx.status(`第${ACT_LABEL[(game.act || 1) - 1]}幕过去,雾角报平安,岸台通报风暴进程`);
|
||
ctx.note('幕间喘口气,听风往哪走');
|
||
await ctx.waitVirtual(2600);
|
||
continue;
|
||
}
|
||
if (game.phase === 'MEMORY') {
|
||
ctx.note('唱针落下了……这是回忆,别慌,听完风声就回来');
|
||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||
ctx.status('风声回来了,回到现在');
|
||
continue;
|
||
}
|
||
// WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
||
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
|
||
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
||
if (!walkedAway && game.phase === 'WATCH' && !game.choiceArmed) {
|
||
walkedAway = true;
|
||
ctx.silence(25, '听到一半走了神,在想父亲那支手电');
|
||
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
||
ctx.status('听到一声轻提醒和「单击屏幕,继续」,马上点');
|
||
await ctx.gesture('tap');
|
||
continue;
|
||
}
|
||
if (!helpDone && game.nodeId === 'amo_c') {
|
||
helpDone = true;
|
||
ctx.note('他爷爷四十年前在这片海没的……这句话有分量,双击重听一遍');
|
||
await ctx.gesture('doubletap');
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
ctx.status('重听完,接着值守');
|
||
}
|
||
await ctx.gesture('tap'); // 链末单击 = 继续
|
||
}
|
||
|
||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||
ctx.status('结局链走完,尾声把这一夜逐项点名——阿澈、灯的记录簿、祖父的日志、父亲的班次');
|
||
ctx.note('听完岸台那笔账——人和真相都接住了(E1 灯与归人),会话结束');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 乱按压力剧本
|
||
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
||
// 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。
|
||
|
||
export async function sessionStress(ctx, mock) {
|
||
const { game } = ctx;
|
||
|
||
let seed = 20260914;
|
||
const rng = () => {
|
||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||
return seed / 0x7fffffff;
|
||
};
|
||
const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
|
||
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||
|
||
let menuOpenCount = 0;
|
||
let choiceCount = 0;
|
||
let answered = 0;
|
||
let noiseCount = 0;
|
||
let memCount = 0;
|
||
|
||
for (let i = 0; i < 400; i++) {
|
||
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
||
|
||
if (game.leaving) break;
|
||
if (game.phase === 'MENU') {
|
||
menuOpenCount++;
|
||
await ctx.gesture('tap'); // 乱切换
|
||
await ctx.waitCond(() => !game.voicePlaying);
|
||
await ctx.gesture('down'); // 下滑关闭(不双击,避免误触「重开/回大厅」)
|
||
continue;
|
||
}
|
||
if (game.choiceArmed) {
|
||
choiceCount++;
|
||
if (rng() < 0.35) {
|
||
// 好奇宝宝:犹豫时乱试一个不推进剧情的手势
|
||
noiseCount++;
|
||
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
||
await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying);
|
||
}
|
||
await ctx.gesture(pick(['left', 'right']));
|
||
continue;
|
||
}
|
||
if (game.phase === 'CALM') {
|
||
await ctx.waitVirtual(2600);
|
||
continue;
|
||
}
|
||
if (game.phase === 'MEMORY') {
|
||
memCount++;
|
||
if (rng() < 0.4) {
|
||
// 回忆里也手痒:双击重听当前回忆节点 / 下滑开菜单再关
|
||
noiseCount++;
|
||
await ctx.gesture(rng() < 0.5 ? 'doubletap' : 'down');
|
||
await ctx.waitCond(() => !game.voicePlaying || game.phase !== 'MEMORY');
|
||
if (game.phase === 'MENU') {
|
||
await ctx.gesture('down'); // 关掉,回忆还在原地等我
|
||
await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH');
|
||
}
|
||
}
|
||
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
||
continue;
|
||
}
|
||
if (game.phase === 'ENDING') {
|
||
await ctx.waitCond(() => game.awaitingRestart);
|
||
break;
|
||
}
|
||
// WATCH 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
||
noiseCount++;
|
||
answered++;
|
||
await ctx.gesture(pick(gestures));
|
||
}
|
||
|
||
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次、穿过 ${memCount} 段回忆`);
|
||
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
||
}
|