G6+G4: Cone damage-at-contact + zone fill telegraph (ZoneEffect GhostFields) + co-op saturation budget

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:27:50 -07:00
parent 9d6977cd64
commit 2aebc37115
25 changed files with 598 additions and 64 deletions
@@ -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<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().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<TuningConfig>(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<NetworkTime>(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<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(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<NetworkTime>(out var meleeNt) && meleeNt.ServerTick.IsValid
&& !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick))
{
int cLen = SystemAPI.TryGetSingleton<TuningConfig>(out var ct2) ? (int)math.clamp((int)ct2.MeleeComboLength, 1, 3) : 3;
var ct2 = SystemAPI.TryGetSingleton<TuningConfig>(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<TuningConfig>(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<TuningConfig>(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<StatModifier>(_localPlayer))
range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
@@ -903,17 +962,7 @@ namespace ProjectM.Client
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
fdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_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<TuningConfig>(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<TuningConfig>(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;