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
348
src/AcDream.App/Interaction/WorldSelectionQuery.cs
Normal file
348
src/AcDream.App/Interaction/WorldSelectionQuery.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal readonly record struct SelectionCameraSnapshot(
|
||||
Matrix4x4 View,
|
||||
Matrix4x4 Projection,
|
||||
Vector2 Viewport);
|
||||
|
||||
internal readonly record struct PlayerInteractionPose(uint CellId, Vector3 Position);
|
||||
|
||||
internal readonly record struct WorldInteractionTarget(
|
||||
uint ServerGuid,
|
||||
uint LocalEntityId,
|
||||
WorldEntity Entity);
|
||||
|
||||
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
||||
|
||||
internal readonly record struct InteractionApproach(
|
||||
WorldInteractionTarget Target,
|
||||
PlayerInteractionPose Player,
|
||||
float UseRadius,
|
||||
bool IsCloseRange,
|
||||
bool CanCharge,
|
||||
float TargetRadius,
|
||||
float TargetHeight);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only owner for retail world selection and interaction classification.
|
||||
/// It consumes the canonical live-entity runtime but never mutates selection,
|
||||
/// movement, inventory, or network state.
|
||||
/// </summary>
|
||||
internal sealed class WorldSelectionQuery
|
||||
{
|
||||
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
private const uint StuckObjectFlag = 0x0004u;
|
||||
private const float DefaultUseRadius = 0.6f;
|
||||
private const float CreatureUseRadius = 3f;
|
||||
private const float LargeObjectUseRadius = 2f;
|
||||
private const float AceCanChargeDistance = 7.5f;
|
||||
|
||||
private const uint SmallItemMask =
|
||||
(uint)(ItemType.MeleeWeapon
|
||||
| ItemType.Armor
|
||||
| ItemType.Clothing
|
||||
| ItemType.Jewelry
|
||||
| ItemType.Food
|
||||
| ItemType.Money
|
||||
| ItemType.Misc
|
||||
| ItemType.MissileWeapon
|
||||
| ItemType.Container
|
||||
| ItemType.Gem
|
||||
| ItemType.SpellComponents
|
||||
| ItemType.Writable
|
||||
| ItemType.Key
|
||||
| ItemType.Caster);
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly RetailSelectionScene _selectionScene;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<SelectionCameraSnapshot> _camera;
|
||||
private readonly Func<Vector2> _cursor;
|
||||
private readonly Func<PlayerInteractionPose?> _playerPose;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
||||
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
||||
|
||||
public WorldSelectionQuery(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ClientObjectTable objects,
|
||||
RetailSelectionScene selectionScene,
|
||||
Func<uint> playerGuid,
|
||||
Func<SelectionCameraSnapshot> camera,
|
||||
Func<Vector2> cursor,
|
||||
Func<PlayerInteractionPose?> playerPose,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
||||
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_selectionScene = selectionScene ?? throw new ArgumentNullException(nameof(selectionScene));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose));
|
||||
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
||||
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
||||
}
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
{
|
||||
Vector2 cursor = _cursor();
|
||||
return PickAt(cursor.X, cursor.Y, includeSelf);
|
||||
}
|
||||
|
||||
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
|
||||
{
|
||||
SelectionCameraSnapshot camera = _camera();
|
||||
RetailSelectionHit? hit = _selectionScene.Pick(
|
||||
mouseX,
|
||||
mouseY,
|
||||
camera.Viewport,
|
||||
camera.View,
|
||||
camera.Projection,
|
||||
includeSelf ? 0u : _playerGuid());
|
||||
return hit is { } found
|
||||
&& _liveEntities.TryGetInteractionEligibleRecord(
|
||||
found.ServerGuid,
|
||||
found.LocalEntityId,
|
||||
out _)
|
||||
? found.ServerGuid
|
||||
: null;
|
||||
}
|
||||
|
||||
public void BeginLightingPulse(uint serverGuid)
|
||||
{
|
||||
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
|
||||
}
|
||||
|
||||
public bool TryGetInteractionTarget(
|
||||
uint serverGuid,
|
||||
out WorldInteractionTarget target)
|
||||
{
|
||||
if (_liveEntities.TryGetInteractionEligibleRecord(
|
||||
serverGuid,
|
||||
out LiveEntityRecord record)
|
||||
&& record.WorldEntity is { } entity)
|
||||
{
|
||||
target = new WorldInteractionTarget(serverGuid, entity.Id, entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
target = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsCurrent(WorldInteractionTarget target)
|
||||
=> IsCurrent(target.ServerGuid, target.LocalEntityId);
|
||||
|
||||
public bool IsCurrent(uint serverGuid, uint localEntityId)
|
||||
=> _liveEntities.TryGetInteractionEligibleRecord(serverGuid, localEntityId, out _);
|
||||
|
||||
public ItemType GetItemType(uint serverGuid)
|
||||
=> _objects.Get(serverGuid)?.Type ?? ItemType.None;
|
||||
|
||||
public string Describe(uint serverGuid)
|
||||
{
|
||||
string? name = _objects.Get(serverGuid)?.Name;
|
||||
return string.IsNullOrWhiteSpace(name) ? $"0x{serverGuid:X8}" : name;
|
||||
}
|
||||
|
||||
public bool IsCreature(uint serverGuid)
|
||||
{
|
||||
if (serverGuid == _playerGuid()
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetAnimationRuntime(target.LocalEntityId, out var animation)
|
||||
&& animation.CurrentMotion == MotionCommand.Dead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
public bool IsHostileMonster(uint serverGuid)
|
||||
=> IsCreature(serverGuid)
|
||||
&& CombatTargetPolicy.IsHostileMonster(
|
||||
_playerGuid(),
|
||||
_objects.Get(_playerGuid()),
|
||||
_objects.Get(serverGuid));
|
||||
|
||||
public bool ShouldShowHealth(uint serverGuid)
|
||||
=> SelectedObjectHealthPolicy.ShouldQueryHealth(
|
||||
_playerGuid(),
|
||||
_objects.Get(_playerGuid()),
|
||||
_objects.Get(serverGuid));
|
||||
|
||||
public ClosestCombatTarget? FindClosestHostileMonster()
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player))
|
||||
return null;
|
||||
|
||||
ClosestCombatTarget? best = null;
|
||||
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
|
||||
{
|
||||
if (!IsHostileMonster(guid))
|
||||
continue;
|
||||
float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position);
|
||||
if (best is null || distanceSquared < best.Value.DistanceSquared)
|
||||
best = new ClosestCombatTarget(guid, distanceSquared);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
|
||||
=> IsHostileMonster(serverGuid)
|
||||
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
||||
? target.Entity.Position
|
||||
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)
|
||||
: null;
|
||||
|
||||
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out _)
|
||||
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
uint pwdBits = _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
? spawn.ObjectDescriptionFlags ?? 0u
|
||||
: 0u;
|
||||
return new VividTargetInfo(center, radius, (uint)GetItemType(serverGuid), pwdBits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SmartBox::GetObjectBoundingBox @ 0x00452E20 delegates to
|
||||
/// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit
|
||||
/// fallback when Setup authored no sphere.
|
||||
/// </summary>
|
||||
public bool TryGetSelectionSphere(
|
||||
uint serverGuid,
|
||||
out Vector3 worldCenter,
|
||||
out float worldRadius)
|
||||
{
|
||||
worldCenter = default;
|
||||
worldRadius = 0f;
|
||||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity))
|
||||
return false;
|
||||
|
||||
worldCenter = entity.Position;
|
||||
worldRadius = 0.1f;
|
||||
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| spawn.SetupTableId is not uint setupId
|
||||
|| _selectionSphere(setupId) is not { } sphere
|
||||
|| sphere.Radius <= 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||||
Vector3 localCenter = sphere.Origin * scale;
|
||||
worldCenter = entity.Position + Vector3.Transform(localCenter, entity.Rotation);
|
||||
worldRadius = sphere.Radius * scale;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x00566A20 call family.</summary>
|
||||
public bool IsUseable(uint serverGuid)
|
||||
{
|
||||
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
|
||||
{
|
||||
if (spawn.Useability is uint useability)
|
||||
return useability is not 0u and not 1u;
|
||||
if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u)
|
||||
return true;
|
||||
}
|
||||
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>
|
||||
public bool IsPickupable(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| ((spawn.ObjectDescriptionFlags ?? 0u) & StuckObjectFlag) != 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u;
|
||||
}
|
||||
|
||||
public bool TryGetApproach(
|
||||
uint serverGuid,
|
||||
out InteractionApproach approach)
|
||||
{
|
||||
if (_playerPose() is not { } player
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
approach = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
float useRadius = GetUseRadius(serverGuid);
|
||||
float dx = target.Entity.Position.X - player.Position.X;
|
||||
float dy = target.Entity.Position.Y - player.Position.Y;
|
||||
float distanceSquared = dx * dx + dy * dy;
|
||||
(float radius, float height) = _setupCylinder(serverGuid, target.Entity);
|
||||
approach = new InteractionApproach(
|
||||
target,
|
||||
player,
|
||||
useRadius,
|
||||
distanceSquared <= useRadius * useRadius,
|
||||
distanceSquared >= AceCanChargeDistance * AceCanChargeDistance,
|
||||
radius,
|
||||
height);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsWithinExternalContainerUseRange(uint serverGuid)
|
||||
{
|
||||
if (_playerPose() is not { } playerPose
|
||||
|| !_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player)
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
||||
|| !_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| spawn.UseRadius is not > 0f)
|
||||
{
|
||||
// The server remains authoritative while render projection is absent.
|
||||
return true;
|
||||
}
|
||||
|
||||
var playerCylinder = _setupCylinder(_playerGuid(), player);
|
||||
var targetCylinder = _setupCylinder(serverGuid, target.Entity);
|
||||
return ObjectRangeMath.ObjectsInRange(
|
||||
playerPose.Position,
|
||||
playerCylinder.Radius,
|
||||
playerCylinder.Height,
|
||||
target.Entity.Position,
|
||||
targetCylinder.Radius,
|
||||
targetCylinder.Height,
|
||||
spawn.UseRadius.Value,
|
||||
useRadii: true,
|
||||
ignoreZDelta: false);
|
||||
}
|
||||
|
||||
private float GetUseRadius(uint serverGuid)
|
||||
{
|
||||
if ((GetItemType(serverGuid) & ItemType.Creature) != 0)
|
||||
return CreatureUseRadius;
|
||||
return _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
&& ((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u
|
||||
? LargeObjectUseRadius
|
||||
: DefaultUseRadius;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue