Deferred feel items: true enemy body hit-flash, co-op remote swing arcs, near-impact strike beep
Clears the three follow-ups deferred by the combat-overhaul pass (c3b53cef2) + the
DR-041 "needs its own ShaderGraph slice" note. All client-only, observe-only presentation
(PresentationSystemGroup; no sim mutation, no [GhostField], no server work).
- Item 1: EnemyHitFlashSystem flashes the ACTUAL enemy body by driving the stock
Entities-Graphics URPMaterialPropertyBaseColor override on the Rukhanka render-entity
LEG children (root has no MaterialMeshInfo) -- NO ShaderGraph edit, no new component type.
Lerp white->BodyFlashColor on a Health-decrease edge, decay back to white. Verified on
screen (the AnimatedLitShader honors the per-instance _BaseColor override).
- Item 2: per-remote-player slash-arc pool in CombatFeedbackSystem, edge-detected from the
replicated MeleeCombo on interpolated teammates (.WithDisabled<GhostOwnerIsLocal>());
BuildSlashMesh -> BuildSlashInto(mesh,...) refactor; local player keeps _slashMr.
- Item 3: once-per-windup near-impact strike beep folded into the danger-cone loop, gated
to a resolved local player.
- 9 new FeelConfig knobs (+ ResetDefaults).
390/390 EditMode, clean compile, zero Play exceptions. 3-lens adversarial review
(wf_8a998c6c-af9) -- no critical/major; fixed 4 minors: spurious beep at base origin before
the local player resolves, frozen tint if BodyFlashEnabled toggles off mid-flash, render-child
capture with no recovery, OnDestroy GO symmetry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,21 @@ namespace ProjectM.Client
|
||||
Material _barBgMat, _barFillMat;
|
||||
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
||||
readonly Dictionary<Entity, float> _pulseStart = new();
|
||||
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
|
||||
readonly Dictionary<Entity, uint> _strikeBeeped = new();
|
||||
|
||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
||||
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
||||
class RemoteSlash
|
||||
{
|
||||
public GameObject Go; public Mesh Mesh; public MeshRenderer Mr; public Material Mat;
|
||||
public float Age, Life, Range, Half; public int SweepSign; public Color Tint;
|
||||
public bool Active; public uint LastSwingTick; public bool Init;
|
||||
}
|
||||
readonly Dictionary<Entity, RemoteSlash> _remoteSlashes = new();
|
||||
readonly HashSet<Entity> _remoteSeen = new();
|
||||
readonly List<Entity> _remoteStale = new();
|
||||
|
||||
|
||||
AudioClip _hitClip;
|
||||
AudioClip _deathClip;
|
||||
@@ -152,6 +167,14 @@ namespace ProjectM.Client
|
||||
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
||||
foreach (var kv in _healthBars)
|
||||
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
|
||||
foreach (var kv in _remoteSlashes)
|
||||
{
|
||||
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||
if (kv.Value.Mat != null) Object.Destroy(kv.Value.Mat);
|
||||
if (kv.Value.Go != null) Object.Destroy(kv.Value.Go);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
@@ -395,7 +418,8 @@ namespace ProjectM.Client
|
||||
PruneVfx();
|
||||
AnimateNumbers(dt, cam);
|
||||
UpdateSlash(dt);
|
||||
UpdateEnemyDanger();
|
||||
UpdateEnemyDanger(localPos);
|
||||
UpdateRemoteSwings(dt);
|
||||
UpdateHealthBars(dt, cam, localPos);
|
||||
}
|
||||
|
||||
@@ -691,7 +715,7 @@ namespace ProjectM.Client
|
||||
|
||||
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
|
||||
// `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
|
||||
void BuildSlashMesh(float range, float halfAngle, float reveal, int sweepSign)
|
||||
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
|
||||
{
|
||||
const int seg = 16;
|
||||
float r1 = Mathf.Max(0.4f, range);
|
||||
@@ -721,12 +745,12 @@ namespace ProjectM.Client
|
||||
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
||||
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
||||
}
|
||||
_slashMesh.Clear();
|
||||
_slashMesh.vertices = verts;
|
||||
_slashMesh.colors = cols;
|
||||
_slashMesh.uv = uvs;
|
||||
_slashMesh.triangles = tris;
|
||||
_slashMesh.RecalculateBounds();
|
||||
mesh.Clear();
|
||||
mesh.vertices = verts;
|
||||
mesh.colors = cols;
|
||||
mesh.uv = uvs;
|
||||
mesh.triangles = tris;
|
||||
mesh.RecalculateBounds();
|
||||
}
|
||||
|
||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||
@@ -738,7 +762,7 @@ namespace ProjectM.Client
|
||||
bool finisher = step >= comboLen;
|
||||
_slashRange = range; _slashHalf = halfAngle;
|
||||
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes
|
||||
BuildSlashMesh(range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
|
||||
BuildSlashInto(_slashMesh, range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
|
||||
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
|
||||
var tr = _slashMr.transform;
|
||||
tr.position = pos + Vector3.up * 0.12f;
|
||||
@@ -766,15 +790,105 @@ namespace ProjectM.Client
|
||||
// MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
|
||||
// travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
|
||||
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||
BuildSlashMesh(_slashRange, _slashHalf, reveal, _slashSweepSign);
|
||||
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
|
||||
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c;
|
||||
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.10f);
|
||||
}
|
||||
|
||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing
|
||||
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
|
||||
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
|
||||
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
|
||||
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;
|
||||
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||
|
||||
_remoteSeen.Clear();
|
||||
foreach (var (xf, facing, mc, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<MeleeCombo>>()
|
||||
.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().WithEntityAccess())
|
||||
{
|
||||
_remoteSeen.Add(entity);
|
||||
if (!_remoteSlashes.TryGetValue(entity, out var rs)) { rs = CreateRemoteSlash(); _remoteSlashes[entity] = rs; }
|
||||
|
||||
uint swing = mc.ValueRO.SwingStartTick;
|
||||
if (rs.Init && swing != 0 && swing != rs.LastSwingTick)
|
||||
{
|
||||
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||
bool finisher = step >= comboLen;
|
||||
rs.Range = finisher ? baseRange * finisherMult : baseRange;
|
||||
rs.Half = baseHalf;
|
||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||
rs.Life = finisher ? 0.26f : 0.18f;
|
||||
rs.Age = 0f;
|
||||
rs.Active = true;
|
||||
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign);
|
||||
rs.Mat.color = rs.Tint;
|
||||
rs.Mr.enabled = true;
|
||||
}
|
||||
rs.LastSwingTick = swing;
|
||||
rs.Init = true;
|
||||
|
||||
if (rs.Active)
|
||||
{
|
||||
rs.Age += dt;
|
||||
float u = rs.Age / Mathf.Max(1e-4f, rs.Life);
|
||||
if (u >= 1f) { rs.Active = false; rs.Mr.enabled = false; }
|
||||
else
|
||||
{
|
||||
float2 fdir = facing.ValueRO.Direction;
|
||||
Vector3 f = math.lengthsq(fdir) > 1e-6f ? new Vector3(fdir.x, 0f, fdir.y).normalized : Vector3.forward;
|
||||
var tr = rs.Mr.transform;
|
||||
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.12f;
|
||||
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
||||
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign);
|
||||
var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c;
|
||||
tr.localScale = Vector3.one * (1f + u * 0.10f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_remoteSlashes.Count != _remoteSeen.Count)
|
||||
{
|
||||
_remoteStale.Clear();
|
||||
foreach (var kv in _remoteSlashes) if (!_remoteSeen.Contains(kv.Key)) _remoteStale.Add(kv.Key);
|
||||
for (int i = 0; i < _remoteStale.Count; i++)
|
||||
{
|
||||
var rs = _remoteSlashes[_remoteStale[i]];
|
||||
if (rs.Mesh != null) Object.Destroy(rs.Mesh);
|
||||
if (rs.Mat != null) Object.Destroy(rs.Mat);
|
||||
if (rs.Go != null) Object.Destroy(rs.Go);
|
||||
_remoteSlashes.Remove(_remoteStale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RemoteSlash CreateRemoteSlash()
|
||||
{
|
||||
var go = new GameObject("RemoteSlashArc");
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
var mesh = new Mesh { name = "RemoteSlashArc" };
|
||||
go.AddComponent<MeshFilter>().sharedMesh = mesh;
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
var mat = MakeParticleMaterial();
|
||||
mat.name = "RemoteSlashArc";
|
||||
mr.sharedMaterial = mat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.enabled = false;
|
||||
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
|
||||
}
|
||||
|
||||
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
|
||||
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
|
||||
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
|
||||
void UpdateEnemyDanger()
|
||||
void UpdateEnemyDanger(float3 localPos)
|
||||
{
|
||||
if (_fxRoot == null || _dangerMat == null) return;
|
||||
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
||||
@@ -804,6 +918,20 @@ namespace ProjectM.Client
|
||||
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
|
||||
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
|
||||
intensity = math.saturate(1f - remaining / windupDur);
|
||||
|
||||
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to
|
||||
// enemies near the local player (the danger cone already proves it's winding up to strike).
|
||||
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks
|
||||
&& (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until))
|
||||
{
|
||||
float3 ep = xf.ValueRO.Position;
|
||||
if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
|
||||
{
|
||||
PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume);
|
||||
_strikeBeeped[entity] = until;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
|
||||
@@ -861,6 +989,8 @@ namespace ProjectM.Client
|
||||
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
|
||||
_dangerZones.Remove(_dangerStale[i]);
|
||||
_pulseStart.Remove(_dangerStale[i]);
|
||||
_strikeBeeped.Remove(_dangerStale[i]);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user