using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
///
/// 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 in (once per frame, no
/// rollback double-fire) that only OBSERVES replicated state — never mutates the sim.
///
/// - 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.
/// - Gamepad: the ring sits a fixed distance ahead of the player along the replicated
/// .
///
/// Asset-free (a procedural ring texture on a primitive quad, like HudSystem'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.
///
[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;
GameObject _tether;
LineRenderer _tetherLine;
Material _tetherMat; // 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;
float3 lpPos = default;
float2 lpFacing = default;
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
foreach (var (xform, facing) in
SystemAPI.Query, RefRO>()
.WithAll())
{
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;
lpPos = playerPos;
lpFacing = facing.ValueRO.Direction;
haveTarget = true;
break;
}
if (_reticle != null)
{
if (haveTarget) _reticle.transform.position = (Vector3)ringPos;
if (_reticle.activeSelf != haveTarget) _reticle.SetActive(haveTarget);
}
// Lock-on tether (cosmetic aim HINT - the client computes nearest enemy itself; the server's actual
// gamepad auto-target cone may differ, so divergence is acceptable, not a bug).
bool tetherShown = false;
if (_tetherLine != null && haveTarget && FeelConfig.LockOnEnabled
&& (!FeelConfig.LockOnGamepadOnly || scheme == InputSchemeId.Gamepad))
{
float2 fdir = lpFacing;
if (math.lengthsq(fdir) < 1e-6f) fdir = new float2(0f, 1f);
fdir = math.normalize(fdir);
float rangeSq = FeelConfig.LockOnRange * FeelConfig.LockOnRange;
float cone = math.cos(math.radians(FeelConfig.LockOnArcDegrees));
float bestSq = float.MaxValue;
float3 bestPos = default;
bool found = false;
foreach (var (hx, hh) in
SystemAPI.Query, RefRO>().WithAll())
{
if (hh.ValueRO.Current <= 0f) continue;
float3 hp = hx.ValueRO.Position;
float2 to = hp.xz - lpPos.xz;
float sq = math.lengthsq(to);
if (sq > rangeSq || sq < 1e-6f) continue;
if (math.dot(fdir, math.normalize(to)) < cone) continue;
if (sq < bestSq) { bestSq = sq; bestPos = hp; found = true; }
}
if (found)
{
_tetherLine.startColor = FeelConfig.LockOnLineColor;
_tetherLine.endColor = FeelConfig.LockOnLineColor;
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
_tetherLine.SetPosition(0, (Vector3)ringPos);
_tetherLine.SetPosition(1, new Vector3(bestPos.x, ringPos.y, bestPos.z));
tetherShown = true;
}
}
if (_tether != null && _tether.activeSelf != tetherShown) _tether.SetActive(tetherShown);
// 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.
// END-2: while the run is over (terminal banner up) keep the cursor visible so the player can click the
// Play Again / Quit buttons, regardless of aim state. AimReticleSystem is the sole Cursor.visible writer.
bool runOver = SystemAPI.TryGetSingleton(out var ro) && ro.Value != RunOutcomeId.InProgress;
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible && !runOver;
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();
if (rig != null) cam = rig.GetComponent();
}
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);
if (_tether != null) Object.Destroy(_tether);
if (_tetherMat != null) Object.Destroy(_tetherMat);
}
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();
if (col != null) Object.Destroy(col); // cosmetic only — keep it out of any physics
var mr = _reticle.GetComponent();
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);
// Lock-on tether line (persistent; built once, GC-clean). Own material so the ring texture doesn't tint it.
var lineShader = Shader.Find("Sprites/Default");
if (lineShader == null) lineShader = Shader.Find("Universal Render Pipeline/Particles/Unlit");
_tetherMat = new Material(lineShader) { color = Color.white };
_tether = new GameObject("~AimTether");
_tetherLine = _tether.AddComponent();
_tetherLine.material = _tetherMat;
_tetherLine.positionCount = 2;
_tetherLine.useWorldSpace = true;
_tetherLine.numCapVertices = 2;
_tetherLine.alignment = LineAlignment.View;
_tetherLine.textureMode = LineTextureMode.Stretch;
_tetherLine.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_tetherLine.receiveShadows = false;
_tetherLine.startColor = FeelConfig.LockOnLineColor;
_tetherLine.endColor = FeelConfig.LockOnLineColor;
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
_tether.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);
}
}
}