Files

440 lines
22 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 随从的<b>服务端兜底驱动</b>:跟随 + 协战。
///
/// ⛑⛑⛑ 2026-10-01 定位到更正的解法,本文件已<b>不再是默认路径</b>
/// (开关 <c>gameplay.aether.companionServerDrive</c>,默认 false):
/// 随从模板用的是专属伴生行为树(晴 <c>Companion_JiaMu_Shuangmawei</c>、
/// 盖斯 <c>Companion_Guys</c>),它们的<b>行为集里没有</b> Behavior_Battle 和 RelatedSkillIds,
/// 战斗能力写在那棵树的 C# 实现里,服务端查不到 ⇒ 硬喂招只能给到
/// <c>EnemyAiFallbackAttacks</c>(金属吉他手那两下),这就是实机上看到的
/// 「只有远程射击动作、也没有武器、打不出伤害」。
/// 正解 = <c>SyncAgentCompanionAI</c> + <c>SyncCompanionMaster</c> + <b>模板自带的阵营</b>
/// (<c>UnitCamp.PlayerDefault = 1</c>,见 CompanionBind.cs),然后让客户端那棵树自己跑。
///
/// ⛑⛑ 2026-10-01 更早的实机:<b>只推 SyncCompanionMaster / SyncAgentCompanionAI 是不够的</b> ——
/// 那两个包只登记「谁是谁的随从」,客户端不会因此给它下任何指令,实测就是站着不动的木桩。
/// 当时补的就是这条用主线<b>本来就有</b>的敌人战斗指令驱动:
/// <c>SyncBattleAiS</c>(68555063) 开/关战斗 AI
/// <c>SyncUnitLockTarget</c>(68009754) 锁定谁
/// <c>SyncEnemyFightEdict</c>(68882858) 给不给攻击令牌
/// <c>SyncEnemyBattleMoveTowardUnit</c>(68792181) 朝谁移动
/// 这四个包在 <c>GameRouter.EnemyAiActions.cs:446-454</c> 已经在用(驱动敌人追玩家)。
/// 捐赠端 7k v0.5-2 那 323 行 <c>GarmCompanionAi.cs</c> 的核心也就是这行 ——
/// 所以它不需要整体移植,主线把同样的包换个目标发就行。
///
/// <b>跟随</b> = 没有敌人时把 target 留空、移动目标填主人 uid;
/// <b>协战</b> = 选一个最近的敌人当 target,同时给攻击令牌。
/// </summary>
internal sealed partial class GameRouter
{
private const int CompanionTickMs = 800;
/// <summary>每几条移动命令打一行日志(诊断"一步一停"用,别刷屏)。</summary>
private const int CompanionMoveLogEvery = 5;
/// <summary>随从离玩家多远之内才去找敌人打(米)。</summary>
private const float CompanionAggroMeters = 24f;
/// <summary>超过这个距离就放弃当前目标(米)。大于上面的进入值 ⇒ 滞回。</summary>
private const float CompanionDropMeters = 30f;
/// <summary>跟随时的停步距离(米)。捐赠端同款逻辑用的是 4f。</summary>
private const float CompanionFollowStopMeters = 4f;
/// <summary>worker 最长活多久(兜底,防止漏 stop 之后线程越积越多)。</summary>
private const long CompanionMaxLifetimeMs = 15 * 60 * 1000;
private sealed class CompanionAssistRuntime
{
public ulong UnitId;
public ulong OwnerUnitId;
/// <summary>
/// 当前锁定的目标。<b>初值必须是哨兵</b>(不是 0):engagement 三件套只在目标变化时下发,
/// 而"跟随"态的目标正是 0 —— 若初值也是 0,<c>next != Target</c> 恒为 false ⇒
/// 整局<b>一个 engagement 包都发不出去</b>,包括"关掉战斗 AI"那条,随从就一直保持
/// spawn 时的战斗姿态举着枪(2026-10-01 实机踩到)。
/// </summary>
public ulong Target = ulong.MaxValue;
/// <summary>单调递增的 move 序号;契约里 MoveId/InstanceId 是 <c>byte</c>,落盘前截断。</summary>
public uint MoveSeq;
public long NextMoveAt;
/// <summary>
/// 估算锚点。服务端拿不到实体真实坐标(客户端不回传),只能这么估。
/// spawn 用的是「全 0 pose = 落在玩家跟前」的约定 ⇒ 首帧拿玩家坐标当起点是准的。
/// </summary>
public Vec3 Anchor;
public bool HasAnchor;
public int MoveCount;
/// <summary>朝向(度)。出招前先转身用 —— 官方技能很多带 NoRotation=true。</summary>
public float Facing;
/// <summary>出招冷却到期时间(TickCount64)。</summary>
public long CastUntil;
/// <summary>下次允许出招的时间。</summary>
public long ActionAt;
/// <summary>出招实例号;契约里 instanceId 是 <c>int</c>,别写成 uint。</summary>
public int CastInstance;
public int CastCount;
}
/// <summary>同一个单位只起一个 worker;uid 是 key。</summary>
private static readonly ConcurrentDictionary<ulong, byte> s_companionWorkers = new();
/// <summary>
/// 随从的<b>真实坐标</b>(uid → 位置/时刻)。由 <c>OnMovementReport</c> 从客户端上报流里喂进来。
///
/// ⛑⛑ 2026-10-01 实机定案:没有它,服务端只能用"首帧玩家坐标 + 每次移动推进"的估算锚点,
/// 误差几秒内就发散(日志里 flat 在 4.0/5.1/8.2 之间乱跳就是证据)⇒ 判定"贴脸了"时
/// 随从真实位置可能还在四五米外,对着空气挥拳 ⇒ **出招了但零命中上报**。
/// 捐赠端 <c>GarmCompanionAi.cs</c> 的 <c>TrackGarmMovement</c> 也是靠这条上报流拿随从坐标的。
/// </summary>
private static readonly ConcurrentDictionary<ulong, (Vec3 Pos, long AtTicks)> s_companionPositions = new();
/// <summary>随从坐标新鲜多久之内算可用(毫秒)。超时就退回估算,免得拿几秒前的旧点当真。</summary>
private const long CompanionPositionFreshMs = 3000;
/// <summary>由 <c>OnMovementReport</c> 调用:喂一条随从的真实坐标。</summary>
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}) " +
"— 出招距离判定改用真实坐标");
}
/// <summary>取随从的真实坐标;没有或过期返回 false。</summary>
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;
}
/// <summary>
/// 给刚绑好的随从起一个驱动 worker。失败不影响随从本身(它已经生成了)。
/// </summary>
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));
}
/// <summary>让某个随从停手(解散 / 单位消失时用)。</summary>
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. 锚点:优先用客户端上报的<b>真实坐标</b>,拿不到才用估算 ----------
// ⛑⛑ 估算锚点几秒内就发散(实测 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 三件套<b>只在目标真的变了才发</b>。
// 原来每 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 是<b>有限位移</b> ——
// 客户端只走 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,**从没发过出招包** —— 实机上看到它"摆远程射击
// 姿势却打不出伤害"就是客户端自己在空放。想让它真的帮打,必须服务端下
// <c>SyncUseTargetSkill</c>:客户端播技能、自己判定命中,命中的伤害再由
// <c>TrackLocalEnemyHitAsync</c> 记账(它在 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}");
}
/// <summary>
/// 贴身出招:<c>SyncUnitSmoothFace</c> 转身 + <c>SyncUseTargetSkill</c> 放招。
/// 与主线 <c>GameRouter.EnemyAiActions.cs:592-668</c> 的那段逐行对齐(那套是调过的,别另写)。
///
/// ⛑ 技能池来自 <c>gameplay.aether.companionAttacks</c>:随从<b>没有</b>自己的攻击模组
/// (行为集里空),只能借通用池;换池子改配置即可,不用重编译。
/// </summary>
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}");
}
}
/// <summary>
/// 从主线的本地敌人账本里挑一个最近的目标,并返回它当前的位置。挑不到返回 (0, 玩家坐标) = 跟随。
/// ⛑ 排除:自己、玩家单位、已死单位;是否参战按<b>离玩家的距离</b>判断(随从离玩家太远就不打)。
/// </summary>
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;
}
}