/** * 盲航(space)R6 —— 太空盲飞躲避,纯音频。 * 按 docs/design/26-audio-interaction-playbook.md 深度重构:设计单正文见 * docs/design/04-space-rebuild.md 附录 C,施工记录见 docs/design/26-apply-space.md。 * * 玩法核心(04 号 §1,不动):同向闪避——听到陨石从哪边来,就向哪边滑,把它拨开。 * 判定窗覆盖呼啸全程(spawn ~ 撞击点 + 250ms 宽限);whoosh 四向资产、前 60s * 保护期(撞击不掉血 + protect_ 归因教学)、死亡归因分方位(hit_ + * hit_last)、里程碑 10/25/50/100 整句,全部保留。 * * 交互层(26 号模式): * - 发言权(§4.1):对局 = Floor ACTION 态(引擎声即状态声,不起等待声); * 演练指令/归因/里程碑 = SPEAKING(音乐压低跨链);结算链播完 yieldTurn 交出 * settle 等待点(单击 = 再飞一次)。 * - 手势宪法(§4.3 裁定:四向滑动是玩法 → 帮助一律长按): * 四向 = 闪避(swipeCore,不按阶梯锁);长按 = 「我在哪」一句话(longpressCore, * 全期关闭旁白快进);单击 = 演练/结算里「继续」、对局里 = 加速(阶段 4 解锁后, * 未解锁 = invalid + 首次「现在只要滑。」);双击 = 护盾(仅 ladder.stage()>=3 * 的会话,index.html 按现值分档 attach 双击窗口)。 * - 演练(04 §2):删 D0 零操作欢迎,第一句 = D1 指令(critical,3 秒内动手), * 第一次成功 ≤10s;做错当场重教、2 次后演示通过;等待点 d1~d4 三档提示 + * 迷茫接管(L2 只认该方向 / L3 演示拨开)。 * - 失败表(04 §0.7 / 原则 15):归因真话(没出手说「没拨到」,禁止说「慢了」); * 首局前两次失败锁血吸收;第 3 次失败起自动降刷怪。 * - 语音优先级(§4.6 + R5-2 保留):对局语音走游戏层语音队列(_say/_pumpVoice): * dir 呼喊 = 生存优先可顶断一切;hit_*、hit_last、protect_end 被顶断时登记重排 * 一次(不排长链尾,重播整链先因后果);里程碑/护盾/教学 FIFO;hint/feedback * 类空闲才播、播不出去就丢。protect_* 教学句去重(R5:上一句在播即跳过)。 * - 新手期:无双击、无加速/护盾开场课(04 §3 废止)。加速 = 第一局结束或大厅 * 全部解锁后教一次;护盾 = 阶段 3 以后(累计 3 局或大厅)单独一课。 * - 断点复述(§4.12):里程碑/受击/stop() 自动存档;回来问继续/重开,续玩先播 * 复述句,再以裸飞缓冲 count-in 慢速陨石(round2 R2 机制复用)。 * * 状态 × 手势矩阵(新手期,完整版见 04 号附录 C): * | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) | * |-------------------|-------------------------|---------------------------|-------------|--------------| * | 演练指令(critical)| busy 回执 | busy(说完再出陨石) | busy | busy | * | 演练陨石(轮到你) | invalid+「向声音那边滑」| 同向=拨开;错向=纠正重来 | 帮助(不闭窗)| 当单击 | * | 对局(ACTION) | 未解锁=invalid+首次解释;已解锁=加速/冷却拒绝 | 同向闪避 / 挥空 whiff+invalid | 帮助 | 当单击 | * | 归因在播(SPEAKING)| 跳过这句 | 打断并闪避 | 打断,帮助 | 当单击 | * | 结算(轮到你) | 再飞一次 | invalid+解释 | 重听结算 | 当单击 | * | 菜单 | 确认当前项 | 左右滑换项;上下滑 invalid| 重听菜单 | 当单击 | */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.js'; import { vibrate } from '../engine/haptics.js'; /** * 带台账的等待点:L1/L2 接管句播出前先登记(30s 内不原样重播,原则 9)。 * 引擎 WaitPoint.takeover 自己不写台账(hints.js 不动),游戏层子类补上。 */ class MarkingWaitPoint extends WaitPoint { takeover(level) { const line = this.conf.restrictLine || this.conf.short || this.conf.full; if (level === 1 && this.conf.short) markHintSaid(this.conf.short); if (level === 2 && line) markHintSaid(line); super.takeover(level); } } // ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ---- const DIRS = ['left', 'right', 'up', 'down']; const SWIPES = ['left', 'right', 'up', 'down']; const APPROACH_SFX = { left: 'sfx/whoosh_left', right: 'sfx/whoosh_right', up: 'sfx/whoosh_up', down: 'sfx/whoosh_down', }; // ---- 调参(04 号 §1/§3:判定常量与难度曲线不动) ---- const HULL_MAX = 3; // 撞击上限(保护期后) const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程) const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s) const SHIELD_DURATION_MS = 6000; const SHIELD_COOLDOWN_MS = 15000; const DENY_DEBOUNCE_MS = 4000; // 护盾/加速不可用应答(语音+震动)共用去抖(R5) 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 颗保持慢节奏(不断崖 + count-in) const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害) const PROTECT_SOON_MS = 50000; // 保护期还剩 10s 预告 const NOVICE_ABSORB = 2; // 原则 15:首局前两次失败锁血吸收 const EASE_FACTOR = 1.35; // 第 3 次失败起自动降难度(刷怪间隔 ×,逼近 +400ms) const EASE_APPROACH_BONUS_MS = 400; const TEACH_BOOST_AT_MS = 6000; // 加速解锁教学句时刻(对局开始后;不暂停刷怪) const TEACH_SHIELD_AT_MS = 10000; // 护盾解锁教学句时刻(单独一课,不与加速同句) /** 难度插值:t = 保护期结束后已存活 ms,返回 [0,1] */ function ramp(t) { return Math.min(1, Math.max(0, t) / RAMP_MS); } /** 刷怪间隔:3.2s -> 1.2s(失败降难度另乘 EASE_FACTOR) */ function spawnIntervalMs(t) { return 3200 - 2000 * ramp(t); } /** 逼近时长:2.8s -> 1.8s(失败降难度另加宽限) */ function approachMs(t) { return 2800 - 1000 * ramp(t); } // ---- 演练步骤(D1-D4 一句一练;D5 收尾句;04 §2 表) ---- const DRILLS = [ { dir: 'left', say: 'voice/drill_left', retry: 'voice/drill_retry_left' }, { dir: 'right', say: 'voice/drill_right', retry: 'voice/drill_retry_right' }, { dir: 'up', say: 'voice/drill_up', retry: 'voice/drill_retry_up' }, { dir: 'down', say: 'voice/drill_down', retry: 'voice/drill_retry_down' }, ]; // 里程碑:躲过数 -> 播报 id(超出 100 不播) 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 LP_TAUGHT_KEY = 'soundgame.space.lpTaught'; const TURN_TAUGHT_KEY = 'soundgame.space.turnTaught'; const ADV_KEY = 'soundgame.space.adv'; // 高级手势教学标记 { boost, shield } const RUN_KEY = 'soundgame.space.run'; // 断点存档(§4.12) const BEST_KEY = 'soundgame.space.best'; // { bestTimeSec, bestDodges, runs } const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html) export class SpaceGame extends Game { constructor(audio, root) { super(audio, root); // ---- 26 号引擎六模块接线(§4.1/§4.2/§4.3/§4.5/原则 7) ---- this.receipts = new Receipts(audio); this.floor = new Floor(audio); this.confusion = new Confusion({ onConfused: (lvl) => this._takeover(lvl) }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new GestureLadder(); this.audio.setRateBoostEnabled(false); // 长按=帮助(核心手势),全期不快进(12 号豁免) this.phase = 'idle'; // idle | reopen | ask | drill | playing | over | menu | leaving this._session = 0; // 会话令牌:重开/回大厅时使旧计时器与语音链失效 this._timers = []; this._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay,round4 R1) 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) ); // 双击窗口分档快照(与 index.html 的条件 attach 同一时点读取): // ladder.stage()>=3 = 双击已识别(累计 3 局或大厅全部解锁),本会话双击 = 护盾。 this._dtRecognized = this.ladder.stage() >= 3; this._advTaught = this._loadAdvTaught(); this._runExplained = new Set(); // 对局「状态×手势」首次解释(原则 7) this._vq = []; // 对局语音队列(§4.6,见 _say/_pumpVoice) this._vqActive = null; this._goingHome = false; // 「回大厅收尾中」标记(_goHome 幂等 + leaving 态) this._resetRunState(); } /** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势。 */ get PIPELINE_OPTS() { return { swipeCore: true, longpressCore: true }; } /** 双击窗口分档(index.html 读):true = 双击已识别(默认窗口 + doubletapCore)。 */ get DT_CORE() { return this._dtRecognized; } _resetRunState() { this.hits = 0; this.dodges = 0; this.runStart = 0; this.protectionUntil = 0; this._protectAnnounced = false; this._protectSoonAnnounced = 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._menuIdx = 0; this._bareCount = 0; // 慢节奏裸飞陨石计数(断崖过渡/恢复缓冲/count-in) this._denyAt = -Infinity; // 护盾/加速不可用应答共用去抖(R5) this._restrictRunDir = null; // 迷茫 L2:对局只放行的方向(04 §0.5) this._noviceAbsorb = 0; // 首局前两次失败锁血(原则 15) this._failCount = 0; // 本局失败总数(含锁血吸收) this._eased = false; // 第 3 次失败起自动降难度 this._goalFired = false; // 第一个小目标(第一次成功闪避)已上报阶梯 this._settleIds = null; // 结算成绩链(长按/双击重听用) // 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', 'sfx/whiff', // 对局期可能并发的语音全量预热(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/protect_soon', 'voice/shield_ready', 'voice/shield_block', 'voice/feat_boost', 'voice/feat_shield', 'voice/feat_shield_done', 'voice/boost_not_ready', 'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100', 'voice/lp_run', 'voice/hint_swipe', 'voice/miss', 'voice/d_short_left', 'voice/d_short_right', 'voice/d_short_up', 'voice/d_short_down', 'voice/drill_left', 'voice/drill_right', 'voice/drill_up', 'voice/drill_down', 'voice/drill_retry_left', 'voice/drill_retry_right', 'voice/drill_retry_up', 'voice/drill_retry_down', 'voice/drill_good', 'voice/drill_moveon', 'voice/drill_done', 'voice/second_run_ask', 'voice/resume_ask', 'voice/resume_recap', 'voice/time_1', 'voice/time_2', 'voice/time_3', 'voice/time_4', 'voice/dodge_1', 'voice/dodge_2', 'voice/dodge_3', 'voice/dodge_4', 'voice/new_record', 'voice/restart_hint', 'voice/hint_tap', 'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help', 'voice/help_recap', 'voice/menu_lobby', 'voice/sys_say_again', 'voice/sys_turn_teach', 'voice/sys_unlock_down', 'voice/sys_demo', 'voice/sys_now_you', 'voice/lp_teach', 'voice/inv_tap', 'voice/inv_whiff', ]); this._session++; this._clearTimers(); this._goingHome = false; this._vq = []; this._vqActive = null; const drilled = this._flag(DRILL_DONE_KEY); const save = this._loadRun(); if (save && save.inProgress) { this._startReopen(save); // 断点复述(§4.12):回来问继续/重开 } else if (drilled) { this._startAsk(); // 回访者:点一下出发(04 §2) } else { this._startDrill(); // 首局:第一句就让玩家拨一颗(§4.10) } } stop() { if (this.phase === 'playing') this._saveRun(); // 任意时刻退出自动存档(原则 16) this._session++; this._clearTimers(); this.floor.interrupt(); this._vq = []; this._vqActive = null; this.audio.setRateBoostEnabled(true); // 离开游戏恢复默认 this.audio.stopAll(); } // ---------------- 手势(pipeline 之后) ---------------- onGesture(g) { switch (this.phase) { case 'reopen': return this._onReopenGesture(g); case 'ask': return this._onAskGesture(g); case 'drill': return this._onDrillGesture(g); case 'playing': return this._onPlayGesture(g); case 'over': return this._onOverGesture(g); case 'menu': return this._onMenuGesture(g); case 'leaving': // 回大厅收尾中:单击跳过确认句(链自然推进跳转,看门狗兜底) if (g === 'tap' && this.audio.isVoicePlaying()) this.audio.skipVoice(); return 'ok'; default: return 'invalid'; } } stateName() { return this.phase === 'idle' ? 'idle' : this.phase; } /** * 无效操作首次解释(原则 7)。对局(playing)返回 null:对局没有等待点, * 解释句由游戏层经语音队列自己播(播完回 ACTION),不走 InvalidHints 的 * resumeTurn 路径——否则解释句播完 Floor 落在 BUSY,下一次手势全变 busy。 */ invalidHint(state, g) { if (state === 'playing') return null; if (state === 'drill') return (g === 'tap' || g === 'doubletap') ? 'voice/hint_swipe' : null; if (state === 'over') return 'voice/restart_hint'; if (state === 'menu') return 'voice/menu_main'; if (state === 'ask') return 'voice/second_run_ask'; if (state === 'reopen') return 'voice/resume_ask'; return null; } // ---------------- 回访者 / 断点复述(§4.12) ---------------- _startAsk() { this._session++; this._clearTimers(); this.floor.interrupt(); this._resetRunState(); this.phase = 'ask'; markHintSaid('voice/second_run_ask'); // 30s 内等待点被动复述降档(原则 9) this.floor.speak(['voice/second_run_ask'], { onDone: () => this.floor.yieldTurn(this._wp({ id: 'ask', expected: ['tap', 'up'], full: 'voice/second_run_ask', short: 'voice/hint_tap', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), })), }); } _onAskGesture(g) { switch (g) { case 'tap': this._startRun(); return 'ok'; case 'up': // 重听演练(04 §2 回访者可重听;up 在本作是玩法方向,不与帮助冲突) this._startDrill(); return 'ok'; case 'longpress': this.floor.speak(['voice/second_run_ask'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; default: return 'invalid'; } } _startReopen(save) { this._session++; this._clearTimers(); this.floor.interrupt(); this._resetRunState(); this.phase = 'reopen'; this._pendingSave = save; markHintSaid('voice/resume_ask'); this.floor.speak(['voice/resume_ask'], { onDone: () => this.floor.yieldTurn(this._wp({ id: 'reopen', expected: ['tap', 'right'], full: 'voice/resume_ask', short: 'voice/hint_tap', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), })), }); } _onReopenGesture(g) { switch (g) { case 'tap': // 继续上次航程(先复述,再 count-in 慢速陨石) this._continueRun(this._pendingSave); return 'ok'; case 'right': // 重新开始 this._clearRunSave(); this._startRun(); return 'ok'; case 'longpress': this.floor.speak(['voice/resume_ask'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; default: return 'invalid'; } } // ---------------- D1-D4 交互式演练(04 §2;首 3 分钟 §4.10) ---------------- _startDrill() { this._session++; this._clearTimers(); this.floor.interrupt(); this._resetRunState(); this.phase = 'drill'; this.drillIdx = -1; this._nextDrill(); } _nextDrill() { this.drillIdx++; this.drillFails = 0; if (this.drillIdx >= DRILLS.length) { // D5:收尾 → 进保护期(critical:流程驱动句,被打断会卡死流程) markHintSaid('voice/drill_done'); this.floor.speak(['voice/drill_done'], { critical: true, onDone: () => { try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ } this._startRun(); }, }); return; } // 每步指令先播完(critical:唯一一次规则说明,≤3s),再 spawn 演练陨石(不与 whoosh 叠) markHintSaid(DRILLS[this.drillIdx].say); this.floor.speak([DRILLS[this.drillIdx].say], { critical: true, onDone: () => this._spawnDrillOb(), }); } _spawnDrillOb() { const d = DRILLS[this.drillIdx]; const now = performance.now(); const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS, spawnAt: now }; this.obstacles.add(ob); this.drillOb = ob; this.audio.play(APPROACH_SFX[d.dir]); // 演练陨石 = 等待点(04 §0.5 d1~d4):三档提示 + 迷茫接管(L2 只认该方向 / L3 演示) this.floor.yieldTurn(this._wp({ id: `d${this.drillIdx + 1}_${d.dir}`, expected: [d.dir], full: DRILLS[this.drillIdx].say, short: `voice/d_short_${d.dir}`, silenceMs: [DRILL_APPROACH_MS + 1500, 15000], onDemo: () => this._drillDemo(), })); this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob)); } _onDrillGesture(g) { const wp = this.floor.waitPoint; if (g === 'longpress') { // 帮助(不闭窗,矩阵行 2):一句话,播完回等待态 this.floor.speak(['voice/lp_run'], { kind: 'hint', onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; } if (!this.drillOb) return 'invalid'; // 指令/纠错链均为 critical:正常接线走不到这 if (wp && wp.restricted && !wp.allows(g)) return 'invalid'; // 直连(harness)时同样尊重 L2 const d = DRILLS[this.drillIdx]; if (g === d.dir) { this.obstacles.delete(this.drillOb); this.drillOb = null; this.audio.play('sfx/dodge'); this._afterDrillSuccess(); return 'silent-ok'; // dodge 音即反馈 } if (SWIPES.includes(g)) { this._drillFail(true); // 错向:当场重教(04 §0.7) return 'invalid'; } return 'invalid'; // tap/doubletap:invalid 回执 + 首次「向声音那边滑。」(invalidHint) } /** 演练陨石到点未拨开 = 没出手(超时)。 */ _resolveDrill(ob) { if (!this.obstacles.has(ob) || this.phase !== 'drill') return; this._drillFail(false); } _drillFail(wrongDir) { if (this.drillOb) { this.obstacles.delete(this.drillOb); this.drillOb = null; } this.drillFails++; vibrate('error'); this.confusion.recordFail(); const retry = DRILLS[this.drillIdx].retry; if (this.drillFails >= 2) { // 同一步失败 2 次:演示拨开 + 带过(04 §0.6「2 次后演示通过」)。 // 带过句 30s 台账去重(原则 9):连着几步都带过时不原样重播,只演示。 this.audio.play('sfx/dodge'); if (hintSaidRecently('voice/drill_moveon')) { this._nextDrill(); return; } markHintSaid('voice/drill_moveon'); this.floor.speak(['voice/drill_moveon'], { critical: true, onDone: () => this._nextDrill() }); return; } // 纠错句 30s 内不原样重播(原则 9):刚听过就只给回执,同一颗直接重来。 // 没出手先说真话「没拨到」(禁止说慢了,04 §0.7)。 if (hintSaidRecently(retry)) { this._spawnDrillOb(); return; } markHintSaid(retry); const chain = wrongDir ? [retry] : (hintSaidRecently('voice/miss') ? [retry] : ['voice/miss', retry]); this.floor.speak(chain, { critical: true, onDone: () => this._spawnDrillOb() }); } /** 迷茫 L3 / 演示通过:游戏自己拨开这颗,再交回(04 §0.5「游戏演示拨开」)。 */ _drillDemo() { if (this.drillOb) { this.obstacles.delete(this.drillOb); this.drillOb = null; } this.audio.play('sfx/dodge'); this.floor.speak(['voice/sys_demo'], { critical: true, onDone: () => this._nextDrill() }); } /** 演练做对:肯定(仅 D1)+ 一次性的两个教学插入(原则 11:做对过的不教第二遍)。 */ _afterDrillSuccess() { const ids = []; if (this.drillIdx === 0) { ids.push('voice/drill_good'); // 「漂亮。」(≤4 字肯定) this.ladder.onGoal(); // 第一次成功闪避(D1)= 第一个小目标 → 阶梯阶段 1 if (!this._flag(LP_TAUGHT_KEY)) { ids.push('voice/lp_teach'); // 马上教长按帮助(04 §0.3 阶段 1) try { localStorage.setItem(LP_TAUGHT_KEY, '1'); } catch (_) { /* 隐私模式 */ } } } else if (this.drillIdx === 1 && !this._flag(TURN_TAUGHT_KEY)) { ids.push('voice/sys_turn_teach'); // 10~30s 窗口:教会「轮到你」这个状态(§4.10 模板) try { localStorage.setItem(TURN_TAUGHT_KEY, '1'); } catch (_) { /* 隐私模式 */ } } if (!ids.length) { this._nextDrill(); return; } this.floor.speak(ids, { critical: true, onDone: () => this._nextDrill() }); } // ---------------- 对局 ---------------- _startRun() { this._session++; this._clearTimers(); this.floor.interrupt(); this._resetRunState(); this._vq = []; this._vqActive = null; this.phase = 'playing'; this.runStart = performance.now(); this.protectionUntil = this.runStart + PROTECTION_MS; this._noviceAbsorb = this._runs() === 0 ? NOVICE_ABSORB : 0; // 原则 15:首局锁血 this.floor.enterAction(); // 对局 = ACTION 态(玩法声即状态,不起等待声) this.audio.play('bgm/engine'); this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态 this._after(PROTECT_SOON_MS, () => this._announceProtectionSoon()); this._after(PROTECTION_MS, () => this._announceProtectionEnd()); this._maybeScheduleTeach(); this._saveRun(); } /** 断点续玩(§4.12):恢复计时/成绩,先播复述句,再 count-in 慢速陨石。 */ _continueRun(save) { this._session++; this._clearTimers(); this.floor.interrupt(); this._resetRunState(); this._vq = []; this._vqActive = null; this.phase = 'playing'; const now = performance.now(); const elapsed = Math.max(0, save.elapsedMs || 0); this.runStart = now - elapsed; this.dodges = save.dodges || 0; this.hits = save.hits || 0; this._noviceAbsorb = save.noviceAbsorb || 0; this._failCount = save.failCount || 0; this._eased = !!save.eased; const remain = PROTECTION_MS - elapsed; this.protectionUntil = remain > 0 ? now + remain : now; this._protectAnnounced = remain <= 0; this._protectSoonAnnounced = elapsed >= PROTECT_SOON_MS; this._bareCount = 0; // count-in:前 BARE_GRACE_METEORS 颗慢速(复用恢复缓冲机制) this.floor.enterAction(); this.audio.play('bgm/engine'); // 复述句播完才开始 count-in(04 §0.9:先复述,再恢复刷怪) this._say(['voice/resume_recap'], { kind: 'content', onDone: () => { if (this.phase === 'playing') this._scheduleSpawn(1200); }, }); if (!this._protectAnnounced) this._after(this.protectionUntil - now, () => this._announceProtectionEnd()); if (!this._protectSoonAnnounced) { const soonIn = this.runStart + PROTECT_SOON_MS - now; if (soonIn > 0) this._after(soonIn, () => this._announceProtectionSoon()); } this._maybeScheduleTeach(); } _inProtection() { return performance.now() < this.protectionUntil; } _onPlayGesture(g) { const now = performance.now(); let r; switch (g) { case 'up': case 'down': case 'left': case 'right': { // 迷茫 L2:只放行最近一颗的正确方向(其余 invalid 回执,不产生效果) if (this._restrictRunDir && g !== this._restrictRunDir) { r = 'invalid'; break; } this.swipes.push({ dir: g, t: now }); this._trimSwipes(now); r = this._whiff(g); // 同向 = ok(撞击点 dodge 音确认);挥空 = invalid + whiff 音 break; } case 'longpress': this._longpressHelp(); r = 'ok'; break; case 'tap': r = this._tapAction(now); break; case 'doubletap': r = this._doubletapAction(now); break; default: r = 'invalid'; } // 对局收尾:手势处理完且没起新链 → 回 ACTION;被手势打断的队列包作废(原则 5) if (this.phase === 'playing' && !this.floor.speaking) { if (this._vqActive) this._vqActive = null; this.floor.enterAction(); this._pumpVoice(); } return r; } /** 长按 = 我在哪(原则 3:位置 → 目标 → 一个动作,≤20 字,经语音队列不顶呼喊)。 */ _longpressHelp() { this._say(['voice/lp_run'], { kind: 'hint' }); } /** 挥空(04 §0.7):whiff 音 + invalid 回执 + 首次解释「现在没有这边的陨石。」 */ _whiff(dir) { if ([...this.obstacles].some((ob) => ob.dir === dir)) return 'ok'; this.audio.play('sfx/whiff', { volume: 0.55 }); this._explainOnceRun('whiff', 'voice/inv_whiff'); return 'invalid'; } /** 对局单击:未解锁加速 = invalid + 首次「现在只要滑。」;已解锁 = 加速 / 冷却拒绝。 */ _tapAction(now) { if (!this._boostOn()) { this._explainOnceRun('tap', 'voice/inv_tap'); return 'invalid'; } if (now >= this.boostReadyAt) { this.boostUntil = now + BOOST_DURATION_MS; this.boostReadyAt = now + BOOST_COOLDOWN_MS; this.audio.play('sfx/boost'); vibrate('confirm'); // 激活确认(04 §5) return 'silent-ok'; // boost 音即反馈 } this._deny(now, 'voice/boost_not_ready'); // R5-4:冷却拒绝必须出声 return 'invalid'; } /** 对局双击 = 护盾(仅双击已识别的会话);未解锁折算成单击语义。 */ _doubletapAction(now) { if (!this._shieldOn()) return this._tapAction(now); if (now >= this.shieldReadyAt) { this.shieldUpUntil = now + SHIELD_DURATION_MS; this.shieldReadyAt = now + SHIELD_COOLDOWN_MS; this.audio.play('sfx/shield'); vibrate('confirm'); // 激活确认(04 §5) this._scheduleShieldReady(); return 'silent-ok'; // shield 音即反馈 } this._deny(now, 'voice/feat_shield_done'); // 不可用必须有回音(round4 R1) return 'invalid'; } /** 加速解锁:第一局结束或大厅全部解锁(04 §4)。 */ _boostOn() { return this._runs() >= 1 || this.ladder.stage() >= 3; } /** 护盾解锁:双击已识别(累计 3 局或大厅全部解锁,04 §0.3/§4)。 */ _shieldOn() { return this._dtRecognized; } /** * 高级手势教学(04 §4):解锁后教一次(跨局持久),一句 + 马上能用——不暂停 * 刷怪、不插保护期课(§3 废止),经语音队列在空闲点播出。 */ _maybeScheduleTeach() { if (!this._advTaught.boost && this._boostOn()) { this._after(TEACH_BOOST_AT_MS, () => { if (this.phase !== 'playing' || this._advTaught.boost) return; this._advTaught.boost = true; this._saveAdvTaught(); this._say(['voice/feat_boost'], { kind: 'content' }); }); } if (!this._advTaught.shield && this._shieldOn()) { this._after(TEACH_SHIELD_AT_MS, () => { if (this.phase !== 'playing' || this._advTaught.shield) return; this._advTaught.shield = true; this._saveAdvTaught(); this._say(['voice/feat_shield'], { kind: 'content' }); }); } } /** * 无效手势轻反馈(归因真话,R5 保留)。对局层「状态×手势」首次解释(原则 7): * 不走 InvalidHints(无等待点可 resume,见 invalidHint 注释),经语音队列播完回 ACTION。 */ _explainOnceRun(key, voiceId) { if (this._runExplained.has(key)) return; this._runExplained.add(key); if (hintSaidRecently(voiceId)) return; // 同句音频 30s 内不重播(原则 9) markHintSaid(voiceId); this._say([voiceId], { kind: 'feedback' }); } /** 护盾/加速不可用的即时应答:HEARTBEAT 震 + 语音,4s 共用去抖(R5)。 */ _deny(now, voiceId) { if (now - this._denyAt < DENY_DEBOUNCE_MS) return; this._denyAt = now; vibrate('heartbeat'); this._say([voiceId], { kind: 'feedback' }); } /** 护盾充能完毕播报 + PICKUP 震动(仅已解锁玩家,04 §4)。 */ _scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) { this._after(delay, () => { if (this.phase === 'playing' && this._shieldOn()) { vibrate('pickup'); this._say(['voice/shield_ready'], { kind: 'content' }); } }); } // ---------------- 对局语音队列(§4.6 优先级 + R5-2 防顶断登记) ---------------- /** * 对局期所有语音经此队列(一条链一条链播,引擎只播一条): * - interrupt(dir 呼喊):生存优先,立刻顶断当前链;被顶断的 defend 包 * (hit_*、hit_last、protect_end)整链重排一次(R5-2:登记残留补播,先因后果); * 其余内容包(lp/里程碑/教学)作废——针对旧局面(原则 5)。 * - defend(归因/保护结束/最后一命):排在呼喊之后、其他内容之前;FIFO 互不插队。 * - 其余(里程碑/护盾/教学/帮助):FIFO 尾;hint/feedback 空闲才播、过期即丢。 * 玩家手势打断链(滑动打断并闪避):_onPlayGesture 收尾把 _vqActive 作废(原则 5)。 */ _say(ids, opts = {}) { const p = { ids, kind: opts.kind || 'content', defend: !!opts.defend, interrupt: !!opts.interrupt, protect: !!opts.protect, onDone: opts.onDone || null, replayed: false, }; if (p.interrupt) { const old = this._vqActive; if (old && old.defend && !old.replayed) this._vq.unshift({ ...old, replayed: true }); this._vqActive = null; // 旧链由下面 pump 的 floor.speak 顶断 this._vq.unshift(p); } else if (p.defend) { let i = 0; // 排在已有 defend 之后(先因后果,FIFO) while (i < this._vq.length && this._vq[i].defend) i++; this._vq.splice(i, 0, p); } else { this._vq.push(p); } this._pumpVoice(); } _pumpVoice() { if (!this._vq.length) return; const p = this._vq[0]; if (this._vqActive && !p.interrupt) return; // 有包在播:只有呼喊能顶断 this._vq.shift(); this._vqActive = p; this.floor.speak(p.ids, { kind: p.kind, onDone: () => { if (this._vqActive === p) this._vqActive = null; if (p.onDone) p.onDone(); if (this.phase === 'playing' && !this.floor.speaking) this.floor.enterAction(); this._pumpVoice(); }, }); } // ---------------- 保护期 ---------------- /** R5:50s 预告(hint 类,空闲才播;04 §0.8)。 */ _announceProtectionSoon() { if (this._protectSoonAnnounced || this.phase !== 'playing') return; this._protectSoonAnnounced = true; this._say(['voice/protect_soon'], { kind: 'hint' }); } /** 保护结束宣告:走防顶断登记(04 §0.8:protect_end 顶断后补播一次)。 */ _announceProtectionEnd() { if (this._protectAnnounced || this.phase !== 'playing') return; this._protectAnnounced = true; vibrate('warning'); this._say(['voice/protect_end'], { kind: 'feedback', defend: true }); this._saveRun(); } /** * 保护期吸收的教学句(04 §3):上一句在播即跳过(R5 去重,每句完整送达); * 同方位 30s 台账去重(原则 9:被动原句重复 = 0)——被跳过的那次仍有 * shield_block 音 + WARNING 震落位。 */ _protectSay(dir) { const id = `voice/protect_${dir}`; const busy = (this._vqActive && this._vqActive.protect) || this._vq.some((p) => p.protect); if (busy || hintSaidRecently(id)) return; markHintSaid(id); this._say([id], { kind: 'feedback', protect: true }); } // ---------------- 陨石调度 ---------------- _difficultyNow() { return performance.now() - this.protectionUntil; // 保护期后为正 } _scheduleSpawn(delay) { this._after(delay, () => { if (this.phase !== 'playing') return; this._spawnObstacle(); const slow = this._inProtection() || this._bareCount < BARE_GRACE_METEORS; const base = slow ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()); this._scheduleSpawn(this._eased ? base * EASE_FACTOR : base); }); } _spawnObstacle() { const dir = DIRS[Math.floor(Math.random() * DIRS.length)]; const now = performance.now(); const inProt = this._inProtection(); // 断崖过渡/count-in:慢节奏裸飞陨石不收缩;失败降难度再加宽(原则 15) const grace = !inProt && this._bareCount < BARE_GRACE_METEORS; let approach = inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()); if (this._eased && !inProt && !grace) approach += EASE_APPROACH_BONUS_MS; const impactAt = now + approach; const ob = { dir, impactAt, spawnAt: now }; this.obstacles.add(ob); if (!inProt) this._bareCount++; this.audio.play(APPROACH_SFX[dir]); // dir_ 超短方向呼喊与呼啸同窗直出(round4 R1:反应窗口起点);撞击点落在 // 保护期内的不播(protect_ 长句教学优先,呼喊会把它顶断)。经语音队列: // 生存优先顶断(defend 包自动登记重排,R5-2)。 if (impactAt > this.protectionUntil) { this._say([`voice/dir_${dir}`], { kind: 'content', interrupt: true }); } // 警报两档渐强,DANGER 震动同步叠加(F6:同档 600ms 去重 + 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); this._restrictRunDir = null; // 迷茫 L2 随最近一颗的结算解除 const now = performance.now(); // 1) 加速增益期间:冲过空隙 if (now <= this.boostUntil + DODGE_GRACE_MS) { this._onDodge(); return; } // 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效(round4 R1) const hit = this.swipes.find((s) => 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(); return; } // 3) 护盾挡下 if (now <= this.shieldUpUntil) { this.shieldUpUntil = 0; this.audio.play('sfx/shield_block'); vibrate('success'); this._say(['voice/shield_block'], { kind: 'feedback' }); return; } // 4) 新手锁血(首局前两次失败不扣资源,原则 15):归因照说真话(hit_),只是不掉血 if (!this._inProtection() && this._noviceAbsorb > 0) { this._noviceAbsorb--; this._failCount++; this._easeMaybe(); this.audio.play('sfx/shield_block'); vibrate('warning'); this.confusion.recordFail(); this._say([`voice/hit_${ob.dir}`], { kind: 'feedback', defend: true }); return; } // 5) 保护期训练护盾:吸收不掉血 + 分方位教学(04 §3) if (this._inProtection()) { this._failCount++; this._easeMaybe(); this.audio.play('sfx/shield_block'); vibrate('warning'); this._protectSay(ob.dir); return; } // 6) 实打实撞上:HIT 震动 + 分方位归因(第 2 次整链接「最后一命」) this.hits += 1; this._failCount++; this._easeMaybe(); this.audio.play('sfx/hit'); vibrate('hit'); this.confusion.recordFail(); if (this.hits >= HULL_MAX) { this._gameOver(); return; } const ids = [`voice/hit_${ob.dir}`]; if (this.hits === 2) ids.push('voice/hit_last'); this._say(ids, { kind: 'feedback', defend: true }); // 被呼喊顶断时整链重排一次(R5-2) this._saveRun(); } /** 第 3 次失败起自动降难度(原则 15 / 04 §0.7「第 3 次可降刷怪」)。 */ _easeMaybe() { if (!this._eased && this._failCount >= 3) this._eased = true; } _onDodge() { this.dodges += 1; this.audio.play('sfx/dodge'); if (!this._goalFired) { this._goalFired = true; this.ladder.onGoal(); // 第一个小目标(第一次成功闪避)→ 阶梯阶段 1 // space:up 是玩法方向、帮助在长按——不播 sys_unlock_up(教长按已在演练做) } this.confusion.resetForWaitPoint(); // 进展事件:迷茫计数清零,下段从 L1 重新分级 const ms = MILESTONES[this.dodges]; if (ms) { vibrate('confirm'); this._say([ms], { kind: 'content' }); this._saveRun(); // 里程碑存档(§4.12) } } // ---------------- 迷茫接管(§4.5 三级:提示 → 缩小 → 演示) ---------------- _takeover(level) { if (this.phase === 'drill') return; // 演练等待点自己接管(wp.takeover) if (this.phase !== 'playing') return; const once = (id, ids) => { // 30s 台账去重(原则 9:说过就只给交接提示音) if (hintSaidRecently(id)) { this.floor.chime(); return; } markHintSaid(id); this._say(ids, { kind: 'hint' }); }; if (level === 1) { // L1:只说下一步的一个动作(04 §0.5 短句) once('voice/hint_swipe', ['voice/hint_swipe']); } else if (level === 2) { // L2:只认当前最近一颗的正确方向 const ob = this._nearestOb(); if (!ob) return; this._restrictRunDir = ob.dir; once(`voice/d_short_${ob.dir}`, [`voice/d_short_${ob.dir}`]); } else if (level === 3) { // L3:自动拨开最近一颗(04 §0.5:保护期或不扣血演示),再把话轮交回 this._restrictRunDir = null; const ob = this._nearestOb(); if (!ob) return; this.obstacles.delete(ob); this.audio.play('sfx/dodge'); once('voice/sys_demo', ['voice/sys_demo', 'voice/sys_now_you']); } } _nearestOb() { let best = null; for (const ob of this.obstacles) { if (!best || ob.impactAt < best.impactAt) best = ob; } return best; } // ---------------- 菜单(00 §5.4:左右滑切换 + 单击确认;阶段 2 后,结算呼出) ---------------- _openMenu() { this.phase = 'menu'; this._clearTimers(); // 模态暂停:停刷怪调度(结算态本就已停) this._vq = []; this._vqActive = null; this._menuIdx = 0; this.floor.interrupt(); this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 300 }); markHintSaid('voice/menu_main'); this.floor.speak(['voice/menu_main', MENU_ITEMS[0]], { onDone: () => this.floor.yieldTurn(this._wp({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/menu_main', short: 'voice/menu_main', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), })), }); } _onMenuGesture(g) { const announce = () => { this.floor.speak([MENU_ITEMS[this._menuIdx]], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); }; switch (g) { case 'tap': // 确认当前项 if (this._menuIdx === 0) { this._startRun(); return 'ok'; } // 继续飞行 if (this._menuIdx === 1) { this._goHome(); return 'ok'; } // 回大厅 this.floor.speak(['voice/help_recap'], { // 教学要点 onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; case 'left': this._menuIdx = (this._menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length; announce(); return 'hold'; case 'right': this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length; announce(); return 'hold'; case 'longpress': case 'doubletap': // 重听菜单说明(双击=重听,阶段 3 语义) this.floor.speak(['voice/menu_main'], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; default: return 'invalid'; } } /** * 回大厅(rhythm 同款收尾):leaving 收尾态停刷怪/BGM,播确认句,播完 *(或 2.5s 看门狗兜底)再跳转——不落无声死界面(round2 R2)。 */ _goHome() { if (this._goingHome) return; this._goingHome = true; this.phase = 'leaving'; this._clearTimers(); this.obstacles.clear(); this._vq = []; this._vqActive = null; this.audio.stopBgm(300); const leave = () => { if (!this._goingHome) return; this._goingHome = false; this.stop(); window.location.href = '../'; }; this.floor.speak(['voice/menu_lobby'], { onDone: leave }); this._after(2500, leave); } // ---------------- 坠毁结算(04 §5:整句 + 单击再飞) ---------------- _gameOver() { this.phase = 'over'; this._clearTimers(); this.obstacles.clear(); this.swipes = []; this._vq = []; this._vqActive = null; this._restrictRunDir = null; this.audio.stopBgm(600); this.floor.interrupt(); // 坠毁即掐断警告链,结算链干净开场不叠播(round2 R2 B4) vibrate('error'); this._after(300, () => vibrate('hit')); this.audio.play('sfx/crash'); this._clearRunSave(); // 本局已结束 const aliveSec = Math.floor((performance.now() - this.runStart) / 1000); const isRecord = this._saveScore(aliveSec); const got = this.ladder.onGameDone(); // 一局结束 → 阶段 2/3(space 只教 down=菜单) // 给爆炸声留 1.2s 再开口 this._after(1200, () => { if (isRecord) vibrate('success'); const head = [this._timeBandVoice(aliveSec)]; if (isRecord) head.push('voice/new_record'); head.push(this._dodgeBandVoice(this.dodges)); this._settleIds = [...head]; markHintSaid('voice/restart_hint'); // 结算等待点 full:20s 被动复述降档(原则 9) const tail = ['voice/restart_hint']; if (got === 'down') tail.push('voice/sys_unlock_down'); // 阶段 2:下滑=菜单 this.floor.speak(head, { onDone: () => this.floor.speak(tail, { critical: true, // 承诺段(「单击,再飞一次。」+解锁教学,各 <3s) onDone: () => { this.audio.play('bgm/menu', { fadeMs: 800 }); this.floor.yieldTurn(this._settleWp()); }, }), }); }); } _settleWp() { return this._wp({ id: 'settle', expected: ['tap'], full: 'voice/restart_hint', short: 'voice/hint_tap', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } _onOverGesture(g) { switch (g) { case 'tap': // 再飞一次 this._startRun(); return 'ok'; case 'down': // 菜单(阶段 2 解锁后;对局中下滑永远是闪避,04 §0.3) if (this.ladder.has('down')) { this._openMenu(); return 'ok'; } return 'invalid'; case 'longpress': case 'doubletap': { // 重听结算(双击=重听,阶段 3 语义) const ids = ['voice/sys_say_again', ...(this._settleIds || ['voice/restart_hint'])]; this.floor.speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; } default: return 'invalid'; } } /** 存活时间 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'; } // ---------------- 存档与成绩 ---------------- _flag(key) { try { return localStorage.getItem(key) === '1'; } catch (_) { return false; } } _loadBest() { try { return JSON.parse(localStorage.getItem(BEST_KEY)) || { bestTimeSec: 0, bestDodges: 0, runs: 0 }; } catch (_) { return { bestTimeSec: 0, bestDodges: 0, runs: 0 }; } } _runs() { return this._loadBest().runs || 0; } /** 断点存档(§4.12:里程碑/受击/退出时写)。 */ _saveRun() { try { localStorage.setItem(RUN_KEY, JSON.stringify({ inProgress: true, elapsedMs: performance.now() - this.runStart, dodges: this.dodges, hits: this.hits, noviceAbsorb: this._noviceAbsorb, failCount: this._failCount, eased: this._eased, })); } catch (_) { /* 隐私模式 */ } } _loadRun() { try { const raw = localStorage.getItem(RUN_KEY); return raw ? JSON.parse(raw) : null; } catch (_) { return null; } } _clearRunSave() { try { localStorage.removeItem(RUN_KEY); } catch (_) { /* 隐私模式 */ } } _loadAdvTaught() { try { return JSON.parse(localStorage.getItem(ADV_KEY)) || {}; } catch (_) { return {}; } } _saveAdvTaught() { try { localStorage.setItem(ADV_KEY, JSON.stringify(this._advTaught)); } catch (_) { /* 隐私模式 */ } } /** * 结算记分:本地 best 兜底(localStorage),破存活纪录时同步服务器 *(04 §0.9 进度:{ bestSec, totalRuns, summary })。 * @returns {boolean} 是否破存活时间纪录 */ _saveScore(aliveSec) { const best = this._loadBest(); best.runs += 1; // totalRuns:加速解锁条件(第一局结束) 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, totalRuns: best.runs, schema: 1, updatedAt: new Date().toISOString(), }, }), }).catch(() => { /* 离线静默 */ }); } catch (_) { /* 静默 */ } return true; } // ---------------- 工具 ---------------- /** * voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载 * 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条管线并播。 * 本层所有 voice play 走此门串行:等上一次 play 的令牌落位再发下一次。 * 等待是纯 microtask(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能 * 并发的语音全部 prewarm 缓存热;资产缺失等异常路径 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; } _wp(conf) { return new MarkingWaitPoint(conf, { floor: this.floor, confusion: this.confusion }); } _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 <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500); } }