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 AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
|
||||
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
|
||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||
/// support. Required at startup — missing bindless throws
|
||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||
|
|
@ -304,6 +305,8 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
public required AcDream.Core.World.WorldEntity 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.Animation Animation;
|
||||
public required int LowFrame;
|
||||
|
|
@ -2743,6 +2746,40 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
|
||||
_dats!, _datLock)),
|
||||
_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.
|
||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||
|
||||
|
|
@ -13740,14 +13777,10 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (!_persistedGameplay.ViewCombatTarget
|
||||
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|
||||
|| _selection.SelectedObjectId is not uint selected
|
||||
|| !IsLiveHostileMonsterTarget(selected)
|
||||
|| !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
|
||||
|| _selection.SelectedObjectId is not uint selected)
|
||||
return null;
|
||||
|
||||
return target.Position
|
||||
+ System.Numerics.Vector3.Transform(
|
||||
new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation);
|
||||
return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -13765,45 +13798,13 @@ public sealed class GameWindow : IDisposable
|
|||
/// pick the local player while plain selection excludes it.
|
||||
/// </summary>
|
||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
|
||||
=> _worldSelectionQuery?.PickAtCursor(includeSelf);
|
||||
|
||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool 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;
|
||||
}
|
||||
=> _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
|
||||
|
||||
private void BeginSelectionLightingPulse(uint serverGuid)
|
||||
{
|
||||
if (_retailSelectionScene is null
|
||||
|| _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true
|
||||
|| record.LocalEntityId is not { } localEntityId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId);
|
||||
}
|
||||
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
{
|
||||
|
|
@ -13918,9 +13919,9 @@ public sealed class GameWindow : IDisposable
|
|||
// Close-range deferral fires the wire packet ONCE on
|
||||
// MoveToComplete(None) (turn-first done), not a retry of an
|
||||
// earlier failed send. No re-send path.
|
||||
if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget)
|
||||
!= true
|
||||
|| useTarget.LocalEntityId is not { } useTargetLocalId)
|
||||
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
||||
guid,
|
||||
out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -13934,7 +13935,7 @@ public sealed class GameWindow : IDisposable
|
|||
// will fire it after rotation completes.
|
||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||
guid,
|
||||
useTargetLocalId,
|
||||
useTarget.LocalEntityId,
|
||||
IsPickup: false);
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
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.
|
||||
//
|
||||
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
||||
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
||||
if (_worldSelectionQuery?.TryGetInteractionTarget(
|
||||
itemGuid,
|
||||
out LiveEntityRecord pickupTarget) != true
|
||||
|| pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
|
||||
out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -14029,7 +14029,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_pendingPostArrivalAction = new PendingPostArrivalAction(
|
||||
itemGuid,
|
||||
pickupTargetLocalId,
|
||||
pickupTarget.LocalEntityId,
|
||||
IsPickup: true,
|
||||
destinationContainerId,
|
||||
placement);
|
||||
|
|
@ -14075,10 +14075,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
if (_liveEntities?.TryGetInteractionEligibleRecord(
|
||||
if (_worldSelectionQuery?.IsCurrent(
|
||||
pending.Guid,
|
||||
pending.LocalEntityId,
|
||||
out _) != true)
|
||||
pending.LocalEntityId) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -14132,89 +14131,21 @@ public sealed class GameWindow : IDisposable
|
|||
/// until our local rotation overlay reports alignment, then fire.
|
||||
/// </summary>
|
||||
private bool IsCloseRangeTarget(uint targetGuid)
|
||||
{
|
||||
if (_playerController is null) return false;
|
||||
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;
|
||||
}
|
||||
=> _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
|
||||
&& approach.IsCloseRange;
|
||||
|
||||
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
||||
{
|
||||
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);
|
||||
}
|
||||
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
|
||||
|
||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||||
{
|
||||
if (_playerController is not { } pc || pc.MoveTo is null) return;
|
||||
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
||||
if (_playerController is not { } pc
|
||||
|| pc.MoveTo is null
|
||||
|| _worldSelectionQuery?.TryGetApproach(
|
||||
targetGuid,
|
||||
out AcDream.App.Interaction.InteractionApproach approach) != true)
|
||||
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
|
||||
// from local distance so the speculative moveto uses the
|
||||
// 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
|
||||
// MoveToObject broadcast overwrites it (PerformMovement
|
||||
// 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 /
|
||||
// MoveToObject through the SAME manager the wire path uses (decomp
|
||||
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
|
||||
|
|
@ -14242,18 +14166,18 @@ public sealed class GameWindow : IDisposable
|
|||
// are non-default.
|
||||
var p = new AcDream.Core.Physics.Motion.MovementParameters
|
||||
{
|
||||
DistanceToObject = useRadius,
|
||||
CanCharge = speculativeCanCharge,
|
||||
DistanceToObject = approach.UseRadius,
|
||||
CanCharge = approach.CanCharge,
|
||||
};
|
||||
var ms = new AcDream.Core.Physics.MovementStruct
|
||||
{
|
||||
ObjectId = targetGuid,
|
||||
TopLevelId = targetGuid,
|
||||
Pos = new AcDream.Core.Physics.Position(
|
||||
_playerController.CellId, entity.Position,
|
||||
approach.Player.CellId, approach.Target.Entity.Position,
|
||||
System.Numerics.Quaternion.Identity),
|
||||
Params = p,
|
||||
Type = IsCloseRangeTarget(targetGuid)
|
||||
Type = approach.IsCloseRange
|
||||
? AcDream.Core.Physics.MovementType.TurnToObject
|
||||
: AcDream.Core.Physics.MovementType.MoveToObject,
|
||||
};
|
||||
|
|
@ -14265,7 +14189,7 @@ public sealed class GameWindow : IDisposable
|
|||
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
|
||||
// auto-walk stopped ~one player-radius farther out than the AP-23
|
||||
// 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
|
||||
// exactly as the wire mt-6 this install anticipates would (the P1
|
||||
// unpack store, IsAutonomous=false) — without it the per-tick
|
||||
|
|
@ -14281,25 +14205,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||
return null;
|
||||
|
||||
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;
|
||||
}
|
||||
AcDream.App.Interaction.ClosestCombatTarget? closest =
|
||||
_worldSelectionQuery?.FindClosestHostileMonster();
|
||||
uint? bestGuid = closest?.ServerGuid;
|
||||
|
||||
if (bestGuid is { } selection)
|
||||
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||
|
|
@ -14308,7 +14216,7 @@ public sealed class GameWindow : IDisposable
|
|||
if (bestGuid is { } 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}");
|
||||
if (showToast)
|
||||
_debugVm?.AddToast($"Target {label}");
|
||||
|
|
@ -14323,30 +14231,10 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private bool IsLiveCreatureTarget(uint guid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
=> _worldSelectionQuery?.IsCreature(guid) == true;
|
||||
|
||||
private bool IsLiveHostileMonsterTarget(uint guid)
|
||||
{
|
||||
if (!IsLiveCreatureTarget(guid))
|
||||
return false;
|
||||
|
||||
return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster(
|
||||
_playerServerGuid,
|
||||
Objects.Get(_playerServerGuid),
|
||||
Objects.Get(guid));
|
||||
}
|
||||
=> _worldSelectionQuery?.IsHostileMonster(guid) == true;
|
||||
|
||||
/// <summary>
|
||||
/// 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>.
|
||||
/// </summary>
|
||||
private bool IsHealthBarTarget(uint guid)
|
||||
=> AcDream.Core.Combat.SelectedObjectHealthPolicy.ShouldQueryHealth(
|
||||
_playerServerGuid,
|
||||
Objects.Get(_playerServerGuid),
|
||||
Objects.Get(guid));
|
||||
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
|
||||
|
||||
private (System.Numerics.Matrix4x4 View,
|
||||
System.Numerics.Matrix4x4 Projection,
|
||||
|
|
@ -14374,20 +14259,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint 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);
|
||||
}
|
||||
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -14420,45 +14292,11 @@ public sealed class GameWindow : IDisposable
|
|||
out System.Numerics.Vector3 worldCenter,
|
||||
out float worldRadius)
|
||||
{
|
||||
if (_worldSelectionQuery is { } query)
|
||||
return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius);
|
||||
worldCenter = default;
|
||||
worldRadius = 0f;
|
||||
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -14490,90 +14328,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </para>
|
||||
/// </summary>
|
||||
private bool IsUseableTarget(uint guid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
=> _worldSelectionQuery?.IsUseable(guid) == true;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
||||
|
|
@ -14597,63 +14352,16 @@ public sealed class GameWindow : IDisposable
|
|||
/// </para>
|
||||
/// </summary>
|
||||
private bool IsPickupableTarget(uint guid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
=> _worldSelectionQuery?.IsPickupable(guid) == true;
|
||||
|
||||
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 DescribeLiveEntity(uint guid)
|
||||
{
|
||||
var name = LiveName(guid);
|
||||
if (!string.IsNullOrWhiteSpace(name)) return name!;
|
||||
return $"0x{guid:X8}";
|
||||
return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ namespace AcDream.App.World;
|
|||
public interface ILiveEntityAnimationRuntime
|
||||
{
|
||||
WorldEntity Entity { get; }
|
||||
uint CurrentMotion { get; }
|
||||
}
|
||||
|
||||
/// <summary>Remote motion state owned by a live object.</summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue