研究命题(00-plan):验证并打磨《围猎》联机纯音频体验——不是逻辑能跑通, 而是真人玩起来是什么体验、愿不愿意再来一局。 设施 web/harness/hunt-sim/(可复用,零依赖): - arena.mjs 编排器:起真实服务器(随机端口+临时数据目录隔离),10 个拟人客户端 经真实 WS 同时在线,错峰组队、真实时长对局、局间状态跨轮维护 - sim-client.mjs:game.js 行为移植(协议+手势路由+R1-R8 音频调度逐字节对齐), 产出「玩家耳朵听到什么」的感知时间线(语音全文/loop 方位音量档/喊话/震动) - brain.mjs:人设大脑——察觉延迟/前后判读(以真值为条件的正确率模型)/掩蔽放大/ 冷却乱滑/技能囤积/呼喊倾向/分心断网,全部随机判定用按实体 id 哈希的独立抽签 (mulberry32 流会被刷怪座位轮转结构带偏,实测造成系统性镜像反转,已证伪) - recorder.mjs:每人每局 events.jsonl 全量 + percept.md 感知转写 + 对局速览 研究档案 research/hunt-sim/: - players/:10 人配置(LLM 亲笔)+策略自述+逐局去留决策+人设终评+退游者重访 - rounds/:v1 三轮(R0 教学+R1/R2 真人快速)+ v2 复测两轮,共 48 局全记录 - analysis/01-04:数值曲线/音频信息设计/协作社交/上手留存四视角报告 - synthesis.md(S1-S9 裁定)、metrics.md/metrics-v2.md(两版对比总账)、report.md(总报告) v1 核心发现:10 人 3 轮 28 局 100% 胜率 26S/营地零损耗;击杀垄断(专家单人 30/39); 呼喊 3 次/28 局应答 0;N/S 前后判读仅 63/69% 可命中;四种退游死因 (失衡/社交真空/交互摩擦/观光无聊)→ 详见 report.md
957 lines
36 KiB
JavaScript
957 lines
36 KiB
JavaScript
/**
|
||
* 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; }
|
||
}
|