- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
210 lines
9.3 KiB
JavaScript
210 lines
9.3 KiB
JavaScript
/**
|
|
* 《不眠频率》新手会话剧本。
|
|
*
|
|
* 剧本:
|
|
* - `session`(主线):一位夜间主播的完整弧线——听完开场白、接完五通电话、
|
|
* 每个抉择点都做选择(C1 走心 / C2 劝上车 / C3 问她+真话 / C4 带话+留在直播 /
|
|
* C5 陪聊 / p9 直面),听完五句尾声回响,以完整 E1「天亮之前」收尾。
|
|
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛,
|
|
* 一样要能走到 ENDING,全程零死锁、零静默死局。
|
|
*
|
|
* 剧本失真红线(参照 storyline/escape 剧本先例):
|
|
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
|
|
* - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次看门狗,链一停就接话;
|
|
* - RING/BRIDGE 有铃声/喘息定时器循环,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
|
|
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
|
*/
|
|
|
|
const CALL_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 === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:单击接听`;
|
|
if (game.choiceArmed) {
|
|
return `「请选择」念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑动做出你的决定`;
|
|
}
|
|
if (game.voicePlaying) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`;
|
|
if (game.phase === 'ONAIR') return `对方说完了,等我接话(第 ${CALL_LABEL[game.callN - 1]} 通):单击继续`;
|
|
if (game.phase === 'BRIDGE') return '幕间喘息,垫乐播放中,马上接下一通';
|
|
if (game.phase === 'ENDING') {
|
|
return game.awaitingRestart
|
|
? '结局和五条回响都听完了。单击再来一局,下滑开菜单'
|
|
: '结局播报中';
|
|
}
|
|
return '……';
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 主线剧本
|
|
|
|
export async function session(ctx, mock) {
|
|
const { game } = ctx;
|
|
|
|
// 开场:INTRO 自动流(台标口播 + 一句规矩 + 今晚目标)→ 第一声铃
|
|
await ctx.waitCond(() => game.phase === 'RING');
|
|
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 choiceIdx = 0;
|
|
|
|
// 抉择路径:C1 走心、C2 劝上车、C3 问她+真话、C4 带话+留在直播、C5 陪聊、p9 直面
|
|
const choices = [
|
|
{ dir: 'left', why: '老人在发抖,我陪他哼那首老歌' },
|
|
{ dir: 'left', why: '留在原地陪他等巡逻队' },
|
|
{ dir: 'left', why: '小鹿妈妈等的是那声「妈」,劝她上车' },
|
|
{ dir: 'right', why: '糖糖像是憋着事,我轻轻问她怎么了' },
|
|
{ dir: 'left', why: '告诉她这不是她的错——大人的事不算在孩子头上' },
|
|
{ dir: 'left', why: '陈姐听见孩子问话了,我把话带回去' },
|
|
{ dir: 'left', why: '留在直播里,让全城等这声门铃' },
|
|
{ dir: 'left', why: '老韩眼皮在打架,我陪他聊聊闺女' },
|
|
{ dir: 'left', why: '留言机红灯在闪——直面它,替老主播把晚安播出去' },
|
|
];
|
|
|
|
for (let i = 0; i < 200 && !game.leaving; i++) {
|
|
// 只等当前语音链播完或抉择武装;RING/BRIDGE 有定时器,绝不用裸 waitIdle
|
|
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
|
|
|
if (game.leaving) break;
|
|
if (game.phase === 'RING') {
|
|
ctx.status(`第 ${CALL_LABEL[game.callN - 1]} 通电话响了,接`);
|
|
await ctx.gesture('tap');
|
|
await ctx.waitCond(() => !game.voicePlaying);
|
|
continue;
|
|
}
|
|
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 === 'BRIDGE') {
|
|
ctx.status('幕间,口碑播报和垫乐,等下一通');
|
|
await ctx.waitVirtual(2600);
|
|
continue;
|
|
}
|
|
// ONAIR 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
|
// 注意不要在这里 waitVirtual 小憩——等待态挂着看门狗定时器,waitVirtual
|
|
// 会一口气跳到下一个定时器 tick(25s 看门狗),剧本就变成「每句干晾 25s」。
|
|
if (!walkedAway && game.phase === 'ONAIR' && !game.choiceArmed) {
|
|
walkedAway = true;
|
|
ctx.silence(25, '听到一半走了神,在想老人的老花镜');
|
|
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
|
|
ctx.status('听到一声轻响和「单击屏幕,继续」,马上点');
|
|
await ctx.gesture('tap');
|
|
continue;
|
|
}
|
|
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 = 20260901;
|
|
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;
|
|
|
|
for (let i = 0; i < 300; i++) {
|
|
await ctx.waitCond(() => !game.voicePlaying || game.choiceArmed);
|
|
|
|
if (game.leaving) break;
|
|
if (game.phase === 'RING') {
|
|
answered++;
|
|
await ctx.gesture('tap'); // 不接会一直响,乱按也照接
|
|
continue;
|
|
}
|
|
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 === 'BRIDGE') {
|
|
await ctx.waitVirtual(2600);
|
|
continue;
|
|
}
|
|
if (game.phase === 'ENDING') {
|
|
await ctx.waitCond(() => game.awaitingRestart);
|
|
break;
|
|
}
|
|
// ONAIR 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
|
noiseCount++;
|
|
await ctx.gesture(pick(gestures));
|
|
}
|
|
|
|
await ctx.waitCond(() => game.phase === 'ENDING' && game.awaitingRestart);
|
|
ctx.status(`乱按剧本收尾:接听 ${answered} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
|
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
|
|
}
|