Compare commits
5 Commits
13d591e789
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dced78de2 | |||
| 150f30f56c | |||
| 4bd00818e1 | |||
| e0c59ad663 | |||
| bdeee3c51a |
@@ -21,11 +21,33 @@ namespace ProjectM.Client
|
||||
|
||||
static PixelArtDevControls _instance;
|
||||
|
||||
/// <summary>
|
||||
/// OPT-IN EditorPref, mirroring `ProjectM/Boot Into Menu (Editor)`. This used to self-spawn into EVERY
|
||||
/// scene — Game.unity included, not just DevSandbox — and <see cref="OnGUI"/> draws its toggle button on
|
||||
/// every IMGUI pass BEFORE the `!_open` early-out. IMGUI dispatches at least Layout+Repaint per frame,
|
||||
/// so it allocated continuously during normal gameplay and dominated allocation profiling until it was
|
||||
/// found on 2026-08-13. Off by default; enable from the menu when you actually want to tune the look.
|
||||
/// </summary>
|
||||
const string EnabledPref = "ProjectM.PixelArtDevControls.Enabled";
|
||||
const string MenuPath = "ProjectM/Pixel Art Dev Controls (Editor)";
|
||||
|
||||
[MenuItem(MenuPath)]
|
||||
static void ToggleEnabled() => EditorPrefs.SetBool(EnabledPref, !EditorPrefs.GetBool(EnabledPref, false));
|
||||
|
||||
[MenuItem(MenuPath, true)]
|
||||
static bool ToggleEnabledValidate()
|
||||
{
|
||||
Menu.SetChecked(MenuPath, EditorPrefs.GetBool(EnabledPref, false));
|
||||
return true;
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Bootstrap()
|
||||
{
|
||||
if (_instance != null)
|
||||
return;
|
||||
if (!EditorPrefs.GetBool(EnabledPref, false))
|
||||
return; // opt-in only — see EnabledPref
|
||||
var go = new GameObject("~PixelArtDevControls") { hideFlags = HideFlags.HideAndDontSave };
|
||||
DontDestroyOnLoad(go);
|
||||
_instance = go.AddComponent<PixelArtDevControls>();
|
||||
|
||||
@@ -41,6 +41,14 @@ namespace ProjectM.Client
|
||||
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
|
||||
readonly int[] _prevRemaining = new int[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 ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
|
||||
@@ -125,9 +133,27 @@ namespace ProjectM.Client
|
||||
{
|
||||
float frac = math.saturate(remaining / (float)total);
|
||||
_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;
|
||||
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ namespace ProjectM.Client
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace ProjectM.Client
|
||||
if (_cloudMesh != null) Object.Destroy(_cloudMesh);
|
||||
if (_critterMat != null) Object.Destroy(_critterMat);
|
||||
if (_cloudMat != null) Object.Destroy(_cloudMat);
|
||||
FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace ProjectM.Client
|
||||
|
||||
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
|
||||
readonly List<TimedVfx> _activeVfx = new();
|
||||
readonly Dictionary<Entity, GameObject> _projTrails = new();
|
||||
readonly Dictionary<Entity, VfxInstance> _projTrails = new();
|
||||
readonly HashSet<Entity> _projSeen = 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
|
||||
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)
|
||||
// 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)
|
||||
int _pendingConnectStep;
|
||||
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
|
||||
int _stressBeat;
|
||||
#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
|
||||
// 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
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -161,6 +191,10 @@ namespace ProjectM.Client
|
||||
|
||||
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)
|
||||
Object.Destroy(_fxRoot.gameObject);
|
||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||
@@ -168,6 +202,18 @@ namespace ProjectM.Client
|
||||
if (_smearMesh != null) Object.Destroy(_smearMesh);
|
||||
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)
|
||||
{
|
||||
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||
@@ -178,6 +224,8 @@ namespace ProjectM.Client
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
@@ -246,7 +294,6 @@ namespace ProjectM.Client
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
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 && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
|
||||
|
||||
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
@@ -271,7 +318,6 @@ namespace ProjectM.Client
|
||||
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||
TryHold(); // C4: kill crunch (throttled)
|
||||
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||
@@ -338,7 +384,6 @@ namespace ProjectM.Client
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
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
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
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)
|
||||
{
|
||||
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) });
|
||||
if (_activeVfx.Count >= MaxActiveVfx) return; // in-flight cap: unchanged, this bounds live particles too
|
||||
var inst = RentVfx(prefab, pos, rot);
|
||||
if (inst == null) return;
|
||||
_activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) });
|
||||
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
|
||||
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()
|
||||
{
|
||||
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);
|
||||
ReturnVfx(_activeVfx[i].Inst); // pooled, not destroyed
|
||||
_activeVfx.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
@@ -722,10 +867,11 @@ namespace ProjectM.Client
|
||||
{
|
||||
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)
|
||||
{
|
||||
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
|
||||
foreach (var kv in _projTrails) ReturnVfx(kv.Value);
|
||||
_projTrails.Clear();
|
||||
}
|
||||
return;
|
||||
@@ -739,15 +885,12 @@ namespace ProjectM.Client
|
||||
Vector3 wp = (Vector3)xf.ValueRO.Position;
|
||||
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
|
||||
{
|
||||
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;
|
||||
var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity);
|
||||
if (inst != null) _projTrails[entity] = inst;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,7 +900,7 @@ namespace ProjectM.Client
|
||||
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]]);
|
||||
ReturnVfx(_projTrails[_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.
|
||||
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);
|
||||
// DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance
|
||||
// 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))
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (mb != null) 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)
|
||||
// Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a
|
||||
// finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old
|
||||
// GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime.
|
||||
static double VfxLifetime(ParticleSystem[] systems)
|
||||
{
|
||||
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;
|
||||
if (d > longest) longest = d;
|
||||
}
|
||||
@@ -796,6 +943,8 @@ namespace ProjectM.Client
|
||||
|
||||
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
|
||||
|
||||
const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers)
|
||||
|
||||
class FloatingNumber
|
||||
{
|
||||
public TextMesh Tm;
|
||||
@@ -805,6 +954,7 @@ namespace ProjectM.Client
|
||||
public Vector3 Vel;
|
||||
public Color BaseColor;
|
||||
public bool Active;
|
||||
public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers)
|
||||
}
|
||||
|
||||
FloatingNumber CreateNumber()
|
||||
@@ -836,6 +986,7 @@ namespace ProjectM.Client
|
||||
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.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.Vel = new Vector3(0f, 2.2f, 0f);
|
||||
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;
|
||||
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
||||
|
||||
var c = fn.BaseColor;
|
||||
c.a = 1f - (fn.Age / fn.Life);
|
||||
fn.Tm.color = c;
|
||||
// Track B: legacy TextMesh bakes colour into VERTEX colours, so every colour write forces a
|
||||
// text-mesh rebuild — up to 32 rebuilds a frame with a full pool. Quantising the fade to 12
|
||||
// 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,
|
||||
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 r0 = r1 * innerFrac;
|
||||
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
||||
@@ -923,47 +1082,53 @@ namespace ProjectM.Client
|
||||
float aBase = aStart;
|
||||
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);
|
||||
var verts = new Vector3[(seg + 1) * 2];
|
||||
var cols = new Color[(seg + 1) * 2];
|
||||
var uvs = new Vector2[(seg + 1) * 2];
|
||||
var tris = new int[seg * 6];
|
||||
|
||||
// UVs and triangles do not depend on ANY argument, so they are built once for the whole system
|
||||
// instead of being regenerated (and re-uploaded) on every call.
|
||||
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++)
|
||||
{
|
||||
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
|
||||
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
||||
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
|
||||
verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
|
||||
_arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0);
|
||||
_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)
|
||||
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
|
||||
cols[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);
|
||||
_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
|
||||
_arcCols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
||||
}
|
||||
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
|
||||
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
||||
// directional, escalating cleave rather than a static flash.
|
||||
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
|
||||
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
|
||||
{
|
||||
if (!FeelConfig.HitStopFreezeEnabled) return;
|
||||
double now = SystemAPI.Time.ElapsedTime;
|
||||
if (now - _lastHoldTime < 0.22) return;
|
||||
_lastHoldTime = now;
|
||||
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames);
|
||||
// First fill for THIS mesh — _slashMesh, _smearMesh and every remote arc each hit it once.
|
||||
// Vertices must be uploaded before triangles or index validation fails on an empty mesh.
|
||||
// Afterwards only the two channels that actually change are re-uploaded.
|
||||
if (mesh.vertexCount != _arcVerts.Length)
|
||||
{
|
||||
mesh.Clear();
|
||||
mesh.vertices = _arcVerts;
|
||||
mesh.colors = _arcCols;
|
||||
mesh.uv = _arcUvs;
|
||||
mesh.triangles = _arcTris;
|
||||
}
|
||||
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
|
||||
@@ -1002,7 +1167,6 @@ namespace ProjectM.Client
|
||||
if (finisher)
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
|
||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||
// 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;
|
||||
bool finisher = step >= comboLen;
|
||||
|
||||
@@ -85,6 +85,10 @@ namespace ProjectM.Client
|
||||
if (_kindMats != null)
|
||||
for (int i = 0; i < _kindMats.Length; 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)
|
||||
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`.
|
||||
// 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)
|
||||
{
|
||||
const int seg = 14;
|
||||
var verts = new Vector3[seg + 2];
|
||||
var cols = new Color[seg + 2];
|
||||
var uvs = new Vector2[seg + 2];
|
||||
var tris = new int[seg * 3];
|
||||
const int seg = WedgeSeg;
|
||||
if (!s_wedgeStaticsBuilt)
|
||||
{
|
||||
s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
|
||||
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;
|
||||
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++)
|
||||
{
|
||||
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
||||
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
|
||||
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
|
||||
uvs[i + 1] = new Vector2(0.5f, 0.5f);
|
||||
s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
|
||||
s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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".
|
||||
|
||||
@@ -30,6 +30,10 @@ namespace ProjectM.Client
|
||||
public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg;
|
||||
public float ShowTimer; public bool Visible;
|
||||
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;
|
||||
@@ -151,7 +155,8 @@ namespace ProjectM.Client
|
||||
var entry = new HealthBarEntry
|
||||
{
|
||||
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;
|
||||
return entry;
|
||||
@@ -209,8 +214,17 @@ namespace ProjectM.Client
|
||||
}
|
||||
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
|
||||
? 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); }
|
||||
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
|
||||
// Track B: these ran unconditionally for every visible bar every frame. Writing
|
||||
// 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; }
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ namespace ProjectM.Client
|
||||
|
||||
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
|
||||
_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
|
||||
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;
|
||||
entry.RenderKids.Add(c);
|
||||
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||
{
|
||||
if (!hasEcb) { ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); hasEcb = true; }
|
||||
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).
|
||||
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
|
||||
}
|
||||
ecb.Playback(EntityManager);
|
||||
ecb.Dispose();
|
||||
// Only pay the structural-change sync point on the frames that actually recorded something
|
||||
// (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.
|
||||
var bc = FeelConfig.BodyFlashColor;
|
||||
|
||||
@@ -139,7 +139,23 @@ namespace ProjectM.Client
|
||||
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||||
{
|
||||
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) ----
|
||||
|
||||
@@ -39,14 +39,10 @@ namespace ProjectM.Client
|
||||
public static float HitStopFovKickMin;
|
||||
/// <summary>Max FOV kick (deg) on a heavy player-dealt hit (delta >= HitStopRefDamage).</summary>
|
||||
public static float HitStopFovKickMax;
|
||||
/// <summary>Reserved cap for the (deferred) true freeze-frame, in fixed frames.</summary>
|
||||
public static int HitStopMaxFrames;
|
||||
/// <summary>Damage delta that saturates the player-dealt punch to HitStopFovKickMax.</summary>
|
||||
public static float HitStopRefDamage;
|
||||
/// <summary>Tint for the (deferred) enemy material hit-flash — exposed now, wired in the ShaderGraph slice.</summary>
|
||||
public static Color HitFlashColor;
|
||||
/// <summary>Master gate for the (deferred) true freeze-frame hit-stop. FALSE for v1 (camera-punch only).</summary>
|
||||
public static bool HitStopFreezeEnabled;
|
||||
|
||||
// ---- Feature 1/2: death camera punch ----
|
||||
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
|
||||
@@ -170,8 +166,6 @@ namespace ProjectM.Client
|
||||
public static float MeleeArcIntensity;
|
||||
/// <summary>Bubbles shed along a melee cleave (0 = off).</summary>
|
||||
public static int MeleeArcBubbles;
|
||||
/// <summary>Camera hold frames when the melee FINISHER lands (07-20 G5 impact ladder; light hits use HitStopMaxFrames).</summary>
|
||||
public static int FinisherHoldFrames;
|
||||
|
||||
// ---- 07-21 enemy hit-reacts (G5; the Rukhanka-safe flinch that replaced the cut vibrate) ----
|
||||
/// <summary>Seconds the light flinch pulse holds (0 = hit-reacts OFF).</summary>
|
||||
@@ -252,10 +246,8 @@ namespace ProjectM.Client
|
||||
HitStopDurationMs = 90f;
|
||||
HitStopFovKickMin = 0.6f;
|
||||
HitStopFovKickMax = 2.2f;
|
||||
HitStopMaxFrames = 2; // C4: a 2-frame (~33ms) camera hold reads as crunch, not a lag-y stutter
|
||||
HitStopRefDamage = 30f;
|
||||
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
|
||||
HitStopFreezeEnabled = true; // C4: enable the finisher hit-stop hold (presentation-only camera freeze, bounded below)
|
||||
|
||||
// Feature 1/2 death
|
||||
PlayerDeathShake = 0.50f;
|
||||
@@ -318,7 +310,6 @@ namespace ProjectM.Client
|
||||
CombatIdleHoldSec = 4f; // 07-18: Menacing01 combat-idle hold after the last swing
|
||||
MeleeArcIntensity = 0.8f; // 07-21 HEAVY LOCK: the weapon is the read, the arc recedes further
|
||||
MeleeArcBubbles = 6;
|
||||
FinisherHoldFrames = 7; // 07-21 HEAVY LOCK: a fatter finisher beat (~117ms, still under the 8-frame ladder cap)
|
||||
HitReactSeconds = 0.3f; // 07-21: light flinch pulse (clip plays at 1.4x -> the peak lands inside it)
|
||||
HitStaggerSeconds = 0.55f; // heavy stagger pulse
|
||||
HitReactStaggerDamage = 50f; // finisher (63 seeded) staggers, light (42) flinches -- tracks B2 poise
|
||||
|
||||
@@ -73,6 +73,7 @@ namespace ProjectM.Client
|
||||
if (_discMat != null) Object.Destroy(_discMat);
|
||||
if (_burstMat != null) Object.Destroy(_burstMat);
|
||||
if (_discMesh != null) Object.Destroy(_discMesh);
|
||||
FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
|
||||
@@ -90,6 +90,12 @@ namespace ProjectM.Client
|
||||
float _downedSince = -1f;
|
||||
float _prevHp, _flash;
|
||||
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)
|
||||
VisualElement _invPanel, _invList, _equipList;
|
||||
bool _invOpen;
|
||||
@@ -161,9 +167,10 @@ namespace ProjectM.Client
|
||||
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
|
||||
}
|
||||
}
|
||||
_aetherNum.text = aether.ToString();
|
||||
_oreNum.text = ore.ToString();
|
||||
_bioNum.text = bio.ToString();
|
||||
// Track B: three int.ToString()s every frame, for counts that change a few times a minute.
|
||||
if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.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);
|
||||
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;
|
||||
_threatIcon.style.unityBackgroundImageTintColor = tc;
|
||||
RetintPanel(_threatPanel, PanelDark);
|
||||
@@ -225,7 +232,13 @@ namespace ProjectM.Client
|
||||
_healthFill.style.backgroundColor = shielded
|
||||
? 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);
|
||||
_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;
|
||||
|
||||
if (dead)
|
||||
@@ -237,9 +250,16 @@ namespace ProjectM.Client
|
||||
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
|
||||
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;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -57,9 +57,24 @@ namespace ProjectM.Client
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static void DestroySourceClip(AudioSource src)
|
||||
{
|
||||
if (src == null) return;
|
||||
var clip = src.clip;
|
||||
src.clip = null;
|
||||
FeedbackFx.DestroyClip(ref clip);
|
||||
}
|
||||
|
||||
AudioSource MakeSource(AudioClip clip)
|
||||
{
|
||||
var src = _root.AddComponent<AudioSource>();
|
||||
|
||||
@@ -53,7 +53,9 @@ namespace ProjectM.Client
|
||||
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
||||
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();
|
||||
|
||||
// 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
|
||||
@@ -58,10 +58,6 @@ namespace ProjectM.Client
|
||||
s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants)
|
||||
}
|
||||
|
||||
/// <summary>C4 hit-stop: hold the follow camera for a couple frames on a heavy hit (the combo finisher) so the
|
||||
/// impact lands with a beat of crunch. Presentation only — NEVER Time.timeScale (the deterministic sim keeps ticking).</summary>
|
||||
static int s_holdFrames;
|
||||
public static void Hold(int frames) { if (frames > s_holdFrames) s_holdFrames = frames; }
|
||||
|
||||
|
||||
[Header("Angle (degrees)")]
|
||||
@@ -95,6 +91,11 @@ namespace ProjectM.Client
|
||||
|
||||
Camera _cam;
|
||||
Vector3 _leadOffset; // smoothed look-ahead offset (world units), eased toward the desired lead each frame
|
||||
// The SMOOTHED follow position, kept free of shake. Reading the shaken transform back into the follow
|
||||
// filter is what made the camera wander in combat (see LateUpdate). Seeded from the transform on the
|
||||
// first frame so a scene-authored camera pose is honoured rather than snapped from the origin.
|
||||
Vector3 _basePos;
|
||||
bool _baseInit;
|
||||
|
||||
// 07-16c DEV ZOOM (LoL/SoD-style scroll-to-inspect; may not ship): the wheel moves a smoothed
|
||||
// distance target between ZoomMinDistance and the rig's authored Distance — zoom IN only, scroll
|
||||
@@ -156,15 +157,32 @@ namespace ProjectM.Client
|
||||
// uninitialized -> no change). Multiplies the serialized FollowSharpness so scenes stay untouched.
|
||||
float dragSharp = FollowSharpness * Mathf.Max(0.05f, FeelConfig.CameraDragMult <= 0f ? 1f : FeelConfig.CameraDragMult);
|
||||
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-dragSharp * Time.deltaTime);
|
||||
Vector3 basePos;
|
||||
if (s_holdFrames > 0) { s_holdFrames--; basePos = transform.position; } // C4: brief hit-stop hold (freeze the follow a couple frames)
|
||||
else basePos = Vector3.Lerp(transform.position, desired, k);
|
||||
// 2026-08-13, TWO fixes here — they are the whole "combat freezes on a kill / doesn't feel smooth" bug.
|
||||
//
|
||||
// (1) SHAKE IS A TRANSIENT OFFSET, NOT PART OF THE FOLLOW STATE. The smoothing used to run as
|
||||
// Lerp(transform.position, desired, k) — and transform.position already contained LAST frame's
|
||||
// random shake, so the filter saw shake as real positional error and integrated it, correcting
|
||||
// only ~9 %/frame (FollowSharpness 8 at ~12 ms). Shake was never subtracted, only slowly lerped
|
||||
// out while new shake was added on top, so in sustained combat the camera random-walked around
|
||||
// its framing and never settled. Measured on a STATIONARY player (ideal motion = 0): one kill
|
||||
// left it 0.45 units off-frame and still 0.20 off 24 frames later. The follow now smooths
|
||||
// _basePos, which shake never touches; shake is added only when writing the transform.
|
||||
//
|
||||
// (2) The camera position-HOLD is gone. It froze the follow for a fixed number of RENDER frames on
|
||||
// every kill/heavy hit, so the freeze length was framerate-dependent (7 frames = 49 ms at
|
||||
// 144 fps, 233 ms at 30 fps), and it took its base from transform.position, permanently baking
|
||||
// that frame's shake in — a measured 0.28-unit single-frame jump. A camera that stops while the
|
||||
// world keeps moving reads as a hitch, not as crunch; impact rides the FOV punch + shake alone.
|
||||
if (!_baseInit) { _basePos = transform.position; _baseInit = true; }
|
||||
_basePos = Vector3.Lerp(_basePos, desired, k);
|
||||
|
||||
Vector3 writePos = _basePos;
|
||||
if (s_shake > 0.0001f)
|
||||
{
|
||||
basePos += UnityEngine.Random.insideUnitSphere * s_shake;
|
||||
writePos += UnityEngine.Random.insideUnitSphere * s_shake;
|
||||
s_shake = Mathf.Lerp(s_shake, 0f, 1f - Mathf.Exp(-12f * Time.deltaTime));
|
||||
}
|
||||
transform.SetPositionAndRotation(basePos, rot);
|
||||
transform.SetPositionAndRotation(writePos, rot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ namespace ProjectM.Client
|
||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it
|
||||
if (_fuseMat != null) Object.Destroy(_fuseMat);
|
||||
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()
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"feel": [
|
||||
{ "k": "MeleeArcIntensity", "v": "0.8" },
|
||||
{ "k": "MeleeArcBubbles", "v": "6" },
|
||||
{ "k": "FinisherHoldFrames", "v": "7" },
|
||||
{ "k": "MeleeConnectFovKick", "v": "1.1" },
|
||||
{ "k": "CombatIdleHoldSec", "v": "4" }
|
||||
]
|
||||
|
||||
@@ -32,10 +32,6 @@
|
||||
"k": "MeleeArcIntensity",
|
||||
"v": "0.8"
|
||||
},
|
||||
{
|
||||
"k": "FinisherHoldFrames",
|
||||
"v": "7"
|
||||
},
|
||||
{
|
||||
"k": "MeleeConnectFovKick",
|
||||
"v": "1.1"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
],
|
||||
"feel": [
|
||||
{ "k": "MeleeArcIntensity", "v": "1.1" },
|
||||
{ "k": "FinisherHoldFrames", "v": "4" },
|
||||
{ "k": "MeleeConnectFovKick", "v": "0.7" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -83,21 +83,23 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **The player is a Unity Character Controller kinematic character** (NOT a dynamic Rigidbody; M5's `PlayerMoveSystem`/`PlayerPlanarConstraintSystem` deleted, predicted-physics infra kept). `PlayerControlSystem` maps input → `CharacterControl`; `CharacterProcessor` collide-and-slides in the relocated `KinematicCharacterPhysicsUpdateGroup`. CC 1.4.2 API = `IKinematicCharacterProcessor<T>` + `KinematicCharacterDataAccess` + static `KinematicCharacterUtilities.Update_*` (verify with `unity_reflect`).
|
||||
- **`KinematicCharacterUtilities.BakeCharacter` aborts with a `Rigidbody`** and needs uniform (1,1,1) scale. **`CharacterInterpolation` must be PredictedClient-only** (a `DefaultVariantSystemBase` strips it from server + interpolated prefabs) — else double-interp on remotes. **Do NOT copy the CC sample's global `LocalTransform → DontSerializeVariant`** (project-wide; breaks non-character ghosts that rely on stock `LocalTransform` replication).
|
||||
- **Top-down CC config:** `SnapToGround=false`, `InterpolateRotation=false` (rotation owned by `PlayerAimSystem`), `SimulateDynamicBody=false`; gravity handled by feeding `float3.zero` to `Update_GroundPushing`.
|
||||
- **Hit/area tests must be SWEPT, not point checks** — a point check tunnels when the per-tick step exceeds the target radius (high speed *or* tick-batching); test the segment traversed this tick. **In a PLAIN `SimulationSystemGroup` system do NOT use `SystemAPI.Time.DeltaTime`** (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (`Projectile.LastStep`, written in the fixed-step group) and rebuild the segment as `cur - dir*LastStep`. `ecb.DestroyEntity` **at-most-once** per tick (destroyed-bitset; double destroy throws at Playback). **TWO target types in one pass: UNIFY into one best-target loop + one shared bitset** (separate sweeps double-destroy a projectile overlapping both — DR-018). **A per-hit yield `(int)` cast that also gates despawn is an immortal-sink** (sub-1.0→0→no deposit, shot still consumed): guard `math.max(1,(int)yield)` + `[Min(1f)]` authoring.
|
||||
- **Hit/area tests must be SWEPT, not point checks** — a point check tunnels when the per-tick step exceeds the target radius; test the segment traversed this tick. **In a PLAIN `SimulationSystemGroup` system do NOT use `SystemAPI.Time.DeltaTime`** (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (`Projectile.LastStep`, written in the fixed-step group) and rebuild the segment as `cur - dir*LastStep`. `ecb.DestroyEntity` **at-most-once** per tick (destroyed-bitset; double destroy throws at Playback). **TWO target types in one pass: UNIFY into one best-target loop + one shared bitset** (DR-018). **A per-hit yield `(int)` cast that also gates despawn is an immortal-sink**: guard `math.max(1,(int)yield)` + `[Min(1f)]` authoring.
|
||||
|
||||
### Build / structures / grid
|
||||
- **Grid math** (`BaseGridMath`, still live for spawn rings/respawn/lights): corner-origin, center-returning, **half-open** cell bounds, `math.floor`; lock cell size as a coordinate space once. Structures/placement themselves are deleted — recipe + atomicity rules in [[DR-014_M6_Build_Structures_Automation_Foundation]] if buildables return.
|
||||
- **Ledger spends:** afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return).
|
||||
- **DR-051 purge (07-15) ★:** siege/cycle/core/turret/automation + legacy `AbilityRef` path + onboarding **DELETED** (git = the archive). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell** (run FSM, meta shop, prep, boons, build/structures, storage, inventory/equipment, enemy variants + boss): [[DR-054_Audit_Purge_2026-08]].
|
||||
- **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`). The personal-bag/equipment layer was deleted 2026-08-07; reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites.
|
||||
- **DR-051 purge (07-15) ★:** the dead direction was **DELETED** (git = the archive; enumerated in the DR). **Retired byte VALUES stay reserved, never renumbered** (`StructureType` 1-4, `ResourceId.Charge`, `DebugOp` 3/10/11, `TuningKnob` 20-23); `DebugOp.SpawnWave`/`EndSiege` RE-MEANT (force-wave / quiet-arena). **Waves UNGATED** — a baked `WaveDirectorAuthoring` decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — **the Spark defs must be in EVERY gameplay subscene's `AbilityDatabaseAuthoring`; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0** (audit H2, live-proven). `FrameKind` = Bathynaut(2)/Harpooner(3); `PlayerClass` is gone (FrameId is the single frame identity). [[DR-051_Lantern_Realignment_Purge]] · **2026-08-07 audit purge deleted the whole base/expedition shell**: [[DR-054_Audit_Purge_2026-08]].
|
||||
- **Harvest is single-sink** (→ the shared ledger, via `HarvestMath.DepositYield`; the personal-bag layer died 08-07). Reintroduce LANTERN's carried-vs-banked split *inside HarvestMath*, not at its two call sites.
|
||||
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned) ★:** **born-correct load** — `CycleDirectorSpawnSystem` (now the ledger host only) applies the menu-staged `PendingSave` AT SPAWN. **v7 = a FRESH EPOCH: `MinLoadableVersion = CurrentVersion = 7`**; additive going forward — the save now carries only the ledger (structure/meta fields persist empty so v7 files still load). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
|
||||
### Presentation / juice / VFX
|
||||
- **All juice/HUD = client-only observe-only `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire), never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()` — NOT MonoBehaviour `LateUpdate` (job-safety throw). `Entity` = a stable client dict key per ghost lifetime — **prune the cache each frame** (a pruned ghost = a kill/loss → death VFX); **never `DestroyEntity` a ghost client-side** (`GhostDespawnSystem` owns despawn). Hit-stop = camera punch, **never `Time.timeScale`**.
|
||||
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset`→`Unload`. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16.
|
||||
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash).
|
||||
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. The build palette (lazy from the client `StructureCatalog`) drives click-to-place: green/red `BuildPreviewMath` ghost → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate. See [[DR-021_HUD_UITK_BuildPalette]].
|
||||
- **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → archive 2026-07-06 + [[DR-024_HUD_Synty_Skin_Theme]].
|
||||
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool; code-built **UI Toolkit**. Prefab-asset edits: `LoadPrefabContents`→modify→`SaveAsPrefabAsset`→`Unload`. Watch shared-material bleed. Detail → archive 07-16.
|
||||
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash; a `HideFlags.DontSave` object survives play EXIT → leaks one per reload).
|
||||
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. See [[DR-021_HUD_UITK_BuildPalette]] (build-palette half died with DR-051).
|
||||
- **Camera feel ★:** shake/punch is a TRANSIENT offset — never let the follow filter read it back (`Lerp(transform.position, …)` integrates it as real error → the cam wanders); smooth a `_basePos` the offset never touches. No frame-counted holds (framerate-dependent); the position-hold hit-stop was DROPPED 08-13 (impact = FOV + shake).
|
||||
- **Pooling + per-frame cost ★:** a per-frame value-CHANGE gate must derive its text FROM the quantised key (`Mathf.RoundToInt` = half-to-EVEN vs `ToString("0.0")` = half-away → the label latches stale). A pooled `AudioSource` ≠ `PlayClipAtPoint`: `spatialBlend=1` (default is 2D), `dopplerLevel=0` (voices teleport), root `DontDestroyOnLoad` + `SubsystemRegistration` reset. `AudioClip.Create` clips aren't FX-root-owned — `FeedbackFx.DestroyClip`. **`GC.GetTotalMemory` quantises to 4 KB — attribute with `ProfilerRecorder("GC Allocated In Frame")`.** → archive 08-13.
|
||||
- **HUD skin = build-safe `HudTheme` SO of serialized sprite refs** (runtime `Resources.Load` by name is build-stripped); tint MULTIPLIES, never set `unitySlice*` on 9-slices → [[DR-024_HUD_Synty_Skin_Theme]].
|
||||
|
||||
### Art import (HDRP store packs → URP)
|
||||
- Synty = URP-native. (BefourStudios HDRP pack deleted 2026-08-07 — 4 reachable textures kept in `_Project/Textures/Env`.)
|
||||
@@ -107,7 +109,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`).
|
||||
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
|
||||
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring (`SM_Env_Rock_Cliff` rim) + landmark colliders, player blocked via the layer matrix; enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ that slide has NO pathfinding — re-validate movers aren't frozen whenever you add Environment cover** (`EnemyMoveUtil.Depenetrate` + tangent-slide + a COVER-AWARE `EnemyNavState` nudge; full 07-07/07-10 history → archive). See [[2026-06-08_World_Collision_HUD_Scaling]].
|
||||
- **A GA "projectile" prefab self-propels** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name.
|
||||
- **A GA "projectile" prefab self-propels** — `CombatFeedbackSystem.StripCosmetic` strips every MonoBehaviour/Rigidbody/Collider at pool-fill, under an INACTIVE root so `Start` never runs.
|
||||
|
||||
### Aim / facing (SoD model — DR-052) ★
|
||||
- **`PlayerFacing` is body-yaw ONLY** (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). **Every gameplay direction reads `FacingMath.ResolveAim(PlayerInput.Aim, facing)`** (all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (`TickWindowMath`); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. [[DR-052_SoD_Facing_Underwater_Feel]] + archive 2026-07-06.
|
||||
@@ -125,7 +127,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
|
||||
|
||||
### MCP / editor workflow ★
|
||||
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**; a raw-`Write`-created NEW `.cs` gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force`. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) `script_apply_edits` **`anchor_replace`** (regex) + **`delete_method`** work even on a `struct : ISystem`.
|
||||
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** — one edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **★ One edit can SWALLOW an adjacent attribute/comment line** (07-06 `_portalMat` NRE · 07-07 `[RuntimeInitializeOnLoadMethod]` off `WorldFeelConfig.ResetDefaults` → feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config's `Enabled` in-play. **`create_script` won't overwrite**; full-file rewrites = whole-span `apply_text_edits` (its brace-balance validator guards botched spans) or `manage_script delete`+`create_script` (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). `script_apply_edits replace_method` is safe for class methods but **can't target a `struct : ISystem`**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** — one edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **★ One edit can SWALLOW an adjacent attribute/comment line — ALWAYS re-read neighbors after editing** (2 shipped bugs → archive 07-06/07-07); a silently-dead presentation slice → probe its config's `Enabled` in-play. **`create_script` won't overwrite**; full-file rewrites = whole-span `apply_text_edits` or `manage_script delete`+`create_script` (NON-GUID-referenced files only). `script_apply_edits replace_method` is safe for class methods but **can't target a `struct : ISystem`**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **`execute_code` runs as a method body** — no `using` directives (parsed as statements); fully-qualify every type. Identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` (flags overlap a shared `Game` bit).
|
||||
- **`manage_gameobject create` / `manage_prefabs modify_contents` `component_properties` SILENTLY DROP enum + Vector3 fields** — set those via a follow-up `manage_components set_property` and VERIFY through `mcpforunity://scene/gameobject/{id}/component/{Type}` (or read the baked component in `execute_code` after Play). `manage_material set_renderer_color` uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
|
||||
- **New ghost prefab recipe:** `manage_asset duplicate` a correctly-configured ghost (`UpgradePickup.prefab`) → swap the authoring MB (ownerless/interpolated `GhostAuthoring` + LEG come free). **Runtime-spawn shared ghosts** via a one-shot server spawner (dodges the prespawn handshake); wire baked spawners via `manage_scene load additive`→`set_active`→create→`save`→`close_scene`. Detail → archive 07-16.
|
||||
@@ -136,9 +138,9 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
|
||||
|
||||
## Bootstrap & worlds
|
||||
|
||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld`/`ClientWorld`); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
- **Scenes (the DR-051 contract — exactly these four):** `MainMenu.unity` (build 0, UITK frontend) · `Game.unity` (build 1, the seabed arena; subscene `Gameplay.unity`) · `DevSandbox.unity` (renamed from Gym; dev tooling + subscene `GymSub.unity`; the `DebugOverlay`/F1-F2 dev scripts gate on this scene NAME) · `ArtStaging.unity` (art viewing, no player; the look's source of truth). All share the LANTERN look (see World bullet). The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
|
||||
- **Direction = LANTERN ★ — pivot LOCKED 2026-07-13 ([[DR-048_Lantern_Adoption_Full_Pivot]]).** Co-op action-RPG, *light is territory* (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Operative roadmap [[Roadmap_Lantern_Slice]]; surviving code is **QUARRY, not foundation** ([[Lantern_Strip_Mothball_Inventory]]). World-model review **PASSED** ([[DR-049_Lantern_World_Model_Design]]) → build against [[World_Model_Build_Spec]], **re-anchoring it first** (its step 1 assumes SaveData `MinLoadableVersion` < 7, which DR-051 shipped past; its RegionTag→PocketTag anchors moved). Engine fork **PARKED — Unity stays** ([[DR-053_Engine_Fork_Bevy_Parked]], operator 08-07). The co-op-Hades core loop was DELETED 08-07 (audit H1); git is the archive. ★ **a serialized prefab/component value ignores the C# initializer — change it on the instance.**
|
||||
- **Direction = LANTERN ★ — pivot LOCKED 2026-07-13 ([[DR-048_Lantern_Adoption_Full_Pivot]]).** Co-op action-RPG, *light is territory* (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Operative roadmap [[Roadmap_Lantern_Slice]]; surviving code is **QUARRY, not foundation** ([[Lantern_Strip_Mothball_Inventory]]). World-model review **PASSED** ([[DR-049_Lantern_World_Model_Design]]) → build against [[World_Model_Build_Spec]], **re-anchoring it first** (its SaveData/RegionTag anchors moved past DR-051). Engine fork **PARKED — Unity stays** ([[DR-053_Engine_Fork_Bevy_Parked]]). ★ **a serialized prefab/component value ignores the C# initializer — change it on the instance.**
|
||||
|
||||
## DOTS / ECS conventions (authoritative summary)
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
date: 2026-08-13
|
||||
topic: Combat "freeze on kill" diagnosis + Track B (allocation / pooling)
|
||||
status: shipped (code uncommitted at time of writing)
|
||||
---
|
||||
|
||||
# 2026-08-13 — Combat freeze diagnosis + Track B allocation pass
|
||||
|
||||
Operator report: *"on a kill the combat feels like it freezes up the game and overall doesn't feel smooth."*
|
||||
|
||||
## Part 1 — Diagnosis (measured, not inferred)
|
||||
|
||||
Instrumented Play with an `EditorApplication.update` guardian injecting **real lethal `DamageEvent`s**
|
||||
server-side (the true death path — setting `Health.Current = 0` directly does NOT work, because
|
||||
`HealthApplyDamageSystem` skips any entity whose `DamageEvent` buffer is empty and so never stamps `Dying`).
|
||||
|
||||
**Ruled out by measurement:**
|
||||
|
||||
| Hypothesis | Verdict |
|
||||
|---|---|
|
||||
| CPU hitch on kill | **No.** Median frame 12.2 ms, p99 20.1 ms; a kill costs ~15 ms. |
|
||||
| Wave respawn stalls on the last kill | **No.** Enemies drip back ~1 per 29 frames, no spike. |
|
||||
| Corpses block movement for the 0.9 s `Dying` window | **No.** Enemies carry **no `PhysicsCollider` at all**. |
|
||||
| `Time.timeScale` abuse | **No.** The codebase is disciplined about this. |
|
||||
|
||||
**What it actually is — the camera.** With a *stationary* player (so ideal camera motion is exactly zero,
|
||||
making every measured value an artifact), one kill produces:
|
||||
|
||||
```
|
||||
f51 shake=0.301 hold=2 fovKick=2.20 dev=0.111 <- kill lands
|
||||
f52 step=0.283 hold=1 dev=0.269 <- 28 cm camera jump in ONE frame
|
||||
f58 dev=0.447 <- peak, 45 cm off-frame
|
||||
f75 dev=0.199 <- still 20 cm off, 24 frames later
|
||||
```
|
||||
|
||||
Three root causes, all in `PrototypeCameraRig.LateUpdate`:
|
||||
|
||||
1. **Shake is integrated by the follow filter.** `basePos = Vector3.Lerp(transform.position, desired, k)` reads
|
||||
back a `transform.position` that already contains last frame's random shake, so the smoother treats shake as
|
||||
real positional error and corrects only ~9 %/frame (`FollowSharpness 8` at 12 ms → k≈0.09). Shake is never
|
||||
subtracted, only slowly lerped out while new shake is added. In sustained combat the camera random-walks
|
||||
around its ideal framing and never settles. **This is the "doesn't feel smooth".**
|
||||
2. **The hit package and the kill package both fire on the lethal frame** — measured `shake = 0.301`
|
||||
(`HitShakeRemote 0.10` + `KillShake 0.20` stacking) and `fovKick = 2.20` (the hit punch winning over the
|
||||
kill's 1.0). One kill fires 2 SFX + 4 particle bursts + 2 light flashes + a damage number + 2 FOV punches +
|
||||
a shake + a hold + a rumble, simultaneously.
|
||||
3. **`PrototypeCameraRig.Hold()` freezes the follow, counted in FRAMES.** `HitStopMaxFrames = 2` on every kill,
|
||||
`FinisherHoldFrames = 7`. The comment claims "~117 ms" but that assumes 60 fps — it is 49 ms at 144 fps and
|
||||
233 ms at 30 fps. The hold branch also does `basePos = transform.position`, permanently baking in the shake
|
||||
offset (that is the 0.283-unit single-frame step at f52).
|
||||
|
||||
Plus a **one-off 41 ms spike on the first kill** (+44 KB) — first-use warmup of the death VFX/audio path;
|
||||
later kills cost ~15 ms.
|
||||
|
||||
A full remediation plan was presented (Track A camera, Track B allocation, Track C feel). The operator chose
|
||||
**Track B** first, then — on the hold fork — chose **drop**, and Track A shipped in the same session (Part 3).
|
||||
Track C remains open.
|
||||
|
||||
## Part 2 — Track B shipped
|
||||
|
||||
### Measurement instrument matters (a retracted result)
|
||||
|
||||
The first attribution run used `System.GC.GetTotalMemory(false)` deltas per frame and produced a per-system
|
||||
table that was **entirely noise** — that API quantises to 4 KB pages, so every per-system median came back as 0
|
||||
or exactly one page and the "savings" column was negative nonsense. **Discarded.** `ProfilerRecorder(
|
||||
ProfilerCategory.Memory, "GC Allocated In Frame")` is the correct instrument and gives real bytes/frame.
|
||||
|
||||
The corrected bulk measurement then reframed the whole track:
|
||||
|
||||
| Condition | median B/frame | p90 |
|
||||
|---|---|---|
|
||||
| All on | 15 151 | 49 073 |
|
||||
| **All 29 `ProjectM.Client` systems OFF** | **11 586** | 12 125 |
|
||||
|
||||
With *every* client system disabled the editor still allocates ~11.6 KB/frame. Our entire presentation layer is
|
||||
only ~10–25 % of the median — but it owns the **spiky tail** (p90 49 KB → 12 KB with our systems off), and that
|
||||
tail is what triggers collections in bursts. A design-review critic also found the reason the floor is so high:
|
||||
`PixelArtDevControls` is an **editor-only** dev tool that self-spawns via `[RuntimeInitializeOnLoadMethod]` +
|
||||
`DontDestroyOnLoad` into **Game.unity**, and its `OnGUI` draws a `GUI.Button` on every IMGUI pass *before* its
|
||||
early-out. It is a MonoBehaviour, so disabling ECS systems never touched it. It does not ship — but it polluted
|
||||
every editor-side allocation number taken this session.
|
||||
|
||||
### B1 — pooled one-shot SFX (`OneShotAudioPool.cs`, new)
|
||||
|
||||
All 21 one-shot call sites funnel through the single line `FeedbackFx.PlayClip` →
|
||||
`AudioSource.PlayClipAtPoint`, which allocates a `GameObject` + `AudioSource` **per call** and schedules a
|
||||
delayed `Destroy` — ~20–33 calls/s in light combat. Replaced with a 32-voice ring; `PlayClip`'s signature is
|
||||
unchanged so **all 20 consuming call sites compile untouched**.
|
||||
|
||||
Parity traps the design review caught before they shipped:
|
||||
- **`spatialBlend = 1`** — a fresh `AudioSource` defaults to **2D**; `PlayClipAtPoint` is the only thing setting
|
||||
it. Missing it would silently make every combat cue non-positional.
|
||||
- **`dopplerLevel = 0`** — pooled voices *teleport* between events; at the stock `1` a 20 m jump pitch-bends the
|
||||
clip. This bug cannot exist when the source is created at the position and never moves.
|
||||
- **`DontDestroyOnLoad`** — `WorldLauncher` does `LoadScene(..., Single)` while the client world is alive; a
|
||||
scene-parented pool would be destroyed mid-session and every SFX would go silently dead.
|
||||
- **`[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset** — statics survive fast-enter-playmode but
|
||||
the `UnityEngine.Object`s they point at do not; session two would hold an array of destroyed voices and throw
|
||||
on the first cue.
|
||||
- Recycle deadline from the actual `clip.length` (cues span 0.05 s–0.45 s), steal-oldest when saturated,
|
||||
`pitch` reset per rent, `GameVolume.Sfx` read at play time (never cached, never double-applying master).
|
||||
|
||||
### B2 — pooled authored VFX (`CombatFeedbackSystem`)
|
||||
|
||||
Per-impact `Object.Instantiate`/`Destroy` replaced with a per-prefab pool (`RentVfx`/`FillVfx`/`ReturnVfx`).
|
||||
Traps handled, all from the risk pass:
|
||||
- Component arrays cached **per instance**, not per prefab (component refs are instance-scoped; arrays captured
|
||||
off the prefab asset would drive the asset).
|
||||
- `main.stopAction = None` forced at fill — a prefab set to `Destroy`/`Disable` would silently drain the pool.
|
||||
- Instances fill under an **inactive** root so `Awake`/`Start` never run, which is what makes `DestroyImmediate`
|
||||
in `StripCosmetic` safe (a deferred `Destroy` would hand out an instance still carrying a live Rigidbody +
|
||||
Collider for one frame).
|
||||
- `StripCosmetic` now disables **all** MonoBehaviours, not two name substrings — a pooled instance re-runs
|
||||
`OnEnable` on every rent, so a surviving helper would re-arm each time.
|
||||
- Transform (position/rotation/**scale**/parent) rewritten per rent; `ps.Clear(true)` before `Play` (a
|
||||
world-space system would re-show the previous burst); `TrailRenderer.Clear()` after the reposition.
|
||||
- `Rented` flag = at-most-once guard against a double `Return` aliasing one instance to two callers.
|
||||
- Returned by the prefab stored **on the record**, never a re-read of `VFXConfig` (an inspector swap mid-play
|
||||
would file it under the wrong effect). In-flight cap `MaxActiveVfx = 40` unchanged; separate retained cap
|
||||
`VfxPerPrefabRetain = 10`.
|
||||
|
||||
### B3 — per-frame allocation
|
||||
|
||||
- `BuildSlashInto` + `BuildDangerMesh`: four arrays each (~1.7 KB / ~840 B) allocated on **every** call — the
|
||||
first runs twice a frame while a swing arc is alive, the second once per winding enemy per frame. Hoisted to
|
||||
scratch fields; UVs/triangles are argument-independent so they now upload to a mesh only on its first fill.
|
||||
- `HudSystem`, `AbilityBarSystem`: unguarded per-frame `Label.text` builds gated on a changed value.
|
||||
- `CombatFeedbackSystem.AnimateNumbers`: legacy `TextMesh` bakes colour into vertex colours, so every colour
|
||||
write rebuilds the text mesh — fade quantised to 12 steps.
|
||||
- `EnemyHealthBarSystem`: per-bar uGUI writes epsilon-gated (an `anchorMax` write triggers
|
||||
`OnRectTransformDimensionsChange`).
|
||||
- `EnemyHitFlashSystem` / `NodeFeedbackSystem`: stopped playing back an **empty** `EntityCommandBuffer` (a
|
||||
structural-change sync point) every frame.
|
||||
- **`AudioClip` leak, closed across all seven clip-owning systems.** An `AudioClip.Create`d clip is a standalone
|
||||
`UnityEngine.Object` — destroying a system's FX-root does **not** take it with it, so every client-world
|
||||
teardown leaked its native audio buffer (`MusicSystem` alone ~6.8 MB, `AmbientAudioSystem` ~2 MB). Helper
|
||||
promoted to `FeedbackFx.DestroyClip(ref AudioClip)`.
|
||||
|
||||
## Validation
|
||||
|
||||
- **L1** console clean (0 errors) against the session baseline.
|
||||
- **L2** EditMode **304/304 green** (304 is the expected post-purge count, matching 2026-08-07).
|
||||
- **L3 live**, the structural proofs that are immune to editor noise:
|
||||
- **`oneShotAudioObjectsSeen = 0`** — `PlayClipAtPoint`'s signature `"One shot audio"` GameObject never
|
||||
appeared once across 270 frames of combat with kills.
|
||||
- VFX pool filled to its `VfxPerPrefabRetain = 10` cap and **stabilised** (bounded reuse, not churn).
|
||||
- Audio verified live end-to-end: real cues (`husk_hit`, `strike`, `growl_grunt` ×2) serviced through the
|
||||
ring within 2 s of a non-lethal injected hit.
|
||||
- Clean-run frame time **median 8.6 ms / p99 13.2 ms** (pre-change runs measured 12.2–13.3 ms / 20–21 ms).
|
||||
Caveat: the two harnesses are not byte-identical (the pre-change one built a string per frame), so treat
|
||||
the direction as solid and the exact delta as indicative.
|
||||
- Allocation medians stayed inside the editor noise band — expected, given our layer is a minority of it.
|
||||
**A player build is required for a true allocation number.**
|
||||
|
||||
### A false negative worth remembering
|
||||
|
||||
An intermediate audio check reported `framesWithAudio = 0/280` and looked exactly like a silent-audio
|
||||
regression. It was a **dead window** — the previous run had killed every enemy, so nothing was cueing. Direct
|
||||
probing (`OneShotAudioPool.Play` → `isPlaying = true`, plus `s_freeAt` showing four voices serviced in the last
|
||||
0.4 s) disproved it. *Rule: before believing a "feature is dead" measurement, prove the stimulus actually
|
||||
occurred.*
|
||||
|
||||
## Reviews
|
||||
|
||||
- **Pre-code audit** (4 lenses + completeness critic + refactor-risk verifier, 6/6 agents, 0 failures): produced
|
||||
the parity spec and the trap list above. Caught `spatialBlend`, Doppler, `LoadScene(Single)`, and the
|
||||
play-enter static reset **before** they shipped, plus the `PixelArtDevControls` measurement contaminant.
|
||||
- **Post-impl diff review** (3 lenses + 19 adversarial verifiers, 22/22 agents, 0 failures): **19 findings → 5
|
||||
confirmed**, all fixed:
|
||||
1. **Real regression I introduced** — `Mathf.RoundToInt` rounds **half-to-even** while `ToString("0.0")`
|
||||
rounds **half-away-from-zero**, so my ability-bar gate key and the string it guarded disagreed at
|
||||
midpoints; the label latched a stale, too-high reading and skipped a tenth on every cooldown. Fixed by
|
||||
single-sourcing the text from the quantised integer and switching to `CeilToInt` (a countdown should never
|
||||
read lower than the true remainder), with the branch bool cached so the format still switches at 597 ticks.
|
||||
2. The clip-leak fix was **half-closed** — only `CombatFeedbackSystem`'s ten clips. Completed across all seven
|
||||
owners.
|
||||
3–5. Two duplicated comment blocks left by structured edits, and a doc-accuracy nit on the ring-size rationale.
|
||||
|
||||
## Part 3 — Track A shipped (the camera), + the dev-tool contaminant
|
||||
|
||||
Operator resolved the fork: **drop the position hold**, impact rides the FOV punch + shake.
|
||||
|
||||
**A2 — the hold is gone.** `PrototypeCameraRig.Hold`/`s_holdFrames`, `CombatFeedbackSystem.TryHold` and its four
|
||||
call sites, and the `HitStopMaxFrames` / `HitStopFreezeEnabled` / `FinisherHoldFrames` knobs all retired; the
|
||||
three saved feel profiles drop the dead keys (`FeelProfileService` skips unknown keys with a warning, so this
|
||||
was safe either way — verified before removing).
|
||||
|
||||
**A1 — the shake channel, which the measurement forced.** Dropping the hold *alone* measured **worse** (max
|
||||
deviation 0.653 vs 0.447): the hold had been partly masking the real bug by pinning the camera during the
|
||||
frames shake was loudest. So A1 landed too — the follow now smooths a `_basePos` that shake never touches, and
|
||||
shake is applied only when writing the transform.
|
||||
|
||||
| | max per-frame step | max deviation | mean deviation |
|
||||
|---|---|---|---|
|
||||
| Original | 0.283 | 0.447 (still 0.199 at +24 frames) | — |
|
||||
| Hold dropped only | 0.429 | 0.653 | — |
|
||||
| **Both fixes** | 0.428 (the shake impulse itself) | **0.329, back to 0.000 within a few frames** | **0.038** |
|
||||
|
||||
Deviation at f42/f45/f50 after the f40 kill is now literally `0.000` (was 0.269/0.429/0.399). Shake reads as a
|
||||
crisp transient punch instead of a drift the follow filter spends half a second digesting.
|
||||
|
||||
**`PixelArtDevControls` is now opt-in.** Gated behind an EditorPrefs toggle (checked menu item, mirroring
|
||||
`ProjectM/Boot Into Menu (Editor)`), off by default. Extra find: the object carries `HideFlags.DontSave`, so it
|
||||
**survives play-mode exit** while the static `_instance` does not — the old code leaked one instance per domain
|
||||
reload, and two live strays were cleared out of the editor.
|
||||
|
||||
## Notes / loose ends
|
||||
|
||||
- `Assets/_Project/Shaders/PixelOutline.mat` was dirtied by `PixelArtDevControls` writing to the shared material
|
||||
asset during the Play sessions (`_MasterEnabled` 0→1). **Reverted on the operator's instruction** — it was not
|
||||
an intentional change. The dev tool is now opt-in, so it cannot recur silently.
|
||||
- **CLAUDE.md is at 39 936 / 40 960 bytes** — exactly the ≥1 KB-headroom target. Two ★ rules were added (camera
|
||||
feel; pooling + per-frame cost) and paid for under the net-zero rule by retiring the DR-051 build-palette text
|
||||
(that code died with the purge), the two purge enumerations now carried by their DRs, and prose trims across
|
||||
the MCP-edit, swept-hit, LANTERN-direction and presentation bullets. It is still tight — a dedicated
|
||||
condensation pass would buy room for the next few sessions.
|
||||
|
||||
## Next session
|
||||
|
||||
1. **Track A leftovers** (the two smaller items from the original plan, not yet done): **de-stack the lethal
|
||||
frame** — the hit package and the kill package still both fire on the kill (measured shake 0.10+0.20 stacking,
|
||||
two FOV punches, two SFX, four bursts) — and **prewarm** the first-kill VFX/audio path to kill the one-off
|
||||
41 ms spike.
|
||||
2. **Track C — feel**: enemies have **no collider** (you walk straight through them, which is the biggest
|
||||
remaining feel gap); FOV pumps on every hit; the 0.9 s corpse window wants re-judging now the camera is calm.
|
||||
3. Measure allocation in a **player build** to get a number free of editor contamination.
|
||||
4. **Eyes-on the camera.** The numbers say it settles; whether the impact still reads as *impact* with the hold
|
||||
gone is a feel call only the operator can make.
|
||||
@@ -553,3 +553,93 @@ long forms are preserved here.
|
||||
shaking."* An `EditorApplication.update` trace quantified it instantly (y 1.157↔13.559, 7.43 units/tick; after
|
||||
the fix span 0.051, max step 0.004). Gate such effects on the presence of the owning component
|
||||
(`GetComponent<PrototypeCameraRig>()`), never on a scene name.
|
||||
|
||||
## 2026-08-13 — Combat-freeze diagnosis + Track B (allocation / pooling)
|
||||
|
||||
Session log: [[2026-08-13_Combat_Freeze_Diagnosis_Track_B_Allocation]]. Payment for the CLAUDE.md line added
|
||||
this session (net-zero rule): the six items below stay here, one condensed pointer lives inline.
|
||||
|
||||
1. **`GC.GetTotalMemory(false)` is USELESS for per-frame allocation attribution — it quantises to 4 KB pages.**
|
||||
A 29-system toggle sweep produced medians of exactly `0` or `4096` and a "savings" column of pure noise; I
|
||||
nearly shipped a refactor ranked off it. The correct instrument is
|
||||
`ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame")`, which reports real
|
||||
bytes/frame. Retract and re-measure rather than reasoning over a quantised signal.
|
||||
|
||||
2. **Attribute allocation by BULK toggle before ranking per-system work.** Disabling all 29 `ProjectM.Client`
|
||||
systems at once dropped the median from ~15.1 KB to ~11.6 KB/frame — i.e. the whole presentation layer was
|
||||
only ~10–25 % of the editor-side number, while owning the spiky tail (p90 49 KB → 12 KB). Per-system deltas
|
||||
were inside the noise; only the bulk delta was legible. Rank work off the bulk number, not the per-item one.
|
||||
|
||||
3. **★ An editor-only IMGUI dev tool can dominate your allocation measurement.** `PixelArtDevControls`
|
||||
self-spawns via `[RuntimeInitializeOnLoadMethod(AfterSceneLoad)]` + `DontDestroyOnLoad` into **Game.unity**
|
||||
(not just DevSandbox) and its `OnGUI` draws a `GUI.Button` on EVERY IMGUI pass *before* its `if (!_open)
|
||||
return`. It is a MonoBehaviour, so an ECS-system toggle sweep never touches it. Exclude it before
|
||||
attributing any editor-measured allocation to the game.
|
||||
|
||||
4. **★ A value-CHANGE gate must be single-sourced with the value it renders.** Gating a label on
|
||||
`Mathf.RoundToInt(x)` while the string is built by `ToString("0.0")` is a live bug: `Mathf.RoundToInt` is
|
||||
round-half-to-**EVEN**, custom numeric formats round half-**AWAY-from-zero**. They disagree at midpoints, so
|
||||
the gate latches and the label holds a stale, too-high reading and visibly skips a value. Derive the rendered
|
||||
text FROM the quantised key, never re-derive it from the raw input. For a countdown prefer `CeilToInt` so the
|
||||
readout never reads lower than the true remainder. (Caught by the post-impl review, not by 304 green tests.)
|
||||
|
||||
5. **Pooling an `AudioSource` is not a drop-in for `AudioSource.PlayClipAtPoint` — three properties bite.**
|
||||
(a) a fresh `AudioSource` defaults to `spatialBlend = 0` (**2D**); PlayClipAtPoint is the only thing setting
|
||||
it to 1, so a naive pool silently makes every cue non-positional and no test catches it. (b) `dopplerLevel`
|
||||
defaults to 1 and a pooled voice **teleports** between events — a 20 m jump pitch-bends the clip, a failure
|
||||
that cannot exist when the source is created at the position and never moves; set 0. (c) `playOnAwake`
|
||||
defaults true, which is harmless for a one-frame object and wrong for a long-lived one. Also: the pool root
|
||||
needs `DontDestroyOnLoad` (WorldLauncher does `LoadScene(..., Single)` while the client world is alive) and a
|
||||
`[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` reset, or session two holds destroyed voices.
|
||||
|
||||
6. **Pooling a ParticleSystem prefab: `SetActive(false)/(true)` does NOT reset it.** Rent order is transform →
|
||||
`SetActive(true)` → `ps.Clear(true); ps.Play(true)` (a world-space system otherwise re-shows the previous
|
||||
burst at its OLD positions) and `TrailRenderer.Clear()` AFTER the reposition (else a streak draws from the
|
||||
last despawn point). Force `main.stopAction = None` at fill — a prefab set to `Destroy` silently drains the
|
||||
pool. Cache component arrays **per instance** (component refs are instance-scoped; arrays captured off the
|
||||
prefab asset drive the asset). Fill under an **inactive** root so `Awake`/`Start` never run, which is what
|
||||
makes `DestroyImmediate` of the Rigidbody/Collider safe — a deferred `Destroy` would hand out an instance
|
||||
still carrying them for one frame. Guard `Return` with a `Rented` flag: a double return aliases one instance
|
||||
to two callers.
|
||||
|
||||
7. **An `AudioClip.Create`d clip is NOT owned by the GameObject that played it.** Destroying a system's FX-root
|
||||
in `OnDestroy` leaves every procedural clip alive — seven presentation systems here leaked their native audio
|
||||
buffers on every client-world teardown (`MusicSystem` ~6.8 MB, `AmbientAudioSystem` ~2 MB), growing unbounded
|
||||
across menu → game → menu cycles. Shared helper: `FeedbackFx.DestroyClip(ref AudioClip)`.
|
||||
|
||||
8. **Before believing a "the feature is dead" measurement, prove the STIMULUS occurred.** A 280-frame watch
|
||||
reported `framesWithAudio = 0` and looked exactly like a silent-audio regression from the new pool. It was a
|
||||
dead window — the prior run had killed every enemy, so nothing was cueing. Direct probing (a synthetic
|
||||
`PlayClip` → `isPlaying = true`, plus `s_freeAt` showing four voices serviced within 0.4 s) disproved it in
|
||||
one call. The same false-negative class as the 07-21 auto-recast retraction.
|
||||
|
||||
9. **Injecting a kill: append a lethal `DamageEvent`, never write `Health.Current = 0`.**
|
||||
`HealthApplyDamageSystem` early-`continue`s on an empty `DamageEvent` buffer, so a direct Health write is
|
||||
never seen by the death branch and no `Dying` is ever stamped.
|
||||
|
||||
### 2026-08-13b — camera feel (Track A, same session)
|
||||
|
||||
10. **★ Never let a transient offset (shake, recoil, punch) live in the same value your smoothing filter reads
|
||||
back.** `Lerp(transform.position, desired, k)` where `transform.position` already carries last frame's shake
|
||||
makes the filter treat shake as real positional error and INTEGRATE it — at `FollowSharpness 8` / ~12 ms
|
||||
that is ~9 %/frame, so it washes out over half a second while new shake piles on. Measured on a stationary
|
||||
player (ideal motion = 0): one kill left the camera 0.447 units off-frame, still 0.199 off 24 frames later.
|
||||
Fix: smooth a private `_basePos` the offset never touches; add the offset only when writing the transform.
|
||||
After: deviation returns to 0.000 within a few frames, mean 0.038.
|
||||
|
||||
11. **A frame-counted hold is a framerate-dependent freeze.** `FinisherHoldFrames = 7` was documented as
|
||||
"~117 ms" — true only at 60 fps; it is 49 ms at 144 and 233 ms at 30. Anything that gates on *feel duration*
|
||||
must be time-based. Worse, the hold branch took its base from `transform.position`, permanently baking that
|
||||
frame's shake in (a measured 0.283-unit single-frame jump).
|
||||
|
||||
12. **★ A masking fix can make the metric WORSE before it makes it better — measure the intermediate state.**
|
||||
Dropping the camera hold alone measured worse than leaving it (max deviation 0.653 vs 0.447), because the
|
||||
hold had been pinning the camera during the frames shake was loudest and thereby hiding the integration bug
|
||||
in #10. Had I shipped the drop on its own and stopped, the "fix" would have been a regression. Re-measure
|
||||
after each half of a two-part fix.
|
||||
|
||||
13. **`HideFlags.DontSave` objects SURVIVE play-mode exit.** A self-spawning dev tool created with
|
||||
`HideAndDontSave` + `DontDestroyOnLoad` outlives the play session while its `static _instance` guard is
|
||||
cleared by the domain reload — so it leaks one live instance per reload (two were found). If you gate such
|
||||
a bootstrap off, also sweep the existing strays with
|
||||
`Resources.FindObjectsOfTypeAll<GameObject>()` — `GameObject.Find` alone will not show you the history.
|
||||
|
||||
Reference in New Issue
Block a user