acdream/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Erik cdee7a4b49 refactor(runtime): close simulation ownership
Move remote-motion construction, CreateObject vector initialization, final simulation-component retirement, and the combined J5 ownership ledger into Runtime. Delete App compatibility views and moved-state reconstruction while preserving the existing graphical projection and retail update order.
2026-07-26 15:53:31 +02:00

235 lines
7.8 KiB
C#

using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
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; }
bool ViewCombatTarget { get; }
}
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
{
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
public bool AutoTarget => Value.AutoTarget;
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
public bool ViewCombatTarget => Value.ViewCombatTarget;
}
internal interface ICombatFeedbackSink
{
void Show(string message);
}
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
{
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel))
throw new InvalidOperationException(
"Combat feedback is already bound to a developer view model.");
_viewModel = viewModel;
}
public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
{
ArgumentNullException.ThrowIfNull(viewModel);
if (ReferenceEquals(_viewModel, viewModel))
_viewModel = null;
}
public void Show(string message) => _viewModel?.AddToast(message);
}
internal sealed class CombatAttackOperationsSlot
: IRuntimeCombatAttackOperations
{
private IRuntimeCombatAttackOperations? _owner;
public void Bind(IRuntimeCombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null && !ReferenceEquals(_owner, owner))
throw new InvalidOperationException(
"Combat attack operations are already bound.");
_owner = owner;
}
public IDisposable BindOwned(IRuntimeCombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Combat attack operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeCombatAttackOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
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;
private sealed class Binding : IDisposable
{
private CombatAttackOperationsSlot? _slot;
private readonly IRuntimeCombatAttackOperations _expected;
public Binding(
CombatAttackOperationsSlot slot,
IRuntimeCombatAttackOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
/// <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
: IRuntimeCombatAttackOperations
{
private readonly CombatState _combat;
private readonly ICombatAttackTargetSource _targets;
private readonly ICombatGameplaySettingsSource _settings;
private readonly IRuntimeLocalPlayerControllerSource _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,
IRuntimeLocalPlayerControllerSource 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));
}
}