Files
2026-06-03 13:46:13 -07:00

69 lines
3.9 KiB
C#

using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic, Burst-safe helpers for turning a screen-cursor camera ray into a planar (XZ)
/// aim direction (and the ground point it targets) for the top-down player. Isolated from the managed
/// <c>Camera</c> so the projection is unit-testable without a render context (mirrors
/// <see cref="AutoTarget"/> / <c>CharacterControlMath</c> / <c>BaseGridMath</c>). Used client-side by the
/// input gather (facing direction) and the aim-reticle presentation (the world ground point), but kept
/// here in Simulation alongside the other pure combat/movement math.
/// </summary>
public static class AimMath
{
/// <summary>
/// Intersects a camera ray with the horizontal plane at <paramref name="planeY"/>. Returns false (and
/// <paramref name="hit"/> = default) when the ray is parallel to / points away from the plane (cursor
/// above the horizon); otherwise <paramref name="hit"/> is the world-space intersection point.
/// </summary>
/// <param name="rayOrigin">Camera ray origin (world space).</param>
/// <param name="rayDir">Camera ray direction (need not be normalized).</param>
/// <param name="planeY">World Y of the aim plane (the player's movement plane).</param>
/// <param name="hit">The world-space ground intersection point, when this returns true.</param>
public static bool TryGroundHit(float3 rayOrigin, float3 rayDir, float planeY, out float3 hit)
{
hit = default;
// Solve rayOrigin.y + t * rayDir.y == planeY. A near-zero rayDir.y means the ray runs parallel.
float denom = rayDir.y;
if (math.abs(denom) < 1e-6f)
return false;
float t = (planeY - rayOrigin.y) / denom;
if (t < 0f)
return false; // intersection behind the ray origin (cursor above the horizon line)
hit = rayOrigin + rayDir * t;
return true;
}
/// <summary>
/// Normalized planar aim direction (world XZ mapped to <c>float2(x, y)</c>) from
/// <paramref name="playerPos"/> toward the cursor's ground-projection point. Returns
/// <paramref name="fallback"/> when the ray misses the plane, OR the hit lies within
/// <paramref name="deadZoneRadius"/> of the player (too close to define a stable heading), so the
/// caller holds its previous aim instead of snapping or spinning when the cursor is over the character.
/// </summary>
/// <param name="rayOrigin">Camera ray origin (world space).</param>
/// <param name="rayDir">Camera ray direction (need not be normalized).</param>
/// <param name="playerPos">Player world position; only XZ is used for the heading.</param>
/// <param name="planeY">World Y of the aim plane.</param>
/// <param name="fallback">Direction returned when there is no valid hit (e.g. the previous aim).</param>
/// <param name="deadZoneRadius">
/// World radius around the player inside which facing is held (no update). 0 keeps only the tiny
/// epsilon guard against an exactly-coincident hit (the original behaviour).
/// </param>
public static float2 PlanarAimFromRay(float3 rayOrigin, float3 rayDir, float3 playerPos, float planeY,
float2 fallback, float deadZoneRadius = 0f)
{
if (!TryGroundHit(rayOrigin, rayDir, planeY, out var hit))
return fallback;
float2 planar = new float2(hit.x - playerPos.x, hit.z - playerPos.z);
float dz = math.max(1e-3f, deadZoneRadius); // never below the original coincident-hit epsilon
if (math.lengthsq(planar) < dz * dz)
return fallback;
return math.normalize(planar);
}
}
}