fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn. Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence. Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
This commit is contained in:
parent
4874d8595a
commit
b96b680a20
5 changed files with 235 additions and 21 deletions
|
|
@ -216,6 +216,14 @@ public sealed class GameWindow : IDisposable
|
||||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||||
public System.Numerics.Vector3 LastServerPos;
|
public System.Numerics.Vector3 LastServerPos;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||||
|
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||||
|
/// it for a server-controlled creature, derive it from consecutive
|
||||||
|
/// authoritative positions instead of guessing from player RUM state.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Vector3 ServerVelocity;
|
||||||
|
public bool HasServerVelocity;
|
||||||
|
/// <summary>
|
||||||
/// Legacy field — no longer used for slerp (retail hard-snaps
|
/// Legacy field — no longer used for slerp (retail hard-snaps
|
||||||
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -527,6 +535,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly record struct LiveEntityInfo(
|
private readonly record struct LiveEntityInfo(
|
||||||
string? Name,
|
string? Name,
|
||||||
AcDream.Core.Items.ItemType ItemType);
|
AcDream.Core.Items.ItemType ItemType);
|
||||||
|
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||||
private int _liveSpawnReceived; // diagnostics
|
private int _liveSpawnReceived; // diagnostics
|
||||||
private int _liveSpawnHydrated;
|
private int _liveSpawnHydrated;
|
||||||
private int _liveDropReasonNoPos;
|
private int _liveDropReasonNoPos;
|
||||||
|
|
@ -1303,6 +1312,7 @@ 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.EntityDeleted += OnLiveEntityDeleted;
|
||||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||||
|
|
@ -1654,23 +1664,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// For a respawn, drop the previous rendering state here before we
|
// For a respawn, drop the previous rendering state here before we
|
||||||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||||||
// its value is the live WorldEntity we need to dispose.
|
// its value is the live WorldEntity we need to dispose.
|
||||||
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
|
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||||
{
|
|
||||||
_worldState.RemoveEntityByServerGuid(spawn.Guid);
|
|
||||||
_worldGameState.RemoveById(existingEntity.Id);
|
|
||||||
_animatedEntities.Remove(existingEntity.Id);
|
|
||||||
// Physics collision registry entry is keyed by local id too.
|
|
||||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
|
||||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
|
||||||
// clear using the same guid the new spawn will use. Leaving old
|
|
||||||
// SnapResidual / DeadReckonedPos in would make the next first
|
|
||||||
// UpdatePosition look like a 2m-residual soft-snap.
|
|
||||||
_remoteDeadReckon.Remove(spawn.Guid);
|
|
||||||
_remoteLastMove.Remove(spawn.Guid);
|
|
||||||
_liveEntityInfoByGuid.Remove(spawn.Guid);
|
|
||||||
if (_selectedTargetGuid == spawn.Guid)
|
|
||||||
_selectedTargetGuid = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log every spawn that arrives so we can inventory what the server
|
// Log every spawn that arrives so we can inventory what the server
|
||||||
// sends (including the ones we can't render yet). The Name field
|
// sends (including the ones we can't render yet). The Name field
|
||||||
|
|
@ -2150,6 +2144,41 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||||
|
{
|
||||||
|
if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true)
|
||||||
|
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
|
||||||
|
{
|
||||||
|
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_worldState.RemoveEntityByServerGuid(serverGuid);
|
||||||
|
_worldGameState.RemoveById(existingEntity.Id);
|
||||||
|
_animatedEntities.Remove(existingEntity.Id);
|
||||||
|
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||||
|
|
||||||
|
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||||
|
// clear using the same guid the next spawn/update would use.
|
||||||
|
_remoteDeadReckon.Remove(serverGuid);
|
||||||
|
_remoteLastMove.Remove(serverGuid);
|
||||||
|
_liveEntityInfoByGuid.Remove(serverGuid);
|
||||||
|
_entitiesByServerGuid.Remove(serverGuid);
|
||||||
|
if (_selectedTargetGuid == serverGuid)
|
||||||
|
_selectedTargetGuid = null;
|
||||||
|
|
||||||
|
if (logDelete)
|
||||||
|
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
/// 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
|
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
|
||||||
|
|
@ -2293,6 +2322,32 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
|
||||||
|
bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
|
||||||
|
or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
|
||||||
|
or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
|
||||||
|
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
||||||
|
&& rmCheck.Airborne;
|
||||||
|
|
||||||
|
// Retail MotionTable::GetObjectSequence routes action-class
|
||||||
|
// ForwardCommand values (creature attacks, chat-emotes) through
|
||||||
|
// the Action branch, where the swing is appended before the
|
||||||
|
// current cyclic tail and currState.Substate remains Ready.
|
||||||
|
// Treating 0x10000051/52/53 as SetCycle commands made the
|
||||||
|
// immediate follow-up Ready packet abort the swing.
|
||||||
|
if (forwardIsOverlay)
|
||||||
|
{
|
||||||
|
if (!remoteIsAirborne)
|
||||||
|
{
|
||||||
|
AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
|
||||||
|
ae.Sequencer,
|
||||||
|
fullStyle,
|
||||||
|
fullMotion,
|
||||||
|
speedMod <= 0f ? 1f : speedMod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
// Pick which cycle to play on the sequencer. Priority:
|
// Pick which cycle to play on the sequencer. Priority:
|
||||||
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
||||||
// 2. Else sidestep cmd if active — legs strafe.
|
// 2. Else sidestep cmd if active — legs strafe.
|
||||||
|
|
@ -2340,8 +2395,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// the post-resolve landing path restores the cycle to
|
// the post-resolve landing path restores the cycle to
|
||||||
// whatever the interpreted state says when the body
|
// whatever the interpreted state says when the body
|
||||||
// lands.
|
// lands.
|
||||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
|
||||||
&& rmCheck.Airborne;
|
|
||||||
if (!remoteIsAirborne)
|
if (!remoteIsAirborne)
|
||||||
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
|
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
|
||||||
|
|
||||||
|
|
@ -2419,6 +2472,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||||
|
|
@ -2634,6 +2688,26 @@ public sealed class GameWindow : IDisposable
|
||||||
// slerp doesn't visibly rotate from Identity to truth.
|
// slerp doesn't visibly rotate from Identity to truth.
|
||||||
rmState.Body.Orientation = rot;
|
rmState.Body.Orientation = rot;
|
||||||
}
|
}
|
||||||
|
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
System.Numerics.Vector3? serverVelocity = update.Velocity;
|
||||||
|
if (serverVelocity is null
|
||||||
|
&& !IsPlayerGuid(update.Guid)
|
||||||
|
&& rmState.LastServerPosTime > 0.0)
|
||||||
|
{
|
||||||
|
double elapsed = nowSec - rmState.LastServerPosTime;
|
||||||
|
if (elapsed > 0.001)
|
||||||
|
serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
|
||||||
|
}
|
||||||
|
if (serverVelocity is { } authoritativeVelocity)
|
||||||
|
{
|
||||||
|
rmState.ServerVelocity = authoritativeVelocity;
|
||||||
|
rmState.HasServerVelocity = true;
|
||||||
|
}
|
||||||
|
else if (!IsPlayerGuid(update.Guid))
|
||||||
|
{
|
||||||
|
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||||
|
rmState.HasServerVelocity = false;
|
||||||
|
}
|
||||||
rmState.Body.Position = worldPos;
|
rmState.Body.Position = worldPos;
|
||||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||||
|
|
@ -2673,7 +2747,7 @@ public sealed class GameWindow : IDisposable
|
||||||
rmState.Body.Orientation = rot;
|
rmState.Body.Orientation = rot;
|
||||||
rmState.TargetOrientation = rot;
|
rmState.TargetOrientation = rot;
|
||||||
rmState.LastServerPos = worldPos;
|
rmState.LastServerPos = worldPos;
|
||||||
rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
rmState.LastServerPosTime = nowSec;
|
||||||
// Align the body's physics clock with our clock so update_object
|
// Align the body's physics clock with our clock so update_object
|
||||||
// doesn't sub-step a huge initial gap.
|
// doesn't sub-step a huge initial gap.
|
||||||
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
||||||
|
|
@ -2710,6 +2784,10 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
|
||||||
|
{
|
||||||
|
rmState.Body.Velocity = rmState.ServerVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
entity.Position = rmState.Body.Position;
|
entity.Position = rmState.Body.Position;
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
|
|
@ -4858,7 +4936,10 @@ public sealed class GameWindow : IDisposable
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
|
||||||
|
rm.Body.Velocity = rm.ServerVelocity;
|
||||||
|
else
|
||||||
|
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>ObjectDelete</c> GameMessage (opcode <c>0xF747</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail dispatch path:
|
||||||
|
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 reads guid from
|
||||||
|
/// <c>buf+4</c> and instance sequence from <c>buf+8</c>, then calls
|
||||||
|
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0. ACE emits the same
|
||||||
|
/// layout from <c>GameMessageDeleteObject</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DeleteObject
|
||||||
|
{
|
||||||
|
public const uint Opcode = 0xF747u;
|
||||||
|
|
||||||
|
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
|
||||||
|
/// 4-byte opcode, matching every other parser in this namespace.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
|
{
|
||||||
|
if (body.Length < 10)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
|
||||||
|
if (opcode != Opcode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||||||
|
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
|
||||||
|
return new Parsed(guid, instanceSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,16 @@ public sealed class WorldSession : IDisposable
|
||||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||||
public event Action<EntitySpawn>? EntitySpawned;
|
public event Action<EntitySpawn>? EntitySpawned;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires when the session parses a 0xF747 ObjectDelete game message.
|
||||||
|
/// Retail routes this through
|
||||||
|
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
|
||||||
|
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
|
||||||
|
/// an object leaves the world, including the living creature object
|
||||||
|
/// after its corpse is created.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<DeleteObject.Parsed>? EntityDeleted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||||
/// whose motion changed and its new server-side stance + forward command.
|
/// whose motion changed and its new server-side stance + forward command.
|
||||||
|
|
@ -641,6 +651,12 @@ public sealed class WorldSession : IDisposable
|
||||||
parsed.Value.MotionTableId));
|
parsed.Value.MotionTableId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (op == DeleteObject.Opcode)
|
||||||
|
{
|
||||||
|
var parsed = DeleteObject.TryParse(body);
|
||||||
|
if (parsed is not null)
|
||||||
|
EntityDeleted?.Invoke(parsed.Value);
|
||||||
|
}
|
||||||
else if (op == UpdateMotion.Opcode)
|
else if (op == UpdateMotion.Opcode)
|
||||||
{
|
{
|
||||||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||||||
|
|
|
||||||
39
tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
Normal file
39
tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
public sealed class DeleteObjectTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RejectsWrongOpcode()
|
||||||
|
{
|
||||||
|
Span<byte> body = stackalloc byte[12];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||||||
|
|
||||||
|
Assert.Null(DeleteObject.TryParse(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsTruncated()
|
||||||
|
{
|
||||||
|
Assert.Null(DeleteObject.TryParse(ReadOnlySpan<byte>.Empty));
|
||||||
|
Assert.Null(DeleteObject.TryParse(new byte[9]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesGuidAndInstanceSequence()
|
||||||
|
{
|
||||||
|
Span<byte> body = stackalloc byte[12];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000439u);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
|
||||||
|
|
||||||
|
var parsed = DeleteObject.TryParse(body);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(0x80000439u, parsed!.Value.Guid);
|
||||||
|
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1313,6 +1313,45 @@ public sealed class AnimationSequencerTests
|
||||||
Assert.Equal(99f, fr[0].Origin.X, 1);
|
Assert.Equal(99f, fr[0].Origin.X, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
|
||||||
|
{
|
||||||
|
// ACE broadcasts creature attacks as Action-class ForwardCommand
|
||||||
|
// values followed by Ready. Retail keeps currState.Substate at Ready
|
||||||
|
// while the action link drains, so the Ready echo must not abort the
|
||||||
|
// in-flight swing.
|
||||||
|
const uint Style = 0x003Du;
|
||||||
|
const uint IdleMotion = 0x41000003u;
|
||||||
|
const uint AttackMotion = 0x10000052u;
|
||||||
|
const uint IdleAnimId = 0x03000503u;
|
||||||
|
const uint AttackAnimId = 0x03000504u;
|
||||||
|
|
||||||
|
var setup = Fixtures.MakeSetup(1);
|
||||||
|
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
|
||||||
|
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||||||
|
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||||||
|
|
||||||
|
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||||||
|
var cmdData = new MotionCommandData();
|
||||||
|
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
|
||||||
|
mt.Links[linkOuter] = cmdData;
|
||||||
|
|
||||||
|
var loader = new FakeLoader();
|
||||||
|
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
||||||
|
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
|
||||||
|
|
||||||
|
var seq = new AnimationSequencer(setup, mt, loader);
|
||||||
|
seq.SetCycle(Style, IdleMotion);
|
||||||
|
seq.PlayAction(AttackMotion);
|
||||||
|
|
||||||
|
seq.SetCycle(Style, IdleMotion);
|
||||||
|
|
||||||
|
var fr = seq.Advance(0.01f);
|
||||||
|
Assert.Single(fr);
|
||||||
|
Assert.Equal(12f, fr[0].Origin.X, 1);
|
||||||
|
Assert.Equal(IdleMotion, seq.CurrentMotion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue