feat(selection): port retail offscreen target indicator

This commit is contained in:
Erik 2026-07-18 07:41:12 +02:00
parent ea4f52ec51
commit ec1bb19609
9 changed files with 404 additions and 97 deletions

View file

@ -2294,7 +2294,6 @@ public sealed class GameWindow : IDisposable
() => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
guid => _retailSelectionScene?.WasVisible(guid) == true,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook,
@ -13667,7 +13666,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
{
if (!_visibleEntitiesByServerGuid.ContainsKey(guid)
if (!_entitiesByServerGuid.ContainsKey(guid)
|| !TryGetEntitySelectionSphere(guid, out var center, out float radius))
return null;
@ -13715,17 +13714,25 @@ public sealed class GameWindow : IDisposable
worldCenter = default;
worldRadius = 0f;
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
if (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
if (spawn.SetupTableId is not uint setupId) return false;
if (_dats is null) return false;
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) 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 didn't bake one — fall back to the
// caller's other path.
// 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 false;
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

View file

@ -16,8 +16,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new();
private readonly HashSet<PartKey> _buildingKeys = new();
private HashSet<uint> _buildingGuids = new();
private HashSet<uint> _publishedGuids = new();
private FrustumPlanes? _viewFrustum;
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
@ -47,7 +45,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
{
_building.Clear();
_buildingKeys.Clear();
_buildingGuids.Clear();
_viewFrustum = null;
}
@ -82,13 +79,11 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
partIndex,
partWorld,
mesh));
_buildingGuids.Add(entity.ServerGuid);
}
public void CompleteFrame()
{
(_published, _building) = (_building, _published);
(_publishedGuids, _buildingGuids) = (_buildingGuids, _publishedGuids);
}
public uint? Pick(
@ -107,8 +102,6 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
}
public bool WasVisible(uint serverGuid) => _publishedGuids.Contains(serverGuid);
internal static bool DrawingSphereIntersectsFrustum(
RetailSelectionMesh mesh,
Matrix4x4 localToWorld,

View file

@ -15,35 +15,43 @@ public sealed record VividTargetRuntimeBindings(
Func<uint> PlayerGuid,
Func<bool> Enabled,
Func<uint, VividTargetInfo?> ResolveTarget,
Func<uint, bool> WasDrawn,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera);
/// <summary>
/// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>.
/// The four corner surfaces are client-enum category 0x10000009 values 1..4, colorized
/// with the selected object's retail radar-blip color and placed around the
/// Setup selection sphere's SmartBox screen rectangle.
/// Client-enum category 0x10000009 values 1..4 are the four on-screen corners;
/// values 5..12 are retail's 24x24 off-screen direction images. SmartBox computes
/// this presentation from the live object's selection sphere, independently of
/// whether the world renderer drew or occluded the object.
/// </summary>
public sealed class VividTargetIndicatorController
{
private const uint ClientEnumCategory = 0x10000009u;
private const uint FirstSourceImageEnum = 1u;
private const int SourceImageCount = 12;
private const float ViewportMargin = 8f;
private readonly UiPanel _root;
private readonly UiPanel _onScreen;
private readonly UiTextureElement[] _corners;
private readonly UiTextureElement _offScreen;
private readonly VividTargetRuntimeBindings _bindings;
private readonly (float Width, float Height)[] _sizes;
private readonly VividTargetSource[] _sources;
private readonly (float Width, float Height)[] _cornerSizes;
private VividTargetIndicatorController(
UiPanel root,
UiPanel onScreen,
UiTextureElement[] corners,
(float Width, float Height)[] sizes,
UiTextureElement offScreen,
VividTargetSource[] sources,
VividTargetRuntimeBindings bindings)
{
_root = root;
_onScreen = onScreen;
_corners = corners;
_sizes = sizes;
_offScreen = offScreen;
_sources = sources;
_cornerSizes = sources.Take(4)
.Select(source => (source.Width, source.Height))
.ToArray();
_bindings = bindings;
}
@ -52,9 +60,9 @@ public sealed class VividTargetIndicatorController
RetailUiAssets assets,
VividTargetRuntimeBindings bindings)
{
var root = new UiPanel
var onScreen = new UiPanel
{
Name = "VividTargetIndicator",
Name = "VividTargetIndicatorOnScreen",
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
ClickThrough = true,
@ -63,9 +71,8 @@ public sealed class VividTargetIndicatorController
Anchors = AnchorEdges.None,
};
var corners = new UiTextureElement[4];
var sizes = new (float Width, float Height)[4];
for (uint i = 0; i < 4; i++)
var sources = new VividTargetSource[SourceImageCount];
for (uint i = 0; i < SourceImageCount; i++)
{
uint did;
lock (assets.DatLock)
@ -79,22 +86,39 @@ public sealed class VividTargetIndicatorController
var resolved = assets.ResolveSprite(did);
if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0)
return null;
sources[i] = new VividTargetSource(
resolved.Texture, resolved.Width, resolved.Height);
}
sizes[i] = (resolved.Width, resolved.Height);
var corners = new UiTextureElement[4];
for (int i = 0; i < corners.Length; i++)
{
VividTargetSource source = sources[i];
corners[i] = new UiTextureElement
{
Name = $"VividTargetCorner{i + 1}",
Texture = resolved.Texture,
Width = resolved.Width,
Height = resolved.Height,
Texture = source.Texture,
Width = source.Width,
Height = source.Height,
ClickThrough = true,
Anchors = AnchorEdges.None,
};
root.AddChild(corners[i]);
onScreen.AddChild(corners[i]);
}
host.AddChild(root);
return new VividTargetIndicatorController(root, corners, sizes, bindings);
var offScreen = new UiTextureElement
{
Name = "VividTargetIndicatorOffScreen",
Visible = false,
ClickThrough = true,
ZOrder = -10_000,
Anchors = AnchorEdges.None,
};
host.AddChild(onScreen);
host.AddChild(offScreen);
return new VividTargetIndicatorController(
onScreen, corners, offScreen, sources, bindings);
}
public void Tick()
@ -103,44 +127,67 @@ public sealed class VividTargetIndicatorController
|| _bindings.Selection.SelectedObjectId is not uint guid
|| guid == 0u
|| guid == _bindings.PlayerGuid()
|| !_bindings.WasDrawn(guid)
|| _bindings.ResolveTarget(guid) is not VividTargetInfo target)
{
_root.Visible = false;
Hide();
return;
}
var camera = _bindings.Camera();
if (!ScreenProjection.TryProjectSphereToScreenRect(
target.SelectionSphereCenter,
target.SelectionSphereRadius,
camera.View,
camera.Projection,
camera.Viewport,
out Vector2 rectMin,
out Vector2 rectMax,
out _,
minSidePixels: 0f))
VividTargetProjection projection = ComputeProjection(
target.SelectionSphereCenter,
target.SelectionSphereRadius,
camera.View,
camera.Projection,
camera.Viewport);
if (projection.Status == VividTargetProjectionStatus.Invalid)
{
_root.Visible = false;
Hide();
return;
}
VividTargetLayout layout = ComputeLayout(
rectMin, rectMax, camera.Viewport, _sizes);
_root.Left = layout.RootPosition.X;
_root.Top = layout.RootPosition.Y;
_root.Width = layout.RootSize.X;
_root.Height = layout.RootSize.Y;
for (int i = 0; i < _corners.Length; i++)
SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y);
RadarBlipColors.Rgba color = RadarBlipColors.For(
target.ItemType, target.ObjectDescriptionFlags);
var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha);
for (int i = 0; i < _corners.Length; i++)
_corners[i].Tint = tint;
_root.Visible = true;
if (projection.Status == VividTargetProjectionStatus.OnScreen)
{
VividTargetLayout layout = ComputeLayout(
projection.RectMin, projection.RectMax, camera.Viewport, _cornerSizes);
_onScreen.Left = layout.RootPosition.X;
_onScreen.Top = layout.RootPosition.Y;
_onScreen.Width = layout.RootSize.X;
_onScreen.Height = layout.RootSize.Y;
for (int i = 0; i < _corners.Length; i++)
{
SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y);
_corners[i].Tint = tint;
}
_offScreen.Visible = false;
_onScreen.Visible = true;
return;
}
uint imageEnum = SelectOffScreenImageEnum(projection.AngleDegrees);
VividTargetSource offScreenSource = _sources[imageEnum - FirstSourceImageEnum];
Vector2 position = ComputeOffScreenPosition(
projection.AngleDegrees,
camera.Viewport,
new Vector2(offScreenSource.Width, offScreenSource.Height));
_offScreen.Texture = offScreenSource.Texture;
_offScreen.Left = position.X;
_offScreen.Top = position.Y;
_offScreen.Width = offScreenSource.Width;
_offScreen.Height = offScreenSource.Height;
_offScreen.Tint = tint;
_onScreen.Visible = false;
_offScreen.Visible = true;
}
private void Hide()
{
_onScreen.Visible = false;
_offScreen.Visible = false;
}
private void SetCorner(int index, float left, float top)
@ -149,6 +196,128 @@ public sealed class VividTargetIndicatorController
_corners[index].Top = top;
}
internal static VividTargetProjection ComputeProjection(
Vector3 worldCenter,
float worldRadius,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport)
{
if (viewport.X <= 0f || viewport.Y <= 0f
|| !float.IsFinite(worldRadius) || worldRadius < 0f)
return default;
bool projected = ScreenProjection.TryProjectSphereToScreenRect(
worldCenter,
worldRadius,
view,
projection,
viewport,
out Vector2 rectMin,
out Vector2 rectMax,
out _,
minSidePixels: 0f);
// SmartBox::GetObjectBoundingBox @ 0x00452E20 calls
// Render::set_default_view before viewconeCheck. The selected object is
// therefore tested against the full viewport, not the portal/occlusion
// traversal which happened to draw the world.
if (projected
&& rectMax.X >= 0f && rectMin.X <= viewport.X
&& rectMax.Y >= 0f && rectMin.Y <= viewport.Y)
{
return new VividTargetProjection(
VividTargetProjectionStatus.OnScreen,
rectMin,
rectMax,
0f);
}
Vector3 local = Vector3.Transform(worldCenter, view);
float length = local.Length();
float angle = 0f;
if (float.IsFinite(length) && length > 1e-6f)
{
// Retail viewer-local axes are x=screen-right, y=forward,
// z=screen-up. System.Numerics view space is x=right, y=up,
// -z=forward. When the target is behind the viewer, retail
// deliberately flattens forward/up to zero so the arrow chooses
// the nearest horizontal screen edge.
float right = local.X / length;
float forward = -local.Z / length;
float up = local.Y / length;
float radians = forward > 0f
? MathF.Atan2(right, up)
: MathF.Atan2(right, 0f);
angle = PositiveModulo(
450f - radians * (180f / MathF.PI),
360f);
}
return new VividTargetProjection(
VividTargetProjectionStatus.OffScreen,
default,
default,
angle);
}
internal static uint SelectOffScreenImageEnum(float angleDegrees)
{
float angle = PositiveModulo(angleDegrees, 360f);
// VividTargetIndicator::OnDraw @ 0x004F62B0 uses eight 45-degree
// sectors with integer boundaries centered on the cardinal axes.
if (angle >= 338f || angle < 23f) return 6u;
if (angle < 68f) return 7u;
if (angle < 113f) return 9u;
if (angle < 158f) return 12u;
if (angle < 203f) return 11u;
if (angle < 248f) return 10u;
if (angle < 293f) return 8u;
return 5u;
}
internal static Vector2 ComputeOffScreenPosition(
float angleDegrees,
Vector2 viewport,
Vector2 imageSize)
{
float angleRadians = PositiveModulo(angleDegrees, 360f) * (MathF.PI / 180f);
var direction = new Vector2(MathF.Cos(angleRadians), -MathF.Sin(angleRadians));
var halfImage = imageSize * 0.5f;
var center = viewport * 0.5f;
var minimumCenter = new Vector2(ViewportMargin) + halfImage;
var maximumCenter = viewport - new Vector2(ViewportMargin) - halfImage;
float horizontalDistance = direction.X switch
{
> 1e-6f => (maximumCenter.X - center.X) / direction.X,
< -1e-6f => (minimumCenter.X - center.X) / direction.X,
_ => float.PositiveInfinity,
};
float verticalDistance = direction.Y switch
{
> 1e-6f => (maximumCenter.Y - center.Y) / direction.Y,
< -1e-6f => (minimumCenter.Y - center.Y) / direction.Y,
_ => float.PositiveInfinity,
};
float distance = MathF.Min(horizontalDistance, verticalDistance);
if (!float.IsFinite(distance) || distance < 0f)
distance = 0f;
Vector2 position = center + direction * distance - halfImage;
return new Vector2(
Math.Clamp(position.X, ViewportMargin,
MathF.Max(ViewportMargin, viewport.X - imageSize.X - ViewportMargin)),
Math.Clamp(position.Y, ViewportMargin,
MathF.Max(ViewportMargin, viewport.Y - imageSize.Y - ViewportMargin)));
}
private static float PositiveModulo(float value, float modulus)
{
float result = value % modulus;
return result < 0f ? result + modulus : result;
}
internal static VividTargetLayout ComputeLayout(
Vector2 rectMin,
Vector2 rectMax,
@ -202,3 +371,21 @@ internal readonly record struct VividTargetLayout(
Vector2 RootPosition,
Vector2 RootSize,
IReadOnlyList<Vector2> CornerPositions);
internal enum VividTargetProjectionStatus
{
Invalid,
OnScreen,
OffScreen,
}
internal readonly record struct VividTargetProjection(
VividTargetProjectionStatus Status,
Vector2 RectMin,
Vector2 RectMax,
float AngleDegrees);
internal readonly record struct VividTargetSource(
uint Texture,
float Width,
float Height);