Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip -> AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call and schedules a delayed Destroy — ~20-33 times a second in light combat. New OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so all 20 consuming call sites are untouched. Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate divergences, both forced by the voices being long-lived: playOnAwake = false, and dopplerLevel = 0 because a pooled voice TELEPORTS between events and would otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does LoadScene(Single) while the client world is alive) with a SubsystemRegistration reset, or session two rents destroyed voices. Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit: components cached per INSTANCE (refs are instance-scoped), main.stopAction forced to None (a prefab set to Destroy silently drains the pool), instances filled under an inactive root so Awake/Start never run — which is what makes the DestroyImmediate in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the reposition, and a Rented flag as the at-most-once guard against a double Return aliasing one instance to two callers. Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated four arrays on every call (up to twice a frame, and once per winding enemy); HUD and ability-bar labels rebuilt their strings every frame; damage-number fades rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes unconditionally; two systems played back an empty EntityCommandBuffer (a structural-change sync point) every frame. Also closes an AudioClip leak across all seven clip-owning systems: an AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per client-world teardown). CombatFeedbackSystem's TryHold call sites go with this commit because they share the file; the camera-side removal lands in the next one. Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again across 270 frames of combat with kills; the VFX pool fills to its retain cap and stabilises; real cues route through the ring. 304/304 EditMode green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,14 @@ namespace ProjectM.Client
|
|||||||
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
|
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
|
||||||
readonly int[] _prevRemaining = new int[SlotCount];
|
readonly int[] _prevRemaining = new int[SlotCount];
|
||||||
readonly float[] _flashUntil = new float[SlotCount];
|
readonly float[] _flashUntil = new float[SlotCount];
|
||||||
|
// Track B: last countdown value rendered per slot, in tenths of a second (-1 = blank). Gates the
|
||||||
|
// float formatting in UpdateSlotCooldown, which otherwise ran 5x per frame.
|
||||||
|
// Seeded to int.MinValue, not the default 0: 0 is a REAL value (a cooldown under ~1/20 s rounds to
|
||||||
|
// 0 tenths), and a default-0 array would silently skip that first render.
|
||||||
|
readonly int[] _shownDeci = FilledWith(SlotCount, int.MinValue);
|
||||||
|
readonly bool[] _shownWhole = new bool[SlotCount]; // which countdown FORMAT is currently rendered per slot
|
||||||
|
|
||||||
|
static int[] FilledWith(int n, int v) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = v; return a; }
|
||||||
|
|
||||||
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
|
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
|
||||||
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
|
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
|
||||||
@@ -125,9 +133,27 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
float frac = math.saturate(remaining / (float)total);
|
float frac = math.saturate(remaining / (float)total);
|
||||||
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
|
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
|
||||||
_countdown[slot].text = remaining > 0
|
|
||||||
? (remaining < 597 ? (remaining / 60f).ToString("0.0") : Mathf.CeilToInt(remaining / 60f).ToString())
|
// Track B: this ran for all 5 slots EVERY frame and formatted a float each time. The readout only
|
||||||
: "";
|
// has a tenth-of-a-second resolution (6 ticks), so quantise first and only format on a real change.
|
||||||
|
//
|
||||||
|
// The rendered text is derived from `deci`, NEVER re-derived from `remaining`. Deriving it twice is a
|
||||||
|
// trap: Mathf.RoundToInt rounds half-to-EVEN while ToString("0.0") rounds half-AWAY-from-zero, so the
|
||||||
|
// gate and the string disagreed at midpoints and the label latched a stale, too-high reading and
|
||||||
|
// skipped a tenth on every cooldown. CeilToInt also means the readout never reads LOWER than the true
|
||||||
|
// remainder. `whole` is cached alongside deci because the two branches can produce the same deci
|
||||||
|
// (596 ticks -> 100 -> "9.9" vs 597 ticks -> 100 -> "10") and the format must still switch.
|
||||||
|
bool whole = remaining >= 597;
|
||||||
|
int deci = remaining > 0
|
||||||
|
? (whole ? Mathf.CeilToInt(remaining / 60f) * 10 : Mathf.CeilToInt(remaining / 6f))
|
||||||
|
: -1;
|
||||||
|
if (deci != _shownDeci[slot] || whole != _shownWhole[slot])
|
||||||
|
{
|
||||||
|
_shownDeci[slot] = deci;
|
||||||
|
_shownWhole[slot] = whole;
|
||||||
|
_countdown[slot].text = deci < 0 ? "" : (whole ? (deci / 10).ToString() : (deci * 0.1f).ToString("0.0"));
|
||||||
|
}
|
||||||
|
|
||||||
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
|
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
|
||||||
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
|
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ namespace ProjectM.Client
|
|||||||
protected override void OnDestroy()
|
protected override void OnDestroy()
|
||||||
{
|
{
|
||||||
if (_root != null) Object.Destroy(_root);
|
if (_root != null) Object.Destroy(_root);
|
||||||
|
// The AudioSources die with _root, but the clips they played do NOT — see FeedbackFx.DestroyClip.
|
||||||
|
// _ambientClip alone is ~1.4 MB of native audio per client-world teardown.
|
||||||
|
FeedbackFx.DestroyClip(ref _ambientClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _groanClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _stingBeep);
|
||||||
|
FeedbackFx.DestroyClip(ref _stingRoar);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ namespace ProjectM.Client
|
|||||||
if (_cloudMesh != null) Object.Destroy(_cloudMesh);
|
if (_cloudMesh != null) Object.Destroy(_cloudMesh);
|
||||||
if (_critterMat != null) Object.Destroy(_critterMat);
|
if (_critterMat != null) Object.Destroy(_critterMat);
|
||||||
if (_cloudMat != null) Object.Destroy(_cloudMat);
|
if (_cloudMat != null) Object.Destroy(_cloudMat);
|
||||||
|
FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
|
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
|
||||||
readonly List<TimedVfx> _activeVfx = new();
|
readonly List<TimedVfx> _activeVfx = new();
|
||||||
readonly Dictionary<Entity, GameObject> _projTrails = new();
|
readonly Dictionary<Entity, VfxInstance> _projTrails = new();
|
||||||
readonly HashSet<Entity> _projSeen = new();
|
readonly HashSet<Entity> _projSeen = new();
|
||||||
readonly List<Entity> _projStale = new();
|
readonly List<Entity> _projStale = new();
|
||||||
|
|
||||||
@@ -65,6 +65,16 @@ namespace ProjectM.Client
|
|||||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||||
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
|
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
|
||||||
|
// Track B: BuildSlashInto used to allocate four arrays (~1.7 KB) on EVERY call, and it runs twice a
|
||||||
|
// frame for the local arc + smear plus once per live remote swing. The segment count is a compile-time
|
||||||
|
// constant, so the sizes never vary — fill these in place instead. UVs/triangles are argument-independent
|
||||||
|
// (built once, guarded by _arcStaticsBuilt) and are uploaded to each mesh only on its first fill.
|
||||||
|
const int ArcSeg = 16;
|
||||||
|
readonly Vector3[] _arcVerts = new Vector3[(ArcSeg + 1) * 2];
|
||||||
|
readonly Color[] _arcCols = new Color[(ArcSeg + 1) * 2];
|
||||||
|
readonly Vector2[] _arcUvs = new Vector2[(ArcSeg + 1) * 2];
|
||||||
|
readonly int[] _arcTris = new int[ArcSeg * 6];
|
||||||
|
bool _arcStaticsBuilt;
|
||||||
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
|
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
|
||||||
int _pendingConnectStep;
|
int _pendingConnectStep;
|
||||||
uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
|
uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
|
||||||
@@ -75,7 +85,6 @@ namespace ProjectM.Client
|
|||||||
float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
|
float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
|
||||||
int _stressBeat;
|
int _stressBeat;
|
||||||
#endif
|
#endif
|
||||||
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
|
||||||
|
|
||||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
||||||
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
||||||
@@ -116,7 +125,28 @@ namespace ProjectM.Client
|
|||||||
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
|
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
|
||||||
|
|
||||||
EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
|
EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
|
||||||
struct TimedVfx { public GameObject Go; public double Kill; }
|
// Track B: a pooled VFX instance. Component arrays are cached PER INSTANCE — component references are
|
||||||
|
// instance-scoped, so arrays captured off the prefab ASSET would drive the asset, not the clone.
|
||||||
|
class VfxInstance
|
||||||
|
{
|
||||||
|
public GameObject Go;
|
||||||
|
public Transform Tr;
|
||||||
|
public ParticleSystem[] Systems;
|
||||||
|
public TrailRenderer[] Trails;
|
||||||
|
public GameObject Prefab; // the stack this instance returns to; never re-read from VFXConfig
|
||||||
|
public bool Rented; // at-most-once guard: a double Return would alias one instance to two callers
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimedVfx { public VfxInstance Inst; public double Kill; }
|
||||||
|
|
||||||
|
// Per-prefab pool of inactive instances, plus the two values that ARE legitimately per-prefab. Instance
|
||||||
|
// fields, never static: the Kill deadlines come from the per-world SystemAPI.Time.ElapsedTime, which
|
||||||
|
// restarts at 0 for each session world.
|
||||||
|
readonly Dictionary<GameObject, Stack<VfxInstance>> _vfxPool = new();
|
||||||
|
readonly Dictionary<GameObject, double> _vfxLifetime = new();
|
||||||
|
readonly Dictionary<GameObject, Vector3> _vfxPrefabScale = new();
|
||||||
|
Transform _vfxFillRoot; // INACTIVE parent: instances fill here so Awake/Start never run
|
||||||
|
const int VfxPerPrefabRetain = 10; // retained inactive instances per prefab; destroy beyond it
|
||||||
|
|
||||||
protected override void OnCreate()
|
protected override void OnCreate()
|
||||||
{
|
{
|
||||||
@@ -161,6 +191,10 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
protected override void OnDestroy()
|
protected override void OnDestroy()
|
||||||
{
|
{
|
||||||
|
// The SFX ring is DontDestroyOnLoad (it must outlive LoadScene(Single)), so a world teardown
|
||||||
|
// would otherwise bleed in-flight combat cues straight into the main menu.
|
||||||
|
OneShotAudioPool.SilenceAll();
|
||||||
|
|
||||||
if (_fxRoot != null)
|
if (_fxRoot != null)
|
||||||
Object.Destroy(_fxRoot.gameObject);
|
Object.Destroy(_fxRoot.gameObject);
|
||||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||||
@@ -168,6 +202,18 @@ namespace ProjectM.Client
|
|||||||
if (_smearMesh != null) Object.Destroy(_smearMesh);
|
if (_smearMesh != null) Object.Destroy(_smearMesh);
|
||||||
if (_smearMat != null) Object.Destroy(_smearMat);
|
if (_smearMat != null) Object.Destroy(_smearMat);
|
||||||
|
|
||||||
|
// See FeedbackFx.DestroyClip: an AudioClip.Create'd clip is not owned by _fxRoot, so all ten leaked
|
||||||
|
// on every client-world teardown. The four sibling clip-owning systems do the same in their OnDestroy.
|
||||||
|
FeedbackFx.DestroyClip(ref _hitClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _deathClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _fireClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _telegraphClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _dashClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _swingClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _meleeConnectClip);
|
||||||
|
for (int i = 0; i < _footstepClips.Length; i++) FeedbackFx.DestroyClip(ref _footstepClips[i]);
|
||||||
|
|
||||||
|
|
||||||
foreach (var kv in _remoteSlashes)
|
foreach (var kv in _remoteSlashes)
|
||||||
{
|
{
|
||||||
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||||
@@ -178,6 +224,8 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
{
|
{
|
||||||
float dt = SystemAPI.Time.DeltaTime;
|
float dt = SystemAPI.Time.DeltaTime;
|
||||||
@@ -246,7 +294,6 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
|
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
|
||||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||||
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
|
|
||||||
|
|
||||||
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||||
@@ -271,7 +318,6 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
|
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||||
TryHold(); // C4: kill crunch (throttled)
|
|
||||||
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
|
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||||
@@ -338,7 +384,6 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||||
TryHold(); // C4: kill crunch (throttled)
|
|
||||||
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||||
@@ -695,24 +740,124 @@ namespace ProjectM.Client
|
|||||||
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
|
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
|
||||||
{
|
{
|
||||||
if (prefab == null || _fxRoot == null) return;
|
if (prefab == null || _fxRoot == null) return;
|
||||||
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC
|
if (_activeVfx.Count >= MaxActiveVfx) return; // in-flight cap: unchanged, this bounds live particles too
|
||||||
var go = Object.Instantiate(prefab, pos, rot, _fxRoot);
|
var inst = RentVfx(prefab, pos, rot);
|
||||||
go.transform.position = pos;
|
if (inst == null) return;
|
||||||
StripCosmetic(go);
|
_activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) });
|
||||||
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) });
|
|
||||||
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
|
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
|
||||||
DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f);
|
DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track B: take an instance from the per-prefab pool (or fill a new one) instead of Instantiating.
|
||||||
|
/// Order matters — transform BEFORE the particle restart, because a world-space ParticleSystem would
|
||||||
|
/// otherwise re-show the previous burst's particles at their OLD positions for a frame.
|
||||||
|
/// </summary>
|
||||||
|
VfxInstance RentVfx(GameObject prefab, Vector3 pos, Quaternion rot)
|
||||||
|
{
|
||||||
|
VfxInstance inst = null;
|
||||||
|
if (_vfxPool.TryGetValue(prefab, out var stack))
|
||||||
|
{
|
||||||
|
while (stack.Count > 0) // null-skip: a pooled entry destroyed out from under us is discarded
|
||||||
|
{
|
||||||
|
var cand = stack.Pop();
|
||||||
|
if (cand != null && cand.Go != null) { inst = cand; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inst ??= FillVfx(prefab);
|
||||||
|
if (inst == null) return null;
|
||||||
|
|
||||||
|
var tr = inst.Tr;
|
||||||
|
tr.SetParent(_fxRoot, false);
|
||||||
|
tr.SetPositionAndRotation(pos, rot);
|
||||||
|
tr.localScale = _vfxPrefabScale.TryGetValue(prefab, out var s) ? s : Vector3.one; // never inherit the last rent's scale
|
||||||
|
inst.Go.SetActive(true);
|
||||||
|
|
||||||
|
for (int i = 0; i < inst.Trails.Length; i++)
|
||||||
|
if (inst.Trails[i] != null) inst.Trails[i].Clear(); // else a streak draws from the previous despawn point
|
||||||
|
for (int i = 0; i < inst.Systems.Length; i++)
|
||||||
|
{
|
||||||
|
var ps = inst.Systems[i];
|
||||||
|
if (ps == null) continue;
|
||||||
|
ps.Clear(true);
|
||||||
|
ps.Play(true);
|
||||||
|
}
|
||||||
|
inst.Rented = true;
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build one pooled instance. Instantiated under an INACTIVE root so Awake/Start never run, which is
|
||||||
|
/// also what makes the DestroyImmediate in StripCosmetic safe: a deferred Destroy would hand out an
|
||||||
|
/// instance still carrying a live Rigidbody + Collider for one frame if it were rented the same frame.
|
||||||
|
/// Component arrays are cached PER INSTANCE — component references are instance-scoped, so caching them
|
||||||
|
/// off the prefab asset would drive the asset instead.
|
||||||
|
/// </summary>
|
||||||
|
VfxInstance FillVfx(GameObject prefab)
|
||||||
|
{
|
||||||
|
if (_vfxFillRoot == null)
|
||||||
|
{
|
||||||
|
var fillGo = new GameObject("~VfxPool");
|
||||||
|
fillGo.transform.SetParent(_fxRoot, false);
|
||||||
|
fillGo.SetActive(false);
|
||||||
|
_vfxFillRoot = fillGo.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
var go = Object.Instantiate(prefab, _vfxFillRoot);
|
||||||
|
StripCosmetic(go);
|
||||||
|
var inst = new VfxInstance
|
||||||
|
{
|
||||||
|
Go = go,
|
||||||
|
Tr = go.transform,
|
||||||
|
Systems = go.GetComponentsInChildren<ParticleSystem>(true),
|
||||||
|
Trails = go.GetComponentsInChildren<TrailRenderer>(true),
|
||||||
|
Prefab = prefab,
|
||||||
|
};
|
||||||
|
for (int i = 0; i < inst.Systems.Length; i++)
|
||||||
|
{
|
||||||
|
// A prefab whose stopAction is Destroy/Disable would silently destroy the POOLED instance when
|
||||||
|
// the effect finishes, draining the pool and pushing dead objects onto the stack.
|
||||||
|
var main = inst.Systems[i].main;
|
||||||
|
main.stopAction = ParticleSystemStopAction.None;
|
||||||
|
}
|
||||||
|
if (!_vfxPrefabScale.ContainsKey(prefab)) _vfxPrefabScale[prefab] = prefab.transform.localScale;
|
||||||
|
if (!_vfxLifetime.ContainsKey(prefab)) _vfxLifetime[prefab] = VfxLifetime(inst.Systems);
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Park an instance back on its OWN prefab's stack (keyed off the record, never a re-read of VFXConfig —
|
||||||
|
/// swapping a config field mid-play would otherwise file it under the wrong effect). The Rented flag is
|
||||||
|
/// the at-most-once guard: a double return would hand one instance to two callers.
|
||||||
|
/// </summary>
|
||||||
|
void ReturnVfx(VfxInstance inst)
|
||||||
|
{
|
||||||
|
if (inst == null || !inst.Rented) return;
|
||||||
|
inst.Rented = false;
|
||||||
|
if (inst.Go == null) return; // destroyed out from under us: drop it rather than pool a dead object
|
||||||
|
|
||||||
|
for (int i = 0; i < inst.Systems.Length; i++)
|
||||||
|
if (inst.Systems[i] != null) inst.Systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||||
|
for (int i = 0; i < inst.Trails.Length; i++)
|
||||||
|
if (inst.Trails[i] != null) inst.Trails[i].Clear();
|
||||||
|
|
||||||
|
inst.Go.SetActive(false);
|
||||||
|
if (_vfxFillRoot != null) inst.Tr.SetParent(_vfxFillRoot, false);
|
||||||
|
|
||||||
|
if (!_vfxPool.TryGetValue(inst.Prefab, out var stack)) { stack = new Stack<VfxInstance>(); _vfxPool[inst.Prefab] = stack; }
|
||||||
|
if (stack.Count >= VfxPerPrefabRetain) { Object.Destroy(inst.Go); return; } // bound the retained set after a burst
|
||||||
|
stack.Push(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
double VfxLifetimeFor(GameObject prefab) => _vfxLifetime.TryGetValue(prefab, out var d) ? d : 1.0;
|
||||||
|
|
||||||
void PruneVfx()
|
void PruneVfx()
|
||||||
{
|
{
|
||||||
double now = SystemAPI.Time.ElapsedTime;
|
double now = SystemAPI.Time.ElapsedTime;
|
||||||
for (int i = _activeVfx.Count - 1; i >= 0; i--)
|
for (int i = _activeVfx.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
if (now < _activeVfx[i].Kill) continue;
|
if (now < _activeVfx[i].Kill) continue;
|
||||||
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go);
|
ReturnVfx(_activeVfx[i].Inst); // pooled, not destroyed
|
||||||
_activeVfx.RemoveAt(i);
|
_activeVfx.RemoveAt(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -722,10 +867,11 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
|
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
|
||||||
{
|
{
|
||||||
// Config cleared mid-run: drop any orphaned trails so they don't linger.
|
// Config cleared mid-run: RETURN the orphans rather than destroying them, or the pool's
|
||||||
|
// bookkeeping under-counts and the instances leak out of it.
|
||||||
if (_projTrails.Count > 0)
|
if (_projTrails.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
|
foreach (var kv in _projTrails) ReturnVfx(kv.Value);
|
||||||
_projTrails.Clear();
|
_projTrails.Clear();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -739,15 +885,12 @@ namespace ProjectM.Client
|
|||||||
Vector3 wp = (Vector3)xf.ValueRO.Position;
|
Vector3 wp = (Vector3)xf.ValueRO.Position;
|
||||||
if (_projTrails.TryGetValue(entity, out var trail))
|
if (_projTrails.TryGetValue(entity, out var trail))
|
||||||
{
|
{
|
||||||
if (trail != null) trail.transform.position = wp;
|
if (trail != null && trail.Tr != null) trail.Tr.position = wp;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot);
|
var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity);
|
||||||
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only
|
if (inst != null) _projTrails[entity] = inst;
|
||||||
var systems = go.GetComponentsInChildren<ParticleSystem>();
|
|
||||||
for (int i = 0; i < systems.Length; i++) systems[i].Play();
|
|
||||||
_projTrails[entity] = go;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,7 +900,7 @@ namespace ProjectM.Client
|
|||||||
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
|
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
|
||||||
for (int i = 0; i < _projStale.Count; i++)
|
for (int i = 0; i < _projStale.Count; i++)
|
||||||
{
|
{
|
||||||
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]);
|
ReturnVfx(_projTrails[_projStale[i]]);
|
||||||
_projTrails.Remove(_projStale[i]);
|
_projTrails.Remove(_projStale[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -767,27 +910,31 @@ namespace ProjectM.Client
|
|||||||
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
|
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
|
||||||
static void StripCosmetic(GameObject go)
|
static void StripCosmetic(GameObject go)
|
||||||
{
|
{
|
||||||
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
|
// DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance
|
||||||
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
|
// filled and rented in the SAME frame would still carry a live Rigidbody + Collider — exactly the
|
||||||
|
// self-propelling / secondary-spawn behaviour this strip exists to prevent. Legal here because the
|
||||||
|
// target is a freshly-instantiated runtime instance under an inactive root, never a prefab asset.
|
||||||
|
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.DestroyImmediate(rb);
|
||||||
|
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.DestroyImmediate(col);
|
||||||
|
|
||||||
|
// Cosmetic VFX must be particles ONLY. This used to disable by type-name substring ("Projectile" /
|
||||||
|
// "Move"), which let any other authored helper (auto-destroy timers, effect settings, light flicker)
|
||||||
|
// survive — harmless when the object was destroyed after one use, but a pooled instance re-runs
|
||||||
|
// OnEnable on EVERY rent, so a survivor would re-arm each time and could Destroy the pooled object.
|
||||||
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
|
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
|
||||||
{
|
if (mb != null) mb.enabled = false;
|
||||||
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
|
// Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a
|
||||||
// or hold a finished GameObject around on a blanket TTL.
|
// finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old
|
||||||
static double VfxLifetime(GameObject go)
|
// GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime.
|
||||||
|
static double VfxLifetime(ParticleSystem[] systems)
|
||||||
{
|
{
|
||||||
float longest = 0f;
|
float longest = 0f;
|
||||||
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
|
for (int i = 0; i < systems.Length; i++)
|
||||||
{
|
{
|
||||||
var main = ps.main;
|
if (systems[i] == null) continue;
|
||||||
|
var main = systems[i].main;
|
||||||
float d = main.duration + main.startLifetime.constantMax;
|
float d = main.duration + main.startLifetime.constantMax;
|
||||||
if (d > longest) longest = d;
|
if (d > longest) longest = d;
|
||||||
}
|
}
|
||||||
@@ -796,6 +943,8 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
|
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
|
||||||
|
|
||||||
|
const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers)
|
||||||
|
|
||||||
class FloatingNumber
|
class FloatingNumber
|
||||||
{
|
{
|
||||||
public TextMesh Tm;
|
public TextMesh Tm;
|
||||||
@@ -805,6 +954,7 @@ namespace ProjectM.Client
|
|||||||
public Vector3 Vel;
|
public Vector3 Vel;
|
||||||
public Color BaseColor;
|
public Color BaseColor;
|
||||||
public bool Active;
|
public bool Active;
|
||||||
|
public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers)
|
||||||
}
|
}
|
||||||
|
|
||||||
FloatingNumber CreateNumber()
|
FloatingNumber CreateNumber()
|
||||||
@@ -836,6 +986,7 @@ namespace ProjectM.Client
|
|||||||
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
||||||
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
||||||
fn.Tm.color = fn.BaseColor;
|
fn.Tm.color = fn.BaseColor;
|
||||||
|
fn.ShownAlphaStep = AlphaSteps; // BaseColor is fully opaque, i.e. the top fade step — keeps AnimateNumbers from re-writing on frame 1
|
||||||
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
||||||
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
||||||
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
|
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
|
||||||
@@ -862,9 +1013,17 @@ namespace ProjectM.Client
|
|||||||
fn.Tr.position += fn.Vel * dt;
|
fn.Tr.position += fn.Vel * dt;
|
||||||
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
||||||
|
|
||||||
var c = fn.BaseColor;
|
// Track B: legacy TextMesh bakes colour into VERTEX colours, so every colour write forces a
|
||||||
c.a = 1f - (fn.Age / fn.Life);
|
// text-mesh rebuild — up to 32 rebuilds a frame with a full pool. Quantising the fade to 12
|
||||||
fn.Tm.color = c;
|
// steps turns ~50 rebuilds per number into 12, with no visible difference over its <1 s life.
|
||||||
|
int step = (int)((1f - fn.Age / fn.Life) * AlphaSteps);
|
||||||
|
if (step != fn.ShownAlphaStep)
|
||||||
|
{
|
||||||
|
fn.ShownAlphaStep = step;
|
||||||
|
var c = fn.BaseColor;
|
||||||
|
c.a = step / (float)AlphaSteps;
|
||||||
|
fn.Tm.color = c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,7 +1073,7 @@ namespace ProjectM.Client
|
|||||||
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign,
|
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign,
|
||||||
float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f)
|
float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f)
|
||||||
{
|
{
|
||||||
const int seg = 16;
|
const int seg = ArcSeg;
|
||||||
float r1 = Mathf.Max(0.4f, range);
|
float r1 = Mathf.Max(0.4f, range);
|
||||||
float r0 = r1 * innerFrac;
|
float r0 = r1 * innerFrac;
|
||||||
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
||||||
@@ -923,47 +1082,53 @@ namespace ProjectM.Client
|
|||||||
float aBase = aStart;
|
float aBase = aStart;
|
||||||
if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge
|
if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge
|
||||||
aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad);
|
aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad);
|
||||||
var verts = new Vector3[(seg + 1) * 2];
|
|
||||||
var cols = new Color[(seg + 1) * 2];
|
// UVs and triangles do not depend on ANY argument, so they are built once for the whole system
|
||||||
var uvs = new Vector2[(seg + 1) * 2];
|
// instead of being regenerated (and re-uploaded) on every call.
|
||||||
var tris = new int[seg * 6];
|
if (!_arcStaticsBuilt)
|
||||||
|
{
|
||||||
|
for (int i = 0; i <= seg; i++)
|
||||||
|
{
|
||||||
|
_arcUvs[i * 2] = new Vector2(0.5f, 0.5f);
|
||||||
|
_arcUvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < seg; i++)
|
||||||
|
{
|
||||||
|
int b = i * 2;
|
||||||
|
_arcTris[i * 6 + 0] = b; _arcTris[i * 6 + 1] = b + 1; _arcTris[i * 6 + 2] = b + 2;
|
||||||
|
_arcTris[i * 6 + 3] = b + 1; _arcTris[i * 6 + 4] = b + 3; _arcTris[i * 6 + 5] = b + 2;
|
||||||
|
}
|
||||||
|
_arcStaticsBuilt = true;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i <= seg; i++)
|
for (int i = 0; i <= seg; i++)
|
||||||
{
|
{
|
||||||
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
|
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
|
||||||
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
||||||
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
|
_arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0);
|
||||||
verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
|
_arcVerts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
|
||||||
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
|
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
|
||||||
cols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
|
_arcCols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
|
||||||
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
_arcCols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
||||||
uvs[i * 2] = new Vector2(0.5f, 0.5f);
|
|
||||||
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
|
||||||
}
|
}
|
||||||
for (int i = 0; i < seg; i++)
|
|
||||||
{
|
|
||||||
int b = i * 2;
|
|
||||||
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
|
||||||
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
|
||||||
}
|
|
||||||
mesh.Clear();
|
|
||||||
mesh.vertices = verts;
|
|
||||||
mesh.colors = cols;
|
|
||||||
mesh.uv = uvs;
|
|
||||||
mesh.triangles = tris;
|
|
||||||
mesh.RecalculateBounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
// First fill for THIS mesh — _slashMesh, _smearMesh and every remote arc each hit it once.
|
||||||
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
// Vertices must be uploaded before triangles or index validation fails on an empty mesh.
|
||||||
// directional, escalating cleave rather than a static flash.
|
// Afterwards only the two channels that actually change are re-uploaded.
|
||||||
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
|
if (mesh.vertexCount != _arcVerts.Length)
|
||||||
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
|
{
|
||||||
{
|
mesh.Clear();
|
||||||
if (!FeelConfig.HitStopFreezeEnabled) return;
|
mesh.vertices = _arcVerts;
|
||||||
double now = SystemAPI.Time.ElapsedTime;
|
mesh.colors = _arcCols;
|
||||||
if (now - _lastHoldTime < 0.22) return;
|
mesh.uv = _arcUvs;
|
||||||
_lastHoldTime = now;
|
mesh.triangles = _arcTris;
|
||||||
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mesh.vertices = _arcVerts;
|
||||||
|
mesh.colors = _arcCols;
|
||||||
|
}
|
||||||
|
mesh.RecalculateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes
|
// 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes
|
||||||
@@ -1002,7 +1167,6 @@ namespace ProjectM.Client
|
|||||||
if (finisher)
|
if (finisher)
|
||||||
{
|
{
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
||||||
TryHold(FeelConfig.FinisherHoldFrames); // G5: the heavy payoff beat, at contact
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,8 +1206,10 @@ namespace ProjectM.Client
|
|||||||
if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
|
if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
|
// the range telegraph (MC-4 clarity) AND SWEEPS across + ramps per combo step so the swing reads as a
|
||||||
|
// directional, escalating cleave rather than a static flash.
|
||||||
|
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
|
||||||
{
|
{
|
||||||
if (_slashMr == null || _slashMat == null) return;
|
if (_slashMr == null || _slashMat == null) return;
|
||||||
bool finisher = step >= comboLen;
|
bool finisher = step >= comboLen;
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ namespace ProjectM.Client
|
|||||||
if (_kindMats != null)
|
if (_kindMats != null)
|
||||||
for (int i = 0; i < _kindMats.Length; i++)
|
for (int i = 0; i < _kindMats.Length; i++)
|
||||||
if (_kindMats[i] != null) Object.Destroy(_kindMats[i]);
|
if (_kindMats[i] != null) Object.Destroy(_kindMats[i]);
|
||||||
|
// Procedural clips are not owned by _fxRoot — see FeedbackFx.DestroyClip.
|
||||||
|
FeedbackFx.DestroyClip(ref _strikeBeepClip);
|
||||||
|
if (_kindGrowls != null)
|
||||||
|
for (int i = 0; i < _kindGrowls.Length; i++) FeedbackFx.DestroyClip(ref _kindGrowls[i]);
|
||||||
foreach (var kv in _dangerZones)
|
foreach (var kv in _dangerZones)
|
||||||
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
||||||
}
|
}
|
||||||
@@ -245,28 +249,52 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
|
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
|
||||||
|
// Track B: the four arrays used to be allocated on EVERY call — once per winding enemy per frame
|
||||||
|
// (~840 B a time). seg is a compile-time constant, so they are hoisted to scratch and filled in place;
|
||||||
|
// UVs/triangles are argument-independent and upload to a given mesh only on its first fill.
|
||||||
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
|
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
|
||||||
{
|
{
|
||||||
const int seg = 14;
|
const int seg = WedgeSeg;
|
||||||
var verts = new Vector3[seg + 2];
|
if (!s_wedgeStaticsBuilt)
|
||||||
var cols = new Color[seg + 2];
|
{
|
||||||
var uvs = new Vector2[seg + 2];
|
s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
|
||||||
var tris = new int[seg * 3];
|
for (int i = 0; i <= seg; i++) s_wedgeUvs[i + 1] = new Vector2(0.5f, 0.5f);
|
||||||
|
for (int i = 0; i < seg; i++) { s_wedgeTris[i * 3] = 0; s_wedgeTris[i * 3 + 1] = i + 1; s_wedgeTris[i * 3 + 2] = i + 2; }
|
||||||
|
s_wedgeStaticsBuilt = true;
|
||||||
|
}
|
||||||
|
|
||||||
float aCenter = 0.18f + 0.62f * intensity;
|
float aCenter = 0.18f + 0.62f * intensity;
|
||||||
verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f);
|
s_wedgeVerts[0] = Vector3.zero;
|
||||||
|
s_wedgeCols[0] = new Color(1f, 1f, 1f, aCenter);
|
||||||
for (int i = 0; i <= seg; i++)
|
for (int i = 0; i <= seg; i++)
|
||||||
{
|
{
|
||||||
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
||||||
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
|
s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
|
||||||
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
|
s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
|
||||||
uvs[i + 1] = new Vector2(0.5f, 0.5f);
|
}
|
||||||
|
|
||||||
|
if (mesh.vertexCount != s_wedgeVerts.Length)
|
||||||
|
{
|
||||||
|
mesh.Clear();
|
||||||
|
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
|
||||||
|
mesh.uv = s_wedgeUvs; mesh.triangles = s_wedgeTris;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; }
|
|
||||||
mesh.Clear();
|
|
||||||
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
|
|
||||||
mesh.RecalculateBounds();
|
mesh.RecalculateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wedge scratch (see BuildDangerMesh). Static is safe: presentation systems are main-thread only, and
|
||||||
|
// the contents are deterministic geometry, so surviving a domain reload leaves them valid.
|
||||||
|
const int WedgeSeg = 14;
|
||||||
|
static readonly Vector3[] s_wedgeVerts = new Vector3[WedgeSeg + 2];
|
||||||
|
static readonly Color[] s_wedgeCols = new Color[WedgeSeg + 2];
|
||||||
|
static readonly Vector2[] s_wedgeUvs = new Vector2[WedgeSeg + 2];
|
||||||
|
static readonly int[] s_wedgeTris = new int[WedgeSeg * 3];
|
||||||
|
static bool s_wedgeStaticsBuilt;
|
||||||
|
|
||||||
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
|
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
|
||||||
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
|
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
|
||||||
// already rotated to the enemy facing, so +Z is "toward the locked target".
|
// already rotated to the enemy facing, so +Z is "toward the locked target".
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ namespace ProjectM.Client
|
|||||||
public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg;
|
public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg;
|
||||||
public float ShowTimer; public bool Visible;
|
public float ShowTimer; public bool Visible;
|
||||||
public float LastHp; public float MaxHp; public float3 Pos;
|
public float LastHp; public float MaxHp; public float3 Pos;
|
||||||
|
// Track B: last values actually pushed to uGUI, so a bar that is merely showing pushes nothing.
|
||||||
|
// CreateHealthBar seeds these to -1 (a struct would otherwise default them to 0, which is a
|
||||||
|
// legitimate frac and would skip the first render of a fully-drained bar).
|
||||||
|
public float LastFrac; public float LastAlpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int HealthBarPoolLimit = 24;
|
const int HealthBarPoolLimit = 24;
|
||||||
@@ -151,7 +155,8 @@ namespace ProjectM.Client
|
|||||||
var entry = new HealthBarEntry
|
var entry = new HealthBarEntry
|
||||||
{
|
{
|
||||||
CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false,
|
CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false,
|
||||||
LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos
|
LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos,
|
||||||
|
LastFrac = -1f, LastAlpha = -1f // force the first visual push (a real frac/alpha is never negative)
|
||||||
};
|
};
|
||||||
_healthBars[entity] = entry;
|
_healthBars[entity] = entry;
|
||||||
return entry;
|
return entry;
|
||||||
@@ -209,8 +214,17 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
|
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
|
||||||
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
|
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
|
||||||
if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); }
|
// Track B: these ran unconditionally for every visible bar every frame. Writing
|
||||||
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
|
// anchorMax triggers OnRectTransformDimensionsChange and an Image.color write dirties the
|
||||||
|
// canvas — so a bar that is merely SHOWING (not changing) used to keep re-laying-out uGUI.
|
||||||
|
// Epsilon-gated: a bar only pushes when its fill or fade actually moved.
|
||||||
|
if (entry.Fill != null && (math.abs(frac - entry.LastFrac) > 0.002f || math.abs(alpha - entry.LastAlpha) > 0.002f))
|
||||||
|
{
|
||||||
|
var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c;
|
||||||
|
entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f);
|
||||||
|
if (entry.Bg != null) { var bgc = entry.Bg.color; bgc.a = 0.82f * alpha; entry.Bg.color = bgc; }
|
||||||
|
entry.LastFrac = frac; entry.LastAlpha = alpha;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
|
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
|
||||||
_seen.Clear();
|
_seen.Clear();
|
||||||
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
|
// Track B: created lazily — it only ever records on a newly-seen enemy, but it used to be built
|
||||||
|
// and played back (a sync point) every single frame.
|
||||||
|
EntityCommandBuffer ecb = default;
|
||||||
|
bool hasEcb = false;
|
||||||
foreach (var (health, entity) in
|
foreach (var (health, entity) in
|
||||||
SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess())
|
SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess())
|
||||||
{
|
{
|
||||||
@@ -64,13 +67,17 @@ namespace ProjectM.Client
|
|||||||
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
|
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
|
||||||
entry.RenderKids.Add(c);
|
entry.RenderKids.Add(c);
|
||||||
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||||
|
{
|
||||||
|
if (!hasEcb) { ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); hasEcb = true; }
|
||||||
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
|
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
|
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
|
||||||
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
|
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
|
||||||
}
|
}
|
||||||
ecb.Playback(EntityManager);
|
// Only pay the structural-change sync point on the frames that actually recorded something
|
||||||
ecb.Dispose();
|
// (i.e. a newly-seen enemy) instead of every frame.
|
||||||
|
if (hasEcb) { ecb.Playback(EntityManager); ecb.Dispose(); }
|
||||||
|
|
||||||
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
|
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
|
||||||
var bc = FeelConfig.BodyFlashColor;
|
var bc = FeelConfig.BodyFlashColor;
|
||||||
|
|||||||
@@ -139,7 +139,23 @@ namespace ProjectM.Client
|
|||||||
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||||||
{
|
{
|
||||||
if (clip == null) return;
|
if (clip == null) return;
|
||||||
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
// Pooled 3D voices, NOT AudioSource.PlayClipAtPoint: that allocates a GameObject +
|
||||||
|
// AudioSource per call and schedules a delayed Destroy, ~20-33 times a second in combat.
|
||||||
|
// GameVolume.Sfx is read HERE (at play time) so the bus trim applies per cue, as before.
|
||||||
|
OneShotAudioPool.Play(clip, pos, vol * GameVolume.Sfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Destroy a procedurally-built clip on world teardown. An <c>AudioClip.Create</c>d clip is a standalone
|
||||||
|
/// UnityEngine.Object — destroying the system's FX-root GameObject does NOT take it with it, so every
|
||||||
|
/// presentation system that builds clips leaked its native audio buffer on each client-world teardown
|
||||||
|
/// (unbounded growth across menu -> game -> menu cycles). Nulls the reference so a re-created system
|
||||||
|
/// rebuilds rather than holding a destroyed clip.
|
||||||
|
/// </summary>
|
||||||
|
public static void DestroyClip(ref AudioClip clip)
|
||||||
|
{
|
||||||
|
if (clip != null) Object.Destroy(clip);
|
||||||
|
clip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
|
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ namespace ProjectM.Client
|
|||||||
if (_discMat != null) Object.Destroy(_discMat);
|
if (_discMat != null) Object.Destroy(_discMat);
|
||||||
if (_burstMat != null) Object.Destroy(_burstMat);
|
if (_burstMat != null) Object.Destroy(_burstMat);
|
||||||
if (_discMesh != null) Object.Destroy(_discMesh);
|
if (_discMesh != null) Object.Destroy(_discMesh);
|
||||||
|
FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ namespace ProjectM.Client
|
|||||||
float _downedSince = -1f;
|
float _downedSince = -1f;
|
||||||
float _prevHp, _flash;
|
float _prevHp, _flash;
|
||||||
bool _haveHp;
|
bool _haveHp;
|
||||||
|
// Track B: last value pushed to each Label. A UITK text assignment is cheap, but BUILDING the string
|
||||||
|
// (int.ToString / concat) allocates every frame regardless — these gate the build, not just the write.
|
||||||
|
// int.MinValue rather than -1 so a legitimately negative or zero first value still renders.
|
||||||
|
int _shownAether = int.MinValue, _shownOre = int.MinValue, _shownBio = int.MinValue;
|
||||||
|
int _shownThreat = int.MinValue, _shownHp = int.MinValue, _shownMaxHp = int.MinValue;
|
||||||
|
int _shownRespawnSecs = int.MinValue;
|
||||||
// personal inventory panel (read-only; toggled with I)
|
// personal inventory panel (read-only; toggled with I)
|
||||||
VisualElement _invPanel, _invList, _equipList;
|
VisualElement _invPanel, _invList, _equipList;
|
||||||
bool _invOpen;
|
bool _invOpen;
|
||||||
@@ -161,9 +167,10 @@ namespace ProjectM.Client
|
|||||||
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
|
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_aetherNum.text = aether.ToString();
|
// Track B: three int.ToString()s every frame, for counts that change a few times a minute.
|
||||||
_oreNum.text = ore.ToString();
|
if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.ToString(); }
|
||||||
_bioNum.text = bio.ToString();
|
if (ore != _shownOre) { _shownOre = ore; _oreNum.text = ore.ToString(); }
|
||||||
|
if (bio != _shownBio) { _shownBio = bio; _bioNum.text = bio.ToString(); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +184,7 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
float intensity = Mathf.Clamp01(huskCount / 30f);
|
float intensity = Mathf.Clamp01(huskCount / 30f);
|
||||||
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
|
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
|
||||||
_threatNum.text = huskCount.ToString();
|
if (huskCount != _shownThreat) { _shownThreat = huskCount; _threatNum.text = huskCount.ToString(); } // Track B: changes on a spawn/kill, not per frame
|
||||||
_threatNum.style.color = tc;
|
_threatNum.style.color = tc;
|
||||||
_threatIcon.style.unityBackgroundImageTintColor = tc;
|
_threatIcon.style.unityBackgroundImageTintColor = tc;
|
||||||
RetintPanel(_threatPanel, PanelDark);
|
RetintPanel(_threatPanel, PanelDark);
|
||||||
@@ -225,7 +232,13 @@ namespace ProjectM.Client
|
|||||||
_healthFill.style.backgroundColor = shielded
|
_healthFill.style.backgroundColor = shielded
|
||||||
? new Color(0.45f, 0.85f, 1f)
|
? new Color(0.45f, 0.85f, 1f)
|
||||||
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
|
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
|
||||||
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
|
// Track B: `int + " / " + int` allocated a fresh string EVERY frame even at full health.
|
||||||
|
int hpI = Mathf.CeilToInt(Mathf.Max(0f, hp)), maxI = Mathf.CeilToInt(maxHp);
|
||||||
|
if (hpI != _shownHp || maxI != _shownMaxHp)
|
||||||
|
{
|
||||||
|
_shownHp = hpI; _shownMaxHp = maxI;
|
||||||
|
_healthText.text = hpI + " / " + maxI;
|
||||||
|
}
|
||||||
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
|
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
|
||||||
if (dead)
|
if (dead)
|
||||||
@@ -237,9 +250,16 @@ namespace ProjectM.Client
|
|||||||
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
|
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
|
||||||
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
|
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
|
||||||
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
|
// Track B: the countdown only changes ~1x/second, but this ran every frame — the
|
||||||
|
// concat allocated a fresh string ~80x/second while dead.
|
||||||
|
int secsLeft = left > 0.05f ? Mathf.CeilToInt(left) : -1;
|
||||||
|
if (secsLeft != _shownRespawnSecs)
|
||||||
|
{
|
||||||
|
_shownRespawnSecs = secsLeft;
|
||||||
|
_downedText.text = secsLeft > 0 ? "RESPAWNING IN " + secsLeft : "RESPAWNING...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else _downedSince = -1f;
|
else { _downedSince = -1f; _shownRespawnSecs = int.MinValue; } // re-render on the next death
|
||||||
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -57,9 +57,24 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
protected override void OnDestroy()
|
protected override void OnDestroy()
|
||||||
{
|
{
|
||||||
|
// Read the clips off the sources BEFORE destroying _root: the AudioSources die with it, but the four
|
||||||
|
// AudioClip.Create'd loops are standalone objects that do not (~6.8 MB of native audio, leaked on
|
||||||
|
// every client-world teardown). See FeedbackFx.DestroyClip.
|
||||||
|
DestroySourceClip(_bass);
|
||||||
|
DestroySourceClip(_pad);
|
||||||
|
DestroySourceClip(_arp);
|
||||||
|
DestroySourceClip(_pulse);
|
||||||
if (_root != null) Object.Destroy(_root);
|
if (_root != null) Object.Destroy(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void DestroySourceClip(AudioSource src)
|
||||||
|
{
|
||||||
|
if (src == null) return;
|
||||||
|
var clip = src.clip;
|
||||||
|
src.clip = null;
|
||||||
|
FeedbackFx.DestroyClip(ref clip);
|
||||||
|
}
|
||||||
|
|
||||||
AudioSource MakeSource(AudioClip clip)
|
AudioSource MakeSource(AudioClip clip)
|
||||||
{
|
{
|
||||||
var src = _root.AddComponent<AudioSource>();
|
var src = _root.AddComponent<AudioSource>();
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ namespace ProjectM.Client
|
|||||||
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
||||||
Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
|
Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
|
||||||
|
|
||||||
ecb.Playback(EntityManager);
|
// Track B: this only ever records on a node's FIRST sighting, but Playback is a structural-change
|
||||||
|
// sync point and used to run every frame regardless.
|
||||||
|
if (!ecb.IsEmpty) ecb.Playback(EntityManager);
|
||||||
ecb.Dispose();
|
ecb.Dispose();
|
||||||
|
|
||||||
// Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost.
|
// Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost.
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pooled 3D one-shot SFX voices — the allocation-free replacement for
|
||||||
|
/// <c>AudioSource.PlayClipAtPoint</c> that sits behind <see cref="FeedbackFx.PlayClip"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <c>PlayClipAtPoint</c> allocates a fresh <c>GameObject</c> + <c>AudioSource</c> per call and
|
||||||
|
/// schedules a delayed <c>Destroy</c>. Every one-shot in the project funnels through
|
||||||
|
/// <see cref="FeedbackFx.PlayClip"/> (footsteps, hits, kills, telegraphs, growls, strike beeps,
|
||||||
|
/// swings, connects, socket fire, dash), measured at ~20-33 calls/s in light combat — i.e. hundreds
|
||||||
|
/// of create/destroy pairs per ten seconds of play. This ring replaces that with a fixed set of
|
||||||
|
/// long-lived voices, so a one-shot costs zero managed allocation.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// PARITY with PlayClipAtPoint matters — 20 call sites are re-levelled at once by any deviation.
|
||||||
|
/// PlayClipAtPoint sets <c>spatialBlend = 1</c> explicitly (a fresh AudioSource defaults to 0 =
|
||||||
|
/// fully 2D) and leaves everything else at the stock defaults, so those are all re-stated in
|
||||||
|
/// <see cref="MakeVoice"/>. Two DELIBERATE divergences, both forced by the voices being long-lived
|
||||||
|
/// rather than per-event: <c>playOnAwake = false</c> (a pooled source would otherwise self-start on
|
||||||
|
/// any enable, replaying whatever clip is still assigned) and <c>dopplerLevel = 0</c> (a pooled
|
||||||
|
/// voice TELEPORTS between events — at the stock dopplerLevel of 1 a 20 m jump pitch-bends the clip,
|
||||||
|
/// a bug that cannot exist when the source is created at the position and never moves).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Lifetime: the root is <c>DontDestroyOnLoad</c> because <c>WorldLauncher</c> does
|
||||||
|
/// <c>LoadScene(..., Single)</c> while the client world is alive — a scene-parented pool would be
|
||||||
|
/// destroyed mid-session and every SFX would go silently dead. Statics survive fast-enter-playmode
|
||||||
|
/// domain reloads, so <see cref="ResetStatics"/> nulls them on play-enter (the house rule; without
|
||||||
|
/// it the second play session holds an array of destroyed objects and the first cue throws
|
||||||
|
/// <c>MissingReferenceException</c>). Both the root and each voice are additionally rebuilt on
|
||||||
|
/// fake-null, so anything that does destroy them heals instead of going quiet.
|
||||||
|
/// </para>
|
||||||
|
/// Main-thread only (every caller is a <c>PresentationSystemGroup</c> SystemBase) — no locking.
|
||||||
|
/// Asset-free: built from <c>new GameObject</c> + <c>AddComponent</c>, never a prefab or Resources.Load.
|
||||||
|
/// </summary>
|
||||||
|
public static class OneShotAudioPool
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Voice count. Observed peak is ~33 starts/s; the live combat cues top out at 0.45 s (the barrel boom),
|
||||||
|
/// which is ~15 concurrent voices worst case, so 32 leaves headroom and stealing never bites in practice.
|
||||||
|
/// The longest clip that can reach this pool at all is AmbientLifeSystem's 0.9 s thunder, and that sits
|
||||||
|
/// behind a currently-unreachable biome branch. Do not drop below 24: PlayClipAtPoint is effectively
|
||||||
|
/// unbounded in voice count, and bounded stealing is the one intentional behaviour change here.
|
||||||
|
/// </summary>
|
||||||
|
/// </summary>
|
||||||
|
const int RingSize = 32;
|
||||||
|
|
||||||
|
static GameObject s_root;
|
||||||
|
static AudioSource[] s_voices;
|
||||||
|
static float[] s_freeAt; // unscaled time each voice is expected to finish; 0 = idle
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Play-enter reset (the static-presentation-bridge rule). Statics survive a fast-enter-playmode
|
||||||
|
/// domain reload but the UnityEngine.Objects they point at do NOT survive exiting play mode — so
|
||||||
|
/// the array must be dropped, not reused, or session two rents destroyed voices.
|
||||||
|
/// </summary>
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetStatics()
|
||||||
|
{
|
||||||
|
s_root = null;
|
||||||
|
s_voices = null;
|
||||||
|
s_freeAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fire a positional one-shot. <paramref name="volume"/> is the FINAL level — the caller
|
||||||
|
/// (<see cref="FeedbackFx.PlayClip"/>) has already applied the <c>GameVolume.Sfx</c> bus trim,
|
||||||
|
/// exactly as the PlayClipAtPoint call did. Baked in at play time and never re-applied to a live
|
||||||
|
/// voice, so moving the SFX slider cannot retroactively re-level a cue already in flight.
|
||||||
|
/// </summary>
|
||||||
|
public static void Play(AudioClip clip, Vector3 pos, float volume)
|
||||||
|
{
|
||||||
|
if (clip == null || !Application.isPlaying) return;
|
||||||
|
|
||||||
|
var voice = Rent(clip.length);
|
||||||
|
if (voice == null) return;
|
||||||
|
|
||||||
|
// Order matters: position BEFORE Play so the voice is spatialised at the event, not at
|
||||||
|
// wherever the previous rent left it (a pooled voice parked at the origin loses 3D panning).
|
||||||
|
voice.transform.position = pos;
|
||||||
|
voice.clip = clip;
|
||||||
|
voice.pitch = 1f; // defensive: a future per-cue pitch jitter must never leak across a reuse
|
||||||
|
voice.volume = volume;
|
||||||
|
voice.Play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stop every voice. Called on client-world teardown so in-flight combat SFX do not bleed into
|
||||||
|
/// the main menu (the pool outlives the world by design — it is DontDestroyOnLoad).
|
||||||
|
/// </summary>
|
||||||
|
public static void SilenceAll()
|
||||||
|
{
|
||||||
|
if (s_voices == null) return;
|
||||||
|
for (int i = 0; i < s_voices.Length; i++)
|
||||||
|
{
|
||||||
|
if (s_voices[i] != null) s_voices[i].Stop();
|
||||||
|
s_freeAt[i] = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First idle voice, else steal the one nearest to finishing (smallest deadline). Deadlines come
|
||||||
|
/// from the actual <c>clip.length</c> — never a fixed constant: the project's SFX span 0.05 s
|
||||||
|
/// (strike beep) to 0.45 s (barrel boom), and a fixed recycle window would audibly truncate the
|
||||||
|
/// long ones. <c>Time.unscaledTime</c> because this project never touches <c>Time.timeScale</c>
|
||||||
|
/// (hit-stop is a camera punch by house rule).
|
||||||
|
/// </summary>
|
||||||
|
static AudioSource Rent(float clipLength)
|
||||||
|
{
|
||||||
|
Build();
|
||||||
|
if (s_voices == null) return null;
|
||||||
|
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
int steal = -1;
|
||||||
|
float oldest = float.MaxValue;
|
||||||
|
|
||||||
|
for (int i = 0; i < s_voices.Length; i++)
|
||||||
|
{
|
||||||
|
if (s_voices[i] == null) continue; // destroyed out from under us; Build() already retried it
|
||||||
|
if (s_freeAt[i] <= now)
|
||||||
|
{
|
||||||
|
s_freeAt[i] = now + Mathf.Max(0.01f, clipLength);
|
||||||
|
return s_voices[i];
|
||||||
|
}
|
||||||
|
if (s_freeAt[i] < oldest) { oldest = s_freeAt[i]; steal = i; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steal < 0) return null;
|
||||||
|
s_freeAt[steal] = now + Mathf.Max(0.01f, clipLength);
|
||||||
|
return s_voices[steal];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Idempotent: builds the root and any missing voice, and heals fake-null entries.</summary>
|
||||||
|
static void Build()
|
||||||
|
{
|
||||||
|
if (s_root == null)
|
||||||
|
{
|
||||||
|
s_root = new GameObject("~OneShotAudioPool") { hideFlags = HideFlags.HideAndDontSave };
|
||||||
|
Object.DontDestroyOnLoad(s_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_voices == null || s_voices.Length != RingSize)
|
||||||
|
{
|
||||||
|
s_voices = new AudioSource[RingSize];
|
||||||
|
s_freeAt = new float[RingSize];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < RingSize; i++)
|
||||||
|
if (s_voices[i] == null) { s_voices[i] = MakeVoice(i); s_freeAt[i] = 0f; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One voice, configured for PlayClipAtPoint parity. Every stock default is re-stated explicitly
|
||||||
|
/// because a pooled source is long-lived and would otherwise drift with any future edit.
|
||||||
|
/// </summary>
|
||||||
|
static AudioSource MakeVoice(int index)
|
||||||
|
{
|
||||||
|
var go = new GameObject("Voice" + index);
|
||||||
|
go.transform.SetParent(s_root.transform, false);
|
||||||
|
var src = go.AddComponent<AudioSource>();
|
||||||
|
|
||||||
|
src.playOnAwake = false; // divergence #1: a long-lived source must never self-start
|
||||||
|
src.dopplerLevel = 0f; // divergence #2: pooled voices teleport; Doppler would pitch-bend them
|
||||||
|
src.spatialBlend = 1f; // the one thing PlayClipAtPoint sets explicitly (default is 2D)
|
||||||
|
|
||||||
|
src.loop = false;
|
||||||
|
src.mute = false;
|
||||||
|
src.rolloffMode = AudioRolloffMode.Logarithmic;
|
||||||
|
src.minDistance = 1f;
|
||||||
|
src.maxDistance = 500f;
|
||||||
|
src.spread = 0f;
|
||||||
|
src.priority = 128;
|
||||||
|
src.panStereo = 0f;
|
||||||
|
src.reverbZoneMix = 1f;
|
||||||
|
src.bypassEffects = false;
|
||||||
|
src.bypassListenerEffects = false;
|
||||||
|
src.bypassReverbZones = false;
|
||||||
|
src.pitch = 1f;
|
||||||
|
src.volume = 1f;
|
||||||
|
src.outputAudioMixerGroup = null; // no AudioMixer in this project: straight to the listener
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bba17743370546d4e85c057b60b43653
|
||||||
@@ -68,6 +68,10 @@ namespace ProjectM.Client
|
|||||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it
|
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it
|
||||||
if (_fuseMat != null) Object.Destroy(_fuseMat);
|
if (_fuseMat != null) Object.Destroy(_fuseMat);
|
||||||
if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh);
|
if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh);
|
||||||
|
// Procedural clips are NOT children of _fxRoot — see FeedbackFx.DestroyClip.
|
||||||
|
FeedbackFx.DestroyClip(ref _chipClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _clearClip);
|
||||||
|
FeedbackFx.DestroyClip(ref _boomClip);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
|
|||||||
Reference in New Issue
Block a user