diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 4894cb80a..71a17abef 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -312,13 +312,25 @@ namespace ProjectM.Client } } - // Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot. + // C3: a CONE ability (the Warrior cleave) gets the dedicated cone-arc cue below, NOT the projectile muzzle + // flash + zap (those read as a ranged shot). Detect the local ability archetype once and gate the muzzle. + bool localIsCone = false; + if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer) + && SystemAPI.TryGetSingleton(out var muzDb) && muzDb.Value.IsCreated) + { + ref var muzAdb = ref muzDb.Value.Value; + if (muzAdb.TryGetAbility(EntityManager.GetComponentData(_localPlayer).Id, out var muzDef) + && muzDef.Archetype == (byte)AbilityArchetype.Cone) + localIsCone = true; + } + +// 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(_localPlayer)) { uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; - if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick) + if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick && !localIsCone) { Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8); PlayClip(_fireClip, (Vector3)localPos, 0.5f); @@ -398,7 +410,11 @@ namespace ProjectM.Client 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); + if (finisher) + { + PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); + if (FeelConfig.HitStopFreezeEnabled) PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit) + } } _lastLocalSwingTick = mc.SwingStartTick; _swingTickInit = true; @@ -938,6 +954,8 @@ namespace ProjectM.Client if (_fxRoot == null || _dangerMat == null) return; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; _dangerSeen.Clear(); + bool bossRoom = SystemAPI.TryGetSingleton(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 @@ -946,6 +964,8 @@ namespace ProjectM.Client { // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity); + bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph + uint until = windup.ValueRO.WindUpUntilTick; if (until == 0u && !lunging) continue; @@ -961,7 +981,7 @@ namespace ProjectM.Client 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); + 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 @@ -1003,7 +1023,13 @@ namespace ProjectM.Client } 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) + if (isBoss) + { + // 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().sharedMesh, Tuning.BossSlamRadius, 3.14159f, 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 diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs index a10dea7af..c47279482 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs @@ -67,6 +67,8 @@ namespace ProjectM.Client dt = dt, prevPos = _prevPos, seen = seen, + isLunging = SystemAPI.GetComponentLookup(true), + }; Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos // Prune stale entries (despawned Husks) AFTER the job, on the main thread. @@ -90,6 +92,8 @@ namespace ProjectM.Client partial struct EnemyDriveJob : IJobEntity { public FastAnimatorParameter moveX, moveZ, speed, isAttacking; + [Unity.Collections.ReadOnly] public ComponentLookup isLunging; // A7: a lunge has no AttackWindup; OR it in so the boss/Charger attack anim plays during the committed lunge + public float dt; public NativeParallelHashMap prevPos; public NativeParallelHashSet seen; @@ -111,7 +115,7 @@ namespace ProjectM.Client float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); - bool attacking = windup.WindUpUntilTick != 0; + bool attacking = windup.WindUpUntilTick != 0 || (isLunging.HasComponent(e) && isLunging.IsComponentEnabled(e)); // A7: the committed lunge (which zeroes AttackWindup) still animates as an attack var a = new AnimatorParametersAspect(parametersArr, indexTable); if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x); diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs index 7e1467e72..6258f9717 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs @@ -32,6 +32,7 @@ namespace ProjectM.Client public float LastHp; public float Flash; // 1 on a fresh hit, decays to 0 public bool Settled; // wrote the final white frame after a flash ended (skips per-frame writes at rest) + public bool IsPlayer; // C2: a player entry flashes PlayerHurtFlashColor (red), an enemy the white overdrive rest) } readonly Dictionary _tracked = new(); @@ -50,12 +51,12 @@ namespace ProjectM.Client _seen.Clear(); var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); foreach (var (health, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) + SystemAPI.Query>().WithAny().WithAll().WithEntityAccess()) { _seen.Add(entity); if (_tracked.ContainsKey(entity)) continue; - var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true }; + var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true, IsPlayer = EntityManager.HasComponent(entity) }; var leg = EntityManager.GetBuffer(entity); for (int i = 0; i < leg.Length; i++) { @@ -73,7 +74,9 @@ namespace ProjectM.Client // Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children. var bc = FeelConfig.BodyFlashColor; - float4 peak = new float4(bc.r, bc.g, bc.b, bc.a); + float4 enemyPeak = new float4(bc.r, bc.g, bc.b, bc.a); + var pc = FeelConfig.PlayerHurtFlashColor; + float4 playerPeak = new float4(pc.r, pc.g, pc.b, pc.a); float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec); foreach (var kv in _tracked) { @@ -82,7 +85,7 @@ namespace ProjectM.Client if (!_seen.Contains(entity)) continue; // despawned -> pruned below float cur = EntityManager.GetComponentData(entity).Current; - if (cur < entry.LastHp - 0.001f) { entry.Flash = 1f; entry.Settled = false; } + if (cur < entry.LastHp - 0.5f) { entry.Flash = 1f; entry.Settled = false; } entry.LastHp = cur; if (entry.Flash <= 0f) @@ -92,6 +95,7 @@ namespace ProjectM.Client } entry.Flash = math.max(0f, entry.Flash - decay); + float4 peak = entry.IsPlayer ? playerPeak : enemyPeak; WriteColor(entry, math.lerp(White, peak, entry.Flash)); } diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs index 4b7624a96..e47ed29df 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -140,6 +140,11 @@ namespace ProjectM.Client public static bool BodyFlashEnabled; /// Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back). public static Color BodyFlashColor; + + /// C2: player-body HURT flash tint (hot red-orange) — distinct from the enemy white overdrive so + /// 'you got hit' reads differently from 'you hit them'. Multiplies the Synty atlas base map like BodyFlashColor. + public static Color PlayerHurtFlashColor; + /// Seconds the body flash decays from peak back to the baked white base. public static float BodyFlashDurationSec; /// Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability). @@ -168,11 +173,11 @@ namespace ProjectM.Client HitStopDurationMs = 90f; HitStopFovKickMin = 0.6f; HitStopFovKickMax = 2.2f; - HitStopMaxFrames = 3; + HitStopMaxFrames = 2; // C4: a 2-frame (~33ms) camera hold reads as crunch, not a lag-y stutter HitStopRefDamage = 30f; HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f); HitFlashDurationMs = 80f; - HitStopFreezeEnabled = false; + HitStopFreezeEnabled = true; // C4: enable the finisher hit-stop hold (presentation-only camera freeze, bounded below) // Feature 1/2 death PlayerDeathShake = 0.50f; @@ -226,6 +231,7 @@ namespace ProjectM.Client // Deferred-items pass (2026-06) BodyFlashEnabled = true; BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map) + PlayerHurtFlashColor = new Color(2.6f, 0.55f, 0.4f, 1f); // C2: hot red-orange body flash when the player is hitmap) BodyFlashDurationSec = 0.16f; RemoteSwingEnabled = true; RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 13946db34..42ed00d97 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; +using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file) + using UnityEngine; using UnityEngine.UIElements; @@ -317,7 +319,7 @@ namespace ProjectM.Client int launchSecs = 0; bool terminal = SystemAPI.TryGetSingleton(out var readyOc) && readyOc.Value != RunOutcomeId.InProgress; - bool readyShow = haveRun && !terminal + bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */ && (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching); if (readyShow) { @@ -337,21 +339,28 @@ namespace ProjectM.Client } UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs); - // Boss presence bar — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live - // EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface. + // Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe) + // in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region + // siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the + // baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change). bool bossAlive = false; float bossHp = 0f, bossMax = 0f; if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) { - foreach (var bhq in SystemAPI.Query>().WithAll()) + float bossBakedMax = 0f; + foreach (var (bhq, tele, blt) in + SystemAPI.Query, RefRO, RefRO>().WithAll()) { - if (bhq.ValueRO.Max > bossMax) + if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers + if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy) + if (bhq.ValueRO.Max > bossBakedMax) { - bossMax = bhq.ValueRO.Max; + bossBakedMax = bhq.ValueRO.Max; bossHp = bhq.ValueRO.Current; bossAlive = bhq.ValueRO.Current > 0f; } } + bossMax = bossBakedMax * Tuning.BossHealthMultiplier; } UpdateBossBar(bossAlive, bossHp, bossMax); @@ -435,7 +444,7 @@ namespace ProjectM.Client // broken turret): a dry base during a siege tells the player to build a Fabricator. if (siege && charge == 0 && !onExpedition) { - _locationText.text = "TURRETS OUT OF CHARGE - build a Fabricator (Ore -> Charge)"; + _locationText.text = "TURRETS OUT OF AMMO - build a Fabricator (Ore -> ammo)"; _locationText.style.color = new Color(1f, 0.4f, 0.9f); } @@ -466,8 +475,16 @@ namespace ProjectM.Client } // First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own // location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay). - if (OnboardingState.Active) _locationText.text = ""; - // ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- + if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive + // D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the + // player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only). + if (haveRun && goalFull && !terminal && !siege && !finalSiege) + { + _locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!"; + _locationText.style.color = new Color(1f, 0.35f, 0.28f); + } + +// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- if (SystemAPI.TryGetSingleton(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) { bool win = runOutcome.Value == RunOutcomeId.Victory; diff --git a/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs b/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs index 31088e62f..25c494543 100644 --- a/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/PrototypeCameraRig.cs @@ -58,7 +58,13 @@ namespace ProjectM.Client s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants) } - [Header("Angle (degrees)")] + /// C4 hit-stop: hold the follow camera for a couple frames on a heavy hit (the combo finisher) so the + /// impact lands with a beat of crunch. Presentation only — NEVER Time.timeScale (the deterministic sim keeps ticking). + static int s_holdFrames; + public static void Hold(int frames) { if (frames > s_holdFrames) s_holdFrames = frames; } + + +[Header("Angle (degrees)")] [Range(10f, 89f)] public float Pitch = 45f; [Range(-180f, 180f)] public float Yaw = 0f; @@ -125,7 +131,9 @@ namespace ProjectM.Client Vector3 desired = target - (rot * Vector3.forward) * Distance; float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-FollowSharpness * Time.deltaTime); - Vector3 basePos = Vector3.Lerp(transform.position, desired, k); + Vector3 basePos; + if (s_holdFrames > 0) { s_holdFrames--; basePos = transform.position; } // C4: brief hit-stop hold (freeze the follow a couple frames) + else basePos = Vector3.Lerp(transform.position, desired, k); if (s_shake > 0.0001f) { basePos += UnityEngine.Random.insideUnitSphere * s_shake; diff --git a/Assets/_Project/Scripts/Simulation/Player/DashSystem.cs b/Assets/_Project/Scripts/Simulation/Player/DashSystem.cs index 2177322e5..61e6f0336 100644 --- a/Assets/_Project/Scripts/Simulation/Player/DashSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/DashSystem.cs @@ -61,7 +61,10 @@ namespace ProjectM.Simulation && new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); if (input.ValueRO.Dash.IsSet && ready && !inWindow) { - float2 dir = facing.ValueRO.Direction; + // C1: dash toward MOVEMENT input when moving (a panic 'dash away' while aiming at the threat now + // works), else toward facing (a stationary aim-and-dash). Pure fn of replicated input -> idempotent. + float2 mv = input.ValueRO.Move; + float2 dir = math.lengthsq(mv) > 1e-4f ? mv : facing.ValueRO.Direction; if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f); dir = math.normalize(dir); ds.ValueRW.Dir = dir;