研究闭环:v1 三轮 28 局四死因(失衡/社交真空/交互摩擦/观光羞辱)→ 四视角分析 → synthesis S1-S9 裁定 → 实施 → 同 10 人配置复测两轮 20 局。依据 research/hunt-sim/ (synthesis.md / metrics-v2.md / report.md),设计登记 23-hunt.md §16。 服务端 hunt-room.js: - S5 协作人声包:真人座位角色接线(杀熊/三连杀/旋风斩≥3 播角色句、greet 全座位); 满编真人房呼喊 2.5s 无 bot 应答 → 邻座角色代答 help_ack;喊话预算 10s 窗 2→3 - S7 难度重构:waveCounts 4 人房 ×4/3;alignPincers 第 3-5 波齐压拍(跨座位同种类 同秒进内圈,种子确定性保持);mobYielded bot 对人类防区怪让位 2s(只约束 bot, 不拦真人避免挥空真话说谎);gradeForEx 双轴评级(S=耐久≥9 且 ≥2 人各 6 杀, 单人带飞局上限 A) 客户端 game.js: - S1 WAVE 下滑恒出击(无怪=空挥真话),菜单收窄到波间休整窗(E3 修订)—— v1 误触菜单单人单局最多 14 次清零 - S2 冷却首拦才震 + 就绪 beat 轻叮(v1 全场 666 次乱滑连震) - S3 0 杀不播 kill_band0(「你还没开张」下架;复测 P08 0杀→12杀) - S4 看门狗重定义:本波出过手一级语音不触发、语音闲置每局 ≤1、一级降纯震 - S5③ help_call/help_ack 永不超时丢弃 - S6 后半平面 loop 低通 750Hz 第二线索(N/S 判读 63/69%→75/81%) - S8 教学作答步(T1-T3)下滑不再误跳 - S9 状态链按变化播(「固若金汤」复读归零) 测试同步:hunt-sim A1/A2/A12 断言更新(真人 greet/波次缩放/预算 3)、 playtest Block 3 按 S1 新语义改写(战斗中下滑=空挥 + 波间菜单) 回归:hunt-sim 16/16 + playtest 213 + guide 132 + engine-voice 19 全绿; balance-scan:挂机局 5/8 落 C/失守带(v1 恒满血)、optimal 单人带飞封顶 A 遗留:营地零损耗未破(18/20 局余10)——v3 头号,参数方案 report.md §5
1455 lines
55 KiB
JavaScript
1455 lines
55 KiB
JavaScript
/**
|
||
* 《围猎》客户端 —— 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._atkBlockedThisCd = false; // v2 S2:每冷却窗首拦才震
|
||
this._atkReadyCueTimer = null;
|
||
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._actedThisWave = false; // v2 S4:每波重新计「在场证明」
|
||
this._restUntil = 0;
|
||
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 myBand = kills > 0 ? [killBandId(kills)] : []; // v2 S3:0 杀不播「你还没开张」(沉默优于羞辱)
|
||
const chain = win
|
||
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank', 'end_again']
|
||
: ['end_lose', ...myBand, '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) {
|
||
// v2 S8:跳过保护——作答步(T1/T2/T3)内下滑 = 答错处理(不再误跳教学,
|
||
// P04 教学局手滑跳过记恨三局);只有 T0/T4 非作答态下滑才跳过
|
||
if (g === 'down' && (this._tut === 0 || this._tut === 4 || this._tut === -1)) { // 跳过教学(§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) {
|
||
// v2 S1(E3 修订):下滑恒出击(无怪=空挥真话 atk_swish),菜单只在波间
|
||
// 休整窗(waveclear 后 8s)开放——修复战斗中误触菜单(R1 三人中招 14 次/局)
|
||
const now = performance.now();
|
||
const inRest = now < this._restUntil;
|
||
if (g === 'down' && inRest) { this._openMenu(); return; }
|
||
if (g === 'up' && !this._arcHasTarget(ARCS.up)) { this._statusChain(); 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(); // 左右滑
|
||
}
|
||
|
||
// ================================================================ 战斗动作(客户端意图)
|
||
|
||
/** 出击:冷却内仅每个冷却窗首拦震一次(v2 S2:连拦静默),就绪轻叮一记。 */
|
||
_attackDir(dir) {
|
||
const now = performance.now();
|
||
if (now < this._atkReadyAt) {
|
||
if (!this._atkBlockedThisCd) {
|
||
this._atkBlockedThisCd = true; // 首拦才震:连滑不再连环 error 震(R1 全场 666 次)
|
||
vibrate('error');
|
||
this._armAtkReadyCue();
|
||
}
|
||
return;
|
||
}
|
||
this._atkBlockedThisCd = false;
|
||
this._atkReadyAt = now + ATTACK_CD_MS;
|
||
this._actedThisWave = true;
|
||
this._sendAct('attack', dir);
|
||
this._pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk' });
|
||
}
|
||
|
||
/** 冷却就绪提示:本冷却窗内被拦过 → 就绪时刻轻叮一记(beat 震,无语音占用)。 */
|
||
_armAtkReadyCue() {
|
||
if (this._atkReadyCueTimer !== null) clearTimeout(this._atkReadyCueTimer);
|
||
const wait = Math.max(0, this._atkReadyAt - performance.now());
|
||
this._atkReadyCueTimer = setTimeout(() => {
|
||
this._atkReadyCueTimer = null;
|
||
if (this._atkBlockedThisCd && (this.phase === 'wave' || this.phase === 'teach')) {
|
||
this._atkBlockedThisCd = false;
|
||
vibrate('beat');
|
||
}
|
||
}, wait);
|
||
}
|
||
|
||
/** 呼喊支援:节流 5s/人;本机播自己座位角色 help_call(中央;座 3=哨音带 pan)。 */
|
||
_callout() {
|
||
const now = performance.now();
|
||
if (now < this._calloutReadyAt) { vibrate('error'); return; }
|
||
this._calloutReadyAt = now + CALLOUT_THROTTLE_MS;
|
||
this._actedThisWave = true;
|
||
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._actedThisWave = true;
|
||
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 组):v2 S9 按变化播——与上次播报一致的段跳过(「固若金汤」复读消除);
|
||
* 全部无变化 → 仅 confirm 轻震(沉默优于复读)。 */
|
||
_statusIds() {
|
||
const ids = [
|
||
hpBandId(this.dur),
|
||
stWaveId(this.wave || 1),
|
||
this._teamCount() >= 4 ? 'team_4' : 'team_3',
|
||
killBandId(this._myKills),
|
||
].filter((id) => id !== 'kill_band0' || this._myKills > 0); // v2 S3:0 杀不播「你还没开张」
|
||
const changed = ids.filter((id) => !this._lastStatusSaid || !this._lastStatusSaid.includes(id));
|
||
this._lastStatusSaid = ids;
|
||
return changed;
|
||
}
|
||
|
||
_statusChain(onDone = null) {
|
||
const ids = this._statusIds();
|
||
if (!ids.length) { vibrate('confirm'); if (onDone) onDone(); return; }
|
||
this._playChain(ids, { 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._restUntil = performance.now() + 8000; // v2 S1:波间休整窗内下滑=菜单
|
||
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++;
|
||
this._actedThisWave = true;
|
||
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,
|
||
muffled: isBackAz(az), // v2 S6:身后怪标记(低通第二线索)
|
||
};
|
||
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);
|
||
let tail = gain;
|
||
if (handle.muffled && typeof ctx.createBiquadFilter === 'function') {
|
||
// v2 S6:后半平面低通 750Hz——音色发闷叠加响度 ×0.6,N/S 轴判读从
|
||
// 「纯响度猜」变「响度+音色双线索」(R1 实测 N/S 可命中仅 63/69%)
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.value = 750;
|
||
tail.connect(lp);
|
||
tail = lp;
|
||
}
|
||
if (typeof ctx.createStereoPanner === 'function') {
|
||
const panner = ctx.createStereoPanner();
|
||
panner.pan.value = pan;
|
||
tail.connect(panner);
|
||
panner.connect(this.audio._sfxBus);
|
||
handle.panner = panner;
|
||
} else {
|
||
tail.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,
|
||
// v2 S5③:呼救/应答语义永不超时丢弃(等待投递);氛围句才受 ≤2s 延迟上限
|
||
expireAt: priority ? Infinity : now + 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 + v2 S4 重定义):WAVE 内本波出过手 → 一级语音永不触发(等待是
|
||
* 合法玩法,不是挂机);一级降纯震;语音闲置每局 ≤1 次;重教句只留在教学局。
|
||
* LOBBY 照旧(room_wait 语境合理)。 */
|
||
_rearmWatchdog() {
|
||
if (this._menuOpen) return;
|
||
if (this.phase === 'wave') {
|
||
const sayIdle = !this._actedThisWave && !this._idleVoiceSaid;
|
||
if (sayIdle) this._idleVoiceSaid = true; // 每局最多一次语音闲置提醒
|
||
this.guide.watchdog({
|
||
at: [10000, 25000],
|
||
say: [sayIdle ? 'gd_idle' : null, null],
|
||
cond: () => this.phase === 'wave' && !this._menuOpen,
|
||
});
|
||
} else if (this.phase === 'lobby') {
|
||
this.guide.watchdog({
|
||
at: [10000, 25000],
|
||
say: ['gd_idle', null],
|
||
cond: () => 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();
|
||
this._actedThisWave = false; // v2 S4/S9:每局复位(在场证明/状态链去重/闲置语音额度)
|
||
this._idleVoiceSaid = false;
|
||
this._lastStatusSaid = null;
|
||
this._restUntil = 0;
|
||
this._atkBlockedThisCd = false;
|
||
}
|
||
|
||
_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 */ }
|
||
}
|
||
}
|