Files
SoundGame/web/hunt/game.js
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

1385 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《围猎》客户端 —— 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°<az<270°)loop/呼啸
* 音量 ×0.6("身后发闷")。喊话 pan 取座位防区中心(§3.3)。
*
* 设计文档张力裁决(实施记录,比照 space/rhythm 下滑折中先例):23 §2.1 同时
* 列出「四向滑=出击」与「上滑=状态链 / 下滑=菜单」,直接冲突。落地为**语境
* 折中**:WAVE 中上/下滑的弧内有可打怪(eta≤4 活怪)→ 出击;无目标时
* 上滑=状态链、下滑=菜单(左/右滑恒=出击,挥空有 atk_swish 真话反馈)。
* TEACH 教学段按 §4.3「上滑=状态链(T4 起)」:T1/T3 步骤作答的四向滑恒发
* act(保证错向/挥空重教路径),T4 起上滑=状态链,下滑恒=跳过教学。
*
* 传输可注入:构造 opts.transport(url, handlers) → { send(str), close() },
* 缺省用浏览器 WebSocket;playtest 用内存回路 mock 按 §6 协议注入事件流。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
const SAVE_KEY = 'soundgame.progress.hunt';
const AUTH_KEY = 'soundgame.auth';
const WS_PATH = '/ws/hunt';
const PROTO_VER = 1;
// ---------------- 方位 / 声像(§8.3) ----------------
const DIR_AZ = { N: 0, NE: 45, E: 90, SE: 135, S: 180, SW: 225, W: 270, NW: 315 };
const SWIPE_BASE = { up: 'N', right: 'E', down: 'S', left: 'W' };
const ARCS = {
up: ['NW', 'N', 'NE'],
right: ['NE', 'E', 'SE'],
down: ['SE', 'S', 'SW'],
left: ['SW', 'W', 'NW'],
};
const azOf = (dir) => 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_<char>_<line>。 */
_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 */ }
}
}