fix(hunt): v2 九修 S1-S9 落地——10 人并发模拟迭代裁定(同阵容复测:去留 6/10→10/10)

研究闭环:v1 三轮 28 局四死因(失衡/社交真空/交互摩擦/观光羞辱)→ 四视角分析 →
synthesis S1-S9 裁定 → 实施 → 同 10 人配置复测两轮 20 局。依据 research/hunt-sim/
(synthesis.md / metrics-v2.md / report.md),设计登记 23-hunt.md §16。

服务端 hunt-room.js:
- S5 协作人声包:真人座位角色接线(杀熊/三连杀/旋风斩≥3 播角色句、greet 全座位);
  满编真人房呼喊 2.5s 无 bot 应答 → 邻座角色代答 help_ack;喊话预算 10s 窗 2→3
- S7 难度重构:waveCounts 4 人房 ×4/3;alignPincers 第 3-5 波齐压拍(跨座位同种类
  同秒进内圈,种子确定性保持);mobYielded bot 对人类防区怪让位 2s(只约束 bot,
  不拦真人避免挥空真话说谎);gradeForEx 双轴评级(S=耐久≥9 且 ≥2 人各 6 杀,
  单人带飞局上限 A)

客户端 game.js:
- S1 WAVE 下滑恒出击(无怪=空挥真话),菜单收窄到波间休整窗(E3 修订)——
  v1 误触菜单单人单局最多 14 次清零
- S2 冷却首拦才震 + 就绪 beat 轻叮(v1 全场 666 次乱滑连震)
- S3 0 杀不播 kill_band0(「你还没开张」下架;复测 P08 0杀→12杀)
- S4 看门狗重定义:本波出过手一级语音不触发、语音闲置每局 ≤1、一级降纯震
- S5③ help_call/help_ack 永不超时丢弃
- S6 后半平面 loop 低通 750Hz 第二线索(N/S 判读 63/69%→75/81%)
- S8 教学作答步(T1-T3)下滑不再误跳
- S9 状态链按变化播(「固若金汤」复读归零)

测试同步:hunt-sim A1/A2/A12 断言更新(真人 greet/波次缩放/预算 3)、
playtest Block 3 按 S1 新语义改写(战斗中下滑=空挥 + 波间菜单)

回归:hunt-sim 16/16 + playtest 213 + guide 132 + engine-voice 19 全绿;
balance-scan:挂机局 5/8 落 C/失守带(v1 恒满血)、optimal 单人带飞封顶 A
遗留:营地零损耗未破(18/20 局余10)——v3 头号,参数方案 report.md §5
This commit is contained in:
xsl
2026-09-19 11:06:18 +08:00
parent 0652c2f0e6
commit d78df35b5c
6 changed files with 299 additions and 52 deletions
+93 -23
View File
@@ -156,6 +156,8 @@ export class HuntGame extends Game {
// 本地冷却预扣(服务端 err 为准;冷却内手势本地先拦)
this._atkReadyAt = 0;
this._atkBlockedThisCd = false; // v2 S2:每冷却窗首拦才震
this._atkReadyCueTimer = null;
this._skillReadyAt = 0;
this._skillAnnounced = true; // 每轮冷却完成只播一次 skill_ready
this._skillReadyTimer = null;
@@ -303,6 +305,8 @@ export class HuntGame extends Game {
this._tut = -1;
this._tutDoneWaiting = false;
this.wave = n;
this._actedThisWave = false; // v2 S4:每波重新计「在场证明」
this._restUntil = 0;
this.audio.play('bgm/hunt');
if (!wasInWave) {
this.guide.state('battle');
@@ -331,9 +335,10 @@ export class HuntGame extends Game {
this.guide.state('settle');
this._saveProgress(ev); // 结算 PUT(含 summary),end_rank 播到时已写入
const win = !!ev.win;
const myBand = kills > 0 ? [killBandId(kills)] : []; // v2 S3:0 杀不播「你还没开张」(沉默优于羞辱)
const chain = win
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), killBandId(kills), 'end_rank', 'end_again']
: ['end_lose', killBandId(kills), 'end_again'];
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank', 'end_again']
: ['end_lose', ...myBand, 'end_again'];
this._endChain = chain;
this._playChain(chain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } });
}
@@ -391,7 +396,9 @@ export class HuntGame extends Game {
}
_onTeachGesture(g) {
if (g === 'down') { // 任意步下滑 = 跳过教学(§5)
// v2 S8:跳过保护——作答步(T1/T2/T3)内下滑 = 答错处理(不再误跳教学,
// P04 教学局手滑跳过记恨三局);只有 T0/T4 非作答态下滑才跳过
if (g === 'down' && (this._tut === 0 || this._tut === 4 || this._tut === -1)) { // 跳过教学(§5)
this._chainSeq++;
this.audio.stopVoice();
this._tut = -1;
@@ -437,12 +444,12 @@ export class HuntGame extends Game {
if (g === 'tap') { this._callout(); return; }
if (g === 'doubletap') { this._skill(); return; }
if (g in SWIPE_BASE) {
// 语境折中(见文件头裁决):上/下滑弧内有可打怪 → 出击;否则
// 上滑=状态链、下滑=菜单(三件套保留)。左/右滑恒=出击。
const arc = ARCS[g];
const hasTarget = this._arcHasTarget(arc);
if (!hasTarget && g === 'up') { this._statusChain(); return; }
if (!hasTarget && g === 'down') { this._openMenu(); return; }
// v2 S1(E3 修订):下滑恒出击(无怪=空挥真话 atk_swish),菜单只在波间
// 休整窗(waveclear 后 8s)开放——修复战斗中误触菜单(R1 三人中招 14 次/局)
const now = performance.now();
const inRest = now < this._restUntil;
if (g === 'down' && inRest) { this._openMenu(); return; }
if (g === 'up' && !this._arcHasTarget(ARCS.up)) { this._statusChain(); return; }
this._attackDir(SWIPE_BASE[g]);
return;
}
@@ -511,20 +518,43 @@ export class HuntGame extends Game {
// ================================================================ 战斗动作(客户端意图)
/** 出击:冷却内仅 error 震(无语音、不发 act,§2.1)。 */
/** 出击:冷却内仅每个冷却窗首拦震一次(v2 S2:连拦静默),就绪轻叮一记。 */
_attackDir(dir) {
const now = performance.now();
if (now < this._atkReadyAt) { vibrate('error'); return; }
if (now < this._atkReadyAt) {
if (!this._atkBlockedThisCd) {
this._atkBlockedThisCd = true; // 首拦才震:连滑不再连环 error 震(R1 全场 666 次)
vibrate('error');
this._armAtkReadyCue();
}
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' });
}
/** 冷却就绪提示:本冷却窗内被拦过 → 就绪时刻轻叮一记(beat 震,无语音占用)。 */
_armAtkReadyCue() {
if (this._atkReadyCueTimer !== null) clearTimeout(this._atkReadyCueTimer);
const wait = Math.max(0, this._atkReadyAt - performance.now());
this._atkReadyCueTimer = setTimeout(() => {
this._atkReadyCueTimer = null;
if (this._atkBlockedThisCd && (this.phase === 'wave' || this.phase === 'teach')) {
this._atkBlockedThisCd = false;
vibrate('beat');
}
}, wait);
}
/** 呼喊支援:节流 5s/人;本机播自己座位角色 help_call(中央;座 3=哨音带 pan)。 */
_callout() {
const now = performance.now();
if (now < this._calloutReadyAt) { vibrate('error'); return; }
this._calloutReadyAt = now + CALLOUT_THROTTLE_MS;
this._actedThisWave = true;
this._sendAct('callout');
if (this._myChar) this._npcFire(`sfx/npc_${this._myChar}_help_call`, 0, null);
else this._pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this._mySector)), vol: 1, type: 'whistle' });
@@ -536,6 +566,7 @@ export class HuntGame extends Game {
if (now < this._skillReadyAt) { this._threatReport(); return; }
this._skillReadyAt = now + SKILL_CD_MS;
this._skillAnnounced = false;
this._actedThisWave = true;
this._sendAct('skill');
this._armSkillReady(SKILL_CD_MS);
}
@@ -558,18 +589,24 @@ export class HuntGame extends Game {
this._playChain([id], { queueFirst: true });
}
/** 状态链(D 组):耐久档 → 波次档 → 在场猎人数 → 自己击杀档(queue:true 链播)。 */
/** 状态链(D 组):v2 S9 按变化播——与上次播报一致的段跳过(「固若金汤」复读消除);
* 全部无变化 → 仅 confirm 轻震(沉默优于复读)。 */
_statusIds() {
return [
const ids = [
hpBandId(this.dur),
stWaveId(this.wave || 1),
this._teamCount() >= 4 ? 'team_4' : 'team_3',
killBandId(this._myKills),
];
].filter((id) => id !== 'kill_band0' || this._myKills > 0); // v2 S3: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, onDone });
const ids = this._statusIds();
if (!ids.length) { vibrate('confirm'); if (onDone) onDone(); return; }
this._playChain(ids, { queueFirst: true, onDone });
}
_arcHasTarget(arc) {
@@ -780,7 +817,10 @@ export class HuntGame extends Game {
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._playChain(['wave_clear'], { queueFirst: true }); break;
case 'waveclear':
this._restUntil = performance.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;
@@ -808,6 +848,7 @@ export class HuntGame extends Game {
this._pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill' });
if (ev.slot === this.slot) {
this._myKills++;
this._actedThisWave = true;
vibrate('pickup'); // 命中
}
// 教学 T1:首杀 → tut_kill_ok → T2
@@ -967,7 +1008,10 @@ export class HuntGame extends Game {
if (h.panner) h.panner.pan.value = pan;
return;
}
const handle = { want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind };
const handle = {
want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind,
muffled: isBackAz(az), // v2 S6:身后怪标记(低通第二线索)
};
this._loops.set(m.id, handle);
this.audio._load('sfx/loop_' + m.mkind).then((buf) => {
if (!buf || !handle.want || this._loops.get(m.id) !== handle) return;
@@ -979,14 +1023,24 @@ export class HuntGame extends Game {
const gain = ctx.createGain();
gain.gain.value = this.audio.sfxVolume * vol;
src.connect(gain);
let tail = gain;
if (handle.muffled && typeof ctx.createBiquadFilter === 'function') {
// v2 S6:后半平面低通 750Hz——音色发闷叠加响度 ×0.6,N/S 轴判读从
// 「纯响度猜」变「响度+音色双线索」(R1 实测 N/S 可命中仅 63/69%)
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 750;
tail.connect(lp);
tail = lp;
}
if (typeof ctx.createStereoPanner === 'function') {
const panner = ctx.createStereoPanner();
panner.pan.value = pan;
gain.connect(panner);
tail.connect(panner);
panner.connect(this.audio._sfxBus);
handle.panner = panner;
} else {
gain.connect(this.audio._sfxBus); // 老 Safari 静默降级
tail.connect(this.audio._sfxBus); // 老 Safari 静默降级
}
src.start();
handle.src = src;
@@ -1074,7 +1128,8 @@ export class HuntGame extends Game {
}
this._npcPending = {
id, pan, onDone,
expireAt: now + (priority ? NPC_PRIO_TTL_MS : NPC_VOICE_DELAY_MS),
// v2 S5③:呼救/应答语义永不超时丢弃(等待投递);氛围句才受 ≤2s 延迟上限
expireAt: priority ? Infinity : now + NPC_VOICE_DELAY_MS,
};
this._armNpcPump();
}
@@ -1172,14 +1227,24 @@ export class HuntGame extends Game {
this.guide.error({ voice: 'gd_invalid', onceKey: 'hunt_invalid', skipHaptic: true });
}
/** 看门狗(§11):WAVE/LOBBY 无操作 [10s,25s] → gd_idle / null;TEACH 逐步 [8s] 纯 nudge。 */
/** 看门狗(§11 + v2 S4 重定义):WAVE 内本波出过手 → 一级语音永不触发(等待是
* 合法玩法,不是挂机);一级降纯震;语音闲置每局 ≤1 次;重教句只留在教学局。
* LOBBY 照旧(room_wait 语境合理)。 */
_rearmWatchdog() {
if (this._menuOpen) return;
if (this.phase === 'wave' || this.phase === 'lobby') {
if (this.phase === 'wave') {
const sayIdle = !this._actedThisWave && !this._idleVoiceSaid;
if (sayIdle) this._idleVoiceSaid = true; // 每局最多一次语音闲置提醒
this.guide.watchdog({
at: [10000, 25000],
say: [sayIdle ? 'gd_idle' : null, null],
cond: () => this.phase === 'wave' && !this._menuOpen,
});
} else if (this.phase === 'lobby') {
this.guide.watchdog({
at: [10000, 25000],
say: ['gd_idle', null],
cond: () => (this.phase === 'wave' || this.phase === 'lobby') && !this._menuOpen,
cond: () => this.phase === 'lobby' && !this._menuOpen,
});
} else if (this.phase === 'teach' && this._tut >= 0) {
this._armTutWatchdog(this._tut);
@@ -1363,6 +1428,11 @@ export class HuntGame extends Game {
this._posQueue.length = 0;
this._posActive = [];
this._dropAllNpc();
this._actedThisWave = false; // v2 S4/S9:每局复位(在场证明/状态链去重/闲置语音额度)
this._idleVoiceSaid = false;
this._lastStatusSaid = null;
this._restUntil = 0;
this._atkBlockedThisCd = false;
}
_teardownRound() {
+20 -3
View File
@@ -278,6 +278,7 @@ const evMiss = (slot, dir, ts = now) => ({ t: 'ev', kind: 'miss', ts, slot, dir
const evSkill = (slot, cleared, ts = now) => ({ t: 'ev', kind: 'skill', ts, slot, cleared });
const evBreach = (id, mkind, dir, dur, ts = now) => ({ t: 'ev', kind: 'breach', ts, id, mkind, dir, dur });
const evWave = (wave, ts = now) => ({ t: 'ev', kind: 'wave', ts, wave });
const evWaveclear = (wave, ts = now) => ({ t: 'ev', kind: 'waveclear', ts, wave });
const evCallout = (slot, ts = now) => ({ t: 'ev', kind: 'callout', ts, slot });
const evBotline = (slot, line, ts = now) => ({ t: 'ev', kind: 'botline', ts, slot, line });
const evRoster = (roster, ts = now) => ({ t: 'ev', kind: 'roster', ts, roster });
@@ -841,9 +842,21 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2)
await flush();
// 无 S 弧目标时下滑 = 菜单(三件套保留):sys_menu + 首项 + settings_hint
// v2 S1:战斗中下滑恒出击(无怪 = 空挥真话 atk_swish),不再弹菜单
const cdT0 = performance.now();
await advance(() => performance.now() - cdT0 >= 2600); // 过 2.5s 出击冷却
const atkBefore = ws.acts(conn, 'attack').length;
await g(game, 'down');
assert(game._menuOpen === true, '无目标下滑 → 模态菜单打开');
assert(ws.acts(conn, 'attack').length === atkBefore + 1
&& ws.acts(conn, 'attack')[atkBefore].dir === 'S',
'v2 S1:战斗中无怪下滑 = 空挥出击(act attack dir:S)');
assert(game._menuOpen === false, 'v2 S1:战斗中下滑绝不打开菜单');
// v2 S1:菜单只在波间休整窗(waveclear 后 8s)开放
ws.inject(conn, evWaveclear(1));
await flush();
await g(game, 'down');
assert(game._menuOpen === true, '波间休整窗下滑 → 模态菜单打开');
await advance(() => playsOf(playLog, 'voice/settings_hint') === 1);
assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume', 'settings_hint']),
'菜单打开即播 sys_menu + 首项 + settings_hint(设置只读、回大厅修改)');
@@ -874,8 +887,10 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
assert(game._menuOpen === false, '菜单内下滑 = 关闭');
// 双击确认:选中"重新听帮助"→ 播 help_main 且菜单不收
ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗
await flush();
await g(game, 'down');
assert(game._menuOpen === true, '菜单再次打开');
assert(game._menuOpen === true, '菜单再次打开(休整窗内)');
await advance(() => !audio.isVoicePlaying());
await g(game, 'tap'); // idx 1 = sys_menu_help
await g(game, 'doubletap');
@@ -886,6 +901,8 @@ realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 ==');
assert(game._menuOpen === false, '菜单再次关闭');
// 双击确认"退出房间回大厅":发 leave + 跳转大厅
ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗
await flush();
await g(game, 'down');
await advance(() => !audio.isVoicePlaying());
await g(game, 'tap');