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.MapEntrance.cs

402 lines
18 KiB
C#
Raw Permalink 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.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);
}