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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
|
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
|
||||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||||
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
||||||
|
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
|
||||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||||
/// support. Required at startup — missing bindless throws
|
/// support. Required at startup — missing bindless throws
|
||||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||||
|
|
@ -304,6 +305,8 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
public required AcDream.Core.World.WorldEntity Entity;
|
public required AcDream.Core.World.WorldEntity Entity;
|
||||||
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity;
|
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity;
|
||||||
|
uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion =>
|
||||||
|
Sequencer?.CurrentMotion ?? 0u;
|
||||||
public required DatReaderWriter.DBObjs.Setup Setup;
|
public required DatReaderWriter.DBObjs.Setup Setup;
|
||||||
public required DatReaderWriter.DBObjs.Animation Animation;
|
public required DatReaderWriter.DBObjs.Animation Animation;
|
||||||
public required int LowFrame;
|
public required int LowFrame;
|
||||||
|
|
@ -2743,6 +2746,40 @@ public sealed class GameWindow : IDisposable
|
||||||
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
|
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
|
||||||
_dats!, _datLock)),
|
_dats!, _datLock)),
|
||||||
_retailAlphaQueue);
|
_retailAlphaQueue);
|
||||||
|
_worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery(
|
||||||
|
_liveEntities,
|
||||||
|
Objects,
|
||||||
|
_retailSelectionScene,
|
||||||
|
() => _playerServerGuid,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var camera = GetSelectionCamera();
|
||||||
|
return new AcDream.App.Interaction.SelectionCameraSnapshot(
|
||||||
|
camera.View,
|
||||||
|
camera.Projection,
|
||||||
|
camera.Viewport);
|
||||||
|
},
|
||||||
|
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
|
||||||
|
() => _playerController is { } player
|
||||||
|
? new AcDream.App.Interaction.PlayerInteractionPose(
|
||||||
|
player.CellId,
|
||||||
|
player.Position)
|
||||||
|
: null,
|
||||||
|
GetSetupCylinder,
|
||||||
|
setupId =>
|
||||||
|
{
|
||||||
|
if (_dats is null)
|
||||||
|
return null;
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)
|
||||||
|
|| setup.SelectionSphere is not { } sphere)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (sphere.Origin, sphere.Radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
// A.5 T22.5: apply A2C gate from quality preset.
|
// A.5 T22.5: apply A2C gate from quality preset.
|
||||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||||
|
|
||||||
|
|
@ -13740,14 +13777,10 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (!_persistedGameplay.ViewCombatTarget
|
if (!_persistedGameplay.ViewCombatTarget
|
||||||
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|
||||||
|| _selection.SelectedObjectId is not uint selected
|
|| _selection.SelectedObjectId is not uint selected)
|
||||||
|| !IsLiveHostileMonsterTarget(selected)
|
|
||||||
|| !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return target.Position
|
return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
|
||||||
+ System.Numerics.Vector3.Transform(
|
|
||||||
new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -13765,45 +13798,13 @@ public sealed class GameWindow : IDisposable
|
||||||
/// pick the local player while plain selection excludes it.
|
/// pick the local player while plain selection excludes it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||||
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
|
=> _worldSelectionQuery?.PickAtCursor(includeSelf);
|
||||||
|
|
||||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
||||||
{
|
=> _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
|
||||||
if (_retailSelectionScene is null
|
|
||||||
|| _liveEntities is null
|
|
||||||
|| _window is null)
|
|
||||||
return null;
|
|
||||||
var camera = GetSelectionCamera();
|
|
||||||
AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick(
|
|
||||||
mouseX,
|
|
||||||
mouseY,
|
|
||||||
camera.Viewport,
|
|
||||||
camera.View,
|
|
||||||
camera.Projection,
|
|
||||||
includeSelf ? 0u : _playerServerGuid);
|
|
||||||
if (hit is not { } found
|
|
||||||
|| !_liveEntities.TryGetInteractionEligibleRecord(
|
|
||||||
found.ServerGuid,
|
|
||||||
found.LocalEntityId,
|
|
||||||
out _))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return found.ServerGuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BeginSelectionLightingPulse(uint serverGuid)
|
private void BeginSelectionLightingPulse(uint serverGuid)
|
||||||
{
|
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
||||||
if (_retailSelectionScene is null
|
|
||||||
|| _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true
|
|
||||||
|| record.LocalEntityId is not { } localEntityId)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PickAndStoreSelection(bool useImmediately)
|
private void PickAndStoreSelection(bool useImmediately)
|
||||||
{
|
{
|
||||||
|
|
@ -13918,9 +13919,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// Close-range deferral fires the wire packet ONCE on
|
// Close-range deferral fires the wire packet ONCE on
|
||||||
// MoveToComplete(None) (turn-first done), not a retry of an
|
// MoveToComplete(None) (turn-first done), not a retry of an
|
||||||
// earlier failed send. No re-send path.
|
// earlier failed send. No re-send path.
|
||||||
if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget)
|
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
||||||
!= true
|
guid,
|
||||||
|| useTarget.LocalEntityId is not { } useTargetLocalId)
|
out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -13934,7 +13935,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// will fire it after rotation completes.
|
// will fire it after rotation completes.
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||||
guid,
|
guid,
|
||||||
useTargetLocalId,
|
useTarget.LocalEntityId,
|
||||||
IsPickup: false);
|
IsPickup: false);
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
||||||
|
|
@ -14014,10 +14015,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// overlay reports arrival.
|
// overlay reports arrival.
|
||||||
//
|
//
|
||||||
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
||||||
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
||||||
itemGuid,
|
itemGuid,
|
||||||
out LiveEntityRecord pickupTarget) != true
|
out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true)
|
||||||
|| pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -14029,7 +14029,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||||
itemGuid,
|
itemGuid,
|
||||||
pickupTargetLocalId,
|
pickupTarget.LocalEntityId,
|
||||||
IsPickup: true,
|
IsPickup: true,
|
||||||
destinationContainerId,
|
destinationContainerId,
|
||||||
placement);
|
placement);
|
||||||
|
|
@ -14075,10 +14075,9 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_liveSession is null
|
if (_liveSession is null
|
||||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||||
return;
|
return;
|
||||||
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
if (_worldSelectionQuery?.IsCurrent(
|
||||||
pending.Guid,
|
pending.Guid,
|
||||||
pending.LocalEntityId,
|
pending.LocalEntityId) != true)
|
||||||
out _) != true)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -14132,89 +14131,21 @@ public sealed class GameWindow : IDisposable
|
||||||
/// until our local rotation overlay reports alignment, then fire.
|
/// until our local rotation overlay reports alignment, then fire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool IsCloseRangeTarget(uint targetGuid)
|
private bool IsCloseRangeTarget(uint targetGuid)
|
||||||
{
|
=> _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
|
||||||
if (_playerController is null) return false;
|
&& approach.IsCloseRange;
|
||||||
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
|
|
||||||
float useRadius = 0.6f;
|
|
||||||
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
|
||||||
{
|
|
||||||
useRadius = 3.0f;
|
|
||||||
}
|
|
||||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
|
||||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
|
||||||
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyPos = _playerController.Position;
|
|
||||||
float dx = entity.Position.X - bodyPos.X;
|
|
||||||
float dy = entity.Position.Y - bodyPos.Y;
|
|
||||||
float distSq = dx * dx + dy * dy;
|
|
||||||
return distSq <= useRadius * useRadius;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
||||||
{
|
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
|
||||||
if (_playerController is null
|
|
||||||
|| !_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)
|
|
||||||
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
|
||||||
{
|
|
||||||
// The server remains authoritative for forced close. An entity can be
|
|
||||||
// temporarily absent from the visible-cell projection while its external
|
|
||||||
// view is still valid; do not manufacture a range exit from missing render state.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|
|
||||||
|| spawn.UseRadius is not > 0f)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// gmExternalContainerUI::SetGroundObject @ 0x004CBBD0 registers the
|
|
||||||
// root's UseRadius with useRadii=true and ignoreZDelta=false.
|
|
||||||
// CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360 then calls
|
|
||||||
// ACCWeenieObject::ObjectsInRange @ 0x0058C1A0, whose use-radii branch
|
|
||||||
// compares the gap between both physics cylinders, not their centers.
|
|
||||||
var playerCylinder = GetSetupCylinder(_playerServerGuid, playerEntity);
|
|
||||||
var targetCylinder = GetSetupCylinder(targetGuid, entity);
|
|
||||||
return AcDream.Core.Physics.ObjectRangeMath.ObjectsInRange(
|
|
||||||
_playerController.Position,
|
|
||||||
playerCylinder.Radius,
|
|
||||||
playerCylinder.Height,
|
|
||||||
entity.Position,
|
|
||||||
targetCylinder.Radius,
|
|
||||||
targetCylinder.Height,
|
|
||||||
spawn.UseRadius.Value,
|
|
||||||
useRadii: true,
|
|
||||||
ignoreZDelta: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||||||
{
|
{
|
||||||
if (_playerController is not { } pc || pc.MoveTo is null) return;
|
if (_playerController is not { } pc
|
||||||
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
|| pc.MoveTo is null
|
||||||
|
|| _worldSelectionQuery?.TryGetApproach(
|
||||||
|
targetGuid,
|
||||||
|
out AcDream.App.Interaction.InteractionApproach approach) != true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Per-type use radius — same heuristic as the picker's
|
|
||||||
// radiusForGuid callback (register AP-23, re-anchored here by
|
|
||||||
// R4-V5; survives because ACE's close-branch broadcasts nothing
|
|
||||||
// actionable).
|
|
||||||
float useRadius = 0.6f;
|
|
||||||
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
|
||||||
{
|
|
||||||
useRadius = 3.0f;
|
|
||||||
}
|
|
||||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
|
||||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
|
|
||||||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
|
||||||
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
|
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
|
||||||
// from local distance so the speculative moveto uses the
|
// from local distance so the speculative moveto uses the
|
||||||
// same walk/run as the wire-triggered overwrite that arrives
|
// same walk/run as the wire-triggered overwrite that arrives
|
||||||
|
|
@ -14224,13 +14155,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// the speculative install doesn't flip walk↔run when ACE's
|
// the speculative install doesn't flip walk↔run when ACE's
|
||||||
// MoveToObject broadcast overwrites it (PerformMovement
|
// MoveToObject broadcast overwrites it (PerformMovement
|
||||||
// cancels + restarts — retail-consistent re-target).
|
// cancels + restarts — retail-consistent re-target).
|
||||||
const float AceCanChargeDistance = 7.5f;
|
|
||||||
var bodyPos = _playerController.Position;
|
|
||||||
float ddx = entity.Position.X - bodyPos.X;
|
|
||||||
float ddy = entity.Position.Y - bodyPos.Y;
|
|
||||||
float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
|
|
||||||
bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
|
|
||||||
|
|
||||||
// R4-V5: retail's client-initiated use flow issues TurnToObject /
|
// R4-V5: retail's client-initiated use flow issues TurnToObject /
|
||||||
// MoveToObject through the SAME manager the wire path uses (decomp
|
// MoveToObject through the SAME manager the wire path uses (decomp
|
||||||
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
|
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
|
||||||
|
|
@ -14242,18 +14166,18 @@ public sealed class GameWindow : IDisposable
|
||||||
// are non-default.
|
// are non-default.
|
||||||
var p = new AcDream.Core.Physics.Motion.MovementParameters
|
var p = new AcDream.Core.Physics.Motion.MovementParameters
|
||||||
{
|
{
|
||||||
DistanceToObject = useRadius,
|
DistanceToObject = approach.UseRadius,
|
||||||
CanCharge = speculativeCanCharge,
|
CanCharge = approach.CanCharge,
|
||||||
};
|
};
|
||||||
var ms = new AcDream.Core.Physics.MovementStruct
|
var ms = new AcDream.Core.Physics.MovementStruct
|
||||||
{
|
{
|
||||||
ObjectId = targetGuid,
|
ObjectId = targetGuid,
|
||||||
TopLevelId = targetGuid,
|
TopLevelId = targetGuid,
|
||||||
Pos = new AcDream.Core.Physics.Position(
|
Pos = new AcDream.Core.Physics.Position(
|
||||||
_playerController.CellId, entity.Position,
|
approach.Player.CellId, approach.Target.Entity.Position,
|
||||||
System.Numerics.Quaternion.Identity),
|
System.Numerics.Quaternion.Identity),
|
||||||
Params = p,
|
Params = p,
|
||||||
Type = IsCloseRangeTarget(targetGuid)
|
Type = approach.IsCloseRange
|
||||||
? AcDream.Core.Physics.MovementType.TurnToObject
|
? AcDream.Core.Physics.MovementType.TurnToObject
|
||||||
: AcDream.Core.Physics.MovementType.MoveToObject,
|
: AcDream.Core.Physics.MovementType.MoveToObject,
|
||||||
};
|
};
|
||||||
|
|
@ -14265,7 +14189,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
|
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
|
||||||
// auto-walk stopped ~one player-radius farther out than the AP-23
|
// auto-walk stopped ~one player-radius farther out than the AP-23
|
||||||
// constants intended, and the two MoveToObject sites disagreed.
|
// constants intended, and the two MoveToObject sites disagreed.
|
||||||
(ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity);
|
(ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight);
|
||||||
// Part of this install's AP-23 adaptation: store the autonomy flag
|
// Part of this install's AP-23 adaptation: store the autonomy flag
|
||||||
// exactly as the wire mt-6 this install anticipates would (the P1
|
// exactly as the wire mt-6 this install anticipates would (the P1
|
||||||
// unpack store, IsAutonomous=false) — without it the per-tick
|
// unpack store, IsAutonomous=false) — without it the per-tick
|
||||||
|
|
@ -14281,25 +14205,9 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private uint? SelectClosestCombatTarget(bool showToast)
|
private uint? SelectClosestCombatTarget(bool showToast)
|
||||||
{
|
{
|
||||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
AcDream.App.Interaction.ClosestCombatTarget? closest =
|
||||||
return null;
|
_worldSelectionQuery?.FindClosestHostileMonster();
|
||||||
|
uint? bestGuid = closest?.ServerGuid;
|
||||||
uint? bestGuid = null;
|
|
||||||
float bestDistanceSq = float.PositiveInfinity;
|
|
||||||
foreach (var (guid, entity) in _visibleEntitiesByServerGuid)
|
|
||||||
{
|
|
||||||
if (!IsLiveHostileMonsterTarget(guid))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
|
||||||
entity.Position,
|
|
||||||
playerEntity.Position);
|
|
||||||
if (distanceSq >= bestDistanceSq)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
bestDistanceSq = distanceSq;
|
|
||||||
bestGuid = guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestGuid is { } selection)
|
if (bestGuid is { } selection)
|
||||||
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||||
|
|
@ -14308,7 +14216,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (bestGuid is { } selected)
|
if (bestGuid is { } selected)
|
||||||
{
|
{
|
||||||
string label = DescribeLiveEntity(selected);
|
string label = DescribeLiveEntity(selected);
|
||||||
float distance = MathF.Sqrt(bestDistanceSq);
|
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
|
||||||
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
||||||
if (showToast)
|
if (showToast)
|
||||||
_debugVm?.AddToast($"Target {label}");
|
_debugVm?.AddToast($"Target {label}");
|
||||||
|
|
@ -14323,30 +14231,10 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsLiveCreatureTarget(uint guid)
|
private bool IsLiveCreatureTarget(uint guid)
|
||||||
{
|
=> _worldSelectionQuery?.IsCreature(guid) == true;
|
||||||
if (guid == _playerServerGuid)
|
|
||||||
return false;
|
|
||||||
if (!_visibleEntitiesByServerGuid.ContainsKey(guid))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)
|
|
||||||
&& _animatedEntities.TryGetValue(entity.Id, out var animated)
|
|
||||||
&& animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsLiveHostileMonsterTarget(uint guid)
|
private bool IsLiveHostileMonsterTarget(uint guid)
|
||||||
{
|
=> _worldSelectionQuery?.IsHostileMonster(guid) == true;
|
||||||
if (!IsLiveCreatureTarget(guid))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster(
|
|
||||||
_playerServerGuid,
|
|
||||||
Objects.Get(_playerServerGuid),
|
|
||||||
Objects.Get(guid));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
||||||
|
|
@ -14355,10 +14243,7 @@ public sealed class GameWindow : IDisposable
|
||||||
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>.
|
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool IsHealthBarTarget(uint guid)
|
private bool IsHealthBarTarget(uint guid)
|
||||||
=> AcDream.Core.Combat.SelectedObjectHealthPolicy.ShouldQueryHealth(
|
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
|
||||||
_playerServerGuid,
|
|
||||||
Objects.Get(_playerServerGuid),
|
|
||||||
Objects.Get(guid));
|
|
||||||
|
|
||||||
private (System.Numerics.Matrix4x4 View,
|
private (System.Numerics.Matrix4x4 View,
|
||||||
System.Numerics.Matrix4x4 Projection,
|
System.Numerics.Matrix4x4 Projection,
|
||||||
|
|
@ -14374,20 +14259,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
|
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
|
||||||
{
|
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
|
||||||
if (!_entitiesByServerGuid.ContainsKey(guid)
|
|
||||||
|| !TryGetEntitySelectionSphere(guid, out var center, out float radius))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
uint pwdBits = LastSpawns.TryGetValue(guid, out var spawn)
|
|
||||||
? spawn.ObjectDescriptionFlags ?? 0u
|
|
||||||
: 0u;
|
|
||||||
return new AcDream.App.UI.Layout.VividTargetInfo(
|
|
||||||
center,
|
|
||||||
radius,
|
|
||||||
(uint)LiveItemType(guid),
|
|
||||||
pwdBits);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -14420,45 +14292,11 @@ public sealed class GameWindow : IDisposable
|
||||||
out System.Numerics.Vector3 worldCenter,
|
out System.Numerics.Vector3 worldCenter,
|
||||||
out float worldRadius)
|
out float worldRadius)
|
||||||
{
|
{
|
||||||
|
if (_worldSelectionQuery is { } query)
|
||||||
|
return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius);
|
||||||
worldCenter = default;
|
worldCenter = default;
|
||||||
worldRadius = 0f;
|
worldRadius = 0f;
|
||||||
|
return false;
|
||||||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
|
|
||||||
|
|
||||||
// SmartBox::GetObjectBoundingBox @ 0x00452E20 installs a 0.1-unit
|
|
||||||
// origin sphere when CPhysicsObj::GetSelectionSphere has no authored
|
|
||||||
// Setup sphere. Preserve that presentation for every materialized
|
|
||||||
// object rather than dropping the marker entirely.
|
|
||||||
worldCenter = entity.Position;
|
|
||||||
worldRadius = 0.1f;
|
|
||||||
if (!LastSpawns.TryGetValue(guid, out var spawn)
|
|
||||||
|| spawn.SetupTableId is not uint setupId
|
|
||||||
|| _dats is null
|
|
||||||
|| !_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// DAT Setup carries `SelectionSphere` (Origin + Radius). A zero
|
|
||||||
// radius means the Setup did not author one, so retain the retail
|
|
||||||
// 0.1-unit fallback installed above.
|
|
||||||
var sel = setup.SelectionSphere;
|
|
||||||
if (sel is null || sel.Radius <= 1e-4f) return true;
|
|
||||||
|
|
||||||
// Retail GetSelectionSphere applies part-array scale to the
|
|
||||||
// sphere center (component-wise) and to the radius (Z-scale
|
|
||||||
// only). For uniform entity scale these coincide.
|
|
||||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
|
||||||
var localCenter = new System.Numerics.Vector3(
|
|
||||||
sel.Origin.X * scale,
|
|
||||||
sel.Origin.Y * scale,
|
|
||||||
sel.Origin.Z * scale);
|
|
||||||
|
|
||||||
// Setup-local center → world. Entity rotation applies; entity
|
|
||||||
// position is the world origin of the setup.
|
|
||||||
var rot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation);
|
|
||||||
var rotated = System.Numerics.Vector3.Transform(localCenter, rot);
|
|
||||||
worldCenter = entity.Position + rotated;
|
|
||||||
worldRadius = sel.Radius * scale;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -14490,90 +14328,7 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool IsUseableTarget(uint guid)
|
private bool IsUseableTarget(uint guid)
|
||||||
{
|
=> _worldSelectionQuery?.IsUseable(guid) == true;
|
||||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
|
||||||
{
|
|
||||||
// Authoritative path: server published Useability.
|
|
||||||
// 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
|
|
||||||
// at acclient_2013_pseudo_c.txt:256455 (4 call-site cross-
|
|
||||||
// checks confirm: ItemHolder::UseObject 0x00588a80,
|
|
||||||
// DetermineUseResult 0x402697, UsingItem 0x367638,
|
|
||||||
// disable-button state 0x198826 — all key off non-zero).
|
|
||||||
// BN's `!(x) & 1` rendering is a mis-decompile of the
|
|
||||||
// setne+and test-flag inliner. Real semantic:
|
|
||||||
//
|
|
||||||
// IsUseable(_useability) := (_useability != USEABLE_UNDEF)
|
|
||||||
//
|
|
||||||
// ANY non-zero value passes (including USEABLE_NO=1,
|
|
||||||
// USEABLE_CONTAINED=8, etc.). Retail trusts the server to
|
|
||||||
// have only set non-zero on entities where Use is sensible.
|
|
||||||
//
|
|
||||||
// Previous implementation (B.8) checked
|
|
||||||
// `(useability & USEABLE_REMOTE_BIT) != 0` which is STRICTER
|
|
||||||
// than retail — a USEABLE_NO door would be blocked locally
|
|
||||||
// but pass retail's gate. Now matches retail bit-for-bit.
|
|
||||||
if (spawn.Useability is uint useability)
|
|
||||||
{
|
|
||||||
// Retail-faithful Use gate per acclient_2013_pseudo_c.txt:256455
|
|
||||||
// ItemUses::IsUseable: non-zero useability passes. But two
|
|
||||||
// values produce "cannot be used" client-side without a
|
|
||||||
// wire send in retail's observable behaviour:
|
|
||||||
// USEABLE_UNDEF (0): server's Use handler would reject;
|
|
||||||
// retail UseObject path shows "cannot be used" toast.
|
|
||||||
// USEABLE_NO (1): explicitly not useable — same outcome.
|
|
||||||
// Both come from acclient.h:6478 ITEM_USEABLE enum.
|
|
||||||
//
|
|
||||||
// Retail technically sends the packet for USEABLE_NO (the
|
|
||||||
// audit's `IsUseable != 0` reading is correct), but ACE
|
|
||||||
// never broadcasts MovementType=6 for it, so retail
|
|
||||||
// doesn't visibly approach. Our client installs a
|
|
||||||
// speculative auto-walk overlay BEFORE the server
|
|
||||||
// response — so the only way to avoid "approach then fail"
|
|
||||||
// is to gate USEABLE_NO client-side. Net result matches
|
|
||||||
// user-observed retail behaviour.
|
|
||||||
const uint USEABLE_UNDEF = 0u;
|
|
||||||
const uint USEABLE_NO = 1u;
|
|
||||||
if (useability == USEABLE_UNDEF || useability == USEABLE_NO)
|
|
||||||
return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Useability NOT in PWD — fall back to known-useable types.
|
|
||||||
// ObjectDescriptionFlags BF_DOOR|BF_LIFESTONE|BF_PORTAL|BF_CORPSE
|
|
||||||
// historically work with Use; allow them through.
|
|
||||||
if (spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
|
||||||
if ((odf & UseableFlatMask) != 0)
|
|
||||||
{
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[useability-fallback] flat-class guid=0x{guid:X8} odf=0x{odf:X8} (ACE sent no useability bit)"));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creatures (NPCs / players) are always Use targets in our
|
|
||||||
// fallback even when ACE didn't publish useability. Retail
|
|
||||||
// would have blocked here (null → USEABLE_UNDEF → 0 → block),
|
|
||||||
// but ACE's seed DB has many talk-only NPC weenies with
|
|
||||||
// `ItemUseable = null`; without the fallback the M1 "click NPC"
|
|
||||||
// flow regresses. The diagnostic line below lets us measure
|
|
||||||
// how often this branch fires in real play.
|
|
||||||
if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
|
||||||
{
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[useability-fallback] creature guid=0x{guid:X8} (ACE sent no useability bit)"));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: not useable. Signs, banners, untyped scenery with no
|
|
||||||
// server-supplied useability and no creature/door PWD bits land
|
|
||||||
// here — exactly the retail "nothing happens" case.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
||||||
|
|
@ -14597,63 +14352,16 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool IsPickupableTarget(uint guid)
|
private bool IsPickupableTarget(uint guid)
|
||||||
{
|
=> _worldSelectionQuery?.IsPickupable(guid) == true;
|
||||||
if (!LastSpawns.TryGetValue(guid, out var spawn))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// 2026-05-16 — primary discriminator is the BF_STUCK
|
|
||||||
// ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
|
|
||||||
// ACE mark immovable world objects (signs, banners, doors,
|
|
||||||
// benches) as Stuck server-side. ACE's PutItemInContainer
|
|
||||||
// handler (Player_Inventory.cs:831-836) responds with
|
|
||||||
// WeenieError.Stuck (0x29) when the client attempts a pickup
|
|
||||||
// on an item with the Stuck flag — so the client should gate
|
|
||||||
// out signs etc. before sending the wire packet.
|
|
||||||
//
|
|
||||||
// Discriminates same-ItemType ambiguity that useability can't:
|
|
||||||
// Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
|
|
||||||
// Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
|
|
||||||
// Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
|
|
||||||
if (spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
const uint BF_STUCK = 0x0004u;
|
|
||||||
// Corpses are BF_STUCK containers: Use opens their contents; the
|
|
||||||
// corpse object itself is never picked up. ItemHolder::DetermineUseResult
|
|
||||||
// @ 0x00588460 excludes Stuck objects from PlaceInBackpack.
|
|
||||||
if ((odf & BF_STUCK) != 0u) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small-item ItemType class: dropped weapons, armor, food,
|
|
||||||
// jewelry, money, misc, gems, spell components, etc.
|
|
||||||
uint it = spawn.ItemType ?? 0u;
|
|
||||||
const uint SmallItemMask =
|
|
||||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
|
||||||
| AcDream.Core.Items.ItemType.Armor
|
|
||||||
| AcDream.Core.Items.ItemType.Clothing
|
|
||||||
| AcDream.Core.Items.ItemType.Jewelry
|
|
||||||
| AcDream.Core.Items.ItemType.Food
|
|
||||||
| AcDream.Core.Items.ItemType.Money
|
|
||||||
| AcDream.Core.Items.ItemType.Misc
|
|
||||||
| AcDream.Core.Items.ItemType.MissileWeapon
|
|
||||||
| AcDream.Core.Items.ItemType.Container
|
|
||||||
| AcDream.Core.Items.ItemType.Gem
|
|
||||||
| AcDream.Core.Items.ItemType.SpellComponents
|
|
||||||
| AcDream.Core.Items.ItemType.Writable
|
|
||||||
| AcDream.Core.Items.ItemType.Key
|
|
||||||
| AcDream.Core.Items.ItemType.Caster);
|
|
||||||
return (it & SmallItemMask) != 0u;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
|
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
|
||||||
Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None;
|
_worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None;
|
||||||
|
|
||||||
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
|
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
|
||||||
|
|
||||||
private string DescribeLiveEntity(uint guid)
|
private string DescribeLiveEntity(uint guid)
|
||||||
{
|
{
|
||||||
var name = LiveName(guid);
|
return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}";
|
||||||
if (!string.IsNullOrWhiteSpace(name)) return name!;
|
|
||||||
return $"0x{guid:X8}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ namespace AcDream.App.World;
|
||||||
public interface ILiveEntityAnimationRuntime
|
public interface ILiveEntityAnimationRuntime
|
||||||
{
|
{
|
||||||
WorldEntity Entity { get; }
|
WorldEntity Entity { get; }
|
||||||
|
uint CurrentMotion { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Remote motion state owned by a live object.</summary>
|
/// <summary>Remote motion state owned by a live object.</summary>
|
||||||
|
|
|
||||||
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
|
private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime
|
||||||
{
|
{
|
||||||
public WorldEntity Entity { get; } = entity;
|
public WorldEntity Entity { get; } = entity;
|
||||||
|
public uint CurrentMotion { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class EffectProfile : ILiveEntityEffectProfile { }
|
private sealed class EffectProfile : ILiveEntityEffectProfile { }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue