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 置完成。
1841 lines
73 KiB
JavaScript
1841 lines
73 KiB
JavaScript
/**
|
||
* 《围猎》客户端 —— 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 */ }
|
||
}
|
||
}
|