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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 82ad9d5faa0f4ba4e9c323823b171348
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,234 @@
// Editor-only art-pipeline helpers for Project-M.
// Converts the HDRP-authored BefourStudios props to stock URP/Lit materials
// (Entities-Graphics compatible) and remaps placed prop instances onto them.
// Reusable for future Synty/asset packs: edit CuratedPrefabNames or call the
// public static methods. Lives in Assembly-CSharp-Editor (Editor folder).
//
// Re-run contract: ConvertCurated() OVERWRITES existing M_Env_* materials in place
// (GUID preserved so scene/prefab refs stay valid) -> any hand-tuning of a converted
// material is reset to the generated values on the next run.
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
namespace ProjectM.EditorTools
{
public static class EnvArtTools
{
const string EnvDir = "Assets/_Project/Materials/Env";
const string ArtPrefabs = "Assets/BefourStudios/Art/Prefabs/";
const string FallbackPath = EnvDir + "/M_Env_Fallback.mat";
// Candidate property names across HDRP/Lit and HDRP ShaderGraph variants.
static readonly string[] BaseProps = { "_BaseColorMap", "_BaseTexture", "_BaseMap", "_MainTex", "_AlbedoTexture" };
static readonly string[] NormalProps = { "_NormalMap", "_NormalTexture", "_BumpMap" };
// REAL color tints only. NOT _BaseColorMultiply (a float scalar on these sources) —
// reading a float via GetColor() returns (0,0,0) and would black out the albedo.
static readonly string[] TintProps = { "_BaseColor", "_AlbedoTint", "_Color" };
static readonly string[] EmissiveProps = { "_EmissiveColor", "_EmissionColor" };
// Source prefabs whose materials get converted. Covers all placed home-base props
// plus the two ghost props (SM_Storage, SM_Battery).
static readonly string[] CuratedPrefabNames =
{
"SM_ModularBlockCentral", "SM_ModularBlockEdge01", "SM_ModularBlockEdge02",
"SM_ModularBlockCorner01", "SM_ModularBlockCorner02",
"SM_Dome01", "SM_DomeDoor",
"SM_Battery", "SM_BatteryCharger", "SM_BatteryPod",
"SM_Crate01", "SM_Crate02", "SM_Crate03",
"SM_Plant07", "SM_Plant08", "SM_Plant16",
"SM_LightPole", "SM_SolarPanelModule", "SM_Storage",
};
[MenuItem("ProjectM/Art/1. Convert Curated Env Materials")]
public static int ConvertCurated()
{
EnsureDir();
EnsureFallback();
// Dedup by DESTINATION path; warn on collisions (same-named sources from different folders).
var byDest = new Dictionary<string, Material>();
foreach (var name in CuratedPrefabNames)
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(ArtPrefabs + name + ".prefab");
if (go == null) { Debug.LogWarning("[EnvArt] missing prefab: " + name); continue; }
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
foreach (var m in r.sharedMaterials)
{
if (m == null || string.IsNullOrEmpty(m.name) || m.name == "No Name") continue;
var dest = EnvPathFor(m.name);
if (byDest.TryGetValue(dest, out var prev))
{
if (prev != m)
Debug.LogWarning($"[EnvArt] name collision -> {dest}: keeping '{AssetDatabase.GetAssetPath(prev)}', ignoring '{AssetDatabase.GetAssetPath(m)}'");
continue;
}
byDest[dest] = m;
}
}
int n = 0;
foreach (var kv in byDest) { ConvertToUrp(kv.Value, kv.Key); n++; }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[EnvArt] Converted {n} materials into {EnvDir} (overwrites regenerate; hand edits are lost).");
return n;
}
public static string EnvPathFor(string srcMaterialName)
{
string s = srcMaterialName;
if (s.StartsWith("M_")) s = s.Substring(2);
else if (s.StartsWith("MI_")) s = s.Substring(3);
s = s.Replace(" ", "");
return $"{EnvDir}/M_Env_{s}.mat";
}
// Build (or overwrite, preserving GUID) a stock URP/Lit material from an HDRP source.
public static Material ConvertToUrp(Material src, string destPath)
{
var sh = Shader.Find("Universal Render Pipeline/Lit");
var tmp = new Material(sh);
var baseTex = FindTex(src, BaseProps) as Texture2D;
var normTex = FindTex(src, NormalProps) as Texture2D;
if (baseTex != null) tmp.SetTexture("_BaseMap", baseTex);
Color tint = Color.white;
foreach (var c in TintProps)
if (HasColor(src, c)) { tint = src.GetColor(c); break; }
tint.a = 1f;
tmp.SetColor("_BaseColor", tint);
if (normTex != null) { tmp.SetTexture("_BumpMap", normTex); tmp.EnableKeyword("_NORMALMAP"); tmp.SetFloat("_BumpScale", 1f); }
// ORM channels don't match URP's _MetallicGlossMap, and there's no reflection probe here,
// so keep metallic LOW (high metallic + dark skybox reads near-black) and use uniform smoothness.
string nm = src.name.ToLowerInvariant();
float metallic = 0.10f, smooth = 0.45f;
if (nm.Contains("metal") || nm.Contains("modular") || nm.Contains("battery") || nm.Contains("solar") ||
nm.Contains("dome") || nm.Contains("container") || nm.Contains("charger")) { metallic = 0.20f; smooth = 0.50f; }
if (nm.Contains("glass") || nm.Contains("mirror")) { metallic = 0.0f; smooth = 0.85f; }
if (nm.Contains("plant") || nm.Contains("dirt") || nm.Contains("rock") ||
nm.Contains("rubber") || nm.Contains("plastic")) { metallic = 0.0f; smooth = 0.30f; }
tmp.SetFloat("_Metallic", metallic);
tmp.SetFloat("_Smoothness", smooth);
tmp.SetFloat("_WorkflowMode", 1f); // Metallic
bool clip = nm.Contains("plant")
|| (HasFloat(src, "_AlphaCutoffEnable") && src.GetFloat("_AlphaCutoffEnable") > 0.5f)
|| (HasFloat(src, "_AlphaClip") && src.GetFloat("_AlphaClip") > 0.5f);
if (clip)
{
tmp.SetFloat("_AlphaClip", 1f);
tmp.EnableKeyword("_ALPHATEST_ON");
tmp.SetFloat("_Cutoff", 0.4f);
tmp.renderQueue = 2450;
if (nm.Contains("plant")) tmp.SetFloat("_Cull", 0f); // double-sided foliage
}
// Emission only when the SOURCE _Emissive flag is on AND the name marks it a light fixture,
// so a default non-zero _EmissiveColor on the shader graph doesn't make everything glow
// (flat color emission can't reproduce the source emission mask anyway).
bool srcEmOn = HasFloat(src, "_Emissive") && src.GetFloat("_Emissive") > 0.5f;
bool nameEmissive = nm.Contains("emissive") || nm.Contains("hologram") || nm.Contains("screen") ||
nm.Contains("lightpole") || nm.Contains("lights");
if (srcEmOn && nameEmissive)
{
Color ec = Color.white;
foreach (var c in EmissiveProps)
if (HasColor(src, c)) { ec = src.GetColor(c); break; }
float emInt = HasFloat(src, "_EmissiveIntensity") ? Mathf.Clamp(src.GetFloat("_EmissiveIntensity"), 1f, 4f) : 1.5f;
var e = new Color(ec.r, ec.g, ec.b) * emInt;
if (e.maxColorComponent > 0.05f)
{
tmp.EnableKeyword("_EMISSION");
tmp.SetColor("_EmissionColor", e);
tmp.globalIlluminationFlags = MaterialGlobalIlluminationFlags.BakedEmissive;
}
}
var existing = AssetDatabase.LoadAssetAtPath<Material>(destPath);
if (existing != null)
{
EditorUtility.CopySerialized(tmp, existing);
Object.DestroyImmediate(tmp);
EditorUtility.SetDirty(existing);
return existing;
}
AssetDatabase.CreateAsset(tmp, destPath);
return tmp;
}
// Reassign every renderer material on a placed prop instance to its converted Env equivalent.
public static int RemapRenderersToEnv(GameObject root)
{
EnsureFallback();
var fallback = AssetDatabase.LoadAssetAtPath<Material>(FallbackPath);
int swapped = 0, fell = 0;
foreach (var r in root.GetComponentsInChildren<Renderer>(true))
{
var src = r.sharedMaterials;
var dst = new Material[src.Length];
for (int i = 0; i < src.Length; i++)
{
if (src[i] == null) { dst[i] = fallback; fell++; continue; }
var env = AssetDatabase.LoadAssetAtPath<Material>(EnvPathFor(src[i].name));
if (env != null) { dst[i] = env; swapped++; } else { dst[i] = fallback; fell++; }
}
r.sharedMaterials = dst;
}
if (fell > 0) Debug.Log($"[EnvArt] RemapRenderersToEnv {root.name}: {swapped} mapped, {fell} -> fallback");
return swapped;
}
static bool HasColor(Material m, string prop)
{
if (!m.HasProperty(prop)) return false;
int idx = m.shader.FindPropertyIndex(prop);
if (idx < 0) return false;
var t = m.shader.GetPropertyType(idx);
return t == ShaderPropertyType.Color || t == ShaderPropertyType.Vector;
}
static bool HasFloat(Material m, string prop)
{
if (!m.HasProperty(prop)) return false;
int idx = m.shader.FindPropertyIndex(prop);
if (idx < 0) return false;
var t = m.shader.GetPropertyType(idx);
return t == ShaderPropertyType.Float || t == ShaderPropertyType.Range;
}
static Texture FindTex(Material m, string[] names)
{
foreach (var n in names)
{
if (!m.HasProperty(n)) continue;
int idx = m.shader.FindPropertyIndex(n);
if (idx < 0 || m.shader.GetPropertyType(idx) != ShaderPropertyType.Texture) continue;
var t = m.GetTexture(n);
if (t != null) return t;
}
return null;
}
static void EnsureDir()
{
if (!AssetDatabase.IsValidFolder(EnvDir))
AssetDatabase.CreateFolder("Assets/_Project/Materials", "Env");
}
static void EnsureFallback()
{
if (AssetDatabase.LoadAssetAtPath<Material>(FallbackPath) != null) return;
EnsureDir();
var m = new Material(Shader.Find("Universal Render Pipeline/Lit"));
m.SetColor("_BaseColor", new Color(0.55f, 0.57f, 0.60f));
m.SetFloat("_Metallic", 0.1f);
m.SetFloat("_Smoothness", 0.45f);
AssetDatabase.CreateAsset(m, FallbackPath);
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 193dc7dc521a8014a8bdcd8c64ae1926
@@ -35,11 +35,15 @@ namespace ProjectM.Server
if (spawner.Prefab != Entity.Null)
{
// Preserve the prefab's baked scale/rotation (FromPosition would reset Scale to 1).
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
for (int i = 0; i < spawner.Count; i++)
{
var pickup = ecb.Instantiate(spawner.Prefab);
var position = spawner.Origin + new float3(i * spawner.Spacing, 0f, 0f);
ecb.SetComponent(pickup, LocalTransform.FromPosition(position));
var xform = baseXform;
xform.Position = position;
ecb.SetComponent(pickup, xform);
}
}
@@ -39,7 +39,10 @@ namespace ProjectM.Server
{
var container = ecb.Instantiate(spawner.Prefab);
var position = BaseGridMath.CellToWorld(anchor, spawner.Cell);
ecb.SetComponent(container, LocalTransform.FromPosition(position));
// Preserve the prefab's baked scale/rotation (FromPosition would reset Scale to 1).
var xform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
xform.Position = position;
ecb.SetComponent(container, xform);
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -125,9 +125,11 @@ namespace ProjectM.Simulation
else
rawAim = math.normalize(rawAim);
// Client fires along raw aim; only the server applies the auto-target assist cone.
// Client fires along raw aim. Only the server applies the auto-target assist cone, and only for
// GAMEPAD shots (precise mouse aim is left exact per the active input scheme).
float2 dir = rawAim;
if (isServer && eff.ValueRO.AutoTargetRange > 0f)
if (isServer && eff.ValueRO.AutoTargetRange > 0f
&& applied.InternalInput.Scheme == InputSchemeId.Gamepad)
{
dir = AutoTarget.Resolve(
xform.ValueRO.Position,
@@ -0,0 +1,68 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic, Burst-safe helpers for turning a screen-cursor camera ray into a planar (XZ)
/// aim direction (and the ground point it targets) for the top-down player. Isolated from the managed
/// <c>Camera</c> so the projection is unit-testable without a render context (mirrors
/// <see cref="AutoTarget"/> / <c>CharacterControlMath</c> / <c>BaseGridMath</c>). Used client-side by the
/// input gather (facing direction) and the aim-reticle presentation (the world ground point), but kept
/// here in Simulation alongside the other pure combat/movement math.
/// </summary>
public static class AimMath
{
/// <summary>
/// Intersects a camera ray with the horizontal plane at <paramref name="planeY"/>. Returns false (and
/// <paramref name="hit"/> = default) when the ray is parallel to / points away from the plane (cursor
/// above the horizon); otherwise <paramref name="hit"/> is the world-space intersection point.
/// </summary>
/// <param name="rayOrigin">Camera ray origin (world space).</param>
/// <param name="rayDir">Camera ray direction (need not be normalized).</param>
/// <param name="planeY">World Y of the aim plane (the player's movement plane).</param>
/// <param name="hit">The world-space ground intersection point, when this returns true.</param>
public static bool TryGroundHit(float3 rayOrigin, float3 rayDir, float planeY, out float3 hit)
{
hit = default;
// Solve rayOrigin.y + t * rayDir.y == planeY. A near-zero rayDir.y means the ray runs parallel.
float denom = rayDir.y;
if (math.abs(denom) < 1e-6f)
return false;
float t = (planeY - rayOrigin.y) / denom;
if (t < 0f)
return false; // intersection behind the ray origin (cursor above the horizon line)
hit = rayOrigin + rayDir * t;
return true;
}
/// <summary>
/// Normalized planar aim direction (world XZ mapped to <c>float2(x, y)</c>) from
/// <paramref name="playerPos"/> toward the cursor's ground-projection point. Returns
/// <paramref name="fallback"/> when the ray misses the plane, OR the hit lies within
/// <paramref name="deadZoneRadius"/> of the player (too close to define a stable heading), so the
/// caller holds its previous aim instead of snapping or spinning when the cursor is over the character.
/// </summary>
/// <param name="rayOrigin">Camera ray origin (world space).</param>
/// <param name="rayDir">Camera ray direction (need not be normalized).</param>
/// <param name="playerPos">Player world position; only XZ is used for the heading.</param>
/// <param name="planeY">World Y of the aim plane.</param>
/// <param name="fallback">Direction returned when there is no valid hit (e.g. the previous aim).</param>
/// <param name="deadZoneRadius">
/// World radius around the player inside which facing is held (no update). 0 keeps only the tiny
/// epsilon guard against an exactly-coincident hit (the original behaviour).
/// </param>
public static float2 PlanarAimFromRay(float3 rayOrigin, float3 rayDir, float3 playerPos, float planeY,
float2 fallback, float deadZoneRadius = 0f)
{
if (!TryGroundHit(rayOrigin, rayDir, planeY, out var hit))
return fallback;
float2 planar = new float2(hit.x - playerPos.x, hit.z - playerPos.z);
float dz = math.max(1e-3f, deadZoneRadius); // never below the original coincident-hit epsilon
if (math.lengthsq(planar) < dz * dz)
return fallback;
return math.normalize(planar);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a71e5a6ceef479e49983a86a038abc47
@@ -0,0 +1,21 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Active input-scheme ids for the local player, streamed in <see cref="PlayerInput.Scheme"/> so the
/// server can apply aim-assist only to gamepad shots while leaving precise mouse aim exact.
/// <para>
/// A plain <c>byte</c> (not an <c>enum</c>) deliberately: this value is compared inside the
/// Burst-compiled <c>AbilityFireSystem</c>, and cross-assembly <c>enum</c> use inside Burst code has
/// tripped the Burst type-hash internal compiler error in this project before (see CLAUDE.md /
/// ProjectileClassificationSystem). Integer consts are Burst-safe and replicate as a single byte.
/// </para>
/// </summary>
public static class InputSchemeId
{
/// <summary>Mouse + keyboard: aim is the cursor direction; shots are precise (no auto-target assist).</summary>
public const byte KeyboardMouse = 0;
/// <summary>Gamepad: aim is the right-stick direction; the server applies the auto-target assist cone.</summary>
public const byte Gamepad = 1;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0d2fa5ecb20cf454f9c4d70958df73e4
@@ -22,12 +22,17 @@ namespace ProjectM.Simulation
/// <summary>Primary ability fire. InputEvent survives the frame→tick→rollback boundary so a press fires exactly once.</summary>
[GhostField] public InputEvent Fire;
/// <summary>Active input scheme this tick (<see cref="InputSchemeId"/>: 0 = mouse/keyboard, 1 = gamepad).
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
/// exact. A byte (not an enum): it is compared inside the Burst-compiled <c>AbilityFireSystem</c>.</summary>
[GhostField] public byte Scheme;
public FixedString512Bytes ToFixedString()
{
var s = new FixedString512Bytes();
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
s.Append(Fire.Count);
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme);
return s;
}
}