using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Gameplay; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Attack AI — action layer for SERVER-SPAWNED enemies (GameAdminBridge.SpawnAgentAsync). /// /// Why this exists: BattleAiS only switches the unit onto its combat behaviour tree; it is /// not a decision loop, and the client's tree on this build does not close the distance or /// open an attack on its own for a server-spawned unit. So the server issues bounded native /// commands — move / turn / cast — and the client owns the animation, the movement and the /// hit detection. Nothing here teleports an enemy or fabricates a hit: every packet is a /// request the client's own systems execute. /// /// Compatibility scope, stated up front: this is NOT a reconstruction of the retail behaviour /// trees. Complex navigation, vehicle/seat skills, grabs/executions and co-op sequences are /// out of scope. Short-range approach can be blocked by geometry. /// /// Damage: intentionally zero. Enemy hits on the player are visual feedback only (see /// ApplyEnemyPlayerHitFeedbackAsync) — no HP, no death, no resource packets. /// internal sealed partial class GameRouter { /// Flat distance at which the enemy stops closing in and swings instead. private const float EnemyAiAttackRangeMeters = 2.8f; /// Flat distance at which the enemy notices the player at all. // 2026-09-26:原为硬编码 30f,现提到 Aether.EnemyAiAggroRangeMeters。 // ⛑ 填 0 ⇒ EnemyAiSelectTarget 恒返回 0 ⇒ 敌人永不主动锁定玩家(保持未察觉) // ⇒ 蹲伏暗杀才有窗口(已交战的敌人不可暗杀)。想恢复原行为填 30。 private static float EnemyAiAggroRangeMeters => MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiAggroRangeMeters); /// /// Worker poll period. Was 500ms, which alone put up to half a second of dead air between /// "the previous walk command ran out" and "the next one is dispatched" — the loop walks /// every enemy serially, so the real gap grew with the enemy count. 100ms keeps that gap /// below what the eye reads as a stop. /// private const int EnemyAiPollMs = 100; /// Move dispatch interval. Each command is a finite walk (MoveTime), so re-issuing /// faster than that would restart the walk instead of extending it. private const int EnemyAiMoveIntervalMs = 1000; // ---- 移动节奏(2026-09-23:修「走一步停一下」的抽搐)---- // // SyncEnemyBattleMoveTowardUnit 是**有限位移**:客户端只走 MoveTime 秒就停在那儿等下一条。 // 原来 MoveTime=0.65s 而派发间隔 1000ms ⇒ 走 0.65s、干站 0.35s,看起来就是一步一顿。 // 所以这两个值必须配对:间隔 ≥ 时长(短了会打断),又不能长太多(长了就是停顿)。 // 都从 PrivateServerConfig 读,便于实机微调,不用重编译。 /// 单条移动命令让客户端走多久(秒)。 private static float EnemyAiMoveSecondsNow() => Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveSeconds, 0.25f, 12f); /// 派发间隔:默认贴着时长 +50ms;配置值更大时以配置为准。 private static int EnemyAiMoveIntervalMsNow(float moveSeconds) => Math.Max((int)(moveSeconds * 1000f) + 50, Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveIntervalMs)); /// /// 每次派发把估算锚点推进多少米。1.2 m 是 MoveTime=0.65s 时调出来的,按时长等比放大, /// 免得「估算还在 5m 外、实体其实已经贴脸」导致它一直发移动命令原地挤。 /// private static float EnemyAiMoveStepNow(float moveSeconds) => EnemyAiMoveStepMeters * (moveSeconds / 0.65f); /// How long a cast is assumed to occupy the unit, and the gap before the next /// one. Conservative on purpose: overwriting an attack every frame produces twitching. private const int EnemyAiCastBusyMs = 6000; private const int EnemyAiCastIntervalMs = 6500; /// /// 攻击后的冻结时长改为可配。原来是硬编码 6000/6500 ms —— 打一下原地站 6.5 秒, /// 这也是"走一步停一下"里那段"停"的来源之一(玩家看到的就是打完杵着不动)。 /// 配置缺项时退回硬编码默认值,避免老配置直接把敌人变成连击机关枪。 /// private static int EnemyAiCastBusyMsNow() => Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiCastBusyMs, 300, 30000); private static int EnemyAiCastIntervalMsNow() => Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiCastIntervalMs, 400, 30000); /// Suppression after the enemy itself was hit, so being shot does not make it /// attack faster. private const int EnemyAiHurtSuppressMs = 1500; /// Estimated advance per move command. The client never reports coordinates /// back, so the anchor is walked towards the player by this much each dispatch purely to /// decide when to switch from "closing in" to "swing". Tune against observed behaviour. private const float EnemyAiMoveStepMeters = 1.2f; /// /// 客户端走这条命令的实际速度(米/秒),由 donor 配方反推:MoveTime=0.65s 走 1.2m。 /// 用来把「还剩几米」换算成「这条命令该走几秒」,好让一条命令正好走完剩下的路 —— /// 命令越少起停越少,"走一步停一下"就越不明显。 /// private const float EnemyAiMoveMetersPerSecond = EnemyAiMoveStepMeters / 0.65f; /// Worker exit threshold once nothing is left to drive. private const int EnemyAiIdleExitMs = 15000; /// /// Automatic control-state release, timed off each cast. /// /// Enemy casts are faked by us but the hit — and the stiffness / cast-lock states that /// come with it — are resolved entirely client-side, and on a real server the same /// settlement pass that applies them also releases them. Nobody does that here, so they /// stick and the player is left unable to move or act until they press E (which only /// frees movement, not the cast lock). /// /// There is no C2S "player was hit" report to hook, so the release is scheduled from the /// one thing we do know: when a cast went out. Two passes are queued per cast — the /// early one catches fast swings, the late one catches slow skills whose impact lands /// well after the animation starts. Verified 2026-09-21: the curated set restores the /// player from a boss hit. /// private const int EnemyAiUnlockDelayMsEarly = 2200; private const int EnemyAiUnlockDelayMsLate = 5000; /// /// When the server settles an enemy hit on the player, counted from the cast dispatch. /// The client resolves the impact locally and never reports it, so this is an estimate — /// it reuses the early release window because that is the proven "fast swing" landing time. /// private const int EnemyAiHitDelayMs = 2200; /// How many commands get a log line before the log goes quiet. private const int EnemyAiVerboseLogLimit = 3; /// /// 日志密度:前 3 条必打,之后每 N 条打一条(N 来自配置 EnemyAiVerboseLogEvery)。 /// 原来只打前 3 条 —— 实机上敌人一次接敌要走十几条移动命令,后面全被吞掉, /// 排查「一步一停」时完全看不到节奏。 /// private static bool EnemyAiShouldLog(int count) { if (count <= EnemyAiVerboseLogLimit) return true; var every = Math.Max(1, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiVerboseLogEvery); return count % every == 0; } /// /// Fallback attack pool, used only for the handful of templates whose whole behaviour set /// is unusable (6 of 3160 as of 2026-09-21 — e.g. 地缺/天残 whose kit is entirely /// placeholder). These two are the metallic guitarist's verified swings. A borrowed skill /// does not animate on every skeleton, so a template that closes in and then freezes is /// what this fallback looks like when it fails. /// private static readonly uint[] EnemyAiFallbackAttacks = [51900523, 51900524]; /// Resolved once per template: the behaviour set's own attacks when it has any. /// 2026-09-26 Wave 2:改走 —— /// 战斗模式开启时这里拿到的就是模式套件(melee 空手 / ranged 远程),与 spawn 时 /// 绑的技能槽、换的武器、推的动画集是同一份解析,不会出现「手拿空手动画集、 /// 甩的却是模板武器技能」的错位。off 时 = Find 本体,旧行为不变。 private static IReadOnlyList EnemyAiAttacksFor(uint templateId) { var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(templateId); return profile is not null && profile.Attacks.Count > 0 ? profile.Attacks : EnemyAiFallbackAttacks; } /// Chinese name of one skill in a template's pool, for logs. "" when unknown. /// ⛑ Wave 2:anames 只覆盖模板原配攻击集;模式套件(空手/远程池)查不到名字, /// 日志里就是空串 —— 只影响可读性,不影响行为。 private static string EnemyAiAttackName(uint templateId, uint skillId) { var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(templateId); if (profile is null) return string.Empty; for (var i = 0; i < profile.Attacks.Count && i < profile.AttackNames.Count; i++) if (profile.Attacks[i] == skillId) return profile.AttackNames[i]; return string.Empty; } private static async Task RunEnemyAttackAiAsync(TcpSession session) { var runtime = GetLocalEnemyRuntimeState(session); var lastWorkTicks = Environment.TickCount64; session.Log.Info("[ENEMY-AI] worker started"); try { while (true) { await Task.Delay(EnemyAiPollMs); // 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (IsBackgroundPushPaused(session)) continue; var state = GetStateIfExists(session); var now = Environment.TickCount64; ulong[] ids; lock (runtime.SyncRoot) ids = runtime.Enemies.Values .Where(e => e.AttackAiEnabled && !e.Dead) .OrderBy(e => e.AttackAiActionAt) .Select(e => e.Uid) .ToArray(); // 虚弱出口 + 尸体清理兜底(GameRouter.EnemyStiff.cs),跑在"还有没有活敌人"的判断 // 之前:两者都只服务已经不参与 AI 的单位(虚弱中的、已经死的),挂在 ids 后面会 // 在最后一个活敌人倒下时被跳过。 await MaintainLocalEnemyGroggyAsync(session, now); await SweepLocalEnemyDeathClearsAsync(session, now); if (ids.Length == 0) { if (now - lastWorkTicks > EnemyAiIdleExitMs) { session.Log.Info("[ENEMY-AI] worker exited (no enemies)"); return; } continue; } lastWorkTicks = now; if (state is null) continue; foreach (var id in ids) { await RefreshEnemyEngageAsync(session, id, state, now); await RefreshEnemyActionAsync(session, id, state, now); } // 警戒状态机(血条显示时机)—— GameRouter.EnemyWarning.cs。 // 排在 AI 派发之后:它只改 HUD,不该抢在移动/放技能命令前面。 // 内部自己节流到 2 s,100 ms 的 tick 频率不会变成刷包。 await RefreshLocalEnemyWarningsAsync(session, state, now); // ★ 蹲伏暗杀播种(硬规则 192)—— GameRouter.EnemyAiActions.cs 底部。 // 客户端的候选筛选是自举死锁:PreCheckCanAss 要 canAssassinate==true, // 而它只能由这条 S2C 写入,这条 S2C 又名义上是「收到上报才回」。 // 不主动播种 ⇒ ReportCanAssassinateEnemies 永远为 0。内部自己节流。 await SeedAssassinationCandidatesAsync(session, state, now); await DrainPendingUnlocksAsync(session, state, now); } } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] worker stopped: {ex.Message}"); } finally { lock (runtime.SyncRoot) runtime.AttackAiWorkerStarted = false; } } /// /// Fires the queued control-state releases that have come due. Several casts can queue /// deadlines that land in the same poll, so every due deadline collapses into one batch — /// otherwise a boss swinging twice in a row would send the same 24 packets four times. /// private static async Task DrainPendingUnlocksAsync(TcpSession session, WorldEntryState state, long now) { var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) { if (!runtime.PendingUnlocks.Any(t => t <= now)) return; runtime.PendingUnlocks.RemoveAll(t => t <= now); } var unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : 100000000000UL; try { await GameAdminBridge.ClearControlStatesAsync(session, unit, GameAdminBridge.PlayerControlStates, "auto"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] auto-unlock failed unit={unit}: {ex.Message}"); } } /// /// ★ 蹲伏暗杀播种(硬规则 192)—— 主动下发 SyncEnemyCanAssassinate(68218494)。 /// /// 为什么要这一段:客户端的候选筛选是自举死锁。 /// FeiSuoCrouchManager.CheckCanCrouchAss(0x14CB11E0) → /// PreCheckCanAss(0x131F6700) 要求目标的 BattleDataModule.canAssassinate /// (私有字段,偏移 0xB1)== true;该字段全 .text 唯一写入点就是这条 S2C 的接收函数 /// (0x127D102F mov byte ptr [rax+0xB1], bl,set_CanAssassinate 零调用者); /// 而这条 S2C 名义上是 ReportCanAssassinateEnemies(67161256) 的回应, /// 上报却必须先过 canAssassinate ⇒ 没有任何敌人天生带标记 ⇒ 循环永远启动不了 /// (实机:AskEnterFeiSuoCrouch 19 次 / ReportCanAssassinateEnemies 0 次)。 /// 官方用 Spoon 剧本动作 L18.Spoon.EnemySetCanAssassinate(dump.cs:3194822) 播种, /// 我们没有 Spoon 系统 ⇒ 用这条主动下发替代。 /// /// ⛑⛑ 只在蹲伏期间播:离开蹲伏态由 RevokeAllAssassinationGrants 全部撤销。 /// ⛑ 每个敌人每次蹲伏只播一次(AssassinationSeededAgents):播完客户端若判定 /// 它不合格,会上报一个不含它的名单 ⇒ 上报路径撤销它;只看 /// AssassinationGrantedAgents 的话播种会以为「没授权过」而重播, /// 撤销→播种→撤销来回甩。离开半径后重新靠近才会再播。 /// private static async Task SeedAssassinationCandidatesAsync(TcpSession session, WorldEntryState state, long now) { if (!WebTraversal.PushAssassinationSeed || !WebTraversal.AnswerCrouchAssassination) return; // 不蹲伏 ⇒ 不播,并把节流清零,下次进蹲伏立刻播第一波。 if (!state.FeiSuoCrouchActive) { state.AssassinationSeedAtTicks = 0; return; } if (now - state.AssassinationSeedAtTicks < WebTraversal.AssassinationSeedIntervalMs) return; state.AssassinationSeedAtTicks = now; if (!state.HasLastReportedPlayerTransform || state.ActiveSpiritUnitId == 0) return; var runtime = GetLocalEnemyRuntimeState(session); var player = state.LastReportedPlayerPosition; var rSq = WebTraversal.AssassinationSeedRadiusMeters; rSq *= rSq; var toGrant = new List(); var toRevoke = new List(); lock (runtime.SyncRoot) { foreach (var e in runtime.Enemies.Values) { if (e is null || e.Dead || e.Uid == 0) continue; // 服务端生成的敌人用估算位(AiPosition,由服务端移动命令推进); // 客户端自己生成的(打过才入账)只有命中点 Position。 var p = e.ServerSpawned ? e.AiPosition : e.Position; var dx = player.X - p.X; var dz = player.Z - p.Z; if (!float.IsFinite(dx) || !float.IsFinite(dz)) continue; if (dx * dx + dz * dz <= rSq) { if (state.AssassinationGrantedAgents.Contains(e.Uid)) continue; // 已经授权着(可能是上报路径给的) if (!state.AssassinationSeededAgents.Add(e.Uid)) continue; // 本次蹲伏已播过,客户端自己否了 toGrant.Add(e.Uid); } else if (state.AssassinationSeededAgents.Remove(e.Uid)) { // 走出半径 ⇒ 允许下次靠近再播一次;若此刻还授权着就撤掉。 if (state.AssassinationGrantedAgents.Contains(e.Uid)) toRevoke.Add(e.Uid); } } } foreach (var uid in toRevoke) { state.AssassinationGrantedAgents.Remove(uid); try { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate, UxSerializer.Serialize(new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = uid, canAssassinate = false }), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[ASSASS] seed-revoke failed uid={uid}: {ex.Message}"); } } foreach (var uid in toGrant) { state.AssassinationGrantedAgents.Add(uid); try { await session.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate, UxSerializer.Serialize(new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = uid, canAssassinate = true }), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[ASSASS] seed-grant failed uid={uid}: {ex.Message}"); } } if (toGrant.Count > 0 || toRevoke.Count > 0) session.Log.Info($"[ASSASS] seed grant=+{toGrant.Count} revoke=-{toRevoke.Count} " + $"radius={WebTraversal.AssassinationSeedRadiusMeters:F1}m total={state.AssassinationGrantedAgents.Count}"); } /// 0 when the player is too far, unknown, or the enemy is busy dying. private static ulong EnemyAiSelectTarget(LocalEnemyRecord enemy, WorldEntryState state) { if (enemy.Dead || !state.HasLastReportedPlayerTransform || state.ActiveSpiritUnitId == 0) return 0; var player = state.LastReportedPlayerPosition; var dx = player.X - enemy.AiPosition.X; var dz = player.Z - enemy.AiPosition.Z; if (!float.IsFinite(dx) || !float.IsFinite(dz)) return 0; // 滞回(2026-09-24):单阈值会让 engagement 在边界反复开关,而每次开关都要重发 // LockTarget / FightEdict / BattleAiS —— 客户端动作层被重置、刚起步的动画被裁断, // 实测表现就是"走一步就僵住"。所以已经咬住的目标用更大的脱离半径。 var limit = EnemyAiAggroRangeMeters; if (enemy.AttackAiTarget != 0) limit = MathF.Max(limit, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiAggroExitMeters); return dx * dx + dz * dz <= limit * limit ? state.ActiveSpiritUnitId : 0; } /// Engagement batch, re-sent only when the lock actually changes — spamming it /// resets the client's action layer and cuts every animation short. private static async Task RefreshEnemyEngageAsync(TcpSession session, ulong uid, WorldEntryState state, long now) { var runtime = GetLocalEnemyRuntimeState(session); var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; ulong target; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead) return; target = EnemyAiSelectTarget(enemy, state); if (target == enemy.AttackAiTarget) return; // 二次防抖:刚翻过的状态先按住 EnemyAiEngageHoldMs,别在边界来回甩。 if (now - enemy.AttackAiEngageChangedAt < Math.Max(0, cfg.EnemyAiEngageHoldMs)) return; enemy.AttackAiTarget = target; enemy.AttackAiEngageChangedAt = now; } try { await session.NotifyAsync(MethodId.SyncUnitLockTarget, UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = uid, targetId = target }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyFightEdict, UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = target != 0 }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncBattleAiS, UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = uid, value = target != 0 }), CancellationToken.None); session.Log.Info($"[ENEMY-AI] engage entity={uid} player={target} attackToken={target != 0}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] engage failed entity={uid}: {ex.Message}"); } } private static async Task RefreshEnemyActionAsync(TcpSession session, ulong uid, WorldEntryState state, long now) { var runtime = GetLocalEnemyRuntimeState(session); var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; var moveSeconds = EnemyAiMoveSecondsNow(); var moveInterval = EnemyAiMoveIntervalMsNow(moveSeconds); var moveStep = EnemyAiMoveStepNow(moveSeconds); // 踱步比追击走得更久:一条命令内是连续的,命令次数越少起停越少。 var patrolSeconds = Math.Clamp(cfg.EnemyAiPatrolMoveSeconds, 0.5f, 6f); var patrolInterval = (int)(patrolSeconds * 1000f) + 50; SceneMethods.EnemyAiMoveTowardUnit4229938? move = null; SceneMethods.EnemyAiFace4229938? face = null; SceneMethods.EnemyAiSkillParam4229938? cast = null; int moveCount = 0, castCount = 0; uint castTemplate = 0, castSkill = 0; bool wantPatrol = false; (float Flat, int Count)? suppressed = null; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead || !enemy.AttackAiEnabled) return; var target = EnemyAiSelectTarget(enemy, state); if (target != enemy.AttackAiTarget) return; // 玩家在仇恨范围(30m)之外:EnemyAiSelectTarget 给不出目标,原来直接 return ⇒ // 敌人从生成到玩家走近这段时间完全不动。这里改交给哨兵踱步(见 DispatchEnemyPatrolStepAsync)。 if (target == 0) { wantPatrol = cfg.EnemyAiPatrolEnabled && now >= enemy.AttackAiActionAt; return; } if (now < enemy.AttackAiActionAt || now < enemy.AttackAiCastUntil || (enemy.LastHurtFeedbackTicks > 0 && now - enemy.LastHurtFeedbackTicks < EnemyAiHurtSuppressMs)) return; // Stance break ("虚弱"): a drained stance pool suspends the swing entirely for // LocalEnemyGroggyMs, independent of HP. This is the reward for pressuring the enemy // instead of trading damage with it, and it is the only thing that makes the stance // bar worth showing. The pool refills when the break is applied, so this window ends // on its own — nothing has to reset it. if (now < enemy.GroggyUntilTicks) { enemy.AttackAiActionAt = now + 250; return; } var player = state.LastReportedPlayerPosition; var dx = player.X - enemy.AiPosition.X; var dy = player.Y - enemy.AiPosition.Y; var dz = player.Z - enemy.AiPosition.Z; // Never attack through a floor, and never "fix" coordinates by teleporting the // unit: wait for the player to come back to this level. if (!float.IsFinite(dx + dy + dz) || Math.Abs(dy) > 2.5f) { enemy.AttackAiActionAt = now + moveInterval; return; } var flat = MathF.Sqrt(dx * dx + dz * dz); var moveId = unchecked(++enemy.AttackAiMoveId); if (moveId == 0) moveId = unchecked(++enemy.AttackAiMoveId); if (flat > EnemyAiAttackRangeMeters) { moveCount = ++enemy.AttackAiMoveCount; // ★ 实验开关:关掉后不再发有限位移命令,看客户端自己的战斗 AI 会不会把接近 // 动作走完 —— 2026-09-24 实测:关掉后敌人**会攻击但完全不移动**, // 说明 forbidAetherAI=true 的敌人客户端不会自己走,只能服务端喂命令。 if (!cfg.EnemyAiServerMoveEnabled) { // 不推进估算锚点:客户端没接到命令就不会走,锚点要是自己贴上去, // 敌人就会隔着半条街开始挥空气。 enemy.AttackAiActionAt = now + moveInterval; suppressed = (flat, moveCount); return; } // 一条命令走完剩下的路,中间就不用补第二条 —— 客户端「走 MoveTime 秒就停」, // 命令越长起停越少。按剩余距离反算需要几秒,比整段短就按短的走(免得冲过玩家), // 比整段长就封顶在配置值,剩下的交给下一条。 var needSeconds = (flat - EnemyAiAttackRangeMeters) / EnemyAiMoveMetersPerSecond; var useSeconds = Math.Clamp(needSeconds, 0.4f, moveSeconds); var useInterval = (int)(useSeconds * 1000f) + 50; var useStep = EnemyAiMoveStepNow(useSeconds); enemy.AttackAiActionAt = now + useInterval; move = new SceneMethods.EnemyAiMoveTowardUnit4229938 { EntityId = uid, TargetId = target, MoveId = moveId, InstanceId = moveId, MoveTime = useSeconds, // 略短于 MoveTime:客户端「至少走这么久」,防止它提前刹车收脚。 MinMoveTime = MathF.Max(0.1f, useSeconds * 0.9f), ReportOnStop = true, }; moveInterval = useInterval; moveStep = useStep; // Advance the estimate towards the player so repeated dispatches eventually // cross the attack threshold. The real movement is the client's. if (flat > moveStep) { var k = moveStep / flat; enemy.AiPosition = new Vec3( enemy.AiPosition.X + dx * k, enemy.AiPosition.Y, enemy.AiPosition.Z + dz * k); } else { enemy.AiPosition = new Vec3(player.X, enemy.AiPosition.Y, player.Z); } } else { // Per-template first: a skill belongs to a skeleton, so the behaviour set's own // attacks are the only ones guaranteed to animate on this rig. var pool = EnemyAiAttacksFor(enemy.EnemyId); var skill = pool[enemy.AttackAiCastCount % pool.Count]; enemy.AttackAiCastInstance = enemy.AttackAiCastInstance == int.MaxValue ? 1 : enemy.AttackAiCastInstance + 1; var facing = MathF.Atan2(dx, dz) * (180f / MathF.PI); var deltaAngle = (facing - enemy.Facing) % 360f; if (deltaAngle > 180f) deltaAngle -= 360f; if (deltaAngle < -180f) deltaAngle += 360f; // Turn first: several authored NPC skills carry NoRotation=true, so the cast // alone would swing at whatever the unit happened to be facing. face = new SceneMethods.EnemyAiFace4229938 { pid = uid, fromAngle = enemy.Facing, changeAngle = deltaAngle, }; cast = new SceneMethods.EnemyAiSkillParam4229938 { entityId = uid, targetId = target, moveId = moveId, instanceId = enemy.AttackAiCastInstance, skillId = skill, rotate = facing, unitPosition = new SceneMethods.UxVector3(enemy.AiPosition.X, enemy.AiPosition.Y, enemy.AiPosition.Z), location = new SceneMethods.UxVector3(player.X, player.Y, player.Z), faceToPos = new SceneMethods.UxVector3(player.X, player.Y, player.Z), SelfMobilityPos = new SceneMethods.UxVector3(enemy.AiPosition.X, enemy.AiPosition.Y, enemy.AiPosition.Z), TarMobilityPos = new SceneMethods.UxVector3(player.X, player.Y, player.Z), }; castTemplate = enemy.EnemyId; castSkill = skill; // Queue the player's release. Timing is the closest thing to a hit report we // have: the stiff lands somewhere inside the cast animation. if (runtime.AutoUnlockEnabled) { runtime.PendingUnlocks.Add(now + EnemyAiUnlockDelayMsEarly); runtime.PendingUnlocks.Add(now + EnemyAiUnlockDelayMsLate); } enemy.Facing = facing; enemy.AttackAiCastSkill = skill; enemy.AttackAiCastUntil = now + EnemyAiCastBusyMsNow(); enemy.AttackAiActionAt = now + EnemyAiCastIntervalMsNow(); castCount = ++enemy.AttackAiCastCount; // Logged once per enemy: the whole pool, so a boss that was silently falling // back to two borrowed swings is visible in the log. if (castCount == 1) session.Log.Info($"[ENEMY-AI] attack-pool entity={uid} template={enemy.EnemyId} size={pool.Count} skills={string.Join(',', pool)} names={string.Join(',', pool.Select(s => EnemyAiAttackName(enemy.EnemyId, s)))}"); } } if (wantPatrol) { await DispatchEnemyPatrolStepAsync(session, uid, state, now, cfg, patrolSeconds, patrolInterval, moveStep); return; } if (suppressed is not null && EnemyAiShouldLog(suppressed.Value.Count)) session.Log.Info($"[ENEMY-AI] move-suppressed entity={uid} (enemyAiServerMoveEnabled=false) " + $"flat={suppressed.Value.Flat:F1}m count={suppressed.Value.Count} " + $"— 若此时实体仍在朝你移动,说明客户端行为树会自己走,服务端不该插手"); try { if (move is not null) { await session.NotifyAsync(MethodId.SyncEnemyBattleMoveTowardUnit, UxSerializer.Serialize(move), CancellationToken.None); if (EnemyAiShouldLog(moveCount)) session.Log.Info($"[ENEMY-AI] move-dispatched entity={uid} target={move.TargetId} instance={move.InstanceId} duration={move.MoveTime} gap={moveInterval}ms count={moveCount}"); } else if (cast is not null) { await TurnEnemyToAsync(session, uid, cast.targetId, face); await session.NotifyAsync(MethodId.SyncUseTargetSkill, UxSerializer.Serialize(cast), CancellationToken.None); if (EnemyAiShouldLog(castCount)) session.Log.Info($"[ENEMY-AI] cast-dispatched entity={uid} target={cast.targetId} skill={cast.skillId} name={EnemyAiAttackName(castTemplate, castSkill)} instance={cast.instanceId} count={castCount} method=SyncUseTargetSkill"); // Server-authoritative damage. There is no C2S "player was hit" report, so the hit // is settled off the same clock as the control-state release: the impact lands // somewhere inside the cast animation. No-op while the GM vitals lock is on. _ = ApplyEnemyHitToPlayerDelayedAsync(session, state, uid, cast.skillId); } } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] action dispatch failed entity={uid}: {ex.Message}"); } } /// /// 出招前让单位面向目标。三种方式由 gameplay.aether.enemyAiTurnPacket 选 /// (): /// target(默认)= SyncUnitSmoothFaceToTarget,把目标单位甩给客户端算 —— /// 转得准,因为客户端知道自己的真实朝向; /// smooth = 旧的 SyncUnitSmoothFace(fromAngle, changeAngle),服务端按估算 /// 朝向算增量 —— 估算一旦过期就会转歪(实机上就是"转 90° 打空气"); /// off = 完全不发转向包(对照用:若技能都带 NoRotation=true,此时应当朝哪打哪)。 /// /// ⛑ 单位是度(记忆 2026-09-21.md:1066:Facing 与 EulerAngles.y 同值), /// 一开始怀疑过的"度/弧度搞反"已经排除了。 /// internal static async Task TurnEnemyToAsync(TcpSession session, ulong uid, ulong targetId, SceneMethods.EnemyAiFace4229938? smooth) { var mode = PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiTurnPacket; try { if (string.Equals(mode, "off", StringComparison.OrdinalIgnoreCase)) return; if (string.Equals(mode, "smooth", StringComparison.OrdinalIgnoreCase)) { if (smooth is null) return; await session.NotifyAsync(MethodId.SyncUnitSmoothFace, UxSerializer.Serialize(smooth), CancellationToken.None); return; } // 默认 target:官方的 "face the target unit" 包,转角由客户端算。 if (targetId == 0) { if (smooth is null) return; await session.NotifyAsync(MethodId.SyncUnitSmoothFace, UxSerializer.Serialize(smooth), CancellationToken.None); return; } await session.NotifyAsync(MethodId.SyncUnitSmoothFaceToTarget, UxSerializer.Serialize(new SceneMethods.EnemyAiFaceToTarget4229938 { pid = uid, targetPid = targetId, rotationSpeed = 720, }), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] turn failed entity={uid} mode={mode}: {ex.Message}"); } } /// 踱步两段之间随机停顿多久(毫秒),避免所有敌人同步齐步走。 private static int RandomPause(AetherSettings cfg) { var lo = Math.Max(0, cfg.EnemyAiPatrolPauseMinMs); var hi = Math.Max(lo, cfg.EnemyAiPatrolPauseMaxMs); return lo + Random.Shared.Next(Math.Max(1, hi - lo + 1)); } /// /// 哨兵踱步:玩家不在仇恨范围(30m)时让敌人「走一段 → 停一会 → 倒着走回来」。 /// /// 原来这段时间是完全不动的(EnemyAiSelectTarget 给不出目标 ⇒ RefreshEnemyActionAsync 直接 /// return),敌人从生成到玩家走近一直杵着。这里复用同一条 BattleMove 通道,靠 /// WalkFront / WalkBack 交替让位移互相抵消 —— 它只会原地来回踱步,不会一路漂到玩家身上。 /// /// ⛑️ 服务端拿不到真实坐标(客户端不回传),所以 AiPosition 是估算;估算漂太远会让实体和 /// 锚点对不上,因此用 HomePosition 做保险绳:离出生点超过 leash 就强制往回走。 /// private static async Task DispatchEnemyPatrolStepAsync( TcpSession session, ulong uid, WorldEntryState state, long now, AetherSettings cfg, float moveSeconds, int moveInterval, float moveStep) { var runtime = GetLocalEnemyRuntimeState(session); var playerUnit = state.ActiveSpiritUnitId; if (playerUnit == 0) return; SceneMethods.EnemyAiMoveTowardUnit4229938? move = null; SceneMethods.MoveWanderingData4229938? wanderData = null; int moveCount = 0; bool forward = true; int pause = 0; int wanderMs = 0; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead || !enemy.AttackAiEnabled) return; // 玩家可能刚好跨进 30m —— 那就交回战斗逻辑,别再踱步。 if (EnemyAiSelectTarget(enemy, state) != 0) return; // ---- 实验路线:官方游荡包(默认关)---- // SyncMoveWandering 不带目的地,客户端自己挑路径连续走,行人走的就是它 ⇒ // 真正的连续行走,而不是一串"走 N 秒然后停"的有限位移命令。 if (cfg.EnemyAiWanderEnabled) { var secs = Math.Clamp(cfg.EnemyAiWanderSeconds, 3f, 60f); var minDis = Math.Max(0.5f, cfg.EnemyAiWanderMinDis); var maxDis = Math.Max(minDis + 1f, cfg.EnemyAiWanderMaxDis); wanderData = new SceneMethods.MoveWanderingData4229938 { PathTags = cfg.AmbientPedsWanderPathTags, CloseObstacleAvoidance = true, MinDis = minDis, MaxDis = maxDis, InRangeAngle = cfg.AmbientPedsWanderLeftAngle, OutRangeAngle = cfg.AmbientPedsWanderRightAngle, MaxTime = secs + 5f, MaxOnceWanderTime = secs, UnitId = uid, MoveType = 0, MoveId = WorldCodec.MoveAction.WalkFront, CloseIK = false, InstanceId = 0, BlockCloseCapsuleCollision = false, }; wanderMs = (int)(secs * 1000f); pause = RandomPause(cfg); // 游荡位移不回传,AiPosition 保持不动 —— 所以只能靠估算判断仇恨,别走太远。 enemy.AttackAiActionAt = now + wanderMs + pause; moveCount = ++enemy.AttackAiMoveCount; return; } var home = enemy.HomePosition; if (!float.IsFinite(home.X + home.Z)) home = enemy.AiPosition; var pl = state.LastReportedPlayerPosition; var hx = pl.X - enemy.AiPosition.X; var hz = pl.Z - enemy.AiPosition.Z; var hlen = MathF.Sqrt(hx * hx + hz * hz); if (!float.IsFinite(hlen) || hlen < 0.001f) return; var fromHomeX = enemy.AiPosition.X - home.X; var fromHomeZ = enemy.AiPosition.Z - home.Z; var leash = MathF.Max(2f, cfg.EnemyAiPatrolLeashMeters); var wantForward = enemy.PatrolForward; if (fromHomeX * fromHomeX + fromHomeZ * fromHomeZ > leash * leash) wantForward = (fromHomeX * hx + fromHomeZ * hz) < 0; // 已经跑远了:这一步往回走 var step = MathF.Min(moveStep, MathF.Max(0.3f, hlen - 2f)); var dir = wantForward ? 1f : -1f; var pMoveId = unchecked(++enemy.AttackAiMoveId); if (pMoveId == 0) pMoveId = unchecked(++enemy.AttackAiMoveId); move = new SceneMethods.EnemyAiMoveTowardUnit4229938 { EntityId = uid, TargetId = playerUnit, BattleMoveActionType = wantForward ? WorldCodec.MoveAction.WalkFront : WorldCodec.MoveAction.WalkBack, MoveId = pMoveId, InstanceId = pMoveId, MoveTime = moveSeconds, ReportOnStop = true, }; enemy.AiPosition = new Vec3( enemy.AiPosition.X + (hx / hlen) * step * dir, enemy.AiPosition.Y, enemy.AiPosition.Z + (hz / hlen) * step * dir); enemy.PatrolForward = !wantForward; moveCount = ++enemy.AttackAiMoveCount; forward = wantForward; pause = RandomPause(cfg); enemy.AttackAiActionAt = now + moveInterval + pause; } if (wanderData is not null) { try { await session.NotifyAsync(MethodId.SyncMoveWandering, UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938 { Data = wanderData }), CancellationToken.None); if (EnemyAiShouldLog(moveCount)) session.Log.Info($"[ENEMY-AI] patrol-wander entity={uid} seconds={wanderMs / 1000} " + $"pause={pause}ms count={moveCount}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] wander dispatch failed entity={uid}: {ex.Message}"); } return; } if (move is null) return; try { await session.NotifyAsync(MethodId.SyncEnemyBattleMoveTowardUnit, UxSerializer.Serialize(move), CancellationToken.None); if (EnemyAiShouldLog(moveCount)) session.Log.Info($"[ENEMY-AI] patrol-step entity={uid} dir={(forward ? "WalkFront" : "WalkBack")} " + $"instance={move.InstanceId} duration={move.MoveTime} pause={pause}ms " + $"step={moveStep:F2}m count={moveCount}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY-AI] patrol dispatch failed entity={uid}: {ex.Message}"); } } /// /// Waits out the cast animation and then settles the hit on the player. /// /// Fire-and-forget on purpose: the AI worker must keep polling, and a session that disappears /// during the wait must not surface as an AI failure. /// private static async Task ApplyEnemyHitToPlayerDelayedAsync( TcpSession session, WorldEntryState state, ulong uid, uint skillId) { try { await Task.Delay(EnemyAiHitDelayMs, CancellationToken.None); await ApplyEnemyHitToPlayerAsync(session, state, uid, skillId); } catch (Exception ex) { session.Log.Warn($"[PLAYER-HIT] delayed settlement failed entity={uid}: {ex.Message}"); } } /// /// C2S end-of-move report. Carries a pid and nothing else — no token, no success flag — /// so it is logged as a diagnostic acknowledgement and never treated as proof of arrival /// or as a reason to shorten the move interval. /// [Handler(MethodId.ReportEnemyBattleMoveEnd, HandlerPacketKind.Notify)] private Task ReportEnemyBattleMoveEnd(Connection conn, UxRpcMessage msg) { try { if (!msg.TryGetArgs(out var report) || report is null) return Task.CompletedTask; var runtime = GetLocalEnemyRuntimeState(conn.Session); lock (runtime.SyncRoot) if (runtime.Enemies.TryGetValue(report.pid, out var enemy) && enemy.AttackAiEnabled) conn.Session.Log.Info($"[ENEMY-AI] native-move-end entity={report.pid}; end-report-not-arrival-proof"); } catch (Exception ex) { conn.Log.Info($"[ENEMY-AI] move-end report unparsed: {ex.Message}"); } return Task.CompletedTask; } }