392 lines
14 KiB
C#
392 lines
14 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.Content;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
public readonly record struct VividTargetInfo(
|
|
Vector3 SelectionSphereCenter,
|
|
float SelectionSphereRadius,
|
|
uint ItemType,
|
|
uint ObjectDescriptionFlags);
|
|
|
|
public sealed record VividTargetRuntimeBindings(
|
|
SelectionState Selection,
|
|
Func<uint> PlayerGuid,
|
|
Func<bool> Enabled,
|
|
Func<uint, VividTargetInfo?> ResolveTarget,
|
|
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera);
|
|
|
|
/// <summary>
|
|
/// Retained-UI port of <c>VividTargetIndicator @ 0x004F5CE0..0x004F6DF6</c>.
|
|
/// 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 _onScreen;
|
|
private readonly UiTextureElement[] _corners;
|
|
private readonly UiTextureElement _offScreen;
|
|
private readonly VividTargetRuntimeBindings _bindings;
|
|
private readonly VividTargetSource[] _sources;
|
|
private readonly (float Width, float Height)[] _cornerSizes;
|
|
|
|
private VividTargetIndicatorController(
|
|
UiPanel onScreen,
|
|
UiTextureElement[] corners,
|
|
UiTextureElement offScreen,
|
|
VividTargetSource[] sources,
|
|
VividTargetRuntimeBindings bindings)
|
|
{
|
|
_onScreen = onScreen;
|
|
_corners = corners;
|
|
_offScreen = offScreen;
|
|
_sources = sources;
|
|
_cornerSizes = sources.Take(4)
|
|
.Select(source => (source.Width, source.Height))
|
|
.ToArray();
|
|
_bindings = bindings;
|
|
}
|
|
|
|
public static VividTargetIndicatorController? Mount(
|
|
UiRoot host,
|
|
RetailUiAssets assets,
|
|
VividTargetRuntimeBindings bindings)
|
|
{
|
|
var onScreen = new UiPanel
|
|
{
|
|
Name = "VividTargetIndicatorOnScreen",
|
|
BackgroundColor = Vector4.Zero,
|
|
BorderColor = Vector4.Zero,
|
|
ClickThrough = true,
|
|
Visible = false,
|
|
ZOrder = -10_000,
|
|
Anchors = AnchorEdges.None,
|
|
};
|
|
|
|
var sources = new VividTargetSource[SourceImageCount];
|
|
for (uint i = 0; i < SourceImageCount; i++)
|
|
{
|
|
uint did;
|
|
lock (assets.DatLock)
|
|
did = RetailDataIdResolver.Resolve(
|
|
assets.Dats,
|
|
enumValue: FirstSourceImageEnum + i,
|
|
enumCategory: ClientEnumCategory);
|
|
if (did == 0u)
|
|
return null;
|
|
|
|
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);
|
|
}
|
|
|
|
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 = source.Texture,
|
|
Width = source.Width,
|
|
Height = source.Height,
|
|
ClickThrough = true,
|
|
Anchors = AnchorEdges.None,
|
|
};
|
|
onScreen.AddChild(corners[i]);
|
|
}
|
|
|
|
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()
|
|
{
|
|
if (!_bindings.Enabled()
|
|
|| _bindings.Selection.SelectedObjectId is not uint guid
|
|
|| guid == 0u
|
|
|| guid == _bindings.PlayerGuid()
|
|
|| _bindings.ResolveTarget(guid) is not VividTargetInfo target)
|
|
{
|
|
Hide();
|
|
return;
|
|
}
|
|
|
|
var camera = _bindings.Camera();
|
|
VividTargetProjection projection = ComputeProjection(
|
|
target.SelectionSphereCenter,
|
|
target.SelectionSphereRadius,
|
|
camera.View,
|
|
camera.Projection,
|
|
camera.Viewport);
|
|
if (projection.Status == VividTargetProjectionStatus.Invalid)
|
|
{
|
|
Hide();
|
|
return;
|
|
}
|
|
|
|
RadarBlipColors.Rgba color = RadarBlipColors.For(
|
|
target.ItemType, target.ObjectDescriptionFlags);
|
|
var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha);
|
|
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)
|
|
{
|
|
_corners[index].Left = left;
|
|
_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,
|
|
Vector2 viewport,
|
|
IReadOnlyList<(float Width, float Height)> sizes)
|
|
{
|
|
if (sizes.Count != 4)
|
|
throw new ArgumentException("Retail VividTargetIndicator has exactly four corners.", nameof(sizes));
|
|
|
|
// VividTargetIndicator::OnDraw @ 0x004F69C8..0x004F6A99 reads
|
|
// corner[1]'s dimensions, expands the SmartBox rectangle by one
|
|
// corner on the top/left, then clamps each rectangle edge separately.
|
|
// Partially off-screen targets therefore shrink the assembled region;
|
|
// retail does not translate the whole un-clipped box back on-screen.
|
|
float cornerWidth = sizes[0].Width;
|
|
float cornerHeight = sizes[0].Height;
|
|
float outerLeft = rectMin.X - cornerWidth;
|
|
float outerTop = rectMin.Y - cornerHeight;
|
|
float outerRight = rectMax.X;
|
|
float outerBottom = rectMax.Y;
|
|
|
|
if (outerLeft > outerRight) outerLeft = outerRight - 1f;
|
|
if (outerTop > outerBottom) outerTop = outerBottom - 1f;
|
|
outerLeft = MathF.Max(outerLeft, ViewportMargin);
|
|
outerTop = MathF.Max(outerTop, ViewportMargin);
|
|
outerRight = MathF.Max(outerRight, cornerWidth + ViewportMargin);
|
|
outerBottom = MathF.Max(outerBottom, cornerHeight + ViewportMargin);
|
|
|
|
float maximumRight = viewport.X - cornerWidth - ViewportMargin;
|
|
float maximumBottom = viewport.Y - cornerHeight - ViewportMargin;
|
|
outerLeft = MathF.Min(outerLeft, maximumRight - cornerWidth);
|
|
outerTop = MathF.Min(outerTop, maximumBottom - cornerHeight);
|
|
outerRight = MathF.Min(outerRight, maximumRight);
|
|
outerBottom = MathF.Min(outerBottom, maximumBottom);
|
|
Vector2 rootSize = new(
|
|
MathF.Max(1f, outerRight - outerLeft + cornerWidth),
|
|
MathF.Max(1f, outerBottom - outerTop + cornerHeight));
|
|
return new VividTargetLayout(
|
|
new Vector2(outerLeft, outerTop),
|
|
rootSize,
|
|
[
|
|
Vector2.Zero,
|
|
new Vector2(rootSize.X - sizes[1].Width, 0f),
|
|
new Vector2(rootSize.X - sizes[2].Width, rootSize.Y - sizes[2].Height),
|
|
new Vector2(0f, rootSize.Y - sizes[3].Height),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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);
|