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