Files
xsl 0652c2f0e6 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
2026-09-19 11:06:07 +08:00

957 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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; }
}