6379f5d897
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>
286 lines
16 KiB
C#
286 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
using UnityEngine;
|
|
using static ProjectM.Client.FeedbackFx;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// MC-3/MC-4/A7 — client-only enemy attack TELEGRAPHS. Observe-only presentation <see cref="SystemBase"/> in
|
|
/// <see cref="PresentationSystemGroup"/> that reads replicated state and never mutates the sim. While an enemy's
|
|
/// <see cref="AttackWindup"/> counts down (or a Charger is mid-lunge) it paints a red ground danger shape in the
|
|
/// enemy's facing — a melee cone, a Spitter aim LANE, or the boss's radial SLAM ring / lunge wedge — brightening +
|
|
/// scaling as the strike nears so the player reads WHERE and WHEN to dodge. Also plays a near-impact "dodge NOW"
|
|
/// strike beep once per windup for enemies near the local player. SELF-DETECTS the windup-onset edge via its own
|
|
/// <c>_prevWindup</c> map (a 0 -> nonzero <c>WindUpUntilTick</c> transition arms the anticipation scale-pulse —
|
|
/// this was formerly written by CombatFeedbackSystem's health-scan loop). One pooled mesh per winding-up enemy,
|
|
/// pruned each frame; the tracking maps are pruned against a full enemy-seen set. Extracted from CombatFeedbackSystem;
|
|
/// owns its own FX-root + danger material + beep clip.
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class EnemyDangerTelegraphSystem : SystemBase
|
|
{
|
|
Transform _fxRoot;
|
|
Material _dangerMat;
|
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
|
readonly HashSet<Entity> _dangerSeen = new(); // telegraph-ACTIVE enemies (zone lifecycle: a zone vanishes when its enemy stops winding up)
|
|
readonly List<Entity> _dangerStale = new(); // scratch list reused by every prune
|
|
readonly Dictionary<Entity, float> _pulseStart = new(); // per-enemy windup-onset time (anticipation scale-pulse)
|
|
readonly Dictionary<Entity, uint> _strikeBeeped = new(); // entity -> the WindUpUntilTick it last beeped for (once/windup)
|
|
readonly Dictionary<Entity, uint> _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup)
|
|
readonly HashSet<Entity> _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup)
|
|
AudioClip _strikeBeepClip; // near-impact "dodge NOW" beep
|
|
Entity _localPlayer = Entity.Null;
|
|
|
|
protected override void OnCreate()
|
|
{
|
|
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // near-impact beep
|
|
}
|
|
|
|
protected override void OnStartRunning()
|
|
{
|
|
if (_fxRoot != null) return;
|
|
_fxRoot = new GameObject("~EnemyDangerFX").transform;
|
|
_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)
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
|
if (_dangerMat != null) Object.Destroy(_dangerMat);
|
|
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); }
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
if (_fxRoot == null || _dangerMat == null) return;
|
|
|
|
// Predicted/physics jobs writing these must finish before this main-thread read.
|
|
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
|
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
|
|
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
|
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
|
|
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
|
|
|
|
// Local player (strike-beep proximity gate).
|
|
_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;
|
|
}
|
|
|
|
UpdateEnemyDanger(localPos);
|
|
}
|
|
|
|
// 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();
|
|
_enemySeen.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())
|
|
{
|
|
_enemySeen.Add(entity);
|
|
uint until = windup.ValueRO.WindUpUntilTick;
|
|
|
|
// Self-detect the windup-onset edge (formerly written by the core's health-scan loop): a 0 -> nonzero
|
|
// transition of WindUpUntilTick arms the anticipation scale-pulse (Feature C). Requires a prior 0
|
|
// record so a mid-windup relevancy re-entry doesn't spuriously pulse (matches the old prev.Windup==0).
|
|
bool hadPrev = _prevWindup.TryGetValue(entity, out var pw);
|
|
if (until != 0u && hadPrev && pw == 0u) _pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime;
|
|
_prevWindup[entity] = until;
|
|
|
|
// 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
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Zone lifecycle: a zone vanishes the moment its enemy stops winding up (not in _dangerSeen) or despawns.
|
|
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]);
|
|
}
|
|
}
|
|
|
|
// Prune the tracking maps against the FULL enemy-seen set (a despawned enemy drops its pulse/beep/windup state).
|
|
PruneTracking(_pulseStart);
|
|
PruneTracking(_strikeBeeped);
|
|
PruneTracking(_prevWindup);
|
|
}
|
|
|
|
// Remove entries whose enemy wasn't seen this frame (keyed on the full enemy-seen set); reuses _dangerStale.
|
|
void PruneTracking<T>(Dictionary<Entity, T> dict)
|
|
{
|
|
if (dict.Count == 0) return;
|
|
_dangerStale.Clear();
|
|
foreach (var kv in dict) if (!_enemySeen.Contains(kv.Key)) _dangerStale.Add(kv.Key);
|
|
for (int i = 0; i < _dangerStale.Count; i++) dict.Remove(_dangerStale[i]);
|
|
}
|
|
|
|
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
|
|
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();
|
|
}
|
|
}
|
|
}
|