round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)
This commit is contained in:
+28
-7
@@ -10,6 +10,9 @@
|
||||
* full = earcon + 语音 + 震动;standard = earcon + 震动(无语音);off = 全静。
|
||||
* **off 时全部 API 静默 no-op,包括不挂任何定时器**——保证既有测试/玩法零干扰。
|
||||
* - 向导绝不打断内容:插播语音一律 queue:true(等当前对白播完,绝不顶掉)。
|
||||
* **error / watchdog 语音例外,为 idle-only(round2 F1.4)**:对白在播
|
||||
* (audio.isVoicePlaying())时跳过语音只发事件(事件带 skipped:true)——
|
||||
* 纠错/看门狗句排到长链尾部会迟到误导,宁可不说;turn 语音保持 queue 不变。
|
||||
*
|
||||
* 游戏侧用法(构造时创建一次):
|
||||
* import { Guide } from '../engine/guide.js';
|
||||
@@ -149,6 +152,8 @@ export class Guide {
|
||||
|
||||
/**
|
||||
* A4 纠错:error 震(除非 skipHaptic——游戏层已震过时传 true)+ full 档语音。
|
||||
* 语音为 idle-only(F1.4):对白在播时跳过只发 skipped 事件,且不占 onceKey
|
||||
* (玩家没听到不算说过,下次空闲再纠错仍可播一次)。
|
||||
* @param {{voice?: string|null, onceKey?: string|null, skipHaptic?: boolean}} [opts]
|
||||
*/
|
||||
error(opts = {}) {
|
||||
@@ -156,18 +161,23 @@ export class Guide {
|
||||
this._touch();
|
||||
if (!opts.skipHaptic) vibrate('error');
|
||||
if (!opts.voice) return;
|
||||
if (this.level() !== 'full') return;
|
||||
if (this.audio.isVoicePlaying()) {
|
||||
this._emit('error', 'error', opts.voice, true);
|
||||
return;
|
||||
}
|
||||
if (opts.onceKey) {
|
||||
if (this._once.has(opts.onceKey)) return;
|
||||
if (this.level() === 'full') this._once.add(opts.onceKey); // 只在真的播了才计数
|
||||
this._once.add(opts.onceKey); // 只在真的播了才计数
|
||||
}
|
||||
if (this.level() !== 'full') return;
|
||||
this._say('error', opts.voice);
|
||||
}
|
||||
|
||||
/**
|
||||
* L4 静默看门狗:arm 后按 at 逐级计时,每级播 nudge 轻 earcon(+ say[n-1] 语音,
|
||||
* full 档),再调 onEscalate(n);级数用尽自动停止。cond() 在触发时刻返回 false
|
||||
* 则静默取消(游戏已离开该等待态)。任何 guide API 调用 / disarm() / 再次 arm 复位。
|
||||
* full 档,idle-only——对白在播时跳过只发事件),再调 onEscalate(n);级数用尽
|
||||
* 自动停止。cond() 在触发时刻返回 false 则静默取消(游戏已离开该等待态)。
|
||||
* 任何 guide API 调用 / disarm() / 再次 arm 复位。
|
||||
* @param {{arm?: boolean, at?: number[], say?: (string|null)[], onEscalate?: (n:number)=>void, cond?: ()=>boolean}} opts
|
||||
*/
|
||||
watchdog(opts = {}) {
|
||||
@@ -194,16 +204,27 @@ export class Guide {
|
||||
return this.level() === 'off';
|
||||
}
|
||||
|
||||
_emit(type, name, voiceId) {
|
||||
_emit(type, name, voiceId, skipped = false) {
|
||||
if (!this._hook) return;
|
||||
try {
|
||||
this._hook({ type, name, voiceId: voiceId ?? null });
|
||||
const evt = { type, name, voiceId: voiceId ?? null };
|
||||
if (skipped) evt.skipped = true;
|
||||
this._hook(evt);
|
||||
} catch (_) { /* 钩子异常不影响游戏 */ }
|
||||
}
|
||||
|
||||
/** full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。 */
|
||||
/**
|
||||
* full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。
|
||||
* F1.4 idle-only:其中 error/watchdog 两类在对白在播(audio.isVoicePlaying())
|
||||
* 时跳过语音只发事件(事件带 skipped:true)——排到链尾会迟到误导;turn 保持
|
||||
* queue 不变。
|
||||
*/
|
||||
_say(type, voiceId) {
|
||||
if (this.level() !== 'full') return;
|
||||
if ((type === 'error' || type === 'watchdog') && this.audio.isVoicePlaying()) {
|
||||
this._emit(type, type, voiceId, true);
|
||||
return;
|
||||
}
|
||||
this._emit(type, type, voiceId);
|
||||
this.audio.play(`voice/${voiceId}`, { queue: true });
|
||||
}
|
||||
|
||||
+8
-1
@@ -352,7 +352,14 @@ export class EscapeGame extends Game {
|
||||
if (nx < 0 || nx > 2 || ny < 0 || ny > 2) {
|
||||
this.audio.play('sfx/wall');
|
||||
vibrate('error');
|
||||
this._playVoice('voice/wall'); // 每次撞墙都播(取消一次性限制)
|
||||
// F2.3(round2):撞墙反馈接既有 guide_blunt_<dir> 方向句(纯逻辑接线,
|
||||
// 资产 v1 已有)——「那边是墙」之后补一句方位("手指贴着屏幕,向上推一下"),
|
||||
// 噪音环境下听不出墙音方向时也知道刚才顶到的是哪边。排在本句播完之后;
|
||||
// 被下一次滑动打断则自然作废(不迟到误导)。
|
||||
const dir = dy < 0 ? 'up' : dy > 0 ? 'down' : dx < 0 ? 'left' : 'right';
|
||||
this._playVoice('voice/wall', {
|
||||
onComplete: () => this._playVoice(`voice/guide_blunt_${dir}`),
|
||||
});
|
||||
this._wallBumps++; // 向导层:连续撞墙 = 卡关信号(_checkStuck 提前一级)
|
||||
return;
|
||||
}
|
||||
|
||||
+94
-29
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* 向导层无头测试(web/harness/guide.test.mjs)。
|
||||
*
|
||||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为:
|
||||
* ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音
|
||||
* (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input
|
||||
* 10s → gd_relisten、25s → 免扣机会自动重听一次;
|
||||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏 + Guide 直连断言:
|
||||
* ① 看门狗礼仪(round2 F1):storyline 进入等待态 25s → nudge + gd_continue,
|
||||
* 50s 升级只重申手势(gd_again),**不再重播 help/进度/当前节点**,两级用尽
|
||||
* 彻底静默;simon input 10s → gd_relisten,25s 升级 = confirm 震 + turn
|
||||
* earcon 纯标记(**取消自动重播序列**);
|
||||
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
|
||||
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
|
||||
* ④ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||||
* ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(事件带 skipped:true、
|
||||
* 不入队),空闲时正常播;turn 语音保持 queue 不受影响;
|
||||
* ⑤ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||||
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
|
||||
*
|
||||
* 运行:tools/node/bin/node web/harness/guide.test.mjs
|
||||
@@ -143,6 +146,7 @@ globalThis.fetch = async (u) => {
|
||||
|
||||
const { WebAudioEngine } = await import('../engine/audio.js');
|
||||
const { settings } = await import('../engine/settings.js');
|
||||
const { Guide } = await import('../engine/guide.js');
|
||||
const { StorylineGame } = await import('../storyline/game.js');
|
||||
const { SimonGame } = await import('../simon/game.js');
|
||||
|
||||
@@ -172,44 +176,50 @@ const waitChainDone = (game) => advance(() => !game.voicePlaying);
|
||||
|
||||
// ================================================================ ① storyline 看门狗
|
||||
|
||||
realLog('== ① storyline:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
|
||||
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
|
||||
{
|
||||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await waitChainDone(game); // n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
|
||||
// F8(round2):开场链最前插 gd_brief(brief→intro)
|
||||
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'F8:开场链最前播 gd_brief');
|
||||
assert(playLog.find((e) => e.id.startsWith('voice/'))?.id === 'voice/gd_brief',
|
||||
'F8:gd_brief 是本局第一句语音');
|
||||
await waitChainDone(game); // gd_brief + n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
|
||||
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
|
||||
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
|
||||
|
||||
const tArm = now;
|
||||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||||
assert(now - tArm === 8000, `8s 后 nudge 触发(实际 ${now - tArm}ms)`);
|
||||
assert(now - tArm === 25000, `25s 后 nudge 触发(实际 ${now - tArm}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
|
||||
'看门狗第 1 级发 gd_continue 语音事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
|
||||
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
|
||||
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/help'));
|
||||
assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`);
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/status1'));
|
||||
assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)');
|
||||
assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)');
|
||||
await advance(() => events.filter((e) => e.type === 'watchdog').length >= 2);
|
||||
assert(now - tArm === 50000, `50s 升级触发(实际 ${now - tArm}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_again'),
|
||||
'升级只发 gd_again(重申手势)语音事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_again') === 1, 'gd_again 语音播放');
|
||||
// F1.1:升级不再重播 help + 进度 + 当前节点
|
||||
assert(!playLog.some((e) => e.id === 'voice/help' || e.id.startsWith('voice/status')),
|
||||
'F1:升级不重播 help/进度');
|
||||
assert(voicePlays(playLog, 'voice/n00_intro') === 1, 'F1:升级不重播当前节点(n00_intro 仅开场一次)');
|
||||
|
||||
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
|
||||
// 两级用尽彻底静默:链走完后无任何向导定时器 → idle
|
||||
await idle();
|
||||
const nudges = events.filter((e) => e.type === 'nudge').length;
|
||||
await idle();
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudges, '两级用尽彻底静默(无后续提醒)');
|
||||
|
||||
// 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次
|
||||
await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped)
|
||||
await waitChainDone(game);
|
||||
await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 n01
|
||||
// 玩家手势后重新挂载:n01 链播完重新挂上,且 25s 节奏到点触发一次
|
||||
await gesture(game, 'tap'); // → n01,链播
|
||||
await waitChainDone(game);
|
||||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
const tWait = now;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
|
||||
assert(now - tWait === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${now - tWait}ms)`);
|
||||
assert(now - tWait === 25000, `重新挂载后仍按 25s 节奏触发(实际 ${now - tWait}ms)`);
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
@@ -251,18 +261,28 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
|
||||
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
|
||||
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
|
||||
|
||||
// ①b input 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听
|
||||
// ①b input 看门狗:10s nudge+gd_relisten
|
||||
const tInput = now;
|
||||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||||
assert(now - tInput === 10000, `input 10s 后 nudge 触发(实际 ${now - tInput}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
|
||||
'input 10s 无输入发 gd_relisten 事件');
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
|
||||
assert(game.chances === 1, '看门狗提示不消耗重听机会');
|
||||
|
||||
const chancesBefore = game.chances;
|
||||
await advance(() => game.phase === 'playback');
|
||||
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
|
||||
assert(game.chances === chancesBefore, '自动重听不扣机会');
|
||||
await advance(() => game.phase === 'input');
|
||||
// 25s 升级(round2 F1.2):取消自动重播序列 → 只发 confirm 震 + turn earcon(纯标记)
|
||||
const nudgesBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
const turnsBefore = events.filter((e) => e.type === 'turn').length;
|
||||
const vibBefore = vibrateLog.length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgesBefore);
|
||||
assert(now - tInput === 25000, `25s 升级触发(实际 ${now - tInput}ms)`);
|
||||
assert(game.phase === 'input' && game.round === 1, '升级不再自动重播序列(仍停在 input 等玩家)');
|
||||
assert(events.filter((e) => e.type === 'turn').length === turnsBefore + 1, '升级发 turn earcon 事件(纯标记)');
|
||||
assert(vibrateLog.slice(vibBefore).some((p) => JSON.stringify(p) === JSON.stringify([20])),
|
||||
'升级发 confirm 轻震');
|
||||
assert(voicePlays(playLog, 'voice/round_01') === 1, '序列未重播(round_01 仍只有第 1 轮一次)');
|
||||
await idle(); // 级数用尽:看门狗自动停止
|
||||
assert(game.phase === 'input', '升级后仍等待输入(免费泄题已废除)');
|
||||
|
||||
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
|
||||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||||
@@ -271,13 +291,58 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
|
||||
await advance(() => game.phase === 'input' && game.round === 2);
|
||||
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
|
||||
'答题手势复位看门狗:第 2 轮开启前无 nudge');
|
||||
assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度');
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ④ guide=off 全静
|
||||
// ================================================================ ④ F1.4 idle-only
|
||||
|
||||
realLog('== ④ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
|
||||
realLog('== ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(skipped 事件、不入队),turn 保持 queue ==');
|
||||
{
|
||||
currentGameDir = GAME_DIRS.StorylineGame;
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
const playLog = [];
|
||||
const origPlay = audio.play.bind(audio);
|
||||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
|
||||
await audio.unlock();
|
||||
const guide = new Guide(audio);
|
||||
const events = [];
|
||||
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||||
|
||||
// watchdog:对白在播时到点 → 只发 skipped 事件,语音不入队
|
||||
guide.watchdog({ arm: true, at: [1000], say: ['gd_continue'] });
|
||||
audio.play('voice/n00_intro'); // 长语音占住通道(mock 句长 2s)
|
||||
await flush();
|
||||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||||
await advance(() => events.some((e) => e.type === 'watchdog'));
|
||||
const wd = events.find((e) => e.type === 'watchdog');
|
||||
assert(wd && wd.voiceId === 'gd_continue' && wd.skipped === true,
|
||||
'watchdog 语音被 idle-skip(事件带 skipped:true)');
|
||||
assert(voicePlays(playLog, 'voice/gd_continue') === 0, 'watchdog 语音未入队播放');
|
||||
|
||||
// error:对白在播 → 同规则只发事件;空闲 → 正常播放
|
||||
guide.error({ voice: 'gd_invalid' });
|
||||
assert(events.some((e) => e.type === 'error' && e.skipped === true),
|
||||
'error 语音对白在播时跳过(skipped 事件)');
|
||||
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'error 语音未入队播放');
|
||||
await advance(() => !audio.isVoicePlaying()); // 等在播对白自然结束
|
||||
guide.error({ voice: 'gd_invalid' });
|
||||
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '空闲时 error 语音正常播放');
|
||||
assert(events.filter((e) => e.type === 'error' && !e.skipped).length === 1,
|
||||
'空闲播放的 error 事件不带 skipped 标记');
|
||||
|
||||
// turn:保持 queue,不受 idle-only 影响(对白在播时照常排队)
|
||||
audio.play('voice/n01_wake');
|
||||
await flush();
|
||||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||||
guide.turn({ voice: 'gd_answer' });
|
||||
assert(voicePlays(playLog, 'voice/gd_answer') === 1, 'turn 语音不受 idle-only 影响(照常入队)');
|
||||
assert(events.some((e) => e.type === 'turn'), 'turn 事件照常发出');
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑤ guide=off 全静
|
||||
|
||||
realLog('== ⑤ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
|
||||
{
|
||||
settings.set({ guide: 'off' });
|
||||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||||
|
||||
@@ -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:连续撞墙 → 提前一级的暗示
|
||||
|
||||
|
||||
@@ -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:防御一次
|
||||
|
||||
@@ -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} 次)`);
|
||||
}
|
||||
|
||||
@@ -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} 秒、挨了两下还活着(警报也只起一波了),先到这`);
|
||||
}
|
||||
|
||||
@@ -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('回到开头,会话结束');
|
||||
}
|
||||
|
||||
@@ -93,7 +93,11 @@ function capture(audio) {
|
||||
pending.push(`${fmtT()} ♫ BGM 切换 → ${id}${opts?.fadeMs ? `(淡入 ${opts.fadeMs}ms)` : ''}`);
|
||||
}
|
||||
} else if (isVoice && id.startsWith('voice/gd_')) {
|
||||
// 向导语音经事件钩子渲染(▶ 向导语音),此处不重复记行
|
||||
// 向导语音(round2):实际播出的在本行渲染(含全文);被 idle-skip 跳过的
|
||||
// 不产生 play 调用,由事件钩子单独标注(见 captureGuide)
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 voice/${id.slice(6)}(无台词表文本)`);
|
||||
} else if (isVoice) {
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 语音:「${t}」`
|
||||
@@ -105,15 +109,17 @@ function capture(audio) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 向导实例接入事件流(每个 earcon/插播语音都转写成玩家可感知的一行)。 */
|
||||
/** 向导实例接入事件流(每个 earcon / 被 idle-skip 的语音都转写成玩家可感知的一行;
|
||||
* 实际播出的向导语音已由 audio.play 捕获渲染,此处不再重复记行)。 */
|
||||
function captureGuide(game) {
|
||||
if (!game.guide) return;
|
||||
game.guide.setEventHook((evt) => {
|
||||
if (evt.voiceId) {
|
||||
if (!evt.skipped) return; // 播出的由 capture() 渲染,避免重复
|
||||
const t = textOf(`voice/${evt.voiceId}`);
|
||||
pending.push(t
|
||||
? `${fmtT()} ▶ 向导语音:「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(无台词表文本)`);
|
||||
? `${fmtT()} ▶ 向导语音(对白在播,本句按 idle-only 跳过未播):「${t}」`
|
||||
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(idle-only 跳过未播)`);
|
||||
} else {
|
||||
const label = GUIDE_LABEL[evt.name] || '';
|
||||
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
|
||||
@@ -122,8 +128,11 @@ function captureGuide(game) {
|
||||
}
|
||||
|
||||
function drain() {
|
||||
// 震动/资产缺失按「实际触发时刻」落行(v2 起不再用 drain 时刻近似——
|
||||
// round2 审计指标要核 danger 震 ≥400ms 去抖、警报 600ms 去重,必须看真实时间)
|
||||
for (const m of mock.vibrateLog.splice(0)) {
|
||||
pending.push(`${fmtT()} 〰 震动:${hapticName(m.pattern)}`);
|
||||
const t = `t+${(m.t / 1000).toFixed(1).padStart(6)}s`;
|
||||
pending.push(`${t} 〰 震动:${hapticName(m.pattern)}`);
|
||||
}
|
||||
// 引擎按 ogg→m4a→mp3→wav、性别目录→平铺 逐个探测;只有全部路径都 404,
|
||||
// 玩家才是真的什么都没听到(角色音探测性别目录 404 后平铺成功属正常回退)
|
||||
@@ -131,7 +140,7 @@ function drain() {
|
||||
const key = m.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/');
|
||||
const everOk = mock.fetchedLog.some((f) => f.ok
|
||||
&& f.url.replace(/^audio\//, '').replace(/\.\w+$/, '').replace(/^voice\/(male|female)\//, 'voice/') === key);
|
||||
if (!everOk) pending.push(`${fmtT()} ⚠ 资产缺失 ${key} → 玩家此刻听到的是静默`);
|
||||
if (!everOk) pending.push(`t+${(m.t / 1000).toFixed(1).padStart(6)}s ⚠ 资产缺失 ${key} → 玩家此刻听到的是静默`);
|
||||
}
|
||||
pending.sort((a, b) => parseFloat(a.match(/t\+(\s*)(\d+)/)?.[2] ?? 0) - parseFloat(b.match(/t\+(\s*)(\d+)/)?.[2] ?? 0));
|
||||
lines.push(...pending);
|
||||
@@ -164,7 +173,8 @@ function makeCtx(audio, game, describe) {
|
||||
status: (tag) => {
|
||||
const d = describe ? describe(game) : '';
|
||||
const v = voiceBusy(audio) ? '语音播放中' : (audio._bgm ? `BGM=${audio._bgm?.id ?? '无'}` : '无 BGM');
|
||||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v})`);
|
||||
// 语速档随状态行输出(round2 F9:转写需可见语速切换生效)
|
||||
lines.push(`${fmtT()} 【状态】${tag || ''}${d ? ` ${d}` : ''}(${v},语速${settings.get().speed})`);
|
||||
},
|
||||
silence: (sec, why) => {
|
||||
lines.push(`${fmtT()} ……… 静默 ${sec}s${why ? `(${why})` : ',游戏在等待玩家输入'} ………`);
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_continue": "轻触屏幕,继续。",
|
||||
"gd_invalid": "这个操作在这里用不上。",
|
||||
"gd_brief": "单击继续剧情。请选择的时候,左右滑动。",
|
||||
"gd_again": "还在吗?单击继续。",
|
||||
"n02_linwan": "咳……咳咳……我还活着。晨晨,你在哪儿……你说过,会在北区等我。姐一定,一定找到你。",
|
||||
"n03_radio": "收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……",
|
||||
"n03_msg": "姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。",
|
||||
@@ -199,7 +201,8 @@
|
||||
"menu_home": "回大厅。",
|
||||
"menu_back": "返回游戏。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_relisten": "需要的话,双击可以重新听一遍序列。"
|
||||
"gd_relisten": "需要的话,双击可以重新听一遍序列。",
|
||||
"gd_economy": "记不住就双击重新听,免费机会有两次,用完要扣一次机会。"
|
||||
},
|
||||
"rhythm": {
|
||||
"welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。",
|
||||
@@ -235,7 +238,10 @@
|
||||
"menu_lobby": "回大厅。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_answer": "现在跟着我打。",
|
||||
"gd_miss": "慢了半拍,下一小节跟上我就行。"
|
||||
"gd_miss": "慢了半拍,下一小节跟上我就行。",
|
||||
"gd_miss_early": "抢拍了。等声音落下再滑。",
|
||||
"gd_miss_late": "慢了半拍。跟着节拍走。",
|
||||
"gd_miss_wrong": "方向不对。跟着音色滑。"
|
||||
},
|
||||
"space": {
|
||||
"drill_welcome": "欢迎登上盲航。你只有一双耳朵。",
|
||||
@@ -291,7 +297,8 @@
|
||||
"settings_hint": "音色、语速、背景音乐,请回大厅修改。",
|
||||
"paused": "已暂停。",
|
||||
"skip_hint": "单击可跳过旁白。",
|
||||
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。"
|
||||
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。",
|
||||
"gd_last_life": "这是最后一次机会,撑住。"
|
||||
},
|
||||
"rpg": {
|
||||
"intro_1": "你的眼睛看不见了。但你的耳朵,比谁都灵。",
|
||||
@@ -414,6 +421,9 @@
|
||||
"boss1_warn": "里面就是黑风寨主。你现在回去练练再来,也来得及。",
|
||||
"gd_parry_miss": "前摇声起的时候双击,才能格挡。",
|
||||
"gd_stall": "想好了吗?左右滑动选择,单击出手。",
|
||||
"tut_b2b": "挡得漂亮。",
|
||||
"gd_wrap_first": "回到第一个。",
|
||||
"gd_wrap_last": "最后一个了。",
|
||||
"npc_headman_1": "外乡人,林子里不太平。这五十金,拿着防身。",
|
||||
"npc_headman_2": "商店就在村里,缺什么就去看看。",
|
||||
"npc_headman_3": "听说你是用耳朵走路的剑客?保重啊。",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
-15
@@ -35,10 +35,13 @@
|
||||
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
|
||||
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
|
||||
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
|
||||
* 向导层(research/03-schemes/schemes.md §2):每曲首次领奏→应答切换推
|
||||
* gd_answer(turn cue);连续 2 次 Miss 播 gd_miss(queue 插播,同曲一次);
|
||||
* 结算链换 _speakGuarded(资产缺失不卡结算 BGM);状态签名 earcon 与状态
|
||||
* BGM 同点(select=menu、main=battle、end=settle)。
|
||||
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3):每曲首次
|
||||
* 领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证窗口前
|
||||
* 听到)+ turn earcon;教学 Miss 首次即归因分档(gd_miss_early/late/wrong,
|
||||
* 挂纠正链尾空闲点);连续 2 Miss 播 gd_miss(queue 插播,同曲一次);暂停
|
||||
* 断点续播前插 4 拍 count-in(off 档保持即时续播);结算链换 _speakGuarded
|
||||
* (资产缺失不卡结算 BGM);状态签名 earcon 与状态 BGM 同点
|
||||
* (select=menu、main=battle、end=settle)。
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
import { Guide } from '../engine/guide.js';
|
||||
@@ -341,7 +344,7 @@ export class RhythmGame extends Game {
|
||||
this._tutFails++;
|
||||
vibrate('error');
|
||||
if (this._tutFails >= 2) {
|
||||
// 同一步失败 2 次跳过,不卡流程
|
||||
// 同一步失败 2 次跳过,不卡流程(归因已在首次失败时给过,此处不再叠加)
|
||||
this._speakGuarded(['voice/retry_skip'], () => {
|
||||
if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1);
|
||||
else {
|
||||
@@ -352,13 +355,22 @@ export class RhythmGame extends Game {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen)
|
||||
let correction = 'voice/retry_listen';
|
||||
// F2.1(round2):教学 Miss 首次即归因、按失败原因分档——方向错 gd_miss_wrong /
|
||||
// 抢拍 gd_miss_early / 慢拍 gd_miss_late(无在窗内尝试=没跟上拍,归入慢拍)。
|
||||
// 向导句(full 档)挂在纠正链播完后的空闲点发:idle-only 语义下链中插播会被
|
||||
// 跳过;之后 _tutLead 只排 sfx,不与归因句抢 voice 通道。
|
||||
const wrong = this._tutAttempts.find((a) => !a.correct);
|
||||
const offbeat = this._tutAttempts.find((a) => a.correct && Math.abs(a.dt) > GOOD);
|
||||
const missGrade = wrong ? 'gd_miss_wrong'
|
||||
: offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late';
|
||||
// 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen)
|
||||
let correction = 'voice/retry_listen';
|
||||
if (wrong) correction = 'voice/retry_gesture';
|
||||
else if (offbeat) correction = offbeat.dt < 0 ? 'voice/retry_early' : 'voice/retry_late';
|
||||
this._speakGuarded([correction, 'voice/retry_listen'], () => this._tutLead(rid));
|
||||
this._speakGuarded([correction, 'voice/retry_listen'], () => {
|
||||
this.guide.error({ voice: missGrade, skipHaptic: true });
|
||||
this._tutLead(rid);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 选曲 ----------------
|
||||
@@ -428,11 +440,16 @@ export class RhythmGame extends Game {
|
||||
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
|
||||
}
|
||||
if (i === 0) {
|
||||
// 向导层(schemes §2):每曲首次领奏→应答切换推 gd_answer(turn cue,
|
||||
// 谱面排程处入 _timed,mid_song 同款先例;full 档语音 queue 插播不顶 BGM)
|
||||
// 向导层(round2 F3):每曲首次领奏→应答切换的交接标记。gd_answer 不走
|
||||
// guide 的 queue(对白在播时会排到链尾、错过首个应答窗口)——排进曲谱
|
||||
// 定时队列、在窗口前直接落下;turn earcon 走向导(full 语音,standard
|
||||
// 只听提示音,off 全静)。mid_song 同款先例。
|
||||
this._timed.push({
|
||||
time: leadBarT0 + 4 * spb,
|
||||
fn: () => this.guide.turn({ voice: 'gd_answer' }),
|
||||
fn: () => {
|
||||
if (this.guide.level() === 'full') this.audio.play('voice/gd_answer');
|
||||
this.guide.turn({});
|
||||
},
|
||||
});
|
||||
}
|
||||
bar += 2 + (this._song.restAfter[i] || 0);
|
||||
@@ -625,14 +642,23 @@ export class RhythmGame extends Game {
|
||||
_closeMenu(rid) {
|
||||
if (this._runId !== rid) return;
|
||||
const resumeAt = this.audio.ctx.currentTime + 0.1;
|
||||
const shift = resumeAt - this._pauseAt;
|
||||
// 全部绝对时刻平移暂停时长,节拍网格与 BGM 断点续播保持同步
|
||||
// F3(round2):断点续播前插一整小节 4 拍 count-in(复用底鼓节拍音,走既有
|
||||
// _sched 网格排程 = 每拍一个 inst_kick + beat 轻震),网格与 BGM 整体后移一小节,
|
||||
// 杜绝 P1/P2/P3 实测的「回来 5 秒死寂、第一句报废」。off 档保持原即时续播
|
||||
// (向导层 off = 零改动路径)。
|
||||
const countIn = this.guide.level() !== 'off' ? 4 * (60 / this._song.bpm) : 0;
|
||||
const bgmAt = resumeAt + countIn;
|
||||
const shift = bgmAt - this._pauseAt;
|
||||
// 全部绝对时刻平移(暂停时长 + count-in 小节),节拍网格与 BGM 断点续播保持同步
|
||||
for (const ev of this._sched) ev.time += shift;
|
||||
for (const ev of this._timed) ev.time += shift;
|
||||
for (const cue of this._cues) cue.expected += shift;
|
||||
this._phaseEnd += shift;
|
||||
// _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift
|
||||
this._startBgm(this._song, resumeAt, this._bgmOffset);
|
||||
for (let i = 0; i < 4 && countIn > 0; i++) {
|
||||
this._sched.push({ time: resumeAt + i * (countIn / 4), sfx: INST.tap, volume: 0.7 });
|
||||
}
|
||||
// _startBgm 内部重算 _bgmT0 = bgmAt - offset = 旧网格 + shift(负偏移保护同款成立)
|
||||
this._startBgm(this._song, bgmAt, this._bgmOffset);
|
||||
this.phase = 'main';
|
||||
this.audio.setRateBoostEnabled(false); // 回到演奏期
|
||||
this._startPump(rid);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
-9
@@ -137,6 +137,8 @@ export class RpgGame extends Game {
|
||||
this._ambId = null;
|
||||
// playSec 计时
|
||||
this._segStart = null;
|
||||
// F7:回绕提示去重(同一次菜单打开内每句最多 1 次)
|
||||
this._wrapShown = new Set();
|
||||
this._newRun();
|
||||
}
|
||||
|
||||
@@ -257,6 +259,7 @@ export class RpgGame extends Game {
|
||||
this.scene = id;
|
||||
this.optIdx = 0;
|
||||
this.phase = 'explore';
|
||||
this._wrapShown = new Set(); // F7:场景选项列表重新"打开",回绕提示重新可用
|
||||
this._switchSceneAudio(id, opts.fadeMs || 800);
|
||||
this.audio.play('sfx/step', { volume: 0.8 });
|
||||
|
||||
@@ -307,11 +310,22 @@ export class RpgGame extends Game {
|
||||
return false;
|
||||
}
|
||||
|
||||
_speakOption() {
|
||||
_speakOption(onComplete) {
|
||||
if (this.phase !== 'explore') return;
|
||||
const opts = SCENES[this.scene].options;
|
||||
if (!opts.length) return;
|
||||
this.audio.play(`voice/opt_${opts[this.optIdx]}`);
|
||||
this.audio.play(`voice/opt_${opts[this.optIdx]}`, { onComplete });
|
||||
}
|
||||
|
||||
/**
|
||||
* F7(round2):循环列表切回首项/末项的回绕提示(P1「不报回绕」)。full 档
|
||||
* queue 跟在本项报名之后;同一次菜单打开内每句最多 1 次(_wrapShown),别每滑必播。
|
||||
*/
|
||||
_wrapCue(voiceId) {
|
||||
if (this.guide.level() !== 'full') return;
|
||||
if (this._wrapShown.has(voiceId)) return;
|
||||
this._wrapShown.add(voiceId);
|
||||
this.audio.play(`voice/${voiceId}`, { queue: true });
|
||||
}
|
||||
|
||||
_onExploreGesture(g) {
|
||||
@@ -320,7 +334,10 @@ export class RpgGame extends Game {
|
||||
if (g === 'left' || g === 'right') {
|
||||
const n = opts.length;
|
||||
this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n;
|
||||
this._speakOption();
|
||||
const wrap = n >= 2 && ((g === 'right' && this.optIdx === 0) || (g === 'left' && this.optIdx === n - 1))
|
||||
? (this.optIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
|
||||
// 回绕提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
|
||||
this._speakOption(wrap ? () => this._wrapCue(wrap) : undefined);
|
||||
} else if (g === 'tap') {
|
||||
// voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳),
|
||||
// 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。
|
||||
@@ -605,13 +622,14 @@ export class RpgGame extends Game {
|
||||
const b = this._battle;
|
||||
if (!b) return;
|
||||
b.state = 'PLAYER_SELECT';
|
||||
this._wrapShown = new Set(); // F7:指令循环重新"打开",回绕提示重新可用
|
||||
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
|
||||
this.audio.play('sfx/turn_player');
|
||||
// 向导层静默看门狗(schemes §2):探索/常规战斗本无卡关计时,PLAYER_SELECT
|
||||
// 挂机永远静默等——60s 无输入 → nudge + gd_stall(一级即止,自动停止)
|
||||
// 向导层静默看门狗(round2 F1.3:60s→30s,等待默认低频但不再干晾一分钟):
|
||||
// PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)
|
||||
this.guide.watchdog({
|
||||
arm: true,
|
||||
at: [60000],
|
||||
at: [30000],
|
||||
say: ['gd_stall'],
|
||||
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT',
|
||||
});
|
||||
@@ -666,7 +684,11 @@ export class RpgGame extends Game {
|
||||
if (b.state === 'PLAYER_SELECT') {
|
||||
if (g === 'left' || g === 'right') {
|
||||
b.cmdIdx = (b.cmdIdx + (g === 'right' ? 1 : CMDS.length - 1)) % CMDS.length;
|
||||
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
|
||||
// F7:指令循环回绕提示(探索菜单同款,full 档,跟在本项报名之后)
|
||||
const wrap = (g === 'right' && b.cmdIdx === 0) || (g === 'left' && b.cmdIdx === CMDS.length - 1)
|
||||
? (b.cmdIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
|
||||
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`,
|
||||
wrap ? { onComplete: () => this._wrapCue(wrap) } : undefined);
|
||||
// 教学 T0:切换 2 次(听到指令名切换)即过关
|
||||
if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack');
|
||||
} else if (g === 'tap') {
|
||||
@@ -830,10 +852,12 @@ export class RpgGame extends Game {
|
||||
p.hp -= dmg;
|
||||
if (b.enc.teach) p.hp = Math.max(1, p.hp); // 教学战 HP 锁底 1(必不死)
|
||||
console.log(`[RpgGame] 敌方攻击 dmg=${dmg} 格挡=${b.parry} 玩家HP=${p.hp}/${this.maxHp}`);
|
||||
// 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2)
|
||||
// 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2)。
|
||||
// F4(round2):格挡**成功**通过时先插 tut_b2b「挡得漂亮。」确认表现,
|
||||
// 消除 P1/P4「明明挡住了还说来不及格挡」的歧义(失败路径 tut_b4 不变)。
|
||||
if (b.teachStage === 'parry') {
|
||||
b.teachStage = 'defend';
|
||||
const ids = b.parry ? ['voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6'];
|
||||
const ids = b.parry ? ['voice/tut_b2b', 'voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6'];
|
||||
this._speakGuarded(ids, () => this._playerSelect(), 10000);
|
||||
this._armTeachWatchdog('defend');
|
||||
return;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
-19
@@ -64,10 +64,10 @@ const TEACH_BLUNT = {
|
||||
const BEST_KEY = 'soundgame.simon.best';
|
||||
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
|
||||
|
||||
// 向导层(schemes.md §2):input 阶段静默看门狗——10s 轻提醒+gd_relisten(不改变
|
||||
// 重听扣机会规则),25s 免扣机会自动重听一次(每局 2 次)
|
||||
// 向导层(14 号文档 + round2 synthesis F1.2):input 阶段静默看门狗——10s 轻提醒
|
||||
// +gd_relisten(不改变重听扣机会规则);25s 升级取消自动重播序列(P1:泄题且与
|
||||
// 扣机会规则矛盾)→ 改为 confirm 震 + turn earcon(纯标记,无语音)
|
||||
const GUIDE_INPUT_WATCHDOG_AT = [10000, 25000];
|
||||
const GUIDE_AUTO_RELISTEN_MAX = 2;
|
||||
|
||||
export class SimonGame extends Game {
|
||||
constructor(audio, root) {
|
||||
@@ -87,7 +87,6 @@ export class SimonGame extends Game {
|
||||
this._menuIdx = 0;
|
||||
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
|
||||
this._timers = [];
|
||||
this._guideRelistens = 0; // 向导免机会自动重听已用次数(每局重置)
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -206,8 +205,10 @@ export class SimonGame extends Game {
|
||||
if (this.teachIdx + 1 < GESTURES.length) {
|
||||
this._teachStep(this.teachIdx + 1);
|
||||
} else {
|
||||
// T7:收尾两句 → 正式对局
|
||||
this._speakGuarded(['voice/t7_ready', 'voice/t7_retry'], () => this._beginGame(), 12000);
|
||||
// T7:收尾两句 + 机会经济一句(round2 F5:记不住就双击重听、免费两次、
|
||||
// 用完扣机会——P1「经济不透明」开局一句话讲清)→ 正式对局
|
||||
this._speakGuarded(['voice/t7_ready', 'voice/t7_retry', 'voice/gd_economy'],
|
||||
() => this._beginGame(), 20000);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -233,7 +234,6 @@ export class SimonGame extends Game {
|
||||
this.sequence = [];
|
||||
this.round = 0;
|
||||
this.chances = 1;
|
||||
this._guideRelistens = 0; // 向导免机会自动重听每局重置
|
||||
this._nextRound();
|
||||
}
|
||||
|
||||
@@ -332,26 +332,19 @@ export class SimonGame extends Game {
|
||||
_toInput() {
|
||||
this.phase = 'input';
|
||||
this.inputIdx = 0;
|
||||
// 向导层静默看门狗(schemes §2,全 6 款最危险的静默等待):input 阶段无 BGM
|
||||
// 无超时,挂机永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会
|
||||
// 规则);25s → 免扣机会自动重听序列一次(每局 2 次),防挂机死等。
|
||||
// 向导层静默看门狗(round2 F1.2 礼仪重构):input 阶段无 BGM 无超时,挂机
|
||||
// 永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会规则);25s 升级
|
||||
// **取消免扣机会自动重播序列**(P1:等答案自动重念,双击重听的经济就矛盾了,
|
||||
// 还顺手拆了记忆难度)→ 只发 confirm 震 + turn earcon(纯标记,无语音)。
|
||||
this.guide.watchdog({
|
||||
arm: true,
|
||||
at: GUIDE_INPUT_WATCHDOG_AT,
|
||||
say: ['gd_relisten', null],
|
||||
onEscalate: (n) => { if (n === 2) this._guideAutoRelisten(); },
|
||||
onEscalate: (n) => { if (n === 2) this.guide.turn({ haptic: true }); },
|
||||
cond: () => this.phase === 'input',
|
||||
});
|
||||
}
|
||||
|
||||
/** 向导兜底:免扣机会自动重听当前序列(每局最多 2 次,序列不变进度重输)。 */
|
||||
_guideAutoRelisten() {
|
||||
if (this._guideRelistens >= GUIDE_AUTO_RELISTEN_MAX) return;
|
||||
this._guideRelistens++;
|
||||
if (this.phase !== 'input') return;
|
||||
this._playSequence();
|
||||
}
|
||||
|
||||
_onInput(g) {
|
||||
// 双击的上下文判定(见文件头"设计冲突的处理"):
|
||||
// 期望输入是 doubletap → 答题;否则 → 主动重听(耗机会)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
-18
@@ -31,9 +31,10 @@
|
||||
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
|
||||
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
|
||||
*
|
||||
* 向导层(research/03-schemes/schemes.md §2):对局第 1 次受击(保护期外)追加
|
||||
* 指令式 gd_hit_hint(归因句 hit_<dir> 之外的"该怎么拨");结算链换
|
||||
* _speakGuarded(资产缺失不卡结算 BGM);长按快进对局期豁免
|
||||
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.2/F6):对局第 1 次
|
||||
* 受击(保护期外)在归因链播完后补指令式 gd_hit_hint、第 2 次补 gd_last_life
|
||||
* (idle-only:挂在语音空闲点);警报音效 600ms 去重、danger 震 ≥400ms 去抖;
|
||||
* 结算链换 _speakGuarded(资产缺失不卡结算 BGM);对局期长按快进豁免
|
||||
* (setRateBoostEnabled);状态签名 earcon 与状态 BGM 同点。
|
||||
*/
|
||||
import { Game } from '../engine/game.js';
|
||||
@@ -120,6 +121,10 @@ export class SpaceGame extends Game {
|
||||
this.shieldIntroDone = false;
|
||||
this.featureKind = null;
|
||||
this._menuIdx = 0;
|
||||
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
|
||||
this._alarmSoftAt = -Infinity;
|
||||
this._alarmLoudAt = -Infinity;
|
||||
this._dangerAt = -Infinity;
|
||||
}
|
||||
|
||||
// ---------------- 生命周期 ----------------
|
||||
@@ -449,22 +454,31 @@ export class SpaceGame extends Game {
|
||||
this.obstacles.add(ob);
|
||||
|
||||
this.audio.play(APPROACH_SFX[dir]);
|
||||
// 警报两档渐强,DANGER 震动同步叠加
|
||||
// 警报两档渐强,DANGER 震动同步叠加。
|
||||
// F6(round2):警报音效同 600ms 窗口去重(双陨石同时逼近只起一波警报,
|
||||
// 按 soft/loud 各自去重,单颗的两段渐强不受影响);danger 震 ≥400ms 去抖。
|
||||
this._after(impactAt - ALARM_LEAD_MS - now, () => {
|
||||
if (this.obstacles.has(ob)) {
|
||||
this.audio.play('sfx/alarm', { volume: 0.5 });
|
||||
vibrate('danger');
|
||||
}
|
||||
if (this.obstacles.has(ob)) this._alarm(0.5);
|
||||
});
|
||||
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
|
||||
if (this.obstacles.has(ob)) {
|
||||
this.audio.play('sfx/alarm', { volume: 1 });
|
||||
vibrate('danger');
|
||||
}
|
||||
if (this.obstacles.has(ob)) this._alarm(1);
|
||||
});
|
||||
this._after(impactAt - now, () => this._resolve(ob));
|
||||
}
|
||||
|
||||
/** 一段警报:同档 600ms 内只起一波(F6);danger 震全局限 ≥400ms 间隔。 */
|
||||
_alarm(volume) {
|
||||
const now = performance.now();
|
||||
const key = volume < 1 ? '_alarmSoftAt' : '_alarmLoudAt';
|
||||
if (now - this[key] < 600) return;
|
||||
this[key] = now;
|
||||
if (now - this._dangerAt >= 400) {
|
||||
this._dangerAt = now;
|
||||
vibrate('danger');
|
||||
}
|
||||
this.audio.play('sfx/alarm', { volume });
|
||||
}
|
||||
|
||||
/** 撞击点判定:加速增益 > 同向滑动 > 护盾 > 保护期训练护盾 > 撞击。 */
|
||||
_resolve(ob) {
|
||||
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
|
||||
@@ -508,12 +522,18 @@ export class SpaceGame extends Game {
|
||||
} else {
|
||||
const ids = [`voice/hit_${ob.dir}`];
|
||||
if (this.hits === 2) ids.push('voice/hit_last');
|
||||
this._speak(ids);
|
||||
// 向导层(schemes §2):对局第 1 次受击追加指令式提示(hit_<dir> 已归因方位,
|
||||
// 这句补"该怎么拨";已震 hit 免重复 error 震;onceKey 同局一次)
|
||||
if (this.hits === 1) {
|
||||
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
|
||||
}
|
||||
// 向导层(round2 F1.4/F2.2):受击提示挂在归因链播完后的空闲点——
|
||||
// idle-only 语义下若插在链头会被判为「对白在播」整段跳过。
|
||||
// 第 1 次受击补指令式 gd_hit_hint(hit_<dir> 已归因方位,这句补"该怎么拨",
|
||||
// onceKey 同局一次);第 2 次受击播 gd_last_life(F2.2:生存经济透明,
|
||||
// 与游戏层 hit_last「船体只剩最后一次」互补"撑住"的口径),已震 hit 免重复。
|
||||
this._speak(ids, () => {
|
||||
if (this.hits === 1) {
|
||||
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
|
||||
} else if (this.hits === 2) {
|
||||
this.guide.error({ voice: 'gd_last_life', skipHaptic: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
-17
@@ -82,8 +82,10 @@ const VOICE_WATCHDOG_MS = 30000;
|
||||
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
|
||||
const PROMPT_RE = /^voice\/p\d/;
|
||||
|
||||
// 向导层(schemes.md §2):W1 静默看门狗阈值——8s 轻提醒+gd_continue,20s 升级重播帮助
|
||||
const GUIDE_WATCHDOG_AT = [8000, 20000];
|
||||
// 向导层(14 号文档 + round2 synthesis F1.1):剧情类等待默认低频——25s 轻提醒
|
||||
// +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
|
||||
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)
|
||||
const GUIDE_WATCHDOG_AT = [25000, 50000];
|
||||
|
||||
export class StorylineGame extends Game {
|
||||
constructor(audio, root) {
|
||||
@@ -104,10 +106,6 @@ export class StorylineGame extends Game {
|
||||
this.companion = false; // 带上了小满
|
||||
this.knowTrap = false; // 装死时听到北区陷阱的情报
|
||||
this.betrayed = false; // 向猎狗交出过坐标
|
||||
|
||||
// 向导层:升级帮助播过后不再自动重挂看门狗(L4"之后停止,不轰炸"),
|
||||
// 玩家下一次手势即复位
|
||||
this._guideHelped = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Game
|
||||
@@ -123,7 +121,6 @@ export class StorylineGame extends Game {
|
||||
onGesture(g) {
|
||||
console.log(`[StorylineGame] onGesture: ${g} mode=${this.mode} node=${this.nodeId} armed=${this.choiceArmed} playing=${this.voicePlaying}`);
|
||||
this.guide.disarm(); // 任何手势复位静默看门狗(guide=off 时 no-op)
|
||||
this._guideHelped = false; // 玩家有动作,恢复看门狗自动挂载
|
||||
if (this.mode === MODE.MENU) this.onMenuGesture(g);
|
||||
else this.onStoryGesture(g);
|
||||
}
|
||||
@@ -201,6 +198,10 @@ export class StorylineGame extends Game {
|
||||
this.awaitingRestart = false;
|
||||
if (node.sfx) this.audio.play(node.sfx);
|
||||
const seq = [...node.voices];
|
||||
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
|
||||
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
|
||||
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
|
||||
if (id === 'n00' && this.guide.level() === 'full') seq.unshift('voice/gd_brief');
|
||||
// 开场教学:intro 早期追加一次"单击可跳过旁白"提示(每局只播一次,重开不重播)
|
||||
if (id === 'n00' && !this.skipHintShown) {
|
||||
this.skipHintShown = true;
|
||||
@@ -241,19 +242,14 @@ export class StorylineGame extends Game {
|
||||
} else if (node.ending) {
|
||||
this.awaitingRestart = true;
|
||||
}
|
||||
// W1/W3 静默等待看门狗(向导层,schemes §2):8s nudge+gd_continue;
|
||||
// 20s 升级 = 复用 playHelpAndResume(帮助+进度+重播当前节点),升级后不再
|
||||
// 自动重挂(防循环轰炸),玩家下一次手势恢复。抉择态与语音播放中不挂。
|
||||
if (node.prompt == null && !this._guideHelped) {
|
||||
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue;
|
||||
// 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽
|
||||
// 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。
|
||||
if (node.prompt == null) {
|
||||
this.guide.watchdog({
|
||||
arm: true,
|
||||
at: GUIDE_WATCHDOG_AT,
|
||||
say: ['gd_continue', null],
|
||||
onEscalate: (n) => {
|
||||
if (n !== 2 || this.mode !== MODE.STORY) return;
|
||||
this._guideHelped = true;
|
||||
this.playHelpAndResume();
|
||||
},
|
||||
say: ['gd_continue', 'gd_again'],
|
||||
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user