This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.CompanionBind.cs
T

188 lines
11 KiB
C#
Raw Normal View History

using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 把一个已经在场的单位接进<b>客户端原生的随从 AI</b>。
///
/// ⛑⛑ 2026-10-01 取证:这套协议<b>主线服务端一行都没实现过</b>,但客户端侧是全的 ——
/// 随从的跟随、协战、UI 全由客户端 C# 状态机负责,服务端只需要把两个包推下去。
/// 在此之前主线只能把随从当成「不打人的敌人」生成,表现就是挂着血条、打不死、能被玩家锁定。
///
/// 两个包(签名都取自官方 dump.cs 的 IGameSceneToClient 抽象声明,不是猜的):
/// <c>SyncAgentCompanionAI(ulong pid, bool isCompanionAI)</c> = 68277327 (dump.cs:1900945)
/// <c>SyncCompanionMaster(ulong companionId, ulong masterId)</c> = 68813982 (dump.cs:1901368)
///
/// 为什么不自己写跟随:客户端 lua 里<b>找不到</b>这两个方法名以外的任何实现
/// (lua.zip 只有 RPCMethodIdToName 里有名字,实现在 C# 侧),自己驱动等于重新实现一遍
/// 官方的行为树 —— 性价比远低于把原生开关打开。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>UnitStateConfig.HideHp = 10266(dump.cs:1448623 / 1449004)。</summary>
private const uint CompanionUnitStateHideHp = 10266;
/// <summary>
/// 收起随从头顶的血条:<c>SyncAddUnitState</c>(68026128) + HideHp。
/// 与 <c>GameRouter.EnemyHpBar.cs:55</c> 逐字段对齐(Id / State / EffectFreezeState)。
/// </summary>
private static async Task NotifyCompanionHideHpAsync(TcpSession session, ulong uid)
{
try
{
await session.NotifyAsync(MethodId.SyncAddUnitState,
UxSerializer.Serialize(new GameMethods.SyncAddUnitStateArgs4229938
{
Id = uid,
State = CompanionUnitStateHideHp,
EffectFreezeState = 0u,
}), CancellationToken.None);
}
catch (Exception ex)
{
session.Log.Warn($"[COMPANION] hide-hp failed uid={uid}: {ex.Message}");
}
}
/// <summary>
/// 把 <paramref name="companionUid"/> 绑给当前操控的角色。
/// </summary>
/// <param name="companionUid">
/// 由 <c>SpawnAgentAsync</c> 返回的单位 uid。<b>必须已经 spawn 完</b> —— 客户端消费方会用
/// masterId / agentId 去 <c>SceneDataMgr</c> 里查单位,spawn 包没落地就查不到。
/// </param>
internal static async Task<(bool Ok, string Message)> BindCompanionAsync(ulong companionUid)
{
if (companionUid == 0)
return (false, "随从 uid 为空(生成失败?)");
var target = GameAdminBridge.RequirePlayerSession();
if (target is null)
return (false, "还没有玩家会话(先用游戏客户端登录)");
var session = target.Value.Session;
// ⛑⛑ masterId **不是** Profile.PlayerPid。那个是账号 pid(本机 100 / 捐赠端 666),
// 不在 unit id 空间 —— 2026-09-26 伤害数字攻坚的反汇编结论写得很死:
// 「Profile.PlayerPid 是账号 pid,不在 unit id 空间,三段必空」。
// 捐赠端 GameRouter.Enemy.cs:400-402 回退的也是 world?.ActiveSpiritUnitId ?? Profile.InitialUnitId。
// 所以这里必须拿**当前操控单位**的 unit id。
var state = GetStateIfExists(session);
var masterId = state?.ActiveSpiritUnitId ?? 0;
if (masterId == 0)
return (false, "拿不到当前操控单位的 unit id(world 未 ready 或 ActiveSpiritUnitId=0)");
await session.NotifyAsync(
MethodId.IGameSceneToClient_SyncAgentCompanionAI,
UxSerializer.Serialize(new SceneMethods.SyncAgentCompanionAI4229938
{
pid = companionUid,
isCompanionAI = true,
}),
CancellationToken.None);
await session.NotifyAsync(
MethodId.IGameSceneToClient_SyncCompanionMaster,
UxSerializer.Serialize(new SceneMethods.SyncCompanionMaster4229938
{
companionId = companionUid,
masterId = masterId,
}),
CancellationToken.None);
session.Log.Info($"[COMPANION] bound uid={companionUid} -> master={masterId} " +
"(SyncAgentCompanionAI + SyncCompanionMaster)");
// ⛑⛑ 2026-10-01:<b>默认不开服务端驱动</b>。
// 这两个模板的行走/出招写在**专属伴生行为树**里(晴 Companion_JiaMu_Shuangmawei、
// 盖斯 Companion_Guys),服务端<b>查不到</b>它们的技能(AI 行为集里 Behavior_Battle
// 是空串、RelatedSkillIds 是空表),硬喂只能给金属吉他手的挥击 —— 就是实机上看到的
// "只有远程射击动作、打不死人"。
// ⇒ 正确做法是把它标记成 Companion(上面两个包)+ 发**模板自带的阵营**(spawn 侧已改),
// 然后闭嘴让客户端那棵树自己跑;它自己出招的话,命中的伤害由
// <c>TrackLocalEnemyHitAsync</c> 结算(它在 OnSkillHit 里排在 releaser 校验<b>之前</b>,
// 所以 ReleaserId 是随从 uid 也能记账)。
// ⇒ 只有 Companion 树确实不接管时才把 gameplay.aether.companionServerDrive 设 true,
// 让服务端用敌人那四个包代劳(GameRouter.CompanionAssist.cs)。
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
session.Log.Info($"[COMPANION] camp={cfg.CompanionCamp} " +
$"driver={(cfg.CompanionServerDrive ? "server(packets)" : "client companion tree")} " +
"(开关 gameplay.aether.companionServerDrive)");
// ⛑⛑ 头顶血条(2026-10-01 用户点名):血条<b>不看阵营</b> —— 判据在
// <c>EnemyHUDCtrl.lua:579</c>:<c>shouldShowHp = ... and not UnitStateMgr:HasState(unit,
// UnitStateConfig.HideHp) and ...</c> ⇒ 唯一 per-unit 且服务端可写的开关就是挂
// <b>HideHp 状态(10266)</b>。主线 <c>GameRouter.EnemyHpBar.cs:39</c> 的
// <c>HideLocalEnemyHpBarAsync</c> 用的是同一个包,但它只处理账本内的敌人
// (随从 engage=false、不在账本里)⇒ 这里直接发。
// ⛑ 补一拍:spawn 批是几十个包,AddUnitState 可能比单位在客户端建好更早到
// (engage 五件套也吃这个亏,主线同样延 1.5 s)。
if (cfg.CompanionHideHpBar)
{
await NotifyCompanionHideHpAsync(session, companionUid);
session.Log.Info($"[COMPANION] hp-bar hidden uid={companionUid} state=HideHp(10266) " +
"(开关 gameplay.aether.companionHideHpBar)");
_ = Task.Run(async () =>
{
await Task.Delay(1500);
await NotifyCompanionHideHpAsync(session, companionUid);
});
}
// ⛑⛑ 2026-10-01:随从一直「举着枪、慢慢挪」的真凶 —— 客户端给它的
// <b>上半身持枪瞄准态</b>从来没被清过。主线 <c>GameRouter.EnemyStance.cs:32</c>
// <c>ClearEnemyGunAim4229938</c> 的原文注释:
// "upper-body aim is independent of the weapon model and the selected battle
// locomotion set" ⇒ 跟手里有没有武器、用哪个动画集都无关,是独立的一层。
// 它只在 profile 判成 unarmed 时才发,而随从 profile 是 <c>null</c>(没进
// EnemyAiCatalog,因为它的招在行为树里)⇒ <b>永远走不到那行</b>。
// ⇒ 这里补一刀。射击态的 locomotion 本身就是慢速瞄准步,所以「慢慢」多半也是它。
if (cfg.CompanionClearGunAim)
{
await ClearEnemyGunAim4229938(session, companionUid);
session.Log.Info($"[COMPANION] gun-aim cleared uid={companionUid} " +
"(SyncEndShootingState + SyncEndShootGameplayTag)");
}
// 附加手段(默认关):真的把武器卸掉。随从 InitWeaponId=0,所以这只是个对照开关,
// 留着是为了在「清了瞄准态还举枪」时能一键验证是不是武器模型的问题。
if (cfg.CompanionDisarm)
{
await session.NotifyAsync(MethodId.SyncAgentChangeWeapon,
UxSerializer.Serialize(new SceneMethods.EnemyAiWeapon4229938
{
agentId = companionUid,
weaponId = 0, // 0 = 卸武器(7K melee 模式就是这么干的)
}), CancellationToken.None);
session.Log.Info($"[COMPANION] disarmed uid={companionUid} (weaponId=0)");
}
// ⛑⛑ 7K melee 模式三件套的最后一件:**空手战斗动画集**。
// 官方 Companion 协议里<b>没有</b> follow/move 包(dump.cs 里一共才三个 Companion 方法),
// 跟随本该由客户端那棵树做;它没启动,我们就只能用敌人那套 locomotion 代劳
// ⇒ 走什么步伐、手怎么摆,全由 <c>SyncBattleAnimSet</c> 指的 AnimationSetConfig 决定。
// 主线 <c>SendEnemyCombatStanceAsync</c> 只在 profile 判成 unarmed 时推,随从 profile 是
// null ⇒ 从来没推过 ⇒ 它一直在用<b>默认的持枪/远程动画集</b>走慢步。
// 值取 <c>EnemyAiMethods.cs</c> 注释里写的:<b>325 = 十二面空手</b>、327 = 通用胖子空手,
// 0 = 移除覆盖。默认 0(不发)是为了对照;想修「举枪慢步」就填 <b>325</b>。
if (cfg.CompanionAnimSetTag != 0)
{
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncBattleAnimSet,
UxSerializer.Serialize(new SceneMethods.EnemyAiBattleAnim4229938
{
id = companionUid,
animSetTag = cfg.CompanionAnimSetTag,
}), CancellationToken.None);
session.Log.Info($"[COMPANION] anim-set uid={companionUid} tag={cfg.CompanionAnimSetTag} " +
"(unarmed 325 / 327;0 = 不发)");
}
if (cfg.CompanionServerDrive)
StartCompanionAssist(companionUid, masterId);
return (true, $"uid={companionUid} -> master={masterId}");
}
}