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.
235 lines
7.8 KiB
C#
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));
|
|
}
|
|
}
|