Files
SoundGame/web/space/game.js
T

742 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 盲航(space)R4 —— 太空盲飞躲避,纯音频(按 docs/design/04-space-rebuild.md 实现)。
*
* R4 核心变化:
* - **同向闪避**:听到哪边来就向哪边滑("挥手把陨石拨开"),方位声直达同侧手势,
* 消除旧版"定位→取反→出手"的推理链。whoosh_* 四资产复用,只改映射。
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
* - **前 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。
* - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。
* - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 →
* 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时**
* 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。
* - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回
* `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。
* - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报
* 播放中单击 = `skipVoice()` 跳下一句;无语音在播时单击维持原义(加速/重开)。
* 首局 D0 在 drill_welcome 后插播一次 skip_hint。
*
* 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件——
* 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm /
* 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success。
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
*
* 向导层(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';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
const DIRS = ['left', 'right', 'up', 'down'];
const APPROACH_SFX = {
left: 'sfx/whoosh_left',
right: 'sfx/whoosh_right',
up: 'sfx/whoosh_up',
down: 'sfx/whoosh_down',
};
// ---- 调参 ----
const HULL_MAX = 3; // 撞击上限(保护期后)
const DODGE_WINDOW_MS = 1500; // 撞击前多久内的正确滑动有效
const DODGE_GRACE_MS = 250; // 撞击点后允许的迟到滑动宽限
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
const SHIELD_DURATION_MS = 6000;
const SHIELD_COOLDOWN_MS = 30000;
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 DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
const BOOST_INTRO_MS = 20000; // 单击引入时刻
const SHIELD_INTRO_MS = 45000; // 双击引入时刻
const FEATURE_WAIT_MS = 8000; // 分批引入等待玩家操作上限
/** 难度插值:t = 保护期结束后已存活 ms,返回 [0,1] */
function ramp(t) { return Math.min(1, Math.max(0, t) / RAMP_MS); }
/** 刷怪间隔:3.2s -> 1.2s */
function spawnIntervalMs(t) { return 3200 - 2000 * ramp(t); }
/** 逼近时长:2.8s -> 1.8s */
function approachMs(t) { return 2800 - 1000 * ramp(t); }
// ---- 演练步骤(D1-D4;D0 开场两句、D5 收尾句) ----
const DRILLS = [
{ dir: 'left', say: 'voice/drill_left', retry: 'voice/drill_retry_left', good: true },
{ dir: 'right', say: 'voice/drill_right', retry: 'voice/drill_retry_right', good: false },
{ dir: 'up', say: 'voice/drill_up', retry: 'voice/drill_retry_up', good: true },
{ dir: 'down', say: 'voice/drill_down', retry: 'voice/drill_retry_down', good: false },
];
// 里程碑:躲过数 -> 播报 id
const MILESTONES = { 10: 'voice/ms_10', 25: 'voice/ms_25', 50: 'voice/ms_50', 100: 'voice/ms_100' };
const MENU_ITEMS = ['voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help'];
const DRILL_DONE_KEY = 'soundgame.space.drillDone';
const BEST_KEY = 'soundgame.space.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class SpaceGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
this._resetRunState();
}
_resetRunState() {
this.hits = 0;
this.dodges = 0;
this.runStart = 0;
this.protectionUntil = 0;
this._protectAnnounced = false;
this.swipes = [];
this.obstacles = new Set();
this.drillOb = null;
this.drillIdx = -1;
this.drillFails = 0;
this.shieldUpUntil = 0;
this.shieldReadyAt = 0;
this.boostUntil = 0;
this.boostReadyAt = 0;
this.boostIntroDone = false;
this.shieldIntroDone = false;
this.featureKind = null;
this._menuIdx = 0;
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
this._alarmSoftAt = -Infinity;
this._alarmLoudAt = -Infinity;
this._dangerAt = -Infinity;
}
// ---------------- 生命周期 ----------------
start() {
this.audio.prewarm([
'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/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask',
'voice/skip_hint',
]);
this._session++;
this._clearTimers();
const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1';
if (drilled) {
// 二周目:上滑重听演练 / 单击直接出发
this.phase = 'ask';
this.audio.play('voice/second_run_ask');
} else {
this._startDrill(true); // 首局:D0 插播一次 skip_hint
}
}
stop() {
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this.audio.stopAll();
}
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'up') { this.audio.stopVoice(); this._startDrill(); }
else if (g === 'tap') { this.audio.stopVoice(); this._startRun(); }
break;
case 'drill':
this._onDrillGesture(g);
break;
case 'playing':
this._onPlayGesture(g);
break;
case 'feature':
this._onFeatureGesture(g);
break;
case 'menu':
this._onMenuGesture(g);
break;
case 'over':
// 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次
if (g === 'tap') {
if (this._voiceBusy()) this.audio.skipVoice();
else this._startRun();
} else if (g === 'down') this._goHome(); // 下滑回大厅(restart_hint 口径)
break;
}
}
// ---------------- D0-D5 交互式演练 ----------------
_startDrill(withSkipHint = false) {
this._session++;
this._clearTimers();
this.audio.stopVoice();
this._resetRunState();
this.phase = 'drill';
// D0:建立预期(无陨石)。首局在 welcome 后插播一次 skip_hint(12 号文档 §3 预留 id)。
const d0 = withSkipHint
? ['voice/drill_welcome', 'voice/skip_hint', 'voice/drill_rule']
: ['voice/drill_welcome', 'voice/drill_rule'];
this._speakGuarded(d0, () => this._nextDrill());
}
_nextDrill() {
this.drillIdx++;
this.drillFails = 0;
if (this.drillIdx >= DRILLS.length) {
// D5:收尾 → 进保护期
this._speakGuarded(['voice/drill_done'], () => {
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this._startRun();
});
return;
}
// 每步旁白先播完,再 spawn 演练陨石(语音先、音效后,不叠放)
this._speakGuarded([DRILLS[this.drillIdx].say], () => this._spawnDrillOb());
}
_spawnDrillOb() {
const d = DRILLS[this.drillIdx];
const now = performance.now();
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS };
this.obstacles.add(ob);
this.drillOb = ob;
this.audio.play(APPROACH_SFX[d.dir]);
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
}
_onDrillGesture(g) {
if (!this.drillOb) {
// 无演练陨石悬置(旁白/反馈播放中):下滑 = 跳过演练;
// 旁白在播时单击 = 跳过本句(语音链跳下一句),其余忽略
if (g === 'down') this._skipDrill();
else if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
return;
}
const d = DRILLS[this.drillIdx];
if (g === d.dir) {
// 做对:dodge 音(识别震动引擎内置)+ 短肯定(仅 D1/D3,避免啰嗦)
this.obstacles.delete(this.drillOb);
this.drillOb = null;
this.audio.play('sfx/dodge');
if (d.good) this._speakGuarded(['voice/drill_good'], () => this._nextDrill());
else this._nextDrill();
} else {
this._drillFail(); // 做错(含超时外的任何错误手势):当场重教
}
}
/** 演练陨石到点未拨开 = 超时做错。 */
_resolveDrill(ob) {
if (!this.obstacles.has(ob) || this.phase !== 'drill') return;
this._drillFail();
}
_drillFail() {
if (this.drillOb) {
this.obstacles.delete(this.drillOb);
this.drillOb = null;
}
this.drillFails++;
vibrate('error');
if (this.drillFails >= 2) {
// 同一步失败 2 次自动演示通过,不卡流程
this._speakGuarded(['voice/drill_moveon'], () => this._nextDrill());
} else {
this._speakGuarded([DRILLS[this.drillIdx].retry], () => this._spawnDrillOb());
}
}
_skipDrill() {
this._session++;
this._clearTimers();
this.audio.stopVoice();
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this._speakGuarded(['voice/drill_skip'], () => this._startRun());
}
// ---------------- 对局 ----------------
_startRun() {
this._session++;
this._clearTimers();
this.audio.stopVoice(); // 打断结算/菜单语音链,避免残留到下一局
this._resetRunState();
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 对局期豁免长按快进(演奏/闪避冲突)
this.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
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'));
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
}
_inProtection() {
return performance.now() < this.protectionUntil;
}
_onPlayGesture(g) {
const now = performance.now();
switch (g) {
case 'up': case 'left': case 'right':
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
break;
case 'down':
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单
if ([...this.obstacles].some((ob) => ob.dir === 'down')) {
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
} else {
this._openMenu();
}
break;
case 'tap':
// 归因/里程碑等播报在播时单击 = 跳过本句(12 号文档裁定:space 对局语音可跳)
if (this._voiceBusy()) { this.audio.skipVoice(); break; }
// 加速是持续型增益(~1.8s),期间的撞击一律算冲过空隙,
// 因此 tap 的 ~300ms 判定延迟不影响手感。第 20s 引入后才解锁。
if (this.boostIntroDone && now >= this.boostReadyAt) {
this.boostUntil = now + BOOST_DURATION_MS;
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
this.audio.play('sfx/boost');
}
break;
case 'doubletap':
// 第 45s 引入后才解锁
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();
}
break;
}
}
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。 */
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
this._after(delay, () => {
if (this.phase === 'playing') {
vibrate('pickup');
this.audio.play('voice/shield_ready');
}
});
}
// ---------------- 单击/双击分批引入(F 组) ----------------
/** 暂停刷怪,播引入旁白,等玩家操作(8s 上限,不等待直接继续)。 */
_beginFeature(kind) {
if (this.phase !== 'playing') return;
if (kind === 'boost' && this.boostIntroDone) return;
if (kind === 'shield' && this.shieldIntroDone) return;
if (kind === 'boost') this.boostIntroDone = true;
else this.shieldIntroDone = true;
this.phase = 'feature';
this.featureKind = kind;
this._clearTimers(); // 暂停刷怪与进行中陨石
this.obstacles.clear();
this.swipes = [];
this._speak([kind === 'boost' ? 'voice/feat_boost' : 'voice/feat_shield']);
this._after(FEATURE_WAIT_MS, () => this._endFeature());
}
_onFeatureGesture(g) {
// 引入旁白在播时单击 = 跳过本句(12 号文档:feat_* 引入句可跳);
// 单击加速教学需旁白播完(或被跳过)后再单击一次触发
if (g === 'tap' && this._voiceBusy()) { this.audio.skipVoice(); return; }
const want = this.featureKind === 'boost' ? 'tap' : 'doubletap';
if (g !== want) return; // 其他手势忽略,等超时自动继续
const now = performance.now();
if (this.featureKind === 'boost') {
this.boostUntil = now + BOOST_DURATION_MS;
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
this.audio.play('sfx/boost');
this._speak(['voice/feat_boost_done']);
this._endFeature();
} else {
this.shieldUpUntil = now + SHIELD_DURATION_MS;
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
this.audio.play('sfx/shield');
this._speak(['voice/feat_shield_done']);
this._endFeature(); // _endFeature → _rearmRunTimers 会按 shieldReadyAt 重排充能播报
}
}
_endFeature() {
if (this.phase !== 'feature') return;
this.featureKind = null;
this._clearTimers(); // 清掉 8s 等待
this.phase = 'playing';
this._scheduleSpawn(2500); // 恢复刷怪稍缓,旁白还在播
this._rearmRunTimers();
}
/**
* 恢复被菜单/分批引入清掉的局内一次性调度:单击/双击引入、保护期结束宣告、
* 护盾充能播报。phase 必须为 'playing'。
*/
_rearmRunTimers() {
const now = performance.now();
if (!this.boostIntroDone) {
this._after(Math.max(0, this.runStart + BOOST_INTRO_MS - now), () => this._beginFeature('boost'));
}
if (!this.shieldIntroDone) {
this._after(Math.max(0, this.runStart + SHIELD_INTRO_MS - now), () => this._beginFeature('shield'));
}
if (!this._protectAnnounced) {
const remain = this.protectionUntil - now;
if (remain <= 0) this._announceProtectionEnd(); // 已过期,立即补播
else this._after(remain, () => this._announceProtectionEnd());
}
if (this.shieldReadyAt > now) {
this._scheduleShieldReady(this.shieldReadyAt - now);
}
}
// ---------------- 保护期 ----------------
_announceProtectionEnd() {
if (this._protectAnnounced) return;
if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时由 _rearmRunTimers 补播
this._protectAnnounced = true;
vibrate('warning');
this.audio.play('voice/protect_end');
}
// ---------------- 陨石调度 ----------------
_difficultyNow() {
return performance.now() - this.protectionUntil; // 保护期后为正
}
_scheduleSpawn(delay) {
this._after(delay, () => {
if (this.phase !== 'playing') return;
this._spawnObstacle();
this._scheduleSpawn(
this._inProtection() ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()),
);
});
}
_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 };
this.obstacles.add(ob);
this.audio.play(APPROACH_SFX[dir]);
// 警报两档渐强,DANGER 震动同步叠加。
// F6(round2):警报音效同 600ms 窗口去重(双陨石同时逼近只起一波警报,
// 按 soft/loud 各自去重,单颗的两段渐强不受影响);danger 震 ≥400ms 去抖。
this._after(impactAt - ALARM_LEAD_MS - now, () => {
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);
});
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;
this.obstacles.delete(ob);
const now = performance.now();
// 1) 加速增益期间:冲过空隙
if (now <= this.boostUntil + DODGE_GRACE_MS) {
this._onDodge();
return;
}
// 2) 同向滑动拨开:撞击前 DODGE_WINDOW 内有过同方向滑动
const hit = this.swipes.find((s) =>
s.dir === ob.dir && now - s.t <= DODGE_WINDOW_MS && s.t - ob.impactAt <= DODGE_GRACE_MS);
if (hit) {
this.swipes.splice(this.swipes.indexOf(hit), 1); // 一次滑动只躲一颗
this._onDodge();
return;
}
// 3) 护盾挡下
if (now <= this.shieldUpUntil) {
this.shieldUpUntil = 0;
this.audio.play('sfx/shield_block');
vibrate('success'); // 护盾格挡成功
this.audio.play('voice/shield_block', { queue: true });
return;
}
// 4) 保护期:训练护盾吸收,不掉血,语音解释方位(死亡教学前置)
if (this._inProtection()) {
this.audio.play('sfx/shield_block');
vibrate('warning');
this.audio.play(`voice/protect_${ob.dir}`);
return;
}
// 5) 实打实撞上:HIT 震动 + 归因语音(第 2 次加"只剩最后一次")
this.hits += 1;
this.audio.play('sfx/hit');
vibrate('hit');
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 });
}
});
}
}
_onDodge() {
this.dodges += 1;
this.audio.play('sfx/dodge');
const ms = MILESTONES[this.dodges];
if (ms) {
vibrate('confirm');
this.audio.play(ms);
}
}
// ---------------- 菜单(全球约定:下滑呼出 → 单击循环 → 双击确认 → 上滑帮助) ----------------
_openMenu() {
this.phase = 'menu';
this.audio.setRateBoostEnabled(true); // 暂停期恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._clearTimers(); // 模态暂停:刷怪与进行中陨石全部冻结清除
this.obstacles.clear();
this.swipes = [];
this._menuIdx = 0;
this.audio.play('bgm/menu', { fadeMs: 300 }); // 状态 BGM:进菜单快切(10 号文档 §6)
this._speak(['voice/paused', 'voice/menu_main', MENU_ITEMS[0]]);
}
_onMenuGesture(g) {
if (g === 'tap') {
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
this.audio.play(MENU_ITEMS[this._menuIdx]);
} else if (g === 'up') {
// 帮助:操作要点 + 设置引导
this._speak(['voice/help_recap', 'voice/settings_hint']);
} else if (g === 'down') {
this._closeMenu();
} else if (g === 'doubletap') {
if (this._menuIdx === 0) this._closeMenu();
else if (this._menuIdx === 1) this._goHome();
else this._speak(['voice/help_recap', 'voice/settings_hint']); // menu_help 项
}
}
_closeMenu() {
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
this._scheduleSpawn(800);
this._rearmRunTimers();
}
_goHome() {
this.stop();
window.location.href = '../';
}
// ---------------- 坠毁结算 ----------------
_gameOver() {
this.phase = 'over';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
vibrate('error');
this._after(300, () => vibrate('hit'));
this.audio.play('sfx/crash');
const aliveSec = Math.floor((performance.now() - this.runStart) / 1000);
const isRecord = this._saveScore(aliveSec);
// 给爆炸声留 1.2s 再开口
this._after(1200, () => {
if (isRecord) vibrate('success');
const ids = [this._timeBandVoice(aliveSec)];
if (isRecord) ids.push('voice/new_record');
ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint');
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链。
// 链换 _speakGuarded(清点 §5.4 缺口):time_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => {
if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 });
});
});
}
/** 存活时间 4 段整句:<30 / 30~60 / 60~120 / ≥120。 */
_timeBandVoice(sec) {
if (sec < 30) return 'voice/time_1';
if (sec < 60) return 'voice/time_2';
if (sec < 120) return 'voice/time_3';
return 'voice/time_4';
}
/** 躲避数 4 段整句:<5 / 5~14 / 15~49 / ≥50。 */
_dodgeBandVoice(n) {
if (n < 5) return 'voice/dodge_1';
if (n < 15) return 'voice/dodge_2';
if (n < 50) return 'voice/dodge_3';
return 'voice/dodge_4';
}
// ---------------- 成绩存档 ----------------
_loadBest() {
try {
return JSON.parse(localStorage.getItem(BEST_KEY)) || { bestTimeSec: 0, bestDodges: 0 };
} catch (_) {
return { bestTimeSec: 0, bestDodges: 0 };
}
}
/**
* 结算记分:本地 best 兜底(localStorage),破存活纪录时同步服务器。
* @returns {boolean} 是否破存活时间纪录
*/
_saveScore(aliveSec) {
const best = this._loadBest();
const isRecord = aliveSec > best.bestTimeSec;
best.bestTimeSec = Math.max(best.bestTimeSec, aliveSec);
best.bestDodges = Math.max(best.bestDodges, this.dodges);
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch (_) { /* 隐私模式 */ }
if (!isRecord) return false;
const m = Math.floor(best.bestTimeSec / 60);
const ss = String(best.bestTimeSec % 60).padStart(2, '0');
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
if (!auth || !auth.token) return true;
fetch('/api/progress', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + auth.token,
},
body: JSON.stringify({
game: 'space',
data: {
summary: `最长存活 ${m}:${ss} / 躲避 ${best.bestDodges} 颗`,
bestTimeSec: best.bestTimeSec,
bestDodges: best.bestDodges,
schema: 1,
updatedAt: new Date().toISOString(),
},
}),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
return true;
}
// ---------------- 工具 ----------------
/** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */
_voiceBusy() {
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
_speak(ids, onDone) {
if (!ids.length) { if (onDone) onDone(); return; }
const s = this._session;
const [head, ...rest] = ids;
this.audio.play(head, {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
});
}
/**
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
* 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。
*/
_speakGuarded(ids, onDone, guardMs = 10000) {
const s = this._session;
let fired = false;
const fire = () => {
if (fired || s !== this._session) return;
fired = true;
if (onDone) onDone();
};
this._speak(ids, fire);
this._after(guardMs, fire);
}
_after(ms, fn) {
const s = this._session;
const id = setTimeout(() => {
this._timers = this._timers.filter((t) => t !== id);
if (s === this._session) fn();
}, Math.max(0, ms));
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers = [];
}
/** 清掉过期的滑动记录(超过任何可能的判定窗口)。 */
_trimSwipes(now) {
this.swipes = this.swipes.filter((s) => now - s.t <= DODGE_WINDOW_MS + 500);
}
}