refactor(input): extract the gameplay frame

This commit is contained in:
Erik 2026-07-22 01:38:40 +02:00
parent 0bc9fda9de
commit c557038353
24 changed files with 2433 additions and 559 deletions

View file

@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Combat;
internal interface ICombatAttackOperations
{
bool CanStartAttack();
void PrepareAttackRequest();
bool SendAttack(AttackHeight height, float power);
void SendCancelAttack();
bool IsDualWield { get; }
bool PlayerReadyForAttack { get; }
bool AutoRepeatAttack { get; }
}
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
{
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
public DelegateCombatAttackOperations(
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest,
Action? sendCancelAttack,
Func<bool>? isDualWield,
Func<bool>? playerReadyForAttack,
Func<bool>? autoRepeatAttack)
{
_canStartAttack = canStartAttack
?? throw new ArgumentNullException(nameof(canStartAttack));
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
}
public bool CanStartAttack() => _canStartAttack();
public void PrepareAttackRequest() => _prepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
_sendAttack(height, power);
public void SendCancelAttack() => _sendCancelAttack();
public bool IsDualWield => _isDualWield();
public bool PlayerReadyForAttack => _playerReadyForAttack();
public bool AutoRepeatAttack => _autoRepeatAttack();
}
/// <summary>
/// Owns retail's basic-combat attack request and power-bar state machine.
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable
public const float DesiredPowerStep = 1f / 6f;
private readonly CombatState _combat;
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
private readonly ICombatAttackOperations _operations;
private readonly Func<double> _now;
private bool _buildInProgress;
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
Func<bool>? playerReadyForAttack = null,
Func<bool>? autoRepeatAttack = null,
Func<double>? now = null)
: this(
combat,
new DelegateCombatAttackOperations(
canStartAttack,
sendAttack,
prepareAttackRequest,
sendCancelAttack,
isDualWield,
playerReadyForAttack,
autoRepeatAttack),
now)
{
}
internal CombatAttackController(
CombatState combat,
ICombatAttackOperations operations,
Func<double>? now = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
_combat.CombatModeChanged += OnCombatModeChanged;
@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable
&& !_repeatAttacking)
return;
_sendCancelAttack();
_operations.SendCancelAttack();
_repeatAttacking = false;
if (_buildInProgress)
@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable
if (!_buildInProgress)
return;
if (!_playerReadyForAttack())
if (!_operations.PlayerReadyForAttack)
{
if (_attackRequestInProgress)
{
@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable
private void StartAttackRequest()
{
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|| !_canStartAttack())
|| !_operations.CanStartAttack())
return;
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable
// player movement object and must run before any later attack send.
_attackRequestInProgress = true;
_requestedAttackPower = 1f;
_prepareAttackRequest();
_operations.PrepareAttackRequest();
_currentBuildIsAutomatic = false;
AttemptStartBuildingAttack();
}
@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable
{
if (_buildInProgress
|| _attackServerResponsePending
|| !_playerReadyForAttack())
|| !_operations.PlayerReadyForAttack)
return;
StartPowerBarBuild();
}
@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable
{
if (!_buildInProgress)
return 0f;
double duration = _isDualWield()
double duration = _operations.IsDualWield
? DualWieldPowerUpSeconds
: AttackPowerUpSeconds;
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable
private void ExecuteAttack(AttackHeight height, bool setServerPending)
{
StopBuild();
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
if (!_operations.SendAttack(
height,
Math.Clamp(_requestedAttackPower, 0f, 1f)))
{
ResetPowerBar();
return;
}
if (_autoRepeatAttack())
if (_operations.AutoRepeatAttack)
_repeatAttacking = true;
_attackServerResponsePending = setServerPending;
}
@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable
_repeatAttacking = false;
if (!_attackRequestInProgress
&& _autoRepeatAttack()
&& _operations.AutoRepeatAttack
&& _repeatAttacking)
{
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable
ExecuteAttack(RequestedHeight, setServerPending: false);
}
if (!_autoRepeatAttack() || !_repeatAttacking)
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
{
_repeatAttacking = false;
ResetPowerBar();

View file

@ -0,0 +1,116 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.App.Combat;
/// <summary>
/// Focused automatic-attack target owner. It shares canonical selection and
/// live-entity state without retaining the broader interaction controller.
/// </summary>
internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
{
private readonly SelectionState _selection;
private readonly LiveEntityRuntime _liveEntities;
private readonly ClientObjectTable _objects;
private readonly ILocalPlayerIdentitySource _player;
public CombatAttackTargetSource(
SelectionState selection,
LiveEntityRuntime liveEntities,
ClientObjectTable objects,
ILocalPlayerIdentitySource player)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_player = player ?? throw new ArgumentNullException(nameof(player));
}
public uint? SelectedObjectId => _selection.SelectedObjectId;
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
&& IsHostileMonster(selected))
{
return selected;
}
if (!autoTarget)
return null;
(uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster();
if (closest is not { } best)
{
_selection.Clear(SelectionChangeSource.Keyboard);
return null;
}
_selection.Select(best.Guid, SelectionChangeSource.Keyboard);
string? name = _objects.Get(best.Guid)?.Name;
string label = string.IsNullOrWhiteSpace(name)
? $"0x{best.Guid:X8}"
: name;
Console.WriteLine(
$"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}");
return best.Guid;
}
private (uint Guid, float DistanceSquared)? FindClosestHostileMonster()
{
if (!_liveEntities.TryGetWorldEntity(
_player.ServerGuid,
out WorldEntity playerEntity))
{
return null;
}
(uint Guid, float DistanceSquared)? best = null;
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
{
if (!IsHostileMonster(guid))
continue;
float distanceSquared = Vector3.DistanceSquared(
entity.Position,
playerEntity.Position);
if (best is null || distanceSquared < best.Value.DistanceSquared)
best = (guid, distanceSquared);
}
return best;
}
private bool IsHostileMonster(uint serverGuid)
{
uint playerGuid = _player.ServerGuid;
if (serverGuid == playerGuid
|| !_liveEntities.TryGetInteractionEligibleRecord(
serverGuid,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return false;
}
if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation)
&& animation.CurrentMotion == MotionCommand.Dead)
{
return false;
}
ClientObject? candidate = _objects.Get(serverGuid);
return candidate is not null
&& (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Combat;
internal interface ICombatAttackTargetSource
{
uint? SelectedObjectId { get; }
uint? GetSelectedOrClosestCombatTarget(bool autoTarget);
}
internal interface ICombatGameplaySettingsSource
{
bool AutoTarget { get; }
bool AutoRepeatAttack { get; }
}
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
{
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
public bool AutoTarget => Value.AutoTarget;
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
}
internal interface ICombatFeedbackSink
{
void Show(string message);
}
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
{
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
public void Show(string message) => ViewModel?.AddToast(message);
}
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
{
private ICombatAttackOperations? _owner;
public void Bind(ICombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null && !ReferenceEquals(_owner, owner))
throw new InvalidOperationException(
"Combat attack operations are already bound.");
_owner = owner;
}
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
_owner?.SendAttack(height, power) == true;
public void SendCancelAttack() => _owner?.SendCancelAttack();
public bool IsDualWield => _owner?.IsDualWield == true;
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
}
/// <summary>
/// Production combat request dependencies. The attack state machine retains
/// this typed owner rather than callbacks into the application window.
/// </summary>
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
{
private readonly CombatState _combat;
private readonly ICombatAttackTargetSource _targets;
private readonly ICombatGameplaySettingsSource _settings;
private readonly ILocalPlayerControllerSource _player;
private readonly LocalPlayerOutboundController _outbound;
private readonly ILiveInWorldSource _inWorld;
private readonly ILiveWorldSessionSource _session;
private readonly ICombatFeedbackSink _feedback;
public LiveCombatAttackOperations(
CombatState combat,
ICombatAttackTargetSource targets,
ICombatGameplaySettingsSource settings,
ILocalPlayerControllerSource player,
LocalPlayerOutboundController outbound,
ILiveInWorldSource inWorld,
ILiveWorldSessionSource session,
ICombatFeedbackSink feedback)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_player = player ?? throw new ArgumentNullException(nameof(player));
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
_inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld));
_session = session ?? throw new ArgumentNullException(nameof(session));
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
}
public bool IsDualWield =>
_player.Controller?.Motion.InterpretedState.CurrentStyle
== CombatInputPlanner.DualWieldCombatStyle;
public bool PlayerReadyForAttack
{
get
{
if (_player.Controller is not { } controller)
return false;
var motion = controller.Motion.InterpretedState;
return CombatInputPlanner.PlayerInReadyPositionForAttack(
_combat.CurrentMode,
motion.CurrentStyle,
motion.ForwardCommand);
}
}
public bool AutoRepeatAttack => _settings.AutoRepeatAttack;
public bool CanStartAttack()
{
if (!_inWorld.IsInWorld)
return false;
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
{
_feedback.Show("Enter melee or missile combat first");
Console.WriteLine(
"combat: attack ignored; not in melee/missile combat mode");
return false;
}
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
{
_feedback.Show("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
return false;
}
return true;
}
public bool SendAttack(AttackHeight height, float power)
{
if (!CanStartAttack()
|| _session.CurrentSession is not { } session
|| _targets.SelectedObjectId is not { } target)
{
return false;
}
power = Math.Clamp(power, 0f, 1f);
if (_combat.CurrentMode == CombatMode.Missile)
{
session.SendMissileAttack(target, height, power);
Console.WriteLine(
$"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
session.SendMeleeAttack(target, height, power);
Console.WriteLine(
$"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
return true;
}
public void SendCancelAttack() =>
_session.CurrentSession?.SendCancelAttack();
public void PrepareAttackRequest()
{
if (_player.Controller is not { } controller
|| !controller.PrepareForAttackRequest())
{
return;
}
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
}
}