1286 lines
83 KiB
C#
1286 lines
83 KiB
C#
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 都靠它)");
|
||
}
|
||
}
|