feat(anim): Phase L.1c classify combat animation commands
This commit is contained in:
parent
268af82e28
commit
831392a7b2
3 changed files with 420 additions and 0 deletions
68
docs/research/2026-04-28-combat-animation-planner.md
Normal file
68
docs/research/2026-04-28-combat-animation-planner.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
278
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
278
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
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.BowCombat
|
||||
or CombatAnimationMotionCommands.CrossbowCombat
|
||||
or CombatAnimationMotionCommands.SlingCombat
|
||||
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 => 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 TwoHandedSwordCombat = 0x80000046u;
|
||||
public const uint SlingCombat = 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 = 0x10000173u;
|
||||
public const uint OffhandSlashMed = 0x10000174u;
|
||||
public const uint OffhandSlashLow = 0x10000175u;
|
||||
public const uint OffhandThrustHigh = 0x10000176u;
|
||||
public const uint OffhandThrustMed = 0x10000177u;
|
||||
public const uint OffhandThrustLow = 0x10000178u;
|
||||
public const uint OffhandDoubleSlashLow = 0x10000179u;
|
||||
public const uint OffhandDoubleSlashMed = 0x1000017Au;
|
||||
public const uint OffhandDoubleSlashHigh = 0x1000017Bu;
|
||||
public const uint OffhandTripleSlashLow = 0x1000017Cu;
|
||||
public const uint OffhandTripleSlashMed = 0x1000017Du;
|
||||
public const uint OffhandTripleSlashHigh = 0x1000017Eu;
|
||||
public const uint OffhandDoubleThrustLow = 0x1000017Fu;
|
||||
public const uint OffhandDoubleThrustMed = 0x10000180u;
|
||||
public const uint OffhandDoubleThrustHigh = 0x10000181u;
|
||||
public const uint OffhandTripleThrustLow = 0x10000182u;
|
||||
public const uint OffhandTripleThrustMed = 0x10000183u;
|
||||
public const uint OffhandTripleThrustHigh = 0x10000184u;
|
||||
public const uint OffhandKick = 0x10000185u;
|
||||
public const uint AttackHigh4 = 0x10000186u;
|
||||
public const uint AttackMed4 = 0x10000187u;
|
||||
public const uint AttackLow4 = 0x10000188u;
|
||||
public const uint AttackHigh5 = 0x10000189u;
|
||||
public const uint AttackMed5 = 0x1000018Au;
|
||||
public const uint AttackLow5 = 0x1000018Bu;
|
||||
public const uint AttackHigh6 = 0x1000018Cu;
|
||||
public const uint AttackMed6 = 0x1000018Du;
|
||||
public const uint AttackLow6 = 0x1000018Eu;
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Combat;
|
||||
|
||||
public sealed class CombatAnimationPlannerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed
|
||||
[InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh
|
||||
[InlineData(0x10000180u, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed
|
||||
[InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
|
||||
[InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
|
||||
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
|
||||
[InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6
|
||||
[InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell
|
||||
[InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff
|
||||
[InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1
|
||||
[InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward
|
||||
[InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead
|
||||
[InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat
|
||||
public void ClassifyMotionCommand_RecognisesRetailCombatCommands(
|
||||
uint command,
|
||||
CombatAnimationKind expected)
|
||||
{
|
||||
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Swing_IsActionOverlay()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f);
|
||||
|
||||
Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind);
|
||||
Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind);
|
||||
Assert.Equal(0x10000058u, plan.MotionCommand);
|
||||
Assert.Equal(1.25f, plan.SpeedMod);
|
||||
Assert.True(plan.HasMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Dead_IsPersistentSubState()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011);
|
||||
|
||||
Assert.Equal(CombatAnimationKind.Death, plan.Kind);
|
||||
Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind);
|
||||
Assert.Equal(MotionCommand.Dead, plan.MotionCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Unknown_IsNone()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF);
|
||||
|
||||
Assert.Equal(CombatAnimationPlan.None, plan);
|
||||
Assert.False(plan.HasMotion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CombatAnimationEvent.CombatCommenceAttack)]
|
||||
[InlineData(CombatAnimationEvent.AttackDone)]
|
||||
[InlineData(CombatAnimationEvent.AttackerNotification)]
|
||||
[InlineData(CombatAnimationEvent.DefenderNotification)]
|
||||
[InlineData(CombatAnimationEvent.EvasionAttackerNotification)]
|
||||
[InlineData(CombatAnimationEvent.EvasionDefenderNotification)]
|
||||
[InlineData(CombatAnimationEvent.VictimNotification)]
|
||||
[InlineData(CombatAnimationEvent.KillerNotification)]
|
||||
public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent)
|
||||
{
|
||||
Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue