Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side behavior for server-controlled creature locomotion and combat engagement. Shipped as MVP after live visual verification across multiple iteration rounds with the user. Highlights: -186a584— initial Phase L.1c port: extracts Origin / target guid / MovementParameters block from MoveTo packets (movementType 6/7), adds RemoteMoveToDriver per-tick body-orientation steering with ±20° aux-turn-equivalent snap tolerance. -d247aef— corrected arrival predicate semantics + 1.5 s stale-destination timeout for entities leaving the streaming view. -f794832— root-caused "creature won't stop to attack" via two research subagents converging on retail CMotionInterp::move_to_interpreted_state's unconditional forward_command bulk-copy. Lifted ServerMoveToActive flag clearing + InterpretedState bulk-copy out of substate-only branch so Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear stale MoveTo state and zero forward velocity. -ff6d3d0— RemoteMoveToDriver.ClampApproachVelocity caps horizontal velocity at the final-approach tick so body lands EXACTLY at DistanceToObject instead of overshooting through the player. -37de771— bulk-copy ForwardCommand for MoveTo packets too (closed the regression where MoveTo creatures stayed at default ForwardCommand=Ready in InterpretedState and only translated via UpdatePosition snaps). -34d7f4d+e71ed73— AnimationSequencer.HasCycle query + fallback chain (requested → WalkForward → Ready → no-op) at BOTH the OnLiveMotionUpdated path AND the spawn handler. Prevents ClearCyclicTail from wiping the body's cyclic tail when ACE CreateObject carries CurrentMotionState.ForwardCommand pointing to an Action-class motion (e.g. AttackHigh1 from a mid-swing creature) which has no cyclic-table entry — was the "torso on the ground" symptom for monsters seen in combat by a fresh observer. Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt (MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80, CMotionInterp::move_to_interpreted_state 0x00528xxx, MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/ ACE.Server/Physics/Animation/MoveToManager.cs (port aid), references/holtburger/ (cross-check on snapshot-only client behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md (the Phase L.1c pseudocode doc). Tests: 1404 → 1422 (parser type-7 path retention, type-6 target guid retention, driver arrival semantics, retail-faithful chase/flee branches, approach-velocity clamp scenarios, HasCycle present/missing, AttackHigh1 wire layout). Pending follow-ups (filed for future): target-guid live resolution for type 6 packets (residual chase lag), StickToObject sticky-target guid trailing field, full MoveToManager state machine port (CheckProgressMade stall detector, Sticky/StickTo, use_final_heading, pending_actions queue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b93dfe95d8
44 changed files with 4580 additions and 301 deletions
|
|
@ -226,6 +226,71 @@ public sealed class GameWindow : IDisposable
|
|||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <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>
|
||||
/// True while a server MoveToObject/MoveToPosition packet is the
|
||||
/// active locomotion source. Retail runs these through MoveToManager
|
||||
/// and CMotionInterp; the per-tick remote driver consults this to
|
||||
/// decide whether to feed body steering through
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||||
/// the InterpretedMotionState path.
|
||||
/// </summary>
|
||||
public bool ServerMoveToActive;
|
||||
|
||||
/// <summary>
|
||||
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||||
/// has been parsed and the world-converted destination is stored on
|
||||
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||||
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||||
/// Phase L.1c (2026-04-28).
|
||||
/// </summary>
|
||||
public bool HasMoveToDestination;
|
||||
|
||||
/// <summary>
|
||||
/// World-space destination from the most recent MoveTo packet's
|
||||
/// <c>Origin</c> field, converted via the same landblock-grid
|
||||
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||||
|
||||
/// <summary>
|
||||
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||||
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||||
/// the chase-arrival threshold per retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||||
/// </summary>
|
||||
public float MoveToMinDistance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||||
/// the flee branch (<c>move_away</c>); chase uses
|
||||
/// <see cref="MoveToMinDistance"/>.
|
||||
/// </summary>
|
||||
public float MoveToDistanceToObject;
|
||||
|
||||
/// <summary>
|
||||
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||||
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||||
/// chase. False = flee (<c>move_away</c>) or static target.
|
||||
/// </summary>
|
||||
public bool MoveToMoveTowards;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
|
||||
/// for this entity. Used by the per-tick driver to give up
|
||||
/// steering when no refresh has arrived for
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||||
/// — typically because the entity left our streaming view and
|
||||
/// the server stopped broadcasting its MoveTo updates.
|
||||
/// </summary>
|
||||
public double LastMoveToPacketTime;
|
||||
/// <summary>
|
||||
/// Legacy field — no longer used for slerp (retail hard-snaps
|
||||
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
||||
/// </summary>
|
||||
|
|
@ -532,6 +597,13 @@ public sealed class GameWindow : IDisposable
|
|||
/// keys the render list; this parallel dictionary keys by server guid.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||||
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
|
||||
private uint? _selectedTargetGuid;
|
||||
private readonly record struct LiveEntityInfo(
|
||||
string? Name,
|
||||
AcDream.Core.Items.ItemType ItemType);
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||||
private int _liveSpawnReceived; // diagnostics
|
||||
private int _liveSpawnHydrated;
|
||||
private int _liveDropReasonNoPos;
|
||||
|
|
@ -1315,6 +1387,7 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($"live: connecting to {endpoint} as {user}");
|
||||
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||
|
|
@ -1666,20 +1739,7 @@ public sealed class GameWindow : IDisposable
|
|||
// For a respawn, drop the previous rendering state here before we
|
||||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||||
// its value is the live WorldEntity we need to dispose.
|
||||
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
|
||||
{
|
||||
_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);
|
||||
}
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||
|
||||
// Log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
|
|
@ -1691,12 +1751,19 @@ public sealed class GameWindow : IDisposable
|
|||
: "no-pos";
|
||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||||
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||
$"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
|
||||
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
|
||||
spawn.Name,
|
||||
spawn.ItemType is { } rawItemType
|
||||
? (AcDream.Core.Items.ItemType)rawItemType
|
||||
: AcDream.Core.Items.ItemType.None);
|
||||
|
||||
// Target the statue specifically for full diagnostic dump: Name match
|
||||
// is cheap and gives us exactly one entity's worth of log regardless
|
||||
|
|
@ -2045,9 +2112,64 @@ public sealed class GameWindow : IDisposable
|
|||
if (mtable is not null)
|
||||
{
|
||||
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
||||
uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
|
||||
uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u;
|
||||
sequencer.SetCycle(seqStyle, seqMotion);
|
||||
uint seqStyle = stanceOverride is > 0
|
||||
? (0x80000000u | (uint)stanceOverride.Value)
|
||||
: (uint)mtable.DefaultStyle;
|
||||
uint seqMotion;
|
||||
if (commandOverride is > 0)
|
||||
{
|
||||
uint resolved = AcDream.Core.Physics.MotionCommandResolver
|
||||
.ReconstructFullCommand(commandOverride.Value);
|
||||
seqMotion = resolved != 0
|
||||
? resolved
|
||||
: (0x40000000u | (uint)commandOverride.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
|
||||
}
|
||||
|
||||
// Phase L.1c followup (2026-04-28): apply the same
|
||||
// missing-cycle fallback the OnLiveMotionUpdated path
|
||||
// uses. Without this, a monster spawned in combat
|
||||
// stance with the wire's seqMotion absent from its
|
||||
// MotionTable hits ClearCyclicTail() with no
|
||||
// replacement enqueue, every body part snaps to its
|
||||
// setup-default offset, and the visual collapses to
|
||||
// "torso on the ground" — visible to acdream
|
||||
// observers when another client is in combat with a
|
||||
// monster, until the first OnLiveMotionUpdated UM
|
||||
// applies the same fallback there.
|
||||
uint spawnCycle = seqMotion;
|
||||
if (!sequencer.HasCycle(seqStyle, spawnCycle))
|
||||
{
|
||||
uint origCycle = spawnCycle;
|
||||
// RunForward → WalkForward → Ready
|
||||
if ((spawnCycle & 0xFFu) == 0x07
|
||||
&& sequencer.HasCycle(seqStyle, 0x45000005u))
|
||||
{
|
||||
spawnCycle = 0x45000005u;
|
||||
}
|
||||
else if (sequencer.HasCycle(seqStyle, 0x41000003u))
|
||||
{
|
||||
spawnCycle = 0x41000003u;
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnCycle = 0;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " +
|
||||
$"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " +
|
||||
$"→ fallback=0x{spawnCycle:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
if (spawnCycle != 0)
|
||||
sequencer.SetCycle(seqStyle, spawnCycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2152,6 +2274,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>
|
||||
/// 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
|
||||
|
|
@ -2186,11 +2343,13 @@ public sealed class GameWindow : IDisposable
|
|||
&& update.Guid != _playerServerGuid)
|
||||
{
|
||||
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||||
float spd = update.MotionState.ForwardSpeed ?? 0f;
|
||||
float spd = update.MotionState.ForwardSpeed
|
||||
?? ((update.MotionState.MoveToSpeed ?? 0f)
|
||||
* (update.MotionState.MoveToRunRate ?? 0f));
|
||||
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
|
||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||
Console.WriteLine(
|
||||
$"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||||
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||||
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
|
||||
}
|
||||
|
||||
|
|
@ -2231,10 +2390,27 @@ public sealed class GameWindow : IDisposable
|
|||
// command == null → retail stop signal → Ready
|
||||
// command.Value == 0 → explicit 0 (rare) → Ready
|
||||
// otherwise → resolve class byte and use full cmd
|
||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||
uint fullMotion;
|
||||
if (!command.HasValue || command.Value == 0)
|
||||
if ((!command.HasValue || command.Value == 0)
|
||||
&& update.MotionState.IsServerControlledMoveTo)
|
||||
{
|
||||
// Retail MoveToManager::BeginMoveForward calls
|
||||
// MovementParameters::get_command (0x0052AA00), then
|
||||
// _DoMotion -> adjust_motion. With CanRun and enough
|
||||
// distance, WalkForward + HoldKey_Run becomes RunForward,
|
||||
// and CMotionInterp::apply_run_to_command (0x00527BE0)
|
||||
// multiplies speed by the packet's runRate.
|
||||
var seed = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanMoveToStart(
|
||||
update.MotionState.MoveToSpeed ?? 1f,
|
||||
update.MotionState.MoveToRunRate ?? 1f,
|
||||
update.MotionState.MoveToCanRun);
|
||||
fullMotion = seed.Motion;
|
||||
speedMod = seed.SpeedMod;
|
||||
}
|
||||
else if (!command.HasValue || command.Value == 0)
|
||||
{
|
||||
// Stop — return to the style's default substate (Ready).
|
||||
fullMotion = 0x41000003u;
|
||||
}
|
||||
else
|
||||
|
|
@ -2262,8 +2438,6 @@ public sealed class GameWindow : IDisposable
|
|||
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
||||
// produces "slow walk that never stops" — exactly what the
|
||||
// stop bug looked like.
|
||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
&& update.Guid != _playerServerGuid)
|
||||
Console.WriteLine(
|
||||
|
|
@ -2295,6 +2469,125 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
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.
|
||||
// Phase L.1c followup (2026-04-28): the next two state-update
|
||||
// blocks are LIFTED out of the substate-only `else` branch so
|
||||
// they run for BOTH overlay (Action/Modifier/ChatEmote) and
|
||||
// substate (Walk/Run/Ready/etc) packets. Two separate research
|
||||
// agents converged on the same root cause for the user-
|
||||
// observed "creature just runs instead of attacking" symptom:
|
||||
//
|
||||
// 1. Attack swings arrive as mt=0 with
|
||||
// ForwardCommand=AttackHigh1 (Action class). Retail's
|
||||
// CMotionInterp::move_to_interpreted_state
|
||||
// (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
|
||||
// forward_command from the wire into the body's
|
||||
// InterpretedState UNCONDITIONALLY. With
|
||||
// forward_command=AttackHigh1, get_state_velocity
|
||||
// returns 0 because its gate is RunForward||WalkForward
|
||||
// — body stops moving forward.
|
||||
//
|
||||
// 2. The acdream overlay branch was routing through
|
||||
// PlayAction (animation overlay) but skipping ALL of:
|
||||
// - ServerMoveToActive flag update
|
||||
// - MoveToPath capture
|
||||
// - InterpretedState.ForwardCommand assignment
|
||||
// So during a swing UM, the body's InterpretedState
|
||||
// stayed at RunForward from the prior MoveTo packet,
|
||||
// ServerMoveToActive stayed true, and the per-tick
|
||||
// remote driver kept steering + applying RunForward
|
||||
// velocity through every frame.
|
||||
//
|
||||
// Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
|
||||
// here because the latter is a heuristic that ONLY handles
|
||||
// WalkForward / RunForward / WalkBackward / SideStep / Turn
|
||||
// / Ready (MotionInterpreter.cs:941-970). For an Action
|
||||
// command (e.g. AttackHigh1 = 0x10000062) the switch falls
|
||||
// through and InterpretedState is silently NOT updated —
|
||||
// exactly the bug we are fixing. Direct field assignment
|
||||
// matches retail's <c>copy_movement_from</c> bulk-copy
|
||||
// (acclient_2013_pseudo_c.txt:293301-293311).
|
||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||
{
|
||||
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
|
||||
|
||||
// Bulk-copy the wire's resolved ForwardCommand + speed
|
||||
// into InterpretedState UNCONDITIONALLY (overlay,
|
||||
// substate, AND MoveTo packets). Matches retail's
|
||||
// copy_movement_from semantics
|
||||
// (acclient_2013_pseudo_c.txt:293301-293311) which does
|
||||
// not filter by MovementType.
|
||||
//
|
||||
// For MoveTo packets, fullMotion is the RunForward seed
|
||||
// from PlanMoveToStart, so this populates
|
||||
// ForwardCommand=RunForward + ForwardSpeed=speed*runRate
|
||||
// — what the OLD substate-only DoInterpretedMotion call
|
||||
// (commit f794832 removed) used to set. Without it,
|
||||
// apply_current_movement reads the default
|
||||
// ForwardCommand=Ready and produces zero velocity, so
|
||||
// chasing creatures only translate via UpdatePosition
|
||||
// hard-snaps and at spawn appear posed at default
|
||||
// (visible as "torso on the ground" until the first UP
|
||||
// snap hits).
|
||||
//
|
||||
// For overlay (Action) packets this sets ForwardCommand
|
||||
// to the Attack/Twitch/etc command, and
|
||||
// get_state_velocity returns 0 because the gate is
|
||||
// RunForward||WalkForward — body stops moving forward.
|
||||
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
|
||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
|
||||
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } path)
|
||||
{
|
||||
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.OriginToWorld(
|
||||
path.OriginCellId,
|
||||
path.OriginX,
|
||||
path.OriginY,
|
||||
path.OriginZ,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
remoteMot.MoveToMinDistance = path.MinDistance;
|
||||
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
|
||||
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
|
||||
remoteMot.HasMoveToDestination = true;
|
||||
remoteMot.LastMoveToPacketTime =
|
||||
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
}
|
||||
else if (!update.MotionState.IsServerControlledMoveTo)
|
||||
{
|
||||
// Off MoveTo — clear stale destination so the per-tick
|
||||
// driver doesn't keep steering.
|
||||
remoteMot.HasMoveToDestination = false;
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
||||
// 2. Else sidestep cmd if active — legs strafe.
|
||||
|
|
@ -2342,10 +2635,63 @@ public sealed class GameWindow : IDisposable
|
|||
// the post-resolve landing path restores the cycle to
|
||||
// whatever the interpreted state says when the body
|
||||
// lands.
|
||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
||||
&& rmCheck.Airborne;
|
||||
if (!remoteIsAirborne)
|
||||
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
|
||||
{
|
||||
// Fallback chain for missing cycles in the MotionTable.
|
||||
// SetCycle unconditionally calls ClearCyclicTail() before
|
||||
// looking up the cycle; if the cycle is absent, the body
|
||||
// ends up with no cyclic tail at all and every part snaps
|
||||
// to its setup-default offset — visible as "torso on the
|
||||
// ground" because most creatures' setup-default puts all
|
||||
// limbs at the torso origin.
|
||||
//
|
||||
// This is specifically a regression from commit 186a584
|
||||
// (Phase L.1c port): pre-fix, MoveTo packets fell through
|
||||
// to fullMotion=Ready (which always exists in every
|
||||
// MotionTable). Post-fix, MoveTo packets seed
|
||||
// fullMotion=RunForward, but some creatures (especially
|
||||
// when stance=HandCombat) lack a (combat, RunForward)
|
||||
// cycle. Fall through RunForward → WalkForward → Ready
|
||||
// until we find one the table actually contains.
|
||||
//
|
||||
// Note: this fallback is for the SEQUENCER (visible
|
||||
// animation) only. InterpretedState.ForwardCommand still
|
||||
// gets the wire's (or seeded) ForwardCommand verbatim
|
||||
// so apply_current_movement produces correct velocity.
|
||||
uint cycleToPlay = animCycle;
|
||||
if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
|
||||
{
|
||||
uint requested = cycleToPlay;
|
||||
// RunForward (0x44000007) → WalkForward (0x45000005)
|
||||
if ((cycleToPlay & 0xFFu) == 0x07
|
||||
&& ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
|
||||
{
|
||||
cycleToPlay = 0x45000005u;
|
||||
}
|
||||
// WalkForward → Ready (0x41000003)
|
||||
else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
|
||||
{
|
||||
cycleToPlay = 0x41000003u;
|
||||
}
|
||||
// Ready missing too — leave the existing cycle alone
|
||||
// by not calling SetCycle at all (avoids the
|
||||
// ClearCyclicTail wipe).
|
||||
else
|
||||
{
|
||||
cycleToPlay = 0;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"UM cycle missing for guid=0x{update.Guid:X8} " +
|
||||
$"style=0x{fullStyle:X8} requested=0x{requested:X8} " +
|
||||
$"→ fallback=0x{cycleToPlay:X8}");
|
||||
}
|
||||
}
|
||||
if (cycleToPlay != 0)
|
||||
ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
|
||||
}
|
||||
|
||||
// Retail runs the full MotionInterp state machine on every
|
||||
// remote. Route each wire command (forward, sidestep, turn)
|
||||
|
|
@ -2359,12 +2705,17 @@ public sealed class GameWindow : IDisposable
|
|||
// FUN_00528f70 DoInterpretedMotion
|
||||
// FUN_00528960 get_state_velocity
|
||||
// FUN_00529210 apply_current_movement
|
||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||
// ServerMoveToActive flag, MoveToPath capture, and the
|
||||
// InterpretedState.ForwardCommand bulk-copy are already
|
||||
// handled by the LIFTED block above (so overlay-class swings
|
||||
// also clear stale MoveTo state and update the body's
|
||||
// forward command). This branch only handles sidestep /
|
||||
// turn axes plus the ObservedOmega seed — none of which
|
||||
// appear on overlay packets, so the existing logic is
|
||||
// correct unchanged. (`remoteMot` is the same dictionary
|
||||
// entry obtained at the top of the lifted block.)
|
||||
if (remoteMot is not null)
|
||||
{
|
||||
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
|
||||
remoteMot.Motion.DoInterpretedMotion(
|
||||
fullMotion, speedMod, modifyInterpretedState: true);
|
||||
|
||||
// Sidestep axis.
|
||||
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
||||
{
|
||||
|
|
@ -2421,6 +2772,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||
|
|
@ -2452,57 +2804,20 @@ public sealed class GameWindow : IDisposable
|
|||
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
}
|
||||
|
||||
// Route the Commands list — one-shot Actions, Modifiers, and
|
||||
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
|
||||
// live in the motion table's Links / Modifiers dicts, not
|
||||
// Cycles, and are played on top of the current cycle via
|
||||
// PlayAction which resolves the right dict and interleaves the
|
||||
// action frames before the cyclic tail.
|
||||
//
|
||||
// A typical NPC wave looks like:
|
||||
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
|
||||
// [{0x0003=Ready, ...}]
|
||||
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
|
||||
// standard SetCycle path (for 0x40 SubState). We leave SubState
|
||||
// commands to fall through to the next UpdateMotion; that's how
|
||||
// retail handles transition sequences (Wave → Ready).
|
||||
// Route command-list entries through the shared Core router.
|
||||
// Retail/ACE send these as 16-bit MotionCommand lows in
|
||||
// InterpretedMotionState.Commands[]; the router reconstructs the
|
||||
// class byte and chooses PlayAction for actions/modifiers/emotes
|
||||
// or SetCycle for persistent substates.
|
||||
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
||||
{
|
||||
foreach (var item in cmds)
|
||||
{
|
||||
// Restore the 32-bit MotionCommand from the wire's 16-bit
|
||||
// truncation by OR-ing class bits. The class is encoded
|
||||
// in the low byte's high nibble via command ranges:
|
||||
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
|
||||
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
|
||||
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
|
||||
//
|
||||
// The retail MotionCommand enum carries the class byte in
|
||||
// bits 24-31. DatReaderWriter's enum values match. For
|
||||
// broadcasts, servers emit only low 16 bits (ACE
|
||||
// InterpretedMotionState.cs:139). We reconstruct via a
|
||||
// range-based lookup. See MotionCommand.generated.cs.
|
||||
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
|
||||
if (fullCmd == 0) continue;
|
||||
|
||||
// Action class: play through the link dict then drop back
|
||||
// to the current cycle. Modifier class: resolve from the
|
||||
// Modifiers dict and combine on top. SubState: cycle
|
||||
// change; route through SetCycle so the style-specific
|
||||
// cycle fallback applies.
|
||||
uint cls = fullCmd & 0xFF000000u;
|
||||
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|
||||
|| cls == 0x12000000u || cls == 0x13000000u)
|
||||
{
|
||||
ae.Sequencer.PlayAction(fullCmd, item.Speed);
|
||||
}
|
||||
else if ((cls & 0x40000000u) != 0)
|
||||
{
|
||||
// Substate in the command list — typically the "and
|
||||
// then return to Ready" item. Update the cycle.
|
||||
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
|
||||
}
|
||||
// else: Style / UI / Toggle class — not animation-driving.
|
||||
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
||||
ae.Sequencer,
|
||||
fullStyle,
|
||||
item.Command,
|
||||
item.Speed);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -2604,6 +2919,39 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsRemoteLocomotion(uint motion)
|
||||
{
|
||||
uint low = motion & 0xFFu;
|
||||
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
|
||||
}
|
||||
|
||||
private void ApplyServerControlledVelocityCycle(
|
||||
uint serverGuid,
|
||||
AnimatedEntity ae,
|
||||
RemoteMotion rm,
|
||||
System.Numerics.Vector3 velocity)
|
||||
{
|
||||
if (IsPlayerGuid(serverGuid)) return;
|
||||
if (rm.Airborne) return;
|
||||
if (ae.Sequencer is null) return;
|
||||
// MoveTo packets already seeded the retail speed/runRate cycle.
|
||||
// Keep UpdatePosition-derived velocity for render position only;
|
||||
// using it to choose the cycle reverts fast chases back to slow
|
||||
// velocity-estimated animation.
|
||||
if (rm.ServerMoveToActive) return;
|
||||
|
||||
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanFromVelocity(velocity);
|
||||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||
if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion))
|
||||
return;
|
||||
|
||||
uint style = ae.Sequencer.CurrentStyle != 0
|
||||
? ae.Sequencer.CurrentStyle
|
||||
: 0x8000003Du;
|
||||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||
}
|
||||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
// Phase A.1: track the most recently updated entity's landblock so the
|
||||
|
|
@ -2673,6 +3021,26 @@ public sealed class GameWindow : IDisposable
|
|||
// slerp doesn't visibly rotate from Identity to truth.
|
||||
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;
|
||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||
|
|
@ -2712,7 +3080,7 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.Body.Orientation = rot;
|
||||
rmState.TargetOrientation = rot;
|
||||
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
|
||||
// doesn't sub-step a huge initial gap.
|
||||
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
||||
|
|
@ -2737,6 +3105,7 @@ public sealed class GameWindow : IDisposable
|
|||
// carries no stop information for our ACE.
|
||||
if (svel.LengthSquared() < 0.04f)
|
||||
{
|
||||
rmState.ServerMoveToActive = false;
|
||||
rmState.Motion.StopCompletely();
|
||||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||||
&& aeForStop.Sequencer is not null)
|
||||
|
|
@ -2749,6 +3118,21 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
|
||||
{
|
||||
rmState.Body.Velocity = rmState.ServerVelocity;
|
||||
}
|
||||
|
||||
if (!IsPlayerGuid(update.Guid)
|
||||
&& rmState.HasServerVelocity
|
||||
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
|
||||
{
|
||||
ApplyServerControlledVelocityCycle(
|
||||
update.Guid,
|
||||
aeForVelocity,
|
||||
rmState,
|
||||
rmState.ServerVelocity);
|
||||
}
|
||||
|
||||
entity.Position = rmState.Body.Position;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
|
|
@ -5009,7 +5393,114 @@ public sealed class GameWindow : IDisposable
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
|
||||
{
|
||||
double velocityAge = nowSec - rm.LastServerPosTime;
|
||||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
ApplyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.Velocity = rm.ServerVelocity;
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive
|
||||
&& rm.HasMoveToDestination)
|
||||
{
|
||||
// Phase L.1c port of retail MoveToManager per-tick
|
||||
// steering (HandleMoveToPosition @ 0x00529d80).
|
||||
// Steer body orientation toward the latest
|
||||
// server-supplied destination, then let
|
||||
// apply_current_movement set Velocity from the
|
||||
// RunForward cycle through the now-correct heading.
|
||||
|
||||
// Stale-destination guard (2026-04-28): if no
|
||||
// MoveTo packet has refreshed the destination
|
||||
// recently, the entity has likely left our
|
||||
// streaming view or the server cancelled the
|
||||
// move without us seeing the cancel UM. Continuing
|
||||
// to steer toward a stale point produces the
|
||||
// "monster runs in place after popping back into
|
||||
// view" symptom. Clear and stand down.
|
||||
double moveToAge = nowSec - rm.LastMoveToPacketTime;
|
||||
if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
|
||||
{
|
||||
rm.HasMoveToDestination = false;
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.Drive(
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.MoveToDestinationWorld,
|
||||
rm.MoveToMinDistance,
|
||||
rm.MoveToDistanceToObject,
|
||||
(float)dt,
|
||||
rm.MoveToMoveTowards,
|
||||
out var steeredOrientation);
|
||||
rm.Body.Orientation = steeredOrientation;
|
||||
|
||||
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.DriveResult.Arrived)
|
||||
{
|
||||
// Within arrival window — zero velocity until the
|
||||
// next MoveTo packet refreshes the destination
|
||||
// (or the server explicitly stops us with an
|
||||
// interpreted-motion UM cmd=Ready).
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Steering active — apply_current_movement reads
|
||||
// InterpretedState.ForwardCommand=RunForward (set
|
||||
// when the MoveTo packet arrived) and emits
|
||||
// velocity along +Y in body local space. Our
|
||||
// updated orientation rotates that into the right
|
||||
// world direction toward the target.
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
// Clamp horizontal velocity so we don't overshoot
|
||||
// the arrival threshold during the final tick of
|
||||
// approach. Without this, a 4 m/s body advances
|
||||
// ~6 cm/tick and visibly runs slightly through
|
||||
// the target before the swing UM lands.
|
||||
float arrivalThreshold = rm.MoveToMoveTowards
|
||||
? rm.MoveToDistanceToObject
|
||||
: rm.MoveToMinDistance;
|
||||
rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.ClampApproachVelocity(
|
||||
rm.Body.Position,
|
||||
rm.Body.Velocity,
|
||||
rm.MoveToDestinationWorld,
|
||||
arrivalThreshold,
|
||||
(float)dt,
|
||||
rm.MoveToMoveTowards);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
|
||||
{
|
||||
// MoveTo flag set but we haven't seen a path payload
|
||||
// yet (e.g. truncated packet, or a brand-new entity
|
||||
// whose first cycle UM is still in flight). Hold
|
||||
// velocity at zero — same conservative stance as the
|
||||
// 882a07c stabilizer for incomplete state.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -6081,6 +6572,26 @@ public sealed class GameWindow : IDisposable
|
|||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
|
||||
SelectClosestCombatTarget(showToast: true);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||
ToggleLiveCombatMode();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
|
|
@ -6098,6 +6609,123 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void ToggleLiveCombatMode()
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
|
||||
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
|
||||
_liveSession.SendChangeCombatMode(nextMode);
|
||||
Combat.SetCombatMode(nextMode);
|
||||
string text = $"Combat mode {nextMode}";
|
||||
Console.WriteLine($"combat: {text}");
|
||||
_debugVm?.AddToast(text);
|
||||
}
|
||||
|
||||
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
|
||||
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
|
||||
{
|
||||
_debugVm?.AddToast("Enter melee or missile combat first");
|
||||
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
|
||||
return;
|
||||
}
|
||||
|
||||
uint? target = GetSelectedOrClosestCombatTarget();
|
||||
if (target is null)
|
||||
{
|
||||
_debugVm?.AddToast("No monster target");
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return;
|
||||
}
|
||||
|
||||
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
|
||||
const float FullBar = 1.0f;
|
||||
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
|
||||
{
|
||||
_liveSession.SendMissileAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
private uint? GetSelectedOrClosestCombatTarget()
|
||||
{
|
||||
if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
|
||||
return selected;
|
||||
|
||||
return SelectClosestCombatTarget(showToast: false);
|
||||
}
|
||||
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||
return null;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestDistanceSq = float.PositiveInfinity;
|
||||
foreach (var (guid, entity) in _entitiesByServerGuid)
|
||||
{
|
||||
if (!IsLiveCreatureTarget(guid))
|
||||
continue;
|
||||
|
||||
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
||||
entity.Position,
|
||||
playerEntity.Position);
|
||||
if (distanceSq >= bestDistanceSq)
|
||||
continue;
|
||||
|
||||
bestDistanceSq = distanceSq;
|
||||
bestGuid = guid;
|
||||
}
|
||||
|
||||
_selectedTargetGuid = bestGuid;
|
||||
if (bestGuid is { } selected)
|
||||
{
|
||||
string label = DescribeLiveEntity(selected);
|
||||
float distance = MathF.Sqrt(bestDistanceSq);
|
||||
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
||||
if (showToast)
|
||||
_debugVm?.AddToast($"Target {label}");
|
||||
}
|
||||
else if (showToast)
|
||||
{
|
||||
_debugVm?.AddToast("No monster target");
|
||||
Console.WriteLine("combat: no creature target found");
|
||||
}
|
||||
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
private bool IsLiveCreatureTarget(uint guid)
|
||||
{
|
||||
if (guid == _playerServerGuid)
|
||||
return false;
|
||||
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||||
return false;
|
||||
if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info))
|
||||
return false;
|
||||
|
||||
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
private string DescribeLiveEntity(uint guid)
|
||||
{
|
||||
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
|
||||
&& !string.IsNullOrWhiteSpace(info.Name))
|
||||
return info.Name!;
|
||||
return $"0x{guid:X8}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||||
/// subscriber can call it. Same body as the previous Tab branch in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue