feat(ui): port retail radar and compass

This commit is contained in:
Erik 2026-07-10 16:14:37 +02:00
parent c4af181b92
commit 3cbe4b00a1
43 changed files with 2882 additions and 262 deletions

View file

@ -172,103 +172,88 @@ public sealed class VitalOrb
### A.2 Radar / compass
The radar is the canonical "small circular polar plot of nearby creatures
with the player at the center". The compass is a thin bar across the top of
the screen showing the 16 cardinal directions as the camera rotates.
Retail implements both pieces as one `gmRadarUI` element, not as a radar
plus a separate scrolling strip. Primary sources are the named September
2013 decomp (`gmRadarUI::UpdateCompassTokens`, `DrawObjects`,
`UpdateCoordinates`, and `DrawChildren`) and production `LayoutDesc
0x21000074` in `2026-06-25-retail-ui-layout-dump.json`. The earlier AC2D
comparison in this section was useful secondary evidence but its `1.18`
projection, rotating player arrow, and horizontal compass strip are not
retail behavior.
**Radar — data sources**
**Production layout (`0x21000074`, root type `0x10000010`):**
- Player world position (`Position` packet, 24-byte LandCell + XYZ).
- Every nearby object's `CreateObject` / `UpdatePosition` with heading.
- Per-object `RadarColor` override (hostile = red, green = friendly NPC,
etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC).
- 120×140 root (`gmRadarUI`).
- Root custom properties: center `(60,60)` and radar pixel radius `50`.
- Circular face `0x06004CC1` at `(0,0)`, 120×120.
- Coordinate footer `0x06004CC0` at `(0,120)`, 120×18.
- N/E/S/W token sprites `0x060011FB`, `0x06001938`, `0x0600193A`,
`0x0600193C`, each 10×9 and initially centered at the four cardinal
points.
- Lock/unlock sprites `0x060074B7` / `0x060074B8`; drag sprite
`0x060074C9`.
**Radar — retail dat IDs** (AC2D, `cInterface.cpp:139-144`):
- `0x06001388` — radar window titlebar/toolbar icon.
- `0x06004CC1` — radar background art (the circular bezel).
**Radar — the player arrow + blip placement**
AC2D `cRadar::OnRender` (`cCustomWindows.h:1004-1070`) is the clearest
retail-equivalent. The math:
**Projection (`gmRadarUI::DrawObjects` `0x004D9380`):**
```text
for each nearby object obj:
delta = obj.pos - player.pos
delta = delta.RotateAround(Z, -player.heading) // align to radar-up = camera-forward
screen.x = radar.left + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2)
screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2)
color = PickRadarColor(obj.radar_override, obj.flags2)
DrawQuad2x2(screen, color)
relative = object position converted into the physical player's oriented frame
range = outdoors ? 75 : 25
if relative.X² + relative.Y² >= (range - 1)²:
skip
scale = radarPixelRadius / range
pixelX = trunc(centerX + relative.X * scale)
pixelY = trunc(centerY - relative.Y * scale)
rgbMod = abs(relative.Z) < 5 ? 1.0 : 0.65
```
The `1.18` factor is retail-observed — it shrinks the effective range
slightly so blips near the edge stay visible before the bezel clips them.
Player's own arrow is NOT in the blip loop; it is drawn as a fixed
centered sprite (the "player dot") rotated by `player.heading`.
The player's own object is excluded. `DrawChildren` paints a fixed
bright-green plus at the center after the object blips; it is not a rotating
arrow. Hover chooses the nearest blip within 6 pixels, exposes its object
name as the tooltip, and clicking selects that object.
**Compass — data sources**
- `player.heading` in radians.
**Compass — how the rose is drawn**
The rose is a **seamless horizontal strip texture** where 360° is tiled
across some multiple of the screen width. The U-offset is
`heading_normalized * strip_u_period`, with the visible portion cropped to
a narrow strip at the top of the screen. This is the classic "scrolling
texture" approach used by most 3D clients; retail AC follows it.
Holtburger's TUI compass (`hud/status.rs:11-18`) enumerates the 16
cardinal-direction labels that retail paints onto the strip:
**Compass tokens (`gmRadarUI::UpdateCompassTokens` `0x004D9060`):**
```text
["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE",
"E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"]
tokenX = centerX + sin(angle) * tokenMagnitude - tokenWidth/2
tokenY = centerY + cos(angle) * tokenMagnitude - tokenHeight/2
N angle = heading + π
E angle = heading + π/2
S angle = heading
W angle = heading + 3π/2
```
22.5° per segment, first label centered on 11.25°.
Heading comes from the physical player in degrees, not from the camera.
There is no numeric heading label in `gmRadarUI`.
**Render-path pseudocode:**
**Visibility, color, and shapes:**
```text
function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h):
heading_deg = (heading_rad * 180 / PI) mod 360
// Texture is 360° wide in world-space; we crop to bar_w centered on heading
u_center = heading_deg / 360 // 0..1
u_half = bar_w / strip_texture_w / 2
u_left = u_center - u_half
u_right = u_center + u_half
DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h,
u0=u_left, u1=u_right, v0=0, v1=1)
```
- `RadarEnum`: undefined 0, never 1, movement 2, attacking 3, always 4.
Retail accepts 2/3/4 as showable and does not invent a client-side
movement/attack filter.
- The optional PublicWeenieDesc bytes are `RadarBlipColor` (header flag
`0x00100000`) and `RadarBehavior` (`0x00800000`).
- `_blipColor` overrides 1..10 select the exact blue, gold, white, purple,
red, pink, green, yellow, cyan, and bright-green retail constants.
- Default classification covers portals, vendors, attackable creatures,
visible admins, PK/PKLite/free-PK players, and fellowship overrides.
- Marker shapes are point, hollow box, X, plus, triangle, inverted triangle,
and X-box. Fellowship/allegiance/PK relationships select the special
shapes; the ordinary default is a five-pixel plus.
**C# port sketch:**
**Coordinates (`gmRadarUI::UpdateCoordinates` `0x004D8C80`):**
```csharp
public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading,
Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range)
{
r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);
For an outdoor cell, `LandDefs::gid_to_lcoord` supplies integer landscape
cell X/Y. Each axis becomes `(lcoord - 1024) * 0.1 + 0.5`, then formats as
`{abs(Y):0.0}{N|S},{abs(X):0.0}{E|W}` (no space). The footer is hidden for
indoor cells or when `CoordinatesOnRadar` (`0x00400000`) is disabled.
var cx = bounds.X + bounds.Width * 0.5f;
var cy = bounds.Y + bounds.Height * 0.5f;
foreach (var e in nearby)
{
var d = e.Position - playerPos;
d = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading));
var sx = cx + (d.X / (1.18f * range)) * (bounds.Width * 0.5f);
var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f);
var col = PickRadarColor(e);
r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col);
}
// Player arrow - always on top
r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading);
}
```
`gmRadarUI::UseTime` refreshes range, tokens, coordinates, and dirty state
every 25 ms (40 Hz). Static art remains data-driven through the retained
LayoutDesc importer; dynamic behavior belongs in a dedicated retained
radar widget/controller, with `GameWindow` limited to state providers.
### A.3 Quickbar / hotbar