/** * 《围猎》客户端 —— 2-4 人实时联机纯音频协作守卫。 * 设计:docs/design/23-hunt.md(协议 §6、拥挤控制 R1-R8 §8、教学 §5) * 台词表:docs/design/23-hunt-voice-ids.md(60+ 旁白 id / 24 bot 喊话 id) * * 架构要点: * - 服务端权威:怪物生成/移动/命中/冷却/评级全在服务器裁决;本客户端只发 * 意图(join / act / leave),消费 joined / ev / snap / err 四类消息。 * - 本地怪表 = ev 增删 + snap 校正(§6.7):每 snap 对账——本地多出的 id 删、 * 缺的补、eta 偏差 >0.5s 校正;断网期间按最后已知 eta 继续衰减渲染。 * - 时钟:joined/snap/ev 携带房间单调时钟 ts,记 offset = ts - performance.now(), * 渲染用 nowSrv() = performance.now() + offset(§6.5,局域网无需 RTT 补偿)。 * - 拥挤控制客户端侧(§8):旁白单句队列(引擎 voice 层既有,R1)+ 警报可顶 * 状态类/不可顶教学与结算链;bot 喊话走 sfx 层带 pan、同刻 ≤1、待播 ≤1、 * 旁白在播延迟 ≤2s(R2);怪物 loop ≤3 路只渲染 eta 最小 3 只(R3);定位 * one-shot ≤2 并发、同类间隔 ≥0.15s、呼啸队列 ≤2 超出丢弃(R4);喊话统一 * vol 0.55 压档(R7);听觉视野只报内圈数量档(R6,threat_* 三档)。 * - 方位 → pan(§8.3):pan = sin(az),后半平面(90° DIR_AZ[dir] ?? 0; const panOfAz = (az) => Math.sin((az * Math.PI) / 180); const isBackAz = (az) => az > 90 && az < 270; // 后半平面:loop/呼啸音量 ×0.6 const panOfDir = (dir) => panOfAz(azOf(dir)); /** 座位防区中心方位角:防区为连续罗盘弧,取各方位角均值(§3.3 中心列同值)。 */ function sectorCenterAz(sector) { if (!sector || !sector.length) return 0; return sector.reduce((s, d) => s + azOf(d), 0) / sector.length; } // ---------------- 数值(23 §3.2/§8;裁决以服务器为准,本地仅预扣/门控) ---------------- const ATTACK_CD_MS = 2500; // 出击冷却/座 const SKILL_CD_MS = 8000; // 旋风斩冷却/座 const CALLOUT_THROTTLE_MS = 5000; // 呼喊节流/座 const INNER_ETA_S = 4; // 内圈(近战窗 + threat 播报口径) const MID_ETA_S = 6; // 中圈上限(loop 可闻;之外仅生成呼啸一声) const LOOP_MAX = 3; // R3:同刻 loop ≤3 路(eta 最小 3 只) const LOOP_REFRESH_MS = 500; // §6.7 每 500ms 重估渲染集 const ONESHOT_MAX = 2; // R4:定位 one-shot ≤2 并发 const ONESHOT_SPACING_MS = 150; // R4:同类间隔 ≥0.15s const HOWL_QUEUE_MAX = 2; // R4:生成潮呼啸队列深 ≤2,超出丢弃 const HOWL_QUEUE_TTL_MS = 1500; // 排队的呼啸过期丢弃(怪近后 loop 已接管信息) const NPC_VOL = 0.55; // R7:bot 喊话统一压一档,防压旁白 const NPC_VOICE_DELAY_MS = 2000;// R2:旁白在播时喊话延迟投递上限,超时丢弃 const NPC_PRIO_TTL_MS = 4000; // help_call/help_ack 呼救语义:更长的待播宽限 const BREACH_VOICE_GAP_MS = 3500; // F 组:breach/breach_heavy 播报节流 ≥3.5s const END_GRACE_MS = 800; // H1:结算链进入手势死窗(dungeon 先例) const RECONNECT_LIMIT_MS = 30000; // §6.6 断线 30s 宽限 const RECONNECT_STEP_MS = 2000; // 本地静默重连节奏 const VOICE_GUARD_MS = 9000; // 语音链单句看门狗(资产缺失/解码失败兜底推进) /** sfx 时长表(秒,one-shot 调度记账用;资产值见 23 §10.2)。 */ const SFX_DUR = { atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7, skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4, call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0, }; const NPC_LINE_FALLBACK_DUR_S = 1.6; // npc 喊话时长兜底(精确值取解码后 buffer) const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_help', 'sys_menu_home']; // §2.4 固定 3 项 const GRADE_RANK = { S: 4, A: 3, B: 2, C: 1 }; /** 状态链分档(D 组;耐久 10 制)。 */ const hpBandId = (dur) => (dur >= 8 ? 'hp_band1' : dur >= 5 ? 'hp_band2' : dur >= 3 ? 'hp_band3' : 'hp_band4'); const stWaveId = (wave) => (wave <= 2 ? 'st_wave_a' : wave <= 4 ? 'st_wave_b' : 'st_wave_c'); const killBandId = (k) => (k <= 0 ? 'kill_band0' : k <= 9 ? 'kill_band1' : 'kill_band2'); /** 默认浏览器传输:原生 WebSocket 包成 { send, close } + handlers 形态。 */ function defaultTransport(url, handlers) { const ws = new WebSocket(url); ws.onopen = () => handlers.onopen(); ws.onmessage = (e) => { let msg = null; try { msg = JSON.parse(e.data); } catch (_) { /* 坏帧忽略 */ } if (msg) handlers.onmessage(msg); }; ws.onclose = () => handlers.onclose(); ws.onerror = () => { /* onclose 随后必达,统一走关闭路径 */ }; return { send: (s) => { try { ws.send(s); } catch (_) { /* 连接已死,重连流程兜底 */ } }, close: () => { try { ws.close(); } catch (_) { /* 已关闭 */ } }, }; } export class HuntGame extends Game { /** * @param {WebAudioEngine} audio * @param {HTMLElement} root * @param {object} [opts] * @param {Function} [opts.transport] 传输工厂(测试注入内存回路 mock) * @param {string} [opts.wsUrl] WS 端点(缺省按页面 location 拼 /ws/hunt) */ constructor(audio, root, opts = {}) { super(audio, root); this.guide = new Guide(audio); this._transport = opts.transport || defaultTransport; this._wsUrl = opts.wsUrl || null; // 宏观状态:title / matching / lobby / teach / wave / end / lost(menu 为 overlay) this.phase = 'title'; this._menuOpen = false; this._menuIdx = 0; this._goingHome = false; // 房间/连接 this._conn = null; this._connAlive = false; this._joined = false; this._intentionalClose = false; this.room = null; this.slot = -1; this._resumeToken = null; this._myChar = null; this._mySector = []; this.seed = 0; this._roster = new Map(); // slot → { slot, name, bot, char, sector, alive? } this._seq = 0; this._srvOffset = 0; // 房间时钟 - 本地时钟(ms) this._srvPhase = null; // joined.state.phase / snap.phase(教学零惩罚判定用) // 战斗渲染状态 this.mobs = new Map(); // id → { id, mkind, dir, eta0, t0 }(eta0 为 t0 时刻值) this.wave = 0; this.dur = 10; this._myKills = 0; this._loops = new Map(); // mobId → { want, src, gain, panner, vol, eta } this._renderTimer = null; // 本地冷却预扣(服务端 err 为准;冷却内手势本地先拦) this._atkReadyAt = 0; this._skillReadyAt = 0; this._skillAnnounced = true; // 每轮冷却完成只播一次 skill_ready this._skillReadyTimer = null; this._calloutReadyAt = 0; // 语音纪律(R1) this._chainSeq = 0; // 语音链代数:硬播/打断作废旧链 this._protect = null; // 'announce' | 'settle'(教学段恒视为保护,见 _alertQueued) this._lastBreachVoiceAt = -1e9; this._dangerAnnounced = false; // R4 定位 one-shot 调度 this._posActive = []; // [{ until }] this._posQueue = []; // [{ id, pan, vol, type, droppable, queuedAt }] this._posTypeAt = {}; // type → 上次播出时刻 this._posPumpTimer = null; this._posLog = []; // 断言/转写用:{ id, pan, vol, t } // R2 bot 喊话调度 this._npcBusyUntil = 0; this._npcPending = null; // { id, pan, onDone, expireAt } this._npcPumpTimer = null; this._npcLog = []; // 断言用:{ id, pan, t, dur } // 教学 this._tut = -1; // 0..4(T0..T4),-1 = 非教学 this._tutMiss = 0; // T1 挥空/错向次数(重教 ≤2 次后服务器自动放过) this._tutDoneWaiting = false; // 教学完成/跳过,等 ev wave 1 // 断线重连 this._netDown = false; this._netDownAt = 0; this._netLostSaid = false; this._reconnectTimer = null; this._openGuardTimer = null; // 结算 this._graceUntil = 0; this._endChain = null; this._endAwaiting = false; // 进度 this._progress = { best: null, runs: 0, totalKills: 0, tutDone: false }; } // ================================================================ Game start() { this.audio.prewarm([ 'sfx/atk_whoosh', 'sfx/atk_swish', 'sfx/kill_wolf', 'sfx/kill_bear', 'sfx/skill_spin', 'sfx/breach_thud', 'sfx/horn_wave', 'sfx/join_chime', 'sfx/call_whistle', 'sfx/howl_wolf', 'sfx/howl_bear', 'sfx/loop_wolf', 'sfx/loop_bear', ]); this._loadProgress(); this._pullServerProgress(); // 登录态异步拉回(服务器为准),不阻塞进 TITLE this._enterTitle(); } stop() { this._chainSeq++; this._protect = null; this.guide.disarm(); if (this._renderTimer !== null) { clearInterval(this._renderTimer); this._renderTimer = null; } if (this._skillReadyTimer !== null) { clearTimeout(this._skillReadyTimer); this._skillReadyTimer = null; } if (this._reconnectTimer !== null) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } if (this._posPumpTimer !== null) { clearTimeout(this._posPumpTimer); this._posPumpTimer = null; } if (this._npcPumpTimer !== null) { clearTimeout(this._npcPumpTimer); this._npcPumpTimer = null; } this._stopAllLoops(); this._closeConn(); this.audio.stopAll(); } onGesture(g) { this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op) if (this._goingHome) return; // H1 结算死窗:吞掉终局前后在途的 ghost 手势 if (performance.now() < this._graceUntil) return; if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') return; // 引擎快进接管 if (this._menuOpen) { this._onMenuGesture(g); return; } switch (this.phase) { case 'title': this._onTitleGesture(g); break; case 'matching': this._onMatchingGesture(g); break; case 'lobby': this._onLobbyGesture(g); break; case 'teach': this._onTeachGesture(g); break; case 'wave': this._onWaveGesture(g); break; case 'end': this._onEndGesture(g); break; case 'lost': this._onLostGesture(g); break; default: break; } this._rearmWatchdog(); } // ================================================================ 状态进入 _enterTitle() { this.phase = 'title'; this.audio.play('bgm/menu'); this.guide.state('menu'); } _enterMatching() { this._teardownRound(); this.phase = 'matching'; this.audio.play('bgm/menu'); this.guide.state('menu'); // A 组触发口径「TITLE 单击后/教学房 T0」:首局教学房 T0 播 intro, // 二周目起在匹配等待时播;教学房到 T0 再播,不重复 if (this._progress.tutDone) this._playChain(['intro_1', 'intro_2'], { protect: 'announce' }); this._connect(); } _enterLobby() { this.phase = 'lobby'; this.audio.play('bgm/menu'); this.guide.state('menu'); this._playChain(['room_wait'], { protect: 'announce' }); this._rearmWatchdog(); } _enterTeach() { this.phase = 'teach'; this._tut = 0; this._tutMiss = 0; this._tutDoneWaiting = false; this.audio.play('bgm/hunt'); this.guide.state('battle'); this._startRenderer(); // T0:开场 + 规则总述(教学链不可顶,播完武装步骤看门狗) this._playChain(['intro_1', 'intro_2', 'tut_rule'], { protect: 'teach', onDone: () => this._armTutWatchdog(0), }); } /** * 进波次(开局/波间/重连恢复)。from: 'lobby' | 'teach' | 'wave' | 'resume'。 * 开局链:lobby → room_start + wave_band1;teach → wave_band1(tut_done 已衔接); * 第 2 波起只播 wave_bandN;先号角后播报(§3.4)。 */ _enterWave(n, from) { const wasInWave = this.phase === 'wave'; this.phase = 'wave'; this._tut = -1; this._tutDoneWaiting = false; this.wave = n; this.audio.play('bgm/hunt'); if (!wasInWave) { this.guide.state('battle'); this._startRenderer(); } vibrate('heartbeat'); // §2.1 震动总表:波次开始 this._pos('sfx/horn_wave', { pan: 0, vol: 1, type: 'horn' }); const band = 'wave_band' + Math.min(5, Math.max(1, n)); if (n === 1 && from === 'lobby') this._playChain(['room_start', band], { protect: 'announce' }); else this._playChain([band], { protect: 'announce' }); this._rearmWatchdog(); } _enterEnd(ev) { this.phase = 'end'; this._menuOpen = false; // 终局强制收菜单 overlay(E3 不暂停期间菜单可能被开着) this._graceUntil = performance.now() + END_GRACE_MS; // H1 死窗 this._endAwaiting = false; this._srvPhase = 'end'; this.dur = ev.dur; const kills = (ev.kills && ev.kills[String(this.slot)]) || 0; this._myKills = kills; this._stopAllLoops(); this._dropAllNpc(); this.audio.play('bgm/menu', { volume: 0.5 }); this.guide.state('settle'); this._saveProgress(ev); // 结算 PUT(含 summary),end_rank 播到时已写入 const win = !!ev.win; const chain = win ? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), killBandId(kills), 'end_rank', 'end_again'] : ['end_lose', killBandId(kills), 'end_again']; this._endChain = chain; this._playChain(chain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } }); } _enterLost() { this.phase = 'lost'; this._menuOpen = false; this._netDown = false; this._stopAllLoops(); this._dropAllNpc(); this.audio.play('bgm/menu'); this.guide.state('menu'); } /** 再来一局 / 重进匹配:收拾残局 → 重新连接。 */ _again() { this._sendLeave(); this._enterMatching(); } // ================================================================ 手势:各状态 _onTitleGesture(g) { if (g === 'tap') { this._enterMatching(); return; } if (g === 'doubletap') { this._playChain(['intro_1', 'intro_2']); return; } // 重听开场 if (g === 'up') { const best = this._progress.best; if (!best) { this._invalid(); return; } this._playChain(['grade_' + String(best.grade).toLowerCase(), killBandId(best.kills)], { queueFirst: true }); return; } if (g === 'down') { this._openMenu(); return; } this._invalid(); // left/right } _onMatchingGesture(g) { if (g === 'doubletap') { // 重听匹配提示(开场链) if (this._progress.tutDone) this._playChain(['intro_1', 'intro_2']); return; } if (g === 'down') { this._openMenu(); return; } // 菜单含"回大厅"=取消匹配 if (g === 'tap') { this._invalid(); return; } this._invalid(); } _onLobbyGesture(g) { if (g === 'tap') { this._sendAct('start'); return; } // 立即开局 if (g === 'doubletap') { this._playChain(['room_wait']); return; } // 重听 if (g === 'up') { this._playChain([this._teamCount() >= 4 ? 'team_4' : 'team_3'], { queueFirst: true }); return; } if (g === 'down') { this._openMenu(); return; } this._invalid(); // 四向滑 } _onTeachGesture(g) { if (g === 'down') { // 任意步下滑 = 跳过教学(§5) this._chainSeq++; this.audio.stopVoice(); this._tut = -1; this._playChain(['tut_skip'], { protect: 'teach', onDone: () => this._finishTeach() }); return; } switch (this._tut) { case 0: if (g === 'tap') this._tutGoto(1, ['tut_attack']); else this._invalid(); break; case 1: if (g in SWIPE_BASE) { this._attackDir(SWIPE_BASE[g]); // 步骤作答:恒发 act(错向/挥空走重教路径) } else this._invalid(); break; case 2: if (g === 'tap') this._callout(); else this._invalid(); break; case 3: if (g === 'doubletap') this._skill(); else this._invalid(); break; case 4: if (g === 'up') { // 状态链 4 段 → tut_breach → tut_done → 完成教学(等 ev wave 1) const ids = this._statusIds(); this._chainSeq++; this._playChain([...ids, 'tut_breach', 'tut_done'], { protect: 'teach', onDone: () => this._finishTeach(), }); } else this._invalid(); break; default: this._invalid(); // 已完成等 wave 1 break; } } _onWaveGesture(g) { if (g === 'tap') { this._callout(); return; } if (g === 'doubletap') { this._skill(); return; } if (g in SWIPE_BASE) { // 语境折中(见文件头裁决):上/下滑弧内有可打怪 → 出击;否则 // 上滑=状态链、下滑=菜单(三件套保留)。左/右滑恒=出击。 const arc = ARCS[g]; const hasTarget = this._arcHasTarget(arc); if (!hasTarget && g === 'up') { this._statusChain(); return; } if (!hasTarget && g === 'down') { this._openMenu(); return; } this._attackDir(SWIPE_BASE[g]); return; } this._invalid(); } _onEndGesture(g) { if (g === 'tap') { this._again(); return; } // 再来一局(语音在播也直接重开,rhythm 改口先例) if (g === 'doubletap') { // 重听结算链 if (this._endChain) this._playChain(this._endChain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } }); return; } if (g === 'up') { // 重听个人最佳段 const best = this._progress.best; if (!best) { this._invalid(); return; } this._playChain(['grade_' + String(best.grade).toLowerCase(), killBandId(best.kills)], { queueFirst: true }); return; } if (g === 'down') { this._openMenu(); return; } this._invalid(); } _onLostGesture(g) { if (g === 'tap') { this._enterTitle(); return; } // 回大厅 this._invalid(); } // ================================================================ 菜单(E3:不暂停) _openMenu() { this._menuOpen = true; this._menuIdx = 0; this.guide.state('menu'); // 打开即播 sys_menu + 首项;另播 settings_hint(设置只读、回大厅修改,00 §1.2) this._playChain(['sys_menu', MENU_ITEMS[0], 'settings_hint']); } _closeMenu() { this._menuOpen = false; this._chainSeq++; // 作废弃菜单链(在播当前句自然播完;警报随时可顶) // 恢复返回态签名 const back = this.phase; if (back === 'wave' || back === 'teach') this.guide.state('battle'); else if (back === 'end') this.guide.state('settle'); else this.guide.state('menu'); this._rearmWatchdog(); } _onMenuGesture(g) { if (g === 'tap') { // 单击循环 this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length; this._playChain([MENU_ITEMS[this._menuIdx]]); return; } if (g === 'doubletap') { // 双击确认 const item = MENU_ITEMS[this._menuIdx]; if (item === 'sys_menu_resume') this._closeMenu(); else if (item === 'sys_menu_help') this._playChain(['help_main']); else this._goHome(); // sys_menu_home = 退出房间回大厅 return; } if (g === 'up') { this._playChain(['help_main']); return; } // 帮助 if (g === 'down') { this._closeMenu(); return; } // 关闭 this._invalid(); // 左右滑 } // ================================================================ 战斗动作(客户端意图) /** 出击:冷却内仅 error 震(无语音、不发 act,§2.1)。 */ _attackDir(dir) { const now = performance.now(); if (now < this._atkReadyAt) { vibrate('error'); return; } this._atkReadyAt = now + ATTACK_CD_MS; this._sendAct('attack', dir); this._pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk' }); } /** 呼喊支援:节流 5s/人;本机播自己座位角色 help_call(中央;座 3=哨音带 pan)。 */ _callout() { const now = performance.now(); if (now < this._calloutReadyAt) { vibrate('error'); return; } this._calloutReadyAt = now + CALLOUT_THROTTLE_MS; this._sendAct('callout'); if (this._myChar) this._npcFire(`sfx/npc_${this._myChar}_help_call`, 0, null); else this._pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this._mySector)), vol: 1, type: 'whistle' }); } /** 双击:旋风斩就绪 → 放出;冷却中 → 重听威胁播报(宪法双击语义,E2)。 */ _skill() { const now = performance.now(); if (now < this._skillReadyAt) { this._threatReport(); return; } this._skillReadyAt = now + SKILL_CD_MS; this._skillAnnounced = false; this._sendAct('skill'); this._armSkillReady(SKILL_CD_MS); } _armSkillReady(ms) { if (this._skillReadyTimer !== null) clearTimeout(this._skillReadyTimer); this._skillReadyTimer = setTimeout(() => { this._skillReadyTimer = null; if (this._skillAnnounced) return; this._skillAnnounced = true; if (this.phase !== 'wave' && this.phase !== 'teach') return; this.audio.play('voice/skill_ready', { queue: true }); // 绝不顶句(§2.1) }, Math.max(0, ms)); } /** 威胁重听(E 组):只报内圈数量档,方位永远由声像承载(R6)。 */ _threatReport() { const inner = [...this.mobs.values()].filter((m) => this._mobEta(m) <= INNER_ETA_S && this._mobEta(m) > 0).length; const id = inner === 0 ? 'threat_none' : inner <= 2 ? 'threat_few' : 'threat_many'; this._playChain([id], { queueFirst: true }); } /** 状态链(D 组):耐久档 → 波次档 → 在场猎人数 → 自己击杀档(queue:true 链播)。 */ _statusIds() { return [ hpBandId(this.dur), stWaveId(this.wave || 1), this._teamCount() >= 4 ? 'team_4' : 'team_3', killBandId(this._myKills), ]; } _statusChain(onDone = null) { this._playChain(this._statusIds(), { queueFirst: true, onDone }); } _arcHasTarget(arc) { for (const m of this.mobs.values()) { if (!arc.includes(m.dir)) continue; const eta = this._mobEta(m); if (eta <= INNER_ETA_S && eta > 0) return true; } return false; } // ================================================================ 网络:连接与消息 _wsEndpoint() { if (this._wsUrl) return this._wsUrl; try { const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; return proto + window.location.host + WS_PATH; } catch (_) { return 'ws://localhost' + WS_PATH; } } _connect() { this._intentionalClose = false; const handlers = { onopen: () => this._onOpen(), onmessage: (msg) => this._onMessage(msg), onclose: () => this._onClose(), }; let conn = null; try { conn = this._transport(this._wsEndpoint(), handlers); } catch (_) { // 传输构造即失败(如域名非法):等同连接失败 this._onClose(); return; } this._conn = conn; // 建立超时看守:对端不响应(服务器不可达)时按失败走重连/放弃流程, // 否则无 close 事件可达会永远卡在"重连中" if (this._openGuardTimer !== null) clearTimeout(this._openGuardTimer); this._openGuardTimer = setTimeout(() => { this._openGuardTimer = null; if (!this._connAlive && this._conn === conn) { this._conn = null; this._onClose(); } }, RECONNECT_STEP_MS); } _onOpen() { this._connAlive = true; if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } const join = { t: 'join', ver: PROTO_VER, room: this._resumeToken && this._netDown ? this.room || 'quick' : 'quick', name: this._playerName(), }; if (!this._progress.tutDone) join.teach = true; // 首局=教学房(§5) if (this._resumeToken && this._netDown) join.resume = this._resumeToken; // §6.6 回槽 this._send(join); } _onMessage(msg) { if (!msg || typeof msg.t !== 'string') return; if (typeof msg.ts === 'number') this._srvOffset = msg.ts - performance.now(); switch (msg.t) { case 'joined': this._onJoined(msg); break; case 'ev': this._onEv(msg); break; case 'snap': this._onSnap(msg); break; case 'err': this._onErr(msg); break; default: break; } } _onClose() { this._connAlive = false; this._conn = null; if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } if (this._intentionalClose || this._goingHome) return; const inRoom = this._joined && (this.phase === 'lobby' || this.phase === 'teach' || this.phase === 'wave'); if (this.phase === 'matching' || inRoom) { if (!this._netDown) { // 断线:本地静默重连 ≤30s(net_lost 只播一次);怪物按最后已知 eta 继续衰减渲染 this._netDown = true; this._netDownAt = performance.now(); if (!this._netLostSaid) { this._netLostSaid = true; this._alert('net_lost'); } } this._scheduleReconnect(); } } _scheduleReconnect() { if (this._reconnectTimer !== null) return; if (performance.now() - this._netDownAt > RECONNECT_LIMIT_MS) { this._netGone(); return; } this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; if (!this._netDown) return; if (performance.now() - this._netDownAt > RECONNECT_LIMIT_MS) { this._netGone(); return; } this._connect(); }, RECONNECT_STEP_MS); } /** 重连失败/房已灭:明示失败回大厅出口(不静默死屏)。 */ _netGone() { this._netDown = false; this._joined = false; this._resumeToken = null; this._playChain(['net_gone'], { protect: 'announce' }); this._enterLost(); } _onJoined(j) { const resumed = this._netDown && this._joined; this._netDown = false; this._netLostSaid = false; if (this._reconnectTimer !== null) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._joined = true; this._menuOpen = false; this.room = j.room; this.slot = j.slot; this._resumeToken = j.resume; this._myChar = j.char || null; this._mySector = j.sector || []; this.seed = j.seed || 0; this._seq = 0; this._applyRoster(j.roster || [], true); const st = j.state || {}; this._srvPhase = st.phase || 'lobby'; if (!resumed) this._resetRoundState(); this.wave = st.wave ?? this.wave; this.dur = st.dur ?? this.dur; if (this._srvPhase === 'lobby') this._enterLobby(); else if (this._srvPhase === 'teach') this._enterTeach(); else if (this._srvPhase === 'wave') { if (resumed) { // 回槽续局:net_back + 紧随 snap 全量无缝续渲染(不重播波次播报) this.phase = 'wave'; this.audio.play('bgm/hunt'); this.guide.state('battle'); this._startRenderer(); this._playChain(['net_back'], { protect: 'announce' }); this._rearmWatchdog(); } else { this._enterWave(this.wave || 1, 'lobby'); } } else { // end:迟到回槽,直接落结算等待位 this.phase = 'end'; this._endAwaiting = true; this._playChain(['end_again']); } } _onErr(msg) { // cooldown/skill_cd 为正常违规拒绝——客户端本地已先拦,不惊扰玩家(§6.3) if (msg.code === 'room_gone' || msg.code === 'room_full') { this._netGone(); return; } if (msg.code === 'bad_msg' || msg.code === 'rate') { console.error('[hunt] 服务器拒绝:', msg.code, msg.msg || ''); } } _send(obj) { if (this._conn) this._conn.send(JSON.stringify(obj)); } _sendAct(kind, dir) { this._seq++; const act = { t: 'act', kind, seq: this._seq, at: Date.now() }; if (dir) act.dir = dir; this._send(act); } _sendLeave() { if (this._conn && this._joined) this._send({ t: 'leave' }); this._closeConn(); } _closeConn() { this._intentionalClose = true; const c = this._conn; this._conn = null; this._connAlive = false; this._joined = false; this._resumeToken = null; if (c) c.close(); } _playerName() { try { const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); if (auth && auth.username) return String(auth.username).slice(0, 24); } catch (_) { /* 静默 */ } return '猎人'; } // ================================================================ 事件流(§6.3) _onEv(msg) { switch (msg.kind) { case 'spawn': this._onSpawn(msg); break; case 'kill': this._onKill(msg); break; case 'miss': this._onMiss(msg); break; case 'skill': this._onSkillEv(msg); break; case 'breach': this._onBreach(msg); break; case 'wave': this._srvPhase = 'wave'; this._enterWave(msg.wave, this.phase); break; case 'waveclear': this._playChain(['wave_clear'], { queueFirst: true }); break; case 'callout': this._onCalloutEv(msg); break; case 'botline': this._onBotline(msg); break; case 'roster': this._applyRoster(msg.roster || [], false); break; case 'end': this._enterEnd(msg); break; default: break; } } _onSpawn(ev) { this.mobs.set(ev.id, { id: ev.id, mkind: ev.mkind, dir: ev.dir, eta0: ev.eta, t0: ev.ts ?? this._nowSrv() }); // 生成呼啸一声(外圈可听内容;R4 可丢弃档) const az = azOf(ev.dir); this._pos('sfx/howl_' + ev.mkind, { pan: panOfAz(az), vol: 0.4 * (isBackAz(az) ? 0.6 : 1), type: 'howl', droppable: true, }); this._refreshLoops(); } _onKill(ev) { this.mobs.delete(ev.id); this._stopMobLoop(ev.id); this._pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill' }); if (ev.slot === this.slot) { this._myKills++; vibrate('pickup'); // 命中 } // 教学 T1:首杀 → tut_kill_ok → T2 if (this.phase === 'teach' && this._tut === 1) this._tutGoto(2, ['tut_kill_ok', 'tut_callout']); } _onMiss(ev) { if (ev.slot !== this.slot) return; this._pos('sfx/atk_swish', { pan: panOfDir(ev.dir), vol: 1, type: 'atk' }); // 挥空真话:没打到 vibrate('error'); // 教学 T1:错向/挥空 → gd_idle 重教(≤2 次后服务器自动放过该狼) if (this.phase === 'teach' && this._tut === 1) { this._tutMiss++; this._playChain(['gd_idle'], { protect: 'teach' }); } } _onSkillEv(ev) { for (const id of ev.cleared || []) { this.mobs.delete(id); this._stopMobLoop(id); } this._pos('sfx/skill_spin', { pan: 0, vol: 1, type: 'skill' }); if (ev.slot === this.slot) vibrate('success'); // 教学 T3:放出 → tut_skill_cd → T4 if (this.phase === 'teach' && this._tut === 3) this._tutGoto(4, ['tut_skill_cd', 'tut_status']); } _onBreach(ev) { this.mobs.delete(ev.id); this._stopMobLoop(ev.id); this.dur = ev.dur; if (this._srvPhase === 'teach') return; // 学期零惩罚:教学狼突入不扣耐久、不播警报(§5) this._pos('sfx/breach_thud', { pan: 0, vol: 1, type: 'breach' }); vibrate('hit'); // F 组警报:可顶状态/播报类;节流 ≥3.5s(breach/breach_heavy 共窗) const now = performance.now(); if (now - this._lastBreachVoiceAt >= BREACH_VOICE_GAP_MS) { this._lastBreachVoiceAt = now; this._alert(ev.mkind === 'bear' ? 'breach_heavy' : 'breach'); } // 耐久首次 ≤2:danger_low + danger 震 + danger 签名(每局 1 次)。 // danger_low 走排队(不顶刚开播的 breach 警报——警报零丢失,§8.4 口径) if (this.dur <= 2 && !this._dangerAnnounced) { this._dangerAnnounced = true; this.audio.play('voice/danger_low', { queue: true }); vibrate('danger'); this.guide.state('danger'); } } /** 求援呼喊:他人呼喊 → 该座角色 help_call(防区中心 pan)+ warning 震;自己的本地已播。 */ _onCalloutEv(ev) { if (ev.slot === this.slot) return; const char = this._charOf(ev.slot); const pan = panOfAz(sectorCenterAz(this._sectorOf(ev.slot))); if (char) this._npcSchedule(`sfx/npc_${char}_help_call`, pan, true, null); else this._pos('sfx/call_whistle', { pan, vol: 1, type: 'whistle' }); // 座 3 无角色声(§15 裁决 6) vibrate('warning'); } /** bot 喊话(R2 调度):按 char+line 播 sfx/npc__。 */ _onBotline(ev) { const char = this._charOf(ev.slot); if (!char) return; const priority = ev.line === 'help_call' || ev.line === 'help_ack'; const pan = panOfAz(sectorCenterAz(this._sectorOf(ev.slot))); // 教学 T2:呼喊-响应闭环——bot help_ack 播完后点明 const onDone = (this.phase === 'teach' && this._tut === 2 && ev.line === 'help_ack') ? () => this._tutGoto(3, ['tut_callout_ok', 'tut_skill']) : null; this._npcSchedule(`sfx/npc_${char}_${ev.line}`, pan, priority, onDone); } // ================================================================ 快照兜底(§6.7) _onSnap(s) { if (typeof s.ts === 'number') this._srvOffset = s.ts - performance.now(); if (s.phase) this._srvPhase = s.phase; if (typeof s.dur === 'number') this.dur = s.dur; if (typeof s.wave === 'number') this.wave = s.wave; // 怪表对账:本地多出的删、缺的补、eta 偏差 >0.5s 校正(矛盾以 snap 为准) const seen = new Set(); for (const sm of s.mobs || []) { seen.add(sm.id); const cur = this.mobs.get(sm.id); if (!cur) { this.mobs.set(sm.id, { id: sm.id, mkind: sm.mkind, dir: sm.dir, eta0: sm.eta, t0: s.ts }); } else { if (Math.abs(this._mobEta(cur) - sm.eta) > 0.5) { cur.eta0 = sm.eta; cur.t0 = s.ts; } cur.mkind = sm.mkind; cur.dir = sm.dir; } } for (const id of [...this.mobs.keys()]) { if (!seen.has(id)) { this.mobs.delete(id); this._stopMobLoop(id); } } // 冷却恢复(重连 UI/判定用) const me = (s.slots || []).find((x) => x.slot === this.slot); if (me) { const now = performance.now(); if (typeof me.cd === 'number') this._atkReadyAt = now + me.cd * 1000; if (typeof me.skillCd === 'number') { this._skillReadyAt = now + me.skillCd * 1000; if (me.skillCd > 0 && !this._skillAnnounced) this._armSkillReady(me.skillCd * 1000); } } this._refreshLoops(); } // ================================================================ 听觉视野渲染(R3 + §8.2/§8.3) _nowSrv() { return performance.now() + this._srvOffset; } _mobEta(m) { return m.eta0 - (this._nowSrv() - m.t0) / 1000; } _startRenderer() { if (this._renderTimer !== null) return; this._renderTimer = setInterval(() => this._refreshLoops(), LOOP_REFRESH_MS); } /** * 渲染集重估(每 500ms + 事件驱动):eta>6s 静默;≤6s 按 eta 升序取前 3 只 * 起 loop(R3);音量按档(6-4s:0.30 / 4-2.5s:0.55 / <2.5s:0.80);pan=sin(az), * 后半平面 ×0.6(§8.3)。 */ _refreshLoops() { if (this.phase !== 'wave' && this.phase !== 'teach') { this._stopAllLoops(); return; } const cand = [...this.mobs.values()] .map((m) => ({ m, eta: this._mobEta(m) })) .filter((x) => x.eta <= MID_ETA_S && x.eta > 0) .sort((a, b) => a.eta - b.eta) .slice(0, LOOP_MAX); const keep = new Set(cand.map((x) => x.m.id)); for (const id of [...this._loops.keys()]) if (!keep.has(id)) this._stopMobLoop(id); for (const { m, eta } of cand) this._ensureMobLoop(m, eta); } /** 怪物移动 loop(游戏层句柄 + 自建 panner,abyss _startLoop 先例,不占引擎面)。 */ _ensureMobLoop(m, eta) { const az = azOf(m.dir); const pan = panOfAz(az); const bandVol = eta > 4 ? 0.3 : eta > 2.5 ? 0.55 : 0.8; const vol = bandVol * (isBackAz(az) ? 0.6 : 1); const h = this._loops.get(m.id); if (h) { // 同怪在位:原地更新音量/声像,不重触发 h.eta = eta; h.vol = vol; if (h.gain) h.gain.gain.value = this.audio.sfxVolume * vol; if (h.panner) h.panner.pan.value = pan; return; } const handle = { want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind }; this._loops.set(m.id, handle); this.audio._load('sfx/loop_' + m.mkind).then((buf) => { if (!buf || !handle.want || this._loops.get(m.id) !== handle) return; const ctx = this.audio.ctx; if (!ctx) return; const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; const gain = ctx.createGain(); gain.gain.value = this.audio.sfxVolume * vol; src.connect(gain); if (typeof ctx.createStereoPanner === 'function') { const panner = ctx.createStereoPanner(); panner.pan.value = pan; gain.connect(panner); panner.connect(this.audio._sfxBus); handle.panner = panner; } else { gain.connect(this.audio._sfxBus); // 老 Safari 静默降级 } src.start(); handle.src = src; handle.gain = gain; }).catch(() => { /* 资产缺失降级:威胁信息由播报/呼啸承载 */ }); } _stopMobLoop(id) { const h = this._loops.get(id); if (!h) return; h.want = false; if (h.src) { try { h.src.stop(); } catch (_) { /* 已停止 */ } } this._loops.delete(id); } _stopAllLoops() { for (const id of [...this._loops.keys()]) this._stopMobLoop(id); } // ================================================================ R4:定位 one-shot 调度 /** * 定位 one-shot(呼啸/击杀/突入/技能/号角等):同刻 ≤2 并发;同类间隔 * ≥0.15s;droppable(呼啸)队列深 ≤2、排队 ≤1.5s,超出丢弃(怪近后 loop * 已接管信息,呼啸可弃);其余按 FIFO 等位播出。 */ _pos(id, { pan = 0, vol = 1, type = 'misc', droppable = false } = {}) { const now = performance.now(); this._posActive = this._posActive.filter((a) => a.until > now); const lastType = this._posTypeAt[type] ?? -1e9; if (this._posActive.length < ONESHOT_MAX && now - lastType >= ONESHOT_SPACING_MS) { this._posFire(id, pan, vol, type, now); return; } if (droppable) { if (this._posQueue.filter((q) => q.type === 'howl').length >= HOWL_QUEUE_MAX) return; // 超出丢弃 } this._posQueue.push({ id, pan, vol, type, droppable, queuedAt: now }); this._armPosPump(); } _posFire(id, pan, vol, type, now) { this.audio.play(id, { volume: vol, pan }); const durMs = (SFX_DUR[id.slice(id.lastIndexOf('/') + 1)] ?? 0.5) * 1000; this._posActive.push({ until: now + durMs }); this._posTypeAt[type] = now; this._posLog.push({ id, pan, vol, t: now }); } _armPosPump() { if (this._posPumpTimer !== null) return; this._posPumpTimer = setTimeout(() => { this._posPumpTimer = null; const now = performance.now(); this._posActive = this._posActive.filter((a) => a.until > now); const rest = []; for (const q of this._posQueue) { if (q.droppable && now - q.queuedAt > HOWL_QUEUE_TTL_MS) continue; // 过期呼啸丢弃 const lastType = this._posTypeAt[q.type] ?? -1e9; if (this._posActive.length < ONESHOT_MAX && now - lastType >= ONESHOT_SPACING_MS) { this._posFire(q.id, q.pan, q.vol, q.type, now); } else { rest.push(q); } } this._posQueue = rest; if (this._posQueue.length) this._armPosPump(); }, 100); } // ================================================================ R2:bot 喊话调度 /** * bot 喊话走 sfx 层(带 pan,与旁白并发):同刻在播 ≤1 条;待播队列深 ≤1 * (新到顶掉未播的过期句);旁白在播时延迟投递 ≤2s,超时丢弃;呼救语义 * (help_call/help_ack)优先级最高(待播宽限更长,不被氛围句顶掉)。 */ _npcSchedule(id, pan, priority, onDone) { const now = performance.now(); if (now >= this._npcBusyUntil && !this.audio.isVoicePlaying()) { this._npcFire(id, pan, onDone); return; } this._npcPending = { id, pan, onDone, expireAt: now + (priority ? NPC_PRIO_TTL_MS : NPC_VOICE_DELAY_MS), }; this._armNpcPump(); } _npcFire(id, pan, onDone) { const now = performance.now(); this._npcPending = null; this.audio.play(id, { volume: NPC_VOL, pan }); // 在播时长取解码后 buffer(精确互不叠),加载失败退兜底值 this._npcBusyUntil = now + NPC_LINE_FALLBACK_DUR_S * 1000; const rec = { id, pan, t: now, dur: NPC_LINE_FALLBACK_DUR_S }; this._npcLog.push(rec); this.audio._load(id).then((buf) => { if (!buf) return; rec.dur = buf.duration; this._npcBusyUntil = Math.max(this._npcBusyUntil, now + buf.duration * 1000); }).catch(() => { /* 静默 */ }); if (onDone) setTimeout(onDone, NPC_LINE_FALLBACK_DUR_S * 1000); } _armNpcPump() { if (this._npcPumpTimer !== null) return; this._npcPumpTimer = setTimeout(() => { this._npcPumpTimer = null; const p = this._npcPending; if (!p) return; const now = performance.now(); if (now > p.expireAt) { this._npcPending = null; return; } // 超时丢弃 if (now >= this._npcBusyUntil && !this.audio.isVoicePlaying()) { this._npcFire(p.id, p.pan, p.onDone); return; } this._armNpcPump(); }, 200); } _dropAllNpc() { this._npcPending = null; this._npcBusyUntil = 0; } // ================================================================ 语音纪律(R1) /** * 语音链:整句链播(onComplete 串行);protect 链不可被警报顶(教学/结算/ * 波次播报);queueFirst 时首句排队等当前对白(状态链/威胁播报礼仪); * 单句看门狗兜底:资产缺失/被静默跳过时链照样推进,绝不卡死。 */ _playChain(ids, { protect = null, onDone = null, queueFirst = false } = {}) { if (!ids.length) { if (onDone) onDone(); return; } const token = ++this._chainSeq; if (protect) this._protect = protect; const step = (i) => { if (token !== this._chainSeq) return; // 链被作废(硬播/打断/关菜单) if (i >= ids.length) { if (protect && this._protect === protect) this._protect = null; if (onDone) onDone(); return; } let done = false; let guardId = null; const finish = () => { if (done) return; done = true; if (guardId !== null) clearTimeout(guardId); step(i + 1); }; const guard = () => { if (done || token !== this._chainSeq) return; if (this.audio.isVoicePlaying()) { guardId = setTimeout(guard, 2000); return; } finish(); // 资产缺失等静默跳过:看门狗推进 }; guardId = setTimeout(guard, VOICE_GUARD_MS); this.audio.play('voice/' + ids[i], { queue: queueFirst && i === 0, onComplete: finish, }); }; step(0); } /** * 警报类播报(breach/breach_heavy/danger_low/net_*):教学/结算/波次播报链 * 在播时排队(不可顶,R1);否则硬播顶掉状态/播报类(顶句作废旧链 = * 引擎"打断即作废语音链"语义)。 */ _alert(id) { if (this._protect || this.phase === 'teach') this.audio.play('voice/' + id, { queue: true }); else this._playChain([id]); } /** 无效手势:error 震(全档)+ full 档 gd_invalid(onceKey 同局 1 次)。 */ _invalid() { vibrate('error'); this.guide.error({ voice: 'gd_invalid', onceKey: 'hunt_invalid', skipHaptic: true }); } /** 看门狗(§11):WAVE/LOBBY 无操作 [10s,25s] → gd_idle / null;TEACH 逐步 [8s] 纯 nudge。 */ _rearmWatchdog() { if (this._menuOpen) return; if (this.phase === 'wave' || this.phase === 'lobby') { this.guide.watchdog({ at: [10000, 25000], say: ['gd_idle', null], cond: () => (this.phase === 'wave' || this.phase === 'lobby') && !this._menuOpen, }); } else if (this.phase === 'teach' && this._tut >= 0) { this._armTutWatchdog(this._tut); } } /** 教学逐步看门狗:8s nudge 纯震(教学句不重播);卡 30s 由服务器自动放过推进。 */ _armTutWatchdog(step) { this.guide.watchdog({ at: [8000], say: [null], onEscalate: () => vibrate('confirm'), cond: () => this.phase === 'teach' && this._tut === step && !this._tutDoneWaiting, }); } /** 教学跳步:作废旧链 → 播新教学句(保护链)→ 武装该步看门狗。 */ _tutGoto(step, ids) { this._chainSeq++; this.audio.stopVoice(); this._tut = step; if (ids && ids.length) { this._playChain(ids, { protect: 'teach', onDone: () => this._armTutWatchdog(step) }); } else { this._armTutWatchdog(step); } } /** 教学完成/跳过:tutDone 写入进度,同场等 ev wave 1 接续第 1 波(§5)。 */ _finishTeach() { this._tut = -1; this._tutDoneWaiting = true; this._progress.tutDone = true; this._writeProgress(false); // 立即落盘(不进结算也生效) } // ================================================================ 花名册 _applyRoster(roster, initial) { const old = this._roster; this._roster = new Map(roster.map((r) => [r.slot, r])); if (initial || !old.size) return; for (const r of roster) { const o = old.get(r.slot); if (!o && this.phase === 'lobby') { this._pos('sfx/join_chime', { pan: 0, vol: 1, type: 'chime' }); // 队友加入 } else if (o && !o.bot && r.bot && r.slot !== this.slot) { this._alert('net_to_bot'); // 他人槽位转 bot(§6.6) } } } _teamCount() { return this._roster.size || 3; } _charOf(slot) { const r = this._roster.get(slot); return r ? r.char || null : null; } _sectorOf(slot) { const r = this._roster.get(slot); return r && r.sector ? r.sector : []; } // ================================================================ 进度 / 排行榜(§12) _loadProgress() { try { const raw = localStorage.getItem(SAVE_KEY); if (raw) this._progress = this._normalizeProgress(JSON.parse(raw)); } catch (_) { /* 损坏回退默认 */ } } _normalizeProgress(d) { return { best: d && d.best && typeof d.best === 'object' ? d.best : null, runs: (d && d.runs) | 0, totalKills: (d && d.totalKills) | 0, tutDone: !!(d && d.tutDone), }; } /** 登录态拉回服务器进度(服务器为准);失败静默用本地。 */ _pullServerProgress() { let auth = null; try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ } if (!auth || !auth.token) return; fetch('/api/progress?game=hunt', { headers: { Authorization: `Bearer ${auth.token}` } }) .then((r) => (r.ok ? r.json() : null)) .then((j) => { if (j && j.data) this._progress = this._normalizeProgress(j.data); }) .catch(() => { /* 离线静默 */ }); } /** * 终局写入(每局一次,胜利/失守都记 runs/totalKills;best 只被胜利局按 * 评级 > 营地余耐久 > 击杀 的序更新,失守不入榜 §15 裁决 9)。 * summary 生成规则(§12,大厅卡片直接显示): * 有 best:`最佳评级{grade} · 击杀{kills} · 营地余{durLeft} · 共{runs}局` * 无 best:`共{runs}局 · 还没守到天亮过` */ _saveProgress(ev) { const p = this._progress; p.runs += 1; p.totalKills += this._myKills; if (ev.win && ev.grade) { const cand = { grade: ev.grade, kills: this._myKills, durLeft: ev.dur, at: new Date().toISOString() }; if (this._betterThan(cand, p.best)) p.best = cand; } this._writeProgress(true); } _betterThan(a, b) { if (!b) return true; const ga = GRADE_RANK[a.grade] || 0; const gb = GRADE_RANK[b.grade] || 0; if (ga !== gb) return ga > gb; if (a.durLeft !== b.durLeft) return a.durLeft > b.durLeft; return a.kills > b.kills; } _summaryText() { const p = this._progress; if (!p.best) return `共${p.runs}局 · 还没守到天亮过`; return `最佳评级${p.best.grade} · 击杀${p.best.kills} · 营地余${p.best.durLeft} · 共${p.runs}局`; } /** 写进度:localStorage 必写;登录态 PUT /api/progress(必带 summary,00 §4.3)。 */ _writeProgress(withPut) { const p = this._progress; const data = { schema: 1, game: 'hunt', updatedAt: new Date().toISOString(), summary: this._summaryText(), best: p.best, runs: p.runs, totalKills: p.totalKills, tutDone: p.tutDone, }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ } if (!withPut) return; try { const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); if (!auth || !auth.token) return; fetch('/api/progress', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + auth.token, }, body: JSON.stringify({ game: 'hunt', data }), }).catch(() => { /* 离线静默 */ }); } catch (_) { /* 静默 */ } } // ================================================================ 残局收拾 / 回大厅 /** 每局状态复位(新 joined 受理时;重连回槽不复位)。 */ _resetRoundState() { this.mobs.clear(); this._stopAllLoops(); this._myKills = 0; this.wave = 0; this.dur = 10; this._atkReadyAt = 0; this._skillReadyAt = 0; this._skillAnnounced = true; if (this._skillReadyTimer !== null) { clearTimeout(this._skillReadyTimer); this._skillReadyTimer = null; } this._calloutReadyAt = 0; this._dangerAnnounced = false; this._lastBreachVoiceAt = -1e9; this._tut = -1; this._tutMiss = 0; this._tutDoneWaiting = false; this._endChain = null; this._endAwaiting = false; this._posQueue.length = 0; this._posActive = []; this._dropAllNpc(); } _teardownRound() { this._chainSeq++; this._protect = null; this.audio.stopVoice(); this._closeConn(); this._resetRoundState(); } _goHome() { if (this._goingHome) return; this._goingHome = true; if (this._conn && this._joined) this._send({ t: 'leave' }); // 主动退出无 30s 宽限(§6.2) this.stop(); this._navigatedHome = '../'; try { window.location.href = '../'; } catch (_) { /* mock 环境无 location */ } } }