402 lines
18 KiB
C#
402 lines
18 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 大地图入口(传送点 / 地铁站)—— 2026-09-27 从 kpect 捐赠端移植的「入口四连发 + 点击真传送」。
|
||
///
|
||
/// ## 为什么之前大地图上什么都没有
|
||
/// 客户端地图入口是**服务端下发驱动**的,四条 S2C 在此之前**全服零命中**(grep 过整个 v0.3):
|
||
/// · SyncMapEntrance (64067363) 全量清单 → MapUtils._visibleEntrance / _unlockedEntrance
|
||
/// · UpdateMapEntrance (64239821) 逐点开关
|
||
/// · SyncPrepareMapEntrance (64610549) 逐点准备(交通层入口的注册步骤)
|
||
/// · SyncSubwayFare (64660858) 地铁票价
|
||
/// 判据链:<c>MapUtils.IsEntranceVisible(id)</c> 只查 <c>_visibleEntrance</c>,而这个表
|
||
/// **只能**由上面前两条帧写入 ⇒ 一条不发 = 地图上一个传送点图标都没有。
|
||
///
|
||
/// ## 点击传送
|
||
/// <c>AskTouchMapEntrance</c>(63284182) 是客户端 <c>Notify</c>(void,不等回包),
|
||
/// 调用点 <c>MapCsApi.Legacy_DoFightMapEntrance(gRaidDataManager.RaidId, mapEntranceId)</c>。
|
||
/// 以前没有 handler ⇒ 落到 RPC 兜底 ⇒ 点了没反应。
|
||
/// · 同 raid → <c>TeleportPlayerWithLoadingAsync</c>(官方 loading 传送流程)
|
||
/// · 跨 raid → <c>SwitchSceneAsync</c>(GameRouter.SceneSwitch.cs,换图事务)
|
||
///
|
||
/// ## 数据来源
|
||
/// 用我们自己的 <c>MapentranceConfig</c>(93 行)+ <c>RaidConfig</c>
|
||
/// (<c>SceneCatalog4229938.MapEntrances</c>),**不用** kpect 的 <c>ExtraCatalog</c>,
|
||
/// 也不动主线已经完整的 <c>GameRouter.Metro.cs</c>(SyncRunningMetroInfos 68333359)。
|
||
///
|
||
/// ## 回滚
|
||
/// <c>gameplay.mapEntrance.enabled=false</c> ⇒ 四帧都不发、点击也不传送,逐字节回到移植前。
|
||
/// 逐帧也能单独关(pushSync / pushUpdate / pushPrepare / pushSubwayFare)。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>会话键:入口四连发只推一次(重连/换图会再走一次 world-ready)。</summary>
|
||
private const string MapEntrancePublishedKey = "map-entrance-published";
|
||
|
||
/// <summary>
|
||
/// 进世界后推一次地图入口四连发。
|
||
///
|
||
/// ⛑ 派发量:93 个入口 × (UpdateMapEntrance + SyncPrepareMapEntrance) = 186 帧。
|
||
/// 收包循环是串行的(读完一帧 → await DispatchAsync → 才读下一帧),同步连发会把
|
||
/// 客户端这一秒的上报全部堵住 —— 与 <c>PublishMapFogUnlockAsync</c> 那次 260 帧
|
||
/// 是同一个坑,所以这里照抄它的「后台分批 + 10ms/25 帧」摊平写法。
|
||
/// </summary>
|
||
internal static async Task PublishMapEntrancesAsync(RpcContext ctx)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
|
||
if (!cfg.Enabled)
|
||
{
|
||
ctx.Session.Log.Info("[MAP-ENTRANCE] 下发=no 结果=disabled 原因=gameplay.mapEntrance.enabled=false");
|
||
return;
|
||
}
|
||
|
||
if (ctx.Session.Items.ContainsKey(MapEntrancePublishedKey))
|
||
return;
|
||
ctx.Session.Items[MapEntrancePublishedKey] = true;
|
||
|
||
var entrances = SceneCatalog4229938.MapEntrances;
|
||
if (entrances.Count == 0)
|
||
{
|
||
ctx.Session.Log.Info("[MAP-ENTRANCE] 下发=no 结果=table-empty "
|
||
+ "原因=MapentranceConfig 没有可用行(RaidId=0 或无坐标的行会被 loader 丢掉)");
|
||
return;
|
||
}
|
||
|
||
var ids = entrances.Select(e => e.Id).Distinct().ToList();
|
||
var session = ctx.Session;
|
||
|
||
if (cfg.PushSync)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncMapEntrance, new GameMethods.SyncMapEntrance4229938
|
||
{
|
||
openEntrance = new List<uint>(ids),
|
||
displayableEntrances = new List<uint>(ids),
|
||
});
|
||
}
|
||
|
||
if (cfg.PushSubwayFare)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncSubwayFare, new GameMethods.SyncSubwayFare4229938
|
||
{
|
||
fare = cfg.SubwayFare,
|
||
});
|
||
}
|
||
|
||
var metro = entrances.Where(e => e.Type == SceneCatalog4229938.MapEntranceTypeSubway).ToList();
|
||
session.Log.Info($"[MAP-ENTRANCE] SyncMapEntrance 下发=yes 入口={ids.Count} "
|
||
+ $"地铁(Type=11)={metro.Count} 票价={cfg.SubwayFare} "
|
||
+ $"分布={string.Join(",", entrances.GroupBy(e => e.RaidId).Select(g => g.Key + ":" + g.Count()))}");
|
||
|
||
if (!cfg.PushUpdate && !cfg.PushPrepare)
|
||
return;
|
||
|
||
var pushUpdate = cfg.PushUpdate;
|
||
var pushPrepare = cfg.PushPrepare;
|
||
_ = Task.Run(async () =>
|
||
{
|
||
const int batchSize = 25;
|
||
const int batchDelayMs = 10;
|
||
var sent = 0;
|
||
for (var i = 0; i < ids.Count; i++)
|
||
{
|
||
try
|
||
{
|
||
if (pushUpdate)
|
||
await session.NotifyAsync(MethodId.UpdateMapEntrance,
|
||
UxSerializer.Serialize(new GameMethods.UpdateMapEntrance4229938
|
||
{
|
||
mapEntranceId = ids[i],
|
||
isOpen = true,
|
||
isShow = true,
|
||
}), CancellationToken.None);
|
||
if (pushPrepare)
|
||
await session.NotifyAsync(MethodId.SyncPrepareMapEntrance,
|
||
UxSerializer.Serialize(new GameMethods.SyncPrepareMapEntrance4229938
|
||
{
|
||
entrance = ids[i],
|
||
}), CancellationToken.None);
|
||
sent++;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[MAP-ENTRANCE] push failed entrance={ids[i]}: {ex.Message}");
|
||
break;
|
||
}
|
||
if ((i + 1) % batchSize == 0 && i + 1 < ids.Count)
|
||
await Task.Delay(batchDelayMs, CancellationToken.None);
|
||
}
|
||
session.Log.Info($"[MAP-ENTRANCE] 逐点帧 update={pushUpdate} prepare={pushPrepare} 已发={sent}/{ids.Count}");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskTouchMapEntrance (63284182):客户端 void notify。命中配置就真传送。
|
||
///
|
||
/// 判据分支(grep <c>[MAP-TOUCH]</c>):
|
||
/// ① teleport-in-place 同 raid 命中并传送
|
||
/// ② switch-raid 跨 raid 命中并换图
|
||
/// ③ unknown-id / raid-mismatch / bad-coordinate / malformed / no-world-state / disabled
|
||
/// </summary>
|
||
[Handler(MethodId.AskTouchMapEntrance)]
|
||
private async Task AskTouchMapEntrance(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// 客户端是 notify;万一有人用 invoke 形式发,也立刻回一个空 OK,别把协程卡住。
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
|
||
if (!cfg.Enabled || !cfg.TouchTeleport)
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=disabled 原因=enabled={cfg.Enabled} touchTeleport={cfg.TouchTeleport}");
|
||
return;
|
||
}
|
||
|
||
if (!TryParseMapTouchBody(msg.Body, out var raidId, out var entranceId, out var parseNote))
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=malformed 载荷={msg.Body?.Length ?? 0}B "
|
||
+ "期望=8B(raidId u32 + mapEntranceId u32)");
|
||
return;
|
||
}
|
||
|
||
var row = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceId);
|
||
if (row is null)
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=unknown-id raid={raidId} entrance={entranceId} "
|
||
+ $"原因=MapentranceConfig 未命中(已加载 {SceneCatalog4229938.MapEntrances.Count} 行)");
|
||
return;
|
||
}
|
||
|
||
if (raidId != 0 && row.RaidId != raidId)
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=raid-mismatch 请求 raid={raidId} 配置 raid={row.RaidId} "
|
||
+ $"entrance={entranceId} name={row.Name}");
|
||
return;
|
||
}
|
||
|
||
if (!IsUsableEntranceCoordinate(row.X, row.Y, row.Z))
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=bad-coordinate entrance={entranceId} name={row.Name} "
|
||
+ $"配置坐标=({row.X:F1},{row.Y:F1},{row.Z:F1})");
|
||
return;
|
||
}
|
||
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is null)
|
||
{
|
||
conn.Log.Info("[MAP-TOUCH] 下发=no 结果=no-world-state 说明=会话里还没有世界状态(还没进过游戏)");
|
||
return;
|
||
}
|
||
|
||
uint currentRaid;
|
||
lock (state.SyncRoot)
|
||
currentRaid = state.ActiveRaidId;
|
||
|
||
conn.Log.Info($"[MAP-TOUCH] stage=touch entrance={entranceId} name={row.Name} type={row.Type} "
|
||
+ $"入口 raid={row.RaidId} 当前 raid={currentRaid} 载荷={msg.Body?.Length ?? 0}B({parseNote}) "
|
||
+ $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1}) facing={row.Facing:F1}");
|
||
|
||
if (row.RaidId == currentRaid)
|
||
{
|
||
// ① 同图:走官方 loading 传送流程(instant 那条已被 PoliceArrest.cs ⓪b 证伪:帧发了人不动)。
|
||
await TeleportPlayerWithLoadingAsync(msg.Context, row.X, row.Y, row.Z, row.Facing, $"map-touch:{entranceId}");
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=yes 结果=teleport-in-place entrance={entranceId} "
|
||
+ $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1})");
|
||
return;
|
||
}
|
||
|
||
if (!cfg.TouchCrossRaid)
|
||
{
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=no 结果=cross-raid-skipped entrance={entranceId} "
|
||
+ $"入口 raid={row.RaidId} 当前 raid={currentRaid} 说明=需 gameplay.mapEntrance.touchCrossRaid=true");
|
||
return;
|
||
}
|
||
|
||
// ② 跨图:复用既有换场景事务,落点用**被点击的那个入口**。
|
||
await SwitchSceneAsync(conn.Session, row.RaidId, row.InstanceId, row.UniverseId,
|
||
row.X, row.Y, row.Z, row.Facing, $"map-touch:{entranceId}");
|
||
conn.Log.Info($"[MAP-TOUCH] 下发=yes 结果=switch-raid entrance={entranceId} raid={row.RaidId} "
|
||
+ $"instance={row.InstanceId} universe={row.UniverseId} 目标=({row.X:F1},{row.Y:F1},{row.Z:F1})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 载荷解析:主路径 = 严格 8 B(raidId @0 / mapEntranceId @4,小端裸值)。
|
||
/// 长度不为 8 时做一次自校验容错扫描(要求第二个 u32 能命中入口表、第一个与该行 RaidId 一致),
|
||
/// 命中即采用并在判据行里写明「容错扫描@N」,不会静默成功。
|
||
/// </summary>
|
||
private static bool TryParseMapTouchBody(byte[]? body, out uint raidId, out uint entranceId, out string note)
|
||
{
|
||
raidId = 0;
|
||
entranceId = 0;
|
||
note = "严格8B";
|
||
|
||
if (body is null || body.Length < 8)
|
||
return false;
|
||
|
||
if (body.Length == 8)
|
||
{
|
||
raidId = BitConverter.ToUInt32(body, 0);
|
||
entranceId = BitConverter.ToUInt32(body, 4);
|
||
return true;
|
||
}
|
||
|
||
for (var offset = 0; offset + 8 <= body.Length; offset++)
|
||
{
|
||
var candidateRaid = BitConverter.ToUInt32(body, offset);
|
||
var candidateEntrance = BitConverter.ToUInt32(body, offset + 4);
|
||
if (candidateEntrance == 0)
|
||
continue;
|
||
var hit = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == candidateEntrance);
|
||
if (hit is null)
|
||
continue;
|
||
if (candidateRaid != 0 && hit.RaidId != candidateRaid)
|
||
continue;
|
||
raidId = candidateRaid;
|
||
entranceId = candidateEntrance;
|
||
note = $"容错扫描@{offset}";
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// AskPlayerChangePositionByScenePortal (67006840, invoke u32 mapEntranceId):
|
||
/// 大地图「交通直达」(地铁站 / 公交站 / 传送点)**同图**传送的 domain RPC。
|
||
///
|
||
/// 客户端 <c>MapSubSystem_Entrance.lua:559 MapTeleport</c> 的时序:
|
||
/// ① <c>gLoadingManager:AskTeleport(loadingConfigId, preTeleportOption, ...)</c> —— 客户端
|
||
/// 自己填好了 afterPosition(=MapTeleportPos) / afterResName(=TeleportEndTimeLine) /
|
||
/// loadingResName,服务端 <c>AskTeleport</c> handler 只登记 + ack;
|
||
/// ② ack 回来后回调里 <c>gClientUtils.MapTeleport()</c>(**只关 UI,不动坐标**),再按
|
||
/// 「目标 raid == 当前 raid」分流:同图 → 本 RPC;跨图 → <c>AskPublicSwitchToPublicScene</c>;
|
||
/// ③ 本 RPC 回 Ok 才 <c>StopLoading</c>。
|
||
///
|
||
/// ⛑ 本 RPC 之前**连 method id 都没有**(服务端 0 处引用),又是 Invoke ⇒ 客户端 await
|
||
/// 永久挂起 ⇒ 「长按 F / 点交通直达没反应」。跨图那条(<c>AskPublicSwitchToPublicScene</c>)
|
||
/// 早就有 handler,所以只有同图(25 个地铁站绝大多数都在 23300888 内)表现为完全无响应。
|
||
///
|
||
/// 位移用 <c>SyncTeleport</c> 推,teleportId 取客户端自己那次 <c>AskTeleport</c> 登记的
|
||
/// <c>PendingTeleportId</c>(客户端只认自己事务的那一帧)。
|
||
///
|
||
/// 判据分支(grep <c>[MAP-PORTAL]</c>):
|
||
/// disabled / malformed / unknown-id / bad-coordinate / no-world-state / ok。
|
||
/// </summary>
|
||
[Handler(MethodId.AskPlayerChangePositionByScenePortal)]
|
||
private async Task AskPlayerChangePositionByScenePortal(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
|
||
if (!cfg.Enabled || !cfg.TouchTeleport)
|
||
{
|
||
conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=disabled 原因=enabled={cfg.Enabled} touchTeleport={cfg.TouchTeleport}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
if (!TryParsePortalBody(msg.Body, out var entranceId, out var note))
|
||
{
|
||
conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=malformed 载荷={msg.Body?.Length ?? 0}B 期望=4B(mapEntranceId u32)");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var row = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceId);
|
||
if (row is null)
|
||
{
|
||
conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=unknown-id entrance={entranceId} "
|
||
+ $"原因=MapentranceConfig 未命中(已加载 {SceneCatalog4229938.MapEntrances.Count} 行)");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
if (!IsUsableEntranceCoordinate(row.X, row.Y, row.Z))
|
||
{
|
||
conn.Log.Info($"[MAP-PORTAL] 下发=no 结果=bad-coordinate entrance={entranceId} name={row.Name} "
|
||
+ $"配置坐标=({row.X:F1},{row.Y:F1},{row.Z:F1})");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is null)
|
||
{
|
||
conn.Log.Info("[MAP-PORTAL] 下发=no 结果=no-world-state 说明=会话里还没有世界状态(还没进过游戏)");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
ulong teleportId;
|
||
uint currentRaid;
|
||
lock (state.SyncRoot)
|
||
{
|
||
currentRaid = state.ActiveRaidId;
|
||
teleportId = state.PendingTeleportId;
|
||
state.PendingTeleportSyncSent = true;
|
||
state.LastReportedPlayerPosition = new Vec3(row.X, row.Y, row.Z);
|
||
state.LastReportedPlayerRotation = new Vec3(0f, row.Facing, 0f);
|
||
state.HasLastReportedPlayerTransform = true;
|
||
}
|
||
|
||
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
|
||
{
|
||
teleportId = teleportId,
|
||
Position = new GameMethods.TeleportVec3(row.X, row.Y, row.Z),
|
||
Facing = row.Facing,
|
||
IsSwitchScene = false,
|
||
WaitTaskResource = false,
|
||
MapEntranceId = entranceId,
|
||
});
|
||
conn.Log.Info($"[MAP-PORTAL] 下发=yes 结果=teleport-in-place entrance={entranceId} name={row.Name} type={row.Type} "
|
||
+ $"入口 raid={row.RaidId} 当前 raid={currentRaid} teleportId={teleportId} 载荷={msg.Body?.Length ?? 0}B({note}) "
|
||
+ $"目标=({row.X:F1},{row.Y:F1},{row.Z:F1}) facing={row.Facing:F1}");
|
||
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 载荷解析:主路径 = 严格 4 B(mapEntranceId u32 小端裸值)。
|
||
/// 长度不为 4 时做一次自校验容错扫描(要求该 u32 能命中入口表),命中即采用并写明
|
||
/// 「容错扫描@N」,不会静默成功。
|
||
/// </summary>
|
||
private static bool TryParsePortalBody(byte[]? body, out uint entranceId, out string note)
|
||
{
|
||
entranceId = 0;
|
||
note = "严格4B";
|
||
|
||
if (body is null || body.Length < 4)
|
||
return false;
|
||
|
||
if (body.Length == 4)
|
||
{
|
||
entranceId = BitConverter.ToUInt32(body, 0);
|
||
return true;
|
||
}
|
||
|
||
for (var offset = 0; offset + 4 <= body.Length; offset++)
|
||
{
|
||
var candidate = BitConverter.ToUInt32(body, offset);
|
||
if (candidate == 0)
|
||
continue;
|
||
if (SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == candidate) is null)
|
||
continue;
|
||
entranceId = candidate;
|
||
note = $"容错扫描@{offset}";
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>坐标可用性:有限数且不在原点(表里有 (0,0,0) 占位行)。</summary>
|
||
private static bool IsUsableEntranceCoordinate(float x, float y, float z)
|
||
=> float.IsFinite(x) && float.IsFinite(y) && float.IsFinite(z)
|
||
&& (MathF.Abs(x) > 0.001f || MathF.Abs(z) > 0.001f);
|
||
}
|