Files
Project-M/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs
T
2026-06-03 13:46:13 -07:00

193 lines
8.7 KiB
C#

using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only aim presentation: a flat world-space ground ring marks where the local player is aiming,
/// and the hardware cursor is hidden while aiming so the grounded ring IS the cursor. A managed
/// presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> (once per frame, no
/// rollback double-fire) that only OBSERVES replicated state — never mutates the sim.
/// <list type="bullet">
/// <item>Mouse/keyboard: the ring sits at the cursor's ground-projection point, re-raycast every frame
/// HERE (PresentationSystemGroup runs after the camera's LateUpdate move, so the ring tracks the cursor
/// without a one-frame follow-cam drift). The last valid point is held when the projection misses.</item>
/// <item>Gamepad: the ring sits a fixed distance ahead of the player along the replicated
/// <see cref="PlayerFacing"/>.</item>
/// </list>
/// Asset-free (a procedural ring texture on a primitive quad, like <c>HudSystem</c>'s code-built uGUI).
/// The OS cursor is hidden only while a local player exists AND the app is focused, and is restored on
/// focus loss / destroy so the editor is never left with an invisible pointer.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class AimReticleSystem : SystemBase
{
const float ReticleDistance = 4f; // gamepad world reticle distance ahead of the player
const float ReticleSize = 1.4f; // world units (quad scale)
const float ReticleLiftY = 0.05f; // lift above the movement plane to avoid z-fighting
GameObject _reticle;
Material _reticleMat;
Texture2D _ringTex;
UnityEngine.Camera _camera; // for the KBM cursor -> ground re-raycast (resolved lazily)
float3 _lastKbmGroundPoint; // last valid cursor ground point (held when the projection misses)
bool _haveKbmPoint; // true after the first valid KBM ground hit
bool _cursorHidden; // tracks the applied Cursor.visible state (avoid per-frame churn)
bool _cursorTouched; // we changed the OS cursor at least once -> restore on destroy
protected override void OnStartRunning()
{
if (_reticle == null) BuildReticle();
}
protected override void OnUpdate()
{
byte scheme = AimPresentation.Scheme;
if (_camera == null) _camera = ResolveCamera();
bool haveTarget = false;
float3 ringPos = default;
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<PlayerFacing>();
foreach (var (xform, facing) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
float3 playerPos = xform.ValueRO.Position;
if (scheme == InputSchemeId.Gamepad)
{
float2 dir = facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
ringPos = playerPos + new float3(dir.x, 0f, dir.y) * ReticleDistance;
}
else
{
// KBM: project the LIVE cursor onto the player's plane this frame (camera already moved).
if (!_haveKbmPoint) _lastKbmGroundPoint = playerPos + new float3(0f, 0f, 1f);
var mouse = UnityEngine.InputSystem.Mouse.current;
if (_camera != null && mouse != null)
{
UnityEngine.Vector2 sp = mouse.position.ReadValue();
UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(sp.x, sp.y, 0f));
if (AimMath.TryGroundHit((float3)ray.origin, (float3)ray.direction, playerPos.y, out var hit))
{
_lastKbmGroundPoint = hit;
_haveKbmPoint = true;
}
}
ringPos = _lastKbmGroundPoint;
}
ringPos.y += ReticleLiftY;
haveTarget = true;
break;
}
if (_reticle != null)
{
if (haveTarget) _reticle.transform.position = (Vector3)ringPos;
if (_reticle.activeSelf != haveTarget) _reticle.SetActive(haveTarget);
}
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
// unfocused editor or a windowed session is never stranded with an invisible pointer.
bool wantHidden = haveTarget && Application.isFocused;
if (wantHidden != _cursorHidden)
{
if (wantHidden) Cursor.lockState = CursorLockMode.None;
Cursor.visible = !wantHidden;
_cursorHidden = wantHidden;
_cursorTouched = true;
}
}
UnityEngine.Camera ResolveCamera()
{
var cam = UnityEngine.Camera.main;
if (cam == null)
{
var rig = UnityEngine.Object.FindAnyObjectByType<PrototypeCameraRig>();
if (rig != null) cam = rig.GetComponent<UnityEngine.Camera>();
}
return cam;
}
protected override void OnDestroy()
{
// Restore the default OS cursor so we never leave the editor with a hidden pointer.
if (_cursorTouched)
{
Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
Cursor.visible = true;
}
if (_reticle != null) Object.Destroy(_reticle);
if (_reticleMat != null) Object.Destroy(_reticleMat);
if (_ringTex != null) Object.Destroy(_ringTex);
}
void BuildReticle()
{
_ringTex = BuildRingTexture(64);
var shader = Shader.Find("Sprites/Default"); // double-sided, transparent — proven in this URP project
if (shader == null) shader = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (shader == null) { Debug.LogError("AimReticleSystem: no reticle shader found"); return; }
_reticleMat = new Material(shader) { mainTexture = _ringTex, color = new Color(0.55f, 0.9f, 1f, 0.95f) };
_reticle = GameObject.CreatePrimitive(PrimitiveType.Quad);
_reticle.name = "~AimReticle";
var col = _reticle.GetComponent<Collider>();
if (col != null) Object.Destroy(col); // cosmetic only — keep it out of any physics
var mr = _reticle.GetComponent<MeshRenderer>();
mr.sharedMaterial = _reticleMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
_reticle.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // lay flat on the ground
_reticle.transform.localScale = new Vector3(ReticleSize, ReticleSize, 1f);
_reticle.SetActive(false);
}
// ---- procedural ring texture (asset-free, like HudSystem's code-built uGUI) ----
static Texture2D BuildRingTexture(int size)
{
var tex = new Texture2D(size, size, TextureFormat.RGBA32, false)
{ wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear };
float c = (size - 1) * 0.5f;
const float outer = 0.92f, inner = 0.60f, dotR = 0.12f;
float feather = 1.5f / c;
var px = new Color32[size * size];
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float dx = (x - c) / c, dy = (y - c) / c;
float r = math.sqrt(dx * dx + dy * dy);
float ringA = Smooth(r, inner, inner + feather) * (1f - Smooth(r, outer - feather, outer));
float dotA = 1f - Smooth(r, dotR, dotR + feather);
float a = math.saturate(math.max(ringA, dotA));
px[y * size + x] = new Color32(255, 255, 255, (byte)(a * 255f));
}
tex.SetPixels32(px);
tex.Apply();
return tex;
}
static float Smooth(float x, float a, float b)
{
float t = math.saturate((x - a) / math.max(1e-5f, b - a));
return t * t * (3f - 2f * t);
}
}
}