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); } } }