research(hunt-sim): 10 人并发在线模拟×多 agent 设计迭代——研究设施与全套档案

研究命题(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
This commit is contained in:
xsl
2026-09-19 11:06:07 +08:00
parent 963dfa1cc8
commit 0652c2f0e6
224 changed files with 78576 additions and 0 deletions
+956
View File
@@ -0,0 +1,956 @@
/**
* 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; }
}