/**
* 盲航(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_
归因句)。60s 播 protect_end
* 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。
* - **单击/双击分批引入**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),第 45s 教
* 双击护盾(6s,30s 充能,shield_ready 语音);8s 内没做不等待直接继续。
* - **死亡即教学**:保护期后被撞播 hit_ 归因句,第 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 全部弃用。
*/
import { Game } from '../engine/game.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.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;
}
// ---------------- 生命周期 ----------------
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.stopAll();
}
// ---------------- 手势 ----------------
onGesture(g) {
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.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
this.audio.play('bgm/engine');
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 震动同步叠加
this._after(impactAt - ALARM_LEAD_MS - now, () => {
if (this.obstacles.has(ob)) {
this.audio.play('sfx/alarm', { volume: 0.5 });
vibrate('danger');
}
});
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
if (this.obstacles.has(ob)) {
this.audio.play('sfx/alarm', { volume: 1 });
vibrate('danger');
}
});
this._after(impactAt - now, () => this._resolve(ob));
}
/** 撞击点判定:加速增益 > 同向滑动 > 护盾 > 保护期训练护盾 > 撞击。 */
_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');
this._speak(ids);
}
}
_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._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.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
this._scheduleSpawn(800);
this._rearmRunTimers();
}
_goHome() {
this.stop();
window.location.href = '../';
}
// ---------------- 坠毁结算 ----------------
_gameOver() {
this.phase = 'over';
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);被跳过的句仍逐句推进链
this._speak(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._voice;
}
/** 语音串行链:自然播完才接下一句;被 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);
}
}