round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)

This commit is contained in:
SoundGame Dev
2026-08-31 00:26:25 +08:00
parent 296c996fb9
commit 902461f0f4
65 changed files with 1094 additions and 775 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ export async function session(ctx, mock) {
ctx.silence(3, '辨了辨方向');
await move('up');
await move('up');
await move('up', '北边是墙,撞了一下');
await move('up', '北边是墙,撞了一下——「那边是墙」后面还跟了句方向(向上推的是北墙)');
await move('up', '又撞了一下——听到游戏主动来问我了');
await ctx.waitVirtual(5200); // 等卡关轮询的 5s tick:连续撞墙 → 提前一级的暗示
+12 -10
View File
@@ -40,15 +40,16 @@ export async function session(ctx, mock) {
await ctx.waitCond(() => game.phase === 'explore');
ctx.status('进村了,先听场景描述和选项');
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈再对准「去小路」出发
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈,回绕到首项再出发。
// 确认(tap)前必须等选项名念完——voice 播放中单击是「跳过本句」,会把手势吃掉
for (let i = 0; i < 3; i++) {
await ctx.gesture('right');
await ctx.waitVirtual(1800); // 听选项名
}
ctx.status('选项听了一圈,往林间小路去');
await ctx.waitIdle(); // 选项名播完(无 interval,可安全等待空闲)
await ctx.gesture('right'); // 转回首个选项「去小路」
await ctx.waitVirtual(1500);
ctx.status('选项听了一圈');
await ctx.gesture('right'); // 从末项回绕到首个选项——听到「回到第一个」(round2 F7)
ctx.note('滑到头再滑就兜回来了,有人提醒「回到第一个」,不用自己数');
await ctx.waitCond(() => !ctx.voiceBusy()); // 选项名 + 回绕提示念完
await ctx.gesture('tap'); // → path
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
@@ -57,17 +58,18 @@ export async function session(ctx, mock) {
await ctx.gesture('right'); // 调查四周
await ctx.waitVirtual(1800);
ctx.note('翻找了一阵,捡到一点钱');
await ctx.waitCond(() => !ctx.voiceBusy());
await ctx.gesture('tap');
await ctx.waitVirtual(2400);
await ctx.waitCond(() => !ctx.voiceBusy()); // 捡钱播报念完
await ctx.gesture('left'); // 回「去岔路口」
await ctx.waitVirtual(1500);
await ctx.waitCond(() => !ctx.voiceBusy());
await ctx.gesture('tap'); // → fork
// 岔路口(去枯树洞/去溪流/返回):右滑一次到「去溪流」
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'fork');
ctx.status('岔路口,水声在东边');
await ctx.gesture('right');
await ctx.waitVirtual(1500);
await ctx.waitCond(() => !ctx.voiceBusy());
await ctx.gesture('tap'); // go_stream → 溪流 → 触发教学战
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
@@ -95,10 +97,10 @@ export async function session(ctx, mock) {
ctx.note('单击出手,攻击');
await ctx.gesture('tap');
// T2:格挡(前摇窗口内双击)
// T2:格挡(前摇窗口内双击)——挡住会先听到「挡得漂亮」(round2 F4)再进防御教学
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
ctx.status('前摇声响起了');
ctx.note('双击格挡');
ctx.note('双击格挡——这回干净挡下了,听到「挡得漂亮」');
await ctx.gesture('doubletap');
// T3:防御一次
+40 -23
View File
@@ -1,7 +1,10 @@
/**
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
* 第 1 轮答对、第 2 轮中途忘序列僵住 11 秒(触发向导「双击可重听」提示)、
* 第 3 轮故意答错第一下(消耗机会自动重播),然后答对完成第 3 轮收工。
* 收尾听完机会经济句(免费重听两次、用完扣机会);第 1 轮直接答对;第 2 轮开局
* 忘序列僵住 10 秒,听到向导「双击可以重新听」提示后 5 秒内照做——用掉一次免费
* 重听(round2:25s 升级已不再自动重播序列,坐着等答案重念的路子废了);第 3 轮
* 凭记忆答对,在**整轮边界**收工(round2 剧本修正:不在轮中退出——round1 转写
* 「第 4 轮报数后再没人说话」与「干晾 85 秒」都是剧本失真,不是游戏失真)。
*/
export function describe(game) {
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
@@ -14,7 +17,7 @@ export function describe(game) {
case 'announce': return `第 ${game.round} 轮报数呢,听好了`;
case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`;
case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势`;
case 'roundend': return game.chances < (game.round > 4 ? 2 : 1) ? '答错了,正在重播刚才的序列' : '刚答完,播报中';
case 'roundend': return '刚答完一轮,播报中';
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
default: return '开场,稍等一下';
@@ -35,36 +38,50 @@ export async function session(ctx, mock) {
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
}
await ctx.gesture(G[i]); // 照指令做对
await ctx.waitIdle();
if (i === G.length - 1) {
// 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 input 就接手,
// 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源)
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'
|| game.phase === 'gameover');
} else {
await ctx.waitIdle();
}
}
ctx.status('教学讲完了,机会经济也交代清楚了:免费重听两次,用完要扣机会');
// 正式对局:打到完成第 3 轮
let wrongDone = false;
let stallDone = false;
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover' && game.round < 4) {
// 正式对局:完整打完第 3 轮,在轮边界收工
let relistenDone = false;
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover') {
if (game.phase === 'input') {
// 第 2 轮开局先失忆僵住 11 秒:触发向导「双击可以重新听一遍序列」提示
if (game.round === 2 && game.inputIdx === 0 && !stallDone) {
stallDone = true;
// 第 2 轮先失忆僵住 10 秒:听向导「双击可以重新听一遍序列」,5 秒内照做。
//(期望输入恰为双击时先答这一下,下一步再演示重听——双击语境判定)
if (game.round === 2 && !relistenDone
&& game.inputIdx <= 1 && game.sequence[game.inputIdx] !== 'doubletap') {
relistenDone = true;
ctx.status('序列有点长,先僵一会儿');
ctx.silence(11, '想不起来序列了');
await ctx.waitVirtual(11000);
ctx.silence(10, '想不起来序列了');
await ctx.waitVirtual(10600); // 10s 看门狗:nudge + gd_relisten
ctx.status('听到提示:需要的话,双击可以重新听一遍序列');
}
// 第 2 轮第二拍故意答错:触发"用一次机会,再听一遍"(复活重听)
if (game.round === 2 && game.inputIdx === 1 && !wrongDone) {
wrongDone = true;
ctx.note('这一下故意点错,听听游戏怎么说');
await ctx.gesture(G[(G.indexOf(game.sequence[game.inputIdx]) + 1) % G.length]);
await ctx.waitIdle(); // retry_use + 自动重播同一序列
ctx.note('照提示双击,用掉一次免费重听');
await ctx.gesture('doubletap');
await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input');
continue;
}
ctx.status('轮到我复现了');
if (game.round === 3 && game.inputIdx === game.sequence.length - 1) {
ctx.status('最后一轮最后一个音,打完这轮就收');
} else {
ctx.status('轮到我复现了');
}
await ctx.gesture(game.sequence[game.inputIdx]);
continue;
}
await ctx.waitIdle();
// 整轮边界收尾:第 3 轮答完(roundend),不等第 4 轮报数
if (game.phase === 'roundend' && game.round === 3) break;
// announce/playback/roundend:等阶段推进即可,不裸 waitIdle(防吞掉 input 窗口)
const ph = game.phase;
const r = game.round;
await ctx.waitCond(() => game.phase !== ph || game.round !== r);
}
ctx.status(`收工:完成了 ${game.round - 1} 轮`);
ctx.status(`收工:完整打完 ${game.round} 轮,在轮与轮的边界停手(免费重听剩 ${game.chances} 次)`);
}
+29 -18
View File
@@ -1,7 +1,9 @@
/**
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏一颗
* (触发归因句 + 向导「同向滑动就能拨开」指令)→ 再正常躲几颗收工。
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏两颗
* (第 1 次听归因+向导「同向滑动就能拨开」,第 2 次听 gd_last_life「最后一次机会」
* ——round2 F2.2:受击经济透明)→ 之后全部躲开,保护期结束后再撑 20 秒以上收工
* (不再像 round1 那样在保护期后 8 秒就撤,也没有第三次被撞)。
*/
export function describe(game) {
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
@@ -45,46 +47,55 @@ export async function session(ctx, mock) {
}
ctx.status('演练做完了,要出发了');
// 正式对局:保护期 60s + 结束后再躲一小段就收工(不再模拟,避免挂机撞毁)。
// 每颗陨石生成(呼啸声起)就立即同向拨开——比盯着 1.5s 判定窗口更稳。
// 正式对局:保护期 60s + 结束后再撑 20s 以上收工。
// 每颗陨石生成(呼啸声起)就在判定窗口将开时同向拨开——比盯着 1.5s 判定窗口更稳。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
await ctx.waitCond(() => game.phase === 'playing');
ctx.status('出发。引擎声起来了,保护期里先练手');
const dodged = new Set();
let victim = null; // 保护期结束后故意不躲的那颗
let eaten = false;
const victims = []; // 保护期结束后故意不躲的(前两颗:听归因 + gd_last_life)
let eaten = 0;
while ((game.phase === 'playing' || game.phase === 'feature')
&& performance.now() < game.protectionUntil + 8000) {
&& performance.now() < game.protectionUntil + 21000) {
if (game.phase === 'feature') {
ctx.status('游戏停下来教新功能');
await ctx.waitIdle(); // 引入句听完(feature 态无刷怪链,可安全等待空闲)
// 引入句听完就照做(不裸 waitIdle——feature 态之后的刷怪链是自驱动定时器,
// 裸等会把整局对局连本带利烧穿到坠毁)
await ctx.waitCond(() => !ctx.voiceBusy());
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
continue;
}
if (victim && !game.obstacles.has(victim)) {
ctx.note('被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
victim = null;
// 被撞的那颗消失 → 归因链听完了,继续
const eatenIdx = victims.findIndex((v) => !game.obstacles.has(v));
if (eatenIdx >= 0) {
victims.splice(eatenIdx, 1);
ctx.note(eaten === 2 && victims.length === 0
? '第二次被撞:先听方位归因,又听到「这是最后一次机会,撑住」——这就是全部家底了'
: '被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
continue;
}
// 只在判定窗口将开未开时出手(警报第一响 = 撞击前 1s,窗口内)
const target = [...game.obstacles].find((o) => !dodged.has(o)
&& o.impactAt - performance.now() < 1300);
if (target) {
if (!eaten && performance.now() >= game.protectionUntil) {
eaten = true;
victim = target;
if (eaten < 2 && performance.now() >= game.protectionUntil) {
eaten++;
victims.push(target);
dodged.add(target); // 标记 = 故意不躲
ctx.status('保护期结束了,这颗我故意没动');
ctx.status(eaten === 1
? '保护期结束了,这颗我故意没动'
: '又有一颗,也故意不躲——想听听第二次被撞会说什么');
continue;
}
dodged.add(target);
await ctx.gesture(target.dir);
continue;
}
await ctx.waitCond(() => (game.phase !== 'playing' && game.phase !== 'feature')
|| (victim && !game.obstacles.has(victim))
await ctx.waitCond(() => game.phase !== 'playing'
|| victims.some((v) => !game.obstacles.has(v))
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() < 1300));
}
ctx.status('躲过保护期、挨过一次撞,心跳有点快,先到这');
const aliveSec = Math.round((performance.now() - game.runStart) / 1000);
ctx.status(`保护期结束后撑了 ${aliveSec - 60} 秒、挨了两下还活着(警报也只起一波了),先到这`);
}
+44 -17
View File
@@ -1,7 +1,9 @@
/**
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗)、
* 在非抉择节点试一次无效的左滑(触发向导纠错)、菜单进去逛一圈再出来、
* 抉择点一律选左,一路打到结局。
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗,
* 听到轻响后 5 秒内回应——round2 修正剧本失真:round1 的「干晾 85 秒」是剧本
* 问题不是游戏问题)、在非抉择节点试一次无效的左滑(触发向导纠错)、中途演示
* 一次语速 2.2 切换(F9:设置下一句即时生效,转写状态行可见语速档)、菜单逛
* 一圈再出来、抉择点一律选左,一路打到结局。
*/
export function describe(game) {
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
@@ -15,17 +17,21 @@ export async function session(ctx, mock) {
const { game } = ctx;
let invalidTried = false;
let menuDone = false;
let speedDemoDone = false;
let speedBackDone = false;
// 开场:n00 全听完,愣一会儿——向导看门狗先说话
await ctx.waitIdle();
ctx.status('开场旁白播完了,屏幕黑着,有点不知所措');
ctx.silence(8, '新手没反应过来');
await ctx.waitVirtual(8600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「轻触屏幕,继续」,试着点一下');
await ctx.gesture('tap');
// 开场:gd_brief 先给操作口令(round2 F8),n00 全听完,再愣一会儿——看门狗 25s 才说话
await ctx.waitCond(() => !game.voicePlaying); // 只等链播完(不空烧看门狗窗口)
ctx.status('开场口令和旁白播完了,屏幕黑着,走神想剧情去了');
ctx.silence(25, '在入戏,不是迷路(round2:剧情类等待默认低频)');
await ctx.waitVirtual(25600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「轻触屏幕,继续」,马上点一下');
await ctx.gesture('tap'); // 听到提示 ≤5s 内反应
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
await ctx.waitIdle(); // 等当前语音链播完(真人不会抢话)
// 只等语音链播完(真人不会抢话)。不能裸 waitIdle——会把等待态看门狗的两级
// 提醒整段空烧完才动手(round1 剧本失真根源),链一停就该轮到玩家了
await ctx.waitCond(() => !game.voicePlaying);
if (game.awaitingRestart) break;
@@ -45,6 +51,27 @@ export async function session(ctx, mock) {
continue;
}
// F9:演示一次语速切换——2.2 档对下一句即时生效,听两段再调回 1.0
if (!speedDemoDone && game.nodeId === 'n10') {
speedDemoDone = true;
ctx.note('旁白有点慢,去设置把语速调到 2.2(下一句生效)');
ctx.settings.set({ speed: 2.2 });
if (game.choiceArmed) {
ctx.note('选了左边(循声而去)——后面几步都是 2.2 档');
await ctx.gesture('left'); // n10 抉择 → n11,2.2 倍速播
} else {
await ctx.gesture('tap');
}
await ctx.waitIdle();
ctx.status('语速 2.2 听着赶趟');
continue;
}
if (speedDemoDone && !speedBackDone && game.nodeId === 'n12') {
speedBackDone = true;
ctx.note('听了两段,把语速调回 1.0');
ctx.settings.set({ speed: 1.0 });
}
if (game.choiceArmed) {
ctx.status('抉择点,提示音念完了');
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
@@ -59,19 +86,19 @@ export async function session(ctx, mock) {
invalidTried = true;
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
await ctx.gesture('left');
await ctx.waitIdle();
await ctx.waitVirtual(2000); // 让纠错句播完(不空烧看门狗窗口)
} else if (i % 4 === 2) {
ctx.silence(8, '走神想剧情去了');
await ctx.waitVirtual(8600); // 偶尔挂机,触发看门狗轻提醒
ctx.status('又听到那声轻响,想起该点屏幕了');
ctx.silence(25, '又走神想剧情去了');
await ctx.waitVirtual(25600); // 偶尔挂机,触发看门狗轻提醒(25s 才响,不轰炸)
ctx.status('又听到那声轻响,5 秒内想起该点屏幕了');
}
await ctx.gesture('tap');
}
await ctx.waitIdle();
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('结局');
ctx.note('听完结局,单击重开一次看看别的走向');
await ctx.gesture('tap'); // 结算态单击重开
await ctx.waitIdle();
await ctx.waitCond(() => !game.voicePlaying);
ctx.status('回到开头,会话结束');
}