Asset Dump

This commit is contained in:
2026-06-03 13:46:13 -07:00
parent e362aaeb43
commit 9091388bc2
20821 changed files with 26544125 additions and 58 deletions
@@ -0,0 +1,21 @@
namespace ProjectM.Client
{
/// <summary>
/// Static bridge from the client input gather (which owns active-device detection) to the presentation
/// layer (<see cref="AimReticleSystem"/>), mirroring the <c>PrototypeCameraRig</c> statics idiom. Holds
/// the last detected input scheme so the reticle system can switch the on-screen cursor (mouse
/// crosshair) vs the world reticle (gamepad) without re-deriving raw device state. Process-local and
/// presentation-only — never read by the simulation.
/// </summary>
public static class AimPresentation
{
/// <summary>Active scheme (<see cref="ProjectM.Simulation.InputSchemeId"/>): 0 = mouse/keyboard, 1 = gamepad.</summary>
public static byte Scheme;
// Static fields can survive editor domain reloads (fast enter-play-mode); reset on every play-enter so a
// stale gamepad value from a prior session can't briefly hide the cursor / show the world reticle before
// the input gather republishes the real scheme.
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => Scheme = ProjectM.Simulation.InputSchemeId.KeyboardMouse;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 603d6469024e0c242aabf57eb877dc6b
@@ -44,6 +44,11 @@ namespace ProjectM.Client
/// command buffer (a single-frame pulse can be lost across the frame→tick boundary). 0 = idle.</summary>
public static int FireFrames;
/// <summary>Input scheme stamped onto injected input (<see cref="InputSchemeId"/>). Defaults to Gamepad
/// so injected aim still receives the server auto-target assist (the pre-scheme behaviour), keeping the
/// editor fire / auto-target validation path intact.</summary>
public static byte Scheme = InputSchemeId.Gamepad;
/// <summary>Convenience: hold Fire for the next <paramref name="frames"/> frames (also enables
/// override). The ability cooldown still gates how many shots actually result.</summary>
public static void Fire(int frames = 10) { Active = true; FireFrames = math.max(FireFrames, frames); }
@@ -72,10 +77,15 @@ namespace ProjectM.Client
float2 move = Move;
float2 aim = Aim;
// Keep the presentation bridge in sync with the injected scheme so the reticle/cursor match the
// input the sim is actually using (the gather published a value earlier this frame; we override it).
AimPresentation.Scheme = Scheme;
foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>().WithAll<GhostOwnerIsLocal>())
{
input.ValueRW.Move = move;
input.ValueRW.Aim = aim;
input.ValueRW.Scheme = Scheme;
if (fire)
input.ValueRW.Fire.Set();
}
@@ -2,6 +2,7 @@ using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Client
{
@@ -32,6 +33,21 @@ namespace ProjectM.Client
{
private ProjectMInput _controls;
// Active input scheme (last meaningful actuation wins). Selects KBM-cursor vs gamepad-stick aim and
// is published to AimPresentation for the on-screen cursor / world reticle. Starts on mouse+keyboard.
private byte _scheme = InputSchemeId.KeyboardMouse;
// Last valid KBM cursor aim, held when the projection is degenerate (cursor over the player / above the
// horizon) so facing never snaps to zero while the mouse is the active device.
private float2 _lastKbmAim = new float2(0f, 1f);
// Main camera for cursor -> ground projection; re-resolved when null (e.g. after a domain reload).
private UnityEngine.Camera _camera;
// World radius around the player inside which mouse facing is HELD (no spin when the cursor passes
// over/near the character). Passed to AimMath.PlanarAimFromRay's deadZoneRadius.
private const float KbmAimDeadZone = 0.6f;
protected override void OnCreate()
{
RequireForUpdate<PlayerInput>();
@@ -53,27 +69,109 @@ namespace ProjectM.Client
{
var gameplay = _controls.Gameplay;
// Movement is source-agnostic (WASD or left stick) — read from the merged action.
float2 move = (float2)gameplay.Move.ReadValue<UnityEngine.Vector2>();
float2 aim = (float2)gameplay.Aim.ReadValue<UnityEngine.Vector2>();
// Right-stick deadzone: a resting stick yields zero Aim so PlayerAimSystem falls back to the
// movement heading (controller-first directional aim).
if (math.lengthsq(aim) < 0.04f)
aim = float2.zero;
bool firePressed = gameplay.Fire.WasPressedThisFrame();
foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>().WithAll<GhostOwnerIsLocal>())
// --- Active-device detection: last meaningful actuation wins; hold last when idle ---
var gamepad = UnityEngine.InputSystem.Gamepad.current;
var mouse = UnityEngine.InputSystem.Mouse.current;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
float2 rightStick = float2.zero;
bool gamepadActive = false;
if (gamepad != null)
{
rightStick = (float2)gamepad.rightStick.ReadValue();
float2 leftStick = (float2)gamepad.leftStick.ReadValue();
gamepadActive =
math.lengthsq(rightStick) > 0.04f || math.lengthsq(leftStick) > 0.04f ||
gamepad.rightTrigger.isPressed || gamepad.leftTrigger.isPressed ||
gamepad.buttonSouth.isPressed || gamepad.buttonEast.isPressed ||
gamepad.buttonWest.isPressed || gamepad.buttonNorth.isPressed;
}
bool kbmActive = false;
if (mouse != null)
{
float2 delta = (float2)mouse.delta.ReadValue();
kbmActive = math.lengthsq(delta) > 0.5f || mouse.leftButton.isPressed || mouse.rightButton.isPressed;
}
if (!kbmActive && keyboard != null)
{
kbmActive =
keyboard.wKey.isPressed || keyboard.aKey.isPressed || keyboard.sKey.isPressed || keyboard.dKey.isPressed ||
keyboard.upArrowKey.isPressed || keyboard.downArrowKey.isPressed ||
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed;
}
if (gamepadActive && kbmActive)
{
// Both actuated this frame: break the tie by whichever device updated most recently.
double gpT = gamepad != null ? gamepad.lastUpdateTime : double.MinValue;
double kbmT = math.max(
mouse != null ? mouse.lastUpdateTime : double.MinValue,
keyboard != null ? keyboard.lastUpdateTime : double.MinValue);
_scheme = gpT > kbmT ? InputSchemeId.Gamepad : InputSchemeId.KeyboardMouse;
}
else if (gamepadActive) _scheme = InputSchemeId.Gamepad;
else if (kbmActive) _scheme = InputSchemeId.KeyboardMouse;
// else: neither actuated — keep the previous scheme.
// Publish for the presentation layer (cursor visibility / world reticle).
AimPresentation.Scheme = _scheme;
// Mouse cursor projection needs the camera; resolve/cache it (Camera.main or the rig's camera).
if (_camera == null) _camera = ResolveCamera();
UnityEngine.Vector2 cursorScreen = mouse != null ? mouse.position.ReadValue() : default;
foreach (var (input, xform) in
SystemAPI.Query<RefRW<PlayerInput>, RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal>())
{
float2 aim;
if (_scheme == InputSchemeId.Gamepad)
{
// Right-stick directional aim. A resting stick yields zero so PlayerAimSystem falls back to
// the movement heading (existing controller-first behaviour).
aim = rightStick;
if (math.lengthsq(aim) < 0.04f) aim = float2.zero;
}
else
{
// Mouse: face the cursor. Project the cursor ray onto the player's movement plane and aim
// from the player toward that point. Held to the last valid aim when degenerate, so the
// character keeps facing the cursor even while standing still (the core jank fix).
float3 playerPos = xform.ValueRO.Position;
if (_camera != null && mouse != null)
{
UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(cursorScreen.x, cursorScreen.y, 0f));
_lastKbmAim = AimMath.PlanarAimFromRay(
(float3)ray.origin, (float3)ray.direction, playerPos, playerPos.y, _lastKbmAim, KbmAimDeadZone);
}
aim = _lastKbmAim;
}
input.ValueRW.Move = move;
input.ValueRW.Aim = aim;
input.ValueRW.Scheme = _scheme;
// Reset the per-frame event, then raise it on the press edge. Netcode latches the
// absolute Count into the command buffer; AbilityFireSystem reads it as the SpawnId key.
// Reset the per-frame event, then raise it on the press edge. Netcode latches the absolute
// Count into the command buffer; AbilityFireSystem reads it as the SpawnId key.
input.ValueRW.Fire = default;
if (firePressed)
input.ValueRW.Fire.Set();
}
}
private 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;
}
}
}
@@ -0,0 +1,192 @@
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2891b8c362ba28c45a8d9662220403bf
@@ -16,8 +16,14 @@ namespace ProjectM.Client
/// spawns a death burst + death SFX; the local player crossing to 0 HP does the same. A local-player ability
/// fire (AbilityCooldown advancing) spawns a muzzle flash + zap. Everything derives from already-replicated
/// state, so it is correct without touching the prediction loop, and it lives only in the client world so the
/// server never instantiates GameObjects. SFX are generated procedurally; VFX use a small runtime pool — the
/// slice ships with NO binary audio/particle assets.
/// server never instantiates GameObjects.
/// <para>
/// VFX prefer authored GabrielAguiar Shuriken prefabs supplied by <see cref="VFXConfig"/> (muzzle / hit /
/// death + a projectile-following trail); each hook falls back to a procedural particle burst when no prefab
/// is assigned, so the slice still runs asset-free. Spawned VFX are stripped to particles only
/// (<see cref="StripCosmetic"/>) — GA "projectile" prefabs ship a Rigidbody + collider + mover that would
/// otherwise self-propel and spawn secondary effects. SFX remain procedural.
/// </para>
/// <para>
/// Per-entity last Health + position + isEnemy are cached in a managed dictionary (Entity is a stable client
/// key for a ghost's lifetime); stale keys are pruned each frame (a pruned Husk = a kill → death VFX at its
@@ -36,6 +42,12 @@ namespace ProjectM.Client
readonly List<Entity> _stale = new();
readonly List<FloatingNumber> _numbers = new();
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List<TimedVfx> _activeVfx = new();
readonly Dictionary<Entity, GameObject> _projTrails = new();
readonly HashSet<Entity> _projSeen = new();
readonly List<Entity> _projStale = new();
Transform _fxRoot;
ParticleSystem _hitFx;
ParticleSystem _deathFx;
@@ -49,6 +61,9 @@ namespace ProjectM.Client
bool _fireTickInit;
const int NumberPoolSize = 32;
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
struct TimedVfx { public GameObject Go; public double Kill; }
protected override void OnCreate()
{
@@ -81,6 +96,7 @@ namespace ProjectM.Client
{
float dt = SystemAPI.Time.DeltaTime;
var cam = Camera.main;
var cfg = VFXConfig.Instance;
// Make sure predicted/physics jobs writing these are done before this main-thread read.
EntityManager.CompleteDependencyBeforeRO<Health>();
@@ -112,7 +128,7 @@ namespace ProjectM.Client
if (cur < prev.Hp - 0.001f)
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
EmitAt(_hitFx, (Vector3)p + Vector3.up * 0.8f, 10);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, 10);
PlayClip(_hitClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.32f : 0.10f);
}
@@ -120,7 +136,7 @@ namespace ProjectM.Client
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
if (!isEnemy && cur <= 0f && prev.Hp > 0f)
{
EmitAt(_deathFx, (Vector3)p + Vector3.up * 0.5f, 28);
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, 28);
PlayClip(_deathClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.5f : 0.25f);
}
@@ -141,7 +157,7 @@ namespace ProjectM.Client
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
EmitAt(_deathFx, (Vector3)c.Pos + Vector3.up * 0.5f, 28);
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, 28);
PlayClip(_deathClip, (Vector3)c.Pos, 0.65f);
PrototypeCameraRig.AddShake(0.16f);
}
@@ -150,21 +166,139 @@ namespace ProjectM.Client
}
// Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot.
// Raw uint inequality is intentional here: only the edge (a new shot) matters and the worst case
// of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick)
{
EmitAt(_muzzleFx, (Vector3)localPos + Vector3.up * 0.9f, 8);
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
}
_lastLocalFireTick = nextFire;
_fireTickInit = true;
}
UpdateProjectileTrails(cfg);
PruneVfx();
AnimateNumbers(dt, cam);
}
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
static GameObject PlayerDeathPrefab(VFXConfig cfg)
{
if (cfg == null) return null;
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
}
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
{
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
else EmitAt(fallback, pos, count);
}
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{
if (prefab == null || _fxRoot == null) return;
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC
var go = Object.Instantiate(prefab, pos, rot, _fxRoot);
go.transform.position = pos;
StripCosmetic(go);
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
}
void PruneVfx()
{
double now = SystemAPI.Time.ElapsedTime;
for (int i = _activeVfx.Count - 1; i >= 0; i--)
{
if (now < _activeVfx[i].Kill) continue;
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go);
_activeVfx.RemoveAt(i);
}
}
// A looping trail prefab follows each in-flight projectile ghost; destroyed when it despawns.
void UpdateProjectileTrails(VFXConfig cfg)
{
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
{
// Config cleared mid-run: drop any orphaned trails so they don't linger.
if (_projTrails.Count > 0)
{
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
_projTrails.Clear();
}
return;
}
_projSeen.Clear();
foreach (var (xf, entity) in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>().WithEntityAccess())
{
_projSeen.Add(entity);
Vector3 wp = (Vector3)xf.ValueRO.Position;
if (_projTrails.TryGetValue(entity, out var trail))
{
if (trail != null) trail.transform.position = wp;
}
else
{
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot);
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_projTrails[entity] = go;
}
}
if (_projTrails.Count == _projSeen.Count) return;
_projStale.Clear();
foreach (var kv in _projTrails)
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
for (int i = 0; i < _projStale.Count; i++)
{
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]);
_projTrails.Remove(_projStale[i]);
}
}
// Cosmetic VFX must be particles only. GA demo "projectile" prefabs ship a non-kinematic Rigidbody,
// a solid collider, and a mover (ProjectileMoveScript) that self-propels and spawns secondary muzzle/hit
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
static void StripCosmetic(GameObject go)
{
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
{
if (mb == null) continue;
string n = mb.GetType().Name;
// Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires.
if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0)
mb.enabled = false;
}
}
// Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early
// or hold a finished GameObject around on a blanket TTL.
static double VfxLifetime(GameObject go)
{
float longest = 0f;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var main = ps.main;
float d = main.duration + main.startLifetime.constantMax;
if (d > longest) longest = d;
}
return Mathf.Clamp(longest, 1f, 6f);
}
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
class FloatingNumber
@@ -236,7 +370,7 @@ namespace ProjectM.Client
}
}
// ---- Procedural SFX + pooled particle bursts ----
// ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ----
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise)
{
@@ -30,6 +30,9 @@ namespace ProjectM.Client
/// <summary>True while a locally-owned player exists to follow.</summary>
public static bool HasTarget;
/// <summary>Local player's planar facing (XZ), published each client tick for the aim look-ahead.</summary>
public static float2 TargetFacing;
/// <summary>Accumulated camera-shake amplitude (world units), decayed each LateUpdate. Driven by
/// AddShake from the client combat-feedback layer (presentation only, netcode-safe - never the sim).</summary>
static float s_shake;
@@ -57,6 +60,11 @@ namespace ProjectM.Client
[Tooltip("What to frame before a local player exists (edit mode / pre-spawn).")]
public Vector3 FallbackTarget = new Vector3(3f, 0f, 4f);
[Header("Aim look-ahead")]
[Tooltip("Shift the framed point this many world units toward where the player is aiming (0 = off). " +
"Leads the camera toward the cursor / stick so aiming feels grounded. Smoothed by FollowSharpness.")]
[Range(0f, 8f)] public float AimLeadDistance = 2.5f;
Camera _cam;
void Awake() => _cam = GetComponent<Camera>();
@@ -72,6 +80,15 @@ namespace ProjectM.Client
Vector3 target = HasTarget ? (Vector3)TargetWorldPos : FallbackTarget;
target.y += TargetHeight;
// Aim look-ahead: lead the framed point toward the player's facing (cursor on KBM, stick on pad).
// Driven off the replicated PlayerFacing (not the live cursor projection) so there is no feedback
// loop with the reticle's camera-dependent ground projection. Smoothed by the FollowSharpness lerp.
if (AimLeadDistance > 0f && HasTarget && math.lengthsq(TargetFacing) > 1e-6f)
{
float2 f = math.normalize(TargetFacing);
target += new Vector3(f.x, 0f, f.y) * AimLeadDistance;
}
var rot = Quaternion.Euler(Pitch, Yaw, 0f);
Vector3 desired = target - (rot * Vector3.forward) * Distance;
@@ -98,10 +115,11 @@ namespace ProjectM.Client
protected override void OnUpdate()
{
bool found = false;
foreach (var transform in SystemAPI.Query<RefRO<LocalTransform>>()
foreach (var (transform, facing) in SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
PrototypeCameraRig.TargetWorldPos = transform.ValueRO.Position;
PrototypeCameraRig.TargetFacing = facing.ValueRO.Direction;
found = true;
break;
}
@@ -0,0 +1,37 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side bridge that hands authored VFX prefabs (GabrielAguiar Shuriken systems) to
/// <see cref="CombatFeedbackSystem"/>. Mirrors the <see cref="PrototypeCameraRig"/> pattern: a MonoBehaviour
/// in the bootstrap scene exposes a static <see cref="Instance"/> the client presentation system reads.
/// Any field left null falls back to the procedural particle burst, so the game still runs without it.
/// </summary>
public class VFXConfig : MonoBehaviour
{
public static VFXConfig Instance { get; private set; }
[Header("Combat VFX (one-shot Shuriken prefabs)")]
[Tooltip("Spawned at the player muzzle on each shot.")]
public GameObject Muzzle;
[Tooltip("Follows each in-flight projectile ghost; destroyed when the projectile despawns.")]
public GameObject ProjectileTrail;
[Tooltip("Spawned at a damaged target on each hit.")]
public GameObject Hit;
[Tooltip("Spawned at a Husk's last position when it dies.")]
public GameObject EnemyDeath;
[Tooltip("Spawned when the local player drops to 0 HP (falls back to EnemyDeath if null).")]
public GameObject PlayerDeath;
void Awake()
{
Instance = this;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bccaef02a105d874e846ceb28e278285