feat(gameplay): integrate DRMK NPC spawner, dynamic scene presets, pure combat combo router, and clean character switching
This commit is contained in:
1 parent
3fd7922ac8
commit
b5fc15e4ec
20 files changed
+2329029
-53
No files matched your search
@@ -38,29 +38,6 @@ internal sealed partial class GameRouter
|
||||
return;
|
||||
}
|
||||
|
||||
var cooldown = weapon.Cooldown(style, req.SkillId, 0f);
|
||||
var nowTicks = Environment.TickCount64;
|
||||
if (cooldown > 0f)
|
||||
{
|
||||
if (state.SkillCooldownUntilTicks.TryGetValue(req.SkillId, out var untilTicks) && nowTicks < untilTicks)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] reject skill={req.SkillId} cooldown remaining={untilTicks - nowTicks}ms");
|
||||
return;
|
||||
}
|
||||
state.SkillCooldownUntilTicks[req.SkillId] = nowTicks + (long)(cooldown * 1000f);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prevent rapid re-triggering of the same active or unique skill while the animation is executing
|
||||
if (state.ActiveSkillId == req.SkillId &&
|
||||
(req.SkillId == weapon.ActiveSkill(style) || req.SkillId == weapon.UniqueSkill(style)) &&
|
||||
(nowTicks - state.ActiveSkillStartedTicks) < 1200)
|
||||
{
|
||||
ctx.Session.Log.Warn($"[COMBAT] ignore rapid spam of active/unique skill={req.SkillId} elapsed={nowTicks - state.ActiveSkillStartedTicks}ms");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.CombatUseCount++;
|
||||
state.RestoreResourcesAfterActiveSkill |=
|
||||
req.SkillId == weapon.ActiveSkill(style) ||
|
||||
@@ -107,9 +84,21 @@ internal sealed partial class GameRouter
|
||||
state.ActiveClientSkillInstanceId = 0;
|
||||
state.ActiveSkillStartedTicks = 0;
|
||||
|
||||
// Do not immediately force full recharge here, allowing skills to respect their configured cooldowns.
|
||||
// Ordinary attacks must finish without any server-side skill-state rewrite. Re-publishing the
|
||||
// whole charge/resource snapshot after each common attack can make the client save a new action
|
||||
// while the previous trigger graph is still iterating, which restarts the first animation on
|
||||
// every click. Only cooldown/charge abilities and ultimates need sandbox restoration.
|
||||
if (!restoreResources)
|
||||
return;
|
||||
|
||||
// The private server runs an unrestricted combat sandbox: restore charges and ultimate energy
|
||||
// only after the complete authored ability chain has ended, never between combo/hold stages.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
CombatCodec.AllSkillCharges(state.ActiveSpiritUnitId, weapon, style));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResource,
|
||||
CombatCodec.FightResource(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, CombatCodec.UltimateResourceMax));
|
||||
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
|
||||
CombatCodec.FightResourceFreeState(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, true));
|
||||
}
|
||||
|
||||
Task OnSkillHit(RpcContext ctx, SceneMethods.SkillHitData hit)
|
||||
@@ -398,7 +387,7 @@ internal sealed partial class GameRouter
|
||||
ctx.Session.Log.Info($"[ARMORY] slots committed operation={operation} spirit={spiritId} active={isActive} current={currentWeaponId} unique={slots.Where(x => x != 0).Distinct().Count()} slots={slots.Count}");
|
||||
}
|
||||
|
||||
private static bool IsEditableWeaponSlot(int index) => index >= 0 && index < 16;
|
||||
private static bool IsEditableWeaponSlot(int index) => index >= 1 && index < 16;
|
||||
|
||||
private static List<ulong> WeaponSlotIds(WorldEntryState state, uint spiritId)
|
||||
{
|
||||
@@ -584,9 +573,6 @@ internal sealed partial class GameRouter
|
||||
CombatWeaponDefinition weapon,
|
||||
CombatStyleDefinition style)
|
||||
{
|
||||
var state = GetWorldState(ctx);
|
||||
state.CombatProfilePublished = true;
|
||||
|
||||
// Switching/individually styling one weapon must never overwrite the character-wide
|
||||
// FightStyleInfo map. OnSwitchFightStyle publishes that map explicitly.
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
|
||||
@@ -595,7 +581,6 @@ internal sealed partial class GameRouter
|
||||
await PublishSkillBindings(ctx, unitId, weapon, style);
|
||||
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
|
||||
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
|
||||
ctx.Session.Log.Info($"[COMBAT] profile published unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} skills={style.CommonSkill},{style.HeavyCommonSkill},{style.DodgeSkill},{style.ControlSkill},{weapon.ActiveSkill(style)},{weapon.UniqueSkill(style)}");
|
||||
}
|
||||
|
||||
private static CombatStyleDefinition ResolveWeaponStyle(WorldEntryState state, CombatWeaponDefinition weapon)
|
||||
@@ -618,3 +603,4 @@ internal sealed partial class GameRouter
|
||||
return ResolveWeaponStyle(state, weapon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.SDK.Serialization;
|
||||
using Ananta.Server.ClientData.Client4229938;
|
||||
using Ananta.Server.Configuration;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
public sealed record AdminNpcSpawnItem(uint NpcFormworkId, uint PoiActionId);
|
||||
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
private static long s_nextStaticNpcId = 8_300_000_000_000_000_000L;
|
||||
private static long s_nextNpcBelongingId = 8_310_000_000_000_000_000L;
|
||||
|
||||
internal static Task<(bool Ok, string Message, ulong[] Ids)> SpawnStaticNpcAsync(
|
||||
TcpSession session, uint npcFormworkId, uint poiActionId)
|
||||
=> SpawnStaticNpcsAsync(session, [new(npcFormworkId, poiActionId)], 1.5f, 1.5f, 10);
|
||||
|
||||
internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnStaticNpcsAsync(
|
||||
TcpSession session, IReadOnlyList<AdminNpcSpawnItem> items, float xSpacing, float zSpacing, int maxPerRow)
|
||||
{
|
||||
if (items.Count < 1)
|
||||
return (false, "provide at least one NPC item", []);
|
||||
if (!float.IsFinite(xSpacing) || xSpacing < 0f || !float.IsFinite(zSpacing) || zSpacing < 0f)
|
||||
return (false, "NPC X/Z spacing must be finite non-negative numbers", []);
|
||||
if (maxPerRow < 1)
|
||||
return (false, "maximum NPCs per row must be at least 1", []);
|
||||
// Validate the entire batch before sending any NPCs.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is null || item.NpcFormworkId > int.MaxValue || !NpcCatalog4229938.TryGet(item.NpcFormworkId, out _))
|
||||
return (false, $"unknown NPC formwork {item?.NpcFormworkId}; expected an AgentConfig Id", []);
|
||||
if (item.PoiActionId != 0 && !PoiActionCatalog4229938.TryGet(item.PoiActionId, out _))
|
||||
return (false, $"unknown NPC POI action {item.PoiActionId}", []);
|
||||
}
|
||||
|
||||
var state = GetStateIfExists(session);
|
||||
if (state is null)
|
||||
return (false, "no player world state", []);
|
||||
Vec3 near;
|
||||
float facing;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||||
return (false, "wait until the player is ready in the scene with a known position", []);
|
||||
near = state.LastReportedPlayerPosition;
|
||||
facing = state.LastReportedPlayerRotation.Y;
|
||||
}
|
||||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing))
|
||||
return (false, "player transform is not finite", []);
|
||||
|
||||
var ids = new List<ulong>();
|
||||
var yaw = facing * MathF.PI / 180f;
|
||||
var npcFacing = (facing + 180f) % 360f;
|
||||
if (npcFacing > 180f)
|
||||
npcFacing -= 360f;
|
||||
try
|
||||
{
|
||||
// The static-NPC lifecycle creates the logical agent and its pedestrian data.
|
||||
await SendAetherInitForNpcAsync(session);
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
session.Log.Info($"[{i}] Spawn npc formwork: {item.NpcFormworkId}. poi: {item.PoiActionId}");
|
||||
var id = unchecked((ulong)Interlocked.Increment(ref s_nextStaticNpcId));
|
||||
// Begin directly in front of the player, then lay out a
|
||||
// player-relative grid with the requested number of NPCs per row.
|
||||
var forward = 3f + (i / maxPerRow) * zSpacing;
|
||||
var right = (i % maxPerRow) * xSpacing;
|
||||
NpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc);
|
||||
PoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi);
|
||||
var mainActionId = poi?.SelectMainAction(id) ?? 0;
|
||||
var position = new SceneMethods.UxVector3(
|
||||
near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right,
|
||||
near.Y,
|
||||
near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right);
|
||||
var agentSyncClientInfo = CreateStaticNpcSyncInfo(item, npc, poi, mainActionId);
|
||||
// A standalone battle projection omits persona and static pedestrian POI
|
||||
// state. Let AddAetherAIStaticNpc create the complete logical entity;
|
||||
// ForceGo requests the full character without replacing that lifecycle.
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIStaticNpcAddData,
|
||||
UxSerializer.Serialize(new GameMethods.ClientStaticNpcInitData4229938
|
||||
{
|
||||
StaticNpcInfoId = item.NpcFormworkId,
|
||||
NpcFormworkId = item.NpcFormworkId,
|
||||
AgentPersonaId = npc.AgentPersonaId,
|
||||
SPoiActionId = 0,
|
||||
CPoiActionId = item.PoiActionId,
|
||||
UrbanDiversityId = 88888000,
|
||||
IgnoreAllStim = false,
|
||||
TaskRelated = false,
|
||||
NpcPid = (int)item.NpcFormworkId,
|
||||
AgentSyncClientInfo = agentSyncClientInfo,
|
||||
ForceGo = true,
|
||||
SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon (4229938).
|
||||
Id = id,
|
||||
Position = position,
|
||||
Facing = npcFacing,
|
||||
EulerAngles = new SceneMethods.UxVector3(0, npcFacing, 0),
|
||||
}), CancellationToken.None);
|
||||
ids.Add(id);
|
||||
lock (state.SyncRoot)
|
||||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||||
// Static creation registers the logical entity and its sync module,
|
||||
// but does not assign a simulation owner. Without this notification,
|
||||
// LogicAgentModule.ApplyMasterPolicy(false) disables the full-model
|
||||
// state machine after the ECS-to-GO POI handoff.
|
||||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), CancellationToken.None);
|
||||
if (poi is { UsageId: not 0 })
|
||||
{
|
||||
// The usage loader requires owned items before it can bind them.
|
||||
// Inventory is cached on AgentData; usage is retained by the ECS
|
||||
// handle and transferred by ECSToGOBelongingTransition.
|
||||
await session.NotifyAsync(MethodId.SyncAgentBelongings,
|
||||
UxSerializer.Serialize(new GameMethods.SyncNpcBelongings4229938
|
||||
{
|
||||
Id = id,
|
||||
Items = poi.BelongingSceneItems.Select(configId => new GameMethods.NpcBelongingItem4229938
|
||||
{
|
||||
InstanceId = unchecked((ulong)Interlocked.Increment(ref s_nextNpcBelongingId)),
|
||||
ConfigId = configId,
|
||||
}).ToList(),
|
||||
DebugAgentId = item.NpcFormworkId,
|
||||
}), CancellationToken.None);
|
||||
await session.NotifyAsync(MethodId.SyncAgentUseBelongingChanged,
|
||||
UxSerializer.Serialize(new GameMethods.SyncNpcBelongingUsage4229938
|
||||
{
|
||||
Id = id,
|
||||
UsageIds = [poi.UsageId],
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[NPC] POI_BELONGINGS_SENT entity={id} poi={poi.Id} usage={poi.UsageId} items={string.Join(',', poi.BelongingSceneItems)}");
|
||||
}
|
||||
// Store the active POI in the logical POI module at normal playback speed.
|
||||
// Do not periodically replay it: reactions must be able to interrupt it.
|
||||
if (item.PoiActionId != 0)
|
||||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||||
UxSerializer.Serialize(new GameMethods.ClientNpcPoiActionData4229938
|
||||
{
|
||||
Id = id,
|
||||
CPoiActionId = item.PoiActionId,
|
||||
IsActive = true,
|
||||
PlayPoiSpeed = 1f,
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[NPC] STATIC_PEDESTRIAN_SPAWN_SENT formwork={item.NpcFormworkId} model={npc.GeneralModelId} persona={npc.AgentPersonaId} poiAction={item.PoiActionId} action={mainActionId}/{poi?.MainActionGroup ?? 0} behaviorActionConfig={npc.BehaviorActionConfigId} activityConfig={agentSyncClientInfo.AgentDataSetsActivityCfgId} ai={npc.AiSettingId} entity={id} managedPid={Profile.PlayerPid} spoon={agentSyncClientInfo.SpoonAgentId} forceGo=true ignoreStim=false poiSpeed=1 fashion={agentSyncClientInfo.FashionSuitId} pos={position.X},{position.Y},{position.Z} playerPos={near}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"sent {ids.Count}/{items.Count} NPCs before send failed: {ex.Message}", ids.ToArray());
|
||||
}
|
||||
return (true, $"sent spawn for {ids.Count} static NPC(s) in front of you", ids.ToArray());
|
||||
}
|
||||
|
||||
private static async Task SendAetherInitForNpcAsync(TcpSession session)
|
||||
{
|
||||
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
|
||||
var activeRaidId = GetStateIfExists(session)?.ActiveRaidId ?? Profile.RaidId;
|
||||
var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId;
|
||||
await session.NotifyAsync(MethodId.SyncAetherAIInitDatas, UxSerializer.Serialize(new SceneMethods.AetherAIInitData
|
||||
{
|
||||
RaidId = activeRaidId,
|
||||
HasZoneGraph = hasZoneGraph,
|
||||
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
|
||||
Intersections = [],
|
||||
}), CancellationToken.None);
|
||||
session.Log.Info($"[AETHER] init sent raid={activeRaidId} zoneGraph={hasZoneGraph} intersections=empty");
|
||||
}
|
||||
|
||||
internal static GameMethods.NpcAgentSyncClientInfo4229938 CreateStaticNpcSyncInfo(
|
||||
AdminNpcSpawnItem item, NpcCatalogEntry4229938 npc,
|
||||
PoiActionCatalogEntry4229938? poi, uint mainActionId)
|
||||
{
|
||||
return new GameMethods.NpcAgentSyncClientInfo4229938
|
||||
{
|
||||
stimIDList = new List<int>(),
|
||||
CanBeExaminedByPolice = true,
|
||||
indoorList = new(),
|
||||
roomIds = new List<int>(),
|
||||
SpoonAgentId = (int)item.NpcFormworkId,
|
||||
treeName = "PedBase",
|
||||
petPerformData = "",
|
||||
spawnEffectId = new List<uint>(),
|
||||
randomModelCfgId = 0,
|
||||
LeaveDistance = 10000,
|
||||
approachDistance = 10000,
|
||||
FashionSuitId = 11190001,
|
||||
actionId = mainActionId,
|
||||
actionGroupId = poi?.MainActionGroup ?? 0,
|
||||
initPoiActionId = item.PoiActionId,
|
||||
useDefaultPoiOnReturn = item.PoiActionId != 0,
|
||||
returnPoiActionIds = item.PoiActionId == 0 ? [] : [item.PoiActionId],
|
||||
AgentDataSetsActivityCfgId = 0,
|
||||
InteractId = npc.InteractSettingId,
|
||||
AISetting = npc.AiSettingId,
|
||||
beHitType = 1, // Civilian hit reaction without combat damage.
|
||||
agentStimType = 0,
|
||||
forbidStimulateType = 0,
|
||||
isAttackInSafeMode = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Ananta.SDK.Rpc;
|
||||
using Ananta.Server.Protocol.Client4229938;
|
||||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||||
|
||||
namespace Ananta.Server.Handlers.Game;
|
||||
|
||||
internal sealed partial class GameRouter
|
||||
{
|
||||
[Handler(MethodId.AskPreparePlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskPreparePlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskPreparePlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskPreparePlotEventArgs4229938>(out var args)
|
||||
|| args is null || !HasFinitePosition(args.Source.Source)
|
||||
|| !HasFinitePosition(args.Source.Source2))
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskPreparePlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskPreparePlotEvent event={args.EventId} agent={args.AgentEntityId} "
|
||||
+ $"response={args.ResponseId} source=[{FormatPlotTarget(args.Source.Source)}] "
|
||||
+ $"source2=[{FormatPlotTarget(args.Source.Source2)}]");
|
||||
var state = GetStateIfExists(conn.Session);
|
||||
if (state is not null)
|
||||
lock (state.SyncRoot)
|
||||
if (state.StaticNpcPreparedPlotEvents.ContainsKey(args.AgentEntityId))
|
||||
state.StaticNpcPreparedPlotEvents[args.AgentEntityId] = args.EventId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskEndPreparePlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskEndPreparePlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskEndPreparePlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskEndPreparePlotEventArgs4229938>(out var args)
|
||||
|| args is null)
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskEndPreparePlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskEndPreparePlotEvent event={args.EventId} agent={args.AgentEntityId}");
|
||||
TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Handler(MethodId.AskTriggerPlotEvent, HandlerPacketKind.Notify)]
|
||||
private Task AskTriggerPlotEvent(Connection conn, UxRpcMessage msg)
|
||||
{
|
||||
if (msg.Body.Length != SceneMethods.AskTriggerPlotEventArgs4229938.WireSize
|
||||
|| !msg.TryGetArgs<SceneMethods.AskTriggerPlotEventArgs4229938>(out var args)
|
||||
|| args is null || !HasFinitePosition(args.Parameter.Source)
|
||||
|| !HasFinitePosition(args.Parameter.Source2))
|
||||
{
|
||||
conn.Log.Warn($"[PLOT] AskTriggerPlotEvent invalid payload bytes={msg.Body.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
conn.Log.Info($"[PLOT] AskTriggerPlotEvent event={args.EventId} agent={args.AgentEntityId} "
|
||||
+ $"source=[{FormatPlotTarget(args.Parameter.Source)}] source2=[{FormatPlotTarget(args.Parameter.Source2)}] "
|
||||
+ $"clientDmemOverrideCfgId={args.ClientDmemOverrideCfgId}");
|
||||
// The native response has finished and entered its server-reaction phase.
|
||||
// These ambient NPCs have no plot continuation. Explicitly close it so
|
||||
// SelectStimOnline no longer uses the completed event's priority floor.
|
||||
if (TryCompleteStaticNpcPlot(GetStateIfExists(conn.Session), args.AgentEntityId, args.EventId))
|
||||
{
|
||||
conn.Log.Info($"[PLOT] AMBIENT_RESPONSE_FINISHED agent={args.AgentEntityId} event={args.EventId} currentEvent=0");
|
||||
return conn.NotifyAsync(MethodId.SyncPlotCurrentEvent,
|
||||
new SceneMethods.SyncPlotCurrentEvent4229938 { AgentEntityId = args.AgentEntityId });
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal static bool TryCompleteStaticNpcPlot(WorldEntryState? state, ulong agentId, uint eventId)
|
||||
{
|
||||
if (state is null || eventId == 0) return false;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (!state.StaticNpcPreparedPlotEvents.TryGetValue(agentId, out var pending) || pending != eventId)
|
||||
return false;
|
||||
state.StaticNpcPreparedPlotEvents[agentId] = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFinitePosition(SceneMethods.PlotEventTarget4229938 target)
|
||||
=> float.IsFinite(target.Position.X) && float.IsFinite(target.Position.Y) && float.IsFinite(target.Position.Z);
|
||||
|
||||
private static string FormatPlotTarget(SceneMethods.PlotEventTarget4229938 target)
|
||||
=> $"id={target.Id} type={target.Type} pos=({target.Position.X:F2},{target.Position.Y:F2},{target.Position.Z:F2})";
|
||||
}
|
||||
@@ -77,7 +77,7 @@ internal sealed partial class GameRouter
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
|
||||
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
|
||||
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: true));
|
||||
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: false));
|
||||
// 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.
|
||||
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
|
||||
@@ -111,9 +111,8 @@ internal sealed partial class GameRouter
|
||||
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
|
||||
state.AllBuildBuffsPublished = true;
|
||||
|
||||
// Keep the previous character in the world scene so the player can interact with / carry them
|
||||
// if (oldUnitId != 0 && oldUnitId != unitId)
|
||||
// await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
|
||||
if (oldUnitId != 0 && oldUnitId != unitId)
|
||||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
|
||||
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
|
||||
|
||||
state.SwitchCount++;
|
||||
@@ -130,3 +129,4 @@ internal sealed partial class GameRouter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user