/** * sim-client.mjs — 拟真模拟客户端 = game.js 的行为移植(协议 + 手势路由 + 音频调度)。 * * 与真实客户端的对应关系(改动 game.js 音频相关逻辑时本文件必须同步): * - 语音链 R1(_playChain/_alert):真实资产时长串行链播;警报顶句规则忠实移植 * (protect 链只可排队,非保护链被警报立即切断);queueFirst 等当前句。 * - bot 喊话 R2(_npcSchedule):同刻 1 条、待播 1 条新顶旧、旁白在播延迟 ≤2s * (呼救语义 4s)超时丢弃。 * - 定位 one-shot R4(_pos):并发 ≤2、同类间隔 0.15s、呼啸 droppable 队列 ≤2、 * 排队 1.5s 过期丢弃。 * - 怪物 loop R3(_refreshLoops):500ms 重估、eta≤6 取前 3、音量档 0.30/0.55/0.80、 * 后半平面 ×0.6、pan=sin(az)。 * - 手势路由(_onWaveGesture 语境折中等)逐条对照;本地冷却先拦(乱滑只震不发)。 * - 看门狗(gd_idle 10s/25s)、结算死窗 800ms、skill_ready 冷却完成播报。 * * 输出:percept 事件流(玩家耳朵听到/身体感到的一切)→ brain 决策 + recorder 落盘。 * 简化声明:语音句间留 0.12s 间隙(真机 onComplete 链接近无缝);SoundTouch 语速 * 恒 1.0;引导档 full。其余语义与 game.js 一致。 */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { connect } from './ws.mjs'; // (本文件不直接用 ROOT;保留注释锚点:仓库根 = lib 上四级) // ---- 方位(§8.3,与 game.js 一致)---- export const DIR_AZ = { N: 0, NE: 45, E: 90, SE: 135, S: 180, SW: 225, W: 270, NW: 315 }; export const azOf = (d) => DIR_AZ[d] ?? 0; export const panOfAz = (az) => Math.sin((az * Math.PI) / 180); export const isBackAz = (az) => az > 90 && az < 270; export const panOfDir = (d) => panOfAz(azOf(d)); const ARCS = { up: ['NW', 'N', 'NE'], right: ['NE', 'E', 'SE'], down: ['SE', 'S', 'SW'], left: ['SW', 'W', 'NW'], }; const SWIPE_BASE = { up: 'N', right: 'E', down: 'S', left: 'W' }; const sectorCenterAz = (sector) => (sector || []).reduce((s, d) => s + azOf(d), 0) / (sector || []).length; // ---- 常量(game.js 同值)---- const ATTACK_CD_MS = 2500; const SKILL_CD_MS = 8000; const CALLOUT_THROTTLE_MS = 5000; const INNER_ETA_S = 4; const MID_ETA_S = 6; const LOOP_MAX = 3; const LOOP_REFRESH_MS = 500; const ONESHOT_MAX = 2; const ONESHOT_SPACING_MS = 150; const HOWL_QUEUE_MAX = 2; const HOWL_QUEUE_TTL_MS = 1500; const NPC_VOL = 0.55; const NPC_VOICE_DELAY_MS = 2000; const NPC_PRIO_TTL_MS = 4000; const BREACH_VOICE_GAP_MS = 3500; const END_GRACE_MS = 800; const VOICE_GAP_S = 0.12; // 句间间隙(模拟口径) 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_FALLBACK_DUR_S = 1.6; const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_help', 'sys_menu_home']; const hpBandId = (dur) => (dur >= 8 ? 'hp_band1' : dur >= 5 ? 'hp_band2' : dur >= 3 ? 'hp_band3' : 'hp_band4'); const stWaveId = (w) => (w <= 2 ? 'st_wave_a' : w <= 4 ? 'st_wave_b' : 'st_wave_c'); const killBandId = (k) => (k <= 0 ? 'kill_band0' : k <= 9 ? 'kill_band1' : 'kill_band2'); export class SimClient { /** * @param opts { * port, name, gender ('male'|'female' 旁白音色), assets {durations, voiceText}, * recorder, brain, progress {tutDone, runs, best}, joinDelayMs, teach(bool) * } */ constructor(opts) { this.o = opts; this.assets = opts.assets; this.recorder = opts.recorder; this.brain = opts.brain; if (this.brain) this.brain.client = this; this.session = null; this.joined = false; this.slot = -1; this.char = null; this.sector = []; this.room = null; this.seed = 0; this.roster = new Map(); this.seq = 0; this.phase = 'title'; // title/matching/lobby/teach/wave/end/lost this.menuOpen = false; this.menuIdx = 0; this.srvPhase = null; this.mobs = new Map(); // id → { id, mkind, dir, eta0, t0 }(t0=nowSrv) this.wave = 0; this.dur = 10; this.myKills = 0; this.srvOffset = 0; this.atkReadyAt = 0; this.skillReadyAt = 0; this.calloutReadyAt = 0; this.skillAnnounced = true; this.lastBreachVoiceAt = -1e9; this.dangerAnnounced = false; this.graceUntil = 0; this.lastGestureAt = 0; // 看门狗基准 this.watchdogSaid1 = false; // 教学状态(_tut 0..4) this.tut = -1; this.tutMiss = 0; this.tutDoneWaiting = false; // 音频通道模型 this.voiceQ = []; // { id, dur, protect?, tag }(已含在播句) this.voiceBusyUntil = 0; // 当前句结束时刻(wall ms) this.voiceProtect = null; // 当前保护链标识(teach/announce/settle) this.voiceChainToken = 0; this.npcBusyUntil = 0; this.npcPending = null; // { id, pan, expireAt, prio } this.posActive = []; // [{ until }] this.posQueue = []; this.posTypeAt = {}; this.loops = new Map(); // id → { mkind, dir, band, vol, pan } this.bgm = null; this.progress = opts.progress || { tutDone: false, runs: 0, best: null, totalKills: 0 }; this._timers = new Set(); this._stopped = false; this.result = null; // { win, dur, grade, kills, myKills } this.matchEnded = new Promise((res) => { this._resolveEnd = res; }); } // ================= 基础 ================= now() { return Date.now(); } nowSrv() { return this.now() + this.srvOffset; } voiceDur(id) { const k = `voice/${this.o.gender}/${id}`; return (this.assets.durations[k] || 0) || 2.2; // 缺资产兜底 2.2s } npcDur(id) { return (this.assets.durations[id] || 0) || NPC_FALLBACK_DUR_S; } text(id) { return this.assets.voiceText[id] || id; } vDurTotal(ids) { return ids.reduce((s, id) => s + this.voiceDur(id) + VOICE_GAP_S, 0); } percept(ev) { ev.t = this.now(); this.recorder.percept(ev); if (this.brain) this.brain.onPercept(ev); } timer(fn, ms) { const id = setTimeout(() => { this._timers.delete(id); if (!this._stopped) fn(); }, ms); this._timers.add(id); return id; } // ================= 生命周期 ================= async startMatch({ teach = false } = {}) { this.phase = 'matching'; this.session = await connect(this.o.port); this._wireSession(); const join = { t: 'join', ver: 1, room: 'quick', name: this.o.name }; if (teach || !this.progress.tutDone) join.teach = true; this.session.send(join); this.recorder.log({ t: 'join_sent', join }); // 兜底:对局 10 分钟必须收敛 this.timer(() => this.forceEnd('timeout'), 10 * 60 * 1000); } _wireSession() { this.session.on('message', (m) => this.onMessage(m)); this.session.on('close', (code) => this.onClose(code)); } /** §6.6 断线重连:带 resume 回槽(不重播波次播报,播 net_back)。 */ async reconnect(resumeToken) { if (this._stopped) return; this.recorder.log({ t: 'reconnect_attempt' }); this.session = await connect(this.o.port); this._wireSession(); this.session.send({ t: 'join', ver: 1, room: 'quick', name: this.o.name, resume: resumeToken }); this._netDown = true; } onClose(code) { if (this._stopped) return; this.recorder.log({ t: 'ws_close', code }); const inRoom = this.phase === 'lobby' || this.phase === 'teach' || this.phase === 'wave'; if (inRoom || this.phase === 'matching') { this._netDown = true; this.alert('net_lost'); // 断线:本地静默重连 ≤30s(只播一次) } if (this.brain && this.brain.onNetDown) this.brain.onNetDown(); } sendAct(kind, dir) { this.seq += 1; const act = { t: 'act', kind, seq: this.seq, at: this.now() }; if (dir) act.dir = dir; const ok = this.session && this.session.send(act); this.recorder.log({ t: 'act_sent', act, delivered: !!ok }); } leave() { if (this.session && this.joined) { this.session.send({ t: 'leave' }); this.recorder.log({ t: 'leave_sent' }); } this.stop(); } stop() { this._stopped = true; for (const id of this._timers) clearTimeout(id); this._timers.clear(); if (this.session) { this.session.close(); this.session = null; } } forceEnd(reason) { this.recorder.log({ t: 'force_end', reason }); this.leave(); this._resolveEnd(null); } // ================= 服务器消息 ================= onMessage(msg) { this.recorder.log({ t: 'srv', msg }); if (typeof msg.ts === 'number') this.srvOffset = msg.ts - this.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; } } onJoined(j) { const resumed = this._netDown === true; this._netDown = false; this.joined = true; this._resumeToken = j.resume; this.room = j.room; this.slot = j.slot; this.char = j.char || null; this.sector = j.sector || []; this.seed = j.seed; this.roster = new Map((j.roster || []).map((r) => [r.slot, r])); this.srvPhase = (j.state || {}).phase; this.wave = j.state.wave ?? 0; this.dur = j.state.dur ?? 10; if (!resumed) this.resetRound(); if (this.srvPhase === 'lobby') this.enterLobby(); else if (this.srvPhase === 'teach') this.enterTeach(); else if (this.srvPhase === 'wave') { if (resumed) { // 回槽续局:net_back + 无缝续渲染(不重播号角/波次播报,§6.6) this.phase = 'wave'; this.playBgm('hunt'); this.percept({ type: 'earcon', kind: 'battle' }); this.playChain(['net_back'], { protect: 'announce' }); } else this.enterWave(this.wave || 1, 'resume'); } else { this.phase = 'end'; this.playChain(['end_again']); } if (this.brain && this.brain.onJoined) this.brain.onJoined(j); } resetRound() { this.mobs.clear(); this.loops.clear(); this.myKills = 0; this.wave = 0; this.dur = 10; this.atkReadyAt = 0; this.skillReadyAt = 0; this.calloutReadyAt = 0; this.skillAnnounced = true; this.dangerAnnounced = false; this.lastBreachVoiceAt = -1e9; this.tut = -1; this.tutDoneWaiting = false; this.actedThisWave = false; // v2 S4/S9 每局复位 this._idleVoiceSaid = false; this._lastStatusSaid = null; this._restUntil = 0; this._atkBlockedThisCd = false; } onSnap(s) { if (typeof s.ts === 'number') this.srvOffset = s.ts - this.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; 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); const me = (s.slots || []).find((x) => x.slot === this.slot); if (me) { const now = this.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.skillAnnounced = false; // 由 brain 侧播报计时 } } this.refreshLoops(); } onErr(msg) { if (msg.code === 'room_gone' || msg.code === 'room_full') { this.phase = 'lost'; this.playChain(['net_gone'], { protect: 'announce' }); this._resolveEnd(null); return; } this.recorder.log({ t: 'err_seen', code: msg.code }); } mobEta(m) { return m.eta0 - (this.nowSrv() - m.t0) / 1000; } // ================= 事件流 → 音频 ================= onEv(msg) { switch (msg.kind) { case 'spawn': this.onSpawn(msg); break; case 'kill': this.onKill(msg); break; case 'miss': this.onMiss(msg); break; case 'skill': this.onSkillEv(msg); break; case 'breach': this.onBreach(msg); break; case 'wave': this.srvPhase = 'wave'; this.enterWave(msg.wave, this.phase); break; case 'waveclear': this._restUntil = this.now() + 8000; // v2 S1 波间休整窗 this.playChain(['wave_clear'], { queueFirst: true }); break; case 'callout': this.onCalloutEv(msg); break; case 'botline': this.onBotline(msg); break; case 'roster': this.applyRoster(msg.roster || [], false); break; case 'end': this.enterEnd(msg); break; default: break; } if (this.brain && this.brain.onEv) this.brain.onEv(msg); } onSpawn(ev) { this.mobs.set(ev.id, { id: ev.id, mkind: ev.mkind, dir: ev.dir, eta0: ev.eta, t0: ev.ts ?? this.nowSrv() }); 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, extra: { mkind: ev.mkind }, }); this.refreshLoops(); } onKill(ev) { this.mobs.delete(ev.id); this.stopLoop(ev.id); this.pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill', extra: { mkind: ev.mkind, by: ev.slot } }); if (ev.slot === this.slot) { this.myKills += 1; this.actedThisWave = true; this.percept({ type: 'vibrate', kind: 'pickup', note: '击杀命中' }); } if (this.phase === 'teach' && this.tut === 1) this.tutGoto(2, ['tut_kill_ok', 'tut_callout']); } onMiss(ev) { if (ev.slot !== this.slot) return; this.pos('sfx/atk_swish', { pan: panOfDir(ev.dir), vol: 1, type: 'atk', extra: { note: '挥空' } }); this.percept({ type: 'vibrate', kind: 'error', note: '挥空' }); if (this.phase === 'teach' && this.tut === 1) { this.tutMiss += 1; this.playChain(['gd_idle'], { protect: 'teach' }); } } onSkillEv(ev) { for (const id of ev.cleared || []) { this.mobs.delete(id); this.stopLoop(id); } this.pos('sfx/skill_spin', { pan: 0, vol: 1, type: 'skill', extra: { cleared: (ev.cleared || []).length, by: ev.slot } }); if (ev.slot === this.slot) this.percept({ type: 'vibrate', kind: 'success', note: '旋风斩' }); if (this.phase === 'teach' && this.tut === 3) this.tutGoto(4, ['tut_skill_cd', 'tut_status']); } onBreach(ev) { this.mobs.delete(ev.id); this.stopLoop(ev.id); this.dur = ev.dur; if (this.srvPhase === 'teach') return; // 学期零惩罚 this.pos('sfx/breach_thud', { pan: 0, vol: 1, type: 'breach', extra: { mkind: ev.mkind, dur: ev.dur } }); this.percept({ type: 'vibrate', kind: 'hit', note: `营地被突入(-${ev.mkind === 'bear' ? 2 : 1}) 余${ev.dur}` }); const now = this.now(); if (now - this.lastBreachVoiceAt >= BREACH_VOICE_GAP_MS) { this.lastBreachVoiceAt = now; this.alert(ev.mkind === 'bear' ? 'breach_heavy' : 'breach'); } if (this.dur <= 2 && !this.dangerAnnounced) { this.dangerAnnounced = true; this.playChain(['danger_low'], { queue: true }); this.percept({ type: 'vibrate', kind: 'danger' }); this.percept({ type: 'earcon', kind: 'danger' }); } } onCalloutEv(ev) { if (ev.slot === this.slot) return; const r = this.roster.get(ev.slot); const char = r ? r.char : null; const rsector = r && r.sector ? r.sector : []; const pan = panOfAz(sectorCenterAz(rsector)); if (char) this.npcSchedule('sfx/npc_' + char + '_help_call', pan, true); else this.pos('sfx/call_whistle', { pan, vol: 1, type: 'whistle' }); this.percept({ type: 'vibrate', kind: 'warning', note: '队友呼喊支援' }); } onBotline(ev) { const r = this.roster.get(ev.slot); const char = r ? r.char : null; if (!char) return; const prio = ev.line === 'help_call' || ev.line === 'help_ack'; const pan = panOfAz(sectorCenterAz(r && r.sector ? r.sector : [])); this.npcSchedule('sfx/npc_' + char + '_' + ev.line, pan, prio); } 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', extra: { note: '队友加入' } }); } else if (o && !o.bot && r.bot && r.slot !== this.slot) { this.alert('net_to_bot'); } } } // ================= 状态进入 ================= enterLobby() { this.phase = 'lobby'; this.playBgm('menu'); this.percept({ type: 'earcon', kind: 'menu' }); this.playChain(['room_wait'], { protect: 'announce' }); this.armWatchdog(); } enterTeach() { this.phase = 'teach'; this.tut = 0; this.tutMiss = 0; this.tutDoneWaiting = false; this.playBgm('hunt'); this.percept({ type: 'earcon', kind: 'battle' }); this.playChain(['intro_1', 'intro_2', 'tut_rule'], { protect: 'teach', tag: 'teach-t0' }); } enterWave(n, from) { const wasInWave = this.phase === 'wave'; const wasTeach = this.phase === 'teach'; this.phase = 'wave'; this.actedThisWave = false; // v2 S4 this._restUntil = 0; this.tut = -1; this.tutDoneWaiting = false; this.wave = n; // 教学完成兜底:教学波出清后服务器同场接续第 1 波——只要从 teach 进波次, // 教学即视为完成(tutDone 持久化,二周目不再进教学房)。T4 链 onDone 被波次 // 播报顶掉时由此兜底(真实进度语义不受语音链调度影响)。 if (wasTeach && !this.progress.tutDone) this.finishTeach(); this.playBgm('hunt'); if (!wasInWave) this.percept({ type: 'earcon', kind: 'battle' }); this.percept({ type: 'vibrate', kind: 'heartbeat', note: `第${n}波开始` }); this.pos('sfx/horn_wave', { pan: 0, vol: 1, type: 'horn' }); const band = 'wave_band' + Math.min(5, Math.max(1, n)); if (n === 1 && from === 'lobby') this.playChain(['room_start', band], { protect: 'announce' }); else this.playChain([band], { protect: 'announce' }); this.armWatchdog(); } enterEnd(ev) { this.phase = 'end'; this.menuOpen = false; this.graceUntil = this.now() + END_GRACE_MS; this.srvPhase = 'end'; this.dur = ev.dur; const kills = (ev.kills && ev.kills[String(this.slot)]) || 0; this.myKills = kills; this.loops.clear(); this.playBgm('menu', 0.5); this.percept({ type: 'earcon', kind: 'settle' }); // 进度账(§12 口径) this.progress.runs += 1; this.progress.totalKills += kills; if (ev.win && ev.grade) { const cand = { grade: ev.grade, kills, durLeft: ev.dur }; if (!this.progress.best || ev.grade !== this.progress.best.grade || ev.dur > this.progress.best.durLeft) { this.progress.best = cand; } } this.result = { win: ev.win, dur: ev.dur, grade: ev.grade, myKills: kills, kills: ev.kills, wave: this.wave }; const myBand = kills > 0 ? [killBandId(kills)] : []; // v2 S3:0 杀不播「你还没开张」 const chain = ev.win ? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank', 'end_again'] : ['end_lose', ...myBand, 'end_again']; this.playChain(chain, { protect: 'settle', tag: 'end-chain' }); // 结算链播完即视为对局收官(brain 的 END 手势由 arena 局间决策替代) const totalMs = this.vDurTotal(chain) * 1000 + 1500; this.timer(() => this._resolveEnd(this.result), totalMs); } tutGoto(step, ids) { this.voiceChainToken += 1; this.tut = step; if (ids && ids.length) this.playChain(ids, { protect: 'teach', tag: 'teach-t' + step }); } finishTeach() { this.tut = -1; this.tutDoneWaiting = true; this.progress.tutDone = true; } // ================= 手势(brain 意图 → 客户端路由)================= /** brain 发手势:'up'/'down'/'left'/'right'/'tap'/'doubletap' */ gesture(g) { this.lastGestureAt = this.now(); this.watchdogSaid1 = false; this.recorder.log({ t: 'gesture', g }); if (this.now() < this.graceUntil) { this.recorder.log({ t: 'gesture', note: '死窗吞掉', g }); return; } if (this.menuOpen) { this.onMenuGesture(g); return; } switch (this.phase) { case 'lobby': this.onLobbyGesture(g); break; case 'teach': this.onTeachGesture(g); break; case 'wave': this.onWaveGesture(g); break; case 'end': this.onEndGesture(g); break; default: break; // title/matching 由 arena 控制,brain 不在此发手势 } } onLobbyGesture(g) { if (g === 'tap') { this.sendAct('start'); return; } if (g === 'doubletap') { this.playChain(['room_wait']); return; } if (g === 'up') { this.playChain([this.roster.size >= 4 ? 'team_4' : 'team_3'], { queueFirst: true }); return; } if (g === 'down') { this.openMenu(); return; } this.invalid(); } onTeachGesture(g) { // v2 S8:作答步(1-3)内下滑 = 答错处理,不跳过;仅 T0/T4/完成等待态可跳 if (g === 'down' && (this.tut === 0 || this.tut === 4 || this.tut === -1)) { this.voiceChainToken += 1; this.playChain(['tut_skip'], { protect: 'teach', tag: 'teach-skip' }); this.finishTeach(); return; } switch (this.tut) { case 0: if (g === 'tap') this.tutGoto(1, ['tut_attack']); else this.invalid(); break; case 1: if (g in SWIPE_BASE) this.attackDir(SWIPE_BASE[g]); else this.invalid(); break; case 2: if (g === 'tap') this.callout(); else this.invalid(); break; case 3: if (g === 'doubletap') this.skill(); else this.invalid(); break; case 4: if (g === 'up') { const ids = this.statusIds(); this.voiceChainToken += 1; this.playChain([...ids, 'tut_breach', 'tut_done'], { protect: 'teach', tag: 'teach-t4', onDone: () => this.finishTeach(), }); } else this.invalid(); break; default: this.invalid(); break; } } onWaveGesture(g) { if (g === 'tap') { this.callout(); return; } if (g === 'doubletap') { this.skill(); return; } if (g in SWIPE_BASE) { // v2 S1:下滑恒出击(无怪=空挥);菜单只在波间休整窗(waveclear 后 8s) const now = this.now(); if (g === 'up' && !this.arcHasTarget(ARCS.up)) { this.recorder.log({ t: 'swipe_rerouted', g, to: 'status' }); this.statusChain(); return; } if (g === 'down' && now < this._restUntil) { this.recorder.log({ t: 'swipe_rerouted', g, to: 'menu' }); this.openMenu(); return; } this.attackDir(SWIPE_BASE[g]); return; } this.invalid(); } onEndGesture(g) { if (g === 'tap') { this.recorder.log({ t: 'end_again_gesture' }); return; } // 由 arena 局间决策接管 if (g === 'doubletap') { this.playChain(this._endChainEcho || ['end_win']); return; } if (g === 'down') { this.openMenu(); return; } this.invalid(); } onMenuGesture(g) { if (g === 'tap') { this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length; this.playChain([MENU_ITEMS[this.menuIdx]]); return; } if (g === 'doubletap') { const item = MENU_ITEMS[this.menuIdx]; if (item === 'sys_menu_resume') this.closeMenu(); else if (item === 'sys_menu_help') this.playChain(['help_main']); else { this.recorder.log({ t: 'go_home_from_menu' }); this.leave(); this._resolveEnd(null); } return; } if (g === 'up') { this.playChain(['help_main']); return; } if (g === 'down') this.closeMenu(); this.invalid(); } openMenu() { this.menuOpen = true; this.menuIdx = 0; this.percept({ type: 'earcon', kind: 'menu' }); this.playChain(['sys_menu', MENU_ITEMS[0], 'settings_hint'], { tag: 'menu-open' }); } closeMenu() { this.menuOpen = false; this.voiceChainToken += 1; if (this.phase === 'wave' || this.phase === 'teach') this.percept({ type: 'earcon', kind: 'battle' }); } invalid() { this.percept({ type: 'vibrate', kind: 'error', note: '无效手势' }); // gd_invalid onceKey 同局 1 次(full 档) if (!this._invalidSaid) { this._invalidSaid = true; this.playChain(['gd_invalid'], { queueFirst: true }); } } // ---- 战斗意图(本地门控与 game.js 一致)---- attackDir(dir) { const now = this.now(); if (now < this.atkReadyAt) { if (!this._atkBlockedThisCd) { // v2 S2:每冷却窗首拦才震 + 就绪轻叮 this._atkBlockedThisCd = true; this.percept({ type: 'vibrate', kind: 'error', note: '出击冷却中首拦' }); const wait = Math.max(0, this.atkReadyAt - now); this.timer(() => { if (this._atkBlockedThisCd && (this.phase === 'wave' || this.phase === 'teach')) { this._atkBlockedThisCd = false; this.percept({ type: 'vibrate', kind: 'beat', note: '出击就绪轻叮' }); } }, wait); } this.recorder.log({ t: 'attack_blocked_local', dir }); return; } this._atkBlockedThisCd = false; this.atkReadyAt = now + ATTACK_CD_MS; this.actedThisWave = true; this.sendAct('attack', dir); this.pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk', extra: { dir } }); } callout() { const now = this.now(); if (now < this.calloutReadyAt) { this.percept({ type: 'vibrate', kind: 'error', note: '呼喊节流被拦' }); return; } this.calloutReadyAt = now + CALLOUT_THROTTLE_MS; this.actedThisWave = true; this.sendAct('callout'); if (this.char) this.npcFire('sfx/npc_' + this.char + '_help_call', 0); else this.pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this.sector)), vol: 1, type: 'whistle' }); } skill() { const now = this.now(); if (now < this.skillReadyAt) { this.threatReport(); return; } this.skillReadyAt = now + SKILL_CD_MS; this.skillAnnounced = false; this.actedThisWave = true; this.sendAct('skill'); this.timer(() => { if (this.skillAnnounced) return; this.skillAnnounced = true; if (this.phase === 'wave' || this.phase === 'teach') { this.playChain(['skill_ready'], { queue: true }); } }, SKILL_CD_MS); } threatReport() { const inner = [...this.mobs.values()].filter((m) => { const e = this.mobEta(m); return e <= INNER_ETA_S && e > 0; }).length; const id = inner === 0 ? 'threat_none' : inner <= 2 ? 'threat_few' : 'threat_many'; this.playChain([id], { queueFirst: true, tag: 'threat-relisten' }); } statusIds() { // v2 S3/S9:0 杀不播 kill_band0;与上次播报一致的分段跳过 const ids = [hpBandId(this.dur), stWaveId(this.wave || 1), this.roster.size >= 4 ? 'team_4' : 'team_3', killBandId(this.myKills)] .filter((id) => id !== 'kill_band0' || this.myKills > 0); const changed = ids.filter((id) => !this._lastStatusSaid || !this._lastStatusSaid.includes(id)); this._lastStatusSaid = ids; return changed; } statusChain(onDone = null) { this.playChain(this.statusIds(), { queueFirst: true, tag: 'status-chain', onDone }); } arcHasTarget(arc) { for (const m of this.mobs.values()) { if (!arc.includes(m.dir)) continue; const eta = this.mobEta(m); if (eta <= INNER_ETA_S && eta > 0) return true; } return false; } // ================= 音频通道模型 ================= playBgm(id, vol = 1) { if (this.bgm === id) return; this.bgm = id; this.percept({ type: 'bgm', id, vol }); } /** * 语音链(R1 移植)。opts: { protect, queueFirst, queue, tag, onDone } * - queue:true / queueFirst:true:等当前句与队列自然走完(不顶) * - protect:保护链标识——警报只能插队(当前句后),不切断 * - 无 protect 无 queue:硬播,立即切断当前句并清空队列(引擎"打断即作废") */ playChain(ids, opts = {}) { if (!ids.length) { if (opts.onDone) opts.onDone(); return; } this.voiceChainToken += 1; const token = this.voiceChainToken; if (opts.protect) this.voiceProtect = opts.protect; const now = this.now(); const items = ids.map((id) => ({ id, dur: this.voiceDur(id) })); if (!opts.queue && !opts.queueFirst && !opts.protect) { // 硬播:切断当前句(记 cut),清队列 if (this.voiceQ.length) { this.percept({ type: 'voice_cut', id: this.voiceQ[0].id, note: '被警报类顶掉' }); } this.voiceQ = []; this.voiceBusyUntil = now; this.voiceProtect = null; } else if (opts.protect && this.voiceProtect && this.voiceProtect !== opts.protect && this.voiceQ.length > 1) { // 新保护链插队到当前句之后,旧保护链剩余句顺延(真客户端 guard-advance 近似: // 波次播报会打断教学当前句,但教学链剩余句由看门狗推进照播,不整链蒸发) this.voiceQ.splice(1, 0, ...items.map((it) => ({ ...it, protect: opts.protect, tag: opts.tag || null, token }))); this.voiceBusyUntil = Math.min(this.voiceBusyUntil, now + this.voiceQ[0].dur * 1000); if (opts.onDone) this.timer(opts.onDone, this.vDurTotal(ids) * 1000); return; } else if (!opts.queueFirst && this.voiceQ.length && opts.protect !== this.voiceProtect) { // 新保护链顶旧非保护链(教学跳步等):切断 this.voiceQ = []; this.voiceBusyUntil = now; } this.voiceQ.push(...items.map((it) => ({ ...it, protect: opts.protect || null, tag: opts.tag || null, token }))); if (opts.onDone) { this.timer(() => { if (token === this.voiceChainToken || !opts.protect) opts.onDone(); }, this.vDurTotal(ids) * 1000 + (opts.queueFirst ? 3000 : 200)); } } /** 警报:保护链在播 → 排队(当前句后);否则硬播顶掉。 */ alert(id) { if (this.voiceProtect || this.phase === 'teach') this.playChain([id], { queue: true, tag: 'alert-queued' }); else this.playChain([id], { tag: 'alert-interrupt' }); } /** 音频 tick(100ms):推进三个通道状态机 + loop 重估(500ms 节拍)。 */ audioTick() { const now = this.now(); // voice if (this.voiceQ.length && now >= this.voiceBusyUntil) { const item = this.voiceQ.shift(); this.voiceBusyUntil = now + item.dur * 1000; this.percept({ type: 'voice', id: item.id, text: this.text(item.id), dur: item.dur, protect: item.protect, tag: item.tag, end: now + item.dur * 1000, }); } if (!this.voiceQ.length && this.voiceProtect && now >= this.voiceBusyUntil) this.voiceProtect = null; // npc(200ms 泵节奏并入 100ms tick) if (this.npcPending && now > this.npcPending.expireAt) { this.percept({ type: 'npc_dropped', id: this.npcPending.id, note: '延迟超时丢弃(R2)' }); this.npcPending = null; } if (this.npcPending && now >= this.npcBusyUntil && !this.voiceQ.length && now >= this.voiceBusyUntil) { const p = this.npcPending; this.npcPending = null; this.npcFire(p.id, p.pan, p.onDone); } // pos one-shot 泵(100ms,与客户端一致) this.posActive = this.posActive.filter((a) => a.until > now); if (this.posQueue.length) { const rest = []; for (const q of this.posQueue) { if (q.droppable && now - q.queuedAt > HOWL_QUEUE_TTL_MS) { this.percept({ type: 'oneshot_dropped', id: q.id, note: '呼啸排队过期丢弃(R4)' }); continue; } const last = this.posTypeAt[q.type] ?? -1e9; if (this.posActive.length < ONESHOT_MAX && now - last >= ONESHOT_SPACING_MS) { this.posFire(q.id, q.pan, q.vol, q.type, now, q.extra); } else rest.push(q); } this.posQueue = rest; } // 看门狗(gd_idle 10s;25s 纯震) if ((this.phase === 'wave' || this.phase === 'lobby') && !this.menuOpen) { const idle = now - this.lastGestureAt; if (idle > 10000 && !this.watchdogSaid1) { this.watchdogSaid1 = true; // v2 S4:WAVE 内出过手 → 一级降纯震;语音闲置每局 ≤1 次 const canSay = this.phase === 'lobby' || (!this.actedThisWave && !this._idleVoiceSaid); if (canSay) { if (this.phase === 'wave') this._idleVoiceSaid = true; this.playChain(['gd_idle'], { queueFirst: true, tag: 'watchdog' }); } else { this.percept({ type: 'vibrate', kind: 'confirm', note: '闲置一级纯震' }); } this.percept({ type: 'watchdog', level: 1 }); } else if (idle > 25000) { this.percept({ type: 'vibrate', kind: 'confirm', note: '看门狗2级纯震' }); this.lastGestureAt = now - 12000; // 不再连震 } } // loop 重估(500ms 节拍) if (!this._loopAt || now - this._loopAt >= LOOP_REFRESH_MS) { this._loopAt = now; this.refreshLoops(); } } refreshLoops() { if (this.phase !== 'wave' && this.phase !== 'teach') { for (const id of [...this.loops.keys()]) this.stopLoop(id); 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.stopLoop(id); for (const { m, eta } of cand) { const az = azOf(m.dir); const pan = panOfAz(az); const bandVol = eta > 4 ? 0.3 : eta > 2.5 ? 0.55 : 0.8; const band = eta > 4 ? 'mid' : eta > 2.5 ? 'inner1' : 'inner2'; const vol = bandVol * (isBackAz(az) ? 0.6 : 1); const h = this.loops.get(m.id); if (!h) { const handle = { id: m.id, mkind: m.mkind, dir: m.dir, band, vol, pan, eta, muffled: isBackAz(az), // v2 S6:低通第二线索(音色+响度双线索判前后) }; this.loops.set(m.id, handle); this.percept({ type: 'loop_start', ...handle }); } else if (h.band !== band) { h.band = band; h.vol = vol; h.eta = eta; this.percept({ type: 'loop_band', ...h }); } else { h.eta = eta; h.vol = vol; } } } stopLoop(id) { const h = this.loops.get(id); if (!h) return; this.loops.delete(id); this.percept({ type: 'loop_stop', id }); } pos(id, { pan = 0, vol = 1, type = 'misc', droppable = false, extra = null } = {}) { const now = this.now(); this.posActive = this.posActive.filter((a) => a.until > now); const last = this.posTypeAt[type] ?? -1e9; if (this.posActive.length < ONESHOT_MAX && now - last >= ONESHOT_SPACING_MS) { this.posFire(id, pan, vol, type, now, extra); return; } if (droppable && this.posQueue.filter((q) => q.type === 'howl').length >= HOWL_QUEUE_MAX) { this.percept({ type: 'oneshot_dropped', id, note: '呼啸队列满丢弃(R4)' }); return; } this.posQueue.push({ id, pan, vol, type, droppable, queuedAt: now, extra }); } posFire(id, pan, vol, type, now, extra = null) { this.percept({ type: 'oneshot', kind: type, id, pan, vol, extra }); const durMs = (SFX_DUR[id.slice(id.lastIndexOf('/') + 1)] ?? 0.5) * 1000; this.posActive.push({ until: now + durMs }); this.posTypeAt[type] = now; } npcSchedule(id, pan, prio = false, onDone = null) { const now = this.now(); const voiceBusy = this.voiceQ.length > 0 || now < this.voiceBusyUntil; if (now >= this.npcBusyUntil && !voiceBusy) { this.npcFire(id, pan, onDone); return; } this.npcPending = { id, pan, onDone, expireAt: prio ? Infinity : now + NPC_VOICE_DELAY_MS }; // v2 S5③ } npcFire(id, pan, onDone = null) { const now = this.now(); this.npcPending = null; const dur = this.npcDur(id); this.npcBusyUntil = now + dur * 1000; this.percept({ type: 'npc', id, pan, vol: NPC_VOL, dur, text: this.text(id.slice(4)), end: now + dur * 1000 }); if (onDone) this.timer(onDone, dur * 1000); } armWatchdog() { this.lastGestureAt = this.now(); this.watchdogSaid1 = false; } }