259 lines
12 KiB
C#
259 lines
12 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;
|
|
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<LocalTransform>();
|
|
EntityManager.CompleteDependencyBeforeRO<PlayerFacing>();
|
|
EntityManager.CompleteDependencyBeforeRO<Health>();
|
|
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;
|
|
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<LocalTransform>, RefRO<Health>>().WithAll<EnemyTag>())
|
|
{
|
|
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.
|
|
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);
|
|
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<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);
|
|
|
|
// 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<LineRenderer>();
|
|
_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);
|
|
}
|
|
}
|
|
}
|