using System.Collections.Concurrent; using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 随从的服务端兜底驱动:跟随 + 协战。 /// /// ⛑⛑⛑ 2026-10-01 定位到更正的解法,本文件已不再是默认路径 /// (开关 gameplay.aether.companionServerDrive,默认 false): /// 随从模板用的是专属伴生行为树(晴 Companion_JiaMu_Shuangmawei、 /// 盖斯 Companion_Guys),它们的行为集里没有 Behavior_Battle 和 RelatedSkillIds, /// 战斗能力写在那棵树的 C# 实现里,服务端查不到 ⇒ 硬喂招只能给到 /// EnemyAiFallbackAttacks(金属吉他手那两下),这就是实机上看到的 /// 「只有远程射击动作、也没有武器、打不出伤害」。 /// 正解 = SyncAgentCompanionAI + SyncCompanionMaster + 模板自带的阵营 /// (UnitCamp.PlayerDefault = 1,见 CompanionBind.cs),然后让客户端那棵树自己跑。 /// /// ⛑⛑ 2026-10-01 更早的实机:只推 SyncCompanionMaster / SyncAgentCompanionAI 是不够的 —— /// 那两个包只登记「谁是谁的随从」,客户端不会因此给它下任何指令,实测就是站着不动的木桩。 /// 当时补的就是这条用主线本来就有的敌人战斗指令驱动: /// SyncBattleAiS(68555063) 开/关战斗 AI /// SyncUnitLockTarget(68009754) 锁定谁 /// SyncEnemyFightEdict(68882858) 给不给攻击令牌 /// SyncEnemyBattleMoveTowardUnit(68792181) 朝谁移动 /// 这四个包在 GameRouter.EnemyAiActions.cs:446-454 已经在用(驱动敌人追玩家)。 /// 捐赠端 7k v0.5-2 那 323 行 GarmCompanionAi.cs 的核心也就是这行 —— /// 所以它不需要整体移植,主线把同样的包换个目标发就行。 /// /// 跟随 = 没有敌人时把 target 留空、移动目标填主人 uid; /// 协战 = 选一个最近的敌人当 target,同时给攻击令牌。 /// internal sealed partial class GameRouter { private const int CompanionTickMs = 800; /// 每几条移动命令打一行日志(诊断"一步一停"用,别刷屏)。 private const int CompanionMoveLogEvery = 5; /// 随从离玩家多远之内才去找敌人打(米)。 private const float CompanionAggroMeters = 24f; /// 超过这个距离就放弃当前目标(米)。大于上面的进入值 ⇒ 滞回。 private const float CompanionDropMeters = 30f; /// 跟随时的停步距离(米)。捐赠端同款逻辑用的是 4f。 private const float CompanionFollowStopMeters = 4f; /// worker 最长活多久(兜底,防止漏 stop 之后线程越积越多)。 private const long CompanionMaxLifetimeMs = 15 * 60 * 1000; private sealed class CompanionAssistRuntime { public ulong UnitId; public ulong OwnerUnitId; /// /// 当前锁定的目标。初值必须是哨兵(不是 0):engagement 三件套只在目标变化时下发, /// 而"跟随"态的目标正是 0 —— 若初值也是 0,next != Target 恒为 false ⇒ /// 整局一个 engagement 包都发不出去,包括"关掉战斗 AI"那条,随从就一直保持 /// spawn 时的战斗姿态举着枪(2026-10-01 实机踩到)。 /// public ulong Target = ulong.MaxValue; /// 单调递增的 move 序号;契约里 MoveId/InstanceId 是 byte,落盘前截断。 public uint MoveSeq; public long NextMoveAt; /// /// 估算锚点。服务端拿不到实体真实坐标(客户端不回传),只能这么估。 /// spawn 用的是「全 0 pose = 落在玩家跟前」的约定 ⇒ 首帧拿玩家坐标当起点是准的。 /// public Vec3 Anchor; public bool HasAnchor; public int MoveCount; /// 朝向(度)。出招前先转身用 —— 官方技能很多带 NoRotation=true。 public float Facing; /// 出招冷却到期时间(TickCount64)。 public long CastUntil; /// 下次允许出招的时间。 public long ActionAt; /// 出招实例号;契约里 instanceId 是 int,别写成 uint。 public int CastInstance; public int CastCount; } /// 同一个单位只起一个 worker;uid 是 key。 private static readonly ConcurrentDictionary s_companionWorkers = new(); /// /// 随从的真实坐标(uid → 位置/时刻)。由 OnMovementReport 从客户端上报流里喂进来。 /// /// ⛑⛑ 2026-10-01 实机定案:没有它,服务端只能用"首帧玩家坐标 + 每次移动推进"的估算锚点, /// 误差几秒内就发散(日志里 flat 在 4.0/5.1/8.2 之间乱跳就是证据)⇒ 判定"贴脸了"时 /// 随从真实位置可能还在四五米外,对着空气挥拳 ⇒ **出招了但零命中上报**。 /// 捐赠端 GarmCompanionAi.cs 的 TrackGarmMovement 也是靠这条上报流拿随从坐标的。 /// private static readonly ConcurrentDictionary s_companionPositions = new(); /// 随从坐标新鲜多久之内算可用(毫秒)。超时就退回估算,免得拿几秒前的旧点当真。 private const long CompanionPositionFreshMs = 3000; /// 由 OnMovementReport 调用:喂一条随从的真实坐标。 internal static void NoteCompanionPosition(ulong uid, Vec3 pos) { var first = !s_companionPositions.ContainsKey(uid); s_companionPositions[uid] = (pos, Environment.TickCount64); if (first) GameAdminBridge.RequirePlayerSession()?.Session.Log.Info( $"[COMPANION] position feed online uid={uid} at=({pos.X:F1},{pos.Y:F1},{pos.Z:F1}) " + "— 出招距离判定改用真实坐标"); } /// 取随从的真实坐标;没有或过期返回 false。 private static bool TryGetCompanionPosition(ulong uid, out Vec3 pos) { pos = default; if (!s_companionPositions.TryGetValue(uid, out var entry)) return false; if (Environment.TickCount64 - entry.AtTicks > CompanionPositionFreshMs) return false; pos = entry.Pos; return true; } /// /// 给刚绑好的随从起一个驱动 worker。失败不影响随从本身(它已经生成了)。 /// internal static void StartCompanionAssist(ulong uid, ulong ownerUnitId) { if (uid == 0 || ownerUnitId == 0) return; if (!s_companionWorkers.TryAdd(uid, 0)) return; _ = Task.Run(() => RunCompanionAssistAsync(uid, ownerUnitId)); } /// 让某个随从停手(解散 / 单位消失时用)。 internal static void StopCompanionAssist(ulong uid) => s_companionWorkers.TryRemove(uid, out _); private static async Task RunCompanionAssistAsync(ulong uid, ulong ownerUnitId) { var runtime = new CompanionAssistRuntime { UnitId = uid, OwnerUnitId = ownerUnitId }; var started = Environment.TickCount64; try { var target = GameAdminBridge.RequirePlayerSession(); if (target is null) return; var session = target.Value.Session; for (var tick = 0; ; tick++) { await Task.Delay(CompanionTickMs).ConfigureAwait(false); // 会话断了 / worker 被取消 / 活太久 ⇒ 收工 if (session.Client is not { Connected: true }) return; if (!s_companionWorkers.ContainsKey(uid)) return; if (Environment.TickCount64 - started > CompanionMaxLifetimeMs) return; var world = GetStateIfExists(session); if (world is null || !world.Ready) continue; if (world.ActiveSpiritUnitId != ownerUnitId) continue; // 切角色/切场景,先按住 await TickCompanionAssistAsync(session, world, runtime, tick).ConfigureAwait(false); } } catch (Exception ex) { GameAdminBridge.RequirePlayerSession()?.Session.Log.Warn( $"[COMPANION] assist worker stopped uid={uid}: {ex.Message}"); } finally { s_companionWorkers.TryRemove(uid, out _); } } private static async Task TickCompanionAssistAsync(TcpSession session, WorldEntryState world, CompanionAssistRuntime rt, int tick) { var now = Environment.TickCount64; // ---------- 0. 锚点:优先用客户端上报的真实坐标,拿不到才用估算 ---------- // ⛑⛑ 估算锚点几秒内就发散(实测 flat 乱跳)⇒ 出招判定全靠它必然打空。 // 有了上报流就直接用真值,估算只当兜底。 if (TryGetCompanionPosition(rt.UnitId, out var realPos)) { rt.Anchor = realPos; rt.HasAnchor = true; } else if (!rt.HasAnchor) { if (!world.HasLastReportedPlayerTransform) return; rt.Anchor = world.LastReportedPlayerPosition; rt.HasAnchor = true; } // ---------- 1. 选目标:最近的活敌人;没有就 0(= 跟随玩家) ---------- var (next, dest) = SelectCompanionTarget(session, world, rt); // ⛑⛑ engagement 三件套只在目标真的变了才发。 // 原来每 6 个 tick 无条件重发一次 —— 主线注释(EnemyAiActions.cs:411-416 与 // EnemyAiAggroExitMeters 那段)已经把这坑写死了:每次重发都会重置客户端动作层、 // 裁断刚起步的动画,实测就是「走一步就僵住」。这是"一步一停"的第一来源。 if (next != rt.Target) { rt.Target = next; // ⛑ 捐赠端也是这四个包一起发的;BattleAiS=true 是「会动」的前提。 await session.NotifyAsync(MethodId.SyncUnitLockTarget, UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = rt.UnitId, targetId = next }), CancellationToken.None).ConfigureAwait(false); await session.NotifyAsync(MethodId.SyncEnemyFightEdict, UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = rt.UnitId, isGive = next != 0 }), CancellationToken.None).ConfigureAwait(false); // ⛑ 跟随(没敌人)时**关掉**战斗 AI —— 否则它会一直保持举枪的战斗姿态慢慢挪。 // 主线驱动敌人就是这么写的(EnemyAiActions.cs:453 `value = target != 0`)。 var battleAi = next != 0 || PrivateServerConfigStore.Current.Gameplay.Aether.CompanionBattleAiWhenFollowing; await session.NotifyAsync(MethodId.SyncBattleAiS, UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = rt.UnitId, value = battleAi }), CancellationToken.None).ConfigureAwait(false); session.Log.Info($"[COMPANION] goal unit={rt.UnitId} master={rt.OwnerUnitId} " + $"target={next} mode={(next == 0 ? "follow" : "assist")} battleAi={battleAi}"); } if (now < rt.NextMoveAt) return; if (dest is null) { rt.NextMoveAt = now + 250; return; } // ---------- 2. 位移量:按剩余距离反算,一条命令走完剩下的路 ---------- // // ⛑⛑ "一步一停"的第二来源:SyncEnemyBattleMoveTowardUnit 是有限位移 —— // 客户端只走 MoveTime 秒就停下等下一条(EnemyAiActions.cs:54-68 那段写得很清楚: // "走 0.65s、干站 0.35s,看起来就是一步一顿")。原来的版本连 MoveTime 都没填, // 等于让客户端走一个默认值、然后干等下一个 tick。 // ⇒ 这里完整复用主线调好的那一套:MoveTime + MinMoveTime(0.9×) + ReportOnStop, // useSeconds 按剩余距离反算,命令越少起停越少。 var p = dest.Value; var dx = p.X - rt.Anchor.X; var dz = p.Z - rt.Anchor.Z; if (!float.IsFinite(dx) || !float.IsFinite(dz)) { rt.NextMoveAt = now + 250; return; } // 不隔着楼层走:等目标回到这一层。 if (MathF.Abs(p.Y - rt.Anchor.Y) > 2.5f) { rt.NextMoveAt = now + 500; return; } var stopAt = next == 0 ? CompanionFollowStopMeters : EnemyAiAttackRangeMeters; var flat = MathF.Sqrt(dx * dx + dz * dz); if (flat <= stopAt) { // ---------- 3. 贴身了:出招(协战才有,跟随模式不打) ---------- // ⛑⛑ 上一版只发移动+engagement,**从没发过出招包** —— 实机上看到它"摆远程射击 // 姿势却打不出伤害"就是客户端自己在空放。想让它真的帮打,必须服务端下 // SyncUseTargetSkill:客户端播技能、自己判定命中,命中的伤害再由 // TrackLocalEnemyHitAsync 记账(它在 OnSkillHit 里排在 releaser 校验之前)。 if (next != 0) await CastCompanionAsync(session, rt, dest.Value, now); rt.NextMoveAt = now + 250; return; } // ⛑ 协战用主线的短步长(给贴脸用的);跟随要走十几米,步长太短就是"一顿一顿慢慢挪", // 所以跟随时把上限抬到 gameplay.aether.companionFollowMoveSeconds。 var moveSeconds = EnemyAiMoveSecondsNow(); if (next == 0) moveSeconds = MathF.Max(moveSeconds, Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.CompanionFollowMoveSeconds, 0.5f, 12f)); var needSeconds = (flat - stopAt) / EnemyAiMoveMetersPerSecond; var useSeconds = Math.Clamp(needSeconds, 0.4f, moveSeconds); var useStep = EnemyAiMoveStepNow(useSeconds); rt.NextMoveAt = now + (int)(useSeconds * 1000f) + 50; if (flat > useStep) { var k = useStep / flat; rt.Anchor = new Vec3(rt.Anchor.X + dx * k, rt.Anchor.Y, rt.Anchor.Z + dz * k); } else { rt.Anchor = new Vec3(p.X, rt.Anchor.Y, p.Z); } var token = (byte)(++rt.MoveSeq); if (token == 0) token = (byte)(++rt.MoveSeq); rt.MoveCount++; // ⛑ 跟随用 **Run(6)**,协战保持 WalkFront(2):BattleMoveActionType 枚举 // (dump.cs:1913359: Unknown=0 / Idle=1 / WalkFront=2 / WalkBack=3 / WalkLeft=4 / // WalkRight=5 / **Run=6**)。我们一直用的是 WalkFront ⇒ 跟十几米全靠走,看着就是"慢慢挪"。 var action = (byte)2; // WalkFront if (next == 0 && PrivateServerConfigStore.Current.Gameplay.Aether.CompanionRunWhenFollowing) action = 6; // Run await session.NotifyAsync(MethodId.SyncEnemyBattleMoveTowardUnit, UxSerializer.Serialize(new SceneMethods.EnemyAiMoveTowardUnit4229938 { EntityId = rt.UnitId, TargetId = next != 0 ? next : rt.OwnerUnitId, BattleMoveActionType = action, MoveId = token, InstanceId = token, MoveTime = useSeconds, // 略短于 MoveTime:客户端"至少走这么久",防止它提前刹车收脚。 MinMoveTime = MathF.Max(0.1f, useSeconds * 0.9f), ReportOnStop = true, }), CancellationToken.None).ConfigureAwait(false); if (rt.MoveCount <= 2 || rt.MoveCount % CompanionMoveLogEvery == 0) session.Log.Info($"[COMPANION] move unit={rt.UnitId} toward={(next == 0 ? rt.OwnerUnitId : next)} " + $"flat={flat:F1}m duration={useSeconds:F2}s gap={rt.NextMoveAt - now}ms " + $"action={(action == 6 ? "Run" : "WalkFront")} count={rt.MoveCount}"); } /// /// 贴身出招:SyncUnitSmoothFace 转身 + SyncUseTargetSkill 放招。 /// 与主线 GameRouter.EnemyAiActions.cs:592-668 的那段逐行对齐(那套是调过的,别另写)。 /// /// ⛑ 技能池来自 gameplay.aether.companionAttacks:随从没有自己的攻击模组 /// (行为集里空),只能借通用池;换池子改配置即可,不用重编译。 /// private static async Task CastCompanionAsync(TcpSession session, CompanionAssistRuntime rt, Vec3 targetPos, long now) { if (now < rt.CastUntil || now < rt.ActionAt) return; var pool = PrivateServerConfigStore.Current.Gameplay.Aether.CompanionAttacks; if (pool is null || pool.Count == 0) return; var skill = pool[rt.CastCount % pool.Count]; var dx = targetPos.X - rt.Anchor.X; var dz = targetPos.Z - rt.Anchor.Z; var facing = MathF.Atan2(dx, dz) * (180f / MathF.PI); var deltaAngle = (facing - rt.Facing) % 360f; if (deltaAngle > 180f) deltaAngle -= 360f; if (deltaAngle < -180f) deltaAngle += 360f; rt.CastInstance = rt.CastInstance == int.MaxValue ? 1 : rt.CastInstance + 1; var moveId = (byte)(++rt.MoveSeq); if (moveId == 0) moveId = (byte)(++rt.MoveSeq); try { // ⛑ 走主线同一个 helper(和敌人共用开关):默认发 SyncUnitSmoothFaceToTarget, // 让客户端用真实朝向转向 —— 避免"转 90° 打空气"那种错题。 await TurnEnemyToAsync(session, rt.UnitId, rt.Target, new SceneMethods.EnemyAiFace4229938 { pid = rt.UnitId, fromAngle = rt.Facing, changeAngle = deltaAngle, }); await session.NotifyAsync(MethodId.SyncUseTargetSkill, UxSerializer.Serialize(new SceneMethods.EnemyAiSkillParam4229938 { entityId = rt.UnitId, targetId = rt.Target, moveId = moveId, instanceId = rt.CastInstance, skillId = skill, rotate = facing, unitPosition = new SceneMethods.UxVector3(rt.Anchor.X, rt.Anchor.Y, rt.Anchor.Z), location = new SceneMethods.UxVector3(targetPos.X, targetPos.Y, targetPos.Z), faceToPos = new SceneMethods.UxVector3(targetPos.X, targetPos.Y, targetPos.Z), SelfMobilityPos = new SceneMethods.UxVector3(rt.Anchor.X, rt.Anchor.Y, rt.Anchor.Z), TarMobilityPos = new SceneMethods.UxVector3(targetPos.X, targetPos.Y, targetPos.Z), }), CancellationToken.None).ConfigureAwait(false); rt.Facing = facing; rt.CastUntil = now + EnemyAiCastBusyMsNow(); rt.ActionAt = now + EnemyAiCastIntervalMsNow(); if (++rt.CastCount <= 2 || rt.CastCount % 5 == 0) session.Log.Info($"[COMPANION] cast unit={rt.UnitId} target={rt.Target} skill={skill} " + $"count={rt.CastCount} method=SyncUseTargetSkill " + "(伤害由 TrackLocalEnemyHitAsync 记账;看到 [HIT] reject releaser=<随从uid> 即命中)"); } catch (Exception ex) { session.Log.Warn($"[COMPANION] cast failed unit={rt.UnitId}: {ex.Message}"); } } /// /// 从主线的本地敌人账本里挑一个最近的目标,并返回它当前的位置。挑不到返回 (0, 玩家坐标) = 跟随。 /// ⛑ 排除:自己、玩家单位、已死单位;是否参战按离玩家的距离判断(随从离玩家太远就不打)。 /// private static (ulong Uid, Vec3? Position) SelectCompanionTarget( TcpSession session, WorldEntryState world, CompanionAssistRuntime rt) { if (!world.HasLastReportedPlayerTransform) return (0, null); var player = world.LastReportedPlayerPosition; var ledger = GetLocalEnemyRuntimeState(session); ulong best = 0; var bestDistance = float.MaxValue; Vec3? bestPos = null; lock (ledger.SyncRoot) { foreach (var enemy in ledger.Enemies.Values) { if (enemy.Dead || enemy.Uid == rt.UnitId || enemy.Uid == rt.OwnerUnitId || enemy.Uid == world.ActiveSpiritUnitId) continue; var toPlayer = DistanceSq(enemy.Position, player); if (toPlayer > CompanionAggroMeters * CompanionAggroMeters) continue; if (toPlayer >= bestDistance) continue; bestDistance = toPlayer; best = enemy.Uid; // 服务端刷出来的用估算位(AI推进),客户端刷的只有命中点。 bestPos = enemy.ServerSpawned ? enemy.AiPosition : enemy.Position; } } // 已咬住的目标用更大的脱离半径(滞回),否则在边界上会反复切目标 —— // 每切一次都要重发 engagement 三件套,动作层被重置,就是"一步一停"的第一来源。 if (rt.Target != 0 && ledger.Enemies.TryGetValue(rt.Target, out var current) && !current.Dead && DistanceSq(current.Position, player) <= CompanionDropMeters * CompanionDropMeters) return (rt.Target, current.ServerSpawned ? current.AiPosition : current.Position); if (best != 0) return (best, bestPos); // 没有敌人 ⇒ 跟随玩家 return (0, player); } private static float DistanceSq(Vec3 a, Vec3 b) { var x = a.X - b.X; var y = a.Y - b.Y; var z = a.Z - b.Z; var d = x * x + y * y + z * z; return float.IsFinite(d) ? d : float.PositiveInfinity; } }