8f96b520d6
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>
1168 lines
63 KiB
C#
1168 lines
63 KiB
C#
using System.Collections.Generic;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Client-only combat JUICE. A managed presentation system (SystemBase, main thread, NO Burst) in the
|
|
/// <see cref="PresentationSystemGroup"/> that REACTS to replicated state — it never runs simulation. Each frame
|
|
/// it edge-detects every damageable ghost's replicated <see cref="Health"/>: a decrease spawns a floating
|
|
/// damage number + a hit-spark burst + a hit SFX + camera shake; a Husk despawn (server-authoritative death)
|
|
/// spawns a death burst + death SFX; the local player crossing to 0 HP does the same. A local-player ability
|
|
/// fire (AbilityCooldown advancing) spawns a muzzle flash + zap. Everything derives from already-replicated
|
|
/// state, so it is correct without touching the prediction loop, and it lives only in the client world so the
|
|
/// server never instantiates GameObjects.
|
|
/// <para>
|
|
/// VFX prefer authored GabrielAguiar Shuriken prefabs supplied by <see cref="VFXConfig"/> (muzzle / hit /
|
|
/// death + a projectile-following trail); each hook falls back to a procedural particle burst when no prefab
|
|
/// is assigned, so the slice still runs asset-free. Spawned VFX are stripped to particles only
|
|
/// (<see cref="StripCosmetic"/>) — GA "projectile" prefabs ship a Rigidbody + collider + mover that would
|
|
/// otherwise self-propel and spawn secondary effects. SFX remain procedural.
|
|
/// </para>
|
|
/// <para>
|
|
/// Per-entity last Health + position + isEnemy are cached in a managed dictionary (Entity is a stable client
|
|
/// key for a ghost's lifetime); stale keys are pruned each frame (a pruned Husk = a kill → death VFX at its
|
|
/// last position). Never destroys a ghost from the client — GhostDespawnSystem owns that off the snapshot
|
|
/// protocol; we only OBSERVE.
|
|
/// </para>
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class CombatFeedbackSystem : SystemBase
|
|
{
|
|
struct FxCache { public float Hp; public float MaxHp; public float3 Pos; public bool IsEnemy; public uint Windup; }
|
|
|
|
readonly Dictionary<Entity, FxCache> _cache = new();
|
|
readonly HashSet<Entity> _seen = new();
|
|
readonly List<Entity> _stale = new();
|
|
readonly List<FloatingNumber> _numbers = new();
|
|
|
|
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
|
|
readonly List<TimedVfx> _activeVfx = new();
|
|
readonly Dictionary<Entity, GameObject> _projTrails = new();
|
|
readonly HashSet<Entity> _projSeen = new();
|
|
readonly List<Entity> _projStale = new();
|
|
|
|
Transform _fxRoot;
|
|
ParticleSystem _hitFx;
|
|
ParticleSystem _deathFx;
|
|
ParticleSystem _muzzleFx;
|
|
ParticleSystem _dashFx;
|
|
ParticleSystem _swingFx;
|
|
Mesh _slashMesh;
|
|
MeshRenderer _slashMr;
|
|
Material _slashMat;
|
|
Color _slashTint;
|
|
float _slashAge, _slashLife;
|
|
bool _slashActive;
|
|
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
|
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
|
Material _dangerMat;
|
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
|
readonly HashSet<Entity> _dangerSeen = new();
|
|
readonly List<Entity> _dangerStale = new();
|
|
// ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ----
|
|
struct HealthBarEntry { public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; }
|
|
const int HealthBarPoolLimit = 24;
|
|
const float HealthBarShowDuration = 3f;
|
|
const float HealthBarFadeDuration = 0.5f;
|
|
const float HealthBarAlwaysOnThreshold = 0.25f;
|
|
const float HealthBarWorldYOffset = 2.3f;
|
|
readonly Dictionary<Entity, HealthBarEntry> _healthBars = new();
|
|
readonly List<Entity> _barStale = new();
|
|
readonly List<Entity> _barKeys = new();
|
|
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;
|
|
AudioClip _fireClip;
|
|
AudioClip _telegraphClip;
|
|
AudioClip _dashClip;
|
|
AudioClip _swingClip;
|
|
AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep
|
|
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
|
|
|
|
Entity _localPlayer = Entity.Null;
|
|
uint _lastLocalFireTick;
|
|
bool _fireTickInit;
|
|
uint _lastLocalDashTick;
|
|
bool _dashTickInit;
|
|
uint _lastLocalSwingTick;
|
|
bool _swingTickInit;
|
|
|
|
const int NumberPoolSize = 32;
|
|
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
|
|
|
|
struct TimedVfx { public GameObject Go; public double Kill; }
|
|
|
|
protected override void OnCreate()
|
|
{
|
|
_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);
|
|
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
|
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
|
|
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
|
|
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
|
|
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
|
|
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep
|
|
}
|
|
|
|
protected override void OnStartRunning()
|
|
{
|
|
if (_fxRoot != null) return;
|
|
|
|
_fxRoot = new GameObject("~CombatFeedbackFX").transform;
|
|
var mat = MakeParticleMaterial();
|
|
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
|
|
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
|
|
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
|
|
_dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
|
|
_swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
|
|
BuildSlash();
|
|
_dangerMat = MakeParticleMaterial();
|
|
_dangerMat.name = "EnemyDanger";
|
|
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha)
|
|
// Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha).
|
|
Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default");
|
|
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
|
|
_barFillMat = new Material(uiShader) { name = "HealthBarFill" };
|
|
|
|
for (int i = 0; i < NumberPoolSize; i++)
|
|
_numbers.Add(CreateNumber());
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_fxRoot != null)
|
|
Object.Destroy(_fxRoot.gameObject);
|
|
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
|
if (_slashMat != null) Object.Destroy(_slashMat);
|
|
if (_dangerMat != null) Object.Destroy(_dangerMat);
|
|
if (_barBgMat != null) Object.Destroy(_barBgMat);
|
|
if (_barFillMat != null) Object.Destroy(_barFillMat);
|
|
foreach (var kv in _dangerZones)
|
|
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
|
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()
|
|
{
|
|
float dt = SystemAPI.Time.DeltaTime;
|
|
var cam = Camera.main;
|
|
var cfg = VFXConfig.Instance;
|
|
|
|
// Make sure predicted/physics jobs writing these are done before this main-thread read.
|
|
EntityManager.CompleteDependencyBeforeRO<Health>();
|
|
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
|
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
|
|
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
|
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
|
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
|
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
|
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
|
|
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
|
|
|
|
// Resolve the local player (for hit colouring + fire feedback).
|
|
_localPlayer = Entity.Null;
|
|
float3 localPos = default;
|
|
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
|
|
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
|
|
{
|
|
_localPlayer = entity;
|
|
localPos = xf.ValueRO.Position;
|
|
}
|
|
|
|
// Client-derived dash window of the LOCAL player (DashSystem runs in the client prediction loop
|
|
// too): drives the i-frame shimmer + the hit-feedback suppression below. Observe-only.
|
|
bool localIFrameActive = false;
|
|
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashState>(_localPlayer)
|
|
&& SystemAPI.TryGetSingleton<NetworkTime>(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
|
|
{
|
|
var localDash = EntityManager.GetComponentData<DashState>(_localPlayer);
|
|
localIFrameActive = localDash.IFrameUntilTick != 0u
|
|
&& new NetworkTick(localDash.IFrameUntilTick).IsNewerThan(dashNetTime.ServerTick);
|
|
}
|
|
|
|
// Edge-detect Health on every damageable ghost (players + Husks).
|
|
_seen.Clear();
|
|
foreach (var (health, xf, entity) in
|
|
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>().WithEntityAccess())
|
|
{
|
|
_seen.Add(entity);
|
|
float cur = health.ValueRO.Current;
|
|
float3 p = xf.ValueRO.Position;
|
|
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
|
|
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
|
|
bool isLocalPlayer = entity == _localPlayer;
|
|
bool isStructure = SystemAPI.HasComponent<PlacedStructure>(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem
|
|
|
|
if (_cache.TryGetValue(entity, out var prev))
|
|
{
|
|
if (isEnemy && windup != 0 && prev.Windup == 0)
|
|
{
|
|
// Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands.
|
|
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
|
|
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
|
|
_pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; // Feature C: scale-pulse onset
|
|
}
|
|
|
|
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
|
|
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
|
|
if (cur < prev.Hp - 0.001f && !isStructure && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
|
|
{
|
|
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);
|
|
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
|
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
|
if (isEnemy)
|
|
{
|
|
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
|
|
// (saturate(delta / RefDamage)) so a chip reads soft and a heavy connect snaps.
|
|
// Camera-only hit-stop (NEVER Time.timeScale); keys on the enemy Health-decrease edge.
|
|
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
|
|
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
|
|
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
|
|
// Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read.
|
|
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
|
}
|
|
}
|
|
|
|
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
|
|
// mechanic exists, so a 0 -> positive edge is unambiguously a respawn (observer-only).
|
|
if (isLocalPlayer && FeelConfig.RespawnShimmerEnabled && cur > prev.Hp + 0.001f && prev.Hp <= 0f)
|
|
{
|
|
Burst(_muzzleFx, null, (Vector3)p + Vector3.up * 0.6f, FeelConfig.RespawnShimmerBurst);
|
|
PrototypeCameraRig.AddShake(FeelConfig.RespawnShimmerShake);
|
|
}
|
|
|
|
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
|
|
// EB-1: structures (not EnemyTag) would otherwise fire the HUMAN player-death cue here; their
|
|
// damage/death is routed entirely through StructureFeedbackSystem (gated by !isStructure).
|
|
if (!isEnemy && !isStructure && cur <= 0f && prev.Hp > 0f)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
_cache[entity] = new FxCache { Hp = cur, MaxHp = health.ValueRO.Max, Pos = p, IsEnemy = isEnemy, Windup = windup };
|
|
}
|
|
|
|
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
|
|
if (_cache.Count != _seen.Count)
|
|
{
|
|
_stale.Clear();
|
|
foreach (var kv in _cache)
|
|
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
|
|
|
for (int i = 0; i < _stale.Count; i++)
|
|
{
|
|
var c = _cache[_stale[i]];
|
|
if (c.IsEnemy)
|
|
{
|
|
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
|
|
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
|
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
|
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
|
}
|
|
_cache.Remove(_stale[i]);
|
|
}
|
|
}
|
|
|
|
// Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot.
|
|
// Raw uint inequality is intentional here: only the edge (a new shot) matters and the worst case
|
|
// of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim.
|
|
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer))
|
|
{
|
|
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
|
|
if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick)
|
|
{
|
|
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
|
|
PlayClip(_fireClip, (Vector3)localPos, 0.5f);
|
|
}
|
|
_lastLocalFireTick = nextFire;
|
|
_fireTickInit = true;
|
|
}
|
|
|
|
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
|
|
// (replicated [GhostField], predicted both sides; raw uint edge like the muzzle flash — cosmetic
|
|
// only). Whoosh + afterimage burst + camera punch on start, shimmer trail while i-frames last.
|
|
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashCooldown>(_localPlayer))
|
|
{
|
|
uint nextDash = EntityManager.GetComponentData<DashCooldown>(_localPlayer).NextTick;
|
|
if (_dashTickInit && nextDash != 0 && nextDash != _lastLocalDashTick)
|
|
{
|
|
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.6f, FeelConfig.DashBurstCount);
|
|
PlayClip(_dashClip, (Vector3)localPos, FeelConfig.DashSfxVolume);
|
|
PrototypeCameraRig.AddShake(FeelConfig.DashShake);
|
|
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick, FeelConfig.HitStopDurationMs);
|
|
}
|
|
_lastLocalDashTick = nextDash;
|
|
_dashTickInit = true;
|
|
|
|
if (localIFrameActive) // i-frame shimmer trail while the local window is active
|
|
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame);
|
|
}
|
|
|
|
// Local-player melee swing feedback (MC-4): MeleeCombo.SwingStartTick advances once per swing (owner-predicted
|
|
// [GhostField]; raw uint edge like the muzzle/dash, cosmetic only). Whoosh + arc burst + a small camera
|
|
// nudge ahead of the player; the burst scales with the combo step so the finisher visibly pops.
|
|
if (_localPlayer != Entity.Null && EntityManager.HasComponent<MeleeCombo>(_localPlayer))
|
|
{
|
|
var mc = EntityManager.GetComponentData<MeleeCombo>(_localPlayer);
|
|
if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick)
|
|
{
|
|
int step = math.max(1, (int)mc.Step);
|
|
Vector3 face = Vector3.forward;
|
|
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
|
|
{
|
|
var d = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
|
|
if (math.lengthsq(d) > 1e-6f) face = new Vector3(d.x, 0f, d.y).normalized;
|
|
}
|
|
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;
|
|
bool finisher = step >= comboLen;
|
|
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
|
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
|
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
|
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
|
|
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
|
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
|
|
float cosHalf = Mathf.Cos(slashHalf);
|
|
float2 fdir = new float2(face.x, face.z);
|
|
foreach (var kv in _cache)
|
|
{
|
|
if (!kv.Value.IsEnemy) continue;
|
|
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos))
|
|
{
|
|
float d2 = math.distancesq(localPos, kv.Value.Pos);
|
|
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
|
|
}
|
|
}
|
|
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
|
|
if (connected)
|
|
{
|
|
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
|
PlayClip(_meleeConnectClip, nearestHit, 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 (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
|
}
|
|
_lastLocalSwingTick = mc.SwingStartTick;
|
|
_swingTickInit = true;
|
|
}
|
|
|
|
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
|
if (_localPlayer != Entity.Null)
|
|
{
|
|
Vector3 lp = (Vector3)localPos;
|
|
if (_footInit)
|
|
{
|
|
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
|
|
_footTimer -= dt;
|
|
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
|
|
{
|
|
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
|
|
_footTimer = FeelConfig.FootstepIntervalSec;
|
|
}
|
|
}
|
|
_lastFootPos = lp; _footInit = true;
|
|
}
|
|
RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse
|
|
UpdateProjectileTrails(cfg);
|
|
PruneVfx();
|
|
AnimateNumbers(dt, cam);
|
|
UpdateSlash(dt);
|
|
UpdateEnemyDanger(localPos);
|
|
UpdateRemoteSwings(dt);
|
|
UpdateHealthBars(dt, cam, localPos);
|
|
}
|
|
|
|
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
|
|
|
|
static GameObject PlayerDeathPrefab(VFXConfig cfg)
|
|
{
|
|
if (cfg == null) return null;
|
|
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
|
|
}
|
|
|
|
// Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop
|
|
// without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit).
|
|
void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color)
|
|
{
|
|
if (ps == null || count <= 0) return;
|
|
var ep = new ParticleSystem.EmitParams { position = pos, startColor = color };
|
|
ps.Emit(ep, count);
|
|
}
|
|
|
|
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
|
|
{
|
|
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
|
|
else EmitAt(fallback, pos, count);
|
|
}
|
|
|
|
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
|
|
{
|
|
if (prefab == null || _fxRoot == null) return;
|
|
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC
|
|
var go = Object.Instantiate(prefab, pos, rot, _fxRoot);
|
|
go.transform.position = pos;
|
|
StripCosmetic(go);
|
|
var systems = go.GetComponentsInChildren<ParticleSystem>();
|
|
for (int i = 0; i < systems.Length; i++) systems[i].Play();
|
|
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
|
|
}
|
|
|
|
void PruneVfx()
|
|
{
|
|
double now = SystemAPI.Time.ElapsedTime;
|
|
for (int i = _activeVfx.Count - 1; i >= 0; i--)
|
|
{
|
|
if (now < _activeVfx[i].Kill) continue;
|
|
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go);
|
|
_activeVfx.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
// A looping trail prefab follows each in-flight projectile ghost; destroyed when it despawns.
|
|
void UpdateProjectileTrails(VFXConfig cfg)
|
|
{
|
|
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
|
|
{
|
|
// Config cleared mid-run: drop any orphaned trails so they don't linger.
|
|
if (_projTrails.Count > 0)
|
|
{
|
|
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value);
|
|
_projTrails.Clear();
|
|
}
|
|
return;
|
|
}
|
|
|
|
_projSeen.Clear();
|
|
foreach (var (xf, entity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>().WithEntityAccess())
|
|
{
|
|
_projSeen.Add(entity);
|
|
Vector3 wp = (Vector3)xf.ValueRO.Position;
|
|
if (_projTrails.TryGetValue(entity, out var trail))
|
|
{
|
|
if (trail != null) trail.transform.position = wp;
|
|
}
|
|
else
|
|
{
|
|
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot);
|
|
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only
|
|
var systems = go.GetComponentsInChildren<ParticleSystem>();
|
|
for (int i = 0; i < systems.Length; i++) systems[i].Play();
|
|
_projTrails[entity] = go;
|
|
}
|
|
}
|
|
|
|
if (_projTrails.Count == _projSeen.Count) return;
|
|
_projStale.Clear();
|
|
foreach (var kv in _projTrails)
|
|
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
|
|
for (int i = 0; i < _projStale.Count; i++)
|
|
{
|
|
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]);
|
|
_projTrails.Remove(_projStale[i]);
|
|
}
|
|
}
|
|
|
|
// Cosmetic VFX must be particles only. GA demo "projectile" prefabs ship a non-kinematic Rigidbody,
|
|
// a solid collider, and a mover (ProjectileMoveScript) that self-propels and spawns secondary muzzle/hit
|
|
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
|
|
static void StripCosmetic(GameObject go)
|
|
{
|
|
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
|
|
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
|
|
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
|
|
{
|
|
if (mb == null) continue;
|
|
string n = mb.GetType().Name;
|
|
// Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires.
|
|
if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
|
mb.enabled = false;
|
|
}
|
|
}
|
|
|
|
// Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early
|
|
// or hold a finished GameObject around on a blanket TTL.
|
|
static double VfxLifetime(GameObject go)
|
|
{
|
|
float longest = 0f;
|
|
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
|
|
{
|
|
var main = ps.main;
|
|
float d = main.duration + main.startLifetime.constantMax;
|
|
if (d > longest) longest = d;
|
|
}
|
|
return Mathf.Clamp(longest, 1f, 6f);
|
|
}
|
|
|
|
// ---- Floating damage numbers (pooled, billboarded TextMesh) ----
|
|
|
|
class FloatingNumber
|
|
{
|
|
public TextMesh Tm;
|
|
public Transform Tr;
|
|
public float Age;
|
|
public float Life;
|
|
public Vector3 Vel;
|
|
public Color BaseColor;
|
|
public bool Active;
|
|
}
|
|
|
|
FloatingNumber CreateNumber()
|
|
{
|
|
var go = new GameObject("DamageNumber");
|
|
go.transform.SetParent(_fxRoot, false);
|
|
var tm = go.AddComponent<TextMesh>();
|
|
tm.characterSize = 0.12f;
|
|
tm.fontSize = 64;
|
|
tm.anchor = TextAnchor.MiddleCenter;
|
|
tm.alignment = TextAlignment.Center;
|
|
tm.color = Color.white;
|
|
tm.fontStyle = FontStyle.Bold;
|
|
go.SetActive(false);
|
|
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
|
|
}
|
|
|
|
void SpawnNumber(float amount, Vector3 worldPos, bool isLocalPlayer, Camera cam)
|
|
{
|
|
FloatingNumber fn = null;
|
|
for (int i = 0; i < _numbers.Count; i++)
|
|
if (!_numbers[i].Active) { fn = _numbers[i]; break; }
|
|
if (fn == null) return; // pool exhausted this frame: drop (cheap)
|
|
|
|
fn.Active = true;
|
|
fn.Age = 0f;
|
|
float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger
|
|
fn.Life = Mathf.Lerp(0.6f, 0.95f, mag);
|
|
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
|
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
|
fn.Tm.color = fn.BaseColor;
|
|
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
|
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
|
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
|
|
fn.Tr.gameObject.SetActive(true);
|
|
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
|
}
|
|
|
|
void AnimateNumbers(float dt, Camera cam)
|
|
{
|
|
for (int i = 0; i < _numbers.Count; i++)
|
|
{
|
|
var fn = _numbers[i];
|
|
if (!fn.Active) continue;
|
|
|
|
fn.Age += dt;
|
|
if (fn.Age >= fn.Life)
|
|
{
|
|
fn.Active = false;
|
|
fn.Tr.gameObject.SetActive(false);
|
|
continue;
|
|
}
|
|
|
|
fn.Vel.y -= 3.5f * dt; // ease the rise
|
|
fn.Tr.position += fn.Vel * dt;
|
|
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
|
|
|
var c = fn.BaseColor;
|
|
c.a = 1f - (fn.Age / fn.Life);
|
|
fn.Tm.color = c;
|
|
}
|
|
}
|
|
|
|
// ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ----
|
|
|
|
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise)
|
|
{
|
|
const int rate = 44100;
|
|
int len = Mathf.Max(16, (int)(dur * rate));
|
|
var clip = AudioClip.Create(name, len, 1, rate, false);
|
|
var data = new float[len];
|
|
var rng = new System.Random(name.Length * 9973 + 7);
|
|
float phase = 0f;
|
|
for (int i = 0; i < len; i++)
|
|
{
|
|
float t = i / (float)len;
|
|
float env = Mathf.Exp(-5f * t);
|
|
float freq = Mathf.Lerp(f0, f1, t);
|
|
phase += 2f * Mathf.PI * freq / rate;
|
|
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
|
|
data[i] = s * env * vol;
|
|
}
|
|
clip.SetData(data, 0);
|
|
return clip;
|
|
}
|
|
|
|
static Material MakeParticleMaterial()
|
|
{
|
|
// Sprites/Default is an always-included, transparent, vertex-coloured shader — reliable for
|
|
// billboarded sparks; HDR start colours still push past the bloom threshold (Stage 5 look pass).
|
|
Shader sh = Shader.Find("Sprites/Default");
|
|
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
|
if (sh == null) sh = Shader.Find("Unlit/Color");
|
|
return new Material(sh) { name = "CombatFeedbackParticle" };
|
|
}
|
|
|
|
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
|
|
{
|
|
var go = new GameObject(name);
|
|
go.transform.SetParent(_fxRoot, false);
|
|
var ps = go.AddComponent<ParticleSystem>();
|
|
|
|
var main = ps.main;
|
|
main.loop = false;
|
|
main.playOnAwake = false;
|
|
// duration unused: manual Emit bursts with emission disabled
|
|
main.startLifetime = life;
|
|
main.startSpeed = speed;
|
|
main.startSize = size;
|
|
main.startColor = color;
|
|
main.maxParticles = max;
|
|
main.gravityModifier = 0f;
|
|
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
|
|
|
var emission = ps.emission;
|
|
emission.enabled = false; // we Emit(count) manually
|
|
|
|
var shape = ps.shape;
|
|
shape.enabled = true;
|
|
shape.shapeType = ParticleSystemShapeType.Sphere;
|
|
shape.radius = 0.06f;
|
|
|
|
var colOverLife = ps.colorOverLifetime;
|
|
colOverLife.enabled = true;
|
|
var grad = new Gradient();
|
|
grad.SetKeys(
|
|
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
|
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
|
|
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
|
|
|
|
var sizeOverLife = ps.sizeOverLifetime;
|
|
sizeOverLife.enabled = true;
|
|
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
|
|
|
|
var renderer = ps.GetComponent<ParticleSystemRenderer>();
|
|
renderer.material = mat;
|
|
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
|
return ps;
|
|
}
|
|
|
|
void BuildSlash()
|
|
{
|
|
var go = new GameObject("MeleeSlashArc");
|
|
go.transform.SetParent(_fxRoot, false);
|
|
_slashMesh = new Mesh { name = "MeleeSlashArc" };
|
|
var mf = go.AddComponent<MeshFilter>();
|
|
mf.sharedMesh = _slashMesh;
|
|
_slashMr = go.AddComponent<MeshRenderer>();
|
|
_slashMat = MakeParticleMaterial();
|
|
_slashMat.name = "MeleeSlashArc";
|
|
_slashMr.sharedMaterial = _slashMat;
|
|
_slashMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
_slashMr.receiveShadows = false;
|
|
_slashMr.enabled = false;
|
|
}
|
|
|
|
// 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 BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
|
|
{
|
|
const int seg = 16;
|
|
float r1 = Mathf.Max(0.4f, range);
|
|
float r0 = r1 * 0.45f;
|
|
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
|
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge
|
|
float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep
|
|
var verts = new Vector3[(seg + 1) * 2];
|
|
var cols = new Color[(seg + 1) * 2];
|
|
var uvs = new Vector2[(seg + 1) * 2];
|
|
var tris = new int[seg * 6];
|
|
for (int i = 0; i <= seg; i++)
|
|
{
|
|
float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
|
|
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
|
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
|
|
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
|
|
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
|
|
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
|
|
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
|
uvs[i * 2] = new Vector2(0.5f, 0.5f);
|
|
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
|
}
|
|
for (int i = 0; i < seg; i++)
|
|
{
|
|
int b = i * 2;
|
|
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
|
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
|
}
|
|
mesh.Clear();
|
|
mesh.vertices = verts;
|
|
mesh.colors = cols;
|
|
mesh.uv = uvs;
|
|
mesh.triangles = tris;
|
|
mesh.RecalculateBounds();
|
|
}
|
|
|
|
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
|
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
|
// directional, escalating cleave rather than a static flash.
|
|
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
|
{
|
|
if (_slashMr == null || _slashMat == null) return;
|
|
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
|
|
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;
|
|
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
|
tr.localScale = Vector3.one;
|
|
// Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
|
|
float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
|
|
_slashTint = finisher
|
|
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash
|
|
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step
|
|
if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read)
|
|
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
|
|
_slashAge = 0f;
|
|
_slashActive = true;
|
|
_slashMat.color = _slashTint;
|
|
_slashMr.enabled = true;
|
|
}
|
|
|
|
void UpdateSlash(float dt)
|
|
{
|
|
if (!_slashActive || _slashMr == null) return;
|
|
_slashAge += dt;
|
|
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
|
|
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
|
|
// 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);
|
|
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(float3 localPos)
|
|
{
|
|
if (_fxRoot == null || _dangerMat == null) return;
|
|
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
|
_dangerSeen.Clear();
|
|
if (serverTick.IsValid)
|
|
{
|
|
foreach (var (xf, stats, windup, tele, entity) in
|
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
|
|
.WithAll<EnemyTag>().WithEntityAccess())
|
|
{
|
|
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit).
|
|
bool lunging = SystemAPI.HasComponent<IsLunging>(entity) && SystemAPI.IsComponentEnabled<IsLunging>(entity);
|
|
uint until = windup.ValueRO.WindUpUntilTick;
|
|
if (until == 0u && !lunging) continue;
|
|
|
|
float intensity;
|
|
if (lunging)
|
|
{
|
|
intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears
|
|
}
|
|
else
|
|
{
|
|
var untilTick = new Unity.NetCode.NetworkTick(until);
|
|
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
|
|
int remaining = untilTick.TicksSince(serverTick);
|
|
// Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for
|
|
// 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).
|
|
float pulse = 0f;
|
|
if (_pulseStart.TryGetValue(entity, out var t0))
|
|
{
|
|
float age = (float)SystemAPI.Time.ElapsedTime - t0;
|
|
const float PulseLife = 0.18f;
|
|
if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f;
|
|
else _pulseStart.Remove(entity);
|
|
}
|
|
|
|
_dangerSeen.Add(entity);
|
|
if (!_dangerZones.TryGetValue(entity, out var go) || go == null)
|
|
{
|
|
go = new GameObject("EnemyDanger");
|
|
go.transform.SetParent(_fxRoot, false);
|
|
go.AddComponent<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
|
|
var mr = go.AddComponent<MeshRenderer>();
|
|
mr.sharedMaterial = _dangerMat;
|
|
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
mr.receiveShadows = false;
|
|
_dangerZones[entity] = go;
|
|
}
|
|
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
|
|
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel
|
|
if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter)
|
|
{
|
|
// MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim
|
|
// LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the
|
|
// shot nears so the player reads the line to dodge/dash across it.
|
|
float laneLen = 12f;
|
|
if (SystemAPI.HasComponent<SpitterState>(entity))
|
|
{
|
|
var ss = SystemAPI.GetComponent<SpitterState>(entity);
|
|
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
|
|
}
|
|
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
|
|
}
|
|
else BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
|
|
float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
|
|
var tr = go.transform;
|
|
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
|
|
tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up);
|
|
tr.localScale = Vector3.one * (0.92f + 0.12f * intensity + pulse);
|
|
}
|
|
}
|
|
if (_dangerZones.Count != _dangerSeen.Count)
|
|
{
|
|
_dangerStale.Clear();
|
|
foreach (var kv in _dangerZones) if (!_dangerSeen.Contains(kv.Key)) _dangerStale.Add(kv.Key);
|
|
for (int i = 0; i < _dangerStale.Count; i++)
|
|
{
|
|
var g = _dangerZones[_dangerStale[i]];
|
|
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]);
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
|
|
// ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ----
|
|
|
|
void ShowHealthBar(Entity entity)
|
|
{
|
|
if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null)
|
|
entry = CreateHealthBar(entity);
|
|
entry.ShowTimer = HealthBarShowDuration;
|
|
if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; }
|
|
_healthBars[entity] = entry; // struct — must re-assign
|
|
}
|
|
|
|
HealthBarEntry CreateHealthBar(Entity entity)
|
|
{
|
|
var go = new GameObject("EnemyHPBar");
|
|
if (_fxRoot != null) go.transform.SetParent(_fxRoot, false);
|
|
var canvas = go.AddComponent<Canvas>();
|
|
canvas.renderMode = RenderMode.WorldSpace;
|
|
canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry
|
|
var rt = go.GetComponent<RectTransform>();
|
|
rt.sizeDelta = new Vector2(1.2f, 0.14f);
|
|
|
|
var bgGo = new GameObject("Bg");
|
|
bgGo.transform.SetParent(go.transform, false);
|
|
var bgRt = bgGo.AddComponent<RectTransform>();
|
|
bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one;
|
|
bgRt.offsetMin = bgRt.offsetMax = Vector2.zero;
|
|
var bgImg = bgGo.AddComponent<UnityEngine.UI.Image>();
|
|
bgImg.material = _barBgMat;
|
|
bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f);
|
|
|
|
var fillGo = new GameObject("Fill");
|
|
fillGo.transform.SetParent(go.transform, false);
|
|
var fillRt = fillGo.AddComponent<RectTransform>();
|
|
fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one;
|
|
fillRt.offsetMin = new Vector2(0.02f, 0.02f);
|
|
fillRt.offsetMax = new Vector2(-0.02f, -0.02f);
|
|
var fillImg = fillGo.AddComponent<UnityEngine.UI.Image>();
|
|
fillImg.material = _barFillMat;
|
|
fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f);
|
|
fillImg.type = UnityEngine.UI.Image.Type.Simple; // a sprite-less UI Image ignores fillAmount (it draws a full quad) ->
|
|
fillImg.raycastTarget = false; // the bar empties by sizing the fill RectTransform (anchorMax.x = frac) in UpdateHealthBars
|
|
|
|
go.SetActive(false);
|
|
var entry = new HealthBarEntry { CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false };
|
|
_healthBars[entity] = entry;
|
|
return entry;
|
|
}
|
|
|
|
// Per-frame: prune dead bars (reusing the main loop's _seen set), pool-cap by distance, billboard + fade.
|
|
void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos)
|
|
{
|
|
if (_healthBars.Count > 0)
|
|
{
|
|
_barStale.Clear();
|
|
foreach (var kv in _healthBars)
|
|
if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key);
|
|
for (int i = 0; i < _barStale.Count; i++)
|
|
{
|
|
var e2 = _barStale[i];
|
|
if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo);
|
|
_healthBars.Remove(e2);
|
|
}
|
|
}
|
|
if (_healthBars.Count == 0) return;
|
|
|
|
bool capBars = _localPlayer != Entity.Null && _healthBars.Count > HealthBarPoolLimit;
|
|
_barKeys.Clear();
|
|
foreach (var k in _healthBars.Keys) _barKeys.Add(k);
|
|
for (int i = 0; i < _barKeys.Count; i++)
|
|
{
|
|
var key = _barKeys[i];
|
|
var entry = _healthBars[key];
|
|
if (entry.CanvasGo == null) continue;
|
|
if (!_cache.TryGetValue(key, out var fc)) continue;
|
|
|
|
float frac = fc.MaxHp > 0f ? math.saturate(fc.Hp / fc.MaxHp) : 1f;
|
|
bool alwaysOn = frac < HealthBarAlwaysOnThreshold;
|
|
|
|
if (capBars && math.lengthsq(fc.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq)
|
|
{
|
|
if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
|
_healthBars[key] = entry;
|
|
continue;
|
|
}
|
|
|
|
if (!alwaysOn) entry.ShowTimer -= dt;
|
|
bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration;
|
|
if (shouldShow)
|
|
{
|
|
if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; }
|
|
if (cam != null)
|
|
{
|
|
entry.CanvasGo.transform.position = (Vector3)fc.Pos + Vector3.up * HealthBarWorldYOffset;
|
|
entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard
|
|
}
|
|
float alpha = (!alwaysOn && entry.ShowTimer < 0f)
|
|
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
|
|
if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); }
|
|
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
|
|
}
|
|
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
|
|
|
|
_healthBars[key] = entry;
|
|
}
|
|
}
|
|
|
|
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
|
|
{
|
|
const int seg = 14;
|
|
var verts = new Vector3[seg + 2];
|
|
var cols = new Color[seg + 2];
|
|
var uvs = new Vector2[seg + 2];
|
|
var tris = new int[seg * 3];
|
|
float aCenter = 0.18f + 0.62f * intensity;
|
|
verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f);
|
|
for (int i = 0; i <= seg; i++)
|
|
{
|
|
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
|
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
|
|
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
|
|
uvs[i + 1] = new Vector2(0.5f, 0.5f);
|
|
}
|
|
for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; }
|
|
mesh.Clear();
|
|
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
|
|
mesh.RecalculateBounds();
|
|
}
|
|
|
|
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
|
|
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
|
|
// already rotated to the enemy facing, so +Z is "toward the locked target".
|
|
static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity)
|
|
{
|
|
float a = 0.18f + 0.62f * intensity;
|
|
var verts = new Vector3[4]
|
|
{
|
|
new Vector3(-halfWidth, 0f, 0.2f),
|
|
new Vector3( halfWidth, 0f, 0.2f),
|
|
new Vector3(-halfWidth, 0f, length),
|
|
new Vector3( halfWidth, 0f, length),
|
|
};
|
|
var cols = new Color[4]
|
|
{
|
|
new Color(1f, 1f, 1f, a),
|
|
new Color(1f, 1f, 1f, a),
|
|
new Color(1f, 1f, 1f, a * 0.12f),
|
|
new Color(1f, 1f, 1f, a * 0.12f),
|
|
};
|
|
var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) };
|
|
var tris = new int[6] { 0, 2, 1, 1, 2, 3 };
|
|
mesh.Clear();
|
|
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
|
|
mesh.RecalculateBounds();
|
|
}
|
|
|
|
static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
|
|
{
|
|
if (ps == null) return;
|
|
ps.transform.position = pos;
|
|
ps.Emit(count);
|
|
}
|
|
|
|
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
|
{
|
|
if (clip == null) return;
|
|
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
|
}
|
|
}
|
|
}
|