Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.CompanionAssist.cs
T

441 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}