fix(combat): persist death and retarget on kill
Keep the velocity-only NPC adaptation inside the locomotion family so authoritative Dead motion remains persistent. Route selection clears through a dedicated combat target controller that reacquires the closest eligible creature when retail Auto Target conditions apply. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
5276a83087
commit
9b97102c67
10 changed files with 281 additions and 19 deletions
72
src/AcDream.App/Combat/CombatTargetController.cs
Normal file
72
src/AcDream.App/Combat/CombatTargetController.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Owns combat-target lifecycle around the shared selection state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>ClientCombatSystem::RecvNotice_SelectionChanged</c>
|
||||
/// (0x0056BD80) invokes <c>AutoTarget</c> when selection becomes zero while
|
||||
/// Auto Target is enabled in Melee/Missile combat. Authoritative Dead motion
|
||||
/// makes the selected combat object unavailable and enters that same notice
|
||||
/// path.
|
||||
/// </remarks>
|
||||
public sealed class CombatTargetController : IDisposable
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<bool> _autoTarget;
|
||||
private readonly Func<uint?> _selectClosestTarget;
|
||||
private bool _disposed;
|
||||
|
||||
public CombatTargetController(
|
||||
CombatState combat,
|
||||
SelectionState selection,
|
||||
Func<bool> autoTarget,
|
||||
Func<uint?> selectClosestTarget)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_autoTarget = autoTarget ?? throw new ArgumentNullException(nameof(autoTarget));
|
||||
_selectClosestTarget = selectClosestTarget
|
||||
?? throw new ArgumentNullException(nameof(selectClosestTarget));
|
||||
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after an accepted UpdateMotion has reached the entity's motion
|
||||
/// table. Dead is a persistent substate, so CurrentMotion is the
|
||||
/// authoritative availability signal rather than a damage prediction.
|
||||
/// </summary>
|
||||
public void OnMotionApplied(uint objectId, uint currentMotion)
|
||||
{
|
||||
if (currentMotion != MotionCommand.Dead
|
||||
|| _selection.SelectedObjectId != objectId)
|
||||
return;
|
||||
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.CombatTargetDied);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition transition)
|
||||
{
|
||||
if (transition.SelectedObjectId is not null
|
||||
|| !_autoTarget()
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
return;
|
||||
|
||||
_selectClosestTarget();
|
||||
}
|
||||
}
|
||||
|
|
@ -761,6 +761,7 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
|
|
@ -1991,6 +1992,11 @@ public sealed class GameWindow : IDisposable
|
|||
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
|
||||
playerReadyForAttack: IsPlayerReadyForCombatAttack,
|
||||
autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
|
||||
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
|
||||
Combat,
|
||||
_selection,
|
||||
autoTarget: () => _persistedGameplay.AutoTarget,
|
||||
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
|
||||
|
||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||
if (_options.RetailUi)
|
||||
|
|
@ -5125,6 +5131,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Authoritative Dead motion invalidates a selected combat target.
|
||||
// The controller clears shared selection, whose SelectionChanged
|
||||
// consumer ports retail's post-clear AutoTarget behavior.
|
||||
_combatTargetController?.OnMotionApplied(
|
||||
update.Guid, ae.Sequencer.CurrentMotion);
|
||||
|
||||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||
// the stop-detection loop in TickAnimations sees the previous
|
||||
|
|
@ -5286,12 +5298,6 @@ public sealed class GameWindow : IDisposable
|
|||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||
}
|
||||
|
||||
private static bool IsRemoteLocomotion(uint motion)
|
||||
{
|
||||
uint low = motion & 0xFFu;
|
||||
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
|
||||
}
|
||||
|
||||
private void ApplyServerControlledVelocityCycle(
|
||||
uint serverGuid,
|
||||
AnimatedEntity ae,
|
||||
|
|
@ -5328,11 +5334,16 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||
// AP-80 is a position-only compatibility adaptation, not an
|
||||
// animation authority. Restrict it to Ready/Walk/Run so late death
|
||||
// position deltas cannot replace the authoritative Dead substate.
|
||||
if (!AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.CanApplyVelocityCycle(currentMotion))
|
||||
return;
|
||||
|
||||
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanFromVelocity(velocity);
|
||||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||
if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion))
|
||||
return;
|
||||
|
||||
uint style = ae.Sequencer.CurrentStyle != 0
|
||||
? ae.Sequencer.CurrentStyle
|
||||
|
|
@ -9949,11 +9960,10 @@ public sealed class GameWindow : IDisposable
|
|||
// stop signal flows the same way as any other motion change:
|
||||
// alt releases W → MoveToState(Ready) → ACE broadcasts
|
||||
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
|
||||
// On our side, both are handled: UpdateMotion routes through
|
||||
// MotionInterpreter.DoInterpretedMotion which zeroes the
|
||||
// interpreter's state, and UpdatePosition's HasVelocity~0 hits
|
||||
// MotionInterpreter.StopCompletely. Anything else is server
|
||||
// buggery (packet loss, ACE bug) — don't guess client-side.
|
||||
// On our side, only UpdateMotion changes interpreted state;
|
||||
// UpdatePosition updates physics pose/velocity and never selects an
|
||||
// animation. Anything else is server buggery (packet loss, ACE bug)
|
||||
// — don't guess client-side.
|
||||
var now = System.DateTime.UtcNow;
|
||||
|
||||
foreach (var kv in _animatedEntities)
|
||||
|
|
@ -12575,6 +12585,11 @@ public sealed class GameWindow : IDisposable
|
|||
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||||
return false;
|
||||
|
||||
if (_entitiesByServerGuid.TryGetValue(guid, out var entity)
|
||||
&& _animatedEntities.TryGetValue(entity.Id, out var animated)
|
||||
&& animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead)
|
||||
return false;
|
||||
|
||||
return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
|
|
@ -13616,6 +13631,8 @@ public sealed class GameWindow : IDisposable
|
|||
// UI resource is still alive. UiHost disposes controllers before its renderer.
|
||||
_retailUiRuntime?.Dispose();
|
||||
_retailUiRuntime = null;
|
||||
_combatTargetController?.Dispose();
|
||||
_combatTargetController = null;
|
||||
_combatAttackController?.Dispose();
|
||||
_combatAttackController = null;
|
||||
_uiHost = null;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,21 @@ public static class ServerControlledLocomotion
|
|||
true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Limits the AP-80 position-velocity adaptation to the locomotion state
|
||||
/// family it exists to supply. Retail UpdatePosition never selects a
|
||||
/// motion at all; therefore an authoritative action/substate such as Dead
|
||||
/// must never be replaced by this adaptation.
|
||||
/// </summary>
|
||||
public static bool CanApplyVelocityCycle(uint currentMotion)
|
||||
=> currentMotion == MotionCommand.Ready || IsLocomotion(currentMotion);
|
||||
|
||||
public static bool IsLocomotion(uint motion)
|
||||
{
|
||||
uint low = motion & 0xFFu;
|
||||
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
|
||||
}
|
||||
|
||||
public readonly record struct LocomotionCycle(
|
||||
uint Motion,
|
||||
float SpeedMod,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public enum SelectionChangeReason
|
|||
Selected,
|
||||
Cleared,
|
||||
SelectedObjectRemoved,
|
||||
CombatTargetDied,
|
||||
PreviousSelection,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue