946 lines
51 KiB
C#
946 lines
51 KiB
C#
using System.Text.Json;
|
||
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 Ananta.Server.RpcTypes.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Teleport: inbound client/GM requests plus server-push primitives used by the admin panel.
|
||
/// Lua (RPCSerializeAuto WriteTeleportOption/WritePreTeleportOption) is the wire truth;
|
||
/// dump.cs TeleportOption/PreTeleportOption field order is mirrored 1:1 in TeleportMethods.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>Session key holding the client's own <c>PreTeleportOption</c> (echo source).</summary>
|
||
private const string PendingTeleportOptionKey = "pending-teleport-option";
|
||
|
||
/// <summary>LoadingConfig.ConfigType 里「进出改装店」的取值(客户端 EnterExitCarShop)。</summary>
|
||
private const uint CarShopTeleportConfig = 8u;
|
||
|
||
/// <summary>
|
||
/// <c>LoadingConfig.EnterExitRoomId</c> = 景点 / 电梯传送门(<c>MapentranceConfig.Type == 1</c>)
|
||
/// 的 <c>AskTeleport</c> config 值。客户端 <c>RpcUtils.lua:88</c> 就是拿它发起电梯事务的。
|
||
/// </summary>
|
||
private const uint JiguanTeleportConfig = 2u;
|
||
|
||
/// <summary>
|
||
/// <c>LoadingConfig</c> 里「进出车库」的 <c>AskTeleport</c> config 值
|
||
/// (客户端 <c>GarageManager.lua PlayLoadingCutscene</c> 传的是
|
||
/// <c>LTConfig.LoadingConfig.EnterExitGarage</c>,实测线值 <c>7</c>;
|
||
/// 对应 <c>LoadingConfig.json</c> 的 <c>Id=7 / ConfigType=6 / PanelType=2</c>)。
|
||
///
|
||
/// <para>⛔ 进库与出库<b>共用</b>这一个 config 值,只能靠 <c>beforeResName</c> 区分方向:
|
||
/// 进库 = <c>EnterTimeline[1]</c>(<c>loading_garage_in04</c> / <c>in05</c>),
|
||
/// 出库 = <c>ExitTimeline[1]</c>(<c>loading_garage_out01</c> / <c>out03</c>)。</para>
|
||
/// </summary>
|
||
private const uint GarageExitTeleportConfig = 7u;
|
||
|
||
/// <summary>
|
||
/// 会话键:一趟电梯事务「待生效的落点」。
|
||
///
|
||
/// 写入方 = <c>AskEnterRaidByMapEntrance</c>(推 <c>SyncTeleport</c> 的同一步);
|
||
/// 消费方 = <c>ReportPostTeleportFinish</c>(过场全部结束)—— 见
|
||
/// <see cref="MapEntranceSettings.JiguanHardSet"/> 的说明:
|
||
/// 客户端不落地,所以最后一帧用硬设位置通道补上。
|
||
/// </summary>
|
||
private const string PendingJiguanLandingKey = "jiguan-pending-landing";
|
||
|
||
/// <summary>一趟电梯事务的落点(起终点门 id 只用于日志/排查)。</summary>
|
||
private sealed record PendingJiguanLanding(
|
||
float X, float Y, float Z, float Facing, uint SourceEntranceId, uint DestEntranceId);
|
||
|
||
// ----------------------------------------------------------------------------------
|
||
// 改装店(ApplyCar / RepairShop)—— 2026-09-25 从俄语上游 donor 的 repair-shop.zip 移植。
|
||
//
|
||
// 入场链路(客户端 ApplyCarManager.lua:152 EnterRepairShop):
|
||
// 触发区(AskVehicleEnterArea) → CheckCanEnterCarShop → AskTeleport(config=8)
|
||
// → 服务端 echo SyncPreTeleportOption → 客户端播 beforeResName 时间轴
|
||
// → ReportPreTeleportFinish → **AskTeleportToParkingWaypoint**
|
||
// → 服务端推 SyncTeleport(人) + SyncTeleportVehicle(车) → ReportPostTeleportFinish。
|
||
//
|
||
// ⛑⛑ 两处 donor 用实机日志证明、不看代码绝对猜不出来的坑:
|
||
// ① config=8 的 option.position / afterPosition 客户端填的是 (0,0,0),
|
||
// 过场结束时客户端会「应用 afterPosition」⇒ 不改写就把人和车甩到世界原点
|
||
// (donor 2026-09-22 18:28 实测, SyncChangeIndoor(4) 进店成功 2 秒后掉回家)。
|
||
// ② 关键一步是分布 AskTeleportToParkingWaypoint / LeavingWaypoint 里推 *人的*
|
||
// SyncTeleport:只 ack 不推 ⇒ 客户端永远停在黑屏(17:11 / 17:31 实测),
|
||
// post-finish 永远不来。而且 pre-finish 里不能推(会把驾驶中的玩家拽到半空)。
|
||
// ----------------------------------------------------------------------------------
|
||
|
||
/// <summary>一间 Smokey Drift 的四个落成点(坐标见 <see cref="ShopInteriors"/>)。</summary>
|
||
private sealed record ShopAnchor(
|
||
GameMethods.TeleportVec3 Entrance,
|
||
GameMethods.TeleportVec3 Interior,
|
||
float Facing,
|
||
GameMethods.TeleportVec3 EnterPos,
|
||
float EnterYaw,
|
||
GameMethods.TeleportVec3 Leave,
|
||
float LeaveYaw);
|
||
|
||
/// <summary>
|
||
/// 两间 Smokey Drift 的落成坐标(donor 从客户端 waypoint 表导出,不是手调):
|
||
/// 西门 CarShopConfig.Id=1(indoorID 4)、海边 Id=2(indoorID 47)。
|
||
/// Interior = ParkingWaypoint(地下工位,y<0),EnterPos = StopCarWaypoint(过场起点),
|
||
/// Leave = LeaveWaypoint(离场落地点),Entrance 只用于「离玩家最近的是哪家店」。
|
||
/// </summary>
|
||
private static readonly ShopAnchor[] ShopInteriors =
|
||
[
|
||
new ShopAnchor(new(1439.10803f, 1.5f, 2379.6001f), new(1443.23f, -19.992f, 2351.428f), 180f,
|
||
new(1434.62f, -0.01f, 2378.78f), 90f,
|
||
new(1414.36f, 0f, 2379.78f), 176.5f), // CarShopConfig 1 — 西门
|
||
new ShopAnchor(new(2791.15601f, 1.37899995f, 1286.39001f), new(2783.912f, -11.92f, 1262.419f), 0f,
|
||
new(2790.46f, -0.01f, 1290.93f), 180f,
|
||
new(2817.31f, 0f, 1296.75f), 270f), // CarShopConfig 2 — 海边
|
||
];
|
||
|
||
/// <summary>按「离玩家最近的那家店」选店(没有位置信息时兜底西门)。</summary>
|
||
private static ShopAnchor NearestShop(Vec3? playerPos)
|
||
{
|
||
if (playerPos is not { } p)
|
||
return ShopInteriors[0];
|
||
var best = ShopInteriors[0];
|
||
var bestD = float.MaxValue;
|
||
foreach (var s in ShopInteriors)
|
||
{
|
||
var dx = s.Entrance.X - p.X;
|
||
var dz = s.Entrance.Z - p.Z;
|
||
var d = dx * dx + dz * dz;
|
||
if (d < bestD) { bestD = d; best = s; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>入口与出口共用 config=8,靠 beforeResName 里的 in/out 区分方向。</summary>
|
||
private static bool IsExitCutscene(GameMethods.PreTeleportOption option)
|
||
=> option.beforeResName is not null && option.beforeResName.Contains("out", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// config=8 且 position 是全零时,照 donor 的做法用真实目标点填空:
|
||
/// 进场填 Interior(工位)、离场填 Leave(街边),并同步 afterPosition / afterRot。
|
||
/// </summary>
|
||
private ShopAnchor? PrepareCarShopOption(Connection conn, GameMethods.PreTeleportOption option, WorldEntryState state)
|
||
{
|
||
if (option.configId != CarShopTeleportConfig)
|
||
return null;
|
||
|
||
// 4s 防抖:客户端自己的触发区在离场落地后 0.3s 会补一发 garage_in* 的 AskTeleport,
|
||
// 老实 echo 就把人/车又拽回店里,无限循环(donor 13:35 实测)。真正的「再进一次」
|
||
// 需要真的开车压一次触发区,耗时远大于 4s。
|
||
var isExit = IsExitCutscene(option);
|
||
if (!isExit && state.LastShopExitTicks != 0
|
||
&& Environment.TickCount64 - state.LastShopExitTicks < 4000)
|
||
{
|
||
conn.Log.Info("[TELEPORT] car-shop enter suppressed (within 4s of exit — client trigger re-fire)");
|
||
return null;
|
||
}
|
||
|
||
// 选店用「车的实时位置」,不用玩家坐标:AskVehicleMove 不更新玩家坐标,开车时它是过期的。
|
||
var shop = NearestShop(LastDrivenVehicleFix(conn.Session) is { } f
|
||
? new Vec3(f.X, f.Y, f.Z)
|
||
: state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition : null);
|
||
|
||
if (option.position.X == 0f && option.position.Y == 0f && option.position.Z == 0f)
|
||
{
|
||
var dest = isExit ? shop.Leave : shop.Interior;
|
||
var destYaw = isExit ? shop.LeaveYaw : shop.Facing;
|
||
option.position = dest;
|
||
option.facing = destYaw;
|
||
option.afterPosition = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z);
|
||
option.afterRot = new GameMethods.TeleportVec3(0f, destYaw, 0f);
|
||
}
|
||
// 没有 spoon 图(我们 SyncEnterScene 的 SceneSpoonNames 是空的)时
|
||
// SpoonRaidMgr.GetWayPointPosition 返回 Vector3.zero 而不是 nil ⇒ 客户端「自以为成功」
|
||
// 并带上 customBeforeTrans=true + beforePosition=(0,0,0)。用落成坐标覆盖。
|
||
if (option.customBeforeTrans
|
||
&& option.beforePosition.X == 0f && option.beforePosition.Y == 0f && option.beforePosition.Z == 0f)
|
||
{
|
||
var anchorPos = isExit ? shop.Interior : shop.EnterPos;
|
||
var anchorYaw = isExit ? shop.Facing : shop.EnterYaw;
|
||
option.beforePosition = new GameMethods.TeleportVec3(anchorPos.X, anchorPos.Y, anchorPos.Z);
|
||
option.beforeRot = new GameMethods.TeleportVec3(0f, anchorYaw, 0f);
|
||
conn.Log.Info($"[TELEPORT] rewrote zero beforePosition ({(isExit ? "exit" : "enter")}) → ({anchorPos.X:F1},{anchorPos.Y:F1},{anchorPos.Z:F1}) yaw={anchorYaw:F0}");
|
||
}
|
||
// 非 null = 「这是改装店事务,调用方去 echo」;坐标改写是原地作用在 option 上的。
|
||
return shop;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★ 景点 / 电梯传送门(<c>config == 2</c>,<c>MapentranceConfig.Type == 1</c>)——
|
||
/// 2026-09-30 三修:<b>「进电梯不黑屏、但落地还在原地」的真正元凶在这里</b>。
|
||
///
|
||
/// <h3>前两修都改错了地方</h3>
|
||
/// 一修:开始推 <c>SyncTeleport</c> ⇒ 黑屏解了(<c>ReportPostTeleportFinish result=0</c>)。
|
||
/// 二修:<c>SyncTeleport.MapEntranceId</c> 从起点门改成<b>落点门</b>(343)⇒ <b>依旧原地</b>
|
||
/// (13:38:43 实机:<c>下发门=343 落点=(2277.54,160.60,1412.50)</c>,玩家仍在
|
||
/// <c>[INDOOR] indoorId=332 绿屿公馆大厅</c>)。
|
||
///
|
||
/// <h3>真因(捐赠端在改装店上用实机日志证明过,见本文件顶部注释 ①)</h3>
|
||
/// <blockquote>
|
||
/// config=8 的 <c>option.position / afterPosition</c> 客户端填的是 (0,0,0),
|
||
/// <b>过场结束时客户端会「应用 afterPosition」</b> ⇒ 不改写就把人和车甩到世界原点
|
||
/// (donor 2026-09-22 18:28 实测,<c>SyncChangeIndoor(4)</c> 进店成功 2 秒后掉回家)。
|
||
/// </blockquote>
|
||
/// 电梯这条链路一模一样:<c>RpcUtils.lua:88</c> 只填了
|
||
/// <c>beforePosition = cfg.Coordinate</c>、<c>afterResName = JiguanEndTimeline</c>,
|
||
/// <b><c>afterPosition</c> 是全零、<c>customAfterTrans = false</c></b>。
|
||
/// 而 <c>SyncPreTeleportOption</c> 我们一直是<b>原样回显</b>客户端那份 ⇒ 过场一结束,
|
||
/// 客户端就把全零(或退化后的当前点)当成落点应用 —— 跟我们推的 <c>SyncTeleport</c> 无关。
|
||
///
|
||
/// <h3>修法(照改装店 <see cref="PrepareCarShopOption"/> 的成型做法)</h3>
|
||
/// 在 echo 之前原地改写 <c>option</c>:<c>position</c> / <c>afterPosition</c> 都填推导出来的
|
||
/// 落点,<c>customAfterTrans = true</c>。目的地仍由
|
||
/// <see cref="JiguanElevatorCatalog4229938"/> 从 <c>MapentranceConfig</c> 推。
|
||
/// 起点门用 <c>beforePosition</c> 反查(客户端就是拿 <c>cfg.Coordinate</c> 填的,
|
||
/// 不依赖 <c>extParams</c> 的解析 —— 那份契约我们还没有)。
|
||
///
|
||
/// <c>SyncTeleport</c> 那一帧保留(它是解开 loading 的必要条件),但不再是落点来源。
|
||
///
|
||
/// <h3>★★ 起点门从哪来:<c>extParams</c> 里的 JSON(2026-09-30 实机取证)</h3>
|
||
/// 客户端 <c>RpcUtils.lua:90-105</c> 会把 <c>extParams.mapEntranceId</c> 一起塞进
|
||
/// <c>PreTeleportOption.extParams</c>(一个 <b>JSON 字符串</b>)。13:38:37 实机报文:
|
||
/// <code>
|
||
/// {"configId":2, ..., "customAfterTrans":false, "afterPosition":{"X":0,"Y":0,"Z":0},
|
||
/// "beforePosition":{"X":2257.781,"Y":0,"Z":1458.242}, "beforeRot":{"X":0,"Y":90,"Z":0},
|
||
/// "extParams":"{\"mapEntranceId\":337}"}
|
||
/// </code>
|
||
/// ⛑ <b>光靠 <c>beforePosition</c> 是分不开的</b>:337 与 340 的 <c>Coordinate</c> 完全相同
|
||
/// (大厅同一个门位上下两个按钮),只有 <c>EntranceFacing</c> 差 180°(90 / -90)。
|
||
/// 所以优先读 <c>extParams</c>;读不到才退回「位置 + <c>beforeRot.Y</c> 朝向」匹配。
|
||
///
|
||
/// 回滚 = <c>gameplay.mapEntrance.jiguanElevator=false</c>。
|
||
/// </summary>
|
||
/// <returns>true = 这是电梯事务且已改写,调用方去 echo。</returns>
|
||
private static bool PrepareJiguanOption(Connection conn, GameMethods.PreTeleportOption option)
|
||
{
|
||
if (option.configId != JiguanTeleportConfig)
|
||
return false;
|
||
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
|
||
if (!cfg.Enabled || !cfg.JiguanElevator)
|
||
{
|
||
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=disabled enabled={cfg.Enabled} jiguanElevator={cfg.JiguanElevator}");
|
||
return false;
|
||
}
|
||
|
||
var entranceFromExt = TryReadMapEntranceIdFromExtParams(option.extParams);
|
||
MapEntranceEntry4229938? source = null;
|
||
var how = string.Empty;
|
||
if (entranceFromExt != 0)
|
||
{
|
||
source = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == entranceFromExt && e.IsJiguan);
|
||
how = source is null ? $"extParams={entranceFromExt}(表里没有)" : $"extParams={entranceFromExt}";
|
||
}
|
||
|
||
if (source is null)
|
||
{
|
||
source = FindJiguanDoorByPosition(option.beforePosition, option.beforeRot.Y);
|
||
how = source is null
|
||
? $"位置({option.beforePosition.X:F2},{option.beforePosition.Y:F2},{option.beforePosition.Z:F2})"
|
||
+ $"+朝向{option.beforeRot.Y:F0}"
|
||
: $"位置+朝向={source.Id}";
|
||
}
|
||
|
||
if (source is null)
|
||
{
|
||
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=no-source-door {how} extParams={option.extParams ?? "-"} "
|
||
+ "既没有可用的 mapEntranceId,也没有 Type==1 的入口坐标与之吻合");
|
||
return false;
|
||
}
|
||
|
||
if (!JiguanElevatorCatalog4229938.TryResolve(source.Id, cfg.JiguanGroupRadius,
|
||
cfg.JiguanLandingOverrides, out var dest, out var note))
|
||
{
|
||
conn.Log.Info($"[TELEPORT] jiguan 跳过 原因=unknown-id entrance={source.Id} {note}");
|
||
return false;
|
||
}
|
||
|
||
var to = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z);
|
||
option.position = to;
|
||
option.facing = dest.Facing;
|
||
option.afterPosition = to;
|
||
option.afterRot = new GameMethods.TeleportVec3(0f, dest.Facing, 0f);
|
||
option.customAfterTrans = true;
|
||
|
||
conn.Log.Info($"[TELEPORT] jiguan 改写 afterPosition 起点门={source.Id}({how}) "
|
||
+ $"落点门={dest.EntranceId} 落点=({dest.X:F2},{dest.Y:F2},{dest.Z:F2}) facing={dest.Facing:F0} "
|
||
+ $"resolved={dest.Resolved} {note}");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 <c>PreTeleportOption.extParams</c> 里取 <c>mapEntranceId</c>。
|
||
/// 客户端塞的是一个 JSON 字符串(实测 <c>{"mapEntranceId":337}</c>;不是电梯事务时是 <c>{}</c>)。
|
||
/// 解析失败一律返回 0,由调用方退回坐标匹配。
|
||
/// </summary>
|
||
private static uint TryReadMapEntranceIdFromExtParams(string? extParams)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(extParams))
|
||
return 0u;
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(extParams);
|
||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||
&& doc.RootElement.TryGetProperty("mapEntranceId", out var el)
|
||
&& el.ValueKind == JsonValueKind.Number
|
||
&& el.TryGetUInt32(out var id))
|
||
return id;
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// 客户端换了格式也无所谓 —— 退回坐标匹配即可。
|
||
}
|
||
return 0u;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按坐标 + 朝向反查景点 / 电梯门(客户端把 <c>cfg.Coordinate</c> 填进 <c>beforePosition</c>、
|
||
/// 把 <c>cfg.EntranceFacing</c> 填进 <c>beforeRot.Y</c>)。
|
||
///
|
||
/// ⛑ 必须先按「位置 + 朝向」筛:绿屿大厅 337 与 340 的 <c>Coordinate</c> 完全相同
|
||
/// (同一个门位的两个按钮),只有 <c>EntranceFacing</c> 差 180°(90 / -90),
|
||
/// 而它俩的目的地一个是高楼层、一个是车库。只看坐标会挑到列表里靠后的那个。
|
||
/// </summary>
|
||
private static MapEntranceEntry4229938? FindJiguanDoorByPosition(GameMethods.TeleportVec3 p, float facing)
|
||
{
|
||
if (!float.IsFinite(p.X) || !float.IsFinite(p.Y) || !float.IsFinite(p.Z))
|
||
return null;
|
||
if (p.X == 0f && p.Y == 0f && p.Z == 0f)
|
||
return null;
|
||
|
||
const float tol = 0.5f; // 配置坐标是浮点,0.5m 足够且不会误并(最近的另一条门 ≥ 4m)
|
||
MapEntranceEntry4229938? nearest = null;
|
||
var nearestD = tol;
|
||
MapEntranceEntry4229938? nearestFacing = null;
|
||
var nearestFacingD = tol;
|
||
|
||
foreach (var e in SceneCatalog4229938.MapEntrances)
|
||
{
|
||
if (!e.IsJiguan)
|
||
continue;
|
||
var dx = e.X - p.X;
|
||
var dy = e.Y - p.Y;
|
||
var dz = e.Z - p.Z;
|
||
var d = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (d > tol)
|
||
continue;
|
||
|
||
if (d <= nearestD)
|
||
{
|
||
nearestD = d;
|
||
nearest = e;
|
||
}
|
||
// 朝向按「模 360 的最短夹角」:下面这个式子得到 [-180,180) 的有符号差,取绝对值即夹角。
|
||
// ⛑ 判据是 `diff <= 1f`(夹角足够小)。别写成 `180f - diff <= 1f` —— 那匹配的是「朝向相反」。
|
||
var diff = MathF.Abs(((e.Facing - facing) % 360f + 540f) % 360f - 180f);
|
||
if (diff <= 1f && d <= nearestFacingD)
|
||
{
|
||
nearestFacingD = d;
|
||
nearestFacing = e;
|
||
}
|
||
}
|
||
return nearestFacing ?? nearest;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★★ 车库<b>出库</b>事务(<c>config == 7</c> 且 <c>beforeResName</c> 含 <c>out</c>)的落点改写。
|
||
/// 2026-09-30 —— 与景点电梯三修、改装店 <see cref="PrepareCarShopOption"/> 是同一个坑:
|
||
/// <b>过场结束时客户端应用的是 <c>afterPosition</c>,而客户端自己那份算不出来</b>。
|
||
///
|
||
/// <h3>为什么客户端算不出来</h3>
|
||
/// <c>GarageManager.lua PlayLoadingCutscene</c> 里(反编译后比较符方向已按语义还原):
|
||
/// <code>
|
||
/// afterWaypointName = houseCfg.GarageOutWaypoint -- Spoon 航点 id
|
||
/// afterTLName = houseCfg.ExitTimeline[2]
|
||
/// if afterWaypointName and afterTLName and afterTLName ~= "" then
|
||
/// local afterPos = gSpoonMgr:GetWayPointPositionByNameOrId(afterWaypointName)
|
||
/// if afterPos then ... preTeleportOption.afterPosition = afterPos ... end
|
||
/// end
|
||
/// </code>
|
||
/// 两条都断:① 服务端没有 Spoon 位置表,客户端在私服里也查不到航点;
|
||
/// ② <b>素光小筑(11200006)的 <c>ExitTimeline</c> 只有 1 个元素</b>
|
||
/// (<c>["loading_garage_out03"]</c>,别的房都是 <c>["out01","out03"]</c>)⇒
|
||
/// <c>afterTLName = ExitTimeline[2] = nil</c> ⇒ 整块跳过 ⇒
|
||
/// <c>afterPosition</c> 留全零(实测 23:38:24 报文
|
||
/// <c>afterPosition:(0,0,0) customAfterTrans:false</c>)⇒ 过场一结束就被甩到世界原点 / 黑屏。
|
||
///
|
||
/// <h3>修法</h3>
|
||
/// 原地改写 <c>option</c>,然后由调用方自己 echo 一份
|
||
/// <c>SyncPreTeleportOption</c>(<b>不走</b>那个受 <c>gameplay.mapEntrance</c> 开关约束的
|
||
/// 通用 echo 分支 —— 车库不该被景点配置牵连):<c>position</c> / <c>afterPosition</c> 都填
|
||
/// <see cref="ResolveGarageExitLanding"/> 推出来的落点、<c>customAfterTrans = true</c>。
|
||
/// 过场起点(<c>beforePosition</c>)客户端同样拿不到 Spoon 航点(也是全零),
|
||
/// 用玩家最近上报的位置兜底 —— 出库时玩家正坐在车位上的车里,那就是车位格。
|
||
///
|
||
/// <para>⚠️ <c>PreTeleportOption</c> <b>没有</b> <c>MapEntranceId</c> 字段(那是
|
||
/// <c>SyncTeleport</c> 的 <c>TeleportOption</c> 才有),落点门 id 只在
|
||
/// <c>OnAskTeleportFromHouseGarage</c> 推 <c>SyncTeleport</c> 那一步用。</para>
|
||
///
|
||
/// 回滚 = <c>gameplay.houseGarage.enabled=false</c>(本方法直接返回,option 逐字节不变)。
|
||
/// </summary>
|
||
/// <returns>true = 这是车库出库事务且已改写,调用方去 echo。</returns>
|
||
private static bool PrepareGarageExitOption(Connection conn, GameMethods.PreTeleportOption option)
|
||
{
|
||
if (option.configId != GarageExitTeleportConfig)
|
||
return false;
|
||
if (!IsExitCutscene(option)) // 进库共用 config=7,靠 beforeResName 里的 out 区分
|
||
return false;
|
||
if (!PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled)
|
||
{
|
||
conn.Log.Info("[GARAGE] 出库事务跳过落点改写 原因=houseGarage.enabled=false");
|
||
return false;
|
||
}
|
||
|
||
var houseId = LastGarageHouseId(conn.Session);
|
||
var landing = ResolveGarageExitLanding(houseId, conn.Log);
|
||
var to = new GameMethods.TeleportVec3(landing.X, landing.Y, landing.Z);
|
||
|
||
option.position = to;
|
||
option.facing = landing.Facing;
|
||
option.afterPosition = to;
|
||
option.afterRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f);
|
||
option.customAfterTrans = true;
|
||
|
||
// 过场起点:客户端填的是 (0,0,0) + customBeforeTrans=true(Spoon 反查退化,
|
||
// 见 PrepareCarShopOption 里同样的说明)。出库时玩家正坐在车位的车里 ⇒
|
||
// 最近上报的位置就是车位格。拿不到位置时才退回落点。
|
||
// ⛔ 注意 LastReportedPlayerPosition 是 RpcTypes.Vec3,不是 Game.TeleportVec3,
|
||
// 不能直接赋值(CS0029)—— 拆成三个 float 再构造。
|
||
var before = option.beforePosition;
|
||
if (option.customBeforeTrans && before.X == 0f && before.Y == 0f && before.Z == 0f)
|
||
{
|
||
var state = GetStateIfExists(conn.Session);
|
||
if (state is not null && state.HasLastReportedPlayerTransform)
|
||
{
|
||
var reported = state.LastReportedPlayerPosition;
|
||
before = new GameMethods.TeleportVec3(reported.X, reported.Y, reported.Z);
|
||
option.beforeRot = new GameMethods.TeleportVec3(0f, landing.Facing, 0f);
|
||
}
|
||
else
|
||
{
|
||
before = to;
|
||
}
|
||
option.beforePosition = before;
|
||
}
|
||
|
||
conn.Log.Info($"[GARAGE] 出库事务改写落点 house={houseId} "
|
||
+ $"after=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} "
|
||
+ $"门={landing.EntranceId}({landing.Source}) "
|
||
+ $"before=({before.X:F1},{before.Y:F1},{before.Z:F1}) customAfterTrans=true");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>把人 + 车一起挪到 dst/yaw(改装店进场/离场共用)。</summary>
|
||
private async Task PushPlayerAndVehicleAsync(Connection conn, WorldEntryState state,
|
||
GameMethods.TeleportVec3 dst, float yaw, string reason)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
|
||
{
|
||
teleportId = 0,
|
||
Position = dst,
|
||
Facing = yaw,
|
||
IsSwitchScene = false,
|
||
WaitTaskResource = false,
|
||
MapEntranceId = 0,
|
||
});
|
||
var vehicleId = ResolveShopVehicleId(conn.Session);
|
||
if (vehicleId != 0)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938
|
||
{
|
||
EntityId = vehicleId,
|
||
Position = new SceneMethods.UxVector3(dst.X, dst.Y, dst.Z),
|
||
Rotation = new SceneMethods.UxVector3(0f, yaw, 0f),
|
||
Velocity = 0f,
|
||
Reset = true,
|
||
MoveToken = 0,
|
||
});
|
||
}
|
||
conn.Log.Info($"[TELEPORT] {reason} → pos=({dst.X:F1},{dst.Y:F1},{dst.Z:F1}) yaw={yaw:F0} vehicle={vehicleId}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 「这个 <c>AskTeleport</c> 事务要播 timeline」的判据:任一 timeline 资源名非空。
|
||
/// 决定是否把客户端发来的 option 原样回显(见 <c>MapEntranceSettings.EchoPreTeleport</c>)。
|
||
/// 实测数据点:地铁 <c>config=5</c> → <c>after=loading_metro_end_1</c>;改装店
|
||
/// <c>config=8</c> → <c>loading_garage_in02</c>(后者走它自己的分支,不经过这里)。
|
||
/// </summary>
|
||
private static bool HasTimeline(GameMethods.PreTeleportOption option)
|
||
=> !string.IsNullOrEmpty(option.beforeResName)
|
||
|| !string.IsNullOrEmpty(option.loadingResName)
|
||
|| !string.IsNullOrEmpty(option.afterResName);
|
||
|
||
/// <summary>玩家最近开过的那辆车(服务端 Proxy 生成/F1 召唤才有)。</summary>
|
||
private static (float X, float Y, float Z, float Yaw)? LastDrivenVehicleFix(TcpSession session)
|
||
{
|
||
var runtime = GetVehicleRuntimeState(session);
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
var id = runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId;
|
||
if (id == 0 || !runtime.Vehicles.TryGetValue(id, out var rec))
|
||
return null;
|
||
return (rec.Position.X, rec.Position.Y, rec.Position.Z, rec.Facing);
|
||
}
|
||
}
|
||
|
||
private static ulong ResolveShopVehicleId(TcpSession session)
|
||
{
|
||
var runtime = GetVehicleRuntimeState(session);
|
||
lock (runtime.SyncRoot)
|
||
return runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId;
|
||
}
|
||
|
||
internal static WorldEntryState? GetStateIfExists(TcpSession session)
|
||
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
|
||
? existing
|
||
: null;
|
||
|
||
// Client -> server: native loading-flow request. Accept it, remember the correlation
|
||
// ids, and let the client drive its own flow; admin pushes use the outbound path below.
|
||
[Handler(MethodId.AskTeleport)]
|
||
private async Task AskTeleport(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (msg.TryGetArgs<GameMethods.AskTeleportArgs>(out var args) && args?.option is not null)
|
||
{
|
||
var option = args.option;
|
||
var state = GetWorldState(msg.Context);
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.PendingTeleportId = option.teleportId;
|
||
state.PendingTeleportPreFinished = false;
|
||
state.PendingTeleportSyncSent = false;
|
||
}
|
||
msg.Context.Session.Items[PendingTeleportOptionKey] = option;
|
||
// 新事务开始 ⇒ 上一趟电梯没走完(客户端 86s 后放弃 result=2)留下的落点必须作废,
|
||
// 否则下一个无关的 ReportPostTeleportFinish 会把人硬拽到过期坐标。
|
||
// 车库出库落点同理:AskTeleport(config=7) 在前、AskTeleportFromHouseGarage 在后
|
||
// (后者才写这个键),所以在这里清是安全的。
|
||
msg.Context.Session.Items.Remove(PendingJiguanLandingKey);
|
||
msg.Context.Session.Items.Remove(PendingGarageLandingKey);
|
||
conn.Log.Info($"[TELEPORT] AskTeleport config={option.configId} teleportId={option.teleportId} pos=({option.position.X:F1},{option.position.Y:F1},{option.position.Z:F1}) facing={option.facing:F2} before=(res={option.beforeResName} custom={option.customBeforeTrans} {option.beforePosition.X:F1},{option.beforePosition.Y:F1},{option.beforePosition.Z:F1}) after=({option.afterPosition.X:F1},{option.afterPosition.Y:F1},{option.afterPosition.Z:F1} res={option.afterResName})");
|
||
|
||
// 改装店:服务端必须 echo 同一份 option(客户端冷推是不吃的 —— donor 证明只认
|
||
// 「自己发过的 AskTeleport 的回显」),并且 config=8 的全零坐标要在这里补齐。
|
||
var echo = PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled
|
||
? PrepareCarShopOption(conn, option, state)
|
||
: null;
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
if (echo is not null)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
|
||
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (config={option.configId} teleport={option.teleportId})");
|
||
return;
|
||
}
|
||
|
||
// ★★ 2026-09-30 三修:景点 / 电梯(config=2)的落点必须写在 afterPosition 里。
|
||
// 见 PrepareJiguanOption 上的长注释 —— 真正的元凶是「过场结束时客户端应用
|
||
// afterPosition」,而客户端填的是 (0,0,0);SyncTeleport 那一帧不是落点来源。
|
||
if (PrepareJiguanOption(conn, option))
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
|
||
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (景点/电梯 config={option.configId} "
|
||
+ $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) "
|
||
+ $"customAfterTrans={option.customAfterTrans})");
|
||
return;
|
||
}
|
||
|
||
// ★★ 2026-09-30:车库出库事务(config=7 + beforeResName 含 out)的落点改写。
|
||
// 与景点电梯三修同一个坑:客户端算不出 afterPosition(GarageOutWaypoint 是
|
||
// Spoon 航点 id,且素光小筑的 ExitTimeline 只有 1 个元素 ⇒ 连 Spoon 反查都不做)
|
||
// ⇒ 必须由服务端回显一份带真实落点的 option。这里自己 echo、不走下面那个
|
||
// 受 gameplay.mapEntrance 开关约束的通用分支(车库不该被景点配置牵连)。
|
||
if (PrepareGarageExitOption(conn, option))
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
|
||
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (车库出库 config={option.configId} "
|
||
+ $"after=({option.afterPosition.X:F2},{option.afterPosition.Y:F2},{option.afterPosition.Z:F2}) "
|
||
+ $"customAfterTrans={option.customAfterTrans})");
|
||
return;
|
||
}
|
||
|
||
// ⛑⛑ 2026-09-29:非改装店的「带 timeline 的传送事务」也要回显,否则客户端 loading
|
||
// 流程停在第 2 步(AskTeleport → SyncPreTeleportOption → ReportPreTeleportFinish →
|
||
// domain RPC),回调不执行 ⇒ domain RPC 一个都不发。
|
||
// 实测那一帧(10:22:22):AskTeleport config=5 after=(1878.8,-1.1,2250.5
|
||
// res=loading_metro_end_1) —— 地铁乘车动画事务,只 ack 不回显 ⇒「长按 F 没反应」。
|
||
if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.Enabled
|
||
&& PrivateServerConfigStore.Current.Gameplay.MapEntrance.EchoPreTeleport
|
||
&& HasTimeline(option))
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, option);
|
||
conn.Log.Info($"[TELEPORT] SyncPreTeleportOption echoed (timeline 事务 config={option.configId} "
|
||
+ $"before={option.beforeResName ?? "-"} loading={option.loadingResName ?? "-"} after={option.afterResName ?? "-"})");
|
||
}
|
||
return;
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] AskTeleport undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 64))}");
|
||
}
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
[Handler(MethodId.AskTeleportToParkingWaypoint, HandlerPacketKind.Invoke)]
|
||
private async Task AskTeleportToParkingWaypoint(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
if (!msg.TryGetArgs<SceneMethods.AskTeleportToParkingWaypointArgs4229938>(out var args) || args is null)
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] parking-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
// ⛑⛑ 这一步的本质就是「由服务端给出坐标」:只 ack 不推 ⇒ 客户端永远停在 loading
|
||
// 黑屏且 post-finish 永不来(donor 17:11 / 17:31 实测)。目标是地下工位,
|
||
// 不是街上的入口 marker。到位后客户端自动把玩家请下车(retail 行为:
|
||
// ApplyCarManager 的 SetSeatInteractOperation(0,6) —— 车被抬上升降机)。
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
var shop = args.waypointId is 2 or 1029714 or 1029715 or 1029716 ? ShopInteriors[1] : ShopInteriors[0];
|
||
await PushPlayerAndVehicleAsync(conn, GetWorldState(msg.Context), shop.Interior, shop.Facing, $"parking waypoint={args.waypointId}");
|
||
}
|
||
|
||
[Handler(MethodId.AskTeleportToLeavingWaypoint, HandlerPacketKind.Invoke)]
|
||
private async Task AskTeleportToLeavingWaypoint(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (!PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled)
|
||
{
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
if (!msg.TryGetArgs<SceneMethods.AskTeleportToLeavingWaypointArgs4229938>(out var args) || args is null)
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] leaving-waypoint bad args bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 16))}");
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
// ok 回执让客户端重置店内状态(OnLeaveRepairShopComplete / ParkingVehicleId=0),
|
||
// 真正的位移 = 下面的 SyncTeleport + SyncTeleportVehicle。
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
var state = GetWorldState(msg.Context);
|
||
var shop = args.carshopid == 2u ? ShopInteriors[1] : ShopInteriors[0];
|
||
var leave = new GameMethods.TeleportVec3(
|
||
shop.Leave.X + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetX,
|
||
shop.Leave.Y,
|
||
shop.Leave.Z + PrivateServerConfigStore.Current.Gameplay.RepairShop.ExitOffsetZ);
|
||
state.LastShopExitTicks = Environment.TickCount64;
|
||
await PushPlayerAndVehicleAsync(conn, state, leave, shop.LeaveYaw, $"leaving waypoint carshop={args.carshopid}");
|
||
}
|
||
|
||
[Handler(MethodId.ReportPreTeleportFinish)]
|
||
private async Task ReportPreTeleportFinish(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (msg.TryGetArgs<GameMethods.ReportPreTeleportFinishArgs>(out var args) && args is not null)
|
||
{
|
||
var state = GetWorldState(msg.Context);
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (args.teleportId == state.PendingTeleportId)
|
||
state.PendingTeleportPreFinished = true;
|
||
}
|
||
conn.Log.Info($"[TELEPORT] ReportPreTeleportFinish teleportId={args.teleportId} pending={state.PendingTeleportId}");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] ReportPreTeleportFinish undecodable bytes={msg.Body.Length}");
|
||
}
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
[Handler(MethodId.ReportPostTeleportFinish, HandlerPacketKind.Notify)]
|
||
private async Task ReportPostTeleportFinish(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (msg.TryGetArgs<GameMethods.ReportPostTeleportFinishArgs>(out var args) && args is not null)
|
||
{
|
||
// result = lua 的 CheckEnum(result, 39, 0)(1 字节)。0 = 成功;非 0 是**客户端自己
|
||
// 认为传送没成功**的唯一信号,所以非 0 单独用 Warn 打出来,别混在 Info 里被刷掉。
|
||
if (args.result == 0)
|
||
conn.Log.Info($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result=0(ok)");
|
||
else
|
||
conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish teleportId={args.teleportId} result={args.result}「非0 ⇒ 客户端认为传送未成功」");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] ReportPostTeleportFinish undecodable bytes={msg.Body.Length} "
|
||
+ $"hex={Convert.ToHexString(msg.Body ?? [], 0, Math.Min(msg.Body?.Length ?? 0, 16))}");
|
||
}
|
||
|
||
// ★★ 2026-09-30 四修:电梯落点兜底。
|
||
//
|
||
// 前三修把服务端侧做到了逐字节正确,客户端仍然一帧都不采纳(实测 14:19:12/:17/:22
|
||
// 三次 [INDOOR] indoorId=332 = 绿屿大厅,玩家原地)。所以这里不再依赖客户端的 loading
|
||
// 流程,直接用「已被实测证明有效」的硬设位置通道补最后一帧:
|
||
// 同一会话 14:16:48 后台瞬移 → (2627.48,188.74,2626.95) → 14:17:15 客户端报
|
||
// [INDOOR] 进入 indoorId=298(观澜公馆主体)⇒ 高楼层可达、通道有效。
|
||
//
|
||
// 落点由 AskEnterRaidByMapEntrance 在推 SyncTeleport 的同一步写进会话;
|
||
// 这里读一次就删(一趟事务只补一次)。
|
||
if (conn.Session.Items.TryGetValue(PendingJiguanLandingKey, out var rawLanding)
|
||
&& rawLanding is PendingJiguanLanding landing)
|
||
{
|
||
conn.Session.Items.Remove(PendingJiguanLandingKey);
|
||
|
||
if (PrivateServerConfigStore.Current.Gameplay.MapEntrance.JiguanHardSet)
|
||
{
|
||
// 立刻一帧:客户端若已经收尾完成就会直接生效。
|
||
await TeleportPlayerInstantAsync(msg.Context, landing.X, landing.Y, landing.Z,
|
||
landing.Facing, $"jiguan landing door={landing.DestEntranceId}");
|
||
// 500ms 后再补一帧:loading 面板还在淡出时第一帧会被吞掉(照 ture 分支
|
||
// ReportPostTeleportFinish 里 seat re-assert 的 retry 做法)。同坐标重复下发无副作用。
|
||
// ⛑ 这里用「会话级」发送 + CancellationToken.None,不用 msg.Context ——
|
||
// 本方法返回后 RpcContext 的 token 可能已被取消,延迟发送会直接失败。
|
||
_ = ReassertJiguanLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), landing);
|
||
conn.Log.Info($"[JIGUAN] 硬设落点 起点门={landing.SourceEntranceId} 落点门={landing.DestEntranceId} "
|
||
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) facing={landing.Facing:F0} 原因=客户端不采纳 SyncTeleport");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Info($"[JIGUAN] 硬设落点 跳过 原因=jiguanHardSet=false "
|
||
+ $"落点=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})(只推 SyncTeleport)");
|
||
}
|
||
}
|
||
|
||
// ★★ 2026-09-30:车库出库落点兜底(与上面景点电梯四修同一个套路)。
|
||
// 出库事务(AskTeleport config=7 beforeResName=loading_garage_out*)的落点客户端自己
|
||
// 算不出来:HouseConfig.GarageOutWaypoint 是 Spoon 航点 id,服务端没有 Spoon 位置表;
|
||
// 而 ExitTimeline 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查都不做 ⇒
|
||
// afterPosition 全零。服务端在 AskTeleportFromHouseGarage 推了 SyncTeleport +
|
||
// VisitorExit 落点,这里再过场收尾那一帧硬设一次玩家 + 车辆位置(客户端不一定采纳
|
||
// SyncTeleport 的 Position,见景点电梯三/四修)。
|
||
if (conn.Session.Items.TryGetValue(PendingGarageLandingKey, out var rawGarageLanding)
|
||
&& rawGarageLanding is PendingGarageLanding garageLanding)
|
||
{
|
||
conn.Session.Items.Remove(PendingGarageLandingKey);
|
||
|
||
if (PrivateServerConfigStore.Current.Gameplay.HouseGarage.Enabled)
|
||
{
|
||
await TeleportPlayerInstantAsync(msg.Context, garageLanding.X, garageLanding.Y, garageLanding.Z,
|
||
garageLanding.Facing, $"garage-exit landing house={garageLanding.HouseId}");
|
||
|
||
if (garageLanding.VehicleId != 0)
|
||
{
|
||
await conn.NotifyAsync(MethodId.SyncTeleportVehicle, new SceneMethods.SyncTeleportVehicle4229938
|
||
{
|
||
EntityId = garageLanding.VehicleId,
|
||
Position = new SceneMethods.UxVector3(garageLanding.X, garageLanding.Y, garageLanding.Z),
|
||
Rotation = new SceneMethods.UxVector3(0f, garageLanding.Facing, 0f),
|
||
Velocity = 0f,
|
||
Reset = true,
|
||
MoveToken = 0,
|
||
});
|
||
}
|
||
|
||
// 500ms 后再补一帧(loading 面板淡出时第一帧会被吞掉,同景点电梯四修)。
|
||
// ⛑ 用会话级发送 + CancellationToken.None —— 本方法返回后 msg.Context 的
|
||
// token 可能已取消,延迟发送会直接失败。
|
||
_ = ReassertGarageLandingAsync(conn.Session, ResolveActiveUnitId(conn.Session), garageLanding);
|
||
|
||
conn.Log.Info($"[GARAGE] 出库硬设落点 house={garageLanding.HouseId} "
|
||
+ $"pos=({garageLanding.X:F2},{garageLanding.Y:F2},{garageLanding.Z:F2}) "
|
||
+ $"facing={garageLanding.Facing:F0} 车={garageLanding.VehicleId}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 延迟补发一帧落点(<c>jiguanHardSet</c> 的第二发)。失败只记日志,不影响主流程。
|
||
/// 走 <see cref="TcpSession.NotifyAsync(uint, byte[], CancellationToken)"/> +
|
||
/// <c>CancellationToken.None</c>:本方法的调用者(handler)已经返回,请求上下文可能已失效。
|
||
/// </summary>
|
||
private static async Task ReassertJiguanLandingAsync(
|
||
TcpSession session, ulong unitId, PendingJiguanLanding landing)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(500);
|
||
// 手动序列化:TcpSession 只有 byte[] 重载,而且这里必须显式传 CancellationToken.None
|
||
// (见方法注释)。
|
||
var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing);
|
||
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||
UxSerializer.Serialize(payload), CancellationToken.None);
|
||
session.Log.Info($"[JIGUAN] 硬设落点 补发 door={landing.DestEntranceId} unit={unitId} "
|
||
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[JIGUAN] 硬设落点 补发失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 延迟补发一帧车库出库落点(<c>ReportPostTeleportFinish</c> 硬设之后 500ms 的第二发)。
|
||
/// 与景点电梯四修同一套路:loading 面板还在淡出时第一帧会被吞掉。
|
||
/// 同坐标重复下发无副作用;失败只记日志,不影响主流程。
|
||
/// 走 <see cref="TcpSession.NotifyAsync(uint, byte[], CancellationToken)"/> +
|
||
/// <c>CancellationToken.None</c>:调用它的 handler 已经返回,请求上下文可能已失效。
|
||
/// </summary>
|
||
private static async Task ReassertGarageLandingAsync(
|
||
TcpSession session, ulong unitId, PendingGarageLanding landing)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(500);
|
||
var payload = WorldCodec.PositionAndFacing(unitId, new Vec3(landing.X, landing.Y, landing.Z), landing.Facing);
|
||
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||
UxSerializer.Serialize(payload), CancellationToken.None);
|
||
|
||
if (landing.VehicleId != 0)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncTeleportVehicle,
|
||
UxSerializer.Serialize(new SceneMethods.SyncTeleportVehicle4229938
|
||
{
|
||
EntityId = landing.VehicleId,
|
||
Position = new SceneMethods.UxVector3(landing.X, landing.Y, landing.Z),
|
||
Rotation = new SceneMethods.UxVector3(0f, landing.Facing, 0f),
|
||
Velocity = 0f,
|
||
Reset = true,
|
||
MoveToken = 0,
|
||
}), CancellationToken.None);
|
||
}
|
||
|
||
session.Log.Info($"[GARAGE] 出库硬设落点 补发 house={landing.HouseId} unit={unitId} "
|
||
+ $"pos=({landing.X:F2},{landing.Y:F2},{landing.Z:F2}) 车={landing.VehicleId}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[GARAGE] 出库硬设落点 补发失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前该用哪个单位 id 发 <c>SyncUnitPositionAndFacing</c>。
|
||
/// 与 <see cref="TeleportPlayerInstantAsync"/> 取同一个来源,保证补发那一帧打在同一实体上。
|
||
/// </summary>
|
||
private static ulong ResolveActiveUnitId(TcpSession session)
|
||
{
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return Profile.InitialUnitId;
|
||
lock (state.SyncRoot)
|
||
return state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||
}
|
||
|
||
// GM service Teleport(x, z): UXRPCMethodArgs62733679. Log it and treat it as a
|
||
// same-map XZ jump hint (Y stays at the last reported height).
|
||
[Handler(MethodId.Teleport)]
|
||
private async Task GmTeleportXZ(Connection conn, UxRpcMessage msg)
|
||
{
|
||
if (msg.TryGetArgs<GameMethods.GmTeleportXZArgs>(out var args) && args is not null)
|
||
{
|
||
conn.Log.Info($"[TELEPORT] GM Teleport x={args.x:F1} z={args.z:F1}");
|
||
var state = GetWorldState(msg.Context);
|
||
float y;
|
||
lock (state.SyncRoot)
|
||
y = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition.Y : 0f;
|
||
await TeleportPlayerInstantAsync(msg.Context, args.x, y, args.z, null, "gm-Teleport-xz");
|
||
}
|
||
else
|
||
{
|
||
conn.Log.Warn($"[TELEPORT] GM Teleport undecodable bytes={msg.Body.Length}");
|
||
}
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Instant move without a loading screen: SyncUnitPositionAndFacing (68030312) with the default
|
||
/// SetPositionType.SwitchSpirit (3). Updates the tracked transform optimistically.
|
||
///
|
||
/// ⚠️ Not SyncUnitPosition_P (68233969): that one is refused for the local avatar —
|
||
/// NetworkGhostModule.ForceSetPositionAndFacing needs NetworkRoleType.Proxy, and the main
|
||
/// character is not a proxy (Player.log: "Role(None)不可强设位置!!").
|
||
/// </summary>
|
||
internal static async Task TeleportPlayerInstantAsync(
|
||
RpcContext ctx, float x, float y, float z, float? facing, string reason)
|
||
{
|
||
var state = GetWorldStateStatic(ctx);
|
||
ulong unitId;
|
||
float face;
|
||
lock (state.SyncRoot)
|
||
{
|
||
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||
face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing);
|
||
state.LastReportedPlayerPosition = new Vec3(x, y, z);
|
||
state.LastReportedPlayerRotation = new Vec3(0f, face, 0f);
|
||
state.HasLastReportedPlayerTransform = true;
|
||
}
|
||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing, WorldCodec.PositionAndFacing(unitId, new Vec3(x, y, z), face));
|
||
ctx.Session.Log.Info($"[TELEPORT] instant reason={reason} unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Loading-flow move: SyncPreTeleportOption (64167855) + SyncTeleport (64030755)
|
||
/// with a fresh teleport id. The client is expected to answer with
|
||
/// ReportPreTeleportFinish / ReportPostTeleportFinish (logged by handlers above).
|
||
/// </summary>
|
||
internal static async Task<ulong> TeleportPlayerWithLoadingAsync(
|
||
RpcContext ctx, float x, float y, float z, float facing, string reason)
|
||
{
|
||
var state = GetWorldStateStatic(ctx);
|
||
ulong unitId;
|
||
ulong teleportId;
|
||
lock (state.SyncRoot)
|
||
{
|
||
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||
teleportId = (ulong)Interlocked.Increment(ref s_teleportIdCounter);
|
||
state.PendingTeleportId = teleportId;
|
||
state.PendingTeleportPreFinished = false;
|
||
state.PendingTeleportSyncSent = true;
|
||
state.LastReportedPlayerPosition = new Vec3(x, y, z);
|
||
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
|
||
state.HasLastReportedPlayerTransform = true;
|
||
}
|
||
|
||
var pre = new GameMethods.PreTeleportOption
|
||
{
|
||
configId = 0,
|
||
teleportId = teleportId,
|
||
ForceClear = false,
|
||
TimeLineDration = 0,
|
||
position = new GameMethods.TeleportVec3(x, y, z),
|
||
facing = facing,
|
||
beforeResName = string.Empty,
|
||
customBeforeTrans = false,
|
||
beforePosition = new GameMethods.TeleportVec3(x, y, z),
|
||
beforeRot = new GameMethods.TeleportVec3(0f, facing, 0f),
|
||
loadingResName = string.Empty,
|
||
afterResName = string.Empty,
|
||
customAfterTrans = false,
|
||
afterPosition = new GameMethods.TeleportVec3(x, y, z),
|
||
afterRot = new GameMethods.TeleportVec3(0f, facing, 0f),
|
||
extParams = string.Empty,
|
||
};
|
||
await ctx.NotifyAsync(MethodId.SyncPreTeleportOption, pre);
|
||
await ctx.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
|
||
{
|
||
teleportId = teleportId,
|
||
Position = new GameMethods.TeleportVec3(x, y, z),
|
||
Facing = facing,
|
||
IsSwitchScene = false,
|
||
WaitTaskResource = false,
|
||
MapEntranceId = 0,
|
||
});
|
||
ctx.Session.Log.Info($"[TELEPORT] loading-flow reason={reason} unit={unitId} teleportId={teleportId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
|
||
return teleportId;
|
||
}
|
||
|
||
private static long s_teleportIdCounter = 900_000;
|
||
|
||
private static WorldEntryState GetWorldStateStatic(RpcContext ctx)
|
||
{
|
||
if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
|
||
return existing;
|
||
var created = new WorldEntryState();
|
||
ctx.Session.Items[WorldStateKey] = created;
|
||
return created;
|
||
}
|
||
}
|