/** * 盲航(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 全部弃用。 * * 向导层(research/03-schemes/schemes.md §2):对局第 1 次受击(保护期外)追加 * 指令式 gd_hit_hint(归因句 hit_ 之外的"该怎么拨");结算链换 * _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; } // ---------------- 生命周期 ---------------- 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 震动同步叠加 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); // 向导层(schemes §2):对局第 1 次受击追加指令式提示(hit_ 已归因方位, // 这句补"该怎么拨";已震 hit 免重复 error 震;onceKey 同局一次) if (this.hits === 1) { this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', 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); } }