feat(selection): port retail polygon picking and vivid marker
Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks. Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp. Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0f82a08f0a
commit
146a963aeb
26 changed files with 1302 additions and 1340 deletions
|
|
@ -1,286 +1,54 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
|
||||
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
|
||||
/// </list>
|
||||
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
|
||||
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
|
||||
/// </summary>
|
||||
public static class WorldPicker
|
||||
{
|
||||
/// <summary>
|
||||
/// Unprojects a pixel coordinate to a world-space ray using the supplied
|
||||
/// view + projection matrices (System.Numerics row-vector convention,
|
||||
/// composed as view * projection — same as the rest of acdream's camera
|
||||
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
|
||||
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
|
||||
/// row-vector convention (<c>view * projection</c>).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// (origin = world point on the near plane, direction = normalized
|
||||
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
|
||||
/// if the view-projection composition is singular.
|
||||
/// The camera viewpoint and normalized direction, or two zero vectors
|
||||
/// when the camera transform is singular.
|
||||
/// </returns>
|
||||
public static (Vector3 Origin, Vector3 Direction) BuildRay(
|
||||
float mouseX, float mouseY,
|
||||
float viewportW, float viewportH,
|
||||
Matrix4x4 view, Matrix4x4 projection)
|
||||
float mouseX,
|
||||
float mouseY,
|
||||
float viewportW,
|
||||
float viewportH,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection)
|
||||
{
|
||||
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
|
||||
float ndcX = (2f * mouseX) / viewportW - 1f;
|
||||
float ndcY = 1f - (2f * mouseY) / viewportH;
|
||||
|
||||
var vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out var invVp))
|
||||
Matrix4x4 vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|
||||
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
|
||||
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
|
||||
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
|
||||
var n4 = Vector4.Transform(nearClip, invVp);
|
||||
var f4 = Vector4.Transform(farClip, invVp);
|
||||
if (n4.W == 0f || f4.W == 0f)
|
||||
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
|
||||
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
|
||||
Vector4 near = Vector4.Transform(nearClip, invVp);
|
||||
Vector4 far = Vector4.Transform(farClip, invVp);
|
||||
if (near.W == 0f || far.W == 0f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
|
||||
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
|
||||
var dir = farWorld - nearWorld;
|
||||
if (dir.LengthSquared() < 1e-10f)
|
||||
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
|
||||
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
|
||||
Vector3 direction = farWorld - nearWorld;
|
||||
if (direction.LengthSquared() < 1e-10f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
return (nearWorld, Vector3.Normalize(dir));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
|
||||
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
|
||||
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
|
||||
/// </summary>
|
||||
/// <param name="direction">
|
||||
/// World-space ray direction. <b>Must be normalized</b> — the geometric
|
||||
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
|
||||
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
|
||||
/// makes <c>maxDistance</c> compare against ray-parameter units instead
|
||||
/// of world meters.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
|
||||
/// statics) are skipped — they have no server-side identity and can't be
|
||||
/// the target of a Use packet. The player's own guid is skipped via
|
||||
/// <paramref name="skipServerGuid"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
|
||||
/// in practice this over-picked massively — any cursor anywhere near an
|
||||
/// NPC selected the NPC instead of a nearby item, and "click empty
|
||||
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
|
||||
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
|
||||
/// most items. A future refinement is per-itemType radius (smaller for
|
||||
/// tapers, bigger for shop chests) or priority sorting (items beat
|
||||
/// NPCs at equal hit-distance).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static uint? Pick(
|
||||
Vector3 origin, Vector3 direction,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
float maxDistance = 50f,
|
||||
Func<uint, float>? radiusForGuid = null,
|
||||
Func<uint, float>? verticalOffsetForGuid = null,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
const float DefaultRadius = 1.0f;
|
||||
const float DefaultVerticalOffset = 0.9f;
|
||||
|
||||
if (direction.LengthSquared() < 1e-10f) return null;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): if the caller provides
|
||||
// a cell-BSP occluder, query the nearest wall hit along the ray
|
||||
// ONCE; entities whose ray-t exceeds the wall-t sit behind a wall
|
||||
// and are skipped.
|
||||
float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestT = float.PositiveInfinity;
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
// Per-entity radius + vertical offset (caller-supplied).
|
||||
//
|
||||
// <para>
|
||||
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
|
||||
// is at the entity's feet (Z=ground for a humanoid). User
|
||||
// clicks usually land on chest/head (Z ≈ 1–1.8 m). With the
|
||||
// sphere centred at feet, a chest click is 1.2 m of vertical
|
||||
// distance from sphere centre — bigger than any reasonable
|
||||
// body radius — so the ray misses. Lifting the sphere
|
||||
// centre to mid-body fixes this: 0.9 m default for
|
||||
// humanoids, smaller for items, larger for tall objects.
|
||||
// </para>
|
||||
//
|
||||
// <para>
|
||||
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
|
||||
// accommodate the new vertical-offset sphere placement
|
||||
// (chest-height sphere centre + 1.0 m radius covers from
|
||||
// shin to top-of-head for a 1.8 m humanoid).
|
||||
// </para>
|
||||
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
|
||||
float r2 = r * r;
|
||||
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
|
||||
var sphereCenter = new Vector3(
|
||||
entity.Position.X,
|
||||
entity.Position.Y,
|
||||
entity.Position.Z + vz);
|
||||
|
||||
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
|
||||
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
|
||||
// the ray misses the sphere. Otherwise nearest intersection is
|
||||
// t = -b - sqrt(discriminant).
|
||||
var oc = origin - sphereCenter;
|
||||
float b = Vector3.Dot(oc, direction);
|
||||
float c = Vector3.Dot(oc, oc) - r2;
|
||||
float d = b * b - c;
|
||||
if (d < 0f) continue;
|
||||
|
||||
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
|
||||
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
|
||||
// to t_far so the entity is still pickable at point-blank range.
|
||||
float sqrtD = MathF.Sqrt(d);
|
||||
float t = -b - sqrtD;
|
||||
if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit
|
||||
if (t < 0f) continue; // both roots negative -> sphere entirely behind ray
|
||||
if (t >= maxDistance) continue;
|
||||
if (t >= wallT) continue; // wall is between camera and entity (#86)
|
||||
if (t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
|
||||
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
|
||||
/// projects to a screen-space rectangle through
|
||||
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
|
||||
/// rect is inflated by <paramref name="inflatePixels"/> on every side
|
||||
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
|
||||
/// hit-tested against the mouse pixel. Among rects that contain the
|
||||
/// mouse, the entity with the nearest camera-space depth wins.
|
||||
///
|
||||
/// <para>
|
||||
/// Why screen-space instead of world-space ray-sphere: the indicator
|
||||
/// draws a screen-space RECT. A world-space sphere projects to a
|
||||
/// screen CIRCLE inscribed in that rect — leaving the four rect
|
||||
/// corners as click dead zones. Per user feedback 2026-05-16, the
|
||||
/// click area must match the visible indicator extent exactly. By
|
||||
/// sharing the <see cref="ScreenProjection"/> helper with
|
||||
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
|
||||
/// cannot drift.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Resolver returning <c>null</c> skips the candidate (matches retail
|
||||
/// "no Setup → not pickable" behavior). Entities with
|
||||
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
|
||||
/// guid are also skipped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Stage B (polygon refine via
|
||||
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
|
||||
/// per issue #71 — only needed if visual testing surfaces a Stage A
|
||||
/// over-pick on entities whose visible mesh is well inside the
|
||||
/// indicator rect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="inflatePixels">Pixel inflate on each side of the
|
||||
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
|
||||
/// so the click area extends to where the visible bracket corners
|
||||
/// sit — the user perceives the inflated rect as the clickable area.</param>
|
||||
public static uint? Pick(
|
||||
float mouseX, float mouseY,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection,
|
||||
Vector2 viewport,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
float inflatePixels = 8f,
|
||||
Func<Vector3, Vector3, float>? cellOccluder = null)
|
||||
{
|
||||
uint? bestGuid = null;
|
||||
float bestDepth = float.PositiveInfinity;
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion.
|
||||
// Build the click ray, query the nearest wall along it, convert
|
||||
// to the same camera-space depth metric (clip.W) that
|
||||
// ScreenProjection.TryProjectSphereToScreenRect returns per
|
||||
// candidate. Candidates with depth > wallDepth sit behind a wall.
|
||||
float wallDepth = float.PositiveInfinity;
|
||||
if (cellOccluder is not null)
|
||||
{
|
||||
var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
|
||||
if (rayDir.LengthSquared() > 0f)
|
||||
{
|
||||
float wallT = cellOccluder(rayOrigin, rayDir);
|
||||
if (!float.IsPositiveInfinity(wallT))
|
||||
{
|
||||
var wallPoint = rayOrigin + rayDir * wallT;
|
||||
// ScreenProjection uses clip.W as its depth metric —
|
||||
// "camera-space depth" in the row-vector convention is
|
||||
// the W component of the homogeneous clip-space vector,
|
||||
// which equals the eye-space Z distance to the point.
|
||||
var viewProj = view * projection;
|
||||
var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj);
|
||||
if (clip.W > 0f)
|
||||
wallDepth = clip.W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in candidates)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
var sphere = sphereForEntity(entity);
|
||||
if (sphere is null) continue;
|
||||
var (center, radius) = sphere.Value;
|
||||
if (radius <= 0f) continue;
|
||||
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
center, radius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out var depth))
|
||||
continue;
|
||||
|
||||
// Inflate by inflatePixels on each side — extend hit area to
|
||||
// where the indicator brackets sit.
|
||||
float minX = rMin.X - inflatePixels;
|
||||
float minY = rMin.Y - inflatePixels;
|
||||
float maxX = rMax.X + inflatePixels;
|
||||
float maxY = rMax.Y + inflatePixels;
|
||||
|
||||
if (mouseX < minX || mouseX > maxX) continue;
|
||||
if (mouseY < minY || mouseY > maxY) continue;
|
||||
|
||||
if (depth > wallDepth) continue; // wall is between camera and entity (#86)
|
||||
|
||||
if (depth < bestDepth)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
|
||||
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
|
||||
// not the near-plane point. Inverse-view origin is that viewpoint in
|
||||
// System.Numerics' row-vector convention.
|
||||
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
|
||||
return (viewpoint, Vector3.Normalize(direction));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue