Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1841 lines
73 KiB
JavaScript
Raw Permalink 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/26-audio-
* interaction-playbook.md 深度重构(设计单正文见 docs/design/23-hunt.md 附录 C,
* 施工接线记录见 docs/design/26-apply-hunt.md)。
*
* 玩法核心不动:服务端权威联机(join/act/leave + joined/ev/snap/err,拥挤控制
* R1-R8 全保留)、三源声像(怪 loop pan + 生成呼啸 + bot 喊话防区 pan)、5 波
* 结构、断线 30s 回槽。本地交互层换 26 号模式:
*
* - 发言权(§4.1):WAVE/TEACH 战斗 = ACTION(怪 loop/BGM 就是状态声,输入直接
* 给游戏);波次播报/结算/教学旁白 = SPEAKING(音乐压低跨链),播完 yieldTurn
* 交出话轮(LOBBY / 波间 8s 休整 / END / LOST / 菜单);不存在安静态。
* - 手势(§4.3 裁定:四向滑动是玩法 → 帮助换到长按):
* 四向滑 = 出击(90° 弧,up/down 永不被阶梯锁);单击 = 呼喊(5s 节流);
* 长按 = 「我在哪」(耐久档 + 波次档 + 一个动作,≤20 字);双击 = 阶段 3 起
* 重听威胁档(threat_*),阶段 4(大厅全部解锁/专属教学)双击 = 旋风斩,
* 冷却中仍回威胁档;下滑 = 菜单(仅 TITLE/LOBBY/波间休整/END;WAVE 内
* 下滑恒出击南弧,永不弹菜单)。
* - 等待点(§4.4 三档提示):intro(tap) / teach_hit(滑) / teach_call(tap) /
* teach_help(长按) / lobby(tap) / wave(仅 earcon,本波已出手则永不催)。
* - 迷茫(§4.5):乱滑/连无效喂信号;TEACH/LOBBY 走等待点三级接管;WAVE 走
* 本作接管(L1「朝最响的滑。」→ L2 只放行该弧 → L3 替玩家出刀清一只近狼)。
* - 断点复述(§4.12):联机局不按单机节点存档——中断线 30s 回槽播 net_back
* 复述句;一局内退出(菜单回大厅/房灭)标记 unfinished,下次进 TITLE 一句
* sys_recap 复述 + summary 保留。
*
* 状态 × 手势矩阵(新手期,完整版见 23-hunt.md 附录 C §4):
* | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) | 连点/乱滑 |
* |------------|----------------|-----------------------------|-----------|--------------|------------------|
* | TITLE | 匹配 | invalid(gd_invalid) | 帮助(最佳) | 按单击 | 防连点 busy |
* | MATCHING | busy | busy | busy | busy | busy |
* | LOBBY | 开局(act start)| invalid | 帮助(人数) | 重听 room_wait| 计入迷茫 |
* | 教学作答 | 按步骤(T2/T3=呼喊)| 按步骤出击(T1 起恒发 act) | 帮助 | 按单击 | 迷茫;挥空真话 |
* | WAVE 战斗 | 呼喊(节流内 invalid)| 出击;冷却内 invalid 回执| 帮助(状态) | 按单击=呼喊 | 计入迷茫不放技能 |
* | 波间休整 | invalid | invalid(down=菜单) | 帮助 | 按单击 | — |
* | 旁白在播 | 跳句 | 打断并出击 | 打断,帮助 | 按单击 | 第一下 |
* | 结算链内 | 跳句(尾句 critical=busy)| invalid | 帮助(最佳) | 按单击 | 死窗 invalid 回执 |
* | 结算链完 | 再来一局 | invalid | 重听最佳 | 重听 end_again| 第一下 |
* | 菜单(非WAVE)| 确认当前项 | 左右=切项;上下=关 | 重听菜单 | 按单击 | busy |
*
* 传输可注入:构造 opts.transport(url, handlers) → { send(str), close() },
* 缺省用浏览器 WebSocket;playtest 用内存回路 mock 按 §6 协议注入事件流。
*/
import { Game } from '../engine/game.js';
import { Floor } from '../engine/floor.js';
import { Receipts } from '../engine/receipts.js';
import { Confusion } from '../engine/confusion.js';
import { InvalidHints, WaitPoint, markHintSaid, hintSaidRecently } from '../engine/hints.js';
import { GestureLadder } from '../engine/novice.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));
/** 罗盘方位 → 所属 90° 弧的滑向基向(迷茫 L2/L3 按最近怪弧放行/出刀用)。 */
function baseOfDir(dir) {
for (const k of Object.keys(ARCS)) if (ARCS[k].includes(dir)) return SWIPE_BASE[k];
return 'N';
}
/** 座位防区中心方位角:防区为连续罗盘弧,取各方位角均值(§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 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 WAVE_IDLE_FIRST_MS = 8000; // wave 等待点:本波未出手的首次 earcon(§5 wave 行)
const WAVE_IDLE_REPEAT_MS = 12000;// 之后只喂迷茫静默信号(不升语音档)
/** 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');
/** 无效操作解释句(原则 7:每「状态 × 手势」只解释一次,pipeline 调用)。 */
const INVALID_LINES = {
'locked:longpress': null, // 长按是本作核心手势(帮助),不会走到这
'teach:tap': 'voice/inv_swipe', // T1 期点按:先朝声音滑(附录 C §6 做错列)
'wave:tap': 'voice/call_cd', // 呼喊节流 5s(附录 C §7 失败表)
'wave:swipe': 'voice/atk_cd', // 出击冷却 2.5s(附录 C §7 失败表)
'rest:tap': 'voice/inv_rest',
'rest:swipe': 'voice/inv_rest',
};
/** 默认浏览器传输:原生 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);
// ---- 26 号交互层(engine 六模块) ----
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({ onConfused: (lvl) => this._onConfused(lvl) });
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.audio.setRateBoostEnabled(false); // 长按=帮助(核心手势),不触发旁白快进
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._menuArmedHome = false; // 菜单「回大厅」二次确认 armed(原则 6 破坏性操作)
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; // 每冷却窗首拦才震(连拦静默震,26 号回执仍出声)
this._atkReadyCueTimer = null;
this._skillReadyAt = 0;
this._skillAnnounced = true; // 每轮冷却完成只播一次 skill_ready
this._skillReadyTimer = null;
this._calloutReadyAt = 0;
// 语音纪律(R1):保护链(教学/结算)期间的警报排队
this._protect = null; // 'teach' | 'settle'(链自然播完在 onDone 清除)
this._pendingAlerts = []; // 保护链期间积压的警报 id(链尾冲刷)
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 }
// 教学(26 号重排:T0 点一下 → T1 滑砍 → T2 呼喊 → T3 长按问路;无旋风斩)
this._tut = -1; // 0..3,-1 = 非教学
this._tutDoneWaiting = false; // 教学完成,等 ev wave 1
this._goalFired = false; // ladder.onGoal()(第一次击杀)已上报
// WAVE 等待点(§5 wave 行):本波未出手的 earcon + 静默喂迷茫
this._waveIdleTimer = null;
this._waveIdleChimed = false;
this._actedThisWave = false;
this._restUntil = 0;
this._restrictArc = null; // 迷茫 L2:WAVE 只放行的出击基向(N/E/S/W)
// 26 号发言权自愈(实例级包装,不改引擎):ACTION 语境(TITLE/对局)里被
// 打断的反馈/帮助句会把 floor 留在 BUSY(打断不触发 onDone),下一次手势将
// 被 busy 吞掉。本作 TITLE/WAVE 无合法长驻 BUSY——输入先就地回 ACTION。
const origHandleInput = this.floor.handleInput.bind(this.floor);
this.floor.handleInput = (g) => {
if (this._inActionCtx() && this.floor.state === 'BUSY' && !this.floor.speaking) {
this.floor.enterAction();
}
return origHandleInput(g);
};
// 断线重连
this._netDown = false;
this._netDownAt = 0;
this._netLostSaid = false;
this._reconnectTimer = null;
this._openGuardTimer = null;
// 结算
this._graceUntil = 0;
this._endChain = null;
// 进度
this._progress = { best: null, runs: 0, totalKills: 0, tutDone: false, skillUnlocked: false };
}
// ================================================================ 管道接线(index.html / playtest 读)
/** 管道选项(26 号 §4.3 裁定):四向滑动 + 长按都是本作核心手势。 */
get PIPELINE_OPTS() { return { swipeCore: true, longpressCore: true }; }
/** 双击窗口分档快照(与 index.html 的条件 attach 同一时点读取):
* ladder.stage()>=3 = 双击已识别(累计 3 局或大厅全部解锁),本会话双击 =
* 重听威胁档(阶段 3)/ 旋风斩(阶段 4 unlockAll 后)。此前双击不存在。 */
get DT_CORE() { return this.ladder.stage() >= 3; }
// ================================================================ 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._protect = null;
this._pendingAlerts.length = 0;
this.floor.interrupt();
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._stopWaveIdleFeed();
this._stopAllLoops();
this._closeConn();
this.audio.stopAll();
}
// ================================================================ 手势入口(pipeline 之后)
/**
* 26 号收口:返回 'ok' | 'hold' | 'invalid' | 'silent-ok'(本作出击/呼喊都有
* 明显内容音,但仍统一走 ok 回执——R4 队列可能延迟内容音 >100ms,回执优先)。
*/
onGesture(g) {
if (this._goingHome) return 'ok';
// H1 结算死窗:终局前后在途的 ghost 手势不执行(invalid 回执,禁止静默)
if (performance.now() < this._graceUntil) return 'invalid';
if (g === 'longpressend' || g === 'tapcancel') return 'ok'; // 手势收尾噪声
if (this._menuOpen) return this._onMenuGesture(g);
switch (this.phase) {
case 'title': return this._onTitleGesture(g);
case 'matching': return 'invalid'; // 匹配中无可做事(busy 回执由 floor 出)
case 'lobby': return this._onLobbyGesture(g);
case 'teach': return this._onTeachGesture(g);
case 'wave': return this._onWaveGesture(g);
case 'end': return this._onEndGesture(g);
case 'lost': return this._onLostGesture(g);
default: return 'invalid';
}
}
stateName() {
if (this._menuOpen) return 'menu';
if (this.phase === 'wave') return this._inRest() ? 'rest' : 'wave';
return this.phase; // title/matching/lobby/teach/end/lost
}
invalidHint(state, g) {
if (state === 'locked') return INVALID_LINES[`locked:${g}`];
if (g === 'tap') {
if (INVALID_LINES[`${state}:tap`]) return INVALID_LINES[`${state}:tap`];
}
if (g === 'up' || g === 'down' || g === 'left' || g === 'right') {
if (INVALID_LINES[`${state}:swipe`]) return INVALID_LINES[`${state}:swipe`];
if (state === 'wave' || state === 'teach') return INVALID_LINES['wave:swipe']; // 出击冷却
}
return 'voice/gd_invalid';
}
// ================================================================ 状态进入
_enterTitle() {
this.phase = 'title';
this.audio.play('bgm/menu');
this.floor.enterAction(); // BGM 即状态声(无等待点;单击=匹配)
// 断点复述(§4.12):一局内退出 → 下次进 TITLE 一句话复述 + summary 保留
if (this._progress.unfinished) {
this._progress.unfinished = false;
this._writeProgress(false);
this.floor.speak(['voice/sys_recap'], {
onDone: () => { if (this.phase === 'title' && !this.floor.speaking) this.floor.enterAction(); },
});
}
}
_enterMatching() {
this._teardownRound();
this.phase = 'matching';
this.audio.play('bgm/menu');
this.floor.leaveAction(); // BUSY:等服务器 joined,手势给 busy 回执
// A 组触发口径「TITLE 单击后/教学房 T0」:首局教学房开场句在 T0 播(先动手,
// 不念规则);二周目起匹配等待时播 intro 场景句(等待态的填充,可跳句)
if (this._progress.tutDone) {
this.floor.speak(['voice/intro_1', 'voice/intro_2'], {
onDone: () => { if (this.phase === 'matching') this.floor.leaveAction(); },
});
}
this._connect();
}
_enterLobby() {
this.phase = 'lobby';
this.audio.play('bgm/menu');
// 阶段 4 专属教学(附录 C §3):首次进入已识别双击的大厅,教旋风斩一句并解锁
const teachSkill = this.ladder.stage() >= 3 && !this._progress.skillUnlocked;
if (teachSkill) {
this._progress.skillUnlocked = true;
this._writeProgress(false); // 立即落盘:此后本机双击=旋风斩(冷却中回威胁档)
}
const chain = teachSkill
? ['voice/room_wait', 'voice/skill_teach']
: ['voice/room_wait'];
this.floor.speak(chain, {
onDone: () => {
if (this.phase !== 'lobby') return;
this.floor.yieldTurn(this._wp({
id: 'lobby', expected: ['tap'],
full: 'voice/room_wait', short: 'voice/room_wait_short',
silenceMs: [8000, 12000], // 附录 C §5:8s 提示;12s 服务器到点自动开局
onDemo: () => this._sendAct('start'), // L3 替点开局
}));
},
});
}
_enterTeach() {
this.phase = 'teach';
this._tut = 0;
this._tutDoneWaiting = false;
this.audio.play('bgm/hunt');
this._startRenderer();
// §4.10 首 3 分钟:第一句就让玩家动手(critical,sys_first_tap ≤1s)——
// 旧 intro_1/intro_2/tut_rule 规则总述废除(反模式 6:开场先念规则)
this._speakTeach(['voice/sys_first_tap'], this._introWp());
}
/**
* 进波次(开局/波间/重连恢复)。from: 'lobby' | 'teach' | 'wave' | 'resume'。
* 开局链:lobby → room_start + wave_band1;teach → wave_band1(tut_done 已衔接);
* 第 2 波起只播 wave_bandN;先号角后播报(§3.4)。对局 = ACTION:播报链可被
* 出击打断(打断并执行),播完回 ACTION。
*/
_enterWave(n, from) {
const wasInWave = this.phase === 'wave';
this.phase = 'wave';
this._tut = -1;
this._tutDoneWaiting = false;
this.wave = n;
this._actedThisWave = false; // 每波重新计「在场证明」(§5 wave 行)
this._restUntil = 0;
this._restrictArc = null;
if (this._menuOpen) this._closeMenu(); // 菜单只存在于非 WAVE(附录 C §4)
this.audio.play('bgm/hunt');
if (!wasInWave) this._startRenderer();
vibrate('heartbeat'); // §2.1 震动总表:波次开始
this._pos('sfx/horn_wave', { pan: 0, vol: 1, type: 'horn' });
this.floor.enterAction(); // 战斗音即状态声
this._startWaveIdleFeed();
this._flushAlerts();
const band = 'wave_band' + Math.min(5, Math.max(1, n));
const chain = (n === 1 && from === 'lobby') ? ['voice/room_start', 'voice/' + band] : ['voice/' + band];
this.floor.speak(chain, {
onDone: () => { if (this.phase === 'wave' && !this.floor.speaking) this.floor.enterAction(); },
});
}
_enterEnd(ev) {
this.phase = 'end';
this._menuOpen = false; // 终局强制收菜单 overlay(E3 不暂停期间菜单可能被开着)
this._graceUntil = performance.now() + END_GRACE_MS; // H1 死窗
this._srvPhase = 'end';
this.dur = ev.dur;
const kills = (ev.kills && ev.kills[String(this.slot)]) || 0;
this._myKills = kills;
this._stopWaveIdleFeed();
this._stopAllLoops();
this._dropAllNpc();
this.audio.play('bgm/menu', { volume: 0.5 });
this._saveProgress(ev); // 结算 PUT(含 summary),end_rank 播到时已写入
// 手势阶梯(§4.3):一局 END → onGameDone(第 1 局解锁下滑菜单、第 3 局双击)
const got = this.ladder.onGameDone();
const win = !!ev.win;
const myBand = kills > 0 ? [killBandId(kills)] : []; // 0 杀不播羞辱句(沉默优于羞辱)
const head = win
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank']
: ['end_lose', ...myBand];
this._endChain = head;
const tail = ['voice/end_again'];
if (got && this.ladder.teachLine(got)) tail.push(this.ladder.teachLine(got)); // 解锁教学句
this._protect = 'settle';
this.floor.speak(head.map((id) => 'voice/' + id), {
onDone: () => {
this._protect = null;
this._flushAlerts();
// §4.6:只有「单击再猎一局」承诺段标 critical(<3s),锁住 ghost 双击
this.floor.speak(tail, {
critical: true,
onDone: () => this.floor.yieldTurn(this._endWp()),
});
},
});
}
_enterLost() {
// 断点复述(§4.12):房灭/断线超时 = 一局内被迫退出 → 标记 + 回大厅复述
// (先标记再改 phase:标记条件看的是在途局 wave/teach)
this._markUnfinished();
this.phase = 'lost';
this._menuOpen = false;
this._netDown = false;
this._stopWaveIdleFeed();
this._stopAllLoops();
this._dropAllNpc();
this.audio.play('bgm/menu');
this.floor.speak(['voice/net_gone'], {
onDone: () => {
if (this.phase !== 'lost') return;
this.floor.yieldTurn(this._wp({
id: 'lost', expected: ['tap'],
full: 'voice/net_gone', short: null,
silenceMs: [10000, 30000],
onDemo: () => this.floor.chime(),
}));
},
});
}
/** 再来一局 / 重进匹配:收拾残局 → 重新连接。 */
_again() {
this._sendLeave();
this._enterMatching();
}
// ================================================================ 手势:各状态
_onTitleGesture(g) {
if (g === 'tap') { this._enterMatching(); return 'ok'; }
if (g === 'longpress') { this._titleHelp(); return 'ok'; }
if (g === 'down') { this._openMenu(); return 'ok'; }
return 'invalid'; // 四向滑 / 双击(TITLE 无可重听内容)
}
_onLobbyGesture(g) {
if (g === 'tap') { this._sendAct('start'); return 'ok'; } // 立即开局(等待点完成)
if (g === 'longpress') { this._lobbyHelp(); return 'ok'; } // 帮助(在场人数)
if (g === 'doubletap') { // 阶段 3 起:重听(§4.4 再说一遍 + 完整句)
const wp = this.floor.waitPoint;
if (wp && wp.conf.full) { wp.requestReplay(); return 'ok'; }
return 'invalid';
}
if (g === 'down') { this._openMenu(); return 'ok'; }
return 'invalid'; // 四向滑
}
_onTeachGesture(g) {
if (g === 'doubletap') g = 'tap'; // 教学期双击按单击处理(阶段 3+ 罕见重入)
if (g === 'longpress') {
// T3 的长按是作答(完成教学);其余步骤长按 = 帮助(当前步骤提示句)
if (this._tut === 3) { this._teachDone(); return 'ok'; }
this._teachHelp();
return 'ok';
}
// 出击不完成教学步骤(kill/ack 事件才推进)→ 'hold':等待点继续等、计时复位
const attackHold = (dir) => (this._attackDir(dir) === 'ok' ? 'hold' : 'invalid');
switch (this._tut) {
case 0: // T0 点一下(intro 等待点)
if (g === 'tap') { this._tutStep(1, ['voice/tut_hit_full']); return 'ok'; }
return 'invalid';
case 1: // T1 滑砍(错向走 miss 重教;点按 invalid「先朝声音滑」)
if (g in SWIPE_BASE) return attackHold(SWIPE_BASE[g]);
return 'invalid';
case 2: // T2 呼喊(滑砍不卡死:照常出击)
if (g === 'tap') return this._callout() ? 'hold' : 'invalid'; // 等 bot 应答推进
if (g in SWIPE_BASE) return attackHold(SWIPE_BASE[g]);
return 'invalid';
case 3: // T3 长按问路(tap/滑不卡死)
if (g === 'tap') return this._callout() ? 'ok' : 'invalid';
if (g in SWIPE_BASE) return attackHold(SWIPE_BASE[g]);
return 'invalid';
default:
return 'invalid'; // 已完成等 wave 1
}
}
_onWaveGesture(g) {
// 波间休整(附录 C §4 波间行):下滑=菜单、长按=帮助,其余 invalid(无可打目标)
if (this._inRest()) {
if (g === 'down') { this._openMenu(); return 'ok'; }
if (g === 'longpress') { this._helpChain(); return 'ok'; }
return 'invalid';
}
if (g === 'tap') return this._callout() ? 'ok' : 'invalid';
if (g === 'doubletap') return this._doubleTapCombat();
if (g in SWIPE_BASE) {
// 附录 C §3/§4:菜单只在波间休整窗(waveclear 后 8s)开放;WAVE 内下滑
// 恒出击南弧(无怪=空挥真话 atk_swish),战斗中绝不弹菜单
// 迷茫 L2:只放行接管指定的弧(其余 invalid 回执,不放技能不出刀)
if (this._restrictArc && SWIPE_BASE[g] !== this._restrictArc) return 'invalid';
return this._attackDir(SWIPE_BASE[g]);
}
if (g === 'longpress') { this._helpChain(); return 'ok'; } // 我在哪(状态一句话)
return 'invalid';
}
_onEndGesture(g) {
if (g === 'tap') {
if (this.floor.speaking) return 'hold'; // 链在播:跳句由 floor 处理,不重开
this._again();
return 'ok';
}
if (g === 'longpress') { this._bestHelp(); return 'ok'; } // 重听最佳一段
if (g === 'doubletap') {
const wp = this.floor.waitPoint;
if (wp && wp.conf.full) { wp.requestReplay(); return 'ok'; }
return 'invalid';
}
if (g === 'down') { this._openMenu(); return 'ok'; }
// 结算链被打断后收尾:不留安静态,直接交回结算等待点(§4.1)
if (!this.floor.speaking && this.phase === 'end' && !this.floor.waitPoint) {
this.floor.yieldTurn(this._endWp());
}
return 'invalid';
}
_onLostGesture(g) {
if (g === 'tap') { this._enterTitle(); return 'ok'; } // 回大厅
return 'invalid';
}
// ================================================================ 菜单(E3:不暂停;26 号:左右换项、单击确认)
_openMenu() {
this._menuOpen = true;
this._menuIdx = 0;
this._menuArmedHome = false;
this.floor.interrupt(); // 收掉被顶的链(警报随时可顶菜单链;BGM 照旧,E3 不暂停)
this.floor.speak(['voice/sys_menu', 'voice/' + MENU_ITEMS[0]], {
onDone: () => { if (this._menuOpen) this._menuYield(); },
});
}
_closeMenu() {
this._menuOpen = false;
this._menuArmedHome = false;
this.floor.interrupt();
// 恢复被菜单顶掉的等待态(不留安静态,§4.1)
if (this.phase === 'wave' && this._inRest()) this.floor.yieldTurn(this._restWp());
else if (this.phase === 'lobby') {
this.floor.yieldTurn(this._wp({
id: 'lobby', expected: ['tap'], full: 'voice/room_wait', short: 'voice/room_wait_short',
silenceMs: [8000, 12000], onDemo: () => this._sendAct('start'),
}));
} else if (this.phase === 'end') this.floor.yieldTurn(this._endWp());
else this.floor.enterAction(); // title
}
_onMenuGesture(g) {
if (g === 'doubletap') g = 'tap'; // 双击按单击处理(确认不再绑双击,00 §5.4)
if (g === 'longpress') { // 重听菜单说明
this.floor.speak(['voice/sys_menu', 'voice/' + MENU_ITEMS[this._menuIdx]], {
onDone: () => { if (this._menuOpen && this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
if (g === 'left' || g === 'right') { // 左右滑换项(循环)
this._menuArmedHome = false;
this._menuIdx = (this._menuIdx + (g === 'right' ? 1 : MENU_ITEMS.length - 1)) % MENU_ITEMS.length;
this.floor.speak(['voice/' + MENU_ITEMS[this._menuIdx]], {
onDone: () => { if (this._menuOpen && this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
if (g === 'down') { this._closeMenu(); return 'hold'; } // 下滑=关闭(呼出同键)
if (g === 'tap') { // 单击 = 确认当前项
const item = MENU_ITEMS[this._menuIdx];
if (item === 'sys_menu_resume') { this._closeMenu(); return 'hold'; }
if (item === 'sys_menu_help') {
this.floor.speak(['voice/help_main'], {
onDone: () => { if (this._menuOpen && this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
// sys_menu_home = 退出房间回大厅(破坏性 → 二次确认,原则 6)
if (!this._menuArmedHome) {
this._menuArmedHome = true;
this.floor.speak(['voice/sys_home_check'], {
onDone: () => { if (this._menuOpen && this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
this._goHome();
return 'ok';
}
return 'invalid'; // 上滑在菜单内无义
}
// ================================================================ 战斗动作(客户端意图)
/** 出击:接受发 act + whoosh(ok);冷却内首拦震一次,其后仅 invalid 回执
* (连拦不连环 error 震;回执保证禁止静默),就绪时刻轻叮一记补 beat。 */
_attackDir(dir) {
const now = performance.now();
if (now < this._atkReadyAt) {
if (!this._atkBlockedThisCd) {
this._atkBlockedThisCd = true; // 首拦才震(v2 S2)
vibrate('error');
this._armAtkReadyCue();
}
return 'invalid';
}
this._atkBlockedThisCd = false;
this._atkReadyAt = now + ATTACK_CD_MS;
this._actedThisWave = true;
this._stopWaveIdleFeed(); // 本波已出手:一级语音永不催(§5 wave 行)
this._sendAct('attack', dir);
this._pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk' });
return 'ok';
}
/** 冷却就绪提示:本冷却窗内被拦过 → 就绪时刻轻叮一记(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/人(节流内 invalid + 一次解释);本机播自己座位角色
* help_call(中央;座 3=哨音带 pan)。呼喊是短 sfx,不单开第四态。 */
_callout() {
const now = performance.now();
if (now < this._calloutReadyAt) { vibrate('error'); return false; }
this._calloutReadyAt = now + CALLOUT_THROTTLE_MS;
this._actedThisWave = true;
this._stopWaveIdleFeed();
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' });
return true;
}
/**
* 战斗内双击(阶段 3 起才到达游戏;此前管道折算成 tap=呼喊):
* - 阶段 4(skillUnlocked):就绪 → 放出旋风斩(act skill + 8s 冷却);
* 冷却中 → 重听威胁档;
* - 阶段 3:恒重听威胁档(threat_*,只报内圈数量档,方位由声像承载 R6)。
* 主动重听带「再说一遍:」前缀(§4.4)。
*/
_doubleTapCombat() {
const now = performance.now();
if (this._progress.skillUnlocked && now >= this._skillReadyAt) {
this._skillReadyAt = now + SKILL_CD_MS;
this._skillAnnounced = false;
this._actedThisWave = true;
this._stopWaveIdleFeed();
this._sendAct('skill'); // 新手客户端不发 skill,直到阶段 4(§4)
this._armSkillReady(SKILL_CD_MS);
return 'ok';
}
this._threatReport();
return 'ok';
}
_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;
// §4.6 hint 类:只在空闲时播,播不出去就丢(不排长链迟到)
this.floor.speak(['voice/skill_ready'], {
kind: 'hint',
onDone: () => { if (this.phase === 'wave' && !this.floor.speaking) this.floor.enterAction(); },
});
}, 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.confusion.recordReplay();
this.floor.speak(['voice/sys_say_again', 'voice/' + id], {
onDone: () => { if (this.phase === 'wave' && !this.floor.speaking) this.floor.enterAction(); },
});
}
/**
* 长按 = 「我在哪、我该干嘛」(原则 3:耐久档 + 波次档 + 一个动作,≤20 字)。
* 重复问同一状态 → 只剩没刚说过的段(原则 9:每重复一次就更短);全都说过了
* →「再说一遍:」+ 动作句(主动重听允许原句,§4.4)。
*/
_helpIds() {
return ['voice/' + hpBandId(this.dur), 'voice/' + stWaveId(this.wave || 1), 'voice/lp_act'];
}
_helpChain() {
const fresh = this._helpIds().filter((id) => !hintSaidRecently(id));
const say = (ids) => {
this.floor.speak(ids, {
onDone: () => {
if (this.phase === 'wave' || this.phase === 'title') {
if (!this.floor.speaking) this.floor.enterAction();
} else if (this.floor.waitPoint) this.floor.resumeTurn();
},
});
};
if (!fresh.length) {
for (const id of this._helpIds()) markHintSaid(id);
say(['voice/sys_say_again', 'voice/lp_act']);
return;
}
for (const id of fresh) markHintSaid(id);
say(fresh);
}
/** TITLE 长按帮助:位置 + 一个动作(+ 有 best 时追评级/击杀档)。 */
_titleHelp() {
const ids = ['voice/lp_title'];
const best = this._progress.best;
if (best) ids.push('voice/grade_' + String(best.grade).toLowerCase(), 'voice/' + killBandId(best.kills));
this._sayQuery(ids, () => { if (this.phase === 'title' && !this.floor.speaking) this.floor.enterAction(); });
}
/** LOBBY 长按帮助:在场人数(附录 C §4 大厅行长按列)。 */
_lobbyHelp() {
this._sayQuery(['voice/' + (this._teamCount() >= 4 ? 'team_4' : 'team_3')], () => {
if (this.floor.waitPoint) this.floor.resumeTurn();
});
}
/** END 长按帮助:重听个人最佳一段(附录 C §4 结算链完行)。 */
_bestHelp() {
const best = this._progress.best;
const ids = best
? ['voice/grade_' + String(best.grade).toLowerCase(), 'voice/' + killBandId(best.kills)]
: ['voice/end_again'];
this._sayQuery(ids, () => { if (this.floor.waitPoint) this.floor.resumeTurn(); });
}
/**
* 主动状态查询(原则 9):同一信息 30s 内重复查询 → 加「再说一遍:」前缀播
* 完整句(只有玩家主动要求重听才原句重播);台账标记让被动提示自动降档。
*/
_sayQuery(ids, onDone) {
if (!ids || !ids.length) return;
const recent = hintSaidRecently(ids[0]);
for (const id of ids) markHintSaid(id);
const chain = recent ? ['voice/sys_say_again', ...ids] : ids;
this.floor.speak(chain, { onDone });
}
_inRest() { return this.phase === 'wave' && performance.now() < this._restUntil; }
/** ACTION 语境(无等待点,玩法/氛围声即状态声):TITLE 与对局期 WAVE。 */
_inActionCtx() {
return this.phase === 'title' || (this.phase === 'wave' && !this._inRest());
}
// ================================================================ 网络:连接与消息
_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._alertVoice(['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._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) {
// 回槽续局(§4.12 复述句):net_back + 紧随 snap 全量无缝续渲染(不重播波次播报)
this.phase = 'wave';
this.audio.play('bgm/hunt');
this._startRenderer();
this._startWaveIdleFeed();
this.floor.enterAction();
this.floor.speak(['voice/net_back'], {
onDone: () => { if (this.phase === 'wave' && !this.floor.speaking) this.floor.enterAction(); },
});
} else {
this._enterWave(this.wave || 1, 'lobby');
}
} else { // end:迟到回槽,直接落结算等待位
this.phase = 'end';
this.audio.play('bgm/menu', { volume: 0.5 });
this.floor.speak(['voice/end_again'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._endWp()),
});
}
}
_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':
// 波间休整 8s:wave_clear → 交出话轮(等待点=菜单/下一波;§4 矩阵波间行)。
// 菜单开着(E3):只延长休整窗,不顶菜单链
this._restUntil = performance.now() + 8000;
this._stopWaveIdleFeed();
this._restrictArc = null;
if (this._menuOpen) break;
this.floor.speak(['voice/wave_clear'], {
onDone: () => { if (this.phase === 'wave' && this._inRest()) this.floor.yieldTurn(this._restWp()); },
});
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;
this._stopWaveIdleFeed();
vibrate('pickup'); // 命中
// 手势阶梯(§4.3 阶段 1):第一次击杀 = 第一个小目标。hunt 的「我在哪」在
// 长按(四向玩法裁定),不播 sys_unlock_up(上滑在本作=出击北弧)
if (!this._goalFired) {
this._goalFired = true;
this.ladder.onGoal();
}
}
// 教学 T1:首杀 → 肯定句 + T2 呼喊(原则 11:≤4 字肯定,马上进下一步)
if (this.phase === 'teach' && this._tut === 1) this._tutStep(2, ['voice/tut_kill_ok', 'voice/tut_call_full']);
}
_onMiss(ev) {
if (ev.slot !== this.slot) return;
this._pos('sfx/atk_swish', { pan: panOfDir(ev.dir), vol: 1, type: 'atk' }); // 挥空真话:没打到
vibrate('error');
this.confusion.recordFail(); // §4.5 同步骤失败信号
// 归因(§6 做错列 / 原则 7):「在另一边。」30s 台账去重,之后只给 swish+回执
if (!hintSaidRecently('voice/miss_side')) {
markHintSaid('voice/miss_side');
this.floor.speak(['voice/miss_side'], {
kind: 'feedback',
onDone: () => this._resumeAfterFeedback(),
});
}
}
_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');
}
_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 共窗)。
// 耐久首次 ≤2:同链带 danger_low + danger 震(每局 1 次)——警报零丢失,链内有序
const now = performance.now();
const voiceIds = [];
if (now - this._lastBreachVoiceAt >= BREACH_VOICE_GAP_MS) {
this._lastBreachVoiceAt = now;
voiceIds.push(ev.mkind === 'bear' ? 'breach_heavy' : 'breach');
}
if (this.dur <= 2 && !this._dangerAnnounced) {
this._dangerAnnounced = true;
voiceIds.push('danger_low');
vibrate('danger');
}
if (voiceIds.length) this._alertVoice(voiceIds);
}
/** 求援呼喊:他人呼喊 → 该座角色 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 播完后点明 + T3 长按问路
const onDone = (this.phase === 'teach' && this._tut === 2 && ev.line === 'help_ack')
? () => this._tutStep(3, ['voice/tut_callout_ok', 'voice/tut_help_full'])
: 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), // 身后怪标记(低通第二线索)
};
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') {
// 后半平面低通 750Hz——音色发闷叠加响度 ×0.6(N/S 轴判读响度+音色双线索)
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,
// 呼救/应答语义永不超时丢弃(等待投递);氛围句才受 ≤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 + 26 号 §4.6)
/**
* 警报类播报(breach/breach_heavy/danger_low/net_*):教学/结算保护链在播时
* 排队(不可顶,R1),链尾冲刷;否则硬播顶掉状态/播报类(顶句作废旧链)。
*/
_alertVoice(ids) {
if (this.floor.speaking && this._protect) {
this._pendingAlerts.push(...ids);
return;
}
this.floor.speak(ids.map((id) => 'voice/' + id), {
onDone: () => this._resumeAfterAlert(),
});
}
_flushAlerts() {
if (!this._pendingAlerts.length) return;
const ids = this._pendingAlerts.splice(0, 2);
this._alertVoice(ids);
}
/** 警报播完回到原语境(战斗回 ACTION;等待点回等待态;菜单 overlay 回菜单)。 */
_resumeAfterAlert() {
if (this.floor.speaking) return;
if (this._menuOpen) { this._menuYield(); return; } // E3:菜单开着,警报顶掉菜单链后重建
if (this.phase === 'wave' || this.phase === 'title' || this.phase === 'matching') {
this.floor.enterAction();
if (this.phase === 'matching') this.floor.leaveAction();
} else if (this.floor.waitPoint) {
this.floor.resumeTurn();
}
}
/** 反馈句(miss 归因等)播完回到原语境。 */
_resumeAfterFeedback() {
if (this.floor.speaking) return;
if (this._menuOpen) { this._menuYield(); return; }
if (this.phase === 'wave') this.floor.enterAction();
else if (this.floor.waitPoint) this.floor.resumeTurn();
else this.floor.leaveAction();
}
/** 菜单等待点(打开/重建共用:右滑换项、单击确认)。 */
_menuYield() {
this.floor.yieldTurn(this._wp({
id: 'menu', expected: ['tap', 'left', 'right'],
full: 'voice/sys_menu', short: 'voice/sys_menu_short',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
}));
}
// ================================================================ 等待点 / 教学 / 迷茫接管
_wp(conf) {
return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion });
}
_introWp() {
return this._wp({
id: 'intro', expected: ['tap'],
full: 'voice/sys_first_tap', short: 'voice/sys_first_tap_short',
silenceMs: [6000, 15000],
onDemo: () => this._tutStep(1, ['voice/tut_hit_full']), // L3 替点
});
}
_hitWp() {
return this._wp({
id: 'teach_hit', expected: ['up', 'down', 'left', 'right'],
full: 'voice/tut_hit_full', short: 'voice/tut_hit_short',
silenceMs: [8000, 15000],
onDemo: () => this._demoKillNearest(), // L3 游戏杀掉这只教学狼(§5 表)
});
}
_callWp() {
return this._wp({
id: 'teach_call', expected: ['tap'],
full: 'voice/tut_call_full', short: 'voice/tut_call_short',
silenceMs: [8000, 15000],
onDemo: () => { this._callout(); }, // L3 替喊
});
}
_helpWp() {
return this._wp({
id: 'teach_help', expected: ['longpress'],
full: 'voice/tut_help_full', short: 'voice/tut_help_short',
silenceMs: [8000, 15000],
onDemo: () => this._teachDone(), // L3 替播帮助句并完成教学
});
}
_restWp() {
return this._wp({
id: 'rest', expected: null, // 休整窗不限制手势(菜单/帮助皆可)
full: null, short: null, // 不念说明书:静默只给提示音(§5 wave 行)
silenceMs: [12000, 25000],
onDemo: () => this.floor.chime(),
});
}
_endWp() {
return this._wp({
id: 'end', expected: ['tap'],
full: 'voice/end_again', short: 'voice/end_again_short',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
});
}
/** 教学步进:作废旧链 → 播步骤句(critical:教学旁白不可顶、≤3s)→ 交出话轮。 */
_tutStep(step, ids) {
this._tut = step;
const wp = step === 1 ? this._hitWp() : step === 2 ? this._callWp() : this._helpWp();
for (const id of ids) markHintSaid(id); // 主动刚说过 → 静默三档自动降档(原则 9)
this._speakTeach(ids, wp);
}
_speakTeach(ids, wp) {
this._protect = 'teach';
this.floor.interrupt();
this.floor.speak(ids, {
critical: true, // 教学指令句短(<3s):不打断、警报排队(R1)
onDone: () => {
this._protect = null;
this._flushAlerts();
this.floor.yieldTurn(wp);
},
});
}
/** 教学期长按帮助 = 当前步骤的提示句(更短档;30s 台账去重)。 */
_teachHelp() {
const line = this._tut === 1 ? 'voice/tut_hit_short'
: this._tut === 2 ? 'voice/tut_call_short'
: this._tut === 3 ? 'voice/tut_help_short'
: 'voice/sys_first_tap_short';
if (hintSaidRecently(line)) { this.floor.chime(); return; }
markHintSaid(line);
this.floor.speak([line], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
/** T3 作答:长按听一次营地状态 → tut_done → 等服务器 ev wave 1。 */
_teachDone() {
this._tut = -1;
this._tutDoneWaiting = true;
this._progress.tutDone = true;
this._writeProgress(false); // 立即落盘(不进结算也生效)
this.floor.settleWaitPoint(true); // 作答完成 teach_help 等待点
this._protect = 'teach';
this.floor.interrupt();
const help = ['voice/' + hpBandId(this.dur), 'voice/lp_act'];
for (const id of help) markHintSaid(id);
this.floor.speak([...help, 'voice/tut_done'], {
critical: true,
onDone: () => { this._protect = null; this._flushAlerts(); },
});
}
/** L3 演示:替玩家朝最近怪出刀(服务端裁决;教学狼 ≤2 挡后自动放过)。 */
_demoKillNearest() {
const near = this._nearestMob();
if (!near) { this.floor.chime(); return; }
const dir = baseOfDir(near.m.dir);
if (this._attackDir(dir) === 'invalid') {
// 冷却未就绪:等就绪后替出刀(每冷却窗首拦已震过,这里只补刀)
if (this._atkReadyCueTimer !== null) clearTimeout(this._atkReadyCueTimer);
const wait = Math.max(0, this._atkReadyAt - performance.now());
this._atkReadyCueTimer = setTimeout(() => {
this._atkReadyCueTimer = null;
if (this.phase === 'teach' || this.phase === 'wave') this._attackDir(dir);
}, wait);
}
}
_nearestMob() {
let best = null;
for (const m of this.mobs.values()) {
const eta = this._mobEta(m);
if (eta <= MID_ETA_S && eta > 0 && (!best || eta < best.eta)) best = { m, eta };
}
return best;
}
/**
* 迷茫接管分发(confusion.onConfused):有等待点 → 等待点三级接管(§4.5);
* WAVE 对局(无等待点)→ 本作三级(附录 C §7 迷茫行)。
*/
_onConfused(level) {
const wp = this.floor.waitPoint;
if (wp) { wp.takeover(level); return; }
if (this.phase === 'wave') this._takeoverWave(level);
}
/** WAVE 三级接管:L1「朝最响的滑。」→ L2 只放行该弧 → L3 替玩家清一只近狼。 */
_takeoverWave(level) {
const near = this._nearestMob();
const sayHint = (id) => {
if (!id || hintSaidRecently(id)) { this.floor.chime(); return; }
markHintSaid(id);
this.floor.speak([id], {
kind: 'hint',
onDone: () => { if (this.phase === 'wave' && !this.floor.speaking) this.floor.enterAction(); },
});
};
if (level === 1) {
sayHint(near ? 'voice/gd_idle' : null); // 「朝最响的滑。」(附录 C §7)
} else if (level === 2) {
if (near) {
this._restrictArc = baseOfDir(near.m.dir);
sayHint('voice/wave_only'); // 「只朝声音那边滑。」
} else this.floor.chime();
} else {
this._restrictArc = null;
this._demoKillNearest(); // L3 清一只近狼(§7)
}
}
/**
* wave 等待点(§5 wave 行):本波未出手时 8s 给一次 earcon(不念说明书),
* 之后只喂迷茫静默信号;本波已出手则永不语音催(等待是合法玩法)。
*/
_startWaveIdleFeed() {
this._stopWaveIdleFeed();
this._waveIdleChimed = false;
const tick = () => {
this._waveIdleTimer = null;
if (this.phase !== 'wave' || this._actedThisWave) return;
if (!this._waveIdleChimed) {
this._waveIdleChimed = true;
this.floor.chime(); // 只给交接提示音(一级语音永不触发)
}
this.confusion.recordSilence();
this._waveIdleTimer = setTimeout(tick, WAVE_IDLE_REPEAT_MS);
};
this._waveIdleTimer = setTimeout(tick, WAVE_IDLE_FIRST_MS);
}
_stopWaveIdleFeed() {
if (this._waveIdleTimer !== null) { clearTimeout(this._waveIdleTimer); this._waveIdleTimer = null; }
}
// ================================================================ 花名册
_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._alertVoice(['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),
skillUnlocked: !!(d && d.skillUnlocked),
unfinished: !!(d && d.unfinished),
};
}
/** 登录态拉回服务器进度(服务器为准);失败静默用本地。 */
_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,
skillUnlocked: p.skillUnlocked,
};
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 (_) { /* 静默 */ }
}
/** 断点复述(§4.12):一局内退出(房灭/主动退出)标记,下次进 TITLE 复述。 */
_markUnfinished() {
if (this.phase !== 'wave' && this.phase !== 'teach') return;
this._progress.unfinished = true;
this._writeProgress(false);
}
// ================================================================ 残局收拾 / 回大厅
/** 每局状态复位(新 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._tutDoneWaiting = false;
this._goalFired = false;
this._endChain = null;
this._posQueue.length = 0;
this._posActive = [];
this._dropAllNpc();
this._actedThisWave = false;
this._restUntil = 0;
this._atkBlockedThisCd = false;
this._restrictArc = null;
this._stopWaveIdleFeed();
}
_teardownRound() {
this._markUnfinished(); // 主动退出在途局 → 下次复述(§4.12)
this.floor.interrupt();
this._pendingAlerts.length = 0;
this._protect = null;
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._markUnfinished();
this.stop();
this._navigatedHome = '../';
try { window.location.href = '../'; } catch (_) { /* mock 环境无 location */ }
}
}