Combat feel: correct boss bar, player hit-flash, dash-to-move, finisher hit-stop

Boss bar reconstructs the true max client-side (Health.Max unreplicated) via
Kind==Charger + expedition-pos filter; boss slam telegraph = a full ground ring
at BossSlamRadius; IsAttacking OR's IsLunging. Player body flash on hurt (distinct
red, jitter-safe threshold). Dash toward movement input when moving else facing.
Warrior cone: muzzle-cue suppressed (cone arc is its cue). Minimal finisher-only
hit-stop (PrototypeCameraRig.Hold, behind FeelConfig flag; never Time.timeScale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:21:32 -07:00
parent 3995af736c
commit 43d7cca2f8
7 changed files with 92 additions and 24 deletions
@@ -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<RunOutcome>(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<RefRO<Health>>().WithAll<EnemyTag>())
float bossBakedMax = 0f;
foreach (var (bhq, tele, blt) in
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{
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<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
bool win = runOutcome.Value == RunOutcomeId.Victory;