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
113
docs/research/2026-04-28-combat-animation-planner.md
Normal file
113
docs/research/2026-04-28-combat-animation-planner.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Combat Animation Planner Pseudocode
|
||||
|
||||
## Sources
|
||||
|
||||
- Retail `ClientCombatSystem::ExecuteAttack` (`0x0056BB70`): sends
|
||||
targeted melee or missile attack intent and records pending response state.
|
||||
It does not choose a local swing animation.
|
||||
- Retail `ClientCombatSystem::HandleCommenceAttackEvent` (`0x0056AD20`):
|
||||
starts/updates power-bar and busy UI state. The event carries no
|
||||
`MotionCommand`.
|
||||
- Retail command-name table around `0x00803F34`: combat commands include
|
||||
`Twitch1..4`, `StaggerBackward`, `StaggerForward`, `ThrustMed`,
|
||||
`SlashHigh`, `Shoot`, `AttackHigh1`, and later offhand/multistrike
|
||||
commands.
|
||||
- ACE `Player_Melee.DoSwingMotion` and `GetSwingAnimation`: server chooses
|
||||
a swing from `CombatManeuverTable.GetMotion(...)` and broadcasts the
|
||||
selected `MotionCommand` with `UpdateMotion`.
|
||||
- ACE `CombatManeuverTable.GetMotion`: indexes `(stance, attack height,
|
||||
attack type)` to one or more motion commands; power level chooses between
|
||||
multiple entries.
|
||||
|
||||
## Retail Rule
|
||||
|
||||
Combat GameEvents are state/UI notifications. Motion state is the animation
|
||||
authority.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
PlanForEvent(event):
|
||||
return None
|
||||
|
||||
PlanFromWireCommand(wireCommand, speed):
|
||||
fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand)
|
||||
return PlanFromFullCommand(fullCommand, speed)
|
||||
|
||||
PlanFromFullCommand(fullCommand, speed):
|
||||
kind = ClassifyMotionCommand(fullCommand)
|
||||
if kind is None:
|
||||
return None
|
||||
|
||||
routeKind = AnimationCommandRouter.Classify(fullCommand)
|
||||
return Plan(kind, routeKind, fullCommand, speed)
|
||||
|
||||
ClassifyMotionCommand(fullCommand):
|
||||
if command is a combat stance:
|
||||
return CombatStance
|
||||
if command is a thrust/slash/backhand/offhand/multistrike motion:
|
||||
return MeleeSwing
|
||||
if command is Shoot, MissileAttack*, or Reload:
|
||||
return MissileAttack
|
||||
if command is AttackHigh/Med/Low 1..6:
|
||||
return CreatureAttack
|
||||
if command is CastSpell, UseMagicStaff, or UseMagicWand:
|
||||
return SpellCast
|
||||
if command is Twitch*, Stagger*, FallDown, or Sanctuary:
|
||||
return HitReaction
|
||||
if command is Dead:
|
||||
return Death
|
||||
return None
|
||||
```
|
||||
|
||||
## Maneuver Selection Pseudocode
|
||||
|
||||
```text
|
||||
SelectMotion(table, stance, attackHeight, attackType, powerLevel,
|
||||
isThrustSlashWeapon):
|
||||
candidates = []
|
||||
for maneuver in table.CombatManeuvers:
|
||||
if maneuver.Style == stance
|
||||
and maneuver.AttackHeight == attackHeight
|
||||
and maneuver.AttackType == attackType:
|
||||
candidates.append(maneuver.Motion)
|
||||
|
||||
if candidates is empty:
|
||||
return None
|
||||
|
||||
subdivision = isThrustSlashWeapon ? 0.66 : 0.33
|
||||
|
||||
if candidates.Count > 1 and powerLevel < subdivision:
|
||||
motion = candidates[1]
|
||||
else:
|
||||
motion = candidates[0]
|
||||
|
||||
return motion
|
||||
```
|
||||
|
||||
This matches ACE `CombatManeuverTable.GetMotion` plus
|
||||
`Player_Melee.GetSwingAnimation`. The `prevMotion` parameter is present in
|
||||
ACE's table API but the current ACE implementation does not use it; the
|
||||
power threshold chooses between multiple entries.
|
||||
|
||||
## Named Retail Motion IDs
|
||||
|
||||
`DatReaderWriter.Enums.MotionCommand` is shifted by three entries starting
|
||||
at `AllegianceHometownRecall`. Named retail command tables are:
|
||||
|
||||
- `command_ids` table lines 1017626-1017658:
|
||||
`0x016E..0x0197 -> 0x1000016E..0x10000197`.
|
||||
- command-name table lines 1068272-1068313:
|
||||
`OffhandSlashHigh = 0x10000170`, `AttackLow6 = 0x1000018B`,
|
||||
`PunchFastLow = 0x1000018E`, etc.
|
||||
|
||||
`MotionCommandResolver` therefore overrides that range after building the
|
||||
DRW reflection table, otherwise offhand and late unarmed attack actions
|
||||
resolve as UI/mappable commands and never reach `PlayAction`.
|
||||
|
||||
## Implementation Note
|
||||
|
||||
The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable`
|
||||
and `DatReaderWriter.Types.CombatManeuver` directly. acdream already
|
||||
references `Chorizite.DatReaderWriter`; the missing live-state piece is a
|
||||
named `CombatTable` data-id on player/creature state.
|
||||
285
docs/research/2026-04-28-remote-moveto-pseudocode.md
Normal file
285
docs/research/2026-04-28-remote-moveto-pseudocode.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
# Phase L.1c — Remote MoveTo body-driver pseudocode
|
||||
|
||||
**Date**: 2026-04-28
|
||||
**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body
|
||||
position of server-controlled chasing creatures (movementType 6/7) tracks the
|
||||
server-supplied destination smoothly, instead of freezing at zero velocity
|
||||
between sparse `UpdatePosition` snaps.
|
||||
|
||||
## Problem (root cause from systematic-debugging Phase 1)
|
||||
|
||||
The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive`
|
||||
is true, on the principle "do not let `apply_current_movement` free-run with
|
||||
incomplete MoveTo state." The state IS incomplete: our parser at
|
||||
[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs)
|
||||
keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters`
|
||||
block and the `runRate` trailer, **discarding** `Origin (destination)`,
|
||||
`targetGuid` (type 6 only), `distance_to_object`, `min_distance`,
|
||||
`fail_distance`, `walk_run_threshhold`, and `desired_heading`.
|
||||
|
||||
Symptoms (live log + user observation 2026-04-28):
|
||||
- **Disappearing**: body frozen at `Velocity=0` while RunForward animation
|
||||
plays; next UpdatePosition teleports body to actual server pose. If the
|
||||
teleport target is outside the visible window, observer sees disappear/reappear.
|
||||
- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along
|
||||
the OLD heading; meanwhile the server is steering the creature on a curve.
|
||||
Each new UP snap-corrects → visible stutter.
|
||||
|
||||
The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS
|
||||
sending fresh target positions and headings each tick — we're throwing them
|
||||
away.
|
||||
|
||||
## Retail behavior (named decomp + ACE port)
|
||||
|
||||
Sources:
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below
|
||||
- `docs/research/named-retail/acclient.h` — struct definitions
|
||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs`
|
||||
- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`
|
||||
|
||||
### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7)
|
||||
|
||||
```
|
||||
[uint targetGuid] // type 6 only (MoveToObject)
|
||||
Origin: uint cellId // then 3 floats local x/y/z
|
||||
float x, y, z // destination position
|
||||
MovementParameters (28 bytes, exact retail order):
|
||||
uint flags // bitfield (see below)
|
||||
float distance_to_object // arrival far-bound (ACE default 0.6)
|
||||
float min_distance // arrival near-bound
|
||||
float fail_distance // abort when starting→current >= this
|
||||
float speed // base speed multiplier
|
||||
float walk_run_threshhold // (sic, two h's) — wire default 15.0
|
||||
float desired_heading // final orientation (radians or degrees)
|
||||
float runRate // CMotionInterp::my_run_rate copy
|
||||
```
|
||||
|
||||
### MovementParameters bit-flags (declaration order, acclient.h:31423-31443)
|
||||
|
||||
| Bit | Mask | Name | Meaning |
|
||||
|----:|---------|------|---------|
|
||||
| 0 | 0x00001 | can_walk | gait permission |
|
||||
| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) |
|
||||
| 2 | 0x00004 | can_sidestep | enables strafe path |
|
||||
| 3 | 0x00008 | can_walk_backwards | gait permission |
|
||||
| 4 | 0x00010 | can_charge | force HoldKey_Run |
|
||||
| 5 | 0x00020 | fail_walk | fail if only walk possible |
|
||||
| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival |
|
||||
| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion |
|
||||
| 8 | 0x00100 | move_away | flee target |
|
||||
| 9 | 0x00200 | move_towards | chase target (chase creatures set this) |
|
||||
| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line |
|
||||
| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply |
|
||||
| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) |
|
||||
|
||||
### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440)
|
||||
|
||||
```
|
||||
if physics.motions_pending:
|
||||
cancel any aux turn cmd (let the queued motion complete)
|
||||
else:
|
||||
targetWorld = currentTargetPosition // last server-supplied destination
|
||||
desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd)
|
||||
headingDelta = normalize(desiredHeading - body.heading)
|
||||
if |headingDelta| <= 20°: // retail tolerance
|
||||
// ACE adds set_heading(target, true) here (server-tic-rate fudge)
|
||||
cancel any aux turn cmd
|
||||
else:
|
||||
edi = (headingDelta < 180°) ? TurnLeft : TurnRight
|
||||
if edi != auxCommand:
|
||||
_DoMotion(edi) // -> CMotionInterp
|
||||
auxCommand = edi
|
||||
dist = GetCurrentDistance()
|
||||
if CheckProgressMade(dist):
|
||||
if !movingAway and dist <= min_distance: // arrived
|
||||
popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode()
|
||||
if movingAway and dist >= distance_to_object:
|
||||
popHeadNode(); ...
|
||||
if !movingAway and Position.distance(starting, current) >= fail_distance:
|
||||
CancelMoveTo(0x3d) // YouChargedTooFar
|
||||
```
|
||||
|
||||
### Key insight: MoveToManager does NOT touch the body directly
|
||||
|
||||
Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion`
|
||||
(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the
|
||||
ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is
|
||||
purely a *planner* sitting above CMotionInterp, deciding *which command* (and
|
||||
which auxiliary turn) the body should be running at any given tick.
|
||||
|
||||
## Acdream port — minimum viable subset
|
||||
|
||||
The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can
|
||||
skip:
|
||||
- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it
|
||||
- Sticky / `PositionManager::StickTo`
|
||||
- `CheckProgressMade` stall detection — server cancels the move
|
||||
- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern
|
||||
- `WeenieObj::OnMoveComplete` callback
|
||||
- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as
|
||||
a fresh single-step plan)
|
||||
|
||||
We DO need:
|
||||
1. **Parser**: extract the discarded fields into `ServerMotionState`.
|
||||
2. **Per-tick steer**: compute heading-to-destination, turn body orientation
|
||||
toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow*
|
||||
`apply_current_movement` to run — which sets `Body.Velocity` from the
|
||||
active RunForward cycle, oriented along the now-correct heading.
|
||||
3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready
|
||||
and clear `ServerMoveToActive`. Server's next MoveTo packet will resume.
|
||||
|
||||
## Pseudocode — acdream port
|
||||
|
||||
### Parser change (`UpdateMotion.TryParseMoveToPayload`)
|
||||
|
||||
```
|
||||
TryParseMoveToPayload(body, pos, mt, out parsed):
|
||||
if mt == 6:
|
||||
if rem < 4: return false
|
||||
parsed.TargetGuid = ReadU32; pos += 4
|
||||
|
||||
if rem < 16: return false
|
||||
parsed.OriginCellId = ReadU32; pos += 4
|
||||
parsed.OriginX = ReadF32; pos += 4
|
||||
parsed.OriginY = ReadF32; pos += 4
|
||||
parsed.OriginZ = ReadF32; pos += 4
|
||||
|
||||
if rem < 28: return false
|
||||
parsed.Flags = ReadU32; pos += 4
|
||||
parsed.DistanceToObject = ReadF32; pos += 4
|
||||
parsed.MinDistance = ReadF32; pos += 4
|
||||
parsed.FailDistance = ReadF32; pos += 4
|
||||
parsed.Speed = ReadF32; pos += 4
|
||||
parsed.WalkRunThreshold = ReadF32; pos += 4
|
||||
parsed.DesiredHeading = ReadF32; pos += 4
|
||||
|
||||
if rem < 4: return false
|
||||
parsed.RunRate = ReadF32
|
||||
return true
|
||||
```
|
||||
|
||||
### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`)
|
||||
|
||||
```
|
||||
DriveOneTick(rm, dt):
|
||||
if not rm.HasMoveToDestination: return ApplyDefault
|
||||
|
||||
targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time
|
||||
bodyPos = rm.Body.Position
|
||||
|
||||
// Distance check first — arrival short-circuits before any heading work
|
||||
dist = horizontalDistance(targetWorld, bodyPos)
|
||||
if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble):
|
||||
rm.HasMoveToDestination = false
|
||||
// animation cycle moves to Ready via the existing
|
||||
// ApplyServerControlledVelocityCycle path on next zero-velocity sample
|
||||
rm.Body.Velocity = Vector3.Zero
|
||||
return Arrived
|
||||
|
||||
// Heading compute (XY plane; Z untouched — server owns Z)
|
||||
deltaXY = (targetWorld.XY - bodyPos.XY).Normalized
|
||||
desiredHeading = atan2(deltaXY) // radians
|
||||
currentHeading = QuaternionToYaw(rm.Body.Orientation)
|
||||
headingDelta = wrapPi(desiredHeading - currentHeading)
|
||||
|
||||
// Snap orientation toward target — match ACE's set_heading(target, true)
|
||||
// when within tolerance, otherwise rotate at retail-faithful turn rate.
|
||||
const float tolerance = 20° (in radians)
|
||||
if |headingDelta| <= tolerance:
|
||||
rm.Body.Orientation = QuaternionFromYaw(desiredHeading)
|
||||
else:
|
||||
// retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt
|
||||
float maxStep = TurnRateRadPerSec * dt
|
||||
float step = clamp(headingDelta, -maxStep, +maxStep)
|
||||
rm.Body.Orientation = QuaternionFromYaw(currentHeading + step)
|
||||
|
||||
// Allow apply_current_movement to set Velocity from RunForward cycle.
|
||||
// The cycle was already seeded by PlanMoveToStart at packet receipt
|
||||
// and is being played by the AnimationSequencer. CMotionInterp's
|
||||
// apply_current_movement reads InterpretedState.ForwardCommand and
|
||||
// sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod.
|
||||
return DriveActive // caller now invokes apply_current_movement
|
||||
```
|
||||
|
||||
### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch)
|
||||
|
||||
```
|
||||
on receipt of MoveTo packet:
|
||||
// existing code already seeds the animation cycle via PlanMoveToStart
|
||||
// NEW: store world-converted destination + thresholds on rmState
|
||||
lbX = (originCellId >> 24) & 0xFF
|
||||
lbY = (originCellId >> 16) & 0xFF
|
||||
origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0)
|
||||
rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin
|
||||
rmState.MoveToMinDistance = parsed.MinDistance
|
||||
rmState.MoveToDistanceToObject = parsed.DistanceToObject
|
||||
rmState.HasMoveToDestination = true
|
||||
// ServerMoveToActive remains set; existing
|
||||
```
|
||||
|
||||
### Integration in per-tick remote update (`GameWindow.cs` ~line 5045)
|
||||
|
||||
```
|
||||
// Replace the current Velocity = Zero hold with:
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination)
|
||||
{
|
||||
var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt);
|
||||
if driveResult == Arrived:
|
||||
// signal cycle update to Ready via existing path
|
||||
ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero);
|
||||
else:
|
||||
rm.Body.TransientState |= Contact | OnWalkable | Active
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
|
||||
{
|
||||
// No destination yet (very early frame, packet hasn't fully landed)
|
||||
rm.Body.Velocity = Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
```
|
||||
|
||||
## Conformance test cases
|
||||
|
||||
1. **Parser round-trip — type 7 (MoveToPosition)**
|
||||
- Synthesize a 68-byte body with known origin + 7 params + runRate.
|
||||
- Assert all 9 new fields decode correctly.
|
||||
|
||||
2. **Parser round-trip — type 6 (MoveToObject)**
|
||||
- Synthesize a 72-byte body with target guid + origin + params + runRate.
|
||||
- Assert TargetGuid populated and shifts subsequent fields by 4 bytes.
|
||||
|
||||
3. **DriveOneTick — heading snap within tolerance**
|
||||
- body at (0,0,0) facing east, destination (10,0,0).
|
||||
- DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap.
|
||||
- assert orientation unchanged (already correct).
|
||||
|
||||
4. **DriveOneTick — heading turn beyond tolerance**
|
||||
- body at (0,0,0) facing east, destination (0,10,0).
|
||||
- desiredHeading=π/2; current=0; |delta|=π/2 > 20°.
|
||||
- dt=0.1s, TurnRate=π/2 → step = π/4 toward target.
|
||||
- assert orientation rotated by π/4 (not full snap).
|
||||
|
||||
5. **DriveOneTick — arrival**
|
||||
- body at (0,0,0), destination (0.4,0,0), MinDistance=0.6.
|
||||
- assert HasMoveToDestination cleared and Velocity zeroed.
|
||||
|
||||
6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`)
|
||||
- assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`.
|
||||
|
||||
## Out of scope (future Phase L.1d if needed)
|
||||
|
||||
- Sticky / StickTo for MoveToObject completion
|
||||
- `use_final_heading` (post-arrival turn-to-heading)
|
||||
- `fail_distance` early-cancel (server already does this; we just don't flag it)
|
||||
- `CheckProgressMade` stall detector
|
||||
- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper)
|
||||
- Sphere-cylinder distance (`use_spheres` bit)
|
||||
- `MoveToObject` target-guid resolution — currently we only honor the Origin,
|
||||
which works because the server re-emits with refreshed Origin each tick.
|
||||
If the target is moving fast and the server's emit cadence falls behind,
|
||||
we'd see lag; a future enhancement is to look up the target entity by
|
||||
guid and use its current world position when fresher than Origin.
|
||||
Loading…
Add table
Add a link
Reference in a new issue