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

189 lines
11 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 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}");
}
}