Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.SceneSwitch.cs

374 lines
20 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 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>
/// Scene ("raid") switching: re-push SyncEnterScene with a new raid/instance/universe
/// destination and re-arm the world-entry barrier lifecycle so the client's normal
/// AskLoadSceneCompleted → quartet → AskLoadingFinished → movement flow runs again.
/// Ported from the proven newcityps (4091149) airport handoff, adapted to the minimal
/// 4229938 world entry (no timelines, no AOI streaming).
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// Client -&gt; server map/teleport request: AskPublicSwitchToPublicScene
/// (63491547, invoke raidId/delay/mapEntranceId). Known raid ids switch via the
/// preset arrivals; anything else is accepted and logged for future mapping.
/// </summary>
[Handler(MethodId.AskPublicSwitchToPublicScene)]
private async Task AskPublicSwitchToPublicScene(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskPublicSwitchToPublicSceneArgs>(out var args) && args is not null)
{
conn.Log.Info($"[SCENE] AskPublicSwitchToPublicScene raid={args.raidId} delay={args.delay} entrance={args.mapEntranceId}");
if (args.raidId != 0 && GameAdminBridge.TryGetScenePreset(args.raidId, out var preset) && preset is not null)
await SwitchSceneAsync(msg.Context.Session, preset.RaidId, preset.InstanceId, preset.UniverseId,
preset.X, preset.Y, preset.Z, preset.Facing, "client-public-switch");
}
else
{
conn.Log.Warn($"[SCENE] AskPublicSwitchToPublicScene undecodable bytes={msg.Body.Length}");
}
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// Client -> server <c>EnterRaid</c> (62899289, invoke raidId/immediate) —— GM 传送面板用的
/// 原生副本切换请求。移植自捐赠端 DremkaPS(2026-09-24)。
///
/// 捐赠端硬编码两个 raid(主城 / 重霄)加一个出生点,本服改成查既有的
/// <see cref="GameAdminBridge.ScenePresets"/> —— 那份表覆盖的副本更多,落点还是 2026-09-20
/// 实机量过的;没有 preset 的 raid 按本服既有约定「接受并记日志」,不静默丢弃。
///
/// ⛑ 与同文件另两条不同,这里<b>先回 EmptyOk 再切换</b>(照捐赠端):EnterRaid 是 Invoke,
/// 客户端在 await 返回值,而 <see cref="SwitchSceneAsync"/> 要连发 SyncGamePause +
/// SyncEnterScene;先回包可以避免切换耗时把控制台那次调用拖到超时。
/// </summary>
[Handler(MethodId.EnterRaid)]
private async Task EnterRaid(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<SceneMethods.EnterRaid>(out var args) && args is not null)
conn.Log.Info($"[SCENE] EnterRaid raid={args.raidId} immediate={args.immediate}");
else
conn.Log.Warn($"[SCENE] EnterRaid undecodable bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
if (args is null)
return;
if (args.raidId != 0
&& GameAdminBridge.TryGetScenePreset(args.raidId, out var preset)
&& preset is not null)
{
await SwitchSceneAsync(msg.Context.Session, preset.RaidId, preset.InstanceId, preset.UniverseId,
preset.X, preset.Y, preset.Z, preset.Facing, "client-enter-raid");
}
else
{
conn.Log.Warn($"[SCENE] EnterRaid raid={args.raidId} has no preset (accepted, no switch)");
}
}
/// <summary>
/// <c>AskEnterRaidByMapEntrance</c>(63510026, invoke u32 <c>mapEntranceId</c>):
/// 景点 / 电梯传送门(<c>MapentranceConfig.Type == 1</c>)。
///
/// <h3>⛑⛑ 2026-09-30:这里只 ack 不推 <c>SyncTeleport</c>,就是「进电梯黑屏、ESC 返回不了」的根因</h3>
/// 客户端 <c>RpcUtils.AskEnterRaidByMapEntrance</c>(<c>E:\lua\lua\LX6\Utils\RpcUtils.lua:88</c>):
/// <code>
/// AskTeleport(LoadingConfig.EnterExitRoomId=2, option, extParams, cb)
/// option.beforeResName = cfg.JiguanStartTimeline -- "loading_elevator02"
/// option.beforePosition = cfg.Coordinate -- 电梯门自己那一格
/// option.afterResName = cfg.JiguanEndTimeline -- "loading_general_end"
/// option.afterPosition = (0,0,0) -- ★ 客户端不知道目的地
/// option.customAfterTrans = false -- ★ 所以不会自己套 afterPosition
/// cb(= ReportPreTeleportFinish 被 ack 之后)→ gClientToGameDelegate:AskEnterRaidByMapEntrance(id)
/// </code>
///
/// 实测(<c>packets-latest.log</c>):
/// <code>
/// 01:07:55.716 C2S AskTeleport config=2 before=loading_elevator02 beforePosition=(2257.781,0,1458.242)
/// 01:07:55.717 S2C RETURN AskTeleport e=0
/// 01:07:55.717 S2C NOTIFY SyncPreTeleportOption 146b ← EchoPreTeleport 回显成功
/// 01:08:01.359 C2S ReportPreTeleportFinish {"teleportId":0} ← 过场动画播完
/// 01:08:01.362 C2S AskEnterRaidByMapEntrance {"mapEntranceId":337}
/// 01:08:01.366 S2C RETURN AskEnterRaidByMapEntrance e=0 ← 只 ack,没有 SyncTeleport
/// 01:09:30.020 C2S ReportPostTeleportFinish {"result":2} ← 86 秒后放弃 ⇒ 黑屏
/// </code>
/// 对照同一份日志里<b>成功</b>的一次(01:07:39,大地图传送 / 地铁):
/// <c>AskPlayerChangePositionByScenePortal</c> → <c>S2C NOTIFY SyncTeleport 31b</c> →
/// <c>ReportPostTeleportFinish result=0</c>。差别就在那一帧 <c>SyncTeleport</c>。
///
/// 目的地由 <see cref="JiguanElevatorCatalog4229938"/> 从 <c>MapentranceConfig</c> 自己推出来
/// (客户端只给 Spoon 航点 id,本地没有 Spoon 图)。推不出目的地时退化成
/// 「推到入口自己那一格」——<b>仍然推</b>,因为「不推」才是黑屏。
///
/// 顺序照 <c>AskPlayerChangePositionByScenePortal</c>:先 <c>SyncTeleport</c> 再 ack。
/// 回滚 = <c>gameplay.mapEntrance.jiguanElevator=false</c>(只 ack,逐字节回到改动前)。
/// </summary>
[Handler(MethodId.AskEnterRaidByMapEntrance)]
private async Task AskEnterRaidByMapEntrance(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskEnterRaidByMapEntranceArgs>(out var args) || args is null)
{
conn.Log.Warn($"[SCENE] AskEnterRaidByMapEntrance undecodable bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var cfg = PrivateServerConfigStore.Current.Gameplay.MapEntrance;
if (!cfg.Enabled || !cfg.JiguanElevator)
{
conn.Log.Info($"[JIGUAN] 下发=no 结果=disabled entrance={args.mapEntranceId} "
+ $"原因=enabled={cfg.Enabled} jiguanElevator={cfg.JiguanElevator}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var state = GetStateIfExists(conn.Session);
if (state is null)
{
conn.Log.Info($"[JIGUAN] 下发=no 结果=no-world-state entrance={args.mapEntranceId} "
+ "说明=会话里还没有世界状态(还没进过游戏)");
await conn.ReturnEmptyOkAsync(msg);
return;
}
if (!JiguanElevatorCatalog4229938.TryResolve(args.mapEntranceId, cfg.JiguanGroupRadius,
cfg.JiguanLandingOverrides, out var dest, out var note))
{
conn.Log.Info($"[JIGUAN] 下发=no 结果=unknown-id entrance={args.mapEntranceId} {note}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
uint currentRaid;
ulong teleportId;
lock (state.SyncRoot)
{
currentRaid = state.ActiveRaidId;
teleportId = state.PendingTeleportId;
state.PendingTeleportSyncSent = true;
state.LastReportedPlayerPosition = new Vec3(dest.X, dest.Y, dest.Z);
state.LastReportedPlayerRotation = new Vec3(0f, dest.Facing, 0f);
state.HasLastReportedPlayerTransform = true;
}
// 跨 raid 的景点门(301/303/320/334 副本、335/336 机场、355-358 凌云)需要换图事务,
// 那是另一条链路(SyncEnterScene + world-entry barrier),本轮不动。
// 但仍然推一帧「原地」SyncTeleport:宁可「电梯没动」,也不能停在黑屏。
if (dest.RaidId != 0 && dest.RaidId != currentRaid)
{
var anchor = SceneCatalog4229938.MapEntrances.FirstOrDefault(e => e.Id == args.mapEntranceId);
var sx = anchor?.X ?? dest.X;
var sy = anchor?.Y ?? dest.Y;
var sz = anchor?.Z ?? dest.Z;
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
{
teleportId = teleportId,
Position = new GameMethods.TeleportVec3(sx, sy, sz),
Facing = dest.Facing,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = args.mapEntranceId,
});
conn.Log.Warn($"[JIGUAN] 下发=partial 结果=cross-raid-stay entrance={args.mapEntranceId} "
+ $"目标 raid={dest.RaidId} 当前 raid={currentRaid} "
+ $"落点=({sx:F2},{sy:F2},{sz:F2}) 说明=跨图换场景本轮未实现,只解开 loading(不再是黑屏)");
await conn.ReturnEmptyOkAsync(msg);
return;
}
// ⛑⛑ 2026-09-30 二修:MapEntranceId 必须填「目的地那一层的门」,不能填玩家按下的起点门。
// 客户端 CommonFuncTeleportFlowInfo 的 ReParse_AfterData() 不吃参数,只认服务端推下来的
// teleportOption;本链路客户端自己的 afterPosition 是全零(customAfterTrans=false),
// 所以它只能拿 MapEntranceId 去 MapentranceConfig 反查落点 ⇒ 填 337 就回到 337 那一格 = 原地。
// 对照成功链路(大地图传送 AskPlayerChangePositionByScenePortal):那里填的就是目的地入口 id。
// 两个来源(Position / MapEntranceId)现在都指向同一扇门,走哪条分支都对。
var entranceId = JiguanEntranceIdToSend(cfg, args.mapEntranceId, dest);
// ★ 2026-09-30 四修:记下这趟电梯的落点。实测客户端不采纳这一帧([INDOOR] 三次都报
// 起点大厅),所以 ReportPostTeleportFinish 会用硬设位置通道再补最后一帧。
// 见 MapEntranceSettings.JiguanHardSet。
conn.Session.Items[PendingJiguanLandingKey] =
new PendingJiguanLanding(dest.X, dest.Y, dest.Z, dest.Facing, args.mapEntranceId, dest.EntranceId);
await conn.NotifyAsync(MethodId.SyncTeleport, new GameMethods.TeleportOption
{
teleportId = teleportId,
Position = new GameMethods.TeleportVec3(dest.X, dest.Y, dest.Z),
Facing = dest.Facing,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = entranceId,
});
conn.Log.Info($"[JIGUAN] 下发=yes 结果=teleport entrance={args.mapEntranceId} "
+ $"下发门={entranceId}(mode={cfg.JiguanEntranceIdMode}) "
+ $"resolved={dest.Resolved} teleportId={teleportId} 当前 raid={currentRaid} "
+ $"落点=({dest.X:F2},{dest.Y:F2},{dest.Z:F2}) facing={dest.Facing:F0} {note}");
await conn.ReturnEmptyOkAsync(msg);
}
/// <c>SyncTeleport.MapEntranceId</c> 到底填哪个 id。
/// <c>"dest"</c>(默认)= 落点那一层的门;<c>"source"</c> = 起点门(一修旧行为);
/// <c>"none"</c> = 0(逼客户端只用 <c>Position</c>,用来验证「客户端拿 MapEntranceId 去
/// MapentranceConfig / Spoon 航点反查、本地没图所以落空」这个猜想)。
/// 落点门为 0(推导失败)时退回起点门,保证 Position 与 MapEntranceId 不打架。
/// </summary>
private static uint JiguanEntranceIdToSend(
MapEntranceSettings cfg, uint sourceEntranceId, JiguanDestination4229938 dest)
{
if (dest.EntranceId == 0)
return sourceEntranceId;
return (cfg.JiguanEntranceIdMode ?? string.Empty).Trim().ToLowerInvariant() switch
{
"source" => sourceEntranceId,
"none" => 0u,
_ => dest.EntranceId,
};
}
/// <summary>
/// Server-driven scene switch. Re-arms the world-entry transaction for a new
/// generation, then pushes SyncEnterScene. The existing barrier handlers
/// (OnLoadSceneCompleted / OnLoadingFinished / movement) complete the handoff.
internal static async Task<(bool Ok, string Message)> SwitchSceneDirectAsync(
TcpSession session,
uint raidId,
ulong instanceId,
uint universeId,
Vec3 position,
float facing,
bool isAirport = false)
{
await SwitchSceneAsync(session, raidId, instanceId, universeId, position.X, position.Y, position.Z, facing, isAirport ? "airport" : "direct-debug");
return (true, $"switched to raid={raidId} instance={instanceId} universe={universeId}");
}
/// </summary>
internal static async Task SwitchSceneAsync(
TcpSession session, uint raidId, ulong instanceId, uint universeId,
float x, float y, float z, float facing, string reason)
{
var state = GetStateStatic(session);
// 换场景:机械羊 / 子弹时间都属于旧世界,必须在 SyncEnterScene 之前收回。
// 两者的 runtime 各带 WorldEntryControlGeneration 守卫,这里是即时回收那一层。
await StopMechanikaSheep4229938(session);
// ★ 捐赠端 GameRouter.SceneSwitch.cs:63:换场景要连 S002 root 一起删。
await StopLixiPolice4229938(session, sceneChange: true);
await StopBaiJingTime4229938(session, "switch-scene");
ulong unitId;
uint templateId;
int generation;
lock (state.SyncRoot)
{
// Landing inside the scene the client is already running: SyncEnterScene reloads the
// level but does not move the avatar (see WorldEntryState.SceneLandingPosition), so the
// pose is captured here and re-applied as a real move after the entry flow goes ready.
var sameScene = state.ActiveRaidId == raidId && state.ActiveInstanceId == instanceId;
state.SceneLandingPosition = sameScene ? new Vec3(x, y, z) : null;
state.SceneLandingFacing = sameScene ? facing : null;
state.ActiveRaidId = raidId;
state.ActiveInstanceId = instanceId;
state.ActiveUniverseId = universeId;
state.ActiveContentScene = raidId == Profile.RaidId ? "WorldMap_Release" : $"raid-{raidId}";
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
templateId = state.ActiveSpiritTemplateId != 0 ? state.ActiveSpiritTemplateId : Profile.InitialSpiritTemplateId;
var next = state.WorldEntryControlGeneration + 1;
if (next <= 0)
next = 1;
state.WorldEntryControlGeneration = next;
generation = next;
state.WorldEntryControlPending = true;
state.WorldEntryControlFinalized = false;
state.WorldEntryControlUnit = unitId;
state.WorldEntryControlTemplate = templateId;
// A switch can happen on any owned character, not just the initial one.
state.WorldEntryAllowNonInitialControl = true;
state.WorldEntryLoadingCompletedGeneration = 0;
state.WorldEntryOpeningEndedGeneration = 0;
state.WorldEntryControlFinalizingGeneration = 0;
state.WorldEntryLogicProjectionPublishedGeneration = 0;
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
state.WorldEntrySceneId4229938 = 0;
state.WorldEntrySessionId4229938 = 0;
// ★ R101:声明本次要等的 scene(== instanceId,见 SceneCatalog4229938:
// raid 23300888→20001222 / 23300999→20001223)。OnLoadSceneCompleted 用它
// 丢弃「上一张图迟到的 ack」,否则那个 ack 会占住权威位、把新图的 ack
// 判成 correlation-mismatch ⇒ world-entry 永不 finalize ⇒ 所有摆件不刷。
state.WorldEntryExpectedSceneId = instanceId;
state.WorldEntryCreateHeroPosition = new Vec3(x, y, z);
state.WorldEntryCreateHeroFacing = facing;
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
state.SceneLoadAckSeen = false;
state.GameResourcesReadySeen = false;
state.ControlPublished = false;
state.SceneCompletionSent = false;
state.CurrentSpiritStateSent = false;
state.Ready = false;
state.SameSceneAckSeen = false;
state.MovementCapabilityPublished = false;
state.FreeRoamReleased = false;
state.FirstMovementSeen = false;
state.InitialCapabilityBuffsPublished = false;
state.AllBuildBuffsPublished = false;
state.InitialActorPresentationPublished = false;
state.CombatProfilePublished = false;
state.SceneId = 0;
state.PendingTeleportId = 0;
state.PendingTeleportPreFinished = false;
state.PendingTeleportSyncSent = false;
state.WorldEntryIsAirportTravel = false;
state.PendingSwitchTemplateId = 0;
state.PendingSwitchUnitId = 0;
state.PendingSwitchOldUnitId = 0;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
}
await ForceLeaveVehicleAsync(session);
GetVehicleRuntimeState(session).Clear();
await session.NotifyAsync(MethodId.SyncGamePause,
UxSerializer.Serialize(WorldCodec.GamePause(true)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncEnterScene,
UxSerializer.Serialize(RuntimePayloadFactory.EnterScene(
raidId, instanceId, universeId, unitId, templateId,
new Vec3(x, y, z), facing, switchShowId: 0, isSwitchSpiritShow: false)),
CancellationToken.None);
session.Log.Info($"[SCENE] switch reason={reason} generation={generation} raid={raidId} instance={instanceId} universe={universeId} unit={unitId} template={templateId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
// ⛑ 换图本身就是一次确定的「我在哪」变更 —— 立刻落盘,别等玩家走够 40m / 20s。
// 2026-09-27:只靠移动上报节流保存的话,「切到重霄站着看两眼就下线」这类会
// 丢掉整段进度,重登回出生点。搜打撤副本由 IsPersistableRaidForPosition 拦掉,
// 副本里退出时写回的是进副本前的那份坐标(PlayerProgressStore.Save 的 else 分支)。
PlayerProgressStore.Save(state, session.Log, quiet: true);
}
private static WorldEntryState GetStateStatic(TcpSession session)
{
if (session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
return existing;
var created = new WorldEntryState();
session.Items[WorldStateKey] = created;
return created;
}
}