Hygiene B5: split HudSystem + CombatFeedbackSystem god-objects
Extract 7 sibling PresentationSystemGroup systems (client-only, observe-only), faithfully relocating methods+fields so behavior is preserved by construction: - HudSystem (2129->1588L): BoonModalHudSystem, RouteMapHudSystem, MetaShopHudSystem, ClassPrepPortalHudSystem — each owns its own runtime UIDocument (MenuUi.LoadPanelSettings + own sortingOrder + EnsureEventSystem), the proven EnemyMarkerSystem/OnboardingSystem pattern; no shared root, no new static bridge. - CombatFeedbackSystem (1300->914L): RoomPortalBeaconSystem, EnemyHealthBarSystem, EnemyDangerTelegraphSystem — each owns its FX-root + mats (via FeedbackFx), self-queries enemies + self-detects its edge (health-bar LastHp; danger _prevWindup), prunes its caches each frame. Verified: compiles clean (0 errors), 466/466 EditMode tests pass, Play world-creation clean (no ComponentSystemSorter cycle, no OnCreate exception, 0 console errors). NOTE: the final VISUAL smoke (panels appear at the right lifecycle; enemy health bars / danger telegraphs / portal beacon render; buttons live) needs a FOCUSED Play pass — the play-mode transition throttles while Unity is unfocused, so I could not drive live frames headlessly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,6 @@ namespace ProjectM.Client
|
||||
|
||||
readonly Dictionary<Entity, FxCache> _cache = new();
|
||||
bool _scanPrimed; // Phase 1: first health-scan completed -> new cache entries are true spawns, not the connect flood
|
||||
GameObject _portalFx; // Phase 1: authored portal effect (VFXConfig.Portal) replacing the procedural pillar when wired
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
readonly List<FloatingNumber> _numbers = new();
|
||||
@@ -68,27 +67,6 @@ namespace ProjectM.Client
|
||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||
bool _coneTickInit;
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new(); Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat)
|
||||
GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore
|
||||
|
||||
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).
|
||||
@@ -109,7 +87,7 @@ namespace ProjectM.Client
|
||||
AudioClip _telegraphClip;
|
||||
AudioClip _dashClip;
|
||||
AudioClip _swingClip;
|
||||
AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep
|
||||
AudioClip _meleeConnectClip, _footstepClip; // combat feel pass: connect thunk / footstep
|
||||
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
|
||||
|
||||
Entity _localPlayer = Entity.Null;
|
||||
@@ -135,7 +113,6 @@ namespace ProjectM.Client
|
||||
_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()
|
||||
@@ -150,17 +127,6 @@ namespace ProjectM.Client
|
||||
_dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
|
||||
_swingFx = MakeBurst(_fxRoot, "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)
|
||||
_portalMat = MakeParticleMaterial();
|
||||
_portalMat.name = "RoomPortal";
|
||||
_portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.85f); // DR-046: HDR cyan portal glow (Phase 0: tamed — 2.6/3.4 bloomed to a white blob)
|
||||
|
||||
// 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());
|
||||
@@ -172,14 +138,7 @@ namespace ProjectM.Client
|
||||
Object.Destroy(_fxRoot.gameObject);
|
||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||
if (_slashMat != null) Object.Destroy(_slashMat);
|
||||
if (_dangerMat != null) Object.Destroy(_dangerMat); if (_portalMat != null) Object.Destroy(_portalMat);
|
||||
|
||||
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);
|
||||
@@ -203,9 +162,6 @@ namespace ProjectM.Client
|
||||
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;
|
||||
@@ -248,7 +204,6 @@ namespace ProjectM.Client
|
||||
// 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
|
||||
@@ -271,7 +226,6 @@ namespace ProjectM.Client
|
||||
// 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)
|
||||
@@ -544,10 +498,8 @@ namespace ProjectM.Client
|
||||
PruneVfx();
|
||||
AnimateNumbers(dt, cam);
|
||||
UpdateSlash(dt);
|
||||
UpdateEnemyDanger(localPos); UpdatePortalBeacon();
|
||||
|
||||
UpdateRemoteSwings(dt);
|
||||
UpdateHealthBars(dt, cam, localPos);
|
||||
}
|
||||
|
||||
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
|
||||
@@ -958,342 +910,5 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
|
||||
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
|
||||
}
|
||||
|
||||
// DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the
|
||||
// client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt
|
||||
// alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while").
|
||||
// Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position
|
||||
// resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E"
|
||||
// range always agree.
|
||||
void UpdatePortalBeacon()
|
||||
{
|
||||
if (_fxRoot == null || _portalMat == null) return;
|
||||
bool inExplore = SystemAPI.TryGetSingleton<RunInfo>(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore;
|
||||
if (!inExplore || !SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
{
|
||||
if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false);
|
||||
if (_portalFx != null && _portalFx.activeSelf) _portalFx.SetActive(false);
|
||||
return;
|
||||
}
|
||||
float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1));
|
||||
// Phase 1: prefer the authored portal effect (VFXConfig.Portal, PolygonParticleFX) over the
|
||||
// procedural pillar; the pillar remains the asset-free fallback.
|
||||
var vfx = VFXConfig.Instance;
|
||||
if (vfx != null && vfx.Portal != null)
|
||||
{
|
||||
if (_portalFx == null)
|
||||
{
|
||||
_portalFx = Object.Instantiate(vfx.Portal, _fxRoot, false);
|
||||
_portalFx.name = "~RoomPortalFx";
|
||||
}
|
||||
_portalFx.transform.position = new Vector3(pos.x, 0f, pos.z); // terrain y=0 (pos.y is the capsule plane)
|
||||
if (!_portalFx.activeSelf) _portalFx.SetActive(true);
|
||||
if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false);
|
||||
return;
|
||||
}
|
||||
if (_portalBeacon == null)
|
||||
{
|
||||
_portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
||||
_portalBeacon.name = "~RoomPortalBeacon";
|
||||
var col = _portalBeacon.GetComponent<Collider>(); if (col != null) Object.Destroy(col); // cosmetic only
|
||||
_portalBeacon.transform.SetParent(_fxRoot, false);
|
||||
var mr = _portalBeacon.GetComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _portalMat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
}
|
||||
if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true);
|
||||
float t = (float)SystemAPI.Time.ElapsedTime;
|
||||
float breathe = 0.5f + 0.5f * math.sin(t * 3.5f);
|
||||
var tr = _portalBeacon.transform;
|
||||
// Cylinder is 2u tall in local space -> scale.y=2.2 gives a 4.4u pillar; lift the centre so the base sits
|
||||
// on the TERRAIN (y=0) — pos.y is the CC capsule-center plane (GridOrigin.y=1), 1 u above the ground.
|
||||
tr.position = new Vector3(pos.x, 2.2f, pos.z);
|
||||
tr.localScale = new Vector3(0.9f + 0.12f * breathe, 2.2f, 0.9f + 0.12f * breathe);
|
||||
_portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.45f + 0.3f * breathe); // glow throb (beacon-only mat; Phase 0: tamed + slimmed — the fat 6u pillar bloomed to a white egg swallowing the prompt)
|
||||
}
|
||||
|
||||
|
||||
// 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();
|
||||
bool bossRoom = SystemAPI.TryGetSingleton<RunInfo>(out var dangerRi) && dangerRi.Lifecycle == RunLifecycle.InRoom && dangerRi.CurrentRoomType == RoomTypeId.Boss; // A7: in a Boss room the Charger-kind enemy IS the boss (adds are swarmers)
|
||||
|
||||
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);
|
||||
bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph
|
||||
|
||||
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 = isBoss ? Tuning.BossSlamWindupTicks : math.max(1f, tele.ValueRO.WindupTicks); // A7: ramp over the boss's real slam wind-up
|
||||
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 (isBoss && !lunging)
|
||||
{
|
||||
// A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell
|
||||
// matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE).
|
||||
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity);
|
||||
}
|
||||
else if (isBoss)
|
||||
{
|
||||
// B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup +
|
||||
// travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge.
|
||||
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity);
|
||||
}
|
||||
else 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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user