From 2aebc3711581b64bc5e31907a93c5d5fef7ddfca Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 21 Jul 2026 13:27:50 -0700 Subject: [PATCH] G6+G4: Cone damage-at-contact + zone fill telegraph (ZoneEffect GhostFields) + co-op saturation budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cone/SpecialSlam damage lands at its visual contact via ConeContactPending (knob 32, 0=legacy; early-flush + resolve re-validate; death + BOTH class-swap paths drop armed pendings — fixes the shipped melee death-strand in the same stroke). Zones: [GhostField] Caster/Radius/NextTick + ZoneTelegraphSystem (Geyser latch contract on InterpolationTick; rim = true radius; arm grows, persistent phase drains). Cone cues latch to contact (FireStartRaw, C14); TuningConfig.Defaults() fallback at client cue sites (release-build timing fix). G4: SaturationMath ally-FX degrade (living-enemy census, solo-exempt; enemy telegraphs structurally exempt) + CombatStressDebug + overlay saturation rows. Reviews wf_98bf1268 (13 confirmed folded) / wf_9757d214 (5 confirmed fixed). Co-Authored-By: Claude Fable 5 --- .../Scripts/Authoring/Combat/ZoneAuthoring.cs | 4 +- .../Authoring/Player/PlayerAuthoring.cs | 1 + .../Scripts/Client/Debug/CombatStressDebug.cs | 22 ++ .../Client/Debug/CombatStressDebug.cs.meta | 2 + .../Scripts/Client/Debug/DebugOverlay.cs | 5 + .../Presentation/CombatFeedbackSystem.cs | 166 ++++++++++---- .../Scripts/Client/Presentation/FeedbackFx.cs | 27 +++ .../Scripts/Client/Presentation/FeelConfig.cs | 12 + .../Client/Presentation/SaturationMath.cs | 24 ++ .../Presentation/SaturationMath.cs.meta | 2 + .../Presentation/ZoneTelegraphSystem.cs | 207 ++++++++++++++++++ .../Presentation/ZoneTelegraphSystem.cs.meta | 2 + .../Scripts/Editor/TuningAuditTools.cs | 10 +- .../Server/Combat/ClassSelectReceiveSystem.cs | 2 + .../Scripts/Server/Combat/ZonePulseSystem.cs | 3 +- .../Server/Debug/DebugCommandReceiveSystem.cs | 2 + .../Simulation/Combat/ConeContactPending.cs | 22 ++ .../Combat/ConeContactPending.cs.meta | 2 + .../Combat/SparkEffectComponents.cs | 44 ++-- .../Simulation/Combat/TickWindowMath.cs | 12 +- .../Scripts/Simulation/Debug/TuningConfig.cs | 14 +- .../Player/PlayerDeathStateSystem.cs | 8 + .../Tests/EditMode/SaturationMathTests.cs | 45 ++++ .../EditMode/SaturationMathTests.cs.meta | 2 + .../Tests/EditMode/TickWindowMathTests.cs | 22 ++ 25 files changed, 598 insertions(+), 64 deletions(-) create mode 100644 Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs create mode 100644 Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta create mode 100644 Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs create mode 100644 Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta create mode 100644 Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs create mode 100644 Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/SaturationMathTests.cs create mode 100644 Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs index b66365725..fcc4645ee 100644 --- a/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneAuthoring.cs @@ -10,8 +10,8 @@ namespace ProjectM.Authoring /// AbilityFireSystem — only the Vortex FLAG is authored here (so AbilityFireSystem can pick /// ZoneEffect vs DecoyTag by prefab membership, and the pull behaviour is baked-in). The prefab's ghost setup /// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing ownerless - /// interpolated ghost, so it replicates to all clients via the stock LocalTransform variant (no hand-written - /// [GhostField]). GetEntity(Dynamic) gives a runtime-mutable LocalTransform for the spawn override. + /// interpolated ghost, so position replicates via the stock LocalTransform variant. 07-21 G6: ZoneEffect's + /// CasterNetworkId / Radius / NextTick are hand-written [GhostField]s (the client fill telegraph). GetEntity(Dynamic) gives a runtime-mutable LocalTransform for the spawn override. /// public class ZoneAuthoring : MonoBehaviour { diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index dc7d16876..e5dbe302e 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -81,6 +81,7 @@ namespace ProjectM.Authoring // MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero. AddComponent(entity); AddComponent(entity); // 07-20 G2.1: server-only scheduled cleave (baked zeroed, not replicated) + AddComponent(entity); // 07-21 G6: server-only scheduled Cone-socket slam (baked zeroed, not replicated) // Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive; // plus the server-only respawn timer. diff --git a/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs new file mode 100644 index 000000000..cffda00e3 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs @@ -0,0 +1,22 @@ +#if UNITY_EDITOR +using UnityEngine; + +namespace ProjectM.Client +{ + /// 07-21 G4 (review wf_98bf1268) — editor-only saturation-stress toggle, flipped by the DebugOverlay + /// row. While ON, CombatFeedbackSystem synthesizes the ally attack package at 3 orbiting fake-caster positions + /// (the 4-player worst case without 4 connections). Lives in the Debug family, NOT FeelConfig — + /// FeelProfileService.SaveCurrent snapshots every FeelConfig field and a captured profile would re-arm stress + /// mode on Apply (review finding). Resets on play-enter per the static-presentation-bridge ★ rule (statics + /// survive fast-enter-playmode reloads; a leaked-on flag would spew phantom ally FX with no overlay in + /// Game.unity to clear it). + public static class CombatStressDebug + { + /// While true, CombatFeedbackSystem emits the fake-caster ally-FX package every ~0.5 s. + public static bool StressAllyFx; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetOnEnterPlayMode() => StressAllyFx = false; + } +} +#endif diff --git a/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta new file mode 100644 index 000000000..a21ed50a6 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/CombatStressDebug.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0854c467017c474b8eac1fe74950370 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index 305d0ec13..0e1313f22 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -40,6 +40,11 @@ namespace ProjectM.Client if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves(); if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies(); + GUILayout.Space(6); + GUILayout.Label("- Saturation (G4) -"); + if (GUILayout.Button("Spawn 12 Drowners")) DebugCommandSendSystem.SpawnEnemy(0, 12); // gym roster kind 0; the standing worst-case load + CombatStressDebug.StressAllyFx = GUILayout.Toggle(CombatStressDebug.StressAllyFx, "Stress ally FX (3 fake casters)"); + GUILayout.Space(6); GUILayout.Label("- Resources -"); _grantAmount = IntField("Amount", _grantAmount); diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 06232458c..e25e4c153 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -67,6 +67,14 @@ namespace ProjectM.Client Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height) 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) + float _pendingConeRange; // cone reach/half latched at the fire edge (folded socket stats) + float _pendingConeHalf; + float _allyFxScale = 1f; // 07-21 G4: saturation-derived ally-FX degradation (1 = full loudness) +#if UNITY_EDITOR + 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 @@ -106,10 +114,13 @@ namespace ProjectM.Client const int NumberPoolSize = 32; 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; } protected override void OnCreate() { + // 07-21 G4 (review wf_9757d214): remote-player census — solo (0 remotes) forces _allyFxScale = 1. + _remotePlayersQuery = SystemAPI.QueryBuilder().WithAll().WithDisabled().Build(); _hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true); _deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false); _fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false); @@ -232,7 +243,7 @@ namespace ProjectM.Client SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam); Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); - PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); + 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) @@ -282,7 +293,15 @@ namespace ProjectM.Client { Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount); PlayClip(_deathClip, (Vector3)p, 0.7f); - PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake); + PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation + if (isLocalPlayer) + { + // Post-impl review wf_9757d214: the server dropped any scheduled damage on death + // (PlayerDeathStateSystem zeroes both pendings) — drop the latched connect CUES too, + // or the corpse plays a full connect package for a hit that never landed. + _pendingConnectTick = 0u; + _pendingConeConnectTick = 0u; + } } } else if (isEnemy && _scanPrimed && cur >= health.ValueRO.Max - 0.001f) @@ -334,6 +353,34 @@ namespace ProjectM.Client } } + // 07-21 G4 (SoD's co-op saturation failure is our ceiling): ally-attributed FX degrade as the live + // enemy count rises — remote arcs dim, remote shakes shrink. LOCAL-player FX are untouched and enemy + // telegraphs NEVER degrade (EnemyDangerTelegraphSystem is structurally separate; guidelines G4). + int liveEnemies = 0; + foreach (var kv in _cache) if (kv.Value.IsEnemy && kv.Value.Hp > 0f) liveEnemies++; // LIVING only (review wf_9757d214: Dying corpses linger ~1s in the cache) + _allyFxScale = _remotePlayersQuery.CalculateEntityCount() == 0 + ? 1f // solo: no ally FX exist to budget — never degrade the local player's own feedback (review wf_9757d214) + : SaturationMath.AllyScale(liveEnemies, + FeelConfig.AllyFxDegradeStart, FeelConfig.AllyFxDegradeFull, FeelConfig.AllyFxFloor); +#if UNITY_EDITOR + // 07-21 G4: fake-caster saturation stress (DebugOverlay toggle) — synthesizes the ally attack package + // at 3 orbiting positions so the 4-caster worst case is testable without 4 connections. Editor-only; + // the flag resets on play-enter (CombatStressDebug, the static-presentation-bridge rule). + if (CombatStressDebug.StressAllyFx && _localPlayer != Entity.Null && UnityEngine.Time.time >= _nextStressTime) + { + _nextStressTime = UnityEngine.Time.time + 0.5f; + _stressBeat++; + for (int fake = 0; fake < 3; fake++) + { + float ang = _stressBeat * 0.7f + fake * 2.094f; + Vector3 fpos = (Vector3)localPos + new Vector3(Mathf.Cos(ang), 0f, Mathf.Sin(ang)) * 3.5f; + EmitTinted(_swingFx, fpos + Vector3.up * 0.9f, + (int)Mathf.Ceil(8f * _allyFxScale), FeelConfig.RemoteSlashColor * _allyFxScale); + PlayClip(_swingClip, fpos, 0.3f * _allyFxScale); + } + } +#endif + // LANTERN 4-socket fire feedback: edge-detect each socket's SocketCooldown (raw uint edge, cosmetic // only like dash/melee). A non-Cone Spark -> muzzle flash + zap; a Cone Spark -> the aimed slash-arc // cue (server-only cleave has no projectile). Replaces the single-AbilityCooldown muzzle + cone blocks. @@ -379,28 +426,39 @@ namespace ProjectM.Client var es = effs[sk]; float coneRange = Mathf.Max(0.1f, es.Range); float coneHalf = Mathf.Clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f); - bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue; - float coneCos = Mathf.Cos(coneHalf); - foreach (var kv in _cache) - { - if (!kv.Value.IsEnemy) continue; - if (MeleeConeMath.InCone(localPos, sfdir, coneRange, coneCos, kv.Value.Pos)) - { - float cd2 = math.distancesq(localPos, kv.Value.Pos); - if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; } - } - } - TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, coneConnected); + // 07-21 G6 (review wf_98bf1268): the slam's damage now lands at fire+ConeContactTicks — the arc + // reveal completes AT that contact (C13) and the connect package is LATCHED to it via the C14 + // idiom (knob 0 = legacy immediate). Defaults() fallback matches the release server's timing. + var coneTcfg = SystemAPI.TryGetSingleton(out var coneTcv) ? coneTcv : TuningConfig.Defaults(); + uint coneContactTicks = (uint)math.max(0f, coneTcfg.ConeContactTicks); + float coneLife = Mathf.Max(0.34f, (coneContactTicks / 60f) / 0.6f); + TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, false, coneLife); PlayClip(_swingClip, (Vector3)localPos, 0.5f); PrototypeCameraRig.AddShake(0.06f); - if (coneConnected) + _pendingConeRange = coneRange; + _pendingConeHalf = coneHalf; + if (coneContactTicks == 0u) { - Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); - PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume); - PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs); + _pendingConeConnectTick = 0u; + EvaluateConeConnect(localPos); // knob 0 = legacy same-tick connect + } + else + { + _pendingConeConnectTick = TickUtil.NonZero( + TickWindowMath.FireStartRaw(nf, es.CooldownTicks) + coneContactTicks); } } _socketFireInit = true; + + // 07-21 G6: fire the deferred cone CONNECT when the slam lands (contact tick reached; wrap-safe; + // the C14 idiom — latched once at the fire edge, never reconstructed per-frame). + if (_pendingConeConnectTick != 0u + && SystemAPI.TryGetSingleton(out var coneNt) && coneNt.ServerTick.IsValid + && !new NetworkTick(_pendingConeConnectTick).IsNewerThan(coneNt.ServerTick)) + { + EvaluateConeConnect(localPos); + _pendingConeConnectTick = 0u; + } } // Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash @@ -444,7 +502,8 @@ namespace ProjectM.Client EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5); PlayClip(_swingClip, (Vector3)localPos, 0.45f); PrototypeCameraRig.AddShake(0.04f * step); - int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3; + var tcfg = SystemAPI.TryGetSingleton(out var tcv2) ? tcv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback = Defaults(), matching the server sim + int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3); bool finisher = step >= comboLen; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.2f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; @@ -480,7 +539,8 @@ namespace ProjectM.Client && SystemAPI.TryGetSingleton(out var meleeNt) && meleeNt.ServerTick.IsValid && !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick)) { - int cLen = SystemAPI.TryGetSingleton(out var ct2) ? (int)math.clamp((int)ct2.MeleeComboLength, 1, 3) : 3; + var ct2 = SystemAPI.TryGetSingleton(out var ctv2) ? ctv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback + int cLen = (int)math.clamp((int)ct2.MeleeComboLength, 1, 3); EvaluateMeleeConnect(localPos, _pendingConnectStep, cLen); _pendingConnectTick = 0u; } @@ -888,12 +948,11 @@ namespace ProjectM.Client var cfg = VFXConfig.Instance; bool finisher = step >= comboLen; float range = 2.2f, half = 0.9f, finRange = 1.25f; - if (SystemAPI.TryGetSingleton(out var tcfg)) - { - if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange; - if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad; - if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult; - } + // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing. + var tcfg = SystemAPI.TryGetSingleton(out var mcv) ? mcv : TuningConfig.Defaults(); + if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange; + if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad; + if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult; if (EntityManager.HasBuffer(_localPlayer)) range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange, EntityManager.GetBuffer(_localPlayer, true))); @@ -903,17 +962,7 @@ namespace ProjectM.Client fdir = FacingMath.ResolveAim( EntityManager.GetComponentData(_localPlayer).Aim, EntityManager.GetComponentData(_localPlayer).Direction); - bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue; - float cosHalf = Mathf.Cos(half); - foreach (var kv in _cache) - { - if (!kv.Value.IsEnemy) continue; - if (MeleeConeMath.InCone(localPos, fdir, range, cosHalf, kv.Value.Pos)) - { - float d2 = math.distancesq(localPos, kv.Value.Pos); - if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; } - } - } + bool connected = NearestEnemyInCone(localPos, fdir, range, Mathf.Cos(half), out var nearestHit); // review wf_98bf1268: shared scan if (connected) { Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); @@ -930,6 +979,42 @@ namespace ProjectM.Client } } + // 07-21 G6 (review wf_98bf1268): ONE nearest-living-enemy-in-cone scan over the FX cache — shared by the + // socket-fire cue, the melee connect package and the deferred cone connect (three copies would drift). + bool NearestEnemyInCone(float3 pos, float2 dir, float range, float cosHalf, out Vector3 hit) + { + hit = (Vector3)pos; float best = float.MaxValue; bool found = false; + foreach (var kv in _cache) + { + if (!kv.Value.IsEnemy) continue; + if (!MeleeConeMath.InCone(pos, dir, range, cosHalf, kv.Value.Pos)) continue; + float d2 = math.distancesq(pos, kv.Value.Pos); + if (d2 < best) { best = d2; hit = (Vector3)kv.Value.Pos; found = true; } + } + return found; + } + + // 07-21 G6: the cone socket's CONNECT package at the CONTACT tick — the EvaluateMeleeConnect mirror for + // the SpecialSlam (aim recomputed LIVE at contact; range/half latched at the fire edge from the socket's + // folded stats). Fired by the deferred check next to the socket-fire branch (C14 idiom). + void EvaluateConeConnect(float3 localPos) + { + if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return; + var cfg = VFXConfig.Instance; + float2 fdir = new float2(0f, 1f); + if (EntityManager.HasComponent(_localPlayer) && EntityManager.HasComponent(_localPlayer)) + fdir = FacingMath.ResolveAim( + EntityManager.GetComponentData(_localPlayer).Aim, + EntityManager.GetComponentData(_localPlayer).Direction); + if (!NearestEnemyInCone(localPos, fdir, _pendingConeRange, Mathf.Cos(_pendingConeHalf), out var hit)) return; + Burst(_hitFx, cfg != null ? cfg.Hit : null, hit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); + PlayClip(_meleeConnectClip, hit, FeelConfig.MeleeConnectVolume); + PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs); + if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) + RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); + 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) { @@ -997,7 +1082,10 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int void UpdateRemoteSwings(float dt) { if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return; - int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3; + // Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing + // (the dev TuningConfig singleton is editor-only; default(TuningConfig) reads knob 0 = legacy cues). + var tcfg = SystemAPI.TryGetSingleton(out var rtcv) ? rtcv : TuningConfig.Defaults(); + int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3); float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; float finisherMult = tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult @@ -1023,7 +1111,7 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int rs.Range = finisher ? rRange * finisherMult : rRange; rs.Half = baseHalf; rs.SweepSign = (step % 2 == 0) ? -1 : 1; - rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f); + rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f) * _allyFxScale; // 07-21 G4: ally FX dim under saturation (brightness only — Life stays contact-honest, C13) rs.Life = Mathf.Max(finisher ? 0.50f : 0.34f, (MeleeTiming.ContactTicks((byte)step, remoteContactKnob) / 60f) / 0.6f); // 07-20: reveal ends AT contact (knob-aware, review C13) rs.Age = 0f; diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs index 74c7373a0..64e95b771 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs @@ -204,5 +204,32 @@ namespace ProjectM.Client m.RecalculateBounds(); return m; } + + // Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE + // damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner + // edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it. + public static Mesh BuildRing(int segments, float innerFrac = 0.92f) + { + if (segments < 6) segments = 6; + innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f); + var m = new Mesh { name = "Ring" }; + var v = new Vector3[segments * 2]; + var tris = new int[segments * 6]; + for (int i = 0; i < segments; i++) + { + float a = i / (float)segments * Mathf.PI * 2f; + var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); + v[i * 2] = dir * innerFrac; + v[i * 2 + 1] = dir; + int n = (i + 1) % segments; + int b = i * 6; + tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1; + tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1; + } + m.vertices = v; + m.triangles = tris; + m.RecalculateBounds(); + return m; + } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs index 0872fead3..4fbab3edd 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -222,6 +222,14 @@ namespace ProjectM.Client /// Marker glyph font size (px). public static float EnemyMarkerSize; + // ---- 07-21 G4: co-op saturation budget (guidelines G4 — SoD's failure is our ceiling) ---- + /// Live enemy count at which ALLY-attributed FX (remote arcs, remote shakes) begin to degrade. Local-player FX and enemy telegraphs NEVER degrade. + public static int AllyFxDegradeStart; + /// Live enemy count at which ally FX sit at AllyFxFloor. + public static int AllyFxDegradeFull; + /// Ally-FX brightness/shake floor under full saturation (0..1). + public static float AllyFxFloor; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] public static void ResetDefaults() { @@ -328,6 +336,10 @@ namespace ProjectM.Client EnemyMarkerMinAlpha = 0.28f; EnemyMarkerColor = new Color(1f, 0.86f, 0.3f, 1f); // warm amber, reads over the cool world EnemyMarkerSize = 24f; + // 07-21 G4 co-op saturation budget (review wf_98bf1268) + AllyFxDegradeStart = 8; + AllyFxDegradeFull = 16; + AllyFxFloor = 0.35f; } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs new file mode 100644 index 000000000..27778e3e3 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs @@ -0,0 +1,24 @@ +using Unity.Mathematics; + +namespace ProjectM.Client +{ + /// 07-21 G4 (review wf_98bf1268) — pure co-op saturation-budget math (no ECS; unit-tested like + /// HudVisualMath, the Client-side presentation-math precedent). Maps a live on-screen enemy count to + /// the ALLY-FX degradation scale: 1 at <= start, linear down to at >= full. + /// Enemy telegraphs never ride this (guidelines G4: they never degrade); local-player FX never ride this. + /// Note: EnemyMarkerSystem's pip fade equals AllyScale(count, start, 2*start, floor) — a later + /// pass can retrofit it onto this helper. + public static class SaturationMath + { + /// 1 at enemies <= start; linear to at enemies >= full; clamped. + /// Degenerate inputs (full <= start) snap straight to floor once past start. + public static float AllyScale(int enemies, int start, int full, float floor) + { + floor = math.clamp(floor, 0f, 1f); + if (enemies <= start) return 1f; + if (full <= start) return floor; + float t = math.saturate((enemies - start) / (float)(full - start)); + return math.lerp(1f, floor, t); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta new file mode 100644 index 000000000..ed1074c2a --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/SaturationMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 93f34c59962a12243aec9840a608e2da \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs new file mode 100644 index 000000000..68d6c2eb9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; +using UnityEngine; +using static ProjectM.Client.FeedbackFx; + +namespace ProjectM.Client +{ + /// + /// 07-21 G6 (review wf_98bf1268) — client-only ZONE-socket fill telegraph (Vortex / LightZone), the + /// WildStar-grade honesty decal for player zones: a thin RIM always drawn at the replicated + /// (rim = the TRUE folded damage radius, guidelines G2) plus an inner FILL + /// disc whose arrival at the rim IS the damage moment — fill derives from the replicated absolute + /// over ; each server re-stamp + /// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only + /// in , templated on + /// (shared / + /// unit meshes + MPB alpha + pooled GOs + per-frame silent prune; the value-latch + was-counting-down + /// arm-guard pulse contract, review H1 — never edge-detect the re-stamp). + /// + /// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against + /// NetworkTime.InterpolationTick, NOT the predicted ServerTick — a geyser threatens the PREDICTED + /// local player, but a zone's observables (enemy HP drops, vortex pull) live on the INTERPOLATED timeline + /// the zone ghost itself renders on; the predicted tick would complete the fill ~RTT early and pin it at + /// full (invisible on loopback, 20-40% of the bar wrong at internet RTTs — review finding, confirmed). + /// Ownership tint per guidelines G3: local caster = warm (LightZone) / teal (Vortex); ally = dimmer + /// cool-blue of the same shapes; never red. Enemy telegraphs live elsewhere and NEVER degrade (G4). + /// + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class ZoneTelegraphSystem : SystemBase + { + static readonly Color LocalLightColor = new Color(2.2f, 1.6f, 0.7f); // warm lamp-amber — light is territory + static readonly Color LocalVortexColor = new Color(0.5f, 1.9f, 1.8f); // teal swirl + static readonly Color AllyColor = new Color(0.35f, 0.55f, 1.1f); // dim cool-blue (same shapes, G3) + static readonly int ColorId = Shader.PropertyToID("_Color"); + const float k_PulseFlashSeconds = 0.14f; + + Transform _fxRoot; + Material _mat; + Mesh _discMesh; + Mesh _ringMesh; + MaterialPropertyBlock _mpb; + + // Per-zone pooled pair: [0] = fill disc, [1] = rim ring (children of one root GO). + readonly Dictionary _zones = new(); + readonly Dictionary _armed = new(); // NextTick seen counting down (arm-guard) + readonly Dictionary _lastFired = new(); // NextTick we last pulsed for (value latch) + readonly Dictionary _flashUntil = new(); + readonly HashSet _seen = new(); + readonly List _stale = new(); + + protected override void OnCreate() + { + _mpb = new MaterialPropertyBlock(); + } + + protected override void OnStartRunning() + { + if (_fxRoot != null) return; + _fxRoot = new GameObject("~ZoneTelegraphFX").transform; + _mat = MakeParticleMaterial("ZoneTelegraph"); + _discMesh = BuildDisc(40); + _ringMesh = BuildRing(48); + } + + protected override void OnDestroy() + { + if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); + if (_mat != null) Object.Destroy(_mat); + if (_discMesh != null) Object.Destroy(_discMesh); + if (_ringMesh != null) Object.Destroy(_ringMesh); + } + + protected override void OnUpdate() + { + if (_fxRoot == null || _mat == null) return; + if (!SystemAPI.TryGetSingleton(out var nt)) return; + // The interpolated timeline — see the class doc for why NOT the predicted ServerTick. + var tick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick; + if (!tick.IsValid) return; + int localNetId = SystemAPI.TryGetSingleton(out var nid) ? nid.Value : -1; + + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + + _seen.Clear(); + foreach (var (zone, xf, e) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var ze = zone.ValueRO; + _seen.Add(e); + if (ze.Radius <= 0.01f) continue; // pre-first-snapshot (baked zero) — nothing honest to draw yet + + float3 pos = xf.ValueRO.Position; + bool mine = ze.CasterNetworkId == localNetId; + bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0; + Color baseCol = mine ? (vortex ? LocalVortexColor : LocalLightColor) : AllyColor; + + // Fill from the replicated ABSOLUTE next-pulse tick (0 = unscheduled -> rim only). + float fill = 0f; + uint next = ze.NextTick; + if (next != 0u && new NetworkTick(next).IsValid) + { + int lead = new NetworkTick(next).TicksSince(tick); // >0 counting down, <=0 arrived/past + if (lead > 0) + { + _armed[e] = next; // arm this pulse while it counts down + // Operator report 07-21 ("casts seem to auto-recast"): a fill RE-GROWING every pulse reads + // as a fresh cast. ARM phase (before the first pulse) grows 0->1 (fill reaches the rim = + // first damage); the PERSISTENT phase DRAINS 1->0 toward each pulse (a metronome, not a + // cast) - and the direction split is the G6/R6 one-shot-vs-persistent encoding, done right. + float frac = math.saturate(lead / (float)ZoneEffect.PulsePeriodTicks); + fill = _lastFired.ContainsKey(e) ? frac : 1f - frac; + } + else + { + fill = 1f; + bool armedForThis = _armed.TryGetValue(e, out var av) && av == next; + bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next; + if (armedForThis && !alreadyFired) + { + _flashUntil[e] = UnityEngine.Time.time + k_PulseFlashSeconds; // quiet tier: a rim flash, no burst (G4) + _lastFired[e] = next; + } + } + } + + if (!_zones.TryGetValue(e, out var go) || go == null) + { + go = new GameObject("ZoneTelegraph"); + go.transform.SetParent(_fxRoot, false); + MakeChild(go.transform, "Fill", _discMesh); + MakeChild(go.transform, "Rim", _ringMesh); + _zones[e] = go; + } + if (!go.activeSelf) go.SetActive(true); + go.transform.position = new Vector3(pos.x, 0.05f, pos.z); + + bool flashing = _flashUntil.TryGetValue(e, out var fu) && UnityEngine.Time.time < fu; + var fillTr = go.transform.GetChild(0); + var rimTr = go.transform.GetChild(1); + float fillRadius = ze.Radius * fill; + fillTr.localScale = new Vector3(fillRadius, 1f, fillRadius); + rimTr.localScale = new Vector3(ze.Radius, 1f, ze.Radius); + + float fillAlpha = (mine ? 0.16f : 0.10f) * (0.35f + 0.65f * fill); + float rimAlpha = (mine ? 0.55f : 0.35f) + (flashing ? 0.4f : 0f); + SetTint(fillTr, baseCol, fillAlpha); + SetTint(rimTr, baseCol, rimAlpha); + } + + // Prune despawned zones (expiry / teardown / relevancy drop) — destroy, drop tracking, emit nothing. + if (_zones.Count > 0) + { + _stale.Clear(); + foreach (var kv in _zones) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); + for (int i = 0; i < _stale.Count; i++) + { + if (_zones[_stale[i]] != null) Object.Destroy(_zones[_stale[i]]); + _zones.Remove(_stale[i]); + } + } + PruneMap(_armed); + PruneMap(_lastFired); + PruneFloatMap(_flashUntil); + } + + void MakeChild(Transform parent, string name, Mesh mesh) + { + var child = new GameObject(name); + child.transform.SetParent(parent, false); + child.AddComponent().sharedMesh = mesh; + var mr = child.AddComponent(); + mr.sharedMaterial = _mat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + } + + void SetTint(Transform tr, Color c, float alpha) + { + _mpb.SetColor(ColorId, new Color(c.r, c.g, c.b, alpha)); + tr.GetComponent().SetPropertyBlock(_mpb); + } + + void PruneMap(Dictionary dict) + { + if (dict.Count == 0) return; + _stale.Clear(); + foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); + for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]); + } + + void PruneFloatMap(Dictionary dict) + { + if (dict.Count == 0) return; + _stale.Clear(); + foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); + for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta new file mode 100644 index 000000000..3396b7b2e --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7546a7bb0f6fd634287c3d5b98faa28c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs index e66f694e7..f3f96ee80 100644 --- a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs +++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs @@ -93,8 +93,14 @@ namespace ProjectM.EditorTools } else sb.AppendLine(" Reach: weapon mesh not found (run Attach Melee Weapon first)"); - sb.AppendLine(" NOTE: SpecialSlam (cone socket) still damages AT FIRE, ~0.35s before its visual contact — the"); - sb.AppendLine(" same dishonesty melee just fixed; it rides the SOCKET pipeline (guidelines G6 follow-up)."); + // 07-21 G6 (review wf_98bf1268): the cone socket (SpecialSlam) lands at ConeContactTicks via + // ConeContactPending (0 = legacy at-fire). Visual contact estimate ~0.35s (21t) into the slam anim. + uint coneContact = (uint)Mathf.Max(0f, t.ConeContactTicks); + sb.AppendLine(coneContact == 0 + ? " Cone (SpecialSlam): knob 32 = 0 -> LEGACY at-fire damage (~0.35s before the visual contact)" + : $" Cone (SpecialSlam): contact {coneContact}t ({coneContact / 60f:F2}s after fire; visual estimate ~21t)"); + if (coneContact > PlayerAimSystem.CastFacingTicks) + sb.AppendLine($" !! cone contact {coneContact}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (a resting gamepad stick resolves a MOVE-facing slam)"); Debug.Log(sb.ToString()); return sb.ToString(); } diff --git a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs index dfc27be4a..4d979f870 100644 --- a/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs @@ -71,6 +71,8 @@ namespace ProjectM.Server sockets.Add(new AbilitySocket { SparkId = f3 }); if (SystemAPI.HasComponent(player)) SystemAPI.SetComponent(player, default(SocketCooldown)); // 0 = ready: the swapped kit fires now + if (SystemAPI.HasComponent(player)) + SystemAPI.SetComponent(player, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam (parity with the dev SetClass op) if (haveDb && SystemAPI.HasComponent(player) && SystemAPI.HasComponent(player)) { byte charId = SystemAPI.GetComponent(player).Id; diff --git a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs index 5a414f966..4497d6b6e 100644 --- a/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ZonePulseSystem.cs @@ -26,7 +26,6 @@ namespace ProjectM.Server [UpdateBefore(typeof(EnemyAISystem))] public partial struct ZonePulseSystem : ISystem { - const uint k_PulsePeriodTicks = 30u; // damage cadence (~0.5s at 60 ticks/sec) const float k_VortexPullSpeed = 4f; // gentle inward drift for a Vortex (world units/sec) const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre @@ -49,7 +48,7 @@ namespace ProjectM.Server if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; uint stamp = TickUtil.NonZero(now); - uint reschedule = TickUtil.NonZero(now + k_PulsePeriodTicks); + uint reschedule = TickUtil.NonZero(now + ZoneEffect.PulsePeriodTicks); m_KnockbackLookup.Update(ref state); m_BossLookup.Update(ref state); diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs index d73c71a4b..af219d96b 100644 --- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs @@ -173,6 +173,8 @@ namespace ProjectM.Server swSockets.Add(new AbilitySocket { SparkId = sf3 }); if (SystemAPI.HasComponent(sender)) SystemAPI.SetComponent(sender, default(SocketCooldown)); + if (SystemAPI.HasComponent(sender)) + SystemAPI.SetComponent(sender, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam if (SystemAPI.HasComponent(sender) && SystemAPI.HasComponent(sender) && SystemAPI.TryGetSingleton(out var abilityDb2)) { diff --git a/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs new file mode 100644 index 000000000..ba0d9e1a3 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs @@ -0,0 +1,22 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// 07-21 G6 (socket honesty, design review wf_98bf1268): SERVER-ONLY scheduled Cone-socket cleave — + /// the schedule-and-consume idiom applied to the SpecialSlam (Cone archetype) + /// socket, so its damage lands at the animation's visual contact instead of the fire tick. Stamped by + /// AbilityFireSystem when a Cone socket fires (ResolveTick = fire + TuningConfig.ConeContactTicks; knob 0 = + /// legacy immediate, no stamp), fired on a wrap-safe elapsed compare from LIVE state (cast-turn steers the + /// cone until contact; stats re-folded at resolve), CONSUMED by zeroing. A still-armed pending is FLUSHED + /// (fired early) before a recast overwrites it. Cleared on death (PlayerDeathStateSystem — an armed pending + /// must never fire from the respawn position) and by the dev SetClass swap (socket identity changes). + /// At resolve the socket is re-validated (bounds + still a Cone Spark) — consume-drop on mismatch. + /// Not replicated; client contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed. + public struct ConeContactPending : IComponentData + { + /// NonZero tick the armed cone's slam lands; 0 = nothing pending. + public uint ResolveTick; + /// Socket index (0..SocketId.Count-1) of the ARMED cast — stats/aim re-read live at resolve. + public byte Socket; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta new file mode 100644 index 000000000..002704aa4 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ConeContactPending.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e1f7f8852ab4e548babedafccbc6084 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs index a3d6d7375..bb5c26c9a 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/SparkEffectComponents.cs @@ -1,32 +1,42 @@ using Unity.Entities; +using Unity.NetCode; namespace ProjectM.Simulation { /// - /// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone - /// archetype. NOT a [GhostField]: the zone is a server-spawned INTERPOLATED ownerless ghost whose - /// position replicates via the stock LocalTransform variant; only its damage schedule lives here, server-side. - /// ZonePulseSystem borrows the GeyserEruptSystem skeleton (the INVERTED invalid-tick guard so a - /// born-0 tick never storm-fires + the = now + period reschedule) but is ENEMY-ONLY (no friendly fire), - /// stamps = (kills credit the caster, - /// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym). - /// DestroyEntity when elapses. + /// Periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone archetype. + /// The zone is a server-spawned INTERPOLATED ownerless ghost; position replicates via the stock LocalTransform + /// variant. 07-21 G6 (design review wf_98bf1268): THREE fields are now [GhostField] so the client can + /// draw an HONEST fill telegraph (ZoneTelegraphSystem): (G3 ownership tint), + /// (rim = the TRUE folded damage radius) and (fill arrival = the + /// damage moment — server mutations propagate to all clients on an ownerless ghost). Damage schedule/amount + /// stay server-only. ZonePulseSystem borrows the GeyserEruptSystem skeleton (the INVERTED + /// invalid-tick guard so a born-0 tick never storm-fires + the = now + period reschedule) but is + /// ENEMY-ONLY (no friendly fire), stamps = + /// (kills credit the caster, feeding KillRewardSystem — NOT the -1 environment convention), and has NO region + /// gate (a no-world gym). DestroyEntity when elapses. /// public struct ZoneEffect : IComponentData { - /// NetworkId of the casting player (damage/kill attribution). - public int CasterNetworkId; + /// Pulse cadence in ticks — single source for ZonePulseSystem (damage) and ZoneTelegraphSystem + /// (the client fill window). Hoisted 07-21 G6 (was ZonePulseSystem.k_PulsePeriodTicks). + public const uint PulsePeriodTicks = 30; - /// Planar (XZ) damage radius, world units. - public float Radius; + /// NetworkId of the casting player (damage/kill attribution + the client's mine-vs-ally tint). + [GhostField] public int CasterNetworkId; - /// Damage dealt to each living enemy in radius per pulse. + /// Planar (XZ) damage radius, world units. Replicated so the drawn rim is the TRUE folded radius. + [GhostField(Quantization = 100)] public float Radius; + + /// Raw next-pulse tick (NonZero). 0 = unscheduled → lazy-stamped born-correct (GeyserErupt H2 + /// rule, never storm-fires). Replicated for the client fill: ride the ABSOLUTE tick + a value-latch + a + /// was-counting-down arm-guard (the Geyser ★ rule — never edge-detect the re-stamp). + [GhostField] public uint NextTick; + + /// Damage dealt to each living enemy in radius per pulse. Server-only. public float DamagePerPulse; - /// Raw next-pulse tick (NonZero). 0 = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires). - public uint NextTick; - - /// Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick). + /// Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick). Server-only. public uint ExpireTick; /// See . bit0 = Vortex (also pull enemies toward the zone center each pulse). diff --git a/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs b/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs index 85bf9fe18..c8973b367 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/TickWindowMath.cs @@ -35,12 +35,22 @@ namespace ProjectM.Simulation public static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks) { if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false; - uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks); + uint startRaw = FireStartRaw(nextFireRaw, cooldownTicks); var start = new NetworkTick(startRaw); var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks)); return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); } + /// Reconstructed fire-window START (raw NonZero tick) from the replicated per-socket cooldown + /// stamp minus the locally-derived CooldownTicks; 0 = no window (unstamped / degenerate inputs). The ONE + /// home of this reconstruction (07-21 G6 review wf_98bf1268: the cone connect-cue latches + /// contact = FireStartRaw + ConeContactTicks at the fire edge — never re-derive it inline). + public static uint FireStartRaw(uint nextFireRaw, int cooldownTicks) + { + if (nextFireRaw == 0u || cooldownTicks <= 0) return 0u; + return TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks); + } + /// LANTERN 4-socket fire/cone resolution (any-socket model): firing if ANY socketed, /// NON-Movement Spark's per-socket window is mid-fire; cone if any such active socket holds a /// Cone-archetype Spark. Movement sockets are skipped OUTRIGHT (blink = dodge, not cast). Without a diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index b930a61a5..093ed45f0 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -53,6 +53,10 @@ namespace ProjectM.Simulation public float MeleeBufferTicks; /// Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave). public float MeleeContactTicks; + /// 07-21 G6 (socket honesty): ticks from the Cone-socket fire tick to its damage landing (the + /// SpecialSlam visual contact). 0 = IMMEDIATE (legacy at-fire cleave). Keep < CastFacingTicks (26) or a + /// resting gamepad stick resolves a movement-facing cone; default 21 sits 1 tick under WarriorCone cooldown 22. + public float ConeContactTicks; // EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are // preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem. @@ -96,6 +100,7 @@ namespace ProjectM.Simulation MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult) MeleeBufferTicks = 10f, // 07-21 HEAVY LOCK: wider buffer fits the slower cadence (0 = off) MeleeContactTicks = 20f, // 07-21 HEAVY LOCK: blade lands 20/12/25t per step -- clip speeds retuned to MATCH (wire tool 1.0/0.85/0.88; audit must show zero drift). Step-3 (25t) sits 1 tick under CastFacingTicks 26 -- do NOT raise without raising the cast window. + ConeContactTicks = 21f, // 07-21 G6: SpecialSlam contact ~0.35s after fire; keep < CastFacingTicks 26 StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling @@ -130,6 +135,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeFinisherRangeMult: case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10) case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave) + case TuningKnob.ConeContactTicks: // 07-21 G6: 0 = IMMEDIATE sentinel (legacy at-fire cone) return math.max(0f, value); // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem). // FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one. @@ -172,6 +178,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break; case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break; case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = value; break; + case TuningKnob.ConeContactTicks: c.ConeContactTicks = value; break; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) } @@ -210,6 +217,7 @@ namespace ProjectM.Simulation case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks; case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult; case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks; + case TuningKnob.ConeContactTicks: return c.ConeContactTicks; default: return 0f; } @@ -246,6 +254,7 @@ namespace ProjectM.Simulation MeleeBufferTicks = c.MeleeBufferTicks, MeleeFinisherRangeMult = c.MeleeFinisherRangeMult, MeleeContactTicks = c.MeleeContactTicks, + ConeContactTicks = c.ConeContactTicks, }; @@ -280,6 +289,7 @@ namespace ProjectM.Simulation MeleeBufferTicks = r.MeleeBufferTicks, MeleeFinisherRangeMult = r.MeleeFinisherRangeMult, MeleeContactTicks = r.MeleeContactTicks, + ConeContactTicks = r.ConeContactTicks, }; } @@ -318,9 +328,10 @@ namespace ProjectM.Simulation public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel) public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2 public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel) + public const byte ConeContactTicks = 32; // 07-21 G6 (0 = immediate sentinel) /// Knob count (overlay iteration bound). - public const byte Count = 32; + public const byte Count = 33; } /// @@ -359,6 +370,7 @@ namespace ProjectM.Simulation public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers) public float MeleeFinisherRangeMult; public float MeleeContactTicks; + public float ConeContactTicks; // 07-21 G6 socket honesty (dev-protocol bump: rebuild both peers) } // NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together. diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs index 4aea70d22..aac2a8942 100644 --- a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs @@ -45,6 +45,14 @@ namespace ProjectM.Simulation // MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn. if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(MeleeCombo)); + // 07-21 G6 (review wf_98bf1268): zero any ARMED scheduled cleave — a pending surviving death + // fires from the RESPAWN position once Dead re-disables (the elapsed compare passes instantly). + // Fixes the shipped melee variant + the new cone in one stroke. Idempotent default-writes; + // client copies are always zero, so the both-world predicted write is rollback-safe. + if (SystemAPI.HasComponent(entity)) + SystemAPI.SetComponent(entity, default(MeleeCleavePending)); + if (SystemAPI.HasComponent(entity)) + SystemAPI.SetComponent(entity, default(ConeContactPending)); if (SystemAPI.HasComponent(entity)) { var cc = SystemAPI.GetComponent(entity); diff --git a/Assets/_Project/Tests/EditMode/SaturationMathTests.cs b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs new file mode 100644 index 000000000..d21175ff3 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs @@ -0,0 +1,45 @@ +using NUnit.Framework; +using ProjectM.Client; + +namespace ProjectM.Tests +{ + /// 07-21 G4 — pins the ally-FX saturation ramp (SaturationMath.AllyScale): full loudness at or + /// under the start count, linear to the floor at the full count, clamped floor, degenerate-range snap. + /// Enemy telegraphs never ride this scale (structural, no knob) — that is pinned by ARCHITECTURE, not here. + public class SaturationMathTests + { + [Test] + public void At_Or_Below_Start_Is_Full() + { + Assert.AreEqual(1f, SaturationMath.AllyScale(0, 8, 16, 0.35f), 1e-4f); + Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 16, 0.35f), 1e-4f); + } + + [Test] + public void Midpoint_Is_Half_Lerped() + { + Assert.AreEqual(0.675f, SaturationMath.AllyScale(12, 8, 16, 0.35f), 1e-4f); // lerp(1, .35, .5) + } + + [Test] + public void At_Or_Past_Full_Sits_At_Floor() + { + Assert.AreEqual(0.35f, SaturationMath.AllyScale(16, 8, 16, 0.35f), 1e-4f); + Assert.AreEqual(0.35f, SaturationMath.AllyScale(40, 8, 16, 0.35f), 1e-4f); + } + + [Test] + public void Degenerate_Range_Snaps_To_Floor_Past_Start() + { + Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 8, 0.5f), 1e-4f); + Assert.AreEqual(0.5f, SaturationMath.AllyScale(9, 8, 8, 0.5f), 1e-4f); + } + + [Test] + public void Floor_Is_Clamped_01() + { + Assert.AreEqual(1f, SaturationMath.AllyScale(100, 8, 16, 1.7f), 1e-4f); + Assert.AreEqual(0f, SaturationMath.AllyScale(100, 8, 16, -2f), 1e-4f); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta new file mode 100644 index 000000000..72237aa9a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SaturationMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 876bdcb19d45464418a4a634ebc134c8 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs b/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs index 2becbe597..e83efe31e 100644 --- a/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs +++ b/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs @@ -152,5 +152,27 @@ namespace ProjectM.Tests Assert.IsTrue(firing); Assert.IsTrue(cone); } + + // ---- 07-21 G6 (review wf_98bf1268): FireStartRaw — the ONE home of the window-start reconstruction ---- + + [Test] + public void FireStartRaw_Reconstructs_The_Fire_Tick() + { + Assert.AreEqual(978u, TickWindowMath.FireStartRaw(1000u, 22)); + } + + [Test] + public void FireStartRaw_Degenerate_Inputs_Return_Zero() + { + Assert.AreEqual(0u, TickWindowMath.FireStartRaw(0u, 22), "unstamped cooldown row"); + Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, 0), "no cooldown length"); + Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, -5), "negative cooldown length"); + } + + [Test] + public void FireStartRaw_Wrap_To_Zero_Coerces_Through_NonZero() + { + Assert.AreEqual(TickUtil.NonZero(0u), TickWindowMath.FireStartRaw(10u, 10), "the 0-sentinel is never returned for a REAL window start"); + } } } \ No newline at end of file