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
|
|
@ -4143,13 +4143,15 @@ play, so this should be scheduled soon after A8 rather than left as polish.
|
|||
|
||||
**Resolution:** Deleted the procedural ImGui triangles and mounted a retained
|
||||
gameplay-UI `VividTargetIndicatorController`. It resolves retail client-enum
|
||||
category `0x10000009`, values `1..4`, to the four installed 12×12 RenderSurface
|
||||
corners; colorizes their grayscale masks with `gmRadarUI::GetBlipColor`
|
||||
equivalent colors; places them outside the exact Setup selection-sphere screen
|
||||
rectangle; and ports retail's separate-edge 8-pixel viewport clamp. The marker
|
||||
is available without devtools and follows only targets reported by the normal
|
||||
world draw. Installed-DAT and layout conformance tests pin the assets and
|
||||
placement.
|
||||
category `0x10000009`, values `1..12`, to the four installed 12×12 corner
|
||||
surfaces and eight 24×24 off-screen direction surfaces; colorizes their
|
||||
grayscale masks with `gmRadarUI::GetBlipColor` equivalent colors; places them
|
||||
outside the exact Setup selection-sphere screen rectangle; and ports retail's
|
||||
8-pixel viewport clamp. The marker is available without devtools, remains
|
||||
visible through walls, has no marker-side distance cutoff, and uses the larger
|
||||
authored direction image when the live target leaves the viewport.
|
||||
Installed-DAT and layout conformance tests pin the assets, direction sectors,
|
||||
and placement.
|
||||
|
||||
**Description:** Per 2026-05-16 user feedback during the
|
||||
`SelectionSphere` indicator ship, the triangle apex direction
|
||||
|
|
|
|||
|
|
@ -524,7 +524,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
||||
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
||||
- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state.
|
||||
- **✓ SHIPPED — retail world selection + complete SmartBox feedback (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
|
||||
- **✓ SHIPPED — retail world selection + complete SmartBox feedback (2026-07-18).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..12` to the four installed 12×12 corner surfaces and eight 24×24 off-screen direction surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle, through-wall/no-distance SmartBox callback, direction sectors, and 8-pixel viewport-edge clamp. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
|
||||
- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||
|
|
|
|||
|
|
@ -125,23 +125,70 @@ enum values `1..12`. Values `1..4` are the four on-screen corners.
|
|||
uses the active retail blit mode (`s_BlitMethod == 4`) to colorize the source
|
||||
art with the same color as the object's radar blip.
|
||||
|
||||
`VividTargetIndicator::OnDraw @ 0x004F62B0` (on-screen branch)
|
||||
`SmartBox::GetObjectBoundingBox @ 0x00452E20`
|
||||
|
||||
```text
|
||||
if disabled, no selected id, or selected part was not reported by this world draw:
|
||||
hide indicator
|
||||
resolve the selected id directly through CObjectMaint::GetObjectA
|
||||
if the object is absent:
|
||||
return ObjectNotFound
|
||||
|
||||
reset to Render::set_default_view
|
||||
push the object's position
|
||||
obtain CPhysicsObj::GetSelectionSphere
|
||||
if no sphere exists:
|
||||
use an origin-centered sphere with radius 0.1 world units
|
||||
|
||||
if Render::viewconeCheck says the sphere intersects the full viewport cone:
|
||||
project Render::GetViewerBBox's two corners
|
||||
return ObjectOnscreen and the resulting screen rectangle
|
||||
|
||||
target = normalized selection-sphere center in viewer-local coordinates
|
||||
if target is in front of the viewer:
|
||||
angle = (450 - degrees(atan2(target.screenRight, target.screenUp))) mod 360
|
||||
else:
|
||||
colorize the four DAT corner images with the selected radar-blip color
|
||||
obtain the selected object's SmartBox screen rectangle
|
||||
use the actual DAT corner width and height
|
||||
place the corner images just outside the rectangle
|
||||
clamp the assembled indicator to an 8-pixel viewport margin
|
||||
show the on-screen root and hide the off-screen root
|
||||
angle = (450 - degrees(atan2(target.screenRight, 0))) mod 360
|
||||
return ObjectOffscreen and angle
|
||||
```
|
||||
|
||||
The indicator is persistent selection presentation. Retail also has a separate,
|
||||
short-lived click confirmation owned by SmartBox; it is not implemented in
|
||||
`CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
|
||||
This path has no object-distance test and does not ask whether the selected
|
||||
object participated in the visible world draw. `set_default_view` also removes
|
||||
the per-portal view cone before the test. Consequently, a live selected object
|
||||
keeps its four corners through walls and at any distance for which it remains in
|
||||
the client object table.
|
||||
|
||||
`VividTargetIndicator::OnDraw @ 0x004F62B0`
|
||||
|
||||
```text
|
||||
if disabled, display-off, or selected id is zero:
|
||||
hide both roots
|
||||
else if status is ObjectOnscreen:
|
||||
colorize source images 1..4 with the selected radar-blip color
|
||||
use the actual DAT corner width and height
|
||||
place the corners just outside the SmartBox screen rectangle
|
||||
clamp the assembled indicator to an 8-pixel viewport margin
|
||||
show the on-screen root and hide the off-screen root
|
||||
else if status is ObjectOffscreen:
|
||||
choose one authored source image by angle:
|
||||
[338,360) or [0,23) -> 6
|
||||
[23,68) -> 7
|
||||
[68,113) -> 9
|
||||
[113,158) -> 12
|
||||
[158,203) -> 11
|
||||
[203,248) -> 10
|
||||
[248,293) -> 8
|
||||
[293,338) -> 5
|
||||
colorize it with the selected radar-blip color
|
||||
intersect the angle ray from viewport center with the screen edge
|
||||
center the image on that intersection
|
||||
clamp its top-left to the 8-pixel viewport margin
|
||||
hide the on-screen root and show the off-screen image
|
||||
```
|
||||
|
||||
Installed retail DAT verification pins images 1..4 at 12×12 pixels and images
|
||||
5..12 at 24×24 pixels. The larger off-screen marker is authored art, not a
|
||||
procedural arrow. The indicator is persistent selection presentation. Retail
|
||||
also has a separate, short-lived click confirmation owned by SmartBox; it is not
|
||||
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
|
||||
|
||||
## SmartBox click lighting pulse
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ using System.Numerics;
|
|||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Shared screen-space projection math for the target indicator and the
|
||||
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/>
|
||||
/// so the click hit-area is guaranteed to match the visible indicator
|
||||
/// rect — "what you see is what you click".
|
||||
/// Screen-space selection-sphere projection used by SmartBox's vivid target
|
||||
/// indicator. World picking separately ports retail's visible GfxObj polygon
|
||||
/// ray tests; the persistent marker intentionally ignores world occlusion.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
|
||||
|
|
@ -35,8 +34,7 @@ public static class ScreenProjection
|
|||
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
|
||||
/// center — use this for nearest-first sorting when multiple rects overlap.</param>
|
||||
/// <param name="minSidePixels">Minimum side length of the rect. Distant
|
||||
/// entities clamp to this so they remain pickable / visible. 12 px
|
||||
/// matches the indicator's clamp floor.</param>
|
||||
/// entities may clamp to this for a stable presentation.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the sphere is in front of the camera and the rect was
|
||||
/// produced; <c>false</c> if the center is behind the camera
|
||||
|
|
@ -74,9 +72,7 @@ public static class ScreenProjection
|
|||
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
|
||||
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
|
||||
|
||||
// Floor at minSidePixels so distant entities still get a visible /
|
||||
// clickable rect. The picker must apply the same floor as the
|
||||
// indicator or distant clicks won't match the visible bracket.
|
||||
// Optional presentation floor for very distant spheres.
|
||||
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
|
||||
|
||||
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ namespace AcDream.App.Tests.Rendering;
|
|||
public sealed class RetailSelectionAssetTests
|
||||
{
|
||||
[Fact]
|
||||
public void InstalledDat_ResolvesAllFourVividTargetCorners()
|
||||
public void InstalledDat_ResolvesAllTwelveVividTargetImages()
|
||||
{
|
||||
string datDir = ResolveDatDir();
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -24,7 +24,7 @@ public sealed class RetailSelectionAssetTests
|
|||
uint subMapDid = master.ClientEnumToID[0x10000009u];
|
||||
var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid));
|
||||
|
||||
for (uint index = 0; index < 4; index++)
|
||||
for (uint index = 0; index < 12; index++)
|
||||
{
|
||||
uint enumValue = 1u + index;
|
||||
uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u);
|
||||
|
|
@ -35,7 +35,8 @@ public sealed class RetailSelectionAssetTests
|
|||
$"enum 0x{enumValue:X8} resolved 0x{did:X8}");
|
||||
Assert.NotNull(surface);
|
||||
Assert.Equal(0x06000000u, did & 0xFF000000u);
|
||||
Assert.Equal((12, 12), (surface.Width, surface.Height));
|
||||
Assert.Equal(index < 4 ? (12, 12) : (24, 24),
|
||||
(surface.Width, surface.Height));
|
||||
|
||||
Palette? palette = surface.DefaultPaletteId != 0
|
||||
? dats.Get<Palette>(surface.DefaultPaletteId)
|
||||
|
|
@ -51,7 +52,7 @@ public sealed class RetailSelectionAssetTests
|
|||
coloredPixels++;
|
||||
}
|
||||
Assert.True(coloredPixels == 0,
|
||||
$"corner {index + 1} has {coloredPixels} non-grayscale source pixels");
|
||||
$"image {index + 1} has {coloredPixels} non-grayscale source pixels");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,4 +37,78 @@ public sealed class VividTargetIndicatorLayoutTests
|
|||
Assert.Equal(new Vector2(8f, 8f), layout.RootPosition);
|
||||
Assert.Equal(new Vector2(66f, 87f), layout.RootSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectionHasNoDistanceOrWorldDrawVisibilityGate()
|
||||
{
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 2f, 4f / 3f, 0.1f, 100f);
|
||||
|
||||
VividTargetProjection result = VividTargetIndicatorController.ComputeProjection(
|
||||
new Vector3(0f, 0f, -1_000_000f),
|
||||
1f,
|
||||
Matrix4x4.Identity,
|
||||
projection,
|
||||
new Vector2(800f, 600f));
|
||||
|
||||
Assert.Equal(VividTargetProjectionStatus.OnScreen, result.Status);
|
||||
Assert.InRange((result.RectMin.X + result.RectMax.X) * 0.5f, 399.9f, 400.1f);
|
||||
Assert.InRange((result.RectMin.Y + result.RectMax.Y) * 0.5f, 299.9f, 300.1f);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100f, 0f, -10f, 0f, 6u)]
|
||||
[InlineData(0f, 100f, -10f, 90f, 9u)]
|
||||
[InlineData(-100f, 0f, -10f, 180f, 11u)]
|
||||
[InlineData(0f, -100f, -10f, 270f, 8u)]
|
||||
public void OffScreenDirectionMatchesRetailViewerAxes(
|
||||
float x, float y, float z, float expectedAngle, uint expectedImageEnum)
|
||||
{
|
||||
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
MathF.PI / 2f, 4f / 3f, 0.1f, 100f);
|
||||
|
||||
VividTargetProjection result = VividTargetIndicatorController.ComputeProjection(
|
||||
new Vector3(x, y, z),
|
||||
1f,
|
||||
Matrix4x4.Identity,
|
||||
projection,
|
||||
new Vector2(800f, 600f));
|
||||
|
||||
Assert.Equal(VividTargetProjectionStatus.OffScreen, result.Status);
|
||||
Assert.Equal(expectedAngle, result.AngleDegrees, precision: 3);
|
||||
Assert.Equal(expectedImageEnum,
|
||||
VividTargetIndicatorController.SelectOffScreenImageEnum(result.AngleDegrees));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 768f, 288f)]
|
||||
[InlineData(90f, 388f, 8f)]
|
||||
[InlineData(180f, 8f, 288f)]
|
||||
[InlineData(270f, 388f, 568f)]
|
||||
public void OffScreenImageClampsToRetailEightPixelEdge(
|
||||
float angle, float expectedLeft, float expectedTop)
|
||||
{
|
||||
Vector2 position = VividTargetIndicatorController.ComputeOffScreenPosition(
|
||||
angle,
|
||||
new Vector2(800f, 600f),
|
||||
new Vector2(24f, 24f));
|
||||
|
||||
Assert.Equal(expectedLeft, position.X, precision: 3);
|
||||
Assert.Equal(expectedTop, position.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 6u)]
|
||||
[InlineData(22.999f, 6u)]
|
||||
[InlineData(23f, 7u)]
|
||||
[InlineData(68f, 9u)]
|
||||
[InlineData(113f, 12u)]
|
||||
[InlineData(158f, 11u)]
|
||||
[InlineData(203f, 10u)]
|
||||
[InlineData(248f, 8u)]
|
||||
[InlineData(293f, 5u)]
|
||||
[InlineData(338f, 6u)]
|
||||
public void DirectionImageUsesRetailSectorBoundaries(float angle, uint expected)
|
||||
=> Assert.Equal(expected,
|
||||
VividTargetIndicatorController.SelectOffScreenImageEnum(angle));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue