refactor(interaction): extract world selection query
Move render-hit validation, live target classification, closest-hostile search, selection spheres, use/pickup gates, range checks, and approach parameters behind a read-only App-layer query owner. GameWindow now supplies runtime seams and delegates instead of owning those algorithms.
This commit is contained in:
parent
52dbb5749e
commit
e74f2ca99a
5 changed files with 749 additions and 371 deletions
320
tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs
Normal file
320
tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
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.Interaction;
|
||||
|
||||
public sealed class WorldSelectionQueryTests
|
||||
{
|
||||
private const uint Player = 0x5000_0001u;
|
||||
private const uint Target = 0x7000_0001u;
|
||||
|
||||
private sealed class Resources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
|
||||
private sealed class GeometrySource(RetailSelectionMesh mesh)
|
||||
: IRetailSelectionGeometrySource
|
||||
{
|
||||
public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly LiveEntityRuntime Runtime;
|
||||
public readonly RetailSelectionScene Scene;
|
||||
public readonly WorldSelectionQuery Query;
|
||||
public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101_FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Runtime = new LiveEntityRuntime(spatial, new Resources());
|
||||
Scene = new RetailSelectionScene(new GeometrySource(Mesh()));
|
||||
Query = new WorldSelectionQuery(
|
||||
Runtime,
|
||||
Objects,
|
||||
Scene,
|
||||
() => Player,
|
||||
Camera,
|
||||
() => new Vector2(400f, 300f),
|
||||
() => PlayerPose,
|
||||
(_, _) => (0.5f, 2f),
|
||||
_ => (new Vector3(1f, 0f, 0f), 2f));
|
||||
|
||||
Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
|
||||
}
|
||||
|
||||
public WorldEntity Add(
|
||||
uint guid,
|
||||
Vector3 position,
|
||||
ItemType type,
|
||||
uint publicFlags = 0u,
|
||||
uint? useability = null,
|
||||
uint objectDescriptionFlags = 0u,
|
||||
ushort instance = 1,
|
||||
float scale = 1f,
|
||||
Quaternion? rotation = null)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
|
||||
{
|
||||
ItemType = (uint)type,
|
||||
Useability = useability,
|
||||
ObjectDescriptionFlags = objectDescriptionFlags,
|
||||
};
|
||||
Runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x0101_0001u,
|
||||
id => Entity(id, guid, position, scale, rotation ?? Quaternion.Identity))!;
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
Name = $"Object {guid:X8}",
|
||||
Type = type,
|
||||
PublicWeenieBitfield = publicFlags,
|
||||
});
|
||||
return entity;
|
||||
}
|
||||
|
||||
public void Publish(WorldEntity entity)
|
||||
{
|
||||
SelectionCameraSnapshot camera = Camera();
|
||||
Scene.BeginFrame();
|
||||
Scene.SetViewFrustum(FrustumPlanes.FromViewProjection(
|
||||
camera.View * camera.Projection));
|
||||
Scene.AddVisiblePart(
|
||||
entity,
|
||||
0,
|
||||
0x0100_0001u,
|
||||
Matrix4x4.CreateTranslation(entity.Position));
|
||||
Scene.CompleteFrame();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickRejectsPublishedPartAfterGuidWasReused()
|
||||
{
|
||||
var h = new Harness();
|
||||
WorldEntity oldTarget = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
|
||||
h.Publish(oldTarget);
|
||||
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
|
||||
|
||||
Assert.True(h.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Target, 1),
|
||||
isLocalPlayer: false));
|
||||
WorldEntity replacement = h.Add(
|
||||
Target,
|
||||
new Vector3(0f, 0f, -5f),
|
||||
ItemType.Misc,
|
||||
instance: 1);
|
||||
|
||||
Assert.NotEqual(oldTarget.Id, replacement.Id);
|
||||
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Add(
|
||||
0x7000_0010u,
|
||||
new Vector3(8f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
h.Add(
|
||||
0x7000_0011u,
|
||||
new Vector3(2f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
publicFlags: 0u);
|
||||
h.Add(
|
||||
0x7000_0012u,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Misc,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
|
||||
ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();
|
||||
|
||||
Assert.Equal(0x7000_0010u, closest?.ServerGuid);
|
||||
Assert.Equal(64f, closest?.DistanceSquared);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.59f, true)]
|
||||
[InlineData(0.61f, false)]
|
||||
[InlineData(7.49f, false)]
|
||||
[InlineData(7.50f, true)]
|
||||
public void ApproachUsesRetailGroundItemRadiusAndAceChargeBoundary(
|
||||
float distance,
|
||||
bool expectedBoundary)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Add(Target, new Vector3(distance, 0f, 0f), ItemType.Misc);
|
||||
|
||||
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
||||
|
||||
if (distance < 1f)
|
||||
Assert.Equal(expectedBoundary, approach.IsCloseRange);
|
||||
else
|
||||
Assert.Equal(expectedBoundary, approach.CanCharge);
|
||||
Assert.Equal(0.6f, approach.UseRadius);
|
||||
Assert.Equal(0.5f, approach.TargetRadius);
|
||||
Assert.Equal(2f, approach.TargetHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickupAndUseabilityPreserveIndependentRetailGates()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint stuckComponent = 0x7000_0020u;
|
||||
const uint looseComponent = 0x7000_0021u;
|
||||
h.Add(
|
||||
stuckComponent,
|
||||
Vector3.UnitX,
|
||||
ItemType.SpellComponents,
|
||||
useability: 1u,
|
||||
objectDescriptionFlags: 0x0004u);
|
||||
h.Add(
|
||||
looseComponent,
|
||||
Vector3.UnitX * 2f,
|
||||
ItemType.SpellComponents,
|
||||
useability: 1u);
|
||||
|
||||
Assert.False(h.Query.IsUseable(stuckComponent));
|
||||
Assert.False(h.Query.IsPickupable(stuckComponent));
|
||||
Assert.False(h.Query.IsUseable(looseComponent));
|
||||
Assert.True(h.Query.IsPickupable(looseComponent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectionSphereAppliesSetupOffsetScaleAndRotation()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Add(
|
||||
Target,
|
||||
new Vector3(10f, 20f, 3f),
|
||||
ItemType.Misc,
|
||||
scale: 2f,
|
||||
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
|
||||
Assert.True(h.Query.TryGetSelectionSphere(Target, out Vector3 center, out float radius));
|
||||
|
||||
Assert.True(Vector3.Distance(new Vector3(10f, 22f, 3f), center) < 0.0001f);
|
||||
Assert.Equal(4f, radius);
|
||||
}
|
||||
|
||||
private static SelectionCameraSnapshot Camera()
|
||||
{
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
||||
Vector3.Zero,
|
||||
-Vector3.UnitZ,
|
||||
Vector3.UnitY);
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 3f,
|
||||
4f / 3f,
|
||||
0.1f,
|
||||
100f);
|
||||
return new SelectionCameraSnapshot(view, projection, new Vector2(800f, 600f));
|
||||
}
|
||||
|
||||
private static RetailSelectionMesh Mesh()
|
||||
=> new(
|
||||
Vector3.Zero,
|
||||
2f,
|
||||
[new RetailSelectionPolygon(
|
||||
[
|
||||
new(-1f, -1f, 0f),
|
||||
new( 1f, -1f, 0f),
|
||||
new( 1f, 1f, 0f),
|
||||
new(-1f, 1f, 0f),
|
||||
],
|
||||
SingleSided: false)]);
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint guid,
|
||||
Vector3 position,
|
||||
float scale,
|
||||
Quaternion rotation)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = position,
|
||||
Rotation = rotation,
|
||||
Scale = scale,
|
||||
MeshRefs = [],
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
|
||||
{
|
||||
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, instance));
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x0200_0001u,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x0900_0001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime
|
||||
{
|
||||
public WorldEntity Entity { get; } = entity;
|
||||
public uint CurrentMotion { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EffectProfile : ILiveEntityEffectProfile { }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue