591 lines
34 KiB
C#
591 lines
34 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
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>
|
||
/// 可接管召唤物(android-control 一家子):
|
||
/// 黑客 APP 蜘蛛机器人(51938181)/ 送货无人机(51938183)→ 40120908 / 40120909
|
||
/// 巡卫官 APP 机械狗 NCCA Dog(51938184 召唤 / 51938186 驻场态)→ 40120911
|
||
///
|
||
/// 三条是<b>同一套通道</b>,不是三个功能。2026-09-25 补狗时的取证:
|
||
/// · AgentSummonCatalog 里 40120908 蜘蛛 / 40120909 无人机 / 40120911 机械狗 紧挨着,
|
||
/// EnemyClassType=1、GroupEnemy=40211001、AI=40213004 三个字段完全相同
|
||
/// (狗多一个 InitWeaponId 98006028);EnemyCatalog.json:82 再确认一次。
|
||
/// · SummonConfig 只有 5 条:1 无人机 / 2 蜘蛛 / **3 机器狗** / 4 鸵鸟马桶 / 5 扫地机器人
|
||
/// ⇒ 狗是官方召唤物,SyncSwitchControl 的 SummonTag 校验对它成立(硬规则 52)。
|
||
/// · 客户端有完整的 RobotDogControlsStore.lua(召回/切换操控/普攻/蹲/闪避/拍照…),
|
||
/// 它调的正是 AskStopControlAgent(true/false) —— 与蜘蛛/无人机逐字相同。
|
||
/// · 51938184 / 51938186 挂在里希(15021021)的 FightSpiritConfig.TempSkill 上,
|
||
/// 所以 GameRouter.Combat.cs 的 AllowsAuthoredCharacterSkill 已经放行,
|
||
/// 技能包到得了这里(Combat.cs 里那句 TryHandleControllableSummonSkill)。
|
||
///
|
||
/// <b>为什么以前点了没反应 —— 2026-09-23 抓包确认。</b>
|
||
/// 客户端其实一直在上报。packets 日志里这两行进得很清楚:
|
||
/// <code>
|
||
/// C2S DECODED AskClientUseCommonSkill type=AskUseSkill
|
||
/// {"data":{"Releaser":100000000012,"Location":{...},"SkillId":51938181,"SkillInstanceId":-211}}
|
||
/// [COMBAT] accept temp-skill template=15021023 skill=51938181 inst=-211
|
||
/// [COMBAT] use unit=100000000012 weapon=200141 style=39000005 skill=51938181 target=0
|
||
/// </code>
|
||
/// 也就是说:<b>C2S 到了,服务端也认了(走 AllowsAuthoredCharacterSkill 分支),然后——什么
|
||
/// 都没做。</b> OnClientUseSkill 只更新了 combat 沙盘记账就返回了,世界上一个实体也没多出来。
|
||
///
|
||
/// 客户端那边则是全套自备的(lua.zip):
|
||
/// HackScriptFunc.lua SpiderSkill = function() gCS.BattleManager.UseSkillByPid(pid, 51938181) end
|
||
/// SkillConfig.json 51938181 召唤蜘蛛机器人 / 51938183 召唤送货无人机,ClientActionAdvance=true
|
||
/// 所以玩家看到的"放置动作"是客户端先行演出;真正的单位、操控面板、操控本身,都要服务端
|
||
/// 把单位放进世界之后客户端才成立。
|
||
///
|
||
/// <b>分工(用户 2026-09-23 确认):召唤出来之后由玩家接管操控,这套逻辑在客户端。</b>
|
||
/// 服务端只做三件事:
|
||
/// 1. 把实体刷出来(不参与战斗 —— 见 <c>engage:false</c>)
|
||
/// 2. 推一次 SyncSwitchControl(68552741) 把玩家放进操控位
|
||
/// 3. 接住 AskStopControlAgent(67046103) / AskControlAgent(67291468) 这一对「交还 / 重新接管」
|
||
/// 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → RobotSpiderControlStore / DeliveryDroneStore
|
||
/// 全在客户端,我们不插手。
|
||
///
|
||
/// ⛔⛔ <b>2026-09-23 18:02 实机抓到的头号坑:spiritId 不能拿 Profile.InitialUnitId。</b>
|
||
/// 第一版用 <c>state.WorldEntryControlUnit</c>(= 100000000000 = player.initialUnitId)当
|
||
/// spiritId,结果是单位出来了、客户端也收到了两个包,但操控没交出去 —— 表现为「机器人站着
|
||
/// 不动,我还在控角色,而且手机打不开」。客户端日志里是这一行铁证:
|
||
/// <code>
|
||
/// [error] SyncHackEnemy enterOrLeave True unit or master is null
|
||
/// enemyId= 35184372188839 enemy is nullFalse
|
||
/// spiritId= 100000000000 master is nullTrue
|
||
/// LX6.Units.Module.BaseUnitModuleUtils:DoHackEnemy(UInt64, UInt64, Boolean)
|
||
/// </code>
|
||
/// <c>enemy is nullFalse</c> = 单位找得到;<c>master is nullTrue</c> = 按 spiritId 找不到施法者。
|
||
/// 玩家真正的 spirit uid 是 <b>100000000012</b>,三处互相印证:
|
||
/// · AskUseSkill 的 <c>Releaser</c>(客户端自己报的施法者)
|
||
/// · 我们下发的 <c>SyncPlayerCurrentSpirit {"playerPid":100,"spiritId":100000000012}</c>
|
||
/// · 客户端 <c>AskUnitMoveActionSimple</c> 上报自己移动时带的那个 UnitId
|
||
/// 而 100000000000 只是 <c>config/private-server.json</c> 的 <c>player.initialUnitId</c>,
|
||
/// 是进世界时的占位值,切过角色之后就不再是当前 spirit。
|
||
/// ⇒ <b>spiritId 一律优先用 req.Releaser。</b>
|
||
///
|
||
/// 手机打不开也是同一个 bug 的副作用,不是新问题:客户端 SyncSwitchControl 在 DoHackEnemy
|
||
/// 之后会执行 <c>OnSetSkillBtnState(PhoneCall, "isSummonAgentControl", true)</c> 把手机按钮禁掉
|
||
/// —— 它以为自己已经在操控召唤物了,而 DoHackEnemy 静默失败,于是「手机锁了但没接管」。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
// ---------------------------------------------------------------------------------
|
||
// 技能 → 召唤物模板。
|
||
//
|
||
// SkillConfig.json(table/,build 4229938)里 51938181 / 51938183 记录<s>没有任何指向
|
||
// agentId 的字段</s>(全 0),所以这个映射只能是硬编码,donor 资料里也没有。
|
||
//
|
||
// 两个模板都带 SummonTag(AgentConfig):40120908 → 2 蜘蛛,40120909 → 1 无人机。
|
||
// SummonConfig 一共 5 条:1 无人机 / 2 蜘蛛 / 3 机器狗 / 4 鸵鸟马桶 / 5 扫地机器人。
|
||
// 换模板时必须挑带 SummonTag 的那 14 个,否则客户端 SyncSwitchControl 的
|
||
// 卡死点②会静默丢包(硬规则 52)。
|
||
//
|
||
// 机械狗(2026-09-25 补):51938184 = 召唤,51938186 = 同一只狗的「驻场状态」切换
|
||
// —— PoliceDispatchCatalog id=2 的 SkillId / SwitchSkillId,PolicePanelManager 按
|
||
// dispatchState[id] 二选一。服务端表达不了「驻守」这种自主行为,所以两个技能刷同一个
|
||
// 模板,靠下面的「先收掉上一只」保证场上永远只有一只。
|
||
// ---------------------------------------------------------------------------------
|
||
private const uint HackerSpiderSkillId = 51938181;
|
||
private const uint HackerDroneSkillId = 51938183;
|
||
private const uint HackerSpiderAgentId = 40120908;
|
||
private const uint HackerDroneAgentId = 40120909;
|
||
|
||
private const uint PoliceDogSkillId = 51938184;
|
||
private const uint PoliceDogStandGuardSkillId = 51938186;
|
||
private const uint PoliceDogAgentId = 40120911;
|
||
|
||
// 机械狗的<b>武器轮盘</b>走 SceneItem 80003497(SceneitemConfig「机器狗默认武器」),
|
||
// 而战斗 spawn 用的是 <b>Weapon</b> 98006028(WeaponConfig「机器狗默认武器」)。
|
||
// 两个命名空间不能混用 —— 捐赠端 7k-v0.5 GameRouter.HackerDevices.cs:153 专门注释过。
|
||
// 电击能力的落点:98006028 → WeaponConfig.FixedFightSkill 39010040 →
|
||
// FightSkillConfig.ActiveSkill 51942305「机械狗范围电击」。武器本身 spawn 帧已经发了,
|
||
// 缺的是<b>这一帧</b>:客户端 AgentWeaponManager.lua:25 收到后填 agentWeaponSlotDict 并
|
||
// 广播 AGENT_CURRENT_WEAPON_CHANGE,操控 HUD 的武器 / 技能槽就是从这儿来的;
|
||
// 不发 ⇒ HUD 上没有武器 ⇒ 按 E 没技能可放。
|
||
private const uint PoliceDogWeaponWheelSceneItemId = 80003497;
|
||
|
||
// 送货/黑客无人机(2026-09-26 补)的武器轮盘走 SceneItem 80003471「零号爪机」
|
||
// (SceneitemConfig.OldWeaponId = 98005567「送货无人机」)。
|
||
//
|
||
// ⛑ 注意这里的<b>命名空间</b>:轮盘发的是 SceneItem id,战斗 spawn 发的是 Weapon id,
|
||
// 两者靠 SceneitemConfig.OldWeaponId 对应,不能混用(见上面机械狗那段注释)。
|
||
//
|
||
// 取值依据(SummonSkillInfoConfig 全文只有 4 行,dump 包里有、服务端 ClientData 里没有):
|
||
// Id=1 WeaponId=80003471 Normal=777 Basic=853 ← 零号爪机 / 送货无人机
|
||
// Id=2 WeaponId=80003472 Normal=777 Basic=853 ← 大型无人机 / 98005568
|
||
// Id=3 WeaponId=80003497 Normal=0 Basic=731 ← 机器狗(机械狗在用这个)
|
||
// Id=4 WeaponId=80005973 Normal=0 Basic=901 ← 蜘蛛机器人默认武器 / 98006056
|
||
// 无人机 HUD 的按键文本就是从这张表来的,所以必须挑表里存在的 id:
|
||
// GetAgentWeaponInfoCfg 查不到就退回 DEFAULT_NAME(719),按钮文案会变成占位文本。
|
||
private const uint DroneWeaponWheelSceneItemId = 80003471;
|
||
|
||
// 友方阵营:捐赠端 7k-v0.5 GameRouter.HackerDevices.cs:147 给机械狗发的是 camp = 1,
|
||
// AgentConfig 40922014/40120911 自己的 Camp 也是 1。我方 SpawnAgentAsync 默认给
|
||
// hostile 单位发 4(UnitCamp.AllEnemy)⇒ 城里谁都把它当敌人。
|
||
// ⛑ 与硬规则 132(刷友方 NPC 用 campOverride:0)的分歧:132 说的是<b>友方 NPC</b>
|
||
//(战术支援那种走 Main_Common_NPC 树的),机械狗是玩家操控的召唤物(SummonTag=3),
|
||
// 性质不同 ⇒ 这里跟捐赠端用 1。若实机被己方误伤,改回 0 试。
|
||
private const byte SummonCampFriendly = 1;
|
||
|
||
// UnitStateConfig.HideHp(dump.cs:1448623 / 1449004 = 10266)。
|
||
// 血条的判据在 EnemyHUDCtrl.lua:579:
|
||
// shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ...
|
||
// ⇒ 挂上这个状态就能把血条关掉,而且是 <b>per-unit</b> 的:不动公共 spawn 路径、
|
||
// 不影响战斗模块(电击 51942305 照旧)、不影响其他敌人。
|
||
// ⛑ 别用「关掉 SyncAttachBattleModule」那条路:战斗模块没了电击大概率也没了。
|
||
private const uint UnitStateHideHp = 10266;
|
||
|
||
// 蓝猫物流(Cat Express / UberSim)的「部署 / Deploy」无人机支援(2026-09-25 补)。
|
||
// 出处:UberSimOrderSupportConfig Id=1 "Drone" —— functionId=51938185,
|
||
// CanUseStateText="Deploy"、UsedStateText="Call Back"、UnlockBuff=52800661。
|
||
// 与黑客 APP 的 51938183「送货无人机」是同一族 temp-skill,刷的也是同一个模板
|
||
// 40120909(AgentSummonCatalog:EnemyClassType=1、AI=40213004,与蜘蛛/机械狗同源)。
|
||
// ⛑ 按钮的可见性由 UnlockBuff 52800661 控制,buff 走
|
||
// CharacterCapabilities.GlobalCapabilityBuffIds;这里只负责"按下去真刷出来"。
|
||
private const uint DeliveryDroneSkillId = 51938185;
|
||
private const uint DeliveryDroneAgentId = 40120909;
|
||
|
||
// SwitchControlReason(dump.cs line 1736857):
|
||
// None=0 Skill=1 Client=2 ClientWithdraw=3 ClientSwitch=4 System=5
|
||
// Desinger=6 DesignerLoading=7 Distance=8 SummonedAgentDead=9 ControllerDead=10
|
||
private const byte SwitchControlReasonSkill = 1; // 经由技能夺取操控
|
||
private const byte SwitchControlReasonClientWithdraw = 3; // 玩家主动撤回(长按 F 回收)
|
||
private const byte SwitchControlReasonClientSwitch = 4; // 玩家切换操控(N 键)
|
||
|
||
/// <summary>召唤物落点:客户端上报的 Location 前方多少米。比玩家自身坐标更贴近意图。</summary>
|
||
private const float SummonDropDistanceMeters = 2.5f;
|
||
|
||
/// <summary>per-session 当前召唤物记录的 key(session.Items 是 Dictionary<string,object>)。</summary>
|
||
private const string ControllableSummonStateKey = "client4229938-controllable-summon";
|
||
|
||
/// <summary>重复"接管"下发的去抖窗口。客户端按住 N 会 ~200ms 连发。</summary>
|
||
private static readonly TimeSpan HandoverDebounce = TimeSpan.FromSeconds(1);
|
||
|
||
/// <summary>一次召唤的运行时快照。只存交接操控需要的三个 id。</summary>
|
||
private sealed class ControllableSummonState
|
||
{
|
||
public ulong SpiritId; // 玩家 spirit uid(= AskUseSkill.Releaser)
|
||
public ulong AgentEntityId; // 召唤物 unit uid
|
||
public uint AgentId; // AgentConfig 模板 id(40120908 / 40120909 / 40120911)
|
||
|
||
/// <summary>服务端视角:操控位此刻是否在召唤物手上(true)还是已交还玩家(false)。</summary>
|
||
public bool Controlling;
|
||
|
||
/// <summary>上次下发"接管"的时刻,用于给连发的 AskControlAgent 去抖。</summary>
|
||
public DateTime LastHandoverUtc;
|
||
}
|
||
|
||
private static uint? ControllableSummonAgentId(uint skillId) => skillId switch
|
||
{
|
||
HackerSpiderSkillId => HackerSpiderAgentId,
|
||
HackerDroneSkillId => HackerDroneAgentId,
|
||
PoliceDogSkillId => PoliceDogAgentId,
|
||
PoliceDogStandGuardSkillId => PoliceDogAgentId,
|
||
DeliveryDroneSkillId => DeliveryDroneAgentId,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>召唤物的展示名,只用于日志。</summary>
|
||
private static string ControllableSummonName(uint agentId) => agentId switch
|
||
{
|
||
HackerSpiderAgentId => "蜘蛛机器人",
|
||
HackerDroneAgentId => "送货无人机",
|
||
PoliceDogAgentId => "机械狗(NCCA Dog)",
|
||
_ => $"agent={agentId}",
|
||
};
|
||
|
||
/// <summary>
|
||
/// SyncAgentWeaponWheel(68121167) 的载荷。抽成方法是为了让 <c>_ux_wire_check</c> 能反射
|
||
/// 直接断言<b>生产代码</b>产出的字节,而不是在测试里重写一遍。
|
||
///
|
||
/// 客户端 reader(RPCDeserializeAuto.lua:171397):
|
||
/// <code>
|
||
/// local agentId = reader.ReadUInt64(reader)
|
||
/// local currentWeaponId = reader.ReadUInt32(reader)
|
||
/// local weaponSlots = Base.ReadList7Bit(reader, function(r) return r.ReadUInt32(r) end)
|
||
/// </code>
|
||
/// ReadList7Bit = 标记字节 + Int7(count) + count 个 u32。
|
||
/// ⛑ 标记字节按项目硬规则 5 用 <c>0xFF</c>(= 有值);<c>UxWriter.Int7(n)</c> 内部写 n+1,别再加偏置。
|
||
/// </summary>
|
||
internal static byte[] BuildWeaponWheelWire(ulong uid, uint weaponWheelSceneItemId)
|
||
=> new UxWriter()
|
||
.U64(uid)
|
||
.U32(weaponWheelSceneItemId)
|
||
.U8(0xFF)
|
||
.Int7(1)
|
||
.U32(weaponWheelSceneItemId)
|
||
.ToArray();
|
||
|
||
/// <summary>
|
||
/// 召唤物的武器轮盘 SceneItem id。<b>0 = 这个模板没有轮盘,不要发这一帧。</b>
|
||
///
|
||
/// ⛑⛑ 2026-09-26 订正(原注释写错了,按它办的 ⇒ 无人机一直没有 E 技能):
|
||
/// 原注释拿 <c>AgentSummonCatalog.InitWeaponId</c> 当判据,结论是「只有机械狗有武器,
|
||
/// 蜘蛛和无人机 InitWeaponId 都是 0 ⇒ 不用发轮盘」。<b>这个判据是错的</b> —— 轮盘的
|
||
/// 命名空间是 SceneItem(<c>AgentConfig.InitWeaponSceneItemId</c>),而 HUD 上"有没有
|
||
/// 技能键"只看 <c>AgentWeaponManager:GetAgentCurrentWeapon</c> 拿不拿得到值,跟单位
|
||
/// 实际绑了哪把武器无关。
|
||
///
|
||
/// 客户端的判据(RobotFlyerControlsStore.lua,无人机操控 HUD):
|
||
/// <code>
|
||
/// M.RefreshBattleSkillInfo = function(self, weaponId)
|
||
/// if weaponId then btnHitch:SetActive(true); btnDrop:SetActive(true)
|
||
/// else btnHitch:SetActive(false); btnDrop:SetActive(false) end
|
||
/// local cfg = gAgentWeaponManager:GetAgentWeaponInfoCfg(weaponId)
|
||
/// if cfg then btnHitch:SetTipNameTotally(cfg.Normal) -- 挂钩 = 普通攻击
|
||
/// btnDrop:SetTipNameTotally(cfg.Basic) end -- 投放 = E 技能
|
||
/// </code>
|
||
/// 即:无人机 HUD 上<b>没有 btnSkillPc</b>(那是机械狗/蜘蛛的),它的"原生 E 技能"
|
||
/// 就是 <c>btnDrop</c>(长按走 <c>SkillBtnType.Basic</c>),配套还有 <c>btnHitch</c>。
|
||
/// 不发轮盘 ⇒ 这两个键直接 SetActive(false) ⇒ 玩家看到的就是"没有 E 技能"。
|
||
/// 这是硬规则 173 的同一条链路,只是消费方从 RobotDogControlsStore 换成了
|
||
/// RobotFlyerControlsStore。
|
||
///
|
||
/// 蜘蛛 40120908 暂未纳入:它的 HUD(RobotSpiderControlStore)同样按 weaponId 控制
|
||
/// btnSkillPc,映射也已知(80005973「蜘蛛机器人默认武器」→ 98006056,FixedFightSkill
|
||
/// 39010099),但本次只改无人机这一处,蜘蛛留作后续。
|
||
/// </summary>
|
||
private static uint ControllableSummonWeaponWheel(uint agentId) => agentId switch
|
||
{
|
||
PoliceDogAgentId => PoliceDogWeaponWheelSceneItemId,
|
||
HackerDroneAgentId => DroneWeaponWheelSceneItemId, // 40120909 = 黑客 APP 的无人机
|
||
// DeliveryDroneAgentId 与 HackerDroneAgentId 同为 40120909,已在上面覆盖;
|
||
// 这里再列一遍只为说明"蓝猫物流部署"走的也是同一个模板,C# switch 不允许重复 case。
|
||
_ => 0u,
|
||
};
|
||
|
||
/// <summary>
|
||
/// 每个家族一个独立回滚开关(黑客 / 巡卫犬),别共用 —— 见
|
||
/// <c>PrivateServerConfig.PoliceDogSummonEnabled</c> 的注释。
|
||
/// </summary>
|
||
private static bool ControllableSummonEnabled(uint skillId, out string switchName)
|
||
{
|
||
var gameplay = PrivateServerConfigStore.Current.Gameplay;
|
||
if (skillId is PoliceDogSkillId or PoliceDogStandGuardSkillId)
|
||
{
|
||
switchName = "Gameplay.PoliceDogSummonEnabled";
|
||
return gameplay.PoliceDogSummonEnabled;
|
||
}
|
||
|
||
if (skillId == DeliveryDroneSkillId)
|
||
{
|
||
switchName = "Gameplay.DeliveryDroneSummonEnabled";
|
||
return gameplay.DeliveryDroneSummonEnabled;
|
||
}
|
||
|
||
switchName = "Gameplay.HackerSummonEnabled";
|
||
return gameplay.HackerSummonEnabled;
|
||
}
|
||
|
||
// =================================================================================
|
||
// ① 施放召唤技能
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// 接住一次可接管召唤技能(蜘蛛 / 无人机 / 机械狗)。
|
||
/// 返回 false 表示这不是召唤技能,调用方应继续走普通战斗流程。
|
||
/// </summary>
|
||
internal async Task<bool> TryHandleControllableSummonSkill(RpcContext ctx, SceneMethods.SkillUseData req)
|
||
{
|
||
var agentId = ControllableSummonAgentId(req.SkillId);
|
||
if (agentId is null)
|
||
return false;
|
||
|
||
if (!ControllableSummonEnabled(req.SkillId, out var switchName))
|
||
{
|
||
ctx.Session.Log.Info($"[SUMMON] skill={req.SkillId} skipped — {switchName}=false (回滚开关)");
|
||
return true;
|
||
}
|
||
|
||
// ⛔ spiritId 必须是客户端上报的施法者。见类注释里 18:02 那次 master is null。
|
||
// WorldEntryControlUnit / ActiveSpiritUnitId 都可能是进世界时的占位 initialUnitId。
|
||
var playerUnit = req.Releaser;
|
||
if (playerUnit == 0)
|
||
{
|
||
var st = GetStateIfExists(ctx.Session);
|
||
playerUnit = st?.ActiveSpiritUnitId ?? 0;
|
||
if (playerUnit == 0)
|
||
playerUnit = st?.WorldEntryControlUnit ?? 0;
|
||
if (playerUnit != 0)
|
||
ctx.Session.Log.Warn($"[SUMMON] skill={req.SkillId} AskUseSkill.Releaser=0 — 退回 state 的 unit={playerUnit};若客户端再报 master is null 就要查切换流程");
|
||
}
|
||
|
||
if (playerUnit == 0)
|
||
{
|
||
ctx.Session.Log.Warn($"[SUMMON] skill={req.SkillId} 拿不到玩家 spirit uid — DoHackEnemy 会 master is null,本次不放单位");
|
||
return true;
|
||
}
|
||
|
||
// Location is where the player was standing when they pressed the button. Drop the unit a
|
||
// couple of metres ahead so it does not appear inside them. Same convention as the rest of
|
||
// the spawner.
|
||
// ⛑ 2026-09-26 修:这里原来写着 "no radian conversion — Facing feeds sin/cos directly
|
||
// everywhere in this repo",把 bug 当成约定固化下来了。实际上<b>那几处全是 bug</b>:
|
||
// Facing / LastReportedPlayerRotation.Y 都是<b>度数</b>,直接喂 sin/cos 会让落点方向
|
||
// 随数值乱飘(半径碰巧还是对的,所以只看"有没有生成在旁边"发现不了)。
|
||
// 现在全仓库统一:先 × MathF.PI/180 再进三角函数。
|
||
var facing = req.Facing;
|
||
var yaw = facing * (MathF.PI / 180f);
|
||
var x = req.Location.X + MathF.Sin(yaw) * SummonDropDistanceMeters;
|
||
var z = req.Location.Z + MathF.Cos(yaw) * SummonDropDistanceMeters;
|
||
// Y stays at the player's own height: the client uses the server-supplied position verbatim
|
||
// (no ground snapping), so any upward offset leaves a flying unit hovering.
|
||
var y = req.Location.Y;
|
||
|
||
var name = ControllableSummonName(agentId.Value);
|
||
ctx.Session.Log.Info($"[SUMMON] skill={req.SkillId} agent={agentId} ({name}) inst={req.SkillInstanceId} at=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
|
||
|
||
// 场上永远只留一只。机械狗的「驻场态」技能(51938186)会被反复按(PolicePanelManager
|
||
// 的 dispatchState 二选一),不先收掉上一只就会越按越多、session 里那只还找不回来。
|
||
if (ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var previous)
|
||
&& previous is ControllableSummonState old)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||
Ananta.Server.Protocol.Client4229938.WorldCodec.LogicAgentLeave(old.AgentEntityId));
|
||
ctx.Session.Items.Remove(ControllableSummonStateKey);
|
||
ctx.Session.Log.Info($"[SUMMON] replace previous uid={old.AgentEntityId} agent={old.AgentId} ({ControllableSummonName(old.AgentId)}) — SyncLogicAgentLeave sent");
|
||
}
|
||
|
||
var result = await GameAdminBridge.SpawnAgentAsync(agentId.Value, x, y, z, facing,
|
||
hostile: true, // 40120908/40120909/40120911 的 EnemyClassType=1 都在 Hostile 桶里,校验不放行 false
|
||
engage: false, // 但绝不能注册成敌人:召唤物是给玩家操控的,不是用来打玩家的
|
||
campOverride: SummonCampFriendly); // 阵营归友方(捐赠端做法),否则城里谁都打它
|
||
|
||
if (!result.Ok || result.Uid == 0)
|
||
{
|
||
ctx.Session.Log.Warn($"[SUMMON] spawn failed agent={agentId} ({name}) reason={result.Message}");
|
||
return true;
|
||
}
|
||
|
||
var summon = new ControllableSummonState
|
||
{
|
||
SpiritId = playerUnit,
|
||
AgentEntityId = result.Uid,
|
||
AgentId = agentId.Value,
|
||
};
|
||
ctx.Session.Items[ControllableSummonStateKey] = summon;
|
||
|
||
ctx.Session.Log.Info($"[SUMMON] unit spawned uid={result.Uid} — handing control to spirit={playerUnit}");
|
||
|
||
// 武器轮盘。⛑ 必须在 SyncSwitchControl <b>之前</b>——玩家一进操控位,HUD 立刻去读
|
||
// agentWeaponSlotDict,晚一帧就是空槽。
|
||
// 这一帧决定 HUD 上"有没有技能键",不决定单位实际拿哪把武器(那是 spawn 帧的
|
||
// SyncRaidBattleUnitAgent.WeaponId)。
|
||
// · 机械狗 40120911 → 80003497:HUD 亮出 btnSkillPc(E),按出来是电击 51942305
|
||
// (武器 98006028 的 FixedFightSkill 39010040 的 ActiveSkill)。
|
||
// · 无人机 40120909 → 80003471:HUD 亮出 btnDrop(E,投放)+ btnHitch(挂钩)。
|
||
// 文本取自 SummonSkillInfoConfig Id=1(Basic=853 / Normal=777)。
|
||
var weaponWheel = ControllableSummonWeaponWheel(agentId.Value);
|
||
if (weaponWheel != 0)
|
||
{
|
||
var wheelBody = BuildWeaponWheelWire(result.Uid, weaponWheel);
|
||
|
||
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentWeaponWheel, wheelBody);
|
||
ctx.Session.Log.Info($"[SUMMON] weapon wheel uid={result.Uid} agent={agentId} ({name}) "
|
||
+ $"sceneItem={weaponWheel} mid={MethodId.IGameSceneToClient_SyncAgentWeaponWheel} "
|
||
+ $"bytes=[{Convert.ToHexString(wheelBody)}]");
|
||
}
|
||
else
|
||
{
|
||
// 蜘蛛 40120908 目前走这里:HUD 上不会亮出 E 技能键。想加就改
|
||
// ControllableSummonWeaponWheel 的 switch(映射见那里的注释)。
|
||
ctx.Session.Log.Info($"[SUMMON] no weapon wheel for agent={agentId} ({name}) "
|
||
+ "— HUD will hide the E/skill button");
|
||
}
|
||
|
||
// 关掉血条。机械狗是玩家操控的召唤物,顶着一根敌人血条很出戏。
|
||
// AgentConfig 40120911.ShowHPBar = true,而血条的第一判据就是它
|
||
// (EnemyHUDCtrl.lua:561 canShowHp = cfg.ShowHPBar)—— 配置表是客户端压缩二进制池,
|
||
// 服务端改不了 ⇒ 只能从第二个判据下手:shouldShowHp 里的 UnitStateConfig.HideHp。
|
||
await ctx.NotifyAsync(MethodId.SyncAddUnitState, new GameMethods.SyncAddUnitStateArgs4229938
|
||
{
|
||
Id = result.Uid,
|
||
State = UnitStateHideHp,
|
||
EffectFreezeState = 0,
|
||
});
|
||
ctx.Session.Log.Info($"[SUMMON] hide hp bar uid={result.Uid} state={UnitStateHideHp} (UnitStateConfig.HideHp)");
|
||
|
||
await HandOverSummonControl(ctx, summon, SwitchControlReasonSkill, "skill");
|
||
return true;
|
||
}
|
||
|
||
// =================================================================================
|
||
// ② 交还操控 / 回收(长按 F)与重新接管(N 键)
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// C2S AskStopControlAgent(67046103) — <c>clearAgent=true</c> 是长按 F 回收(连单位一起销毁),
|
||
/// <c>false</c> 是 N 键交还操控(单位留在场上,之后还能用 AskControlAgent 切回去)。
|
||
///
|
||
/// 客户端用 <c>gReliableRpcManager:RegisterRPC</c> 等回执,所以 <b>无论解析成功与否都必须先
|
||
/// 回一个 empty OK</b>,否则操控面板的 await 永远不落地(按钮按了没反应)。
|
||
/// </summary>
|
||
[Handler(MethodId.AskStopControlAgent, HandlerPacketKind.Invoke)]
|
||
private async Task AskStopControlAgent(Connection conn, UxRpcMessage msg)
|
||
{
|
||
bool clearAgent = false;
|
||
try
|
||
{
|
||
clearAgent = msg.GetArgs<SceneMethods.AskStopControlAgent4229938>().clearAgent;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// 回执照发,别让客户端挂住;parse 失败只是拿不到语义,行为按"交还操控"处理。
|
||
msg.Context.Session.Log.Warn($"[SUMMON] AskStopControlAgent 解析失败(按 clearAgent=false 处理): {ex.Message}");
|
||
}
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
await OnStopControlAgent(msg.Context, clearAgent);
|
||
}
|
||
|
||
/// <summary>C2S AskControlAgent(67291468) — 重新接管一个已在场的召唤物(N 键的另一半)。</summary>
|
||
/// <remarks>
|
||
/// ⛔⛔ <b>2026-09-23 18:36 实机抓到的第二个坑:包种类是 NOTIFY,不是 INVOKE。</b>
|
||
/// 第一版按 <c>HandlerPacketKind.Invoke</c> 注册,结果 packets 日志里全是这一行:
|
||
/// <code>
|
||
/// C2S NOTIFY AskControlAgent [0x0402C94C] body=9b
|
||
/// ! [RPC4229938] unimplemented notify AskControlAgent [0x0402C94C] -> accepted
|
||
/// </code>
|
||
/// 也就是说 N 键切回去的包<b>到了服务端</b>,但因为 kind 不匹配被视为未实现的 notify 直接
|
||
/// accept 掉,handler 一次都没进(控制台里一条 [SUMMON] 都没有)。表现就是"按 N 没反应"。
|
||
/// 对照:交还操控那条 AskStopControlAgent(67046103) 是 <c>C2S INVOKE</c>(要回执),
|
||
/// 所以两个必须分开注册 —— 这里用 <c>Both</c> 兜住,将来客户端改成 invoke 也不用改代码。
|
||
/// </remarks>
|
||
[Handler(MethodId.AskControlAgent, HandlerPacketKind.Both)]
|
||
private async Task AskControlAgent(Connection conn, UxRpcMessage msg)
|
||
{
|
||
ulong agentEntityId = 0;
|
||
byte reason = SwitchControlReasonClientSwitch;
|
||
try
|
||
{
|
||
var args = msg.GetArgs<SceneMethods.AskControlAgent4229938>();
|
||
agentEntityId = args.agentEntityId;
|
||
reason = args.reason;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
msg.Context.Session.Log.Warn($"[SUMMON] AskControlAgent 解析失败: {ex.Message}");
|
||
}
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
await OnControlAgent(msg.Context, agentEntityId, reason);
|
||
}
|
||
|
||
private async Task OnStopControlAgent(RpcContext ctx, bool clearAgent)
|
||
{
|
||
var summon = ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var raw)
|
||
? raw as ControllableSummonState
|
||
: null;
|
||
|
||
if (summon is null)
|
||
{
|
||
ctx.Session.Log.Warn($"[SUMMON] AskStopControlAgent(clearAgent={clearAgent}) 但没有在场的召唤物记录 — 只回执,不下发");
|
||
return;
|
||
}
|
||
|
||
var reason = clearAgent ? SwitchControlReasonClientWithdraw : SwitchControlReasonClientSwitch;
|
||
var why = clearAgent ? "recall-long-press-F" : "switch-control-N";
|
||
|
||
summon.Controlling = false;
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncSwitchControl, new SceneMethods.SyncSwitchControl4229938
|
||
{
|
||
spiritId = summon.SpiritId,
|
||
agentEntityId = summon.AgentEntityId,
|
||
enterOrLeave = false,
|
||
reason = reason,
|
||
});
|
||
|
||
if (!clearAgent)
|
||
{
|
||
ctx.Session.Log.Info($"[SUMMON] control returned uid={summon.AgentEntityId} reason={reason} ({why}) — 单位留在场上,AskControlAgent 可再接管");
|
||
return;
|
||
}
|
||
|
||
// 回收:销毁单位。
|
||
//
|
||
// ⛔⛔ <b>2026-09-23 19:0x 修:移除通道发错了。</b>
|
||
// 第一版发的是 <c>SyncAetherAINpcRemove</c>(68332284),那是 <b>ambient 行人 / functional NPC</b>
|
||
// 的通道(GameRouter.Ambient.cs / GameRouter.FunctionalNpc.cs 在用)。而蜘蛛 / 无人机是走
|
||
// <c>GameAdminBridge.SpawnAgentAsync</c> 出来的,那条路径的第一句话就是
|
||
// <c>SyncLogicAgentEnter</c>(68281243) —— 与之配对的是 <b>SyncLogicAgentLeave</b>(68281244)。
|
||
// 证据就在同一个文件里:<c>DismissSummonedNpcsAsync()</c>(面板"解散全部召唤角色")用的正是
|
||
// SyncLogicAgentLeave,而且它专门注释过"用自带 uid 段才 retire 得掉",
|
||
// SyncAetherAINpcRemove 对这类单位完全是另一个注册表,发了等于没发。
|
||
// ⇒ 表现:服务端日志打印 `unit removed`,客户端的机器人照样站在原地。
|
||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||
Ananta.Server.Protocol.Client4229938.WorldCodec.LogicAgentLeave(summon.AgentEntityId));
|
||
|
||
ctx.Session.Items.Remove(ControllableSummonStateKey);
|
||
ctx.Session.Log.Info($"[SUMMON] recalled uid={summon.AgentEntityId} agent={summon.AgentId} reason={reason} ({why}) — SyncLogicAgentLeave sent");
|
||
}
|
||
|
||
private async Task OnControlAgent(RpcContext ctx, ulong agentEntityId, byte reason)
|
||
{
|
||
var summon = ctx.Session.Items.TryGetValue(ControllableSummonStateKey, out var raw)
|
||
? raw as ControllableSummonState
|
||
: null;
|
||
|
||
if (summon is null)
|
||
{
|
||
ctx.Session.Log.Warn($"[SUMMON] AskControlAgent(uid={agentEntityId}, reason={reason}) 但没有召唤物记录 — 忽略");
|
||
return;
|
||
}
|
||
|
||
// 客户端可能带上它自己记的 uid;不一致时以服务端这份为准,但记一条日志方便对线。
|
||
if (agentEntityId != 0 && agentEntityId != summon.AgentEntityId)
|
||
ctx.Session.Log.Warn($"[SUMMON] AskControlAgent uid={agentEntityId} != 服务端记录 {summon.AgentEntityId} — 按服务端记录下发");
|
||
|
||
// 去抖:按住 N(或连点)时客户端实测 ~200ms 一发,每次都推会让客户端反复 DoHackEnemy。
|
||
// 已经在操控位且上次下发不到 1s,就只记日志;超过 1s 说明客户端没接住,照发一次。
|
||
if (summon.Controlling && (DateTime.UtcNow - summon.LastHandoverUtc) < HandoverDebounce)
|
||
{
|
||
ctx.Session.Log.Info($"[SUMMON] AskControlAgent uid={summon.AgentEntityId} — 已在操控位且 {DateTime.UtcNow - summon.LastHandoverUtc:mm\\:ss} 前才发过,去抖跳过");
|
||
return;
|
||
}
|
||
|
||
await HandOverSummonControl(ctx, summon,
|
||
reason == 0 ? SwitchControlReasonClientSwitch : reason, "AskControlAgent");
|
||
}
|
||
|
||
// =================================================================================
|
||
// ③ 机械狗「聚焦模式」—— ⛔ 2026-09-26 05:4x 已删除(实机失败,用户要求撤掉)。
|
||
// 原设计:控制狗时按住鼠标左键 ⇒ 准星对准谁就盘查谁。实测不work,
|
||
// 盘查入口改走 GM 面板「盘查模式」+ 里希按 R(见 GameRouter.PoliceExamAuto.cs)。
|
||
// 回滚记录:日志 2026-09-26。
|
||
// =================================================================================
|
||
|
||
/// <summary>
|
||
/// 交出操控位的固定两件套。顺序是硬要求:SyncControllableAgent 先(不查 ClientData),
|
||
/// SyncSwitchControl 后(它第一件事就是 GetUnit + ClientData.AgentId,单位没建号就 print_error)。
|
||
/// </summary>
|
||
private static async Task HandOverSummonControl(RpcContext ctx, ControllableSummonState summon,
|
||
byte reason, string why)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncControllableAgent, new SceneMethods.SyncControllableAgent4229938
|
||
{
|
||
spiritId = summon.SpiritId,
|
||
agentEntityId = summon.AgentEntityId,
|
||
});
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncSwitchControl, new SceneMethods.SyncSwitchControl4229938
|
||
{
|
||
spiritId = summon.SpiritId,
|
||
agentEntityId = summon.AgentEntityId,
|
||
enterOrLeave = true,
|
||
reason = reason,
|
||
});
|
||
|
||
summon.Controlling = true;
|
||
summon.LastHandoverUtc = DateTime.UtcNow;
|
||
|
||
ctx.Session.Log.Info($"[SUMMON] handover spirit={summon.SpiritId} uid={summon.AgentEntityId} enter=true reason={reason} ({why})");
|
||
}
|
||
}
|