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
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-faithful combat animation planner for server-sent motion commands.
|
||||
///
|
||||
/// Retail evidence:
|
||||
/// - <c>ClientCombatSystem::ExecuteAttack</c> (0x0056BB70) only sends the
|
||||
/// targeted melee/missile GameAction and sets response state; it does not
|
||||
/// locally choose or play a swing animation.
|
||||
/// - <c>ClientCombatSystem::HandleCommenceAttackEvent</c> (0x0056AD20)
|
||||
/// updates the power bar/busy state; it carries no MotionCommand.
|
||||
/// - ACE <c>Player_Melee.DoSwingMotion</c> chooses a swing via
|
||||
/// <c>CombatManeuverTable.GetMotion</c> and broadcasts that MotionCommand
|
||||
/// in <c>UpdateMotion</c>.
|
||||
///
|
||||
/// So acdream treats combat GameEvents as state/UI signals and treats
|
||||
/// UpdateMotion command IDs as the animation authority.
|
||||
/// </summary>
|
||||
public static class CombatAnimationPlanner
|
||||
{
|
||||
public static CombatAnimationPlan PlanForEvent(CombatAnimationEvent combatEvent)
|
||||
{
|
||||
_ = combatEvent;
|
||||
return CombatAnimationPlan.None;
|
||||
}
|
||||
|
||||
public static CombatAnimationPlan PlanFromWireCommand(ushort wireCommand, float speedMod = 1f)
|
||||
{
|
||||
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
|
||||
return PlanFromFullCommand(fullCommand, speedMod);
|
||||
}
|
||||
|
||||
public static CombatAnimationPlan PlanFromFullCommand(uint fullCommand, float speedMod = 1f)
|
||||
{
|
||||
var kind = ClassifyMotionCommand(fullCommand);
|
||||
if (kind == CombatAnimationKind.None)
|
||||
return CombatAnimationPlan.None;
|
||||
|
||||
return new CombatAnimationPlan(
|
||||
kind,
|
||||
AnimationCommandRouter.Classify(fullCommand),
|
||||
fullCommand,
|
||||
speedMod);
|
||||
}
|
||||
|
||||
public static CombatAnimationKind ClassifyMotionCommand(uint fullCommand)
|
||||
{
|
||||
return fullCommand switch
|
||||
{
|
||||
CombatAnimationMotionCommands.HandCombat
|
||||
or CombatAnimationMotionCommands.SwordCombat
|
||||
or CombatAnimationMotionCommands.SwordShieldCombat
|
||||
or CombatAnimationMotionCommands.TwoHandedSwordCombat
|
||||
or CombatAnimationMotionCommands.TwoHandedStaffCombat
|
||||
or CombatAnimationMotionCommands.BowCombat
|
||||
or CombatAnimationMotionCommands.CrossbowCombat
|
||||
or CombatAnimationMotionCommands.SlingCombat
|
||||
or CombatAnimationMotionCommands.DualWieldCombat
|
||||
or CombatAnimationMotionCommands.ThrownWeaponCombat
|
||||
or CombatAnimationMotionCommands.AtlatlCombat
|
||||
or CombatAnimationMotionCommands.ThrownShieldCombat
|
||||
or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance,
|
||||
|
||||
CombatAnimationMotionCommands.ThrustMed
|
||||
or CombatAnimationMotionCommands.ThrustLow
|
||||
or CombatAnimationMotionCommands.ThrustHigh
|
||||
or CombatAnimationMotionCommands.SlashHigh
|
||||
or CombatAnimationMotionCommands.SlashMed
|
||||
or CombatAnimationMotionCommands.SlashLow
|
||||
or CombatAnimationMotionCommands.BackhandHigh
|
||||
or CombatAnimationMotionCommands.BackhandMed
|
||||
or CombatAnimationMotionCommands.BackhandLow
|
||||
or CombatAnimationMotionCommands.DoubleSlashLow
|
||||
or CombatAnimationMotionCommands.DoubleSlashMed
|
||||
or CombatAnimationMotionCommands.DoubleSlashHigh
|
||||
or CombatAnimationMotionCommands.TripleSlashLow
|
||||
or CombatAnimationMotionCommands.TripleSlashMed
|
||||
or CombatAnimationMotionCommands.TripleSlashHigh
|
||||
or CombatAnimationMotionCommands.DoubleThrustLow
|
||||
or CombatAnimationMotionCommands.DoubleThrustMed
|
||||
or CombatAnimationMotionCommands.DoubleThrustHigh
|
||||
or CombatAnimationMotionCommands.TripleThrustLow
|
||||
or CombatAnimationMotionCommands.TripleThrustMed
|
||||
or CombatAnimationMotionCommands.TripleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandKick
|
||||
or CombatAnimationMotionCommands.PunchFastHigh
|
||||
or CombatAnimationMotionCommands.PunchFastMed
|
||||
or CombatAnimationMotionCommands.PunchFastLow
|
||||
or CombatAnimationMotionCommands.PunchSlowHigh
|
||||
or CombatAnimationMotionCommands.PunchSlowMed
|
||||
or CombatAnimationMotionCommands.PunchSlowLow
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastHigh
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastMed
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastLow
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowHigh
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowMed
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowLow => CombatAnimationKind.MeleeSwing,
|
||||
|
||||
CombatAnimationMotionCommands.Shoot
|
||||
or CombatAnimationMotionCommands.MissileAttack1
|
||||
or CombatAnimationMotionCommands.MissileAttack2
|
||||
or CombatAnimationMotionCommands.MissileAttack3
|
||||
or CombatAnimationMotionCommands.Reload => CombatAnimationKind.MissileAttack,
|
||||
|
||||
CombatAnimationMotionCommands.AttackHigh1
|
||||
or CombatAnimationMotionCommands.AttackMed1
|
||||
or CombatAnimationMotionCommands.AttackLow1
|
||||
or CombatAnimationMotionCommands.AttackHigh2
|
||||
or CombatAnimationMotionCommands.AttackMed2
|
||||
or CombatAnimationMotionCommands.AttackLow2
|
||||
or CombatAnimationMotionCommands.AttackHigh3
|
||||
or CombatAnimationMotionCommands.AttackMed3
|
||||
or CombatAnimationMotionCommands.AttackLow3
|
||||
or CombatAnimationMotionCommands.AttackHigh4
|
||||
or CombatAnimationMotionCommands.AttackMed4
|
||||
or CombatAnimationMotionCommands.AttackLow4
|
||||
or CombatAnimationMotionCommands.AttackHigh5
|
||||
or CombatAnimationMotionCommands.AttackMed5
|
||||
or CombatAnimationMotionCommands.AttackLow5
|
||||
or CombatAnimationMotionCommands.AttackHigh6
|
||||
or CombatAnimationMotionCommands.AttackMed6
|
||||
or CombatAnimationMotionCommands.AttackLow6 => CombatAnimationKind.CreatureAttack,
|
||||
|
||||
CombatAnimationMotionCommands.CastSpell
|
||||
or CombatAnimationMotionCommands.UseMagicStaff
|
||||
or CombatAnimationMotionCommands.UseMagicWand => CombatAnimationKind.SpellCast,
|
||||
|
||||
CombatAnimationMotionCommands.FallDown
|
||||
or CombatAnimationMotionCommands.Twitch1
|
||||
or CombatAnimationMotionCommands.Twitch2
|
||||
or CombatAnimationMotionCommands.Twitch3
|
||||
or CombatAnimationMotionCommands.Twitch4
|
||||
or CombatAnimationMotionCommands.StaggerBackward
|
||||
or CombatAnimationMotionCommands.StaggerForward
|
||||
or CombatAnimationMotionCommands.Sanctuary => CombatAnimationKind.HitReaction,
|
||||
|
||||
MotionCommand.Dead => CombatAnimationKind.Death,
|
||||
|
||||
_ => CombatAnimationKind.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CombatAnimationPlan(
|
||||
CombatAnimationKind Kind,
|
||||
AnimationCommandRouteKind RouteKind,
|
||||
uint MotionCommand,
|
||||
float SpeedMod)
|
||||
{
|
||||
public static CombatAnimationPlan None { get; } = new(
|
||||
CombatAnimationKind.None,
|
||||
AnimationCommandRouteKind.None,
|
||||
0u,
|
||||
0f);
|
||||
|
||||
public bool HasMotion => Kind != CombatAnimationKind.None && MotionCommand != 0;
|
||||
}
|
||||
|
||||
public enum CombatAnimationEvent
|
||||
{
|
||||
CombatCommenceAttack,
|
||||
AttackDone,
|
||||
AttackerNotification,
|
||||
DefenderNotification,
|
||||
EvasionAttackerNotification,
|
||||
EvasionDefenderNotification,
|
||||
VictimNotification,
|
||||
KillerNotification,
|
||||
}
|
||||
|
||||
public enum CombatAnimationKind
|
||||
{
|
||||
None = 0,
|
||||
CombatStance,
|
||||
MeleeSwing,
|
||||
MissileAttack,
|
||||
CreatureAttack,
|
||||
SpellCast,
|
||||
HitReaction,
|
||||
Death,
|
||||
}
|
||||
|
||||
internal static class CombatAnimationMotionCommands
|
||||
{
|
||||
public const uint HandCombat = 0x8000003Cu;
|
||||
public const uint SwordCombat = 0x8000003Eu;
|
||||
public const uint BowCombat = 0x8000003Fu;
|
||||
public const uint SwordShieldCombat = 0x80000040u;
|
||||
public const uint CrossbowCombat = 0x80000041u;
|
||||
public const uint SlingCombat = 0x80000043u;
|
||||
public const uint TwoHandedSwordCombat = 0x80000044u;
|
||||
public const uint TwoHandedStaffCombat = 0x80000045u;
|
||||
public const uint DualWieldCombat = 0x80000046u;
|
||||
public const uint ThrownWeaponCombat = 0x80000047u;
|
||||
public const uint Magic = 0x80000049u;
|
||||
public const uint AtlatlCombat = 0x8000013Bu;
|
||||
public const uint ThrownShieldCombat = 0x8000013Cu;
|
||||
|
||||
public const uint FallDown = 0x10000050u;
|
||||
public const uint Twitch1 = 0x10000051u;
|
||||
public const uint Twitch2 = 0x10000052u;
|
||||
public const uint Twitch3 = 0x10000053u;
|
||||
public const uint Twitch4 = 0x10000054u;
|
||||
public const uint StaggerBackward = 0x10000055u;
|
||||
public const uint StaggerForward = 0x10000056u;
|
||||
public const uint Sanctuary = 0x10000057u;
|
||||
public const uint ThrustMed = 0x10000058u;
|
||||
public const uint ThrustLow = 0x10000059u;
|
||||
public const uint ThrustHigh = 0x1000005Au;
|
||||
public const uint SlashHigh = 0x1000005Bu;
|
||||
public const uint SlashMed = 0x1000005Cu;
|
||||
public const uint SlashLow = 0x1000005Du;
|
||||
public const uint BackhandHigh = 0x1000005Eu;
|
||||
public const uint BackhandMed = 0x1000005Fu;
|
||||
public const uint BackhandLow = 0x10000060u;
|
||||
public const uint Shoot = 0x10000061u;
|
||||
public const uint AttackHigh1 = 0x10000062u;
|
||||
public const uint AttackMed1 = 0x10000063u;
|
||||
public const uint AttackLow1 = 0x10000064u;
|
||||
public const uint AttackHigh2 = 0x10000065u;
|
||||
public const uint AttackMed2 = 0x10000066u;
|
||||
public const uint AttackLow2 = 0x10000067u;
|
||||
public const uint AttackHigh3 = 0x10000068u;
|
||||
public const uint AttackMed3 = 0x10000069u;
|
||||
public const uint AttackLow3 = 0x1000006Au;
|
||||
|
||||
public const uint MissileAttack1 = 0x100000D0u;
|
||||
public const uint MissileAttack2 = 0x100000D1u;
|
||||
public const uint MissileAttack3 = 0x100000D2u;
|
||||
public const uint CastSpell = 0x400000D3u;
|
||||
public const uint Reload = 0x100000D4u;
|
||||
public const uint UseMagicStaff = 0x400000E0u;
|
||||
public const uint UseMagicWand = 0x400000E1u;
|
||||
|
||||
public const uint DoubleSlashLow = 0x1000011Fu;
|
||||
public const uint DoubleSlashMed = 0x10000120u;
|
||||
public const uint DoubleSlashHigh = 0x10000121u;
|
||||
public const uint TripleSlashLow = 0x10000122u;
|
||||
public const uint TripleSlashMed = 0x10000123u;
|
||||
public const uint TripleSlashHigh = 0x10000124u;
|
||||
public const uint DoubleThrustLow = 0x10000125u;
|
||||
public const uint DoubleThrustMed = 0x10000126u;
|
||||
public const uint DoubleThrustHigh = 0x10000127u;
|
||||
public const uint TripleThrustLow = 0x10000128u;
|
||||
public const uint TripleThrustMed = 0x10000129u;
|
||||
public const uint TripleThrustHigh = 0x1000012Au;
|
||||
|
||||
public const uint OffhandSlashHigh = 0x10000170u;
|
||||
public const uint OffhandSlashMed = 0x10000171u;
|
||||
public const uint OffhandSlashLow = 0x10000172u;
|
||||
public const uint OffhandThrustHigh = 0x10000173u;
|
||||
public const uint OffhandThrustMed = 0x10000174u;
|
||||
public const uint OffhandThrustLow = 0x10000175u;
|
||||
public const uint OffhandDoubleSlashLow = 0x10000176u;
|
||||
public const uint OffhandDoubleSlashMed = 0x10000177u;
|
||||
public const uint OffhandDoubleSlashHigh = 0x10000178u;
|
||||
public const uint OffhandTripleSlashLow = 0x10000179u;
|
||||
public const uint OffhandTripleSlashMed = 0x1000017Au;
|
||||
public const uint OffhandTripleSlashHigh = 0x1000017Bu;
|
||||
public const uint OffhandDoubleThrustLow = 0x1000017Cu;
|
||||
public const uint OffhandDoubleThrustMed = 0x1000017Du;
|
||||
public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
|
||||
public const uint OffhandTripleThrustLow = 0x1000017Fu;
|
||||
public const uint OffhandTripleThrustMed = 0x10000180u;
|
||||
public const uint OffhandTripleThrustHigh = 0x10000181u;
|
||||
public const uint OffhandKick = 0x10000182u;
|
||||
public const uint AttackHigh4 = 0x10000183u;
|
||||
public const uint AttackMed4 = 0x10000184u;
|
||||
public const uint AttackLow4 = 0x10000185u;
|
||||
public const uint AttackHigh5 = 0x10000186u;
|
||||
public const uint AttackMed5 = 0x10000187u;
|
||||
public const uint AttackLow5 = 0x10000188u;
|
||||
public const uint AttackHigh6 = 0x10000189u;
|
||||
public const uint AttackMed6 = 0x1000018Au;
|
||||
public const uint AttackLow6 = 0x1000018Bu;
|
||||
public const uint PunchFastHigh = 0x1000018Cu;
|
||||
public const uint PunchFastMed = 0x1000018Du;
|
||||
public const uint PunchFastLow = 0x1000018Eu;
|
||||
public const uint PunchSlowHigh = 0x1000018Fu;
|
||||
public const uint PunchSlowMed = 0x10000190u;
|
||||
public const uint PunchSlowLow = 0x10000191u;
|
||||
public const uint OffhandPunchFastHigh = 0x10000192u;
|
||||
public const uint OffhandPunchFastMed = 0x10000193u;
|
||||
public const uint OffhandPunchFastLow = 0x10000194u;
|
||||
public const uint OffhandPunchSlowHigh = 0x10000195u;
|
||||
public const uint OffhandPunchSlowMed = 0x10000196u;
|
||||
public const uint OffhandPunchSlowLow = 0x10000197u;
|
||||
}
|
||||
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
using DatMotionStance = DatReaderWriter.Enums.MotionStance;
|
||||
using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
|
||||
using DatAttackType = DatReaderWriter.Enums.AttackType;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Selects combat swing motions from the retail <c>CombatTable</c> DBObj.
|
||||
///
|
||||
/// Retail evidence:
|
||||
/// - <c>CombatManeuverTable::Get</c> (0x0056AB60) loads DB type
|
||||
/// <c>0x1000000D</c> for a 0x30xxxxxx combat table id.
|
||||
/// - ACE <c>CombatManeuverTable.GetMotion</c> indexes maneuvers by
|
||||
/// stance, attack height, and attack type, returning all matching motions.
|
||||
/// - ACE <c>Player_Melee.GetSwingAnimation</c> then chooses
|
||||
/// <c>motions[1]</c> when more than one motion exists and power is below
|
||||
/// the subdivision threshold; otherwise it uses <c>motions[0]</c>.
|
||||
/// </summary>
|
||||
public static class CombatManeuverSelector
|
||||
{
|
||||
public const float DefaultSubdivision = 0.33f;
|
||||
public const float ThrustSlashSubdivision = 0.66f;
|
||||
|
||||
public static CombatManeuverSelection SelectMotion(
|
||||
CombatTable table,
|
||||
DatMotionStance stance,
|
||||
DatAttackHeight attackHeight,
|
||||
DatAttackType attackType,
|
||||
float powerLevel,
|
||||
bool isThrustSlashWeapon = false)
|
||||
{
|
||||
var motions = FindMotions(table, stance, attackHeight, attackType);
|
||||
if (motions.Count == 0)
|
||||
return CombatManeuverSelection.None;
|
||||
|
||||
float subdivision = isThrustSlashWeapon
|
||||
? ThrustSlashSubdivision
|
||||
: DefaultSubdivision;
|
||||
|
||||
var motion = motions.Count > 1 && powerLevel < subdivision
|
||||
? motions[1]
|
||||
: motions[0];
|
||||
|
||||
return new CombatManeuverSelection(
|
||||
Found: true,
|
||||
Motion: motion,
|
||||
Candidates: motions,
|
||||
EffectiveAttackType: attackType,
|
||||
Subdivision: subdivision);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DatMotionCommand> FindMotions(
|
||||
CombatTable table,
|
||||
DatMotionStance stance,
|
||||
DatAttackHeight attackHeight,
|
||||
DatAttackType attackType)
|
||||
{
|
||||
var result = new List<DatMotionCommand>();
|
||||
|
||||
foreach (var maneuver in table.CombatManeuvers)
|
||||
{
|
||||
if (maneuver.Style == stance
|
||||
&& maneuver.AttackHeight == attackHeight
|
||||
&& maneuver.AttackType == attackType)
|
||||
{
|
||||
result.Add(maneuver.Motion);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CombatManeuverSelection(
|
||||
bool Found,
|
||||
DatMotionCommand Motion,
|
||||
IReadOnlyList<DatMotionCommand> Candidates,
|
||||
DatAttackType EffectiveAttackType,
|
||||
float Subdivision)
|
||||
{
|
||||
public static CombatManeuverSelection None { get; } = new(
|
||||
Found: false,
|
||||
Motion: DatMotionCommand.Invalid,
|
||||
Candidates: Array.Empty<DatMotionCommand>(),
|
||||
EffectiveAttackType: DatAttackType.Undef,
|
||||
Subdivision: 0f);
|
||||
}
|
||||
|
|
@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
|
|||
// Full research: docs/research/deepdives/r02-combat-system.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Flags]
|
||||
public enum CombatMode
|
||||
{
|
||||
Undef = 0,
|
||||
NonCombat = 1,
|
||||
Melee = 2,
|
||||
Missile = 3,
|
||||
Magic = 4,
|
||||
Peaceful = 5,
|
||||
NonCombat = 0x01,
|
||||
Melee = 0x02,
|
||||
Missile = 0x04,
|
||||
Magic = 0x08,
|
||||
|
||||
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||
CombatCombat = Melee | Missile | Magic,
|
||||
}
|
||||
|
||||
public enum AttackHeight
|
||||
|
|
@ -24,6 +27,51 @@ public enum AttackHeight
|
|||
Low = 3,
|
||||
}
|
||||
|
||||
public enum CombatAttackAction
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail input-facing combat decisions. The heavyweight parts of the combat
|
||||
/// system remain server authoritative; this helper only maps UI intent to the
|
||||
/// mode / attack-height values sent on the wire.
|
||||
///
|
||||
/// References:
|
||||
/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
|
||||
/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
|
||||
/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
|
||||
/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
|
||||
/// targeted attacks for Melee and Missile modes.
|
||||
/// </summary>
|
||||
public static class CombatInputPlanner
|
||||
{
|
||||
public static CombatMode ToggleMode(
|
||||
CombatMode currentMode,
|
||||
CombatMode defaultCombatMode = CombatMode.Melee)
|
||||
{
|
||||
if ((currentMode & CombatMode.CombatCombat) != 0)
|
||||
return CombatMode.NonCombat;
|
||||
|
||||
return (defaultCombatMode & CombatMode.CombatCombat) != 0
|
||||
? defaultCombatMode
|
||||
: CombatMode.Melee;
|
||||
}
|
||||
|
||||
public static bool SupportsTargetedAttack(CombatMode mode) =>
|
||||
mode == CombatMode.Melee || mode == CombatMode.Missile;
|
||||
|
||||
public static AttackHeight HeightFor(CombatAttackAction action) => action switch
|
||||
{
|
||||
CombatAttackAction.Low => AttackHeight.Low,
|
||||
CombatAttackAction.Medium => AttackHeight.Medium,
|
||||
CombatAttackAction.High => AttackHeight.High,
|
||||
_ => AttackHeight.Medium,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
||||
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
||||
|
|
@ -31,20 +79,26 @@ public enum AttackHeight
|
|||
[Flags]
|
||||
public enum AttackType : uint
|
||||
{
|
||||
None = 0,
|
||||
Punch = 0x0001,
|
||||
Kick = 0x0002,
|
||||
Thrust = 0x0004,
|
||||
Slash = 0x0008,
|
||||
DoubleSlash = 0x0010,
|
||||
TripleSlash = 0x0020,
|
||||
DoubleThrust = 0x0040,
|
||||
TripleThrust = 0x0080,
|
||||
Offhand = 0x0100,
|
||||
OffhandSlash = 0x0200,
|
||||
OffhandThrust = 0x0400,
|
||||
ThrustSlash = 0x0800,
|
||||
// more in r02 §2
|
||||
None = 0,
|
||||
Punch = 0x0001,
|
||||
Thrust = 0x0002,
|
||||
Slash = 0x0004,
|
||||
Kick = 0x0008,
|
||||
OffhandPunch = 0x0010,
|
||||
DoubleSlash = 0x0020,
|
||||
TripleSlash = 0x0040,
|
||||
DoubleThrust = 0x0080,
|
||||
TripleThrust = 0x0100,
|
||||
OffhandThrust = 0x0200,
|
||||
OffhandSlash = 0x0400,
|
||||
OffhandDoubleSlash = 0x0800,
|
||||
OffhandTripleSlash = 0x1000,
|
||||
OffhandDoubleThrust = 0x2000,
|
||||
OffhandTripleThrust = 0x4000,
|
||||
Unarmed = Punch | Kick | OffhandPunch,
|
||||
MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust
|
||||
| OffhandDoubleSlash | OffhandTripleSlash
|
||||
| OffhandDoubleThrust | OffhandTripleThrust,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ public sealed class CombatState
|
|||
{
|
||||
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
|
||||
|
||||
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
|
||||
|
||||
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
|
||||
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
|
||||
|
||||
|
|
@ -57,6 +59,12 @@ public sealed class CombatState
|
|||
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
|
||||
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
|
||||
|
||||
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
|
||||
public event Action? AttackCommenced;
|
||||
|
||||
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
|
||||
public event Action<CombatMode>? CombatModeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the server confirms the player landed a killing blow
|
||||
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
|
||||
|
|
@ -94,6 +102,15 @@ public sealed class CombatState
|
|||
HealthChanged?.Invoke(targetGuid, healthPercent);
|
||||
}
|
||||
|
||||
public void SetCombatMode(CombatMode mode)
|
||||
{
|
||||
if (CurrentMode == mode)
|
||||
return;
|
||||
|
||||
CurrentMode = mode;
|
||||
CombatModeChanged?.Invoke(mode);
|
||||
}
|
||||
|
||||
public void OnVictimNotification(
|
||||
string attackerName, uint attackerGuid, uint damageType, uint damage,
|
||||
uint hitQuadrant, uint critical, uint attackType)
|
||||
|
|
@ -140,5 +157,8 @@ public sealed class CombatState
|
|||
public void OnAttackDone(uint attackSequence, uint weenieError)
|
||||
=> AttackDone?.Invoke(attackSequence, weenieError);
|
||||
|
||||
public void OnCombatCommenceAttack()
|
||||
=> AttackCommenced?.Invoke();
|
||||
|
||||
public void Clear() => _healthByGuid.Clear();
|
||||
}
|
||||
|
|
|
|||
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Central routing for full retail MotionCommand values after the wire's
|
||||
/// 16-bit command id has been reconstructed.
|
||||
///
|
||||
/// Retail/ACE split motion commands by class mask:
|
||||
/// - Action and ChatEmote commands play through link/action data.
|
||||
/// - Modifier commands play through modifier data.
|
||||
/// - SubState commands become the new cyclic state.
|
||||
/// - Style/UI/Toggle commands do not directly drive an animation overlay here.
|
||||
///
|
||||
/// References:
|
||||
/// CMotionTable::GetObjectSequence 0x00522860,
|
||||
/// CMotionInterp::DoInterpretedMotion 0x00528360,
|
||||
/// ACE MotionTable.GetObjectSequence, and
|
||||
/// docs/research/deepdives/r03-motion-animation.md section 3.
|
||||
/// </summary>
|
||||
public static class AnimationCommandRouter
|
||||
{
|
||||
private const uint ActionMask = 0x10000000u;
|
||||
private const uint ModifierMask = 0x20000000u;
|
||||
private const uint SubStateMask = 0x40000000u;
|
||||
private const uint ClassMask = 0xFF000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a reconstructed full MotionCommand.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind Classify(uint fullCommand)
|
||||
{
|
||||
if (fullCommand == 0)
|
||||
return AnimationCommandRouteKind.None;
|
||||
|
||||
uint cls = fullCommand & ClassMask;
|
||||
if (cls == 0x12000000u || cls == 0x13000000u)
|
||||
return AnimationCommandRouteKind.ChatEmote;
|
||||
|
||||
if ((fullCommand & ModifierMask) != 0)
|
||||
return AnimationCommandRouteKind.Modifier;
|
||||
|
||||
if ((fullCommand & ActionMask) != 0)
|
||||
return AnimationCommandRouteKind.Action;
|
||||
|
||||
if ((fullCommand & SubStateMask) != 0)
|
||||
return AnimationCommandRouteKind.SubState;
|
||||
|
||||
return AnimationCommandRouteKind.Ignored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs and routes a 16-bit wire command.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind RouteWireCommand(
|
||||
AnimationSequencer sequencer,
|
||||
uint currentStyle,
|
||||
ushort wireCommand,
|
||||
float speedMod = 1f)
|
||||
{
|
||||
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
|
||||
return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a full MotionCommand to the matching sequencer API.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind RouteFullCommand(
|
||||
AnimationSequencer sequencer,
|
||||
uint currentStyle,
|
||||
uint fullCommand,
|
||||
float speedMod = 1f)
|
||||
{
|
||||
var route = Classify(fullCommand);
|
||||
switch (route)
|
||||
{
|
||||
case AnimationCommandRouteKind.Action:
|
||||
case AnimationCommandRouteKind.Modifier:
|
||||
case AnimationCommandRouteKind.ChatEmote:
|
||||
sequencer.PlayAction(fullCommand, speedMod);
|
||||
break;
|
||||
case AnimationCommandRouteKind.SubState:
|
||||
sequencer.SetCycle(currentStyle, fullCommand, speedMod);
|
||||
break;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnimationCommandRouteKind
|
||||
{
|
||||
None = 0,
|
||||
Action,
|
||||
Modifier,
|
||||
ChatEmote,
|
||||
SubState,
|
||||
Ignored,
|
||||
}
|
||||
|
|
@ -330,6 +330,33 @@ public sealed class AnimationSequencer
|
|||
/// makes the jump look delayed (legs stand still for ~100 ms while
|
||||
/// the link drains, then fold into Falling). Defaults to false to
|
||||
/// preserve normal smooth transitions for everything else.</param>
|
||||
/// <summary>
|
||||
/// Check whether the underlying MotionTable contains a cycle for the
|
||||
/// given (style, motion) pair. Useful for callers that want to fall
|
||||
/// back to a known-good motion (e.g. <c>WalkForward</c> →
|
||||
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
|
||||
/// unconditional <c>ClearCyclicTail</c> path on a missing cycle —
|
||||
/// which leaves the body without any animation tail and snaps every
|
||||
/// part to the setup-default offset (visible as "torso on the
|
||||
/// ground" since most creatures' setup-default has limbs at the
|
||||
/// torso origin).
|
||||
/// </summary>
|
||||
public bool HasCycle(uint style, uint motion)
|
||||
{
|
||||
// adjust_motion remapping (mirrors the head of SetCycle):
|
||||
// TurnLeft, SideStepLeft, WalkBackward map to their right/forward
|
||||
// mirror cycles.
|
||||
uint adjustedMotion = motion;
|
||||
switch (motion & 0xFFFFu)
|
||||
{
|
||||
case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
|
||||
case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
|
||||
case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
|
||||
}
|
||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||
return _mtable.Cycles.ContainsKey(cycleKey);
|
||||
}
|
||||
|
||||
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
|
||||
{
|
||||
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ public static class MotionCommandResolver
|
|||
result[lo] = full;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyNamedRetailOverrides(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> result)
|
||||
{
|
||||
// The generated DRW enum is shifted by three entries starting at
|
||||
// AllegianceHometownRecall. The named Sept 2013 retail command_ids
|
||||
// table is authoritative here:
|
||||
// named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
|
||||
// and command-name table lines 1068272-1068313.
|
||||
//
|
||||
// These values cover recall, offhand, attack 4-6, and fast/slow punch
|
||||
// actions. Without the override, wire command 0x0170 reconstructs to
|
||||
// IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
|
||||
// animations route as UI commands and never play.
|
||||
for (ushort lo = 0x016E; lo <= 0x0197; lo++)
|
||||
result[lo] = 0x10000000u | lo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,12 +72,20 @@ public static class MotionCommand
|
|||
/// regular SetCycle transition.
|
||||
/// </summary>
|
||||
public const uint FallDown = 0x10000050u;
|
||||
/// <summary>0x10000057 — Dead.</summary>
|
||||
public const uint Dead = 0x10000057u;
|
||||
/// <summary>0x40000011 - persistent dead substate.</summary>
|
||||
public const uint Dead = 0x40000011u;
|
||||
/// <summary>0x10000057 - Sanctuary death-trigger action.</summary>
|
||||
public const uint Sanctuary = 0x10000057u;
|
||||
/// <summary>0x41000012 - crouching substate.</summary>
|
||||
public const uint Crouch = 0x41000012u;
|
||||
/// <summary>0x41000013 - sitting substate.</summary>
|
||||
public const uint Sitting = 0x41000013u;
|
||||
/// <summary>0x41000014 - sleeping substate.</summary>
|
||||
public const uint Sleeping = 0x41000014u;
|
||||
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
|
||||
public const uint CrouchLowerBound = 0x41000011u;
|
||||
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperBound = 0x41000014u;
|
||||
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperExclusive = 0x41000015u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -819,7 +827,7 @@ public sealed class MotionInterpreter
|
|||
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
|
||||
/// return 0x49
|
||||
/// uVar1 = InterpretedState.ForwardCommand
|
||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
|
||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
|
||||
/// return 0x48
|
||||
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
||||
/// return 0x48
|
||||
|
|
@ -850,7 +858,7 @@ public sealed class MotionInterpreter
|
|||
return false;
|
||||
|
||||
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
|
||||
return false;
|
||||
|
||||
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
||||
|
|
|
|||
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tick steering for server-controlled remote creatures while a
|
||||
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
||||
/// is the active locomotion source.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
||||
/// stabilizer. With the full MoveTo path payload now captured on
|
||||
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
||||
/// the body solver has the destination + heading + thresholds it needs to
|
||||
/// run the retail per-tick loop instead of waiting for sparse
|
||||
/// UpdatePosition snap corrections.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail references:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
||||
/// per-tick driver. Computes heading-to-target, fires an aux
|
||||
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
||||
/// orientation when within tolerance, and tests arrival via
|
||||
/// <c>dist <= min_distance</c> (chase) or
|
||||
/// <c>dist >= distance_to_object</c> (flee).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
||||
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
||||
/// MoveToManager itself does NOT touch the body. The body's actual
|
||||
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
||||
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
||||
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
||||
/// the body's orientation.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Acdream port scope: minimum viable subset. We skip target re-tracking
|
||||
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
|
||||
/// StickTo, fail-distance progress detector, and the sphere-cylinder
|
||||
/// distance variant — all server-side concerns the local body doesn't need
|
||||
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
|
||||
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
|
||||
/// arrival detection via <c>min_distance</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE divergence: ACE swaps the chase/flee arrival predicates
|
||||
/// (<c>dist <= DistanceToObject</c> vs retail's <c>dist <= MinDistance</c>).
|
||||
/// We follow retail.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RemoteMoveToDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Heading tolerance below which we snap orientation directly to the
|
||||
/// target heading (ACE's <c>set_heading(target, true)</c>
|
||||
/// server-tic-rate fudge). Above tolerance we rotate at
|
||||
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
||||
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
||||
/// </summary>
|
||||
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Default angular rate for in-motion heading correction when delta
|
||||
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
||||
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
||||
/// when the per-creature value differs, the future port can wire it
|
||||
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
||||
/// </summary>
|
||||
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Float-comparison slack for the arrival predicate. With
|
||||
/// <c>min_distance == 0</c> in a chase packet, exact equality is
|
||||
/// unreachable due to integration wobble; this epsilon prevents the
|
||||
/// driver from over-shooting by a sub-meter and snap-flipping back.
|
||||
/// </summary>
|
||||
public const float ArrivalEpsilon = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum staleness (seconds) of the most recent MoveTo packet
|
||||
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
|
||||
/// during active chase; if no fresh packet arrives for this long,
|
||||
/// the entity has likely either left our streaming view, switched
|
||||
/// to a non-MoveTo motion the server's broadcast didn't reach us
|
||||
/// for, or had its move cancelled server-side without our seeing
|
||||
/// the cancel UM. In any of those cases, continuing to drive the
|
||||
/// body toward a stale destination produces the "monster runs in
|
||||
/// place after popping back into view" symptom (2026-04-28).
|
||||
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
|
||||
/// while still failing fast on real loss-of-state.
|
||||
/// </summary>
|
||||
public const double StaleDestinationSeconds = 1.5;
|
||||
|
||||
public enum DriveResult
|
||||
{
|
||||
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
||||
Arrived,
|
||||
/// <summary>Steering active — caller should let
|
||||
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
||||
Steering,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
||||
/// and report whether the body has arrived or should keep running.
|
||||
/// Pure function — emits the updated orientation via
|
||||
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
||||
/// caller assigns the new value back to its body).
|
||||
/// </summary>
|
||||
/// <param name="minDistance">
|
||||
/// <c>min_distance</c> from the wire's MovementParameters block —
|
||||
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
||||
/// </param>
|
||||
/// <param name="distanceToObject">
|
||||
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
||||
/// threshold (default 0.6 m, the melee range). The actual arrival
|
||||
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
||||
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
||||
/// ACE puts the value in <c>distance_to_object</c> with
|
||||
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
||||
/// chase packets never arrive — the body keeps re-targeting around
|
||||
/// the player at melee range and visibly oscillates between facings,
|
||||
/// which is the user-reported "monster keeps running in different
|
||||
/// directions when it should be attacking" symptom (2026-04-28).
|
||||
/// </param>
|
||||
public static DriveResult Drive(
|
||||
Vector3 bodyPosition,
|
||||
Quaternion bodyOrientation,
|
||||
Vector3 destinationWorld,
|
||||
float minDistance,
|
||||
float distanceToObject,
|
||||
float dt,
|
||||
bool moveTowards,
|
||||
out Quaternion newOrientation)
|
||||
{
|
||||
// Horizontal distance only — server owns Z, our body Z is
|
||||
// hard-snapped to the latest UpdatePosition.
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
|
||||
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
|
||||
// MoveToManager.cs:476:
|
||||
//
|
||||
// chase (MoveTowards): dist <= distance_to_object
|
||||
// flee (MoveAway): dist >= min_distance
|
||||
//
|
||||
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
|
||||
// defensive guess; cross-checked with two independent research
|
||||
// agents against the named retail decomp + ACE port + holtburger,
|
||||
// the chase threshold is unambiguously DistanceToObject —
|
||||
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
|
||||
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
|
||||
// at melee range, not run to zero.)
|
||||
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
|
||||
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
|
||||
// Degenerate — already on target horizontally; preserve heading.
|
||||
if (dist < 1e-4f)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
|
||||
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
|
||||
// World forward = Transform((0,1,0), orientation). Yaw extracted
|
||||
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
|
||||
var localForward = new Vector3(0f, 1f, 0f);
|
||||
var worldForward = Vector3.Transform(localForward, bodyOrientation);
|
||||
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
|
||||
|
||||
// Desired heading: face the target. (dx, dy) is the world-space
|
||||
// offset to the target. With local-forward=+Y we want yaw such
|
||||
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
|
||||
// to yaw = atan2(-dx, dy).
|
||||
float desiredYaw = MathF.Atan2(-dx, dy);
|
||||
float delta = WrapPi(desiredYaw - currentYaw);
|
||||
|
||||
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
|
||||
{
|
||||
// ACE's set_heading(target, true) — sync to server-tic-rate.
|
||||
// We have the same sparse-UP problem ACE does, so the same
|
||||
// fudge applies.
|
||||
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
|
||||
// rotate at TurnRate clamped to dt, in the shorter direction.
|
||||
float maxStep = TurnRateRadPerSec * dt;
|
||||
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
// Apply incremental yaw around world +Z (preserving any
|
||||
// server-supplied pitch/roll from the latest UpdatePosition).
|
||||
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
|
||||
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
|
||||
}
|
||||
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a landblock-local Origin from a MoveTo packet
|
||||
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
||||
/// into acdream's render world space using the same arithmetic as
|
||||
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
||||
/// from the live-mode center.
|
||||
/// </summary>
|
||||
public static Vector3 OriginToWorld(
|
||||
uint originCellId,
|
||||
float originX,
|
||||
float originY,
|
||||
float originZ,
|
||||
int liveCenterLandblockX,
|
||||
int liveCenterLandblockY)
|
||||
{
|
||||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
originX + (lbX - liveCenterLandblockX) * 192f,
|
||||
originY + (lbY - liveCenterLandblockY) * 192f,
|
||||
originZ);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cap horizontal velocity so the body lands exactly at
|
||||
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
||||
/// it during the final tick of approach. Without this clamp, a body
|
||||
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
|
||||
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
|
||||
/// 60 fps) — visible as the creature "running slightly through" the
|
||||
/// player it's about to attack (user-reported 2026-04-28).
|
||||
///
|
||||
/// <para>
|
||||
/// The clamp is a strict scale-down of the horizontal component
|
||||
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
||||
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
||||
/// no-op since fleeing has no overshoot risk — the body wants to
|
||||
/// move AWAY from the destination.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static Vector3 ClampApproachVelocity(
|
||||
Vector3 bodyPosition,
|
||||
Vector3 currentVelocity,
|
||||
Vector3 destinationWorld,
|
||||
float arrivalThreshold,
|
||||
float dt,
|
||||
bool moveTowards)
|
||||
{
|
||||
if (!moveTowards || dt <= 0f) return currentVelocity;
|
||||
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
float remaining = MathF.Max(0f, dist - arrivalThreshold);
|
||||
|
||||
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
|
||||
+ currentVelocity.Y * currentVelocity.Y);
|
||||
if (vxy < 1e-3f) return currentVelocity;
|
||||
|
||||
float advance = vxy * dt;
|
||||
if (advance <= remaining) return currentVelocity;
|
||||
|
||||
// Already inside or right at the threshold: zero horizontal
|
||||
// velocity, keep Z. (The arrival predicate in Drive() should
|
||||
// have fired this tick, but this is the belt-and-braces guard.)
|
||||
if (remaining < 1e-3f)
|
||||
return new Vector3(0f, 0f, currentVelocity.Z);
|
||||
|
||||
float scale = remaining / advance;
|
||||
return new Vector3(
|
||||
currentVelocity.X * scale,
|
||||
currentVelocity.Y * scale,
|
||||
currentVelocity.Z);
|
||||
}
|
||||
|
||||
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
||||
private static float WrapPi(float r)
|
||||
{
|
||||
const float TwoPi = MathF.PI * 2f;
|
||||
r %= TwoPi;
|
||||
if (r > MathF.PI) r -= TwoPi;
|
||||
if (r < -MathF.PI) r += TwoPi;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the visible locomotion cycle for server-controlled remotes whose
|
||||
/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an
|
||||
/// InterpretedMotionState.
|
||||
///
|
||||
/// Retail references:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>MovementManager::PerformMovement</c> (0x00524440) dispatches movement
|
||||
/// types 6/7 into <c>MoveToManager::MoveToObject/MoveToPosition</c> instead
|
||||
/// of unpacking an InterpretedMotionState.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>MovementParameters::UnPackNet</c> (0x0052AC50) shows MoveTo packets
|
||||
/// carry movement params + run rate, not a ForwardCommand field.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// ACE <c>MovementData.Write</c> uses the same movement type union; holtburger
|
||||
/// documents the matching <c>MovementType::MoveToPosition = 7</c>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class ServerControlledLocomotion
|
||||
{
|
||||
public const float StopSpeed = 0.20f;
|
||||
public const float RunThreshold = 1.25f;
|
||||
public const float MinSpeedMod = 0.25f;
|
||||
public const float MaxSpeedMod = 3.00f;
|
||||
|
||||
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
|
||||
// (0x0052AA00) seeds forward motion before the next position update.
|
||||
public static LocomotionCycle PlanMoveToStart(
|
||||
float moveToSpeed = 1f,
|
||||
float runRate = 1f,
|
||||
bool canRun = true)
|
||||
{
|
||||
moveToSpeed = SanitizePositive(moveToSpeed);
|
||||
runRate = SanitizePositive(runRate);
|
||||
|
||||
if (!canRun)
|
||||
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
|
||||
|
||||
return new LocomotionCycle(
|
||||
MotionCommand.RunForward,
|
||||
moveToSpeed * runRate,
|
||||
true);
|
||||
}
|
||||
|
||||
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
|
||||
{
|
||||
float horizontalSpeed = MathF.Sqrt(
|
||||
worldVelocity.X * worldVelocity.X +
|
||||
worldVelocity.Y * worldVelocity.Y);
|
||||
|
||||
if (horizontalSpeed < StopSpeed)
|
||||
return new LocomotionCycle(MotionCommand.Ready, 1f, false);
|
||||
|
||||
if (horizontalSpeed < RunThreshold)
|
||||
{
|
||||
float speedMod = Math.Clamp(
|
||||
horizontalSpeed / MotionInterpreter.WalkAnimSpeed,
|
||||
MinSpeedMod,
|
||||
MaxSpeedMod);
|
||||
return new LocomotionCycle(MotionCommand.WalkForward, speedMod, true);
|
||||
}
|
||||
|
||||
return new LocomotionCycle(
|
||||
MotionCommand.RunForward,
|
||||
Math.Clamp(horizontalSpeed / MotionInterpreter.RunAnimSpeed, MinSpeedMod, MaxSpeedMod),
|
||||
true);
|
||||
}
|
||||
|
||||
public readonly record struct LocomotionCycle(
|
||||
uint Motion,
|
||||
float SpeedMod,
|
||||
bool IsMoving);
|
||||
|
||||
private static float SanitizePositive(float value)
|
||||
{
|
||||
return float.IsFinite(value) && value > 0f ? value : 1f;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue