refactor(input): extract the gameplay frame
This commit is contained in:
parent
0bc9fda9de
commit
c557038353
24 changed files with 2433 additions and 559 deletions
209
tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs
Normal file
209
tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class CombatAttackTargetSourceTests
|
||||
{
|
||||
private const uint Player = 0x5000_0001u;
|
||||
|
||||
[Fact]
|
||||
public void ExplicitSelectedHostileIsAcceptedWithoutAutoTarget()
|
||||
{
|
||||
var harness = new Harness();
|
||||
const uint target = 0x7000_0001u;
|
||||
harness.Add(target, new Vector3(2f, 0f, 0f), attackable: true);
|
||||
harness.Selection.Select(target, SelectionChangeSource.Keyboard);
|
||||
|
||||
Assert.Equal(
|
||||
target,
|
||||
harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoTargetUsesNearestEligibleLiveHostile()
|
||||
{
|
||||
var harness = new Harness();
|
||||
const uint valid = 0x7000_0010u;
|
||||
harness.Add(valid, new Vector3(8f, 0f, 0f), attackable: true);
|
||||
harness.Add(0x7000_0011u, new Vector3(2f, 0f, 0f), attackable: false);
|
||||
WorldEntity dead = harness.Add(
|
||||
0x7000_0012u,
|
||||
new Vector3(3f, 0f, 0f),
|
||||
attackable: true);
|
||||
harness.Runtime.SetAnimationRuntime(
|
||||
dead.ServerGuid,
|
||||
new Animation(dead, MotionCommand.Dead));
|
||||
WorldEntity hidden = harness.Add(
|
||||
0x7000_0013u,
|
||||
new Vector3(4f, 0f, 0f),
|
||||
attackable: true);
|
||||
Assert.True(harness.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
hidden.ServerGuid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2),
|
||||
out _));
|
||||
WorldEntity pending = harness.Add(
|
||||
0x7000_0014u,
|
||||
new Vector3(5f, 0f, 0f),
|
||||
attackable: true);
|
||||
Assert.True(harness.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid));
|
||||
|
||||
uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget(
|
||||
autoTarget: true);
|
||||
|
||||
Assert.Equal(valid, selected);
|
||||
Assert.Equal(valid, harness.Selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingAutoTargetClearsAnInvalidSelection()
|
||||
{
|
||||
var harness = new Harness();
|
||||
const uint nonHostile = 0x7000_0020u;
|
||||
harness.Add(nonHostile, Vector3.UnitX, attackable: false);
|
||||
harness.Selection.Select(nonHostile, SelectionChangeSource.Keyboard);
|
||||
|
||||
Assert.Null(harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: true));
|
||||
|
||||
Assert.Null(harness.Selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public ClientObjectTable Objects { get; } = new();
|
||||
public SelectionState Selection { get; } = new();
|
||||
public LiveEntityRuntime Runtime { get; }
|
||||
public CombatAttackTargetSource Targets { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101_FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Runtime = new LiveEntityRuntime(spatial, new Resources());
|
||||
var identity = new LocalPlayerIdentityState { ServerGuid = Player };
|
||||
Targets = new CombatAttackTargetSource(
|
||||
Selection,
|
||||
Runtime,
|
||||
Objects,
|
||||
identity);
|
||||
Add(Player, Vector3.Zero, attackable: false, isPlayer: true);
|
||||
}
|
||||
|
||||
public WorldEntity Add(
|
||||
uint guid,
|
||||
Vector3 position,
|
||||
bool attackable,
|
||||
bool isPlayer = false)
|
||||
{
|
||||
Runtime.RegisterLiveEntity(Spawn(guid));
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x0101_0001u,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = position,
|
||||
Rotation = Quaternion.Identity,
|
||||
Scale = 1f,
|
||||
MeshRefs = [],
|
||||
})!;
|
||||
uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u;
|
||||
if (isPlayer)
|
||||
flags |= SelectedObjectHealthPolicy.BfPlayer;
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
Name = $"Object {guid:X8}",
|
||||
Type = ItemType.Creature,
|
||||
PublicWeenieBitfield = flags,
|
||||
});
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Animation(WorldEntity Entity, uint CurrentMotion)
|
||||
: ILiveEntityAnimationRuntime;
|
||||
|
||||
private sealed class Resources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity)
|
||||
{
|
||||
}
|
||||
public void Unregister(WorldEntity entity)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x0101_0001u,
|
||||
10f,
|
||||
10f,
|
||||
5f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x0200_0001u,
|
||||
MotionTableId: 0x0900_0001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1));
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x0200_0001u,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x0900_0001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class LiveCombatAttackOperationsTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnboundSlotFailsClosedAndRejectsASecondOwner()
|
||||
{
|
||||
var slot = new CombatAttackOperationsSlot();
|
||||
var first = new FakeOperations();
|
||||
var second = new FakeOperations();
|
||||
|
||||
Assert.False(slot.CanStartAttack());
|
||||
Assert.False(slot.SendAttack(AttackHeight.Medium, 0.5f));
|
||||
Assert.False(slot.PlayerReadyForAttack);
|
||||
|
||||
slot.Bind(first);
|
||||
slot.Bind(first);
|
||||
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanStartRequiresLiveWorldBeforeTargetResolution()
|
||||
{
|
||||
Harness harness = CreateHarness();
|
||||
harness.Targets.Acquired = 0x1234u;
|
||||
|
||||
Assert.False(harness.Owner.CanStartAttack());
|
||||
|
||||
Assert.Equal(0, harness.Targets.ResolveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedCombatModeUsesTypedFeedbackSink()
|
||||
{
|
||||
Harness harness = CreateHarness(inWorld: true);
|
||||
|
||||
Assert.False(harness.Owner.CanStartAttack());
|
||||
|
||||
Assert.Equal(["Enter melee or missile combat first"], harness.Feedback.Messages);
|
||||
Assert.Equal(0, harness.Targets.ResolveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportedModeResolvesTargetUsingLiveAutoTargetSetting()
|
||||
{
|
||||
Harness harness = CreateHarness(inWorld: true);
|
||||
harness.Combat.SetCombatMode(CombatMode.Melee);
|
||||
harness.Settings.AutoTarget = true;
|
||||
harness.Targets.Acquired = 0x1234u;
|
||||
harness.Targets.SelectedObjectId = 0x1234u;
|
||||
|
||||
Assert.True(harness.Owner.CanStartAttack());
|
||||
|
||||
Assert.True(harness.Targets.LastAutoTarget);
|
||||
Assert.Equal(1, harness.Targets.ResolveCount);
|
||||
Assert.Empty(harness.Feedback.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTargetPreservesRetailUserFeedback()
|
||||
{
|
||||
Harness harness = CreateHarness(inWorld: true);
|
||||
harness.Combat.SetCombatMode(CombatMode.Missile);
|
||||
|
||||
Assert.False(harness.Owner.CanStartAttack());
|
||||
|
||||
Assert.Equal(["No monster target"], harness.Feedback.Messages);
|
||||
}
|
||||
|
||||
private static Harness CreateHarness(bool inWorld = false)
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var targets = new FakeTargets();
|
||||
var settings = new FakeSettings();
|
||||
var player = new LocalPlayerControllerSlot();
|
||||
var live = new FakeLiveSource { IsInWorld = inWorld };
|
||||
var feedback = new FakeFeedback();
|
||||
var outbound = new LocalPlayerOutboundController(
|
||||
static (_, _, _, _, _, _) => { });
|
||||
var owner = new LiveCombatAttackOperations(
|
||||
combat,
|
||||
targets,
|
||||
settings,
|
||||
player,
|
||||
outbound,
|
||||
live,
|
||||
live,
|
||||
feedback);
|
||||
return new Harness(owner, combat, targets, settings, feedback);
|
||||
}
|
||||
|
||||
private sealed record Harness(
|
||||
LiveCombatAttackOperations Owner,
|
||||
CombatState Combat,
|
||||
FakeTargets Targets,
|
||||
FakeSettings Settings,
|
||||
FakeFeedback Feedback);
|
||||
|
||||
private sealed class FakeTargets : ICombatAttackTargetSource
|
||||
{
|
||||
public uint? SelectedObjectId { get; set; }
|
||||
public uint? Acquired { get; set; }
|
||||
public int ResolveCount { get; private set; }
|
||||
public bool LastAutoTarget { get; private set; }
|
||||
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
||||
{
|
||||
ResolveCount++;
|
||||
LastAutoTarget = autoTarget;
|
||||
return Acquired;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettings : ICombatGameplaySettingsSource
|
||||
{
|
||||
public bool AutoTarget { get; set; }
|
||||
public bool AutoRepeatAttack { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource
|
||||
{
|
||||
public bool IsInWorld { get; set; }
|
||||
public WorldSession? CurrentSession => null;
|
||||
}
|
||||
|
||||
private sealed class FakeFeedback : ICombatFeedbackSink
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
public void Show(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
private sealed class FakeOperations : ICombatAttackOperations
|
||||
{
|
||||
public bool CanStartAttack() => true;
|
||||
public void PrepareAttackRequest()
|
||||
{
|
||||
}
|
||||
public bool SendAttack(AttackHeight height, float power) => true;
|
||||
public void SendCancelAttack()
|
||||
{
|
||||
}
|
||||
public bool IsDualWield => true;
|
||||
public bool PlayerReadyForAttack => true;
|
||||
public bool AutoRepeatAttack => true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue