Files
SoundGame/web/hunt/game.js
T
xsl d78df35b5c fix(hunt): v2 九修 S1-S9 落地——10 人并发模拟迭代裁定(同阵容复测:去留 6/10→10/10)
研究闭环: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
2026-09-19 11:06:18 +08:00

1455 lines
55 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._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 */ }
}
}