live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+230 -53
View File
@@ -7,17 +7,39 @@
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
* 演练陨石呼啸响起时同窗播 dir_<dir>,与实战"听声即滑"的配对一致(round4 R1)。
* - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收
* (shield_block 音 + WARNING 震动 + protect_<dir> 归因句)。60s 播 protect_end
* 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。
* - **单击/双击分批引入**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),第 45s 教
* 双击护盾(6s,30s 充能,shield_ready 语音);8s 内没做不等待直接继续。
* - **死亡即教学**:保护期后被撞播 hit_<dir> 归因句,第 2 次加 hit_last;
* 3 次撞击坠毁。结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
* 过渡不断崖:保护结束后的前 3 颗裸飞陨石保持 4.0s 逼近 / 4.0s 刷怪,
* 第 4 颗起才接入难度曲线(round4 R1:protect_end 后第一颗疑似必中、10s 三连撞)。
* - **闪避窗口覆盖呼啸全程**(round4 R1:早滑被静默吞掉):同向滑在陨石呼啸响起
* (spawn)到撞击点 +250ms 宽限之间都有效,不再只开撞击前 1.5s;spawn 之前的
* "预言家"滑动不算(按 ob.spawnAt 下界)。
* - **单击/双击分批引入(仅首局)**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),
* 第 45s 教双击护盾(6s,15s 充能,shield_ready 语音排队播);8s 内没做不等待
* 直接继续。introDone 标记挂在实例上(不随 _resetRunState 重置),第 2 局起
* 不再重播教学、机制开局即用(round4 R1:feat_* 每局重播)。护盾不可用
* (未引入/充能中)时双击有即时应答(feat_shield_done 现成句 + HEARTBEAT 震,
* 4s 去抖;round4 R1:panic 双击零反馈)。
* - **死亡即教学**:保护期后被撞播 hit_<dir> 归因句,第 2 次追加 hit_last
* (呼喊接力——归因被 dir_<dir> 呼喊顶断时 hit_last 挂起,由呼喊播完的
* onComplete 自动接播,"最后一命"不再被静默吞掉,round4 R1);3 次撞击坠毁。
* 结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
* round2 R2(高速段警告播不出/叠播):**hit_last 在播期间方向呼喊抑制**
* (警告播完前不被下一颗呼喊顶断;归因在播期间呼喊保留生存优先级——
* 顶断 → 呼喊播完接力,警告低延迟路径不变);里程碑/护盾不可用应答在
* 警告链期间排队不顶链;被菜单/分批引入顶断的警告恢复对局即补播;
* **坠毁即掐断警告链**,结算链 1.2s 后干净开场不叠播(live B4)。
* - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。
* - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 →
* 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时**
* 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。
* round2 R2 误暂停修复(live ta=664.99/1670.73:高速段撞击瞬间的下滑躲避
* 被当成开菜单):**最近一次判定(撞/躲/格挡/吸收)后 800ms 冷静期内的下滑
* 一律按躲避意图忽略,绝不开菜单**(其余时刻保留旧折中,暂停可达性不变);「继续飞行」恢复缓冲——前 3 颗裸飞陨石回
* 4.0s 慢节奏(复用断崖过渡机制,不再 3 秒暴毙);「回大厅」改 rhythm 同款收尾:
* leaving 态停刷怪/BGM、播确认句后再跳转(旧版直接跳,跳转失败落无声死界面)。
* - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回
* `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。
* - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报
@@ -26,7 +48,8 @@
*
* 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件——
* 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm /
* 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success。
* 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success /
* 护盾不可用 heartbeat(round4 R1)。
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
@@ -34,9 +57,17 @@
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.2/F6 + round3 G7):
* 对局第 1 次受击(保护期外)在归因链播完后补指令式 gd_hit_hint、第 2 次补
* gd_last_life(idle-only:挂在语音空闲点);警报音效 600ms 去重、danger 震
* ≥400ms 去抖;loud 警报档(撞击前 500ms)同窗播 dir_<dir> 超短方向呼喊
* (voice 层直达、全档位,G7);结算链换 _speakGuarded(资产缺失不卡结算 BGM);
* 对局期长按快进豁免(setRateBoostEnabled);状态签名 earcon 与状态 BGM 同点。
* ≥400ms 去抖;dir_<dir> 超短方向呼喊在陨石呼啸响起瞬间同窗直出(round4 R1
* 改口:G7 原挂在 loud 警报档=撞击前 500ms,实测"听语音再滑"永远来不及;
* 提前到 spawn 后语音指引与反应窗口同起点。撞击点落在保护期内的陨石不播——
* 保护期有 protect_<dir> 长句教学,呼喊会把它顶断);结算链换 _speakGuarded
* (资产缺失不卡结算 BGM);对局期长按快进豁免(setRateBoostEnabled);
* 状态签名 earcon 与状态 BGM 同点。
*
* voice 播放安全门(round4 R1 活体复验追加):_playVoice 令牌异步落位,同 tick
* 第二个 play 漏杀前一个 → 双管线并播 + ScriptProcessor 节点永久泄漏(live 探针
* 实录 dir_up/hit_down 交替抽读百余次)。本层 voice 播放全部经 _voicePlay 串行门
* (等上次令牌落位再发下一次)+ 对局语音全量 prewarm 缓存热,引擎零改动。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
@@ -53,17 +84,19 @@ const APPROACH_SFX = {
// ---- 调参 ----
const HULL_MAX = 3; // 撞击上限(保护期后)
const DODGE_WINDOW_MS = 1500; // 撞击前多久内的正确滑动有效
const DODGE_GRACE_MS = 250; // 撞击点后允许的迟到滑动宽限
const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程,见头注)
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
const SHIELD_DURATION_MS = 6000;
const SHIELD_COOLDOWN_MS = 30000;
const SHIELD_COOLDOWN_MS = 15000; // round4 R1:30s→15s(冷却过长 + 失败零反馈是 P1)
const SHIELD_DENY_DEBOUNCE_MS = 4000; // 护盾不可用应答(语音+震动)去抖
const BOOST_DURATION_MS = 1800;
const BOOST_COOLDOWN_MS = 3000;
const RAMP_MS = 120000; // 难度爬升到满所需时长(保护期后起算)
const PROTECTION_MS = 60000; // 保护期
const PROTECT_APPROACH_MS = 4000;
const PROTECT_SPAWN_MS = 4000;
const BARE_GRACE_METEORS = 3; // 保护期结束后前 N 颗裸飞陨石保持保护期节奏(round4 R1 不断崖)
const MENU_CALM_MS = 800; // 下滑开菜单冷静期:最近判定后 N ms 内下滑=躲避意图(round2 R2 误暂停修复)
const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
const BOOST_INTRO_MS = 20000; // 单击引入时刻
const SHIELD_INTRO_MS = 45000; // 双击引入时刻
@@ -100,6 +133,21 @@ export class SpaceGame extends Game {
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
this._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay)
// voice 播放安全门接线(round4 R1 活体实测发现,详见 _voicePlay):
// 本层(含向导层)所有 voice id 播放一律走串行门,bgm/sfx 直通透传。
// 与 playtest/guide.test 的 play 包装兼容(它们先包,本门包在最外层)。
this._origPlay = audio.play.bind(audio);
this.audio.play = (id, opts) => (
typeof id === 'string' && id.startsWith('voice/')
? this._voicePlay(id, opts)
: this._origPlay(id, opts)
);
// 单击/双击引入教学标记:挂实例不挂局(_resetRunState 不重置)——
// 只在首次进入后的首局各教一次,第 2 局起机制开局即用(round4 R1 P3-4)
this.boostIntroDone = false;
this.shieldIntroDone = false;
this._goingHome = false; // "回大厅收尾中"标记(_goHome 幂等 + leaving 态)
this._resetRunState();
}
@@ -118,10 +166,13 @@ export class SpaceGame extends Game {
this.shieldReadyAt = 0;
this.boostUntil = 0;
this.boostReadyAt = 0;
this.boostIntroDone = false;
this.shieldIntroDone = false;
this.featureKind = null;
this._menuIdx = 0;
this._bareCount = 0; // 保护期结束后已刷裸飞陨石数(断崖过渡用)
this._shieldDenyAt = -Infinity; // 护盾不可用应答去抖时间戳
this._hitLastPending = false; // hits===2 时 hit_last 待接播标记(呼喊接力用)
this._hitLastPlaying = false; // hit_last 在播标记(警告链呼喊抑制用,round2 R2)
this._lastResolveAt = -Infinity; // 最近一次陨石判定时刻(下滑开菜单冷静期,round2 R2)
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
this._alarmSoftAt = -Infinity;
this._alarmLoudAt = -Infinity;
@@ -135,11 +186,23 @@ export class SpaceGame extends Game {
'sfx/whoosh_left', 'sfx/whoosh_right', 'sfx/whoosh_up', 'sfx/whoosh_down',
'sfx/alarm', 'sfx/boost', 'sfx/shield', 'sfx/shield_block',
'sfx/dodge', 'sfx/hit', 'sfx/crash',
// 对局期可能并发的语音全量预热(voice 播放安全门配套:缓存热 = 令牌落位
// 只需数个 microtask,门内零等待):方向呼喊/归因/保护/护盾/引入/里程碑/向导
'voice/dir_left', 'voice/dir_right', 'voice/dir_up', 'voice/dir_down',
'voice/hit_left', 'voice/hit_right', 'voice/hit_up', 'voice/hit_down', 'voice/hit_last',
'voice/protect_left', 'voice/protect_right', 'voice/protect_up', 'voice/protect_down',
'voice/protect_end', 'voice/shield_ready', 'voice/shield_block',
'voice/feat_boost', 'voice/feat_boost_done', 'voice/feat_shield', 'voice/feat_shield_done',
'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100',
'voice/gd_hit_hint', 'voice/gd_last_life',
'voice/paused', 'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby',
'voice/menu_help', 'voice/help_recap', 'voice/settings_hint',
'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask',
'voice/skip_hint',
]);
this._session++;
this._clearTimers();
this._goingHome = false; // 页面重进时复位"回大厅收尾中"标记(leaving 态才可再入)
const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1';
if (drilled) {
// 二周目:上滑重听演练 / 单击直接出发
@@ -178,6 +241,10 @@ export class SpaceGame extends Game {
case 'menu':
this._onMenuGesture(g);
break;
case 'leaving':
// 回大厅收尾中:仅受理"单击跳确认句"(链自然推进跳转,看门狗兜底)
if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
break;
case 'over':
// 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次
if (g === 'tap') {
@@ -221,10 +288,13 @@ export class SpaceGame extends Game {
_spawnDrillOb() {
const d = DRILLS[this.drillIdx];
const now = performance.now();
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS };
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS, spawnAt: now };
this.obstacles.add(ob);
this.drillOb = ob;
// 呼啸 + dir_<dir> 同窗:演练就建立"声音一出立刻滑"的配对(round4 R1),
// 此处 voice 层必空闲(演练陨石只在旁白播完/被跳过后 spawn)
this.audio.play(APPROACH_SFX[d.dir]);
this.audio.play(`voice/dir_${d.dir}`);
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
}
@@ -292,8 +362,9 @@ export class SpaceGame extends Game {
this.audio.play('bgm/engine');
this.guide.state('battle'); // 状态签名:对局(与状态 BGM 同点)
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
// 单击/双击引入教学仅首局(标记在实例上,重开不再调度,第 2 局起机制开局即用)
if (!this.boostIntroDone) this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
if (!this.shieldIntroDone) this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
}
@@ -309,11 +380,15 @@ export class SpaceGame extends Game {
this._trimSwipes(now);
break;
case 'down':
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单。
// round2 R2 误暂停修复(live ta=664.99/1670.73):高速段玩家在撞击瞬间
// 下滑躲避(陨石已判定出集合)被当成开菜单——加冷静期:最近一次判定
// (撞/躲/格挡/吸收)后 MENU_CALM_MS 内的下滑一律按躲避意图忽略,绝不开菜单;
// 其余时刻保留旧折中(无下方陨石悬置即可开菜单),主动暂停可达性不变。
if ([...this.obstacles].some((ob) => ob.dir === 'down')) {
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
} else {
} else if (now - this._lastResolveAt > MENU_CALM_MS) {
this._openMenu();
}
break;
@@ -329,23 +404,38 @@ export class SpaceGame extends Game {
}
break;
case 'doubletap':
// 第 45s 引入后才解锁
// 第 45s 引入后才解锁(仅首局引入,第 2 局起开局即用)
if (this.shieldIntroDone && now >= this.shieldReadyAt) {
this.shieldUpUntil = now + SHIELD_DURATION_MS;
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
this.audio.play('sfx/shield');
this._scheduleShieldReady();
} else {
this._shieldDeny(now); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1)
}
break;
}
}
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。 */
/**
* 护盾不可用(未引入/充能中)的即时应答:现成句 feat_shield_done
* ("会自动充能,好了我会告诉你"语义正合)+ HEARTBEAT 震,4s 去抖防连刷。
* 注:voice_text.json 无"护盾还没好"专用句,复用此句属 round4 R1 裁定。
*/
_shieldDeny(now) {
if (now - this._shieldDenyAt < SHIELD_DENY_DEBOUNCE_MS) return;
this._shieldDenyAt = now;
vibrate('heartbeat');
// 警告链在播时排队到链尾,不顶断"最后一命"(round2 R2)
this.audio.play('voice/feat_shield_done', this._warnActive() ? { queue: true } : undefined);
}
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。语音排队播,不顶 protect_end/归因句。 */
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
this._after(delay, () => {
if (this.phase === 'playing') {
vibrate('pickup');
this.audio.play('voice/shield_ready');
this.audio.play('voice/shield_ready', { queue: true });
}
});
}
@@ -419,6 +509,8 @@ export class SpaceGame extends Game {
if (this.shieldReadyAt > now) {
this._scheduleShieldReady(this.shieldReadyAt - now);
}
// "最后一命"警告链被菜单/分批引入旁白顶断时,恢复对局立即补播(round2 R2)
if (this._hitLastPending) this._drainHitLast();
}
// ---------------- 保护期 ----------------
@@ -442,7 +534,10 @@ export class SpaceGame extends Game {
if (this.phase !== 'playing') return;
this._spawnObstacle();
this._scheduleSpawn(
this._inProtection() ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()),
// 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗保持保护期刷怪节奏(round4 R1)
this._inProtection() || this._bareCount < BARE_GRACE_METEORS
? PROTECT_SPAWN_MS
: spawnIntervalMs(this._difficultyNow()),
);
});
}
@@ -450,11 +545,25 @@ export class SpaceGame extends Game {
_spawnObstacle() {
const dir = DIRS[Math.floor(Math.random() * DIRS.length)];
const now = performance.now();
const impactAt = now + (this._inProtection() ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
const ob = { dir, impactAt };
const inProt = this._inProtection();
// 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗逼近时长固定 4.0s 不收缩(round4 R1)
const grace = !inProt && this._bareCount < BARE_GRACE_METEORS;
const impactAt = now + (inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
const ob = { dir, impactAt, spawnAt: now };
this.obstacles.add(ob);
if (!inProt) this._bareCount++;
this.audio.play(APPROACH_SFX[dir]);
// dir_<dir> 超短方向呼喊与呼啸同窗直出(round4 R1 改口,原 G7 挂在撞击前 500ms
// 的 loud 警报档,"听语音再滑"永远来不及;提前到 spawn 后语音指引 = 反应窗口起点)。
// 撞击点落在保护期内的不播:protect_<dir> 长句教学在播时呼喊会把它顶断。
// onComplete 挂 hit_last 接力:呼喊顶断归因句时,呼喊播完自动接播"最后一命"。
// round2 R2:hit_last 在播期间(_hitLastPlaying)**不呼喊**——警告播完前不再
// 被下一颗的呼喊顶断(live B5:高速段警告刚起播就被顶没);归因在播期间呼喊
// 保留生存优先级(顶断 → 接力,低延迟警告路径不变)。
if (impactAt > this.protectionUntil && !this._hitLastPlaying) {
this.audio.play(`voice/dir_${dir}`, { onComplete: () => this._drainHitLast() });
}
// 警报两档渐强,DANGER 震动同步叠加。
// F6(round2):警报音效同 600ms 窗口去重(双陨石同时逼近只起一波警报,
// 按 soft/loud 各自去重,单颗的两段渐强不受影响);danger 震 ≥400ms 去抖。
@@ -462,15 +571,7 @@ export class SpaceGame extends Game {
if (this.obstacles.has(ob)) this._alarm(0.5);
});
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
if (this.obstacles.has(ob)) {
this._alarm(1);
// G7(round3):loud 档(撞击前 500ms)同窗超短方向呼喊 dir_<dir>——与
// 警报分属 voice/sfx 两通道,天然同时不互顶(引擎 duck 压 BGM 保证可闻),
// 噪音环境下方位不再只装在呼啸单通道(P3 round2 P0)。游戏反馈层全档位
// (gd_economy 先例);直达式播放不排队——呼喊必须赶在撞击前,宁可顶掉
// 里程/充能类短句,也不排到链尾迟到。
this.audio.play(`voice/dir_${ob.dir}`);
}
if (this.obstacles.has(ob)) this._alarm(1);
});
this._after(impactAt - now, () => this._resolve(ob));
}
@@ -493,15 +594,20 @@ export class SpaceGame extends Game {
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
this.obstacles.delete(ob);
const now = performance.now();
this._lastResolveAt = now; // 下滑开菜单冷静期锚点(任何结局都算,round2 R2)
// 1) 加速增益期间:冲过空隙
if (now <= this.boostUntil + DODGE_GRACE_MS) {
this._onDodge();
return;
}
// 2) 同向滑动拨开:撞击前 DODGE_WINDOW 内有过同方向滑动
// 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效——
// 窗口覆盖呼啸全程(round4 R1:旧版只开撞击前 1.5s,呼啸起点 2.8~4s,
// 早滑被静默吞掉);spawn 前的"预言家"滑动不算(spawnAt 下界)。
const hit = this.swipes.find((s) =>
s.dir === ob.dir && now - s.t <= DODGE_WINDOW_MS && s.t - ob.impactAt <= DODGE_GRACE_MS);
s.dir === ob.dir
&& s.t >= (ob.spawnAt || 0) - DODGE_GRACE_MS
&& s.t - ob.impactAt <= DODGE_GRACE_MS);
if (hit) {
this.swipes.splice(this.swipes.indexOf(hit), 1); // 一次滑动只躲一颗
this._onDodge();
@@ -529,30 +635,59 @@ export class SpaceGame extends Game {
if (this.hits >= HULL_MAX) {
this._gameOver();
} else {
const ids = [`voice/hit_${ob.dir}`];
if (this.hits === 2) ids.push('voice/hit_last');
// 向导层(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 });
}
// hit_last 接力机制(round4 R1:旧版 _speak 链被 dir_<dir> 呼喊顶断时
// onComplete 不触发、hit_last 静默丢失——"最后一命"警告从没被听到;queue:true
// 也靠不住——_playVoice 异步设令牌,同步连调时 queue 检查落空会变同播)。
// 正常路径:归因自然播完/被跳过 → onComplete → _drainHitLast 接播;
// 顶断路径:归因被呼喊顶掉 → 呼喊播完的 onComplete(_spawnObstacle 挂的)
// → _drainHitLast 接播。重开/回大厅由 _resetRunState 清挂起。
if (this.hits === 2) this._hitLastPending = true;
this.audio.play(`voice/hit_${ob.dir}`, {
onComplete: () => {
if (this.hits === 1) {
// 第 1 次受击补指令式 gd_hit_hint(hit_<dir> 已归因方位,这句补
// "该怎么拨",onceKey 同局一次)
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
}
this._drainHitLast();
},
});
}
}
/**
* "最后一命"接播:hits===2 时挂起,归因播完或顶断它的呼喊播完两条路径汇到这里。
* 播完补 gd_last_life(F2.2:生存经济透明,与 hit_last「船体只剩最后一次」互补
* "撑住"口径),已震 hit 免重复;被打断跳过可接受(向导层 idle-only 语义不变)。
* round2 R2:接播期间置 _hitLastPlaying(此期间 spawn 呼喊抑制,警告不再被
* 顶断);已离开对局(菜单/坠毁/收尾)不再接播。
*/
_drainHitLast() {
if (!this._hitLastPending) return;
this._hitLastPending = false;
if (this.phase !== 'playing') return;
this._hitLastPlaying = true;
this.audio.play('voice/hit_last', {
onComplete: () => {
this._hitLastPlaying = false;
this.guide.error({ voice: 'gd_last_life', skipHaptic: true });
},
});
}
/** "最后一命"警告链进行中(归因在播待接播 / hit_last 在播)——此时旁白排队不顶链。 */
_warnActive() {
return this._hitLastPending || this._hitLastPlaying;
}
_onDodge() {
this.dodges += 1;
this.audio.play('sfx/dodge');
const ms = MILESTONES[this.dodges];
if (ms) {
vibrate('confirm');
this.audio.play(ms);
// 警告链在播时里程碑排队到链尾,不顶断"最后一命"(round2 R2)
this.audio.play(ms, this._warnActive() ? { queue: true } : undefined);
}
}
@@ -591,13 +726,29 @@ export class SpaceGame extends Game {
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
// round2 R2 恢复缓冲(live B2:恢复后全速弹幕 3 秒暴毙):恢复后前
// BARE_GRACE_METEORS 颗裸飞陨石回 4.0s 慢节奏,复用保护期断崖过渡机制。
this._bareCount = 0;
this._scheduleSpawn(800);
this._rearmRunTimers();
}
/**
* 回大厅(rhythm round1 同款收尾):先进 leaving 收尾态停刷怪/BGM,播回大厅
* 确认句,播完(或 2s 看门狗兜底)再跳转——round2 R2(live B3):旧版无确认
* 直接跳,跳转失败时玩家落进无声死界面,纯听觉玩家被关黑屋。
*/
_goHome() {
this.stop();
window.location.href = '../';
if (this._goingHome) return;
this._goingHome = true;
this.phase = 'leaving'; // 收尾态:不再受理对局/菜单/结算手势(单击仍可跳确认句)
this._clearTimers(); // 停刷怪与进行中陨石调度(保留 voice 通道播确认句)
this.obstacles.clear();
this.audio.stopBgm(300);
this._speakGuarded(['voice/menu_lobby'], () => {
this.stop();
window.location.href = '../';
}, 2000);
}
// ---------------- 坠毁结算 ----------------
@@ -609,6 +760,11 @@ export class SpaceGame extends Game {
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
// round2 R2(live B4):坠毁即掐断"最后一命"警告链(归因/hit_last/gd_last_life
// 可能在播)——旧版 hit_last 与 1.2s 后的结算 time_* 叠播互相掐,谁都没听清。
this._hitLastPending = false;
this._hitLastPlaying = false;
this.audio.stopVoice();
vibrate('error');
this._after(300, () => vibrate('hit'));
this.audio.play('sfx/crash');
@@ -699,6 +855,27 @@ export class SpaceGame extends Game {
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
/**
* voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载
* 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条 SoundTouch 管线
* 并播(台词交叠),先落位的令牌成永久孤儿(ScriptProcessor 节点不断开、
* 探针/监听层被交替抽读污染)。本层所有 voice play 走此门串行:等上一次 play
* 的令牌落位再发下一次,恢复引擎"后播杀先播"语义。
* 等待是纯 microtask 的(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能
* 并发的语音全部 prewarm 缓存热(_load 命中已解码 Promise,落位仅数个
* microtask);资产缺失等异常路径 64 轮后放行,门绝不卡死。
* 会话令牌失效(重开/回大厅)后未发的门内播放直接作废。
*/
_voicePlay(id, opts = {}) {
const s = this._session;
this._voiceGate = this._voiceGate.then(async () => {
if (s !== this._session) return;
this._origPlay(id, opts);
for (let i = 0; i < 64 && !this.audio.isVoicePlaying(); i++) await Promise.resolve();
});
return this._voiceGate;
}
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
_speak(ids, onDone) {
if (!ids.length) { if (onDone) onDone(); return; }
@@ -743,8 +920,8 @@ export class SpaceGame extends Game {
this._timers = [];
}
/** 清掉过期的滑动记录(超过任何可能的判定窗口)。 */
/** 清掉过期的滑动记录(超过任何可能的判定窗口 = 最长逼近时长 + 宽限)。 */
_trimSwipes(now) {
this.swipes = this.swipes.filter((s) => now - s.t <= DODGE_WINDOW_MS + 500);
this.swipes = this.swipes.filter((s) => now - s.t <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500);
}
}