feat(app+core): Phase 6.6+6.7 — wire UpdateMotion/UpdatePosition into GameWindow

Makes NPCs and other server-spawned entities actually move and
transition animations based on the live server feed. Before this,
Phase 6.6/6.7 only parsed the messages and fired events that nothing
consumed, so NPCs stayed frozen at their CreateObject spawn point
playing one idle cycle forever.

Changes:
 - GameWindow now keeps a parallel _entitiesByServerGuid dictionary
   built at CreateObject hydration time so motion / position updates
   can find the target entity by its server guid.
 - WorldEntity.Position and Rotation become get/set (like MeshRefs did
   in Phase 6.4) so the position-update handler can reseat an existing
   entity in place without reallocating MeshRefs.
 - OnLiveMotionUpdated re-resolves the cycle via MotionResolver using
   the server's new (stance, forward-command) override and either
   swaps the AnimatedEntity's current cycle or removes it from the
   animated set if the new pose is static.
 - OnLivePositionUpdated translates the new landblock-local position
   into acdream world space (same math as CreateObject hydration) and
   writes it back onto the entity.

Subscriptions are added alongside the existing EntitySpawned hook so
the three handlers run synchronously on the UDP pump thread, matching
the existing pattern.

194 tests green (98 Core + 96 Core.Net).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 20:40:17 +02:00
parent 333a7c197a
commit 7375f7ad32
2 changed files with 89 additions and 2 deletions

View file

@ -66,6 +66,14 @@ public sealed class GameWindow : IDisposable
private int _liveCenterX; private int _liveCenterX;
private int _liveCenterY; private int _liveCenterY;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
/// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. The sequential <see cref="_liveEntityIdCounter"/>
/// keys the render list; this parallel dictionary keys by server guid.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
private int _liveSpawnReceived; // diagnostics private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated; private int _liveSpawnHydrated;
private int _liveDropReasonNoPos; private int _liveDropReasonNoPos;
@ -589,6 +597,8 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: connecting to {endpoint} as {user}"); Console.WriteLine($"live: connecting to {endpoint} as {user}");
_liveSession = new AcDream.Core.Net.WorldSession(endpoint); _liveSession = new AcDream.Core.Net.WorldSession(endpoint);
_liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.Connect(user, pass); _liveSession.Connect(user, pass);
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0) if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
@ -932,6 +942,10 @@ public sealed class GameWindow : IDisposable
_entities = extended; _entities = extended;
_liveSpawnHydrated++; _liveSpawnHydrated++;
// Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
_entitiesByServerGuid[spawn.Guid] = entity;
// Phase 6.4: register for per-frame playback if we resolved a real // Phase 6.4: register for per-frame playback if we resolved a real
// cycle with a non-zero framerate and at least two frames in the // cycle with a non-zero framerate and at least two frames in the
// cycle (single-frame poses are static and don't need ticking). // cycle (single-frame poses are static and don't need ticking).
@ -1015,6 +1029,74 @@ public sealed class GameWindow : IDisposable
return hx0 * (1 - ty) + hx1 * ty; return hx0 * (1 - ty) + hx1 * ty;
} }
/// <summary>
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
/// new (stance, forward-command) override, and if the cycle is still
/// animated, swap in the new animation/frame range. Entities not in
/// the animated map (static props, entities rejected at spawn time)
/// are simply ignored — there's nothing to tick for them.
/// </summary>
private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
{
if (_dats is null) return;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
ushort stance = update.MotionState.Stance;
ushort? command = update.MotionState.ForwardCommand;
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: null, // same table; already burned into ae.Animation
stanceOverride: stance,
commandOverride: command);
if (newCycle is null || newCycle.Framerate == 0f
|| newCycle.HighFrame <= newCycle.LowFrame
|| newCycle.Animation.PartFrames.Count <= 1)
{
// New pose is a static one — stop animating and leave the
// entity on its last rendered frame. Removing from the map
// means the tick no longer updates it.
_animatedEntities.Remove(entity.Id);
return;
}
ae.Animation = newCycle.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
ae.Framerate = newCycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
/// <summary>
/// Phase 6.7: the server says an entity moved. Translate its new
/// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform.
/// </summary>
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
var p = update.Position;
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
entity.Position = worldPos;
entity.Rotation = rot;
}
private void OnUpdate(double dt) private void OnUpdate(double dt)
{ {
// Drain any pending live-session traffic. Non-blocking — returns // Drain any pending live-session traffic. Non-blocking — returns

View file

@ -6,8 +6,13 @@ public sealed class WorldEntity
{ {
public required uint Id { get; init; } public required uint Id { get; init; }
public required uint SourceGfxObjOrSetupId { get; init; } public required uint SourceGfxObjOrSetupId { get; init; }
public required Vector3 Position { get; init; } /// <summary>
public required Quaternion Rotation { get; init; } /// World-space position. Settable so Phase 6.7 position-update events
/// can reseat an existing entity without rebuilding its meshes.
/// </summary>
public required Vector3 Position { get; set; }
/// <summary>Settable for the same reason as <see cref="Position"/>.</summary>
public required Quaternion Rotation { get; set; }
/// <summary> /// <summary>
/// Per-part mesh references with their root-relative transforms. /// Per-part mesh references with their root-relative transforms.
/// Mutable so the animation tick can replace it each frame for /// Mutable so the animation tick can replace it each frame for