using System.Threading; using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 敌人警戒状态机 —— 敌人 HUD(血条 + 挂它上面的警戒条)什么时候出现。 /// /// 移植自捐赠端 7k-v0.5 的 Ananta.Handlers/Game/GameRouter.EnemyWarning.cs(2026-09-26)。 /// /// 为什么需要它:客户端的 EnemyHUDCtrl 把潜行警戒条绑在 unitDataSet.detectionState 上 /// (:85 → OnShowDetectionEffect),而 detectionState 只由 /// SyncEnemyDetectStatus(68126766) 写。我们原来在 EngageSpawnedEnemyAsync 里 /// 一 engage 就发 Fight(4),于是敌人一生成警戒条就在那; /// 捐赠端是先注册 Idle(1),靠距离推进到 Alert(3) / Fight(4)。 /// /// ⛑⛑⛑ 2026-09-26 02:1x 订正(重要):这套动不了血条。 /// 血条的判据是 EnemyHUDCtrl.lua:551 RefreshHpVisible: /// canShowHp = cfg.ShowHPBar(模板配置,服务端改不了), /// shouldShowHp = ... and not HasState(unit, UnitStateConfig.HideHp) and ...。 /// 整条链上没有任何一处读 detectionState。 /// ⇒ "血条默认不显示、打了才显示" 要走 GameRouter.EnemyHpBar.cs(HideHp 10266)。 /// 实测「改了状态机血条还是一直挂着」就是这个原因,不是状态机没生效。 /// /// 设计要点(照抄捐赠端,别自创): /// · 状态不变不发包:每 2 s 才允许重估一次,且状态相同就直接跳过。 /// · 注册后两拍必须重发(Warmup=2):模型/occur 重建会把早期那份 HUD 快照丢掉, /// 只发一次的话血条就永远绑不上。 /// · 单轮最多 8 个:一次 tick 里不能给几十个敌人刷包。 /// · stealthValue 跟 state 走:Fight=100 / Alert=50 / 其余 0(捐赠端原值)。 /// · 死亡/清账必须 clear:留在 Fight 上的尸体会一直挂着一根空血条。 /// /// ⛑⛑ 这套只改 HUD 显示时机,不碰战斗激活:engage 那五件套 /// (LockTarget / FightEdict / DisarmState / BattleAiS / SyncEnemyDetect)原样不动, /// 敌人会不会打人跟这里无关。关掉开关就整体回到"一生成就有血条"。 /// internal sealed partial class GameRouter { // ---- EnemyDetectState(UX.Game.EnemyDetectState)---- private const byte LocalEnemyDetectStateIdle = 1; private const byte LocalEnemyDetectStateAlert = 3; /// 注册之后的重估间隔(ms)。状态在此期间内不变就不发包。 private const int LocalEnemyWarningRefreshMs = 2000; /// 注册后第一拍的重发延迟(ms)。 private const int LocalEnemyWarningWarmupMs = 1500; /// 注册后必须无条件重发的次数。见类注释第 2 条。 private const int LocalEnemyWarningWarmupCount = 2; /// 单轮最多刷几个敌人。 private const int LocalEnemyWarningMaxPerPass = 8; /// /// 距离平方。用账本里记的敌人坐标()对 /// 玩家最后上报的坐标;任一侧没有有效坐标时按"够远"处理(回落到 Idle)。 /// private static float LocalEnemyWarningDistanceSquared(LocalEnemyRecord record, WorldEntryState state) { var dx = record.Position.X - state.LastReportedPlayerPosition.X; var dy = record.Position.Y - state.LastReportedPlayerPosition.Y; var dz = record.Position.Z - state.LastReportedPlayerPosition.Z; return dx * dx + dy * dy + dz * dz; } /// /// 当前该显示的 detectionState。0 = 不参与(死人/已被骇成友军/坐标没就绪)。 /// /// 2026-09-26 Wave 4:25 m 内按 AttackRange 分 Alert(3) / Fight(4), /// 移植 7K EnemyWantsCloseCombat(GameRouter.EnemyAiActions.cs:23-25)的判据: /// 有攻击技能且距离² ≤ AttackRange² ⇒ Fight,否则 Alert(stealthValue=50)。 /// AttackRange 随 Wave 2 的目录列进来(近战 2.8 / 远程 18)——「远程单位站 20 m 外 /// 开枪是 Alert 不是 Fight」现在能表达了。距离用 /// 估算(追击中的敌人会贴近才升 Fight);未接敌时 = 出生点,与旧值一致。 /// internal static byte SelectLocalEnemyWarningState(LocalEnemyRecord record, WorldEntryState state) { // 死人不再参与。捐赠端这里还判了 IsHackedAlly(被骇成友军),我方账本没有这个字段 // ——被骇的敌人走的是 EnemyHacking 那条路,不进这里的账本。 if (record.Dead) return LocalEnemyDetectStateNone; // 玩家坐标都没上来 / 正在传送 / 正在切人 ⇒ 一律按"没发现"处理,别在这时候刷状态。 if (!state.Ready || state.ActiveSpiritUnitId == 0 || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0 || state.PendingSwitchTemplateId != 0) return LocalEnemyDetectStateNone; var radius = Math.Max(1f, EnemyHit.WarningRadiusMeters); var distSq = LocalEnemyWarningDistanceSquared(record, state); if (distSq > radius * radius) return LocalEnemyDetectStateIdle; // Wave 4:7K EnemyWantsCloseCombat 同款 —— 攻击圈内 Fight(4),圈内→25m 之间 Alert(3)。 var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(record.EnemyId); var combatDistSq = record.AttackAiEnabled ? LocalEnemyWarningAiDistanceSquared(record, state) : distSq; return profile is { Attacks.Count: > 0 } && combatDistSq <= profile.AttackRange * profile.AttackRange ? LocalEnemyDetectStateFight : LocalEnemyDetectStateAlert; } /// 同 ,但用 AI 估算位置 /// (追击中的敌人 会被 worker 逐步推向玩家)。 private static float LocalEnemyWarningAiDistanceSquared(LocalEnemyRecord record, WorldEntryState state) { var dx = record.AiPosition.X - state.LastReportedPlayerPosition.X; var dy = record.AiPosition.Y - state.LastReportedPlayerPosition.Y; var dz = record.AiPosition.Z - state.LastReportedPlayerPosition.Z; return dx * dx + dy * dy + dz * dz; } private static Task SendLocalEnemyWarningStateAsync(TcpSession session, ulong id, byte warningState) => session.NotifyAsync(MethodId.SyncEnemyDetectStatus, UxSerializer.Serialize(new GameMethods.SyncEnemyDetectStatusData4229938 { Id = id, // 捐赠端原值:Fight=100 / Alert=50 / 其余 0。 StealthValue = warningState == LocalEnemyDetectStateFight ? 100f : warningState == LocalEnemyDetectStateAlert ? 50f : 0f, State = warningState, }), CancellationToken.None); /// /// 注册一个敌人进状态机(spawn 之后调一次)。发 SyncEnemyDetect + Idle(1)。 /// 幂等:已经注册过就直接返回。 /// internal static async Task PrimeLocalEnemyWarningAsync(TcpSession session, ulong uid) { if (!EnemyHit.WarningStateMachine) return; var runtime = GetLocalEnemyRuntimeState(session); LocalEnemyRecord record; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var existing) || existing is null) return; // 只有 server-spawned 的走这套;client-spawned 是"第一次命中才进账本", // 本来就等同于"击中才显示"。 if (!existing.ServerSpawned || existing.WarningPrimed || existing.Dead) return; record = existing; } try { await session.NotifyAsync(MethodId.SyncEnemyDetect, UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = uid, Stealth = 0 }), CancellationToken.None); await SendLocalEnemyWarningStateAsync(session, uid, LocalEnemyDetectStateIdle); lock (runtime.SyncRoot) { record.WarningPrimed = true; record.WarningState = LocalEnemyDetectStateIdle; record.WarningWarmupRemaining = LocalEnemyWarningWarmupCount; record.WarningRefreshAt = Environment.TickCount64 + LocalEnemyWarningWarmupMs; } session.Log.Info($"[ENEMY-WARNING] registered uid={uid} state=Idle(1) — HUD hidden until the player closes in"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-WARNING] prime failed uid={uid}: {ex.Message}"); } } /// /// 周期重估。挂在 RunEnemyAttackAiAsync 的 tick 上(100 ms 一次,内部自己节流到 2 s)。 /// internal static async Task RefreshLocalEnemyWarningsAsync(TcpSession session, WorldEntryState state, long now) { if (!EnemyHit.WarningStateMachine) return; var runtime = GetLocalEnemyRuntimeState(session); ulong[] ids; lock (runtime.SyncRoot) ids = runtime.Enemies.Values .Where(e => e.WarningPrimed && !e.Dead) .OrderBy(e => e.WarningRefreshAt) .Select(e => e.Uid) .ToArray(); if (ids.Length == 0) return; int sent = 0; foreach (var id in ids) { byte next; bool warmup; LocalEnemyRecord record; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(id, out var existing) || existing is null) continue; if (!existing.WarningPrimed || existing.Dead) continue; record = existing; next = SelectLocalEnemyWarningState(existing, state); warmup = next != LocalEnemyDetectStateNone && existing.WarningWarmupRemaining > 0 && now >= existing.WarningRefreshAt; // 状态没变、又不在 warmup 期 ⇒ 什么都不做(这是不刷包的关键)。 if (next == existing.WarningState && !warmup) continue; } try { // warmup 重发要连 SyncEnemyDetect 一起补,捐赠端就是这么做的。 if (warmup) await session.NotifyAsync(MethodId.SyncEnemyDetect, UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = id, Stealth = 0 }), CancellationToken.None); await SendLocalEnemyWarningStateAsync(session, id, next); } catch (Exception ex) { session.Log.Warn($"[ENEMY-WARNING] refresh failed uid={id}: {ex.Message}"); continue; } lock (runtime.SyncRoot) { record.WarningState = next; if (warmup) record.WarningWarmupRemaining--; if (next == LocalEnemyDetectStateNone) { record.WarningPrimed = false; record.WarningWarmupRemaining = 0; } record.WarningRefreshAt = now + LocalEnemyWarningRefreshMs; } session.Log.Info($"[ENEMY-WARNING] uid={id} state={next} warmup={warmup}"); if (++sent >= LocalEnemyWarningMaxPerPass) break; } } /// /// 死亡 / 拆账本时清掉警戒状态:发 None(0),血条收起。 /// 不清的话尸体上会一直挂一根空血条。 /// internal static async Task ClearLocalEnemyWarningAsync(TcpSession session, ulong uid) { var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) { if (runtime.Enemies.TryGetValue(uid, out var rec) && rec is not null) { rec.WarningPrimed = false; rec.WarningState = LocalEnemyDetectStateNone; rec.WarningWarmupRemaining = 0; } } // 无条件发一次 None:从来没注册过的单位(client-spawned)本来就是 None,发一次幂等, // 这样也完整保留了 2026-09-26 之前"死亡时发 None 收血条"的行为(那条路径不走状态机)。 try { await SendLocalEnemyWarningStateAsync(session, uid, LocalEnemyDetectStateNone); session.Log.Info($"[ENEMY-WARNING] cleared uid={uid} state=None(0)"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-WARNING] clear failed uid={uid}: {ex.Message}"); } } }