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:
parent
333a7c197a
commit
7375f7ad32
2 changed files with 89 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue