Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.Switching.cs
T

1286 lines
83 KiB
C#
Raw 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 System.Buffers.Binary;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.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>Generic character switching. No protagonist-specific switch path lives here.</summary>
internal sealed partial class GameRouter
{
// 「留下来的同伴」unitId 号段。roster 用 1e11 + index(40 个 ⇒ …0000000000~…0000000039),
// 主角固定占 Profile.InitialUnitId(= 1e11),所以同伴从 1e11+100 起发号,
// 走 IdCounterStore 落盘(硬规则 104:自分配号段的高水位必须持久化)。
private const string SwitchCompanionCounter = "switchCompanion";
private const long SwitchCompanionFloor = 100000000100;
/// <summary>
/// 切人演出的两段式:phase 1 由服务端挑 SwitchSpiritConfig 行并推 SyncPreSwitchSpirit,
/// 客户端播完 Timeline 后用 ReportPreSwitchSpiritFinish 回报,phase 2 才做身份移交。
/// 本类只做「落点正确」的那一份 —— 见 <see cref="SwitchAnimationSettings.SpawnTargetBeforeShow"/>。
/// </summary>
private bool SwitchShowEnabled => PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.Enabled;
/// <param name="forcedSwitchShowId">
/// 面板「切人演出」指定要播的那一条 SwitchSpiritConfig 行;0 = 照老规矩自己挑
/// (距离 / DisperseAnchorsByCharacter / 权重)。
/// </param>
/// <returns>
/// <b>true</b> = 真的把切人演出(SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)推出去了;
/// <b>false</b> = 什么演出都没播(世界没就绪 / 上一次还 pending / 角色不在可控花名册 /
/// 目标就是当前角色 / 演出开关关着 / 退化成静默换皮 / 锚点超限)。
/// 面板「切人演出」卡靠这个返回值给玩家一句人话,而不是一律回「已播放」。
/// </returns>
async Task<bool> OnSwitchSpirit(RpcContext ctx, uint requestedSpiritId, bool allowShow = true, uint forcedSwitchShowId = 0)
{
var state = GetWorldState(ctx);
if (!state.Ready)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored before world ready spirit={requestedSpiritId}");
return false;
}
// 上一次演出没等到回执:直接把它结算掉,否则 roster 永久卡在 pending。
var timeoutMs = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PreSwitchTimeoutMs;
if (state.PendingPreSwitchSent && state.PendingSwitchRequestedTicks != 0 &&
Environment.TickCount64 - state.PendingSwitchRequestedTicks > timeoutMs)
{
var waited = Environment.TickCount64 - state.PendingSwitchRequestedTicks;
ctx.Session.Log.Warn(
$"[SWITCH] pre-switch timeout target={state.PendingSwitchTemplateId} waited={waited}ms; forcing handoff");
await CompleteSwitchWithoutTimelineAsync(ctx, state, "pre-switch-timeout");
}
if (state.PendingSwitchTemplateId != 0)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored while pending target={state.PendingSwitchTemplateId}");
return false;
}
// 科斯机甲不在 Characters() 里(隐藏可控原型,IsPublish=false / CanJoin=false),
// 所以不能只查花名册。多一道闸:只有走过 ControlKesi4229938 才接受它 —— 捐赠端
// GameRouter.Switching.cs:33-38 同一处逻辑,防止客户端自己冒Requests replay 出一个
// 没有 SpiritViewData 的单位。
var target = requestedSpiritId == ClientConfigRepository.Kesi.TemplateId
&& !KesiControls4229938.GetValue(ctx.Session, _ => new()).Unlocked
? null
: ClientConfigRepository.ControlCharacter(requestedSpiritId);
if (target is null)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] unknown requested spirit={requestedSpiritId}");
return false;
}
if (target.TemplateId == state.ActiveSpiritTemplateId)
{
// ★ 面板最常踩的一个「点了没反应」:目标就是当前角色,这里原本静默 return,
// 调用方却照样回报「已播放」。现在把状态传出去,由调用方说人话。
ctx.Session.Log.Info($"[SWITCH] already controlling {target.TemplateId}; nothing to do");
return false;
}
// 切角色:机械羊属于「梅卡妮卡在场上时的随从」,子弹时间属于白靖本人 ——
// 两者都绑在旧角色身上,必须在身份移交前收回,否则旧世界的实体会跟着新角色进新场景。
// (各自的 runtime 里还有 WorldEntryControlGeneration 守卫兜底,这里是即时回收。)
await StopMechanikaSheep4229938(ctx.Session);
// ★ 捐赠端 GameRouter.Switching.cs:62:切人只收摊、保留场景所有的 S002 root。
await StopLixiPolice4229938(ctx.Session);
await StopBaiJingTime4229938(ctx.Session, "switch-character");
// ⛑ 切人发起打点:AskSwitchSpiritComplete(67272187) 回报时用它算「演出真实播了多久」。
state.SwitchStartedTicks = Environment.TickCount64;
// ⛑⛑⛑ 实验开关:客户端发起的切人,服务端只回 ack,一个包都不推。
// 目的是判掉「客户端会不会自己建主角」——会的话,从 09-24 起那七样补丁全都可以删。
// 判据见 PrivateServerConfig.AskOnlyForClientSwitch 的注释(A/B/C 三种)。
// ⛑ 这个实验开关是给「客户端发起的切人」用的(注释原话:只作用于 allowShow=true 的客户端路径)。
// 面板「切人演出」卡虽然也是 allowShow=true,但它是服务端主动指定演出,不属于客户端发起
// ⇒ 用 forcedSwitchShowId != 0 把它排除掉,两套逻辑不混。
if (allowShow && forcedSwitchShowId == 0 &&
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.AskOnlyForClientSwitch)
{
ctx.Session.Log.Info($"[SWITCH-ASKONLY] ack only, zero push target={target.TemplateId}/{target.UnitId} " +
$"active={state.ActiveSpiritTemplateId}");
return false;
}
// 这个角色上次被切换掉时留在了大世界里:unitId、位置都在 LeftBehindCharacters 里。
var switchSettings = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
state.LeftBehindCharacters.TryGetValue(target.TemplateId, out var leftBehind);
var targetInWorld = leftBehind is not null;
var hasLiveTransform = state.HasLastReportedPlayerTransform;
var position = hasLiveTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
var facing = hasLiveTransform && float.IsFinite(state.LastReportedPlayerRotation.Y)
? state.LastReportedPlayerRotation.Y
: state.WorldEntryCreateHeroFacing;
// ★★ 落点的第三个来源:**角色自己的日程点位**(09-26 00:4x 加,开关
// `switchAnimations.switchToScheduleSpot`,默认开)。
// 原来只有两个来源:「上次切走时留在哪」或「玩家现在站哪」⇒ 第一次切某个角色
// (LeftBehindCharacters 里没有他)就只能在原地换皮,玩家看到的是
// 「他明明有日程在城里某个点,切过去却是在我脚下」。
// ⛑ 用**同一个**裁决函数 NpcActivityCatalog4229938.Resolve(常驻摆放 / 切人留场都用它),
// 不另写一套;查不到(主角 tag 2/3 没时刻表、该时段活动没坐标)就保持 null ⇒ 照旧原地切。
// ⛑ 距离上限 SwitchToScheduleSpotMaxMeters(0 = 不限,默认)。
NpcActivityChoice? scheduleChoice = null;
if (leftBehind is null && switchSettings.SwitchToScheduleSpot)
{
int daySecond;
uint raid;
lock (state.SyncRoot)
{
// ⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处,本地时间(规则 149)。
daySecond = state.CurrentDaySecond();
raid = state.ActiveRaidId;
}
scheduleChoice = NpcActivityCatalog4229938.Resolve(
target.TemplateId, daySecond, raid, true, position.X, position.Y, position.Z);
}
var scheduleSpot = scheduleChoice is { Placeable: true } ? scheduleChoice : null;
if (scheduleSpot is not null && switchSettings.SwitchToScheduleSpotMaxMeters > 0f)
{
var dx = scheduleSpot.PlaceX - position.X;
var dz = scheduleSpot.PlaceZ - position.Z;
if (Math.Sqrt(dx * dx + dz * dz) > switchSettings.SwitchToScheduleSpotMaxMeters)
scheduleSpot = null;
}
// Freeze the old actor's last authoritative transform before CurrentSpirit changes ownership.
// Delayed movement packets from the old unit are ignored after the handoff, so every target
// starts exactly where the player stood when the switch was requested.
state.LastReportedPlayerPosition = position;
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
// ⛑ 旧角色「留下」的位姿必须在这里单独记一份:演出期间客户端 Timeline 会把角色
// 移动到落点并照常上报位置,phase 2 再读 LastReportedPlayerPosition 拿到的就是
// 新角色的落点(旧角色因此被冻结在新角色身边 + T-pose,16:5x 实测)。
state.SwitchOldUnitPosition = position;
state.SwitchOldUnitFacing = facing;
state.HasSwitchOldUnitPose = true;
// Minimal mode never starts SyncPreSwitchSpirit/SyncSwitchSpiritConfigId Timeline flows.
// It performs only the identity/unit handoff needed to keep the requested character controllable.
// ★★ 面板「切人演出(按动画)」卡(forcedSwitchShowId != 0)与 Z 轮盘切人是两套逻辑,
// 不共用开关:switchAnimations.enabled 是给客户端 AskSwitchSpirit 那套用的,
// 面板这条路径明确指定要播哪条演出,不该被它挡住(用户 2026-10-03 要求)。
// ⛑ 这里只放开 forcedSwitchShowId != 0 的情况,其余行为一字未改。
if (!allowShow || (!SwitchShowEnabled && forcedSwitchShowId == 0))
{
// 目标留在大世界里 ⇒ 直接移交到它所在的位置(服务端驱动 ⇒ 移交时 Leave+重建)。
// 目标没留过 ⇒ 用上面查到的**日程点位**(没有就照旧原地切)。
Vec3 directPos;
if (targetInWorld)
directPos = leftBehind!.Position;
else if (scheduleSpot is null)
directPos = position;
else
{
// ★ 同一个活动级微调 —— 切过去落的点和常驻摆放的点必须一致。
var (sx, sy, sz) = ApplyActivityOffset(scheduleSpot.Entry?.Id ?? 0u,
scheduleSpot.PlaceX, scheduleSpot.PlaceY, scheduleSpot.PlaceZ);
directPos = new Vec3(sx, sy, sz);
}
// ⛑ 朝向无表可查(规则 171②)⇒ 沿用玩家切人时的朝向;要调就配
// gameplay.rosterNpc.activityPoiOverrides[].facing(那条对常驻摆放同样生效)。
var directFacing = targetInWorld ? leftBehind!.Facing : facing;
var viaScheduleSpot = !targetInWorld && scheduleSpot is not null;
ctx.Session.Log.Info(
$"[SWITCH] show=off (allow={allowShow} enabled={SwitchShowEnabled} forced={forcedSwitchShowId}) " +
$"inWorld={targetInWorld} viaScheduleSpot={viaScheduleSpot}; direct handoff");
// ⛑⛑ 走日程点位时:常驻化很可能已经在那个点摆了一具同样的身体 ⇒ 先撤掉,
// 否则瞬移过去就是「两个里希重叠」(规则 166)。常驻循环 1.5s 才跳,等不及。
if (viaScheduleSpot)
await RetireRosterNpcForSwitchAsync(ctx.Session, target.TemplateId);
// ⛑⛑ 切回「上次被切走、留在大世界里」的角色时,先把那个留下的 NPC 收掉,
// 否则玩家会看到"两个自己"(一个受控 + 一个还在原地做自己的事)。
// 回收本来只在演出分支的 heroUnitReuse 段里做(:186),就地路径没有 ——
// 04:1x 关掉演出后 Z 轮盘全走这里,这个残留就会变成默认行为,所以补上。
if (targetInWorld && leftBehind is not null)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(leftBehind.UnitId));
state.LeftBehindCharacters.Remove(target.TemplateId);
ctx.Session.Log.Info($"[SWITCH] reclaim left-behind unit={leftBehind.UnitId} " +
$"template={target.TemplateId} pos=({leftBehind.Position.X:0.##},{leftBehind.Position.Y:0.##},{leftBehind.Position.Z:0.##})");
}
await SwitchSpiritDirectSameScene4229938(
ctx, state, target.TemplateId, target.UnitId, directPos, directFacing,
targetInWorld: targetInWorld);
return false; // 没播演出(allowShow=false 或 switchAnimations.enabled=false)
}
// 演出分支:挑一个 SwitchSpiritConfig 行,落点 = 行的 Position(不是玩家当前位置)。
// 目标留在大世界里 ⇒ 行按「离它留下位置最近」挑(镜头锚点≈角色所在),落点取留下位置。
// 否则按 DisperseAnchorsByCharacter 决定:每个角色散列到自己固定的行(全城分散),
// 或回到「离玩家最近」的老模式。
var disperse = switchSettings.DisperseAnchorsByCharacter;
SwitchAnimationChoice animation;
// ★ 面板指定了一条演出 ⇒ 直接用它,不再走「距离/散列/权重」那套挑选。
// 这是用户 2026-10-03 纠正的点:切人演出是**按动画**选的,不是按角色;同一角色有
// 十几条演出,让服务端替他挑等于玩家永远选不到想看的那条。
var forced = forcedSwitchShowId != 0 ? ClientConfigRepository.SwitchShow(forcedSwitchShowId) : null;
if (forced is not null)
{
animation = new SwitchAnimationChoice(
forced.Id, forced.Timeline,
forced.Weight > 0f && float.IsFinite(forced.Weight) ? forced.Weight : 1f,
0f, 1, 1, null,
forced.Position, forced.Facing, forced.UseZeroPosAndAngle);
ctx.Session.Log.Info($"[SWITCH] forced show id={forced.Id} tl={forced.Timeline} " +
$"desc={forced.Description}/{forced.TimelineDescription} " +
$"agents={forced.AgentIds.Count} target={target.TemplateId}");
}
else
try
{
animation = targetInWorld && switchSettings.ReturnToLeftBehindLocation
? ClientConfigRepository.RandomSwitchAnimation(target.TemplateId, state.LastSwitchShowId, leftBehind!.Position)
: disperse
? ClientConfigRepository.SwitchAnimationForTemplate(target.TemplateId, state.LastSwitchShowId)
: ClientConfigRepository.RandomSwitchAnimation(target.TemplateId, state.LastSwitchShowId, position);
}
catch (Exception ex)
{
ctx.Session.Log.Warn(
$"[SWITCH] no authored switch animation for {target.TemplateId} ({ex.Message}); direct handoff");
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing,
targetInWorld: targetInWorld);
return false; // 没播演出(这一行挑不出可用演出)
}
var landing = animation.UseZeroPosAndAngle ? position : animation.Position;
var landingFacing = animation.UseZeroPosAndAngle ? facing : animation.Facing;
if (targetInWorld && switchSettings.ReturnToLeftBehindLocation)
{
landing = leftBehind!.Position;
landingFacing = leftBehind.Facing;
}
var maxDistance = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.MaxAnchorDistanceMeters;
if (maxDistance > 0f && !targetInWorld && !disperse)
{
var dx = (double)landing.X - position.X;
var dz = (double)landing.Z - position.Z;
var distance = Math.Sqrt(dx * dx + dz * dz);
if (distance > maxDistance)
{
ctx.Session.Log.Info(
$"[SWITCH] anchor too far ({distance:0.#}m > {maxDistance:0.#}m) config={animation.Id}; direct handoff");
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing);
return false; // 没播演出(锚点超过 maxAnchorDistanceMeters)
}
}
state.PendingSwitchTemplateId = target.TemplateId;
state.PendingSwitchUnitId = target.UnitId;
state.PendingSwitchOldUnitId = state.ActiveSpiritUnitId;
state.PendingSwitchShowId = animation.Id;
state.PendingSwitchConfigId = animation.Id;
state.PendingSwitchPosition = position;
state.PendingSwitchFacing = facing;
state.PendingSwitchLandingPosition = landing;
state.PendingSwitchLandingFacing = landingFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
state.PendingSwitchRequestedTicks = Environment.TickCount64;
state.PendingPreSwitchSent = true;
state.PendingSwitchAgentPreSpawned = false;
state.PendingSwitchTargetInWorld = targetInWorld;
// ⛑⛑⛑ 主角换皮模式(HeroUnitReuse):主角单位固定用登录时 CreateHero 建出来的那一个,
// 切人 = 给它换 templateId。IsMe=True 只挂在它身上,换 unitId 或拆掉重建都拿不回来。
var heroUnitReuse = switchSettings.HeroUnitReuse;
var heroUnitId = heroUnitReuse ? Profile.InitialUnitId : target.UnitId;
state.PendingSwitchUnitId = heroUnitId;
// 目标角色上次被切走时留了个同伴在场上 ⇒ 先把那个同伴收掉(演出还没起,可以动单位)。
if (heroUnitReuse && leftBehind is not null)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(leftBehind.UnitId));
state.LeftBehindCharacters.Remove(target.TemplateId);
ctx.Session.Log.Info($"[SWITCH-HERO] reclaim old companion unit={leftBehind.UnitId} " +
$"template={target.TemplateId} pos=({leftBehind.Position.X:0.##},{leftBehind.Position.Y:0.##},{leftBehind.Position.Z:0.##})");
leftBehind = null;
targetInWorld = false;
}
// Phase 1a:把目标角色先投放到演出落点。这是「镜头飞过去时那里真的站着人」的关键 ——
// 走的正是 GM 生成 NPC 那条已验证的四连发(GameAdminBridge.cs:1583-1590),
// ExistingUnitProjection 的 ManagedPid=0 让它保持非受控,不会提前抢走当前角色。
// 目标已经留在大世界里 ⇒ 跳过投放(同一个 unitId 重复 Enter 没意义),
// 但语义上等同预生成:phase 2 移交前仍要 Leave+重建才能交还操控。
var spawnedAgents = new List<ulong>();
if (!heroUnitReuse && targetInWorld)
{
spawnedAgents.Add(target.UnitId);
state.PendingSwitchAgentPreSpawned = true;
}
else if (!heroUnitReuse && PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SpawnTargetBeforeShow)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(target.UnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(target.UnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.ExistingUnitProjection(target.UnitId, target.TemplateId, landing, landingFacing));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(target.UnitId, landing, landingFacing));
// 同上:预生成的单位也是服务端驱动的,镜头飞过去的这几秒里它同样会往下掉。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.FeedPreSpawnedTarget)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData,
WorldCodec.LogicAgentSyncData(target.UnitId, landing, landingFacing,
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.LeftBehindFeedMoveId));
}
spawnedAgents.Add(target.UnitId);
state.PendingSwitchAgentPreSpawned = true;
}
// ⛑⛑⛑ Phase 1a3(2026-10-04):把**演出演员** spawn 到落点。
//
// 这才是「切人演出里那个在演戏的人」。官方语义见 ClientConfigRepository.cs:862 引的注释:
// "Those rows carry AgentId entries that stock SwitchTeleport expects the server to
// spawn before landing."
// ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent 建好。
// 主演 = 演出行的 FightSpiritIds[0](嘉姆 15020989 / 塔菲 15020992),经
// AgentIdForSpirit 映射成它在 NPC 世界的那一面(40140032 / 40140035);
// 陪衬 = 行的 AgentIds(如 59 行里希的两个混混)。
// ⛑ 和「主角单位换皮」是两件事:换皮那个是给玩家操控用的,
// 这些演员是演戏的身体,演完由服务端 Leave 掉。
state.PendingSwitchShowActors = [];
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SpawnShowActors)
{
// ⛑ animation 是 SwitchAnimationChoice(只有位置/权重),出场名单在原始行上 ⇒ 反查。
var showRow = ClientConfigRepository.SwitchShow(animation.Id);
var cast = new List<uint>();
if (showRow is not null && showRow.FightSpiritIds.Count > 0)
{
var lead = GameAdminBridge.AgentIdForSpirit(showRow.FightSpiritIds[0]);
if (lead != 0) cast.Add(lead);
else ctx.Session.Log.Warn($"[SWITCH-SHOW] config={animation.Id} 主演 spirit={showRow.FightSpiritIds[0]} 映射不到 AgentId(FightSpiritConfig.AgentId 为空)");
}
foreach (var extra in showRow?.AgentIds ?? [])
if (extra != 0 && !cast.Contains(extra)) cast.Add(extra);
foreach (var agentId in cast)
{
// 演员是友方/中立,不该被当敌人;SpawnAgentAsync 会按 AgentSummonCatalog 的
// EnemyClassType 对账,对不上就拒绝 —— 失败只记日志,不阻断演出。
var (castOk, castMsg, castUid) = await GameAdminBridge.SpawnAgentAsync(
agentId, landing.X, landing.Y, landing.Z, landingFacing,
hostile: false, engage: false, forceEnemyRelation: false, campOverride: 0);
if (!castOk || castUid == 0)
{
ctx.Session.Log.Warn($"[SWITCH-SHOW] 演员 spawn 失败 config={animation.Id} agent={agentId} reason={castMsg}");
continue;
}
spawnedAgents.Add(castUid);
state.PendingSwitchShowActors.Add(castUid);
ctx.Session.Log.Info($"[SWITCH-SHOW] 演员就位 config={animation.Id} agent={agentId} uid={castUid} at=({landing.X:F2},{landing.Y:F2},{landing.Z:F2})");
}
}
// ⛑⛑⛑ Phase 1a2:演出开始前先把目标角色以**主角身份**重建好。
// 客户端的 LiftOffOneShot 时间线是 SyncPreSwitchSpirit 起的,全长约 14 秒
//(2026-09-25 01:31:50.011 CreateChangeLodCam → 01:32:04.308 TryDestroyChangeLodCam)。
// 把 Leave+重建放在它播放期间(ReportPreSwitchSpiritFinish 之后 = 第 3.5 秒)
// ⇒ 时间线在第 12.5 秒连抛 3 条 "SwitchChar_common_04 2 System.NullReferenceException"
// ⇒ 主角再也建不出来 ⇒ InitModules IsMe False ⇒ 原地跑步、坐标不动。
// ⇒ 任何 Leave / 重建一律挪到发 PreSwitch 之前。
state.PendingSwitchHeroPrebuilt = false;
if (heroUnitReuse)
{
// 主角换皮:Enter 只是把 id 注册进客户端的 logic-agent 同步管理器,
// projection 把该单位换成新 templateId。
// ⛑ 全程**不 Leave**(规则 134 + 02:0x 实测:Leave 掉这个单位就再也拿不回 IsMe=True)。
//
// ⛑⛑⛑ 但「不 Leave」正是「只有一张脸」的根因(规则 141):BaseUnit 从不销毁重建
// ⇒ 身体部件不按新 templateId 重载。而「独立 unitId + 完整重建」虽然模型正常却**走不动**
//(客户端只对登录时那个 unitId 应用本地移动输入)。
// ⇒ `HeroUnitRebuildOnSwitch`:id 保持不变(保住移动 / IsMe),但把单位销毁重建(修模型)。
// ⛑⛑⛑ 顺序与已验证能出模型的 `RecreateHeroBeforeShow` 分支严格对齐:
// Leave → delay → **Enter → Managed → projection** → PosFacing
// 03:4x 第一版把 projection 放在 Enter **之前**(Leave → projection → Enter),
// 实测仍然只剩脸 —— 猜测 projection 先到会被当成"更新已存在的单位"而不是"新建",
// 于是身体部件照样不重载。必须先 Enter 把 id 注册进 logic-agent 同步管理器,
// 再让 projection 到达。
if (switchSettings.HeroUnitRebuildOnSwitch)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(heroUnitId));
var rebuildDelay = switchSettings.RecreateDelayMs;
if (rebuildDelay > 0)
await Task.Delay((int)Math.Min(rebuildDelay, 2000));
ctx.Session.Log.Info($"[SWITCH-HERO] rebuild-before-reskin unit={heroUnitId} -> template={target.TemplateId} " +
$"delay={rebuildDelay}ms");
}
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(heroUnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(heroUnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(heroUnitId, target.TemplateId, landing, landingFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(heroUnitId, landing, landingFacing));
state.PendingSwitchAgentPreSpawned = true;
state.PendingSwitchHeroPrebuilt = true;
spawnedAgents.Add(heroUnitId);
ctx.Session.Log.Info($"[SWITCH-HERO] reskin unit={heroUnitId} -> template={target.TemplateId} " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
}
else if (switchSettings.RecreateHeroBeforeShow && state.PendingSwitchAgentPreSpawned)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(target.UnitId));
var heroDelay = switchSettings.RecreateDelayMs;
if (heroDelay > 0)
await Task.Delay((int)Math.Min(heroDelay, 2000));
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(target.UnitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(target.UnitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(target.TemplateId, landing, landingFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(target.UnitId, landing, landingFacing));
state.PendingSwitchHeroPrebuilt = true;
ctx.Session.Log.Info($"[SWITCH-HERO] prebuilt before show target={target.TemplateId}/{target.UnitId} " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
}
// Phase 1b:客户端从这一步接手播 Timeline。position 仍是玩家发起切人时站的位置(镜头起点),
// 落点走 SyncSwitchSpiritConfigId 的 position + spawnedAgentIds。
await ctx.NotifyAsync(MethodId.SyncPreSwitchSpirit,
WorldCodec.PreSwitchSpirit(animation.Id, target.TemplateId, position));
state.PendingSwitchConfigPublished = false;
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PublishConfigIdEarly)
{
await ctx.NotifyAsync(MethodId.SyncSwitchSpiritConfigId,
WorldCodec.SwitchSpiritConfigId(animation.Id, landing, spawnedAgents));
state.PendingSwitchConfigPublished = true;
}
ctx.Session.Log.Info(
$"[SWITCH] pre-switch config={animation.Id} timeline={animation.Timeline} anchorDist={animation.AnchorDistance:0.#} " +
$"pool={animation.TimelinePool}/{animation.RowPool} prevTimeline={animation.PreviousTimeline ?? "<none>"} " +
$"target={target.TemplateId}/{target.UnitId} oldUnit={state.PendingSwitchOldUnitId} " +
$"preSpawn={state.PendingSwitchAgentPreSpawned} heroPrebuilt={state.PendingSwitchHeroPrebuilt} inWorld={targetInWorld} disperse={disperse} configEarly={state.PendingSwitchConfigPublished} " +
$"from=({position.X:0.##},{position.Y:0.##},{position.Z:0.##}) " +
$"landing=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##}) facing={landingFacing:0.##}");
return true; // 演出包已推出去
}
/// <summary>
/// Phase 2:客户端播完 pre-switch Timeline。落点早已定好(见 <see cref="OnSwitchSpirit"/>),
/// 这里只把控制权交给已经站在落点上的那个角色,不再把它挪回玩家发起切人时的位置。
/// </summary>
async Task OnPreSwitchSpiritFinish(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (!state.PendingPreSwitchSent || state.PendingSwitchTemplateId == 0)
{
ctx.Session.Log.Warn("[SWITCH] ReportPreSwitchSpiritFinish without a pending pre-switch; ignored");
return;
}
var templateId = state.PendingSwitchTemplateId;
var unitId = state.PendingSwitchUnitId;
var landing = state.PendingSwitchLandingPosition;
var landingFacing = state.PendingSwitchLandingFacing;
var configId = state.PendingSwitchConfigId;
var publishedEarly = state.PendingSwitchConfigPublished;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
if (!publishedEarly)
{
// ⛑ 演出演员也要带上(官方语义:落地前给 SwitchTeleport 的 actor 列表)。
var spawned = new List<ulong>();
if (state.PendingSwitchAgentPreSpawned) spawned.Add(unitId);
spawned.AddRange(state.PendingSwitchShowActors);
await ctx.NotifyAsync(MethodId.SyncSwitchSpiritConfigId,
WorldCodec.SwitchSpiritConfigId(configId, landing, spawned));
}
await SwitchSpiritDirectSameScene4229938(
ctx, state, templateId, unitId, landing, landingFacing, switchShowId: configId,
targetInWorld: state.PendingSwitchTargetInWorld);
// 演完收工:把这些"演戏的身体"撤掉。留着会和换皮后的主角单位重影(两个塔菲)。
await DismissSwitchShowActorsAsync(ctx, state, "pre-switch finish");
ctx.Session.Log.Info(
$"[SWITCH] pre-switch finish config={configId} target={templateId}/{unitId} count={state.SwitchCount} " +
$"configEarly={publishedEarly} landed=({landing.X:0.##},{landing.Y:0.##},{landing.Z:0.##})");
}
/// <summary>
/// 撤掉这出演出 spawn 出来的演员(<see cref="WorldEntryState.PendingSwitchShowActors"/>)。
///
/// ⛑ 移除通道与召唤物一致:<c>SyncLogicAgentLeave</c>(68281244) —— 这类单位是
/// <c>SpawnAgentAsync</c> 出来的,用 <c>SyncAetherAINpcRemove</c>(ambient 行人通道)发了等于没发。
/// 幂等:列表清空后重复调用无副作用。
/// </summary>
private static async Task DismissSwitchShowActorsAsync(RpcContext ctx, WorldEntryState state, string why)
{
if (state.PendingSwitchShowActors.Count == 0) return;
var actors = state.PendingSwitchShowActors.ToArray();
state.PendingSwitchShowActors = [];
foreach (var uid in actors)
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
ctx.Session.Log.Info($"[SWITCH-SHOW] 演员撤离 {actors.Length} uid=[{string.Join(",", actors)}] — {why}");
}
/// <summary>演出没等到回执(丢包 / 客户端没播):按已定好的落点直接完成移交。</summary>
async Task CompleteSwitchWithoutTimelineAsync(RpcContext ctx, WorldEntryState state, string reason)
{
var templateId = state.PendingSwitchTemplateId;
var unitId = state.PendingSwitchUnitId;
var landing = state.PendingSwitchLandingPosition;
var landingFacing = state.PendingSwitchLandingFacing;
var configId = state.PendingSwitchConfigId;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
if (templateId == 0 || unitId == 0)
{
ClearPendingSwitch(state);
// 演员也要收:这一趟演出没播成,留着就是几个站在远处的孤儿身体。
await DismissSwitchShowActorsAsync(ctx, state, "abandoned: " + reason);
return;
}
// ⛑ 兜底也落在演出锚点上:退回玩家原位置就是捐赠端那个「演了个寂寞又被拉回来」的形态。
ctx.Session.Log.Info($"[SWITCH] handoff without timeline reason={reason} target={templateId}/{unitId} config={configId}");
await SwitchSpiritDirectSameScene4229938(
ctx, state, templateId, unitId, landing, landingFacing, switchShowId: configId,
targetInWorld: state.PendingSwitchTargetInWorld);
await DismissSwitchShowActorsAsync(ctx, state, "no-timeline: " + reason);
}
private static void ClearPendingSwitch(WorldEntryState state)
{
state.PendingSwitchTemplateId = 0;
state.PendingSwitchUnitId = 0;
state.PendingSwitchOldUnitId = 0;
state.PendingSwitchShowId = 0;
state.PendingSwitchConfigId = 0;
state.PendingSwitchConfigPublished = false;
state.PendingSwitchAgentPreSpawned = false;
state.PendingSwitchHeroPrebuilt = false;
state.PendingSwitchTargetInWorld = false;
state.PendingPreSwitchSent = false;
state.PendingSwitchRequestedTicks = 0;
// ⛑ 只清记账,不就地发包(ClearPendingSwitch 是 static 且拿不到 ctx);
// 实际撤离由调用方走 DismissSwitchShowActorsAsync。
state.PendingSwitchShowActors = [];
}
/// <summary>
/// Server-driven character switch, used by the admin panel instead of the in-game wheel.
///
/// The wheel (Z) is client-side only: it lists FightSpiritConfig rows whose CanJoin is true
/// (15 of 40 here) and switching from it never tells the server anything. Publishing a
/// SyncTaskRoleTeam roster cannot widen it either — a non-empty team greys out every entry the
/// team does not name (see TOPIC_切换角色.md §6), which is worse than leaving it alone.
///
/// So the panel drives <see cref="OnSwitchSpirit"/> directly: same handoff the client's own
/// AskSwitchSpirit takes, but without the client having to offer the character first. That
/// means every roster row is reachable, including the CanJoin=false ones (Bansy, Aileen,
/// Mimiko, Ringo, ...) and rows kept out of the roster by the placeholder-icon filter until
/// they are added through content.roster.additionalTemplateIds (15021042 Lemon Sour).
/// </summary>
internal static async Task<(bool Ok, string Message)> SwitchSpiritAsync(TcpSession session, uint spiritId)
{
// _gateSessions is the only instance field and nothing on this path reads it, so a bare
// instance is enough to carry these methods. Nothing here mutates router-wide state.
var ctx = new RpcContext(
session,
new RpcPacket(RpcPacketKind.Invoke, MethodId.AskSwitchSpirit, 0, Array.Empty<byte>()),
CancellationToken.None);
try
{
// 面板切人走静默路径:面板是用来挑人的,点一下就被演出传送几百米会很难用。
// 想让面板也播演出,把这里的 allowShow 改成 true(或由配置控制)即可。
await new GameRouter().OnSwitchSpirit(ctx, spiritId, allowShow: false);
}
catch (Exception ex)
{
session.Log.Warn($"[SWITCH-MIN] admin switch failed spirit={spiritId}: {ex.Message}");
return (false, $"切换失败:{ex.Message}");
}
return (true, $"已切换到 {spiritId}(服务端直发,不切场景、不播换人演出)");
}
/// <summary>
/// 面板「切人演出」卡片专用:**指定**要播的那一条 SwitchSpiritConfig 行。
///
/// ⛑ 与上面的 <see cref="SwitchSpiritAsync"/> 唯一的区别就是 <c>allowShow:true</c> +
/// <c>forcedSwitchShowId</c> —— 那条是「静默换皮」(面板挑人用),这条是「播演出」
/// (会发 SyncPreSwitchSpirit / SyncSwitchSpiritConfigId,并把玩家落到该行的 Position 锚点)。
///
/// ⛑ 201 行里 AgentId 非空的行服务端这条链路补不出随从(见 ClientConfigRepository 注释),
/// 面板默认不列这类行;真指定了也只是演出里少了那几个陪衬角色,不会崩。
/// </summary>
internal static async Task<(bool Ok, string Message)> SwitchSpiritWithShowAsync(
TcpSession session, uint spiritId, uint switchShowId)
{
var ctx = new RpcContext(
session,
new RpcPacket(RpcPacketKind.Invoke, MethodId.AskSwitchSpirit, 0, Array.Empty<byte>()),
CancellationToken.None);
var row = ClientConfigRepository.SwitchShow(switchShowId);
if (row is null)
return (false, $"找不到切人演出 #{switchShowId}(SwitchSpiritConfig 里没有这一行)");
// ★★ 面板这条路径**不读** switchAnimations.enabled / askOnlyForClientSwitch ——
// 那两个是给客户端 AskSwitchSpirit(Z 轮盘)那套用的;这里是服务端主动指定演出,
// 两套逻辑不混(用户 2026-10-03 明确要求)。OnSwitchSpirit 里也已用
// forcedSwitchShowId != 0 把这两个开关对面板路径放开。
var settings = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
try
{
var played = await new GameRouter().OnSwitchSpirit(ctx, spiritId, allowShow: true, forcedSwitchShowId: switchShowId);
if (!played)
return (false, $"演出没播出去 —— 最常见的是「目标角色 {spiritId} 就是当前角色」," +
$"其次上一次切人还卡在 pending(等 {settings.PreSwitchTimeoutMs}ms 超时会自动结算)。" +
$"换个目标角色再试;服务端日志搜 [SWITCH] / [SWITCH-MIN] 看具体原因。" +
$"(本条演出 #{switchShowId}「{row.Description} · {row.TimelineDescription}」)");
return (true, $"已推出切人演出 #{switchShowId}「{row.Description} · {row.TimelineDescription}」" +
$"(timeline={row.Timeline},锚点 {row.Position.X:0.#},{row.Position.Y:0.#},{row.Position.Z:0.#})" +
$"→ 切成 {spiritId}。等客户端播完回报 ReportPreSwitchSpiritFinish 才真正换人。");
}
catch (Exception ex)
{
session.Log.Warn($"[SWITCH-SHOW] failed show={switchShowId} spirit={spiritId}: {ex.Message}");
return (false, $"播放切人演出失败:{ex.Message}");
}
}
/// <summary>
/// 客户端 GM 控制台的「就地切人」:<c>GmSwitchSpiritHere</c>(69758035),
/// 参数是 <b>uint32 的 FightSpirit id</b>(<c>ClientToGameSceneGMDelegate.lua:2132</c>)。
///
/// 抓包样本(<c>logs/unknown-methods.log</c>):<c>hex=D337E500</c> → 15020499、
/// <c>hex=2C7D6402</c>、<c>hex=00000000</c>。共 3 次 INVOKE,此前一直是
/// <c>unimplemented</c> —— 控制台按下去只回一个空 ack,什么都不会发生。
///
/// ⛑ 这是<b>客户端自己的</b>原生入口(GM 面板 324 个方法之一),不是我们造的:
/// 它证明官方确实有一条「不播演出、就地换人」的切人路径。接上它等于拿到一条
/// 完全原生的切人通道,可以拿来跟 <c>AskSwitchSpirit</c> 那条 14 秒演出路径做对照。
///
/// "Here" = 就地:走与 <c>AskSwitchSpirit</c> 完全相同的身份移交,但
/// <c>allowShow:false</c> ⇒ 不发 <c>SyncPreSwitchSpirit</c> / <c>SyncSwitchSpiritConfigId</c>,
/// 不播 LiftOff 演出、不传送到演出锚点(换皮 + 原地移交)。
/// </summary>
[Handler(MethodId.GmSwitchSpiritHere, HandlerPacketKind.Invoke)]
private async Task GmSwitchSpiritHere(Connection conn, UxRpcMessage msg)
{
var spiritId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
// 0 字节 INVOKE:必须 ack,否则客户端这次调用会 await 到超时(规则 94)。
await conn.ReturnEmptyOkAsync(msg);
if (spiritId == 0)
{
conn.Log.Warn($"[GM-SWITCH] GmSwitchSpiritHere without a spirit id bytes={msg.Body.Length}");
return;
}
var agentId = GameAdminBridge.AgentIdForSpirit(spiritId);
var state = GetWorldState(msg.Context);
conn.Log.Info($"[GM-SWITCH] here spirit={spiritId} agent={agentId} bytes={msg.Body.Length} " +
$"active={state.ActiveSpiritTemplateId} ready={state.Ready}");
await OnSwitchSpirit(msg.Context, spiritId, allowShow: false);
}
/// <summary>
/// Publish the character-wheel roster through SyncTaskRoleTeam (64534724).
///
/// Pressing Z sends nothing to the server — the rotation list is built entirely client-side, and
/// because retail never called this method the client has always used its own fallback: the
/// FightSpiritConfig rows with CanJoin=true (exactly the 15 characters the player sees, including
/// Yinglong but not Mimiko). This notify is the one server-side handle on that list
/// (SpiritManager.lua:233 <- GameToClientImpl.lua:2311 fills taskRoleTeam from enableRoleIds).
///
/// Opt-in only: an empty roster config means nothing is sent and the client keeps its default.
/// Non-empty takes the list over wholesale, so anything to keep has to be listed too.
///
/// Re-published on every scene load because lua does table.clear + refill — it is idempotent by
/// design, and re-sending is how the list survives a scene change.
/// </summary>
async Task PublishSwitchRoleTeamAsync(RpcContext ctx)
{
var configured = PrivateServerConfigStore.Current.Content.Roster.SwitchableRoles;
if (configured is null || configured.Length == 0)
return;
var (ids, unknown) = TaskRoleCatalog4229938.Resolve(configured);
foreach (var dropped in unknown)
ctx.Session.Log.Warn($"[SWITCH-ROLE] dropped unresolved {dropped}");
if (ids.Length == 0)
{
ctx.Session.Log.Warn("[SWITCH-ROLE] no configured id resolved to a TaskRole row; nothing sent");
return;
}
await ctx.NotifyAsync(MethodId.SyncTaskRoleTeam, new GameMethods.SyncTaskRoleTeamNotify
{
RoleTeam = ids,
EnableRoleIds = ids,
TipRoleId = 0,
EnableSwitch = true,
});
ctx.Session.Log.Info($"[SWITCH-ROLE] published {ids.Length} roles: {string.Join(",", ids)}");
}
async Task SwitchSpiritDirectSameScene4229938(
RpcContext ctx, WorldEntryState state, uint templateId, ulong unitId, Vec3 switchPosition, float switchFacing,
uint switchShowId = 0, bool targetInWorld = false)
{
var oldUnitId = state.ActiveSpiritUnitId;
var oldTemplateId = state.ActiveSpiritTemplateId;
// 旧单位留下时的位姿:优先用在 OnSwitchSpirit 冻结的那份(演出 Timeline 移动角色时
// 会覆盖 LastReportedPlayerPosition,见 WorldEntryState.SwitchOldUnitPosition 的注释)。
var oldPosition = state.HasSwitchOldUnitPose ? state.SwitchOldUnitPosition : state.LastReportedPlayerPosition;
var oldFacing = state.HasSwitchOldUnitPose ? state.SwitchOldUnitFacing : state.LastReportedPlayerRotation.Y;
state.PendingSwitchTemplateId = templateId;
state.PendingSwitchUnitId = unitId;
state.PendingSwitchOldUnitId = oldUnitId;
state.PendingSwitchShowId = switchShowId;
state.PendingSwitchPosition = switchPosition;
state.PendingSwitchFacing = switchFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
// ⛑⛑ IsMe 是客户端「建单位那一刻」按「这个 unit 是不是它当前已知的主角 spirit」锁存的,
// 之后改不了(Player.log:登录 = "InitModules 100000000000 IsMe True" + "[LB] 进入场景 创建主角";
// 切人 = "InitModules <new> IsMe False" + "[LB] 创建角色")。落成「角色」而不是「主角」
// ⇒ 客户端按别人/同伴的单位处理 ⇒ 头顶常驻 F,按下去是 AgentInteractPanel(个人信息/动作/离开)。
// 修法与登录流程对齐:先把客户端当前 spirit 指到新 unit,再建单位;建完之后那份
// CurrentSpirit 照旧发,负责真正绑定操控。预生成过的单位要先 Leave,让它以主角身份重建。
// targetInWorld = 目标上次被切换掉时留在了大世界,与预生成同语义:也是服务端驱动单位。
var preSpawnedTarget = state.PendingSwitchAgentPreSpawned || targetInWorld;
// ⛑⛑ 预生成的单位带着「服务端驱动」标记(建它时发过 SyncManagedLogicAgent)。
// 玩家直接接管它 ⇒ 客户端仍按服务端驱动的 actor 处理 ⇒
// **有跑步动画但位置不动**(攻击/手机正常,因为那些不走移动系统)。
// 必须 Leave 掉再重建。2026-09-24 14:2x 实测:把这段关掉后部分角色切过去就走不动。
// 注意这与头顶的 F 无关(F 是 IsMe 锁存,客户端侧摘按钮,见 switch_no_interact.lua)。
var recreate = preSpawnedTarget && !state.PendingSwitchHeroPrebuilt
&& PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreatePreSpawnedTargetOnHandoff;
if (recreate)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(unitId));
var recreateDelay = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreateDelayMs;
if (recreateDelay > 0)
await Task.Delay((int)Math.Min(recreateDelay, 2000));
}
// ⛑⛑ 已证伪,默认关闭:SyncPlayerCurrentSpirit(68830687) 是 LuaOnly,C# 侧不读。
// 详见 PrivateServerConfig.FixIsMeOnHandoff 的注释。留着只为记录,别再打开。
var fixIsMe = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.FixIsMeOnHandoff;
if (fixIsMe)
{
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
}
// Materialize exactly one target character. No authored switch Timeline, weapons, combat graph,
// fashion inventory replay, vehicle cleanup or old-build gameplay hydration is published.
// ⛑ 演出开始前已经以主角身份建好(RecreateHeroBeforeShow)⇒ 这里不再重复下发建单位那一组:
// 演出期间再来一次 projection 等于让客户端二次重建 actor,又是一次「中途动单位」。
if (!state.PendingSwitchHeroPrebuilt)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(templateId, switchPosition, switchFacing, state));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
}
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
// ⛑⛑ 2026-10-03:官方切人序列里的<b>收尾握手三包</b>,主线从来没发过。
// 参考 CBT2 官方测试服务端 `ClientToGameserver.cs:3142-3160` 的第 5 / 5b 步;
// `SyncManagedSpirit` + `SyncManagedAgent` 的原注释是
// "explicitly transfer control (fix: frozen after switch)" ⇒ 正对「演完 T-pose 几秒」;
// `SyncPlayerSwitchSpiritInSitu` 带新角色的 spiritId ⇒ 正对「演出中角色不显示」。
// ⛑ 包体按 <b>4229938</b> 的签名写(2 字段),不能照抄 CBT2 的 3 字段版本。
// 开关 gameplay.switchAnimations.pushHandoffPackets(默认 true,设 false 即旧行为)。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.PushHandoffPackets)
{
// ⛑ RpcContext.NotifyAsync 直接吃 DTO(内部序列化),不是 session.NotifyAsync 的字节版。
await ctx.NotifyAsync(MethodId.SyncPlayerSwitchSpiritInSitu,
WorldCodec.SwitchSpiritInSitu(templateId, unitId));
await ctx.NotifyAsync(MethodId.SyncManagedSpirit,
WorldCodec.ManagedUnit(Profile.PlayerPid, unitId));
await ctx.NotifyAsync(MethodId.SyncManagedAgent,
WorldCodec.ManagedUnit(Profile.PlayerPid, unitId));
ctx.Session.Log.Info($"[SWITCH] handoff pushed template={templateId} unit={unitId} " +
"(InSitu + ManagedSpirit + ManagedAgent)");
}
// CurrentSpirit can rebuild the controlled actor on the client. Re-assert the frozen transform
// after ownership transfer so the new character cannot fall back to its template/default spawn.
// ⛑ `SwitchHandoffPushPose=false` 可关掉这一发:用户 03:3x 怀疑「走不动 = 被切人坐标绑住」,
// 这是验证它的开关(关掉后若角色能走,说明 setPositionType=SwitchSpirit(3) 会把客户端
// 锁在"等待落点完成"的状态机里)。
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.SwitchHandoffPushPose)
{
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
}
state.LastReportedPlayerPosition = switchPosition;
state.LastReportedPlayerRotation = new Vec3(0f, switchFacing, 0f);
state.HasLastReportedPlayerTransform = true;
state.ActiveSpiritTemplateId = templateId;
state.ActiveSpiritUnitId = unitId;
// ★ 登记「这个角色被玩家亲手操控过」—— 他下次被切走时不再去过日子,
// 而是停在玩家最后离开他的地方(WorldEntryState.ControlledSpirits)。
state.MarkSpiritControlled(templateId);
// The client allocates a fresh actor entity for the new character.
state.PendingSwitchControlTransferred = true;
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
// Direct switch still needs a complete actor presentation even though combat gameplay is
// disabled. Bind the stock fashion/wheel/current weapon so the target does not become a
// half-initialized BaseUnit after CurrentSpirit changes ownership.
await PublishMinimalActorPresentation4229938(ctx, unitId, templateId, "direct-switch");
// A character's verbs live in the combat profile, and the login copy stays bound to whoever
// the player entered the world as. Without a re-publish the new character keeps the old
// unique skill: Seymour (15021023) never receives SyncChangeUniqueSkill=51943053, and his
// hack verb is that skill — which is why the hack prompt was missing entirely. Reference
// build logs show the identical profile republished with reason=direct-switch.
await PublishCombatProfileOnSwitch4229938(ctx, unitId, templateId);
if (state.ActiveFeiSuoBuffInstanceId != 0)
{
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0 ? state.ActiveFeiSuoBuffUnitId : oldUnitId;
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(transientUnitId, state.ActiveFeiSuoBuffInstanceId));
state.ActiveFeiSuoBuffInstanceId = 0;
state.ActiveFeiSuoBuffUnitId = 0;
}
await ClearClientWebLifecycleBuffs(ctx, oldUnitId, "minimal-direct-switch");
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
state.AllBuildBuffsPublished = true;
var sw = PrivateServerConfigStore.Current.Gameplay.SwitchAnimations;
// ⛑ 换皮模式下 oldUnitId 恒等于 unitId(都是主角那个 id)⇒ 不能再拿「id 不同」当判据,
// 否则老角色一个都留不下来。
if (oldUnitId != 0 && (oldUnitId != unitId || sw.HeroUnitReuse))
{
// ⛑⛑ 科斯机甲**不走留场**:它是隐藏可控原型,留场通道会拿 AgentConfig 40140068
// 刷一个街头 NPC,而它本尊又没有动画状态 ⇒ 场上只剩一具 T-pose 残骸
//(2026-09-26 05:15 实测:切回后机甲站在原地不动,Pose 卡死)。
// 正确做法是**直接回收本尊**,一条 NPC 都不刷。
// ⛑ 前提是它的 unitId 在 SyncLogicAgentLeave 有效段(规则 165),
// 所以 ClientConfigRepository.Kesi.UnitId 走的是 0x300000000000 段。
if (oldTemplateId == ClientConfigRepository.Kesi.TemplateId)
{
state.LeftBehindCharacters.Remove(oldTemplateId);
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
ctx.Session.Log.Info($"[KESI] reclaim mecha unit={oldUnitId} " +
$"leftBehind={state.LeftBehindCharacters.Count} (no left-behind spawn)");
}
else if (sw.KeepLeftBehindCharacters && oldTemplateId != 0)
{
// 换皮模式下老角色要用**新分配的 unitId**:主角那个 id 必须留着给主角用。
// ⛑⛑⛑ 这一段必须落在 **GameAdminBridge.NextRetirableNpcUid()**(0x300000000000 段),
// 不能再用 SwitchCompanionFloor(100000000100+)—— 那是客户端**预分配的切人身份**,
// `SyncLogicAgentLeave` 对它**静默 no-op**(规则 165)。
// 09-25 21:5x 实机:每次切走里希都新发一个 100000000103 / 100000000105 …,
// 切回时那句 Leave 全部落空 ⇒ **每切一回跑步机上就多一个里希**。
var companionUnitId = sw.HeroUnitReuse
? GameAdminBridge.NextRetirableNpcUid()
: oldUnitId;
// 旧角色留在大世界:记进 LeftBehindCharacters,下次切回落点取这里。
var leftBehindEntry = new WorldEntryState.LeftBehindCharacter
{
UnitId = companionUnitId,
Position = oldPosition,
Facing = oldFacing,
};
// ⛑ 防御:同一个角色如果已经有一具留场身体(异常路径/重复切人请求),
// 覆盖前先把旧的收掉,否则那具就永远没人记得 Leave 了。
if (state.LeftBehindCharacters.TryGetValue(oldTemplateId, out var stale)
&& stale is not null && stale.UnitId != 0 && stale.UnitId != companionUnitId)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(stale.UnitId));
}
state.LeftBehindCharacters[oldTemplateId] = leftBehindEntry;
// ⛑⛑ 不能只发 PositionAndFacing 冻结:客户端在 CurrentSpirit 移交时已经把旧
// hero actor 销毁了,事后再发位置包只会重建一个没有动画状态的裸 actor
//(16:5x 实测「老角色 T-pose 站在新角色身边」)。
// 必须 Leave 掉再按预生成同款四连发重建(ExistingUnitProjection 走的就是
// GM 生成 NPC 那条路,产出的单位带待机动画,不是 T-pose)。
// ⛑⛑⛑ 但 Leave 有代价:22:2x 实测它把客户端的无缝切换状态机打崩
//(197 条 loading_switch_moving_seamless NullReferenceException,之后新接管的
// 角色 24 秒里只发得出 2 条移动包)。所以默认**不发**(LeftBehindRebuildLeave=false)——
// 客户端自己已经销毁了旧 hero actor,这里直接重建即可。
// ⛑⛑⛑ 换皮模式下**一步都不许碰 oldUnitId**:它就是主角那个单位(规则 134)。
// 老角色是以 companionUnitId 这个全新的 id 重新落地的,主角那边什么都不做。
// ★★★ 原生留场(默认开):老角色用它<b>自己的 AgentConfig 行</b>刷成一个真正的 NPC,
// 走的就是满城那 3978 个市民的通道 —— 见 PrivateServerConfig.LeftBehindAsNativeAgent。
var nativeAgentId = sw.LeftBehindAsNativeAgent ? GameAdminBridge.AgentIdForSpirit(oldTemplateId) : 0u;
if (nativeAgentId != 0)
{
DonorNpcCatalog4229938.TryGet(nativeAgentId, out var nativeNpc);
// ★★ 过日子:按「现在几点」从这个角色自己的日程里挑一条活动,把它刷到那条活动
// 编好的世界坐标上,并在出场包里带上 AgentDataSetsActivityCfgId。
// 全链路(三张官方表,零自造)见 PrivateServerConfig.LeftBehindDailyLife。
// ⛑ 查不到(两个主角 tag 2/3 没有时刻表 / 该时段活动没有坐标)就整段回退原逻辑:
// 留在切人时的坐标 + ambient POI,不会把角色刷到 (0,0,0)。
NpcActivityEntry4229938? dailyLife = null;
NpcActivityChoice? dailyChoice = null;
string dailyMode = "off";
// ⛑⛑ 时间基准收在 WorldEntryState.CurrentDaySecond 一处;必须是**本地时间**,
// 跟服务端其它地方同一套(规则 149)。用 UtcNow 会整整差一个时区 ——
// 09-25 实测 13:00 被算成 05:00,Richie 因此命中凌晨那段无坐标活动 ⇒ 原地不动。
var daySecond = state.CurrentDaySecond();
// ★★ 不管这次去不去过日子,都先解一次「当下的活动」,两个用途:
// ① 记进留场记录 —— 后台(LeftBehindFollowSchedule)靠它判断
// 「游戏时间是不是跨进了新的日程时段」。不记的话每个 tick 都误判
// slice changed ⇒ 1.5 秒重摆一次(09-25 跑步机堆一排里希那次的教训)。
// ② 下面要用就直接复用,**别 Resolve 两次**(两处会漂移)。
// 选择策略只有一份(NpcActivityCatalog4229938.Resolve),常驻那份也用它。
var currentChoice = NpcActivityCatalog4229938.Resolve(
oldTemplateId, daySecond, state.ActiveRaidId,
sw.LeftBehindDailyLifeRequirePosition,
oldPosition.X, oldPosition.Y, oldPosition.Z);
// ⛑⛑ 记 **Timed**(时段表当下那一段),不是 Entry(落地那条)——
// Entry 会被 FirstPositioned 兜底抹平成「与时间无关」的同一个 id,
// 拿它当判据时间跳 13 小时都不触发(2026-09-26 06:0x 实机翻车,详见字段注释)。
leftBehindEntry.TimedActivityId = currentChoice?.Timed?.Id ?? 0u;
// ★ 记下「切走他时时间被主动改过几次」,玩家后来推时间 ⇒ 数字对不上 ⇒ 该去过日子了。
leftBehindEntry.TimeEpoch = state.TimeEpoch;
// ★★ 被玩家亲手操控过的角色:切走后**不再**去过日子,
// 停在玩家最后离开他的地方(用户 09-26 03:3x 定)。
// ⛑ 但这只是「这一次不动」—— 等游戏时间跨进下一个日程时段,
// 后台会把他交给常驻化去演新活动(LeftBehindFollowSchedule)。
// 判据见 WorldEntryState.ControlledSpirits;常驻化那条路径不经过这里,不受影响。
if (sw.LeftBehindDailyLife && !state.HasControlledSpirit(oldTemplateId))
{
var choice = currentChoice;
dailyChoice = choice;
dailyLife = choice?.Entry;
dailyMode = choice?.Mode ?? "off";
ctx.Session.Log.Info($"[LEFTBEHIND-DAILY] spirit={oldTemplateId} " +
$"tag={NpcCultivationCatalog4229938.AgentTagForSpirit(oldTemplateId)} " +
$"daySecond={daySecond}({(state.HasExplicitTime ? "explicit" : "local")}) raid={state.ActiveRaidId} " +
$"mode={dailyMode} " +
$"timed={(choice?.Timed is null ? "none" : $"{choice.Timed.Id}/{choice.Timed.Performance}")} " +
$"actRaid={choice?.Timed?.Raid ?? 0} " +
$"chosen={(dailyLife is null ? "none" : $"{dailyLife.Id}/{dailyLife.Performance}")} " +
$"pos={(dailyLife is null ? "-" : $"{dailyLife.X:0.##},{dailyLife.Y:0.##},{dailyLife.Z:0.##}")}");
}
// 命中带坐标的活动 ⇒ 老角色真的「去了那个地方」,站位与留场记录一起改,
// 免得出现「人明明站在跑步机上,切回来却在街头」的错位。
var spawnX = oldPosition.X;
var spawnY = oldPosition.Y;
var spawnZ = oldPosition.Z;
if (dailyLife is { HasPosition: true })
{
spawnX = dailyLife.X;
spawnY = dailyLife.Y;
spawnZ = dailyLife.Z;
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
else if (dailyChoice?.Spot is { } staticSpot)
{
// 没有官方日程的角色(OC 占位行)⇒ 送到他的手工常驻点,别留在切换原地。
spawnX = staticSpot.X;
spawnY = staticSpot.Y;
spawnZ = staticSpot.Z;
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
// ★ 让留场的 NPC 穿上玩家给这个角色搭的那套衣服。
// 走 NPC 出场包自带的 AIAgentInfo.Fashion(单件列表),不是事后补 S2C。
CivilianLinkDress? leftBehindDress = null;
if (sw.LeftBehindDressWithPlayerFashion)
{
var worn = state.FashionBySpirit.GetValueOrDefault(oldTemplateId);
if (worn is { FashionIds.Count: > 0 })
leftBehindDress = new CivilianLinkDress(
Profile.PlayerPid, oldTemplateId, worn.FunctionSuitId,
worn.FashionIds, worn.HiddenParts);
}
// ★★ 出场表现走**同一个裁决点**(GameRouter.NpcLife.ResolveActivityPresentation):
// 常驻摆放和切人留场必须一致,否则「走过去看到的塔菲」和「切走留下的塔菲」
// 会演两套东西。POI 默认沿用切人那条线的 LeftBehindNativePoiActionId。
// ⛑⛑ 他**没有**被送去演那个活动 ⇒ 就别给他那个活动的表演。
// 角色级覆盖(gameplay.rosterNpc.poiOverrides 里「里希=198 跑步机 + 朝向 220.5」)
// 是按「他正站在跑步机上」这条日程配的;用到「站在玩家最后离开他的街角」的
// 留场身体上 ⇒ **原地摆出跑步机姿势**(2026-09-26 06:1x 用户报的「原地跑步」)。
// 真去演活动时(dailyLife 非空)才走裁决点拿活动/角色级表演;
// 否则一律中性闲置(LeftBehindNativePoiActionId)+ 沿用切走时的朝向。
var (leftBehindPoi, leftBehindActivityCfgId, leftBehindFacingOrNull, _) =
dailyLife is null
? (sw.LeftBehindNativePoiActionId, 0u, (float?)null, false)
: ResolveActivityPresentation(oldTemplateId, dailyLife.Id,
sw.LeftBehindNativePoiActionId);
var leftBehindFacing = leftBehindFacingOrNull ?? oldFacing;
// ⛑ POI 轮换的门禁:非 0 = 这个身体进了生活日程 ⇒ ambient 会盖掉 POI
//(硬规则 163)⇒ Movement.cs 那边直接不轮换这个角色。
leftBehindEntry.ActivityCfgId = leftBehindActivityCfgId;
// ★ 活动级坐标微调:跟常驻摆放同一个偏移(否则「走过去看到的她」和
// 「切过去落地的她」会差 0.8 米)。连 leftBehindEntry.Position 一起挪,
// 那个字段就是切回时的落点。
if (dailyLife is not null)
{
(spawnX, spawnY, spawnZ) =
ApplyActivityOffset(dailyLife.Id, spawnX, spawnY, spawnZ);
leftBehindEntry.Position = new Vec3(spawnX, spawnY, spawnZ);
}
if (sw.LeftBehindAgentUnitChannel)
{
// ★ 正解通道:SyncRaidBattleUnitAgent 带 FashionIdList(单件)。
await SendLeftBehindAgentAsync(ctx.Session, nativeAgentId, companionUnitId,
spawnX, spawnY, spawnZ, leftBehindFacing,
leftBehindPoi, leftBehindDress, sw.LeftBehindForceGo,
leftBehindActivityCfgId);
}
else
{
// 旧通道(Aether 静态 NPC):行为没问题,但没法指定单件衣服。
// ⛑ 留场身体不发骇客手机:否则 POI 17(UsageId=0)会被判定"本来不带
// usage" ⇒ 补一部手机 + usage 6 ⇒ 他一边闲逛一边玩手机,看着只有一种动作。
await SendCivilianAsync(ctx.Session,
new CivilianSpawnItem(nativeAgentId, leftBehindPoi),
companionUnitId, spawnX, spawnY, spawnZ, leftBehindFacing,
leftBehindDress, leftBehindActivityCfgId, sw.LeftBehindSkipPhone);
if (sw.LeftBehindForceGo)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
new SceneMethods.SyncAgentForceGo4229938
{
id = companionUnitId,
isForceGo = true,
});
}
}
// ⛑⛑ A/B 用:老办法(建完补发 SyncSetSpiritFashions)已实测无效 ——
// 那个包按 spiritId 匹配,落不到这个 NPC 身上(09-25 用户实测「没有穿上我的搭配」)。
// 保留开关只是留一条回滚/对照的路,默认 false。
if (sw.LeftBehindDressSyncPacket)
{
await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions,
UxSerializer.Serialize(RuntimePayloadFactory.CharacterFashions(oldTemplateId, state)));
}
// ⛑⛑ 回收**旧操控单位**(穿玩家衣服、没有 POI 的那个)。
// 少了这一步场上就会同时站着两个旧角色:一个是没被 Leave 的 oldUnitId,
// 一个是上面刚刷出来的市民 NPC(04:2x 用户实测)。
// ⛑ 与 `LeftBehindRebuildLeave` 是两件事:那个 Leave 的是 companionUnitId。
// 先建新的再回收旧的,避免中间出现"老角色整个消失"的空档。
if (sw.LeftBehindReclaimOldUnit && oldUnitId != 0 && oldUnitId != companionUnitId)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
}
ctx.Session.Log.Info($"[LEFTBEHIND-NATIVE] spirit={oldTemplateId} agent={nativeAgentId} " +
$"name={nativeNpc?.Name ?? "?"} persona={nativeNpc?.AgentPersonaId ?? 0} " +
$"ai={nativeNpc?.AiSettingId ?? 0} unit={companionUnitId} poi={leftBehindPoi} " +
$"activityCfgId={leftBehindActivityCfgId} facing={leftBehindFacing:0.##} " +
$"forceGo={sw.LeftBehindForceGo} " +
$"dressed={(leftBehindDress is null ? "none" : $"{leftBehindDress.FashionIds.Count}pcs/suit={leftBehindDress.SuitId}")} " +
$"dressSyncPacket={sw.LeftBehindDressSyncPacket} " +
$"daily={(dailyLife is null ? $"off({dailyMode})" : $"{dailyLife.Id}/{dailyLife.Performance}@{dailyLife.MapName}({dailyMode})")} " +
// ★ 记在留场记录里的「当下时段活动 id」—— 后台判「时段变没变」就靠它。
// 打 0 = 这个角色当前时段查不到活动(两个主角 tag 2/3 没有时刻表,
// 52 个角色里常态有 36 个 no-activity),那种情况下他本来就没日子可过,
// 不会触发回归日程 —— 这是对的,不是 bug。
$"sliceActivityId={leftBehindEntry.TimedActivityId} " +
$"reclaimedOldUnit={(sw.LeftBehindReclaimOldUnit ? oldUnitId.ToString() : "off")} " +
$"pos=({spawnX:0.##},{spawnY:0.##},{spawnZ:0.##})");
// ★★ 闲逛的后台循环**必须**在原生留场这条路上也起一次。
// 原本只有下面「非原生」那条分支的 else 里调了 EnsureLeftBehindLoop,
// 而现在默认走的就是原生通道(leftBehindAsNativeAgent=true)
// ⇒ 循环压根没起 ⇒ `[LEFTBEHIND-WANDER]` 一条都没有、旧角色只会站着玩手机
//(2026-09-26 21:5x 用户实测)。
// ⛑ 只在闲逛开着时起:闲逛关着时保持原样(绝不额外喂坐标/原地路点),
// 免得把本来站得好好的原生 NPC 搅动起来。
if (sw.LeftBehindWander || sw.LeftBehindPoiRotation)
EnsureLeftBehindLoop(ctx.Session, state);
}
else
{
// ⛑⛑ 不能只发 PositionAndFacing 冻结:客户端在 CurrentSpirit 移交时已经把旧
// hero actor 销毁了,事后再发位置包只会重建一个没有动画状态的裸 actor
//(16:5x 实测「老角色 T-pose 站在新角色身边」)。
// 必须 Leave 掉再按预生成同款四连发重建(ExistingUnitProjection 走的就是
// GM 生成 NPC 那条路,产出的单位带待机动画,不是 T-pose)。
// ⛑⛑⛑ 但 Leave 有代价:22:2x 实测它把客户端的无缝切换状态机打崩
//(197 条 loading_switch_moving_seamless NullReferenceException,之后新接管的
// 角色 24 秒里只发得出 2 条移动包)。所以默认**不发**(LeftBehindRebuildLeave=false)——
// 客户端自己已经销毁了旧 hero actor,这里直接重建即可。
if (sw.LeftBehindRebuildLeave && !sw.HeroUnitReuse)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(companionUnitId));
var leftBehindDelay = sw.RecreateDelayMs;
if (leftBehindDelay > 0)
await Task.Delay((int)Math.Min(leftBehindDelay, 2000));
}
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(companionUnitId));
if (sw.ManagedForLeftBehind)
{
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent,
WorldCodec.ManagedLogicAgent(companionUnitId, Profile.PlayerPid, 0));
}
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.ExistingUnitProjection(companionUnitId, oldTemplateId, oldPosition, oldFacing));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(companionUnitId, oldPosition, oldFacing));
// ⛑⛑⛑ 同伴协议:补齐 Ped 的第 4/5 步,让客户端行为树接管 locomotion。
// 留下的单位是「服务端驱动」的 ⇒ 客户端停用自己的 AI 后只做物理模拟、没有地面吸附
// ⇒ 自由落体掉出世界(18:1x 实测 Y → -3000)。止跌的正解不是「每 N 毫秒把坐标
// 硬拉回来」(21:33 实测:2s 喂一次路点 + 位姿,000/005 照样掉到 -2999.99),
// 而是把行为树启动起来 —— 街上那些不掉的路人多发的正是这两步。
// ⛑ 因此这两步一发,喂坐标 / 原地路点 / 后台续发循环**全部停掉**(见 Movement.cs 的门控)。
if (sw.LeftBehindCompanionProtocol)
{
// ⛑ CPoiActionId 是 POI 动作 id(17 = 站姿闲置),不是动作组号(400)—— 两张表。
if (sw.LeftBehindPoiActionId != 0)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
new GameMethods.DonorClientNpcPoiActionData4229938
{
Id = companionUnitId,
SPoiActionId = 0,
CPoiActionId = sw.LeftBehindPoiActionId,
IsActive = true,
PlayPoiSpeed = 1f,
});
}
if (sw.LeftBehindForceGo)
{
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
new SceneMethods.SyncAgentForceGo4229938
{
id = companionUnitId,
isForceGo = true,
});
}
ctx.Session.Log.Info($"[LEFTBEHIND-COMPANION] ped4/5 unit={companionUnitId} " +
$"oldUnit={oldUnitId} poi={sw.LeftBehindPoiActionId} forceGo={sw.LeftBehindForceGo} " +
$"pos=({oldPosition.X:0.##},{oldPosition.Y:0.##},{oldPosition.Z:0.##}) " +
$"leave={sw.LeftBehindRebuildLeave} managed={sw.ManagedForLeftBehind}");
}
else
{
// ⛑⛑ 留下的单位是「服务端驱动」的 ⇒ 自由落体(见上)。老做法:先钉一发,
// 之后由 OnMovementReport → FeedLeftBehindUnits 接着钉。
if (sw.FeedLeftBehindPositions)
{
leftBehindEntry.LastFeedTicks = Environment.TickCount64;
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentSyncData,
WorldCodec.LogicAgentSyncData(companionUnitId, oldPosition, oldFacing,
sw.LeftBehindFeedMoveId));
}
// ⛑⛑ 老做法的止跌尝试:一条「原地路点任务」让客户端 locomotion 重新接管 actor。
// 之后由 OnMovementReport → FeedLeftBehindUnits 按 LeftBehindWaypointIntervalMs 续发。
if (sw.LeftBehindWaypointTask)
{
leftBehindEntry.LastWaypointTicks = Environment.TickCount64;
await SendLeftBehindWaypointAsync(ctx.Session, companionUnitId, oldPosition, CancellationToken.None);
}
// 后台续发循环:玩家站着不动时客户端不上报,那一刻旧角色正好在掉。
EnsureLeftBehindLoop(ctx.Session, state);
}
}
}
else if (!sw.HeroUnitReuse)
{
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
}
}
// 目标角色现在归玩家操控了,不再算「留在大世界」。
state.LeftBehindCharacters.Remove(templateId);
state.HasSwitchOldUnitPose = false;
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
// 市民流式中心必须跟着切人落点走:不清掉的话人群还按切人前的位置铺,走过去一片空。
// LastReportedPlayerPosition/Rotation 已在上面按同一个 switchPosition 设好。
state.HasCrowdCenter = false;
state.SwitchCount++;
state.LastSwitchShowId = switchShowId;
ClearPendingSwitch(state);
state.PendingSwitchFacing = state.WorldEntryCreateHeroFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
ctx.Session.Log.Info($"[SWITCH-MIN] direct count={state.SwitchCount} oldUnit={oldUnitId} target={templateId}/{unitId} authoredShowId={switchShowId} combatModules=false actorPresentation=true vehicles=false buffs=safe-web-only isMeFix={(fixIsMe ? "reorder" : "off")} recreate={(recreate ? $"+{PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.RecreateDelayMs}ms" : "off")} inWorld={targetInWorld} leftBehind={state.LeftBehindCharacters.Count} pos=({switchPosition.X:0.##},{switchPosition.Y:0.##},{switchPosition.Z:0.##}) facing={switchFacing:0.##}");
}
/// <summary>
/// ★ <c>AskSwitchSpiritComplete</c>(67272187) —— 客户端「整条切人演出播完了」的回执。
///
/// ⛑⛑ 以前它是**空转**的:`GameRouter.UnhandledBatch.cs` 把它挂在 <c>SkillNeutralAccepts</c>
/// 那个「只打一行 [COMBAT] accept 就 ReturnEmptyOkAsync」的兜底处理器上
/// ⇒ 演出到底播没播完、播了多久,服务端<b>完全不知道</b>(09-26 00:2x 评估 CBT3 资料时发现的盲区)。
/// CBT3 那份 `切人传送时机_apex传送实现.md` 用它标定 apexDelay;我们现在没有 apex 传送,
/// 但这条时间戳本身就是「演出真实时长」的唯一实测来源 —— 先记下来,别再丢。
///
/// ⛑ 客户端在切人流程里一共只发两个 C2S:这个 + <c>ReportPreSwitchSpiritFinish</c>(67654364)。
/// <c>ReportPreTeleportFinish</c>(63032762) **不发**(那是传送流程的),别拿它当切人信号。
/// </summary>
[Handler(MethodId.AskSwitchSpiritComplete)]
private async Task AskSwitchSpiritComplete(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
var args = msg.GetArgs<SceneMethods.AskSwitchSpiritComplete>();
var state = GetWorldState(msg.Context);
var elapsedMs = Environment.TickCount64 - state.SwitchStartedTicks;
conn.Log.Info($"[SWITCH] complete spirit={args.switchSpiritId} count={state.SwitchCount} " +
$"showId={state.LastSwitchShowId} elapsed={elapsedMs}ms " +
$"(演出真实时长 —— 标定 preSwitchTimeoutMs / 以后做 apex 传送的 apexDelayMs 都靠它)");
}
}