Asset Dump
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user