feat(selection): port retail offscreen target indicator
This commit is contained in:
parent
ea4f52ec51
commit
ec1bb19609
9 changed files with 404 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue