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 置完成。
250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
/**
|
|
* 《守塔人》新手会话剧本(26 号重构版:叙事型,storyline/radio 范式)。
|
|
*
|
|
* 剧本:
|
|
* - `session`(主线):一位守塔人的完整风暴夜——开场第一句「点一下屏幕」就动手 →
|
|
* 听完台标与今夜目标接进 C1 → 阿澈求救后每个抉择都做选择(p1 接住人 / p2 翻暗格
|
|
* 听插叙1 / p3 口诀 / p4 真话 / p5 堵漏 / p6 读完缺页听插叙3 / p7 满发条出海 /
|
|
* p8 循渔歌 / p9 说出),中途主动重听一次选项、走神一次听三档提示、
|
|
* 插叙里安安静静听完(回忆没有选择)、结局后逛一圈菜单(阶段 2 下滑此刻才真解锁),
|
|
* 以完整 E1「灯与归人」收尾。
|
|
* - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛、
|
|
* 插叙里忍不住乱滑,一样要能走到 ENDING,全程零死锁、零静默死局。
|
|
*
|
|
* 剧本失真红线(参照 radio/abyss 剧本先例):
|
|
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
|
|
* 出手——voiceBusy 是句级信号、链内句间会闪断,不能当门控;插叙/幕间/危机拍
|
|
* 前置窗是 BUSY 演出段,真机里是 busy 回执,剧本同样等它走完(回忆没有选择,
|
|
* 插叙里一个手势都不出);
|
|
* - 玩家不干晾自己:只在 WATCH 叙事等待态偶尔走神触发一次等待点提示;
|
|
* - 幕间有喘息定时器、抉择点挂着提示计时,禁止裸 waitIdle 空等,必须 waitCond
|
|
* 有界等待;
|
|
* - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。
|
|
*/
|
|
|
|
const ACT_LABEL = ['一', '二', '三'];
|
|
|
|
function actLabel(n) {
|
|
return n >= 1 && n <= 3 ? ACT_LABEL[n - 1] : '·';
|
|
}
|
|
|
|
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
|
|
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
|
|
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
|
|
const atChoice = (game) => atWait(game) && wpId(game) === 'choice';
|
|
const atSettle = (game) => game.phase === 'ENDING' && game.awaitingRestart;
|
|
|
|
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.floor.speaking
|
|
? '开场指令正在播:「点一下屏幕。」'
|
|
: '等待声在响:点一下,听今晚的塔';
|
|
}
|
|
if (game.phase === 'MEMORY') {
|
|
return game.floor.speaking
|
|
? '老唱片在转(回忆里没有选择,唱针会自己抬起)'
|
|
: '回忆的间隙';
|
|
}
|
|
if (game.phase === 'CALM') return '幕间喘息:雾角、岸台通报、账目,马上进下一幕';
|
|
if (atChoice(game)) {
|
|
return `选项念完了(第${actLabel(game.act)}幕):向左或向右滑做出你的决定`;
|
|
}
|
|
if (game.floor.speaking) return `第${actLabel(game.act)}幕通话中,正在听频道那头`;
|
|
if (game.phase === 'WATCH') return `对方说完了,等我接话(第${actLabel(game.act)}幕):单击继续`;
|
|
if (game.phase === 'ENDING') {
|
|
return atSettle(game)
|
|
? '结局和尾声回响都听完了。单击开新的风暴夜,下滑开菜单'
|
|
: '结局播报中';
|
|
}
|
|
return '……';
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 主线剧本
|
|
|
|
export async function session(ctx, mock) {
|
|
const { game } = ctx;
|
|
|
|
// 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 交出话轮 → 第一下单击
|
|
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
|
ctx.status('开场第一句就是指令,不是规则念白');
|
|
ctx.note('风暴底噪里一声雾角,然后它让我点一下屏幕——点');
|
|
await ctx.gesture('tap');
|
|
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
|
ctx.status('台标、雾角的承诺、今夜的目标听完了,风还在拍塔');
|
|
|
|
let relistened = false;
|
|
let walkedAway = false;
|
|
let menuVisited = false;
|
|
let inMemorySeen = false;
|
|
let choiceIdx = 0;
|
|
|
|
// 抉择路径(剧本 A:信任满·全真相·出海接人·说出 → 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 < 400 && !game.leaving; i++) {
|
|
// 只等当前语音链播完或抉择武装;幕间/插叙/危机拍有定时器,绝不用裸 waitIdle
|
|
await ctx.waitCond(() => atWait(game) || atSettle(game));
|
|
|
|
if (game.leaving) break;
|
|
if (atSettle(game) && !menuVisited) {
|
|
// 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾)
|
|
menuVisited = true;
|
|
ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单');
|
|
await ctx.gesture('down');
|
|
await ctx.waitCond(() => atWait(game));
|
|
ctx.status('菜单名和第一项「回到值守」报完了');
|
|
await ctx.gesture('right'); // 下一项报名
|
|
await ctx.waitCond(() => atWait(game));
|
|
ctx.status('听完「重新开始今夜」的报名,不动它');
|
|
await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」)
|
|
await ctx.waitCond(() => atSettle(game));
|
|
ctx.status('菜单关了,结局链复述了一遍局面,回到结算');
|
|
continue;
|
|
}
|
|
if (atSettle(game)) break; // E1 收尾
|
|
if (game.phase === 'MEMORY') {
|
|
// 回忆没有选择:唱针落下就安静听完(一个手势都不出,链末自动回返)
|
|
if (!inMemorySeen) {
|
|
inMemorySeen = true;
|
|
ctx.status('唱针落下、风声退后——这是回忆(rev_teach 教过我)');
|
|
ctx.note('老唱片里是祖父/父亲的声音,听完就回到风暴里');
|
|
}
|
|
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
|
continue;
|
|
}
|
|
if (game.phase === 'CALM') {
|
|
ctx.status('幕间:雾角、岸台通报、这一程的账,喘口气');
|
|
await ctx.waitVirtual(2600);
|
|
continue;
|
|
}
|
|
if (atChoice(game)) {
|
|
const pick = choices[choiceIdx];
|
|
if (!pick) break; // 抉择走完(理论上 p9 后应已进 ENDING)
|
|
|
|
// p2 起上滑已解锁(阶段 1):犹豫时点一下重听选项(原则 9 主动重听)
|
|
if (!relistened && choiceIdx >= 1) {
|
|
relistened = true;
|
|
ctx.note(pick.why + '——没听清,先点一下,让它「再说一遍」');
|
|
await ctx.gesture('tap');
|
|
await ctx.waitCond(() => atWait(game));
|
|
ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上');
|
|
await ctx.waitCond(() => atChoice(game));
|
|
}
|
|
ctx.status('选项念完了,做决定');
|
|
ctx.note(pick.why);
|
|
await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档)
|
|
if (choiceIdx === 0) {
|
|
ctx.status('链头一声「对。」,链尾教了「想知道自己在哪,就向上滑」');
|
|
ctx.note('做完第一件事才教下一件事,一句话只教一个');
|
|
}
|
|
choiceIdx++;
|
|
continue;
|
|
}
|
|
// WATCH 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。
|
|
// 注意不要在这里 waitVirtual 小憩——等待态挂着提示定时器,waitVirtual
|
|
// 会一口气跳到下一个定时器 tick(13s 提示档),剧本就变成「每句干晾 13s」。
|
|
if (!walkedAway && game.phase === 'WATCH' && wpId(game) === 'continue') {
|
|
walkedAway = true;
|
|
ctx.silence(15, '听到一半走了神,在想灯座下的暗格');
|
|
await ctx.waitVirtual(15600); // 触发等待点提示 full 档(13s):「单击,继续。」
|
|
ctx.status('听到一声「单击,继续」,马上点');
|
|
await ctx.gesture('tap');
|
|
continue;
|
|
}
|
|
await ctx.gesture('tap'); // 链末单击 = 继续
|
|
}
|
|
|
|
await ctx.waitCond(() => atSettle(game));
|
|
ctx.status('结局链走完,尾声把这一晚的每一笔都点给我听');
|
|
ctx.note('雾角最后响了一声——航道平安。会话结束(E1 灯与归人)');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 乱按压力剧本
|
|
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
|
|
// 剧本特征:不读提示、快速跳句、插叙里忍不住乱滑(invalid 后恢复重播)、菜单里
|
|
// 乱逛,但仍保留「最终能通关」的底线。
|
|
|
|
export async function sessionStress(ctx, mock) {
|
|
const { game } = ctx;
|
|
|
|
let seed = 20261002;
|
|
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 memPoke = 0;
|
|
let noiseCount = 0;
|
|
|
|
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
|
|
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
|
|
await ctx.gesture('tap');
|
|
|
|
for (let i = 0; i < 400; i++) {
|
|
await ctx.waitCond(() => atWait(game) || atSettle(game));
|
|
|
|
if (game.leaving) break;
|
|
if (atSettle(game)) break;
|
|
if (game.phase === 'MENU') {
|
|
menuOpenCount++;
|
|
await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名)
|
|
await ctx.waitCond(() => atWait(game));
|
|
await ctx.gesture('down'); // 下滑关闭(不单击确认,避免误触「重开/回大厅」)
|
|
continue;
|
|
}
|
|
if (game.phase === 'MEMORY') {
|
|
// 好奇宝宝在回忆里忍不住动一下(插叙无选择 → invalid + 重播当前回忆句)
|
|
if (rng() < 0.4) {
|
|
memPoke++;
|
|
await ctx.gesture(pick(['left', 'right', 'tap']));
|
|
await ctx.waitCond(() => game.phase === 'MEMORY' || game.phase === 'WATCH');
|
|
continue;
|
|
}
|
|
await ctx.waitCond(() => game.phase !== 'MEMORY');
|
|
continue;
|
|
}
|
|
if (game.phase === 'CALM') {
|
|
await ctx.waitVirtual(2600);
|
|
continue;
|
|
}
|
|
if (atChoice(game)) {
|
|
choiceCount++;
|
|
if (rng() < 0.35) {
|
|
// 犹豫时乱试一个不推进剧情的手势(重听/问状态/无效)
|
|
noiseCount++;
|
|
await ctx.gesture(pick(['up', 'tap', 'doubletap']));
|
|
await ctx.waitCond(() => atWait(game));
|
|
}
|
|
await ctx.gesture(pick(['left', 'right']));
|
|
continue;
|
|
}
|
|
// WATCH 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默)
|
|
noiseCount++;
|
|
await ctx.gesture(pick(gestures));
|
|
}
|
|
|
|
await ctx.waitCond(() => atSettle(game));
|
|
ctx.status(`乱按剧本收尾:${choiceCount} 个抉择、插叙里乱试 ${memPoke} 次、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
|
|
ctx.note('全程没有任何一次手势被无声吞掉,回忆里乱滑也只是被温柔挡回来——结局链照样走完');
|
|
}
|