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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,14 @@ namespace ProjectM.Client
|
||||
/// <summary>Marker glyph font size (px).</summary>
|
||||
public static float EnemyMarkerSize;
|
||||
|
||||
// ---- 07-21 G4: co-op saturation budget (guidelines G4 — SoD's failure is our ceiling) ----
|
||||
/// <summary>Live enemy count at which ALLY-attributed FX (remote arcs, remote shakes) begin to degrade. Local-player FX and enemy telegraphs NEVER degrade.</summary>
|
||||
public static int AllyFxDegradeStart;
|
||||
/// <summary>Live enemy count at which ally FX sit at AllyFxFloor.</summary>
|
||||
public static int AllyFxDegradeFull;
|
||||
/// <summary>Ally-FX brightness/shake floor under full saturation (0..1).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>07-21 G4 (review wf_98bf1268) — pure co-op saturation-budget math (no ECS; unit-tested like
|
||||
/// <c>HudVisualMath</c>, 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 <paramref name="floor"/> at >= full.
|
||||
/// Enemy telegraphs never ride this (guidelines G4: they never degrade); local-player FX never ride this.
|
||||
/// Note: <c>EnemyMarkerSystem</c>'s pip fade equals <c>AllyScale(count, start, 2*start, floor)</c> — a later
|
||||
/// pass can retrofit it onto this helper.</summary>
|
||||
public static class SaturationMath
|
||||
{
|
||||
/// <summary>1 at enemies <= start; linear to <paramref name="floor"/> at enemies >= full; clamped.
|
||||
/// Degenerate inputs (full <= start) snap straight to floor once past start.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93f34c59962a12243aec9840a608e2da
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ZoneEffect.Radius"/> (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
|
||||
/// <see cref="ZoneEffect.NextTick"/> over <see cref="ZoneEffect.PulsePeriodTicks"/>; each server re-stamp
|
||||
/// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only
|
||||
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>, templated on
|
||||
/// <see cref="GeyserTelegraphSystem"/> (shared <see cref="FeedbackFx.BuildDisc"/>/<see cref="FeedbackFx.BuildRing"/>
|
||||
/// 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).
|
||||
/// <para>
|
||||
/// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against
|
||||
/// <c>NetworkTime.InterpolationTick</c>, 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<Entity, GameObject> _zones = new();
|
||||
readonly Dictionary<Entity, uint> _armed = new(); // NextTick seen counting down (arm-guard)
|
||||
readonly Dictionary<Entity, uint> _lastFired = new(); // NextTick we last pulsed for (value latch)
|
||||
readonly Dictionary<Entity, float> _flashUntil = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _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<NetworkTime>(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<NetworkId>(out var nid) ? nid.Value : -1;
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<ZoneEffect>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
_seen.Clear();
|
||||
foreach (var (zone, xf, e) in
|
||||
SystemAPI.Query<RefRO<ZoneEffect>, RefRO<LocalTransform>>().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<MeshFilter>().sharedMesh = mesh;
|
||||
var mr = child.AddComponent<MeshRenderer>();
|
||||
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<MeshRenderer>().SetPropertyBlock(_mpb);
|
||||
}
|
||||
|
||||
void PruneMap(Dictionary<Entity, uint> 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<Entity, float> 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7546a7bb0f6fd634287c3d5b98faa28c
|
||||
Reference in New Issue
Block a user