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; /// Generic character switching. No protagonist-specific switch path lives here. 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; /// /// 切人演出的两段式:phase 1 由服务端挑 SwitchSpiritConfig 行并推 SyncPreSwitchSpirit, /// 客户端播完 Timeline 后用 ReportPreSwitchSpiritFinish 回报,phase 2 才做身份移交。 /// 本类只做「落点正确」的那一份 —— 见 。 /// private bool SwitchShowEnabled => PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.Enabled; /// /// 面板「切人演出」指定要播的那一条 SwitchSpiritConfig 行;0 = 照老规矩自己挑 /// (距离 / DisperseAnchorsByCharacter / 权重)。 /// /// /// true = 真的把切人演出(SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)推出去了; /// false = 什么演出都没播(世界没就绪 / 上一次还 pending / 角色不在可控花名册 / /// 目标就是当前角色 / 演出开关关着 / 退化成静默换皮 / 锚点超限)。 /// 面板「切人演出」卡靠这个返回值给玩家一句人话,而不是一律回「已播放」。 /// async Task 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(); 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(); 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 ?? ""} " + $"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; // 演出包已推出去 } /// /// Phase 2:客户端播完 pre-switch Timeline。落点早已定好(见 ), /// 这里只把控制权交给已经站在落点上的那个角色,不再把它挪回玩家发起切人时的位置。 /// 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(); 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.##})"); } /// /// 撤掉这出演出 spawn 出来的演员()。 /// /// ⛑ 移除通道与召唤物一致:SyncLogicAgentLeave(68281244) —— 这类单位是 /// SpawnAgentAsync 出来的,用 SyncAetherAINpcRemove(ambient 行人通道)发了等于没发。 /// 幂等:列表清空后重复调用无副作用。 /// 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}"); } /// 演出没等到回执(丢包 / 客户端没播):按已定好的落点直接完成移交。 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 = []; } /// /// 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 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). /// 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()), 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}(服务端直发,不切场景、不播换人演出)"); } /// /// 面板「切人演出」卡片专用:**指定**要播的那一条 SwitchSpiritConfig 行。 /// /// ⛑ 与上面的 唯一的区别就是 allowShow:true + /// forcedSwitchShowId —— 那条是「静默换皮」(面板挑人用),这条是「播演出」 /// (会发 SyncPreSwitchSpirit / SyncSwitchSpiritConfigId,并把玩家落到该行的 Position 锚点)。 /// /// ⛑ 201 行里 AgentId 非空的行服务端这条链路补不出随从(见 ClientConfigRepository 注释), /// 面板默认不列这类行;真指定了也只是演出里少了那几个陪衬角色,不会崩。 /// 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()), 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}"); } } /// /// 客户端 GM 控制台的「就地切人」:GmSwitchSpiritHere(69758035), /// 参数是 uint32 的 FightSpirit id(ClientToGameSceneGMDelegate.lua:2132)。 /// /// 抓包样本(logs/unknown-methods.log):hex=D337E500 → 15020499、 /// hex=2C7D6402、hex=00000000。共 3 次 INVOKE,此前一直是 /// unimplemented —— 控制台按下去只回一个空 ack,什么都不会发生。 /// /// ⛑ 这是客户端自己的原生入口(GM 面板 324 个方法之一),不是我们造的: /// 它证明官方确实有一条「不播演出、就地换人」的切人路径。接上它等于拿到一条 /// 完全原生的切人通道,可以拿来跟 AskSwitchSpirit 那条 14 秒演出路径做对照。 /// /// "Here" = 就地:走与 AskSwitchSpirit 完全相同的身份移交,但 /// allowShow:false ⇒ 不发 SyncPreSwitchSpirit / SyncSwitchSpiritConfigId, /// 不播 LiftOff 演出、不传送到演出锚点(换皮 + 原地移交)。 /// [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); } /// /// 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. /// 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 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:官方切人序列里的收尾握手三包,主线从来没发过。 // 参考 CBT2 官方测试服务端 `ClientToGameserver.cs:3142-3160` 的第 5 / 5b 步; // `SyncManagedSpirit` + `SyncManagedAgent` 的原注释是 // "explicitly transfer control (fix: frozen after switch)" ⇒ 正对「演完 T-pose 几秒」; // `SyncPlayerSwitchSpiritInSitu` 带新角色的 spiritId ⇒ 正对「演出中角色不显示」。 // ⛑ 包体按 4229938 的签名写(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 重新落地的,主角那边什么都不做。 // ★★★ 原生留场(默认开):老角色用它自己的 AgentConfig 行刷成一个真正的 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.##}"); } /// /// ★ AskSwitchSpiritComplete(67272187) —— 客户端「整条切人演出播完了」的回执。 /// /// ⛑⛑ 以前它是**空转**的:`GameRouter.UnhandledBatch.cs` 把它挂在 SkillNeutralAccepts /// 那个「只打一行 [COMBAT] accept 就 ReturnEmptyOkAsync」的兜底处理器上 /// ⇒ 演出到底播没播完、播了多久,服务端完全不知道(09-26 00:2x 评估 CBT3 资料时发现的盲区)。 /// CBT3 那份 `切人传送时机_apex传送实现.md` 用它标定 apexDelay;我们现在没有 apex 传送, /// 但这条时间戳本身就是「演出真实时长」的唯一实测来源 —— 先记下来,别再丢。 /// /// ⛑ 客户端在切人流程里一共只发两个 C2S:这个 + ReportPreSwitchSpiritFinish(67654364)。 /// ReportPreTeleportFinish(63032762) **不发**(那是传送流程的),别拿它当切人信号。 /// [Handler(MethodId.AskSwitchSpiritComplete)] private async Task AskSwitchSpiritComplete(Connection conn, UxRpcMessage msg) { await conn.ReturnEmptyOkAsync(msg); var args = msg.GetArgs(); 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 都靠它)"); } }