/** * 盲航(space)R4 —— 太空盲飞躲避,纯音频(按 docs/design/04-space-rebuild.md 实现)。 * * R4 核心变化: * - **同向闪避**:听到哪边来就向哪边滑("挥手把陨石拨开"),方位声直达同侧手势, * 消除旧版"定位→取反→出手"的推理链。whoosh_* 四资产复用,只改映射。 * - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 + * 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。 * 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。 * 演练陨石呼啸响起时同窗播 dir_,与实战"听声即滑"的配对一致(round4 R1)。 * - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收 * (shield_block 音 + WARNING 震动 + protect_ 归因句)。60s 播 protect_end * 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。 * 过渡不断崖:保护结束后的前 3 颗裸飞陨石保持 4.0s 逼近 / 4.0s 刷怪, * 第 4 颗起才接入难度曲线(round4 R1:protect_end 后第一颗疑似必中、10s 三连撞)。 * - **闪避窗口覆盖呼啸全程**(round4 R1:早滑被静默吞掉):同向滑在陨石呼啸响起 * (spawn)到撞击点 +250ms 宽限之间都有效,不再只开撞击前 1.5s;spawn 之前的 * "预言家"滑动不算(按 ob.spawnAt 下界)。 * - **单击/双击分批引入(仅首局)**:第 20s 暂停刷怪教单击加速(1.8s 持续增益), * 第 45s 教双击护盾(6s,15s 充能,shield_ready 语音排队播);8s 内没做不等待 * 直接继续。introDone 标记挂在实例上(不随 _resetRunState 重置),第 2 局起 * 不再重播教学、机制开局即用(round4 R1:feat_* 每局重播)。护盾不可用 * (未引入/充能中)时双击有即时应答(feat_shield_done 现成句 + HEARTBEAT 震, * 4s 去抖;round4 R1:panic 双击零反馈)。 * - **死亡即教学**:保护期后被撞播 hit_ 归因句,第 2 次追加 hit_last * (呼喊接力——归因被 dir_ 呼喊顶断时 hit_last 挂起,由呼喊播完的 * onComplete 自动接播,"最后一命"不再被静默吞掉,round4 R1);3 次撞击坠毁。 * 结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。 * round2 R2(高速段警告播不出/叠播):**hit_last 在播期间方向呼喊抑制** * (警告播完前不被下一颗呼喊顶断;归因在播期间呼喊保留生存优先级—— * 顶断 → 呼喊播完接力,警告低延迟路径不变);里程碑/护盾不可用应答在 * 警告链期间排队不顶链;被菜单/分批引入顶断的警告恢复对局即补播; * **坠毁即掐断警告链**,结算链 1.2s 后干净开场不叠播(live B4)。 * - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。 * - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 → * 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时** * 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。 * round2 R2 误暂停修复(live ta=664.99/1670.73:高速段撞击瞬间的下滑躲避 * 被当成开菜单):**最近一次判定(撞/躲/格挡/吸收)后 800ms 冷静期内的下滑 * 一律按躲避意图忽略,绝不开菜单**(其余时刻保留旧折中,暂停可达性不变);「继续飞行」恢复缓冲——前 3 颗裸飞陨石回 * 4.0s 慢节奏(复用断崖过渡机制,不再 3 秒暴毙);「回大厅」改 rhythm 同款收尾: * leaving 态停刷怪/BGM、播确认句后再跳转(旧版直接跳,跳转失败落无声死界面)。 * - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回 * `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。 * - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报 * 播放中单击 = `skipVoice()` 跳下一句;无语音在播时单击维持原义(加速/重开)。 * 首局 D0 在 drill_welcome 后插播一次 skip_hint。 * * 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件—— * 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm / * 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success / * 护盾不可用 heartbeat(round4 R1)。 * * 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别); * 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。 * * 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.2/F6 + round3 G7): * 对局第 1 次受击(保护期外)在归因链播完后补指令式 gd_hit_hint、第 2 次补 * gd_last_life(idle-only:挂在语音空闲点);警报音效 600ms 去重、danger 震 * ≥400ms 去抖;dir_ 超短方向呼喊在陨石呼啸响起瞬间同窗直出(round4 R1 * 改口:G7 原挂在 loud 警报档=撞击前 500ms,实测"听语音再滑"永远来不及; * 提前到 spawn 后语音指引与反应窗口同起点。撞击点落在保护期内的陨石不播—— * 保护期有 protect_ 长句教学,呼喊会把它顶断);结算链换 _speakGuarded * (资产缺失不卡结算 BGM);对局期长按快进豁免(setRateBoostEnabled); * 状态签名 earcon 与状态 BGM 同点。 * * voice 播放安全门(round4 R1 活体复验追加):_playVoice 令牌异步落位,同 tick * 第二个 play 漏杀前一个 → 双管线并播 + ScriptProcessor 节点永久泄漏(live 探针 * 实录 dir_up/hit_down 交替抽读百余次)。本层 voice 播放全部经 _voicePlay 串行门 * (等上次令牌落位再发下一次)+ 对局语音全量 prewarm 缓存热,引擎零改动。 * * R5(space polish round1/round2,research/space-polish/): * - **刺耳重合成**:alarm/dodge/shield_block/boost/shield 五件程序合成重制 * (tools/gen_space_syn.py,真峰值≤-3dBFS、≥3kHz 能量近零),新增 sfx/whiff。 * - **归因真话·接力补 replay**(live A t+151-163:保护期结束 8 秒三连撞, * hit_left/hit_down 被 spawn 呼喊零毫秒顶断,玩家至死不知方位):呼喊维持 * 生存优先裁定不变,但 hit_/protect_end 起播时登记 _defendedId, * 呼喊播完的 onComplete 发现登记未清除(=被顶断)→ queue 接 replay, * 保证每句归因/保护期结束宣告完整送达(含 hit_last 排队其后)。 * - **保护期 protect_* 去重**:6.96s 教学句对 4.0s 刷怪间隔互相截到 4.0s * (live A 保护期 7 连 TRUNCATED),改为上一句在播即跳过(shield_block 音 + * WARNING 震不变),每句教学完整播完。 * - **加速冷却拒绝**(P1/P2/P4:冷却中单击全静默):_deny 共用去抖 * (heartbeat + voice/boost_not_ready 新句),护盾拒绝同路径(原句不变)。 * - **protect_soon**:50s 预告"训练护盾还剩十秒"(queue 不顶句),保护期结束 * 前给反应慢的玩家提前量;菜单/分批引入打断由 _rearmRunTimers 补播。 * - **激活确认震动**:boost/shield 激活补 CONFIRM 震(04 §5 承诺,此前漏装); * 挥空/滑错方向补 sfx/whiff 轻叹(0.55 音量,引擎 swipe 震动已有)。 */ 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_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程,见头注) const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s) const SHIELD_DURATION_MS = 6000; const SHIELD_COOLDOWN_MS = 15000; // round4 R1:30s→15s(冷却过长 + 失败零反馈是 P1) 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 颗裸飞陨石保持保护期节奏(round4 R1 不断崖) const MENU_CALM_MS = 800; // 下滑开菜单冷静期:最近判定后 N ms 内下滑=躲避意图(round2 R2 误暂停修复) const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害) const PROTECT_SOON_MS = 50000; // R5:保护期还剩 10s 预告(protect_soon) 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._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay) // voice 播放安全门接线(round4 R1 活体实测发现,详见 _voicePlay): // 本层(含向导层)所有 voice id 播放一律走串行门,bgm/sfx 直通透传。 // 与 playtest/guide.test 的 play 包装兼容(它们先包,本门包在最外层)。 this._origPlay = audio.play.bind(audio); this.audio.play = (id, opts) => ( typeof id === 'string' && id.startsWith('voice/') ? this._voicePlay(id, opts) : this._origPlay(id, opts) ); // 单击/双击引入教学标记:挂实例不挂局(_resetRunState 不重置)—— // 只在首次进入后的首局各教一次,第 2 局起机制开局即用(round4 R1 P3-4) this.boostIntroDone = false; this.shieldIntroDone = false; this._goingHome = false; // "回大厅收尾中"标记(_goHome 幂等 + leaving 态) this._resetRunState(); } _resetRunState() { this.hits = 0; this.dodges = 0; this.runStart = 0; this.protectionUntil = 0; this._protectAnnounced = false; this._protectSoonAnnounced = false; // R5:50s 预告已播标记 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.featureKind = null; this._menuIdx = 0; this._bareCount = 0; // 保护期结束后已刷裸飞陨石数(断崖过渡用) this._denyAt = -Infinity; // 护盾/加速不可用应答共用去抖时间戳(R5) this._hitLastPending = false; // hits===2 时 hit_last 待接播标记(呼喊接力用) this._hitLastPlaying = false; // hit_last 在播标记(警告链呼喊抑制用,round2 R2) this._defendedId = null; // R5:归因/保护结束句防顶断 replay 登记 this._protectSaying = false; // R5:protect_* 教学句在播标记(去重用) this._lastResolveAt = -Infinity; // 最近一次陨石判定时刻(下滑开菜单冷静期,round2 R2) // 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_boost_done', 'voice/feat_shield', 'voice/feat_shield_done', 'voice/boost_not_ready', 'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100', 'voice/gd_hit_hint', 'voice/gd_last_life', 'voice/paused', 'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help', 'voice/help_recap', 'voice/settings_hint', 'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask', 'voice/skip_hint', ]); this._session++; this._clearTimers(); this._goingHome = false; // 页面重进时复位"回大厅收尾中"标记(leaving 态才可再入) const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1'; if (drilled) { // 二周目:上滑重听演练 / 单击直接出发 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 'leaving': // 回大厅收尾中:仅受理"单击跳确认句"(链自然推进跳转,看门狗兜底) if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice(); 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, spawnAt: now }; this.obstacles.add(ob); this.drillOb = ob; // 呼啸 + dir_ 同窗:演练就建立"声音一出立刻滑"的配对(round4 R1), // 此处 voice 层必空闲(演练陨石只在旁白播完/被跳过后 spawn) this.audio.play(APPROACH_SFX[d.dir]); this.audio.play(`voice/dir_${d.dir}`); this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob)); } _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); // 首颗稍缓,给玩家进入状态 // 单击/双击引入教学仅首局(标记在实例上,重开不再调度,第 2 局起机制开局即用) if (!this.boostIntroDone) this._after(BOOST_INTRO_MS, () => this._beginFeature('boost')); if (!this.shieldIntroDone) this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield')); this._after(PROTECT_SOON_MS, () => this._announceProtectionSoon()); // R5:50s 预告 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); this._whiffIfUnmatched(g); break; case 'down': // 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单。 // round2 R2 误暂停修复(live ta=664.99/1670.73):高速段玩家在撞击瞬间 // 下滑躲避(陨石已判定出集合)被当成开菜单——加冷静期:最近一次判定 // (撞/躲/格挡/吸收)后 MENU_CALM_MS 内的下滑一律按躲避意图忽略,绝不开菜单; // 其余时刻保留旧折中(无下方陨石悬置即可开菜单),主动暂停可达性不变。 if ([...this.obstacles].some((ob) => ob.dir === 'down')) { this.swipes.push({ dir: g, t: now }); this._trimSwipes(now); this._whiffIfUnmatched(g); } else if (now - this._lastResolveAt > MENU_CALM_MS) { 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'); vibrate('confirm'); // R5:激活确认(04 §5 承诺,此前漏装) } else if (this.boostIntroDone) { // R5:冷却中单击必须有回音(P1/P2/P4:冷却零反馈是动作盲区) this._deny(now, 'voice/boost_not_ready'); } break; case 'doubletap': // 第 45s 引入后才解锁(仅首局引入,第 2 局起开局即用) if (this.shieldIntroDone && now >= this.shieldReadyAt) { this.shieldUpUntil = now + SHIELD_DURATION_MS; this.shieldReadyAt = now + SHIELD_COOLDOWN_MS; this.audio.play('sfx/shield'); vibrate('confirm'); // R5:激活确认(04 §5 承诺,此前漏装) this._scheduleShieldReady(); } else { this._deny(now, 'voice/feat_shield_done'); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1) } break; } } /** * R5:无效手势轻反馈(归因真话)。滑向没有任何悬置陨石的方向 = 挥空—— * 引擎 swipe 识别震动已有("收到了"),这里补一声轻叹("但这下没用"), * 与成功闪避的 dodge 音形成对偶,避免玩家怀疑输入失灵。音量压低不抢戏。 */ _whiffIfUnmatched(dir) { if (![...this.obstacles].some((ob) => ob.dir === dir)) { this.audio.play('sfx/whiff', { volume: 0.55 }); } } /** * 护盾/加速不可用(未引入/充能中)的即时应答:HEARTBEAT 震 + 语音提示, * 4s 共用去抖防连刷(R5 起护盾/加速同路径,仅语音 id 不同)。 * 护盾句复用 feat_shield_done("会自动充能,好了我会告诉你"语义正合,round4 R1 裁定); * 加速句用新句 boost_not_ready("引擎还在冷却,稍等一下。",R5)。 * 警告链在播时排队到链尾,不顶断"最后一命"(round2 R2)。 */ _deny(now, voiceId) { if (now - this._denyAt < DENY_DEBOUNCE_MS) return; this._denyAt = now; vibrate('heartbeat'); this.audio.play(voiceId, this._warnActive() ? { queue: true } : undefined); } /** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。语音排队播,不顶 protect_end/归因句。 */ _scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) { this._after(delay, () => { if (this.phase === 'playing') { vibrate('pickup'); this.audio.play('voice/shield_ready', { queue: true }); } }); } // ---------------- 单击/双击分批引入(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'); vibrate('confirm'); // R5:激活确认(04 §5) 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'); vibrate('confirm'); // R5:激活确认(04 §5) 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()); } // R5:50s 预告补播(菜单/分批引入打断后的恢复) if (!this._protectSoonAnnounced) { const soonRemain = this.runStart + PROTECT_SOON_MS - now; if (soonRemain <= 0) { if (this.protectionUntil > now) this._announceProtectionSoon(); } else { this._after(soonRemain, () => this._announceProtectionSoon()); } } if (this.shieldReadyAt > now) { this._scheduleShieldReady(this.shieldReadyAt - now); } // "最后一命"警告链被菜单/分批引入旁白顶断时,恢复对局立即补播(round2 R2) if (this._hitLastPending) this._drainHitLast(); } // ---------------- 保护期 ---------------- /** R5:50s 预告(保护期还剩 10 秒)。queue 不顶在播教学句;被打断由 _rearmRunTimers 补播。 */ _announceProtectionSoon() { if (this._protectSoonAnnounced) return; if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时补播 this._protectSoonAnnounced = true; this.audio.play('voice/protect_soon', { queue: true }); } /** R5:保护结束宣告走防顶断登记(live A t+151.6:被 spawn 呼喊顶得只剩 0.5s)。 */ _announceProtectionEnd() { if (this._protectAnnounced) return; if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时由 _rearmRunTimers 补播 this._protectAnnounced = true; vibrate('warning'); this._defendVoice('voice/protect_end'); } /** * R5 防顶断登记:生存语义长句(hit_/protect_end)起播时登记 id; * 自然播完/被跳过 → onComplete 清除登记(= 完好送达); * 被 spawn 呼喊顶断 → 登记残留,呼喊播完的 onComplete 走 _replayDefended * queue 接 replay(呼喊生存优先裁定不变,句子保证完整送达一次)。 */ _defendVoice(id, onDone) { this._defendedId = id; this.audio.play(id, { onComplete: () => { if (this._defendedId === id) this._defendedId = null; if (onDone) onDone(); }, }); } /** R5:呼喊播完时发现登记残留(= 顶断了生存句)→ queue 接 replay,不顶新威胁信息。 * @returns {boolean} 是否发生了 replay(发生了则 hit_last 由 replay 播完接力,不必此刻 drain) */ _replayDefended() { const id = this._defendedId; if (!id) return false; this._defendedId = null; if (id.startsWith('voice/hit_')) { // 归因 replay 播完仍要接"最后一命"(语义链:先因后果) this.audio.play(id, { queue: true, onComplete: () => this._drainHitLast() }); } else { this.audio.play(id, { queue: true }); } return true; } // ---------------- 陨石调度 ---------------- _difficultyNow() { return performance.now() - this.protectionUntil; // 保护期后为正 } _scheduleSpawn(delay) { this._after(delay, () => { if (this.phase !== 'playing') return; this._spawnObstacle(); this._scheduleSpawn( // 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗保持保护期刷怪节奏(round4 R1) this._inProtection() || this._bareCount < BARE_GRACE_METEORS ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()), ); }); } _spawnObstacle() { const dir = DIRS[Math.floor(Math.random() * DIRS.length)]; const now = performance.now(); const inProt = this._inProtection(); // 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗逼近时长固定 4.0s 不收缩(round4 R1) const grace = !inProt && this._bareCount < BARE_GRACE_METEORS; const impactAt = now + (inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow())); const ob = { dir, impactAt, spawnAt: now }; this.obstacles.add(ob); if (!inProt) this._bareCount++; this.audio.play(APPROACH_SFX[dir]); // dir_ 超短方向呼喊与呼啸同窗直出(round4 R1 改口,原 G7 挂在撞击前 500ms // 的 loud 警报档,"听语音再滑"永远来不及;提前到 spawn 后语音指引 = 反应窗口起点)。 // 撞击点落在保护期内的不播:protect_ 长句教学在播时呼喊会把它顶断。 // onComplete 挂 hit_last 接力:呼喊顶断归因句时,呼喊播完自动接播"最后一命"。 // round2 R2:hit_last 在播期间(_hitLastPlaying)**不呼喊**——警告播完前不再 // 被下一颗的呼喊顶断(live B5:高速段警告刚起播就被顶没);归因在播期间呼喊 // 保留生存优先级(顶断 → 接力,低延迟警告路径不变)。 if (impactAt > this.protectionUntil && !this._hitLastPlaying) { // onComplete 双职责(R5):① 顶断了归因/保护结束句 → queue 接 replay(replay 播完 // 自行接力 hit_last);② 呼喊接力(round4 R1):无 replay 时 hit_last 挂起直接接播。 this.audio.play(`voice/dir_${dir}`, { onComplete: () => { if (!this._replayDefended()) this._drainHitLast(); } }); } // 警报两档渐强,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(); this._lastResolveAt = now; // 下滑开菜单冷静期锚点(任何结局都算,round2 R2) // 1) 加速增益期间:冲过空隙 if (now <= this.boostUntil + DODGE_GRACE_MS) { this._onDodge(); return; } // 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效—— // 窗口覆盖呼啸全程(round4 R1:旧版只开撞击前 1.5s,呼啸起点 2.8~4s, // 早滑被静默吞掉);spawn 前的"预言家"滑动不算(spawnAt 下界)。 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.audio.play('voice/shield_block', { queue: true }); return; } // 4) 保护期:训练护盾吸收,不掉血,语音解释方位(死亡教学前置) if (this._inProtection()) { this.audio.play('sfx/shield_block'); vibrate('warning'); // R5 去重:6.96s 教学句对 4.0s 刷怪间隔(live A 保护期 7 连 TRUNCATED, // 每句都截在同一个字上)——上一句在播即跳过,保证每句完整送达; // 被跳过的那次仍有 shield_block 音 + WARNING 震落位。 if (!this._protectSaying) { this._protectSaying = true; this.audio.play(`voice/protect_${ob.dir}`, { onComplete: () => { this._protectSaying = false; } }); } return; } // 5) 实打实撞上:HIT 震动 + 归因语音(第 2 次加"只剩最后一次") this.hits += 1; this.audio.play('sfx/hit'); vibrate('hit'); if (this.hits >= HULL_MAX) { this._gameOver(); } else { // hit_last 接力机制(round4 R1:旧版 _speak 链被 dir_ 呼喊顶断时 // onComplete 不触发、hit_last 静默丢失——"最后一命"警告从没被听到;queue:true // 也靠不住——_playVoice 异步设令牌,同步连调时 queue 检查落空会变同播)。 // 正常路径:归因自然播完/被跳过 → onComplete → _drainHitLast 接播; // 顶断路径:归因被呼喊顶掉 → 呼喊播完的 onComplete(_spawnObstacle 挂的) // → _drainHitLast 接播。重开/回大厅由 _resetRunState 清挂起。 if (this.hits === 2) this._hitLastPending = true; // R5:归因句走防顶断登记(_defendVoice)——被 spawn 呼喊顶断时由呼喊 // onComplete 走 _replayDefended 补 replay(live A t+155.13/159.13: // hit_left/hit_down 零毫秒被杀,玩家至死不知方位)。 this._defendVoice(`voice/hit_${ob.dir}`, () => { if (this.hits === 1) { // 第 1 次受击补指令式 gd_hit_hint(hit_ 已归因方位,这句补 // "该怎么拨",onceKey 同局一次) this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true }); } this._drainHitLast(); }); } } /** * "最后一命"接播:hits===2 时挂起,归因播完或顶断它的呼喊播完两条路径汇到这里。 * 播完补 gd_last_life(F2.2:生存经济透明,与 hit_last「船体只剩最后一次」互补 * "撑住"口径),已震 hit 免重复;被打断跳过可接受(向导层 idle-only 语义不变)。 * round2 R2:接播期间置 _hitLastPlaying(此期间 spawn 呼喊抑制,警告不再被 * 顶断);已离开对局(菜单/坠毁/收尾)不再接播。 */ _drainHitLast() { if (!this._hitLastPending) return; this._hitLastPending = false; if (this.phase !== 'playing') return; this._hitLastPlaying = true; this.audio.play('voice/hit_last', { onComplete: () => { this._hitLastPlaying = false; this.guide.error({ voice: 'gd_last_life', skipHaptic: true }); }, }); } /** "最后一命"警告链进行中(归因在播待接播 / hit_last 在播)——此时旁白排队不顶链。 */ _warnActive() { return this._hitLastPending || this._hitLastPlaying; } _onDodge() { this.dodges += 1; this.audio.play('sfx/dodge'); const ms = MILESTONES[this.dodges]; if (ms) { vibrate('confirm'); // 警告链在播时里程碑排队到链尾,不顶断"最后一命"(round2 R2) this.audio.play(ms, this._warnActive() ? { queue: true } : undefined); } } // ---------------- 菜单(全球约定:下滑呼出 → 单击循环 → 双击确认 → 上滑帮助) ---------------- _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 // round2 R2 恢复缓冲(live B2:恢复后全速弹幕 3 秒暴毙):恢复后前 // BARE_GRACE_METEORS 颗裸飞陨石回 4.0s 慢节奏,复用保护期断崖过渡机制。 this._bareCount = 0; this._scheduleSpawn(800); this._rearmRunTimers(); } /** * 回大厅(rhythm round1 同款收尾):先进 leaving 收尾态停刷怪/BGM,播回大厅 * 确认句,播完(或 2s 看门狗兜底)再跳转——round2 R2(live B3):旧版无确认 * 直接跳,跳转失败时玩家落进无声死界面,纯听觉玩家被关黑屋。 */ _goHome() { if (this._goingHome) return; this._goingHome = true; this.phase = 'leaving'; // 收尾态:不再受理对局/菜单/结算手势(单击仍可跳确认句) this._clearTimers(); // 停刷怪与进行中陨石调度(保留 voice 通道播确认句) this.obstacles.clear(); this.audio.stopBgm(300); this._speakGuarded(['voice/menu_lobby'], () => { this.stop(); window.location.href = '../'; }, 2000); } // ---------------- 坠毁结算 ---------------- _gameOver() { this.phase = 'over'; this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点) this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进 this._clearTimers(); this.obstacles.clear(); this.audio.stopBgm(600); // round2 R2(live B4):坠毁即掐断"最后一命"警告链(归因/hit_last/gd_last_life // 可能在播)——旧版 hit_last 与 1.2s 后的结算 time_* 叠播互相掐,谁都没听清。 this._hitLastPending = false; this._hitLastPlaying = false; this.audio.stopVoice(); vibrate('error'); this._after(300, () => vibrate('hit')); this.audio.play('sfx/crash'); 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) } /** * voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载 * 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条 SoundTouch 管线 * 并播(台词交叠),先落位的令牌成永久孤儿(ScriptProcessor 节点不断开、 * 探针/监听层被交替抽读污染)。本层所有 voice play 走此门串行:等上一次 play * 的令牌落位再发下一次,恢复引擎"后播杀先播"语义。 * 等待是纯 microtask 的(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能 * 并发的语音全部 prewarm 缓存热(_load 命中已解码 Promise,落位仅数个 * microtask);资产缺失等异常路径 64 轮后放行,门绝不卡死。 * 会话令牌失效(重开/回大厅)后未发的门内播放直接作废。 */ _voicePlay(id, opts = {}) { const s = this._session; this._voiceGate = this._voiceGate.then(async () => { if (s !== this._session) return; this._origPlay(id, opts); for (let i = 0; i < 64 && !this.audio.isVoicePlaying(); i++) await Promise.resolve(); }); return this._voiceGate; } /** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */ _speak(ids, onDone) { if (!ids.length) { if (onDone) onDone(); return; } 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 <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500); } }