Vault Re-Alignment

This commit is contained in:
2026-06-09 23:26:20 -07:00
parent a7405c3f38
commit da522efe7a
63 changed files with 119048 additions and 15 deletions
@@ -0,0 +1,27 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-1 — marks a Husk prefab as a CHARGER variant. Compose this WITH <see cref="EnemyAuthoring"/> on the
/// prefab root (both bakers share the primary entity): EnemyAuthoring bakes the common Husk components and
/// Charger-tuned stats; this bakes the server-only <see cref="LungeState"/> (zeroed = not lunging).
/// Component-PRESENCE is the discriminator <c>EnemyAISystem</c> branches on — no enum/brain byte (the Burst
/// cross-assembly-enum hazard) — routing the Charger to the commit→lunge→whiff-stagger pass while the Grunt
/// pass excludes it via <c>.WithNone&lt;LungeState&gt;()</c>. NOT a <c>[GhostField]</c>: the lunged position
/// replicates via stock LocalTransform like every Husk.
/// </summary>
public class ChargerAuthoring : MonoBehaviour
{
private class ChargerBaker : Baker<ChargerAuthoring>
{
public override void Bake(ChargerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<LungeState>(entity);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9565191e0ea7fc94db934ae91a43a4cf
@@ -84,6 +84,9 @@ namespace ProjectM.Authoring
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddComponent<AbilityCooldown>(entity);
AddBuffer<DamageEvent>(entity);
// MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready.
AddComponent<DashState>(entity);
AddComponent(entity, new DashCooldown { NextTick = 0 });
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
// plus the server-only respawn timer.
@@ -62,6 +62,20 @@ namespace ProjectM.Client
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
GUILayout.EndHorizontal();
GUILayout.Space(6);
GUILayout.Label("- Telemetry (MC-0) -");
if (DevTelemetryReadout.HasData)
{
var t = DevTelemetryReadout.Latest;
GUILayout.Label($"tick {t.LastSampleTick} husks {t.LiveEnemyCount}");
GUILayout.Label($"dash neg {t.DashIFrameNegatedHits} / wasted {t.DashesWasted}");
GUILayout.Label($"whiff open {t.ChargerWhiffWindowsOpened} / punish {t.ChargerWhiffPunishesLanded}");
}
else
{
GUILayout.Label("(waiting for server telemetry...)");
}
GUILayout.EndArea();
}
@@ -0,0 +1,78 @@
#if UNITY_EDITOR
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// MC-0 — EDITOR-ONLY client receiver for the periodic <see cref="ProjectM.Simulation.DebugTelemetryReport"/>.
/// Drains the snapshot into <see cref="DevTelemetryReadout"/> (a plain static) so the IMGUI <c>DebugOverlay</c>
/// reads NO ECS state directly (the job-safety rule for presentation). Plain client
/// <see cref="SimulationSystemGroup"/>; non-Burst (touches a managed static).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTelemetryReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<ProjectM.Simulation.DebugTelemetryReport, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (report, reqEntity) in
SystemAPI.Query<RefRO<ProjectM.Simulation.DebugTelemetryReport>>()
.WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
{
var r = report.ValueRO;
DevTelemetryReadout.Latest = new DevTelemetryReadout.Snapshot
{
DashIFrameNegatedHits = r.DashIFrameNegatedHits,
DashesWasted = r.DashesWasted,
ChargerWhiffWindowsOpened = r.ChargerWhiffWindowsOpened,
ChargerWhiffPunishesLanded = r.ChargerWhiffPunishesLanded,
LiveEnemyCount = r.LiveEnemyCount,
LastSampleTick = r.LastSampleTick,
};
DevTelemetryReadout.HasData = true;
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
/// <summary>
/// MC-0 — static bridge from the ECS telemetry receiver to the IMGUI <c>DebugOverlay</c> (so the overlay reads
/// a plain struct, never ECS state). Reset on play-enter so a fast-enter-playmode reload can't show stale data.
/// </summary>
public static class DevTelemetryReadout
{
public struct Snapshot
{
public uint DashIFrameNegatedHits;
public uint DashesWasted;
public uint ChargerWhiffWindowsOpened;
public uint ChargerWhiffPunishesLanded;
public uint LiveEnemyCount;
public uint LastSampleTick;
}
public static Snapshot Latest;
public static bool HasData;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Reset()
{
Latest = default;
HasData = false;
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 685c34765eac3434aadf08c13fce1aa5
@@ -77,6 +77,7 @@ namespace ProjectM.Client
var gamepad = UnityEngine.InputSystem.Gamepad.current;
var mouse = UnityEngine.InputSystem.Mouse.current;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)) && !BuildPaletteState.Active;
float2 rightStick = float2.zero;
bool gamepadActive = false;
@@ -102,7 +103,7 @@ namespace ProjectM.Client
kbmActive =
keyboard.wKey.isPressed || keyboard.aKey.isPressed || keyboard.sKey.isPressed || keyboard.dKey.isPressed ||
keyboard.upArrowKey.isPressed || keyboard.downArrowKey.isPressed ||
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed;
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed || keyboard.leftShiftKey.isPressed;
}
if (gamepadActive && kbmActive)
@@ -160,6 +161,9 @@ namespace ProjectM.Client
input.ValueRW.Fire = default;
if (firePressed)
input.ValueRW.Fire.Set();
input.ValueRW.Dash = default;
if (dashPressed)
input.ValueRW.Dash.Set();
}
}
@@ -52,14 +52,18 @@ namespace ProjectM.Client
ParticleSystem _hitFx;
ParticleSystem _deathFx;
ParticleSystem _muzzleFx;
ParticleSystem _dashFx;
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
AudioClip _telegraphClip;
AudioClip _dashClip;
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
bool _fireTickInit;
uint _lastLocalDashTick;
bool _dashTickInit;
const int NumberPoolSize = 32;
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
@@ -72,6 +76,7 @@ namespace ProjectM.Client
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
}
protected override void OnStartRunning()
@@ -83,6 +88,7 @@ namespace ProjectM.Client
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
for (int i = 0; i < NumberPoolSize; i++)
_numbers.Add(CreateNumber());
@@ -104,6 +110,8 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
@@ -115,6 +123,17 @@ namespace ProjectM.Client
localPos = xf.ValueRO.Position;
}
// Client-derived dash window of the LOCAL player (DashSystem runs in the client prediction loop
// too): drives the i-frame shimmer + the hit-feedback suppression below. Observe-only.
bool localIFrameActive = false;
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashState>(_localPlayer)
&& SystemAPI.TryGetSingleton<NetworkTime>(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
{
var localDash = EntityManager.GetComponentData<DashState>(_localPlayer);
localIFrameActive = localDash.IFrameUntilTick != 0u
&& new NetworkTick(localDash.IFrameUntilTick).IsNewerThan(dashNetTime.ServerTick);
}
// Edge-detect Health on every damageable ghost (players + Husks).
_seen.Clear();
foreach (var (health, xf, entity) in
@@ -136,7 +155,9 @@ namespace ProjectM.Client
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
}
if (cur < prev.Hp - 0.001f)
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
if (cur < prev.Hp - 0.001f && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
@@ -201,6 +222,26 @@ namespace ProjectM.Client
_fireTickInit = true;
}
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
// (replicated [GhostField], predicted both sides; raw uint edge like the muzzle flash — cosmetic
// only). Whoosh + afterimage burst + camera punch on start, shimmer trail while i-frames last.
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashCooldown>(_localPlayer))
{
uint nextDash = EntityManager.GetComponentData<DashCooldown>(_localPlayer).NextTick;
if (_dashTickInit && nextDash != 0 && nextDash != _lastLocalDashTick)
{
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.6f, FeelConfig.DashBurstCount);
PlayClip(_dashClip, (Vector3)localPos, FeelConfig.DashSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.DashShake);
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick, FeelConfig.HitStopDurationMs);
}
_lastLocalDashTick = nextDash;
_dashTickInit = true;
if (localIFrameActive) // i-frame shimmer trail while the local window is active
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame);
}
UpdateProjectileTrails(cfg);
PruneVfx();
AnimateNumbers(dt, cam);
@@ -74,6 +74,21 @@ namespace ProjectM.Client
/// <summary>Tether line width (world units).</summary>
public static float LockOnLineWidth;
// ---- Feature 5 (MC-1): dash juice ----
/// <summary>Camera shake on the local player's dash start.</summary>
public static float DashShake;
/// <summary>Transient FOV punch (degrees) on dash start — the "lurch" read (camera punch, never Time.timeScale).</summary>
public static float DashFovKick;
/// <summary>Afterimage/whoosh particle burst count at dash start.</summary>
public static int DashBurstCount;
/// <summary>Dash whoosh SFX volume.</summary>
public static float DashSfxVolume;
/// <summary>Particles emitted per frame while the local i-frame window is active (the shimmer trail).</summary>
public static int DashShimmerPerFrame;
/// <summary>Suppress local hit-feedback during the local i-frame window (masks the prediction-reconciliation
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
public static bool DashHitSuppress;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
@@ -108,6 +123,14 @@ namespace ProjectM.Client
LockOnArcDegrees = 60f;
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
LockOnLineWidth = 0.05f;
// Feature 5 dash (MC-1)
DashShake = 0.18f;
DashFovKick = 1.2f;
DashBurstCount = 14;
DashSfxVolume = 0.55f;
DashShimmerPerFrame = 2;
DashHitSuppress = true;
}
}
}
@@ -37,6 +37,11 @@ namespace ProjectM.EditorTools
const string SyntyWerewolf = "Assets/Synty/PolygonWerewolf/Prefabs/Characters/SM_Chr_Werewolf_01.prefab";
const string SyntyKaiju = "Assets/Synty/PolygonKaiju/Prefabs/Characters/SM_Chr_Kaiju_01.prefab";
// MC-1 Charger (SciFi-City Muscle — verified Generic rig, distinct charging silhouette; see Synty_Asset_Inventory).
const string ChargerAtlas = "Assets/Synty/PolygonSciFiCity/Textures/Alts/PolygonScifi_01_A.png";
const string MatCharger = "Assets/_Project/Materials/M_Enemy_Charger_Animated.mat";
const string SyntyMuscle = "Assets/Synty/PolygonSciFiCity/Prefabs/Characters/SM_Chr_Muscle_Male_01.prefab";
struct Variant
{
public string Name, Template, Synty, Output, Material;
@@ -49,8 +54,12 @@ namespace ProjectM.EditorTools
new Variant { Name = "Grunt (Werewolf)", Template = "Assets/_Project/Prefabs/Enemy.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolf.prefab", Material = MatWerewolf, RootY = -1.25f, Scale = 0f },
new Variant { Name = "Swarmer (Werewolf Undead)", Template = "Assets/_Project/Prefabs/EnemySwarmer.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolfUndead.prefab", Material = MatWerewolfUndead, RootY = -1.67f, Scale = 0f },
new Variant { Name = "Brute (Kaiju)", Template = "Assets/_Project/Prefabs/EnemyBrute.prefab", Synty = SyntyKaiju, Output = "Assets/_Project/Prefabs/EnemyKaiju.prefab", Material = MatKaiju, RootY = -0.52f, Scale = 0f },
ChargerVariant(),
};
/// <summary>MC-1 Charger: SciFi-City Muscle silhouette via the standard DR-023 pipeline (the inventory's prescribed next-faction path). Template scale 1.0 -> RootY -1.0 (humanoid -1/scale rule); fine-tune feet in Play.</summary>
static Variant ChargerVariant() => new Variant { Name = "Charger (SciFi Muscle)", Template = "Assets/_Project/Prefabs/EnemyCharger.prefab", Synty = SyntyMuscle, Output = "Assets/_Project/Prefabs/EnemyChargerMuscle.prefab", Material = MatCharger, RootY = -1.00f, Scale = 0f };
[MenuItem("ProjectM/Animation/Enemy Rigs - Build All")]
public static void BuildAll()
{
@@ -60,6 +69,18 @@ namespace ProjectM.EditorTools
Debug.Log("[EnemyRigTools] Build All complete. Re-point WaveDirector.EnemyPrefabs[] at the new prefabs and re-bake the gameplay subscene.");
}
/// <summary>MC-1: build ONLY the Charger material + prefab (leaves the committed Werewolf/Kaiju outputs untouched).</summary>
[MenuItem("ProjectM/Animation/Enemy Rigs - Build Charger (MC-1)")]
public static void BuildCharger()
{
MakeMat(MatCharger, ChargerAtlas, new Color(1f, 0.42f, 0.36f, 1f)); // red-shifted SciFi atlas = danger read
AssetDatabase.SaveAssets();
BuildOne(ChargerVariant());
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[EnemyRigTools] Charger built -> EnemyChargerMuscle.prefab; add it to WaveDirector.EnemyPrefabs[] in the gameplay subscene.");
}
// ---- 1. Materials ------------------------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 1 Build Materials")]
@@ -96,6 +96,7 @@ namespace ProjectM.Server
{
Amount = turret.ValueRO.Damage,
SourceNetworkId = -1,
SourceTick = TickUtil.NonZero(now),
});
uint cd = (uint)math.max(1, turret.ValueRO.CooldownTicks);
ps.ValueRW.NextTick = TickUtil.NonZero(now + cd);
@@ -71,7 +71,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
.WithAll<EnemyTag>())
.WithAll<EnemyTag>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
@@ -145,6 +145,7 @@ namespace ProjectM.Server
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
SourceTick = TickUtil.NonZero(now),
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
@@ -170,6 +171,138 @@ namespace ProjectM.Server
}
}
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
const float ChargerLungeSpeed = 16f; // units/s while lunging
const uint ChargerLungeDurationTicks = 18; // ~0.30 s of committed travel
const uint ChargerWindupTicks = 30; // ~0.50 s readable telegraph (>= interp delay + reaction)
const uint ChargerWhiffStaggerTicks = 36; // ~0.60 s punish window on a whiff
uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
lunge.ValueRW.UntilTick = 0;
continue;
}
knockback.ValueRW.UntilTick = 0;
}
// Nearest living player (reuse the snapshot taken above).
int cbest = -1; float cbestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float2 dd = playerPositions[i].xz - pos.xz;
float sq = math.lengthsq(dd);
if (sq < cbestSq) { cbestSq = sq; cbest = i; }
}
float3 cTargetPos = playerPositions[cbest];
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
var lg = lunge.ValueRO;
if (lg.UntilTick != 0)
{
var lgTick = new NetworkTick(lg.UntilTick);
if (lgTick.IsValid && lgTick.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lg.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up());
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
{
ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1,
SourceTick = TickUtil.NonZero(now),
});
uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks);
lunge.ValueRW.UntilTick = 0; // landed -> end the lunge
}
else
{
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window)
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
}
continue; // committed this tick
}
// Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick.
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
lunge.ValueRW.UntilTick = 0;
chargerWhiffsThisTick++;
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
}
// 3. Seek + face (shared shape with the Grunt path).
float cStop = stats.ValueRO.AttackRange * 0.9f;
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = cNewPos;
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
if (math.lengthsq(cToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
// 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the
// whole point is the commit lands even if the player dodged out of range (the punishable tell).
uint cWindRaw = windup.ValueRO.WindUpUntilTick;
if (cWindRaw != 0)
{
var cWindTick = new NetworkTick(cWindRaw);
if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick)))
{
float3 toT = cTargetPos - pos; toT.y = 0f;
float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f);
lunge.ValueRW.Dir = ldir;
lunge.ValueRW.Speed = ChargerLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
else
{
bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange);
if (cInRange)
{
bool cReady = cooldown.ValueRO.NextAttackTick == 0
|| !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick);
if (cReady)
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks);
}
}
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -25,6 +25,12 @@ namespace ProjectM.Server
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(ProjectileDamageSystem))]
// Pin the drain AFTER DashSystem: a same-tick player-sourced projectile (ProjectileDamageSystem stamps
// SourceTick = now and this system drains the SAME tick) must see a dash window STARTED this tick —
// without the edge the negation at src == StartTick is an unconstrained sorter tiebreak. The Dash chain
// (StatRecompute→PlayerControl→Dash) and the projectile chain (PlayerAim→AbilityFire→ProjectileMove→
// ProjectileDamage→here) are otherwise disjoint, so this edge cannot form a cycle (Play-validated).
[UpdateAfter(typeof(DashSystem))]
[BurstCompile]
public partial struct HealthApplyDamageSystem : ISystem
{
@@ -33,6 +39,8 @@ namespace ProjectM.Server
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime);
uint negatedThisTick = 0;
uint punishesThisTick = 0;
foreach (var (health, dmg, entity) in
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
@@ -63,10 +71,55 @@ namespace ProjectM.Server
}
}
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
uint negatedForThisEntity = 0u;
float total = 0f;
for (int i = 0; i < dmg.Length; i++)
{
uint src = dmg[i].SourceTick;
if (hasDash && src != 0u && ds.IFrameUntilTick != 0u)
{
var srcTick = new NetworkTick(src);
var startTick = new NetworkTick(ds.StartTick);
var untilTick = new NetworkTick(ds.IFrameUntilTick);
// Dash i-frames cover the HALF-OPEN window [StartTick, IFrameUntilTick): negate iff src >= start AND src < until.
bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick);
bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick);
if (atOrAfterStart && beforeUntil)
{
negatedThisTick++;
negatedForThisEntity++;
continue; // dash i-frame negates this hit (per-element, not a whole-buffer clear)
}
}
total += dmg[i].Amount;
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
if (isCharger && dmg[i].SourceNetworkId >= 0)
{
var lunge = SystemAPI.GetComponent<LungeState>(entity);
if (lunge.StaggerUntilTick != 0u)
{
var stag = new NetworkTick(lunge.StaggerUntilTick);
if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick))
{
punishesThisTick++;
lunge.StaggerUntilTick = 0u;
SystemAPI.SetComponent(entity, lunge);
}
}
}
}
dmg.Clear();
if (negatedForThisEntity != 0u)
{
ds.NegatedCount += negatedForThisEntity; // server-side spam signal; DashSystem reads it at window-close
SystemAPI.SetComponent(entity, ds);
}
float newHp = health.ValueRO.Current - total;
@@ -83,6 +136,12 @@ namespace ProjectM.Server
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity)))
ecb.DestroyEntity(entity);
}
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
{
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
telem.ValueRW.DashIFrameNegatedHits += negatedThisTick;
telem.ValueRW.ChargerWhiffPunishesLanded += punishesThisTick;
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
@@ -130,6 +130,7 @@ namespace ProjectM.Server
{
Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId,
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
@@ -0,0 +1,69 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// MC-0 — EDITOR-ONLY server telemetry sampler/sender. Ensures the <see cref="DevTelemetry"/> singleton,
/// samples live-enemy-count + the server tick each tick, and every <see cref="ReportPeriodTicks"/> ships a
/// <see cref="DebugTelemetryReport"/> snapshot to every connection (so the dev overlay shows live fun-gate
/// counters over a real connection too). Combat systems increment the real counters at the stamp sites (MC-1+).
/// Plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple,
/// editor-only). Stripped from builds; the wire TYPE <see cref="DebugTelemetryReport"/> is unconditional.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTelemetrySystem : ISystem
{
const uint ReportPeriodTicks = 15;
EntityQuery m_Husks;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
state.EntityManager.CreateEntity(typeof(DevTelemetry));
}
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
telem.ValueRW.LiveEnemyCount = (uint)m_Husks.CalculateEntityCount();
telem.ValueRW.LastSampleTick = now;
if (now == 0 || (now % ReportPeriodTicks) != 0)
return;
var t = telem.ValueRO;
var report = new DebugTelemetryReport
{
DashIFrameNegatedHits = t.DashIFrameNegatedHits,
DashesWasted = t.DashesWasted,
ChargerWhiffWindowsOpened = t.ChargerWhiffWindowsOpened,
ChargerWhiffPunishesLanded = t.ChargerWhiffPunishesLanded,
LiveEnemyCount = t.LiveEnemyCount,
LastSampleTick = t.LastSampleTick,
};
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (netId, connEnt) in SystemAPI.Query<RefRO<NetworkId>>().WithEntityAccess())
{
var req = ecb.CreateEntity();
ecb.AddComponent(req, report);
ecb.AddComponent(req, new SendRpcCommandRequest { TargetConnection = connEnt });
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef1e1f5e7e01b77489dcb181652176a0
@@ -16,5 +16,11 @@ namespace ProjectM.Simulation
/// <summary>NetworkId of the firing player that caused this hit (attribution / self-hit filtering upstream).</summary>
public int SourceNetworkId;
/// <summary>Raw ServerTick at which this hit logically LANDS (the appending tick), stamped via
/// <c>TickUtil.NonZero</c> at every append site (0 = unstamped). The dash i-frame negation compares it
/// against the dashing player's <c>DashState</c> window, so a strike appended a tick before it is
/// drained is judged against the tick it was AUTHORED, not the tick it was applied.</summary>
public uint SourceTick;
}
}
@@ -0,0 +1,33 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger
/// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted):
/// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch
/// (which excludes these via <c>.WithNone&lt;LungeState&gt;()</c>). On a wind-up commit the Charger LOCKS
/// <see cref="Dir"/> toward the target and travels at <see cref="Speed"/> until <see cref="UntilTick"/> — dealing
/// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot).
/// NOT a <c>[GhostField]</c> (the lunged position replicates via the stock LocalTransform variant, like
/// KnockbackState). All ticks via <c>TickUtil.NonZero</c>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
/// </summary>
public struct LungeState : IComponentData
{
/// <summary>Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y).</summary>
public float2 Dir;
/// <summary>Lunge speed (world units/s); only meaningful while <see cref="UntilTick"/> is active.</summary>
public float Speed;
/// <summary>Raw tick the lunge ends (NonZero). <c>0</c> = not lunging. Active while .IsNewerThan(serverTick).</summary>
public uint UntilTick;
/// <summary>Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not
/// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit
/// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
public uint StaggerUntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc65446b98bef1040bc5b9beaac094ba
@@ -0,0 +1,49 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-0 — server-only dev-telemetry accumulator (a singleton). Counters are incremented at the
/// combat stamp sites (wired in MC-1+) so the fun-gate is MEASURED, not argued. NOT a
/// <c>[GhostField]</c> (no ghost-hash change); shipped to dev clients via a periodic
/// <see cref="DebugTelemetryReport"/> RPC. The component type is unconditional (stable across
/// release/dev peers); only the dev send/sample/receive SYSTEMS are <c>#if UNITY_EDITOR</c>.
/// </summary>
public struct DevTelemetry : IComponentData
{
/// <summary>Hits a dash i-frame window negated (incremented in HealthApplyDamageSystem, MC-1).</summary>
public uint DashIFrameNegatedHits;
/// <summary>Dashes whose i-frame window negated nothing (spam signal, MC-1).</summary>
public uint DashesWasted;
/// <summary>Charger lunges that whiffed and opened a punish window (EnemyAISystem, MC-1).</summary>
public uint ChargerWhiffWindowsOpened;
/// <summary>Of those, the ones the player actually punished (MC-1).</summary>
public uint ChargerWhiffPunishesLanded;
/// <summary>Living Husks, sampled each report — proof-of-life (changes during play, no MC-1 dep).</summary>
public uint LiveEnemyCount;
/// <summary>Server tick at the last sample — proof-of-life that the pipe is live.</summary>
public uint LastSampleTick;
}
/// <summary>
/// MC-0 — server → dev-client telemetry snapshot (sent periodically by the editor-only sampler).
/// <b>Unconditional wire type</b> (like <see cref="DebugCommandRequest"/>) so the reflection-built
/// RpcCollection hash matches across release/dev peers; only the send/receive SYSTEMS are
/// <c>#if UNITY_EDITOR</c>. The dev overlay reads the latest snapshot to show live fun-gate counters.
/// </summary>
public struct DebugTelemetryReport : IRpcCommand
{
public uint DashIFrameNegatedHits;
public uint DashesWasted;
public uint ChargerWhiffWindowsOpened;
public uint ChargerWhiffPunishesLanded;
public uint LiveEnemyCount;
public uint LastSampleTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6c5dfccd35c016940914a8357204f4e8
@@ -0,0 +1,19 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — predicted per-player dash cooldown gate (an <c>AbilityCooldown</c> twin). <c>[GhostField]</c> so the
/// owning client does not mispredict the cooldown across rollback / reconnect: re-predicted ticks see the same
/// authoritative gate the server applied and converge without a double-dash. <c>0</c> = ready; set to
/// <c>serverTick + dashCooldownTicks</c> via <c>TickUtil.NonZero</c> on dash-start; compare by wrapping into a
/// <see cref="NetworkTick"/> and using <see cref="NetworkTick.IsNewerThan"/> (raw uint subtraction is unsafe
/// across tick wraparound). Baked <c>{NextTick = 0}</c>.
/// </summary>
public struct DashCooldown : IComponentData
{
/// <summary>Raw tick of the earliest tick the player may dash again. <c>0</c> = ready.</summary>
[GhostField] public uint NextTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6f32a690284a3a47bf02829323ed8f5
@@ -0,0 +1,33 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — predicted, NON-replicated dash window on the owner-predicted player. A SHAPE-clone of
/// <c>KnockbackState</c>, but UNLIKE KnockbackState it lives on a PREDICTED player and is re-simulated from
/// the replicated <see cref="PlayerInput.Dash"/> InputEvent every predicted tick — so it is authoritative on
/// the server at the tick <c>HealthApplyDamageSystem</c> drains damage, even for a melee strike appended a
/// tick earlier in the plain group. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="Unity.NetCode.NetworkTick"/> only (never raw uint). NOT a <c>[GhostField]</c> (no player-ghost
/// re-bake). Baked all-zero (idle).
/// </summary>
public struct DashState : IComponentData
{
/// <summary>Planar XZ dash heading, captured at dash-start.</summary>
public float2 Dir;
/// <summary>Raw ServerTick at dash-start (NonZero-coerced). Lower (inclusive) bound of the i-frame window.</summary>
public uint StartTick;
/// <summary>StartTick + i-frame window (NonZero). I-frames cover the HALF-OPEN range [StartTick, IFrameUntilTick).</summary>
public uint IFrameUntilTick;
/// <summary>IFrameUntilTick + recovery tail (NonZero). Movement-lock tail (no i-frames) so a panic-dash is punishable.</summary>
public uint RecoverUntilTick;
/// <summary>Hits negated by THIS dash's i-frame window. SERVER-written (HealthApplyDamageSystem);
/// 0 at window-close = a wasted dash (DevTelemetry.DashesWasted spam signal). The client copy stays 0.</summary>
public uint NegatedCount;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28b7317dff9952841a0fb4b66df54f90
@@ -0,0 +1,113 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — the predicted dodge dash. On a fresh <see cref="PlayerInput.Dash"/> press (cooldown ready and not
/// already mid-dash) it captures the dash heading from <see cref="PlayerFacing"/> and opens a HALF-OPEN i-frame
/// window [StartTick, IFrameUntilTick) plus a recovery tail. While the i-frame window is active it OVERRIDES
/// <see cref="CharacterControl.MoveVelocity"/> with the dash velocity and raises
/// <see cref="CharacterComponent.GroundedMovementSharpness"/> to ~200 so the move reads as a BLINK (the CC
/// processor lerps RelativeVelocity toward MoveVelocity at that sharpness — no CharacterProcessor edit needed).
/// During the recovery tail movement is locked to zero (no i-frames) so a panic-dash is punishable.
/// <see cref="ProjectM.Server"/>'s HealthApplyDamageSystem reads the window to negate hits authored inside it.
/// <para>
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it overrides
/// the input-derived MoveVelocity that system wrote this tick) and is gated
/// <c>.WithAll&lt;Simulate&gt;().WithDisabled&lt;Dead&gt;()</c>. The START is an idempotent pure function of
/// replicated input + tick (no IsFirstTimeFullyPredictingTick guard); the OVERRIDE re-applies on EVERY predicted
/// pass so rollback re-simulation converges. All ticks routed through <c>TickUtil.NonZero</c>; compared via
/// <see cref="NetworkTick"/> only. DashSystem owns GroundedMovementSharpness on the player (base = the CC default
/// 15); PlayerDeathStateSystem restores it + clears the window on death.
/// </para>
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerControlSystem))]
[BurstCompile]
public partial struct DashSystem : ISystem
{
// Baked-first feel knobs (MC-1; promote to a live TuningConfig later). Sim runs at 60 ticks/sec.
const float DashDistance = 4.0f; // world units covered during the i-frame window
const uint IFrameWindowTicks = 12; // ~0.20 s of i-frames
const uint RecoverTailTicks = 9; // ~0.15 s movement-locked tail (punishes spam)
const uint DashCooldownTicks = 45; // ~0.75 s
const float DashSharpness = 200f; // GroundedMovementSharpness during the dash -> blink
const float DefaultSharpness = 15f; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f;
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate);
foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
.WithAll<Simulate>().WithDisabled<Dead>())
{
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
bool ready = cd.ValueRO.NextTick == 0u
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
{
float2 dir = facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
ds.ValueRW.Dir = dir;
ds.ValueRW.StartTick = TickUtil.NonZero(now);
ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + IFrameWindowTicks);
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + IFrameWindowTicks + RecoverTailTicks);
cd.ValueRW.NextTick = TickUtil.NonZero(now + DashCooldownTicks);
}
// --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) ---
// The lower bound matters: DashState is non-replicated, so prediction rollback does NOT restore
// it — a re-simulated PRE-dash tick (serverTick < StartTick) still sees the post-press window and,
// gated on the upper bound alone, would stomp dash velocity onto ticks that never had it
// (dash-start overshoot under real latency). Membership = the half-open [StartTick, …) test.
bool inDashWindow = ds.ValueRO.StartTick != 0u
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick);
bool iFrameActive = inDashWindow && ds.ValueRO.IFrameUntilTick != 0u
&& new NetworkTick(ds.ValueRO.IFrameUntilTick).IsNewerThan(serverTick);
bool recoverActive = inDashWindow && ds.ValueRO.RecoverUntilTick != 0u
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (iFrameActive)
{
float2 d = ds.ValueRO.Dir;
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
character.ValueRW.GroundedMovementSharpness = DashSharpness;
}
else if (recoverActive)
{
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
}
else
{
if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness)
character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // restore after the dash
// Window-close edge: score a wasted dash (negated nothing) ONCE, then clear the window.
// SERVER-only — the DevTelemetry singleton exists only in the (editor) server world; the
// client keeps its copy un-zeroed so rollback re-simulation of the tail stays intact. All
// in-window strikes drain >= 9 ticks before this edge, so clearing can't eat a negation.
if (ds.ValueRO.RecoverUntilTick != 0u && SystemAPI.TryGetSingletonRW<DevTelemetry>(out var telem))
{
if (ds.ValueRO.NegatedCount == 0u)
telem.ValueRW.DashesWasted++;
ds.ValueRW = default;
}
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58536af899e5e9442b81c594c17bc034
@@ -27,15 +27,28 @@ namespace ProjectM.Simulation
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (health, control, deadEnabled) in
foreach (var (health, control, deadEnabled, entity) in
SystemAPI.Query<RefRO<Health>, RefRW<CharacterControl>, EnabledRefRW<Dead>>()
.WithAll<PlayerTag, Simulate>()
.WithPresent<Dead>())
.WithPresent<Dead>()
.WithEntityAccess())
{
bool isDead = health.ValueRO.Current <= 0f;
deadEnabled.ValueRW = isDead;
if (isDead)
{
control.ValueRW.MoveVelocity = float3.zero;
// MC-1: clear any in-flight dash window + restore base sharpness so a death mid-dash leaves
// no stale i-frames / stuck-fast on respawn (DashSystem skips dead players via .WithDisabled<Dead>()).
if (SystemAPI.HasComponent<DashState>(entity))
SystemAPI.SetComponent(entity, default(DashState));
if (SystemAPI.HasComponent<CharacterComponent>(entity))
{
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
cc.GroundedMovementSharpness = 15f;
SystemAPI.SetComponent(entity, cc);
}
}
}
}
}
@@ -21,6 +21,9 @@ namespace ProjectM.Simulation
/// <summary>Primary ability fire. InputEvent survives the frame→tick→rollback boundary so a press fires exactly once.</summary>
[GhostField] public InputEvent Fire;
/// <summary>Dodge dash. InputEvent twin of <see cref="Fire"/>: survives the frame-tick-rollback boundary
/// so one press dashes exactly once; read by the predicted DashSystem (MC-1).</summary>
[GhostField] public InputEvent Dash;
/// <summary>Active input scheme this tick (<see cref="InputSchemeId"/>: 0 = mouse/keyboard, 1 = gamepad).
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
@@ -32,7 +35,7 @@ namespace ProjectM.Simulation
var s = new FixedString512Bytes();
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme);
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count);
return s;
}
}