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
@@ -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<AbilityRef>(_localPlayer)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var muzDb) && muzDb.Value.IsCreated)
{
ref var muzAdb = ref muzDb.Value.Value;
if (muzAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_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 // 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. // of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer)) if (_localPlayer != Entity.Null && EntityManager.HasComponent<AbilityCooldown>(_localPlayer))
{ {
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick; uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_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); Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
PlayClip(_fireClip, (Vector3)localPos, 0.5f); PlayClip(_fireClip, (Vector3)localPos, 0.5f);
@@ -398,7 +410,11 @@ namespace ProjectM.Client
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); 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; _lastLocalSwingTick = mc.SwingStartTick;
_swingTickInit = true; _swingTickInit = true;
@@ -938,6 +954,8 @@ namespace ProjectM.Client
if (_fxRoot == null || _dangerMat == null) return; if (_fxRoot == null || _dangerMat == null) return;
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default; Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
_dangerSeen.Clear(); _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) if (serverTick.IsValid)
{ {
foreach (var (xf, stats, windup, tele, entity) in 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). // 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 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; uint until = windup.ValueRO.WindUpUntilTick;
if (until == 0u && !lunging) continue; if (until == 0u && !lunging) continue;
@@ -961,7 +981,7 @@ namespace ProjectM.Client
int remaining = untilTick.TicksSince(serverTick); int remaining = untilTick.TicksSince(serverTick);
// Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for // 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). // 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); intensity = math.saturate(1f - remaining / windupDur);
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to // 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); 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 (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<MeshFilter>().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 // 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 // LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the
@@ -67,6 +67,8 @@ namespace ProjectM.Client
dt = dt, dt = dt,
prevPos = _prevPos, prevPos = _prevPos,
seen = seen, seen = seen,
isLunging = SystemAPI.GetComponentLookup<IsLunging>(true),
}; };
Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos
// Prune stale entries (despawned Husks) AFTER the job, on the main thread. // Prune stale entries (despawned Husks) AFTER the job, on the main thread.
@@ -90,6 +92,8 @@ namespace ProjectM.Client
partial struct EnemyDriveJob : IJobEntity partial struct EnemyDriveJob : IJobEntity
{ {
public FastAnimatorParameter moveX, moveZ, speed, isAttacking; public FastAnimatorParameter moveX, moveZ, speed, isAttacking;
[Unity.Collections.ReadOnly] public ComponentLookup<IsLunging> 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 float dt;
public NativeParallelHashMap<Entity, float3> prevPos; public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen; public NativeParallelHashSet<Entity> seen;
@@ -111,7 +115,7 @@ namespace ProjectM.Client
float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float2 facing = AnimParamMath.PlanarForward(xform.Rotation);
float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); 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); var a = new AnimatorParametersAspect(parametersArr, indexTable);
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x); if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
@@ -32,6 +32,7 @@ namespace ProjectM.Client
public float LastHp; public float LastHp;
public float Flash; // 1 on a fresh hit, decays to 0 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 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<Entity, FlashEntry> _tracked = new(); readonly Dictionary<Entity, FlashEntry> _tracked = new();
@@ -50,12 +51,12 @@ namespace ProjectM.Client
_seen.Clear(); _seen.Clear();
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
foreach (var (health, entity) in foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag, LinkedEntityGroup>().WithEntityAccess()) SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess())
{ {
_seen.Add(entity); _seen.Add(entity);
if (_tracked.ContainsKey(entity)) continue; 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<PlayerTag>(entity) };
var leg = EntityManager.GetBuffer<LinkedEntityGroup>(entity); var leg = EntityManager.GetBuffer<LinkedEntityGroup>(entity);
for (int i = 0; i < leg.Length; i++) 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. // Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
var bc = FeelConfig.BodyFlashColor; 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); float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec);
foreach (var kv in _tracked) foreach (var kv in _tracked)
{ {
@@ -82,7 +85,7 @@ namespace ProjectM.Client
if (!_seen.Contains(entity)) continue; // despawned -> pruned below if (!_seen.Contains(entity)) continue; // despawned -> pruned below
float cur = EntityManager.GetComponentData<Health>(entity).Current; float cur = EntityManager.GetComponentData<Health>(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; entry.LastHp = cur;
if (entry.Flash <= 0f) if (entry.Flash <= 0f)
@@ -92,6 +95,7 @@ namespace ProjectM.Client
} }
entry.Flash = math.max(0f, entry.Flash - decay); entry.Flash = math.max(0f, entry.Flash - decay);
float4 peak = entry.IsPlayer ? playerPeak : enemyPeak;
WriteColor(entry, math.lerp(White, peak, entry.Flash)); WriteColor(entry, math.lerp(White, peak, entry.Flash));
} }
@@ -140,6 +140,11 @@ namespace ProjectM.Client
public static bool BodyFlashEnabled; public static bool BodyFlashEnabled;
/// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary> /// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary>
public static Color BodyFlashColor; public static Color BodyFlashColor;
/// <summary>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.</summary>
public static Color PlayerHurtFlashColor;
/// <summary>Seconds the body flash decays from peak back to the baked white base.</summary> /// <summary>Seconds the body flash decays from peak back to the baked white base.</summary>
public static float BodyFlashDurationSec; public static float BodyFlashDurationSec;
/// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary> /// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary>
@@ -168,11 +173,11 @@ namespace ProjectM.Client
HitStopDurationMs = 90f; HitStopDurationMs = 90f;
HitStopFovKickMin = 0.6f; HitStopFovKickMin = 0.6f;
HitStopFovKickMax = 2.2f; HitStopFovKickMax = 2.2f;
HitStopMaxFrames = 3; HitStopMaxFrames = 2; // C4: a 2-frame (~33ms) camera hold reads as crunch, not a lag-y stutter
HitStopRefDamage = 30f; HitStopRefDamage = 30f;
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f); HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
HitFlashDurationMs = 80f; HitFlashDurationMs = 80f;
HitStopFreezeEnabled = false; HitStopFreezeEnabled = true; // C4: enable the finisher hit-stop hold (presentation-only camera freeze, bounded below)
// Feature 1/2 death // Feature 1/2 death
PlayerDeathShake = 0.50f; PlayerDeathShake = 0.50f;
@@ -226,6 +231,7 @@ namespace ProjectM.Client
// Deferred-items pass (2026-06) // Deferred-items pass (2026-06)
BodyFlashEnabled = true; BodyFlashEnabled = true;
BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map) 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; BodyFlashDurationSec = 0.16f;
RemoteSwingEnabled = true; RemoteSwingEnabled = true;
RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc
@@ -2,6 +2,8 @@ using System.Collections.Generic;
using ProjectM.Simulation; using ProjectM.Simulation;
using Unity.Entities; using Unity.Entities;
using Unity.NetCode; using Unity.NetCode;
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
using UnityEngine; using UnityEngine;
using UnityEngine.UIElements; using UnityEngine.UIElements;
@@ -317,7 +319,7 @@ namespace ProjectM.Client
int launchSecs = 0; int launchSecs = 0;
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc) bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
&& readyOc.Value != RunOutcomeId.InProgress; && 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); && (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
if (readyShow) if (readyShow)
{ {
@@ -337,21 +339,28 @@ namespace ProjectM.Client
} }
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs); UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
// Boss presence bar — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live // Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe)
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface. // 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; bool bossAlive = false;
float bossHp = 0f, bossMax = 0f; float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) 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; bossHp = bhq.ValueRO.Current;
bossAlive = bhq.ValueRO.Current > 0f; bossAlive = bhq.ValueRO.Current > 0f;
} }
} }
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
} }
UpdateBossBar(bossAlive, bossHp, bossMax); 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. // broken turret): a dry base during a siege tells the player to build a Fabricator.
if (siege && charge == 0 && !onExpedition) 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); _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 // 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). // location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
if (OnboardingState.Active) _locationText.text = ""; if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- // 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) if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{ {
bool win = runOutcome.Value == RunOutcomeId.Victory; bool win = runOutcome.Value == RunOutcomeId.Victory;
@@ -58,7 +58,13 @@ namespace ProjectM.Client
s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants) s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants)
} }
[Header("Angle (degrees)")] /// <summary>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).</summary>
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(10f, 89f)] public float Pitch = 45f;
[Range(-180f, 180f)] public float Yaw = 0f; [Range(-180f, 180f)] public float Yaw = 0f;
@@ -125,7 +131,9 @@ namespace ProjectM.Client
Vector3 desired = target - (rot * Vector3.forward) * Distance; Vector3 desired = target - (rot * Vector3.forward) * Distance;
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-FollowSharpness * Time.deltaTime); 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) if (s_shake > 0.0001f)
{ {
basePos += UnityEngine.Random.insideUnitSphere * s_shake; basePos += UnityEngine.Random.insideUnitSphere * s_shake;
@@ -61,7 +61,10 @@ namespace ProjectM.Simulation
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); && new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow) 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); if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir); dir = math.normalize(dir);
ds.ValueRW.Dir = dir; ds.ValueRW.Dir = dir;