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:
@@ -312,13 +312,25 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// 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,7 +475,15 @@ 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
|
||||||
|
// 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 ----
|
// ---- 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)")]
|
[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;
|
||||||
|
|||||||
Reference in New Issue
Block a user