From 7375f7ad32304160c486029035a6eda7b3ab1711 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 20:40:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(app+core):=20Phase=206.6+6.7=20=E2=80=94?= =?UTF-8?q?=20wire=20UpdateMotion/UpdatePosition=20into=20GameWindow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 82 +++++++++++++++++++++++++ src/AcDream.Core/World/WorldEntity.cs | 9 ++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7886a86..bf46b46 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -66,6 +66,14 @@ public sealed class GameWindow : IDisposable private int _liveCenterX; private int _liveCenterY; private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id + + /// + /// 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 + /// keys the render list; this parallel dictionary keys by server guid. + /// + private readonly Dictionary _entitiesByServerGuid = new(); private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -589,6 +597,8 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"live: connecting to {endpoint} as {user}"); _liveSession = new AcDream.Core.Net.WorldSession(endpoint); _liveSession.EntitySpawned += OnLiveEntitySpawned; + _liveSession.MotionUpdated += OnLiveMotionUpdated; + _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.Connect(user, pass); if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0) @@ -932,6 +942,10 @@ public sealed class GameWindow : IDisposable _entities = extended; _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 // cycle with a non-zero framerate and at least two frames in the // 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; } + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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) { // Drain any pending live-session traffic. Non-blocking — returns diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 31441fd..708d825 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -6,8 +6,13 @@ public sealed class WorldEntity { public required uint Id { get; init; } public required uint SourceGfxObjOrSetupId { get; init; } - public required Vector3 Position { get; init; } - 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. + /// + public required Vector3 Position { get; set; } + /// Settable for the same reason as . + public required Quaternion Rotation { get; set; } /// /// Per-part mesh references with their root-relative transforms. /// Mutable so the animation tick can replace it each frame for