diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index 66686750e..a0ee277f9 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -87,6 +87,8 @@ namespace ProjectM.Authoring // MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready. AddComponent(entity); AddComponent(entity, new DashCooldown { NextTick = 0 }); + // MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero. + AddComponent(entity); // Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive; // plus the server-only respawn timer. diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index efb04feab..51e17f7f2 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -94,6 +94,16 @@ namespace ProjectM.Client TuningRow("Chgr lunge t", TuningKnob.ChargerLungeDurationTicks, 1f, "0"); TuningRow("Chgr stagger t", TuningKnob.ChargerWhiffStaggerTicks, 1f, "0"); TuningRow("Grunt windup t", TuningKnob.GruntWindupTicks, 1f, "0"); + GUILayout.Space(4); + TuningRow("Melee dmg", TuningKnob.MeleeDamage, 1f, "0.0"); + TuningRow("Melee range", TuningKnob.MeleeRange, 0.2f, "0.0"); + TuningRow("Melee cone rad", TuningKnob.MeleeConeHalfAngleRad, 0.05f, "0.00"); + TuningRow("Melee recover t", TuningKnob.MeleeRecoverTicks, 1f, "0"); + TuningRow("Melee chain t", TuningKnob.MeleeChainGraceTicks, 1f, "0"); + TuningRow("Melee move x", TuningKnob.MeleeSwingMoveScale, 0.05f, "0.00"); + TuningRow("Melee knock spd", TuningKnob.MeleeKnockbackSpeed, 1f, "0.0"); + TuningRow("Melee finish x", TuningKnob.MeleeFinisherMult, 0.1f, "0.0"); + TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0"); } GUILayout.EndScrollView(); diff --git a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs index 52d03184c..a086a95ca 100644 --- a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs +++ b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs @@ -71,13 +71,16 @@ namespace ProjectM.Client // Movement is source-agnostic (WASD or left stick) — read from the merged action. float2 move = (float2)gameplay.Move.ReadValue(); - bool firePressed = gameplay.Fire.WasPressedThisFrame() && !BuildPaletteState.Active; // no fire while placing a build + // MC-4: melee (left-click / pad West) is the PRIMARY verb; ranged (right-click / pad left-trigger) the secondary poke - both read as direct device reads below (after the device locals). // --- Active-device detection: last meaningful actuation wins; hold last when idle --- 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; + // MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged projectile demoted to right-click / pad left-trigger. Both suppressed while placing a build (like dash/old fire). + bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame)) && !BuildPaletteState.Active; + bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active; float2 rightStick = float2.zero; bool gamepadActive = false; @@ -164,6 +167,9 @@ namespace ProjectM.Client input.ValueRW.Dash = default; if (dashPressed) input.ValueRW.Dash.Set(); + input.ValueRW.Attack = default; + if (attackPressed) + input.ValueRW.Attack.Set(); } } diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 75d1b3abc..f0dccc5dc 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -53,17 +53,21 @@ namespace ProjectM.Client ParticleSystem _deathFx; ParticleSystem _muzzleFx; ParticleSystem _dashFx; + ParticleSystem _swingFx; AudioClip _hitClip; AudioClip _deathClip; AudioClip _fireClip; AudioClip _telegraphClip; AudioClip _dashClip; + AudioClip _swingClip; Entity _localPlayer = Entity.Null; uint _lastLocalFireTick; bool _fireTickInit; uint _lastLocalDashTick; bool _dashTickInit; + uint _lastLocalSwingTick; + bool _swingTickInit; const int NumberPoolSize = 32; const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat @@ -77,6 +81,7 @@ namespace ProjectM.Client _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); + _swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false); } protected override void OnStartRunning() @@ -89,6 +94,7 @@ namespace ProjectM.Client _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); + _swingFx = MakeBurst("MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); for (int i = 0; i < NumberPoolSize; i++) _numbers.Add(CreateNumber()); @@ -112,6 +118,7 @@ namespace ProjectM.Client EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); // Resolve the local player (for hit colouring + fire feedback). _localPlayer = Entity.Null; @@ -242,6 +249,31 @@ namespace ProjectM.Client EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame); } + // Local-player melee swing feedback (MC-4): MeleeCombo.SwingStartTick advances once per swing (owner-predicted + // [GhostField]; raw uint edge like the muzzle/dash, cosmetic only). Whoosh + arc burst + a small camera + // nudge ahead of the player; the burst scales with the combo step so the finisher visibly pops. + if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer)) + { + var mc = EntityManager.GetComponentData(_localPlayer); + if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick) + { + int step = math.max(1, (int)mc.Step); + Vector3 face = Vector3.forward; + if (EntityManager.HasComponent(_localPlayer)) + { + var d = EntityManager.GetComponentData(_localPlayer).Direction; + if (math.lengthsq(d) > 1e-6f) face = new Vector3(d.x, 0f, d.y).normalized; + } + EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5); + PlayClip(_swingClip, (Vector3)localPos, 0.45f); + PrototypeCameraRig.AddShake(0.04f * step); + int comboLen = SystemAPI.TryGetSingleton(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3; + if (step >= comboLen) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); // finisher pop keyed off the live combo length (MC-4 review) + } + _lastLocalSwingTick = mc.SwingStartTick; + _swingTickInit = true; + } + UpdateProjectileTrails(cfg); PruneVfx(); AnimateNumbers(dt, cam); diff --git a/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs b/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs new file mode 100644 index 000000000..660bb709f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs @@ -0,0 +1,39 @@ +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic cone hit-test for the MC-4 melee cleave. Shares the EXACT range + bearing predicate with + /// (planar XZ distance gate + dot(bearing, facing) >= cos(halfAngle)) but as a + /// per-candidate boolean, so the cleave can collect ALL enemies in the cone — is a + /// single-winner reducer and cannot be reused for collect-all (MC-4 review REUSE-1). Burst-safe, allocation-free, + /// no wall-clock / no randomness; intended to run server-side inside the predicted MeleeComboSystem. + /// + public static class MeleeConeMath + { + /// + /// True iff lies within (planar XZ) of + /// AND its bearing from is within the cone half-angle of + /// ( = cos(halfAngle)). A coincident + /// (zero-distance) target is excluded (undefined bearing). must be + /// caller-normalized; a zero-length facing or a non-positive range returns false. + /// + public static bool InCone(float3 from, float2 facingDir, float range, float cosHalfAngle, float3 targetPos) + { + if (range <= 0f || math.lengthsq(facingDir) < 1e-6f) + return false; + + float3 offset = targetPos - from; + float2 planar = new float2(offset.x, offset.z); + float distSq = math.lengthsq(planar); + + if (distSq < 1e-6f) + return false; // coincident -> undefined bearing + if (distSq > range * range) + return false; // out of range + + float2 bearing = planar * math.rsqrt(distSq); // normalized planar bearing + return math.dot(bearing, facingDir) >= cosHalfAngle; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs.meta new file mode 100644 index 000000000..4037281e9 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/MeleeConeMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f2769f11116e304d8674e254ef9bd75 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index bd6f52203..41dd6f723 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -5,14 +5,14 @@ using Unity.NetCode; namespace ProjectM.Simulation { /// - /// MC-0 — live-tunable dash/Charger feel knobs (a per-world singleton). UNCONDITIONAL type (no #if) so the + /// MC-0 — live-tunable dash/Charger/melee feel knobs (a per-world singleton). UNCONDITIONAL type (no #if) so the /// reflection-built RpcCollection hash matches across release/dev peers — only the dev SYSTEMS that create, - /// mutate, broadcast and receive it are #if UNITY_EDITOR. Consumers (DashSystem, EnemyAISystem) read it - /// via TryGetSingleton and FALL BACK to when it is absent (release builds + - /// EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton exists. NOT a - /// [GhostField] (no ghost-hash change / re-bake); the server broadcasts it to clients via - /// so a PREDICTING client's DashSystem stays in sync (an MPPM thin client with no - /// overlay learns tuned values ONLY through this broadcast). + /// mutate, broadcast and receive it are #if UNITY_EDITOR. Consumers (DashSystem, EnemyAISystem, + /// MeleeComboSystem) read it via TryGetSingleton and FALL BACK to when it is absent + /// (release builds + EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton + /// exists. NOT a [GhostField] (no ghost-hash change / re-bake); the server broadcasts it to clients via + /// so a PREDICTING client's DashSystem/MeleeComboSystem stays in sync (an MPPM thin + /// client with no overlay learns tuned values ONLY through this broadcast). /// /// Values match the historical baked consts (DashSystem / EnemyAISystem / ); /// TuningConfigTests pins to them so the promote-to-singleton refactor is @@ -34,6 +34,18 @@ namespace ProjectM.Simulation public float ChargerWhiffStaggerTicks; public float GruntWindupTicks; + // MC-4 melee combo (the primary verb). The whole per-step SHAPE derives from these few LIVE scalars + one + // finisher multiplier (no per-step authored blob — keeps the combo fully live-tunable; MC-4 review F5/BURST-1). + public float MeleeDamage; + public float MeleeRange; + public float MeleeConeHalfAngleRad; + public float MeleeRecoverTicks; + public float MeleeChainGraceTicks; + public float MeleeSwingMoveScale; + public float MeleeKnockbackSpeed; + public float MeleeFinisherMult; + public float MeleeComboLength; + /// The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path. public static TuningConfig Defaults() => new TuningConfig { @@ -47,6 +59,15 @@ namespace ProjectM.Simulation ChargerLungeDurationTicks = 18f, ChargerWhiffStaggerTicks = 36f, GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it) + MeleeDamage = 18f, + MeleeRange = 2.6f, + MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc) + MeleeRecoverTicks = 16f, // light-swing lock/recovery (~0.27s) + MeleeChainGraceTicks = 18f, // window after the lock to chain the next hit (~0.3s) + MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1) + MeleeKnockbackSpeed = 6f, + MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback + MeleeComboLength = 3f, // light, light, finisher }; /// Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path @@ -59,6 +80,12 @@ namespace ProjectM.Simulation case TuningKnob.DashDistance: case TuningKnob.DashSharpness: case TuningKnob.ChargerLungeSpeed: + case TuningKnob.MeleeDamage: + case TuningKnob.MeleeRange: + case TuningKnob.MeleeConeHalfAngleRad: + case TuningKnob.MeleeSwingMoveScale: + case TuningKnob.MeleeKnockbackSpeed: + case TuningKnob.MeleeFinisherMult: return math.max(0f, value); // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem) default: @@ -82,6 +109,15 @@ namespace ProjectM.Simulation case TuningKnob.ChargerLungeDurationTicks: c.ChargerLungeDurationTicks = value; break; case TuningKnob.ChargerWhiffStaggerTicks: c.ChargerWhiffStaggerTicks = value; break; case TuningKnob.GruntWindupTicks: c.GruntWindupTicks = value; break; + case TuningKnob.MeleeDamage: c.MeleeDamage = value; break; + case TuningKnob.MeleeRange: c.MeleeRange = value; break; + case TuningKnob.MeleeConeHalfAngleRad: c.MeleeConeHalfAngleRad = value; break; + case TuningKnob.MeleeRecoverTicks: c.MeleeRecoverTicks = value; break; + case TuningKnob.MeleeChainGraceTicks: c.MeleeChainGraceTicks = value; break; + case TuningKnob.MeleeSwingMoveScale: c.MeleeSwingMoveScale = value; break; + case TuningKnob.MeleeKnockbackSpeed: c.MeleeKnockbackSpeed = value; break; + case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break; + case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) } } @@ -101,6 +137,15 @@ namespace ProjectM.Simulation case TuningKnob.ChargerLungeDurationTicks: return c.ChargerLungeDurationTicks; case TuningKnob.ChargerWhiffStaggerTicks: return c.ChargerWhiffStaggerTicks; case TuningKnob.GruntWindupTicks: return c.GruntWindupTicks; + case TuningKnob.MeleeDamage: return c.MeleeDamage; + case TuningKnob.MeleeRange: return c.MeleeRange; + case TuningKnob.MeleeConeHalfAngleRad: return c.MeleeConeHalfAngleRad; + case TuningKnob.MeleeRecoverTicks: return c.MeleeRecoverTicks; + case TuningKnob.MeleeChainGraceTicks: return c.MeleeChainGraceTicks; + case TuningKnob.MeleeSwingMoveScale: return c.MeleeSwingMoveScale; + case TuningKnob.MeleeKnockbackSpeed: return c.MeleeKnockbackSpeed; + case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult; + case TuningKnob.MeleeComboLength: return c.MeleeComboLength; default: return 0f; } } @@ -118,6 +163,15 @@ namespace ProjectM.Simulation ChargerLungeDurationTicks = c.ChargerLungeDurationTicks, ChargerWhiffStaggerTicks = c.ChargerWhiffStaggerTicks, GruntWindupTicks = c.GruntWindupTicks, + MeleeDamage = c.MeleeDamage, + MeleeRange = c.MeleeRange, + MeleeConeHalfAngleRad = c.MeleeConeHalfAngleRad, + MeleeRecoverTicks = c.MeleeRecoverTicks, + MeleeChainGraceTicks = c.MeleeChainGraceTicks, + MeleeSwingMoveScale = c.MeleeSwingMoveScale, + MeleeKnockbackSpeed = c.MeleeKnockbackSpeed, + MeleeFinisherMult = c.MeleeFinisherMult, + MeleeComboLength = c.MeleeComboLength, }; /// Reconstruct the full config from a wire snapshot (FULL state, not a delta). @@ -133,6 +187,15 @@ namespace ProjectM.Simulation ChargerLungeDurationTicks = r.ChargerLungeDurationTicks, ChargerWhiffStaggerTicks = r.ChargerWhiffStaggerTicks, GruntWindupTicks = r.GruntWindupTicks, + MeleeDamage = r.MeleeDamage, + MeleeRange = r.MeleeRange, + MeleeConeHalfAngleRad = r.MeleeConeHalfAngleRad, + MeleeRecoverTicks = r.MeleeRecoverTicks, + MeleeChainGraceTicks = r.MeleeChainGraceTicks, + MeleeSwingMoveScale = r.MeleeSwingMoveScale, + MeleeKnockbackSpeed = r.MeleeKnockbackSpeed, + MeleeFinisherMult = r.MeleeFinisherMult, + MeleeComboLength = r.MeleeComboLength, }; } @@ -149,9 +212,18 @@ namespace ProjectM.Simulation public const byte ChargerLungeDurationTicks = 7; public const byte ChargerWhiffStaggerTicks = 8; public const byte GruntWindupTicks = 9; + public const byte MeleeDamage = 10; + public const byte MeleeRange = 11; + public const byte MeleeConeHalfAngleRad = 12; + public const byte MeleeRecoverTicks = 13; + public const byte MeleeChainGraceTicks = 14; + public const byte MeleeSwingMoveScale = 15; + public const byte MeleeKnockbackSpeed = 16; + public const byte MeleeFinisherMult = 17; + public const byte MeleeComboLength = 18; /// Knob count (overlay iteration bound). - public const byte Count = 10; + public const byte Count = 19; } /// @@ -172,5 +244,14 @@ namespace ProjectM.Simulation public float ChargerLungeDurationTicks; public float ChargerWhiffStaggerTicks; public float GruntWindupTicks; + public float MeleeDamage; + public float MeleeRange; + public float MeleeConeHalfAngleRad; + public float MeleeRecoverTicks; + public float MeleeChainGraceTicks; + public float MeleeSwingMoveScale; + public float MeleeKnockbackSpeed; + public float MeleeFinisherMult; + public float MeleeComboLength; } } diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs new file mode 100644 index 000000000..156798d02 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs @@ -0,0 +1,32 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// MC-4 — predicted, owner-replicated melee combo state on the player (the PRIMARY offense verb). UNLIKE the dash + /// (DashState is non-replicated because a dash is a STATELESS-per-tick decision), the combo is + /// PATH-DEPENDENT — which chain link you are on depends on the sequence + timing of prior presses — and the bounded + /// input command buffer cannot reconstruct it across a reconnect / long rollback (MC-4 review PRED-1 / ROLLBACK-1). + /// So the minimal anchor is replicated as owner-predicted [GhostField]s: a rollback restores the + /// authoritative combo position, then MeleeComboSystem re-simulates forward with ABSOLUTE-WRITE windows (never an + /// in-place prev+1 of a non-restored field — the DashSystem idempotency idiom). The derived chain deadline + /// (LockUntilTick + grace) is NOT stored. All ticks routed through TickUtil.NonZero; compared via + /// only. Baked all-zero (idle). This is the player ghost's only net-new replicated melee + /// state (one re-bake; in-family with DashCooldown/AbilityCooldown). + /// + public struct MeleeCombo : IComponentData + { + /// Current/last swing index: 0 = idle, 1..N = chain link. Owner-predicted so a rollback restores the + /// authoritative combo position (the only legitimately path-dependent value). + [GhostField] public byte Step; + + /// Raw ServerTick the current swing started (NonZero). Inclusive lower bound of the movement-commit + /// window; also the juice edge signal and the (instant) damage frame. + [GhostField] public uint SwingStartTick; + + /// Raw ServerTick the swing's movement-commit / recovery lock ends (NonZero). Gates the next press + /// (locked while now < this) and anchors the chain window [LockUntilTick, LockUntilTick + grace). + [GhostField] public uint LockUntilTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs.meta b/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs.meta new file mode 100644 index 000000000..21ac6ae08 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeCombo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0dfbfd9f7db58e84e8bc59402cebaafb \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs new file mode 100644 index 000000000..8c927bbf5 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -0,0 +1,205 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Simulation +{ + /// One server-side cleave queued from a player's swing-start this tick, resolved after the player loop so + /// the living-enemy snapshot is gathered ONCE and ONLY when a swing actually fired (never on the client, never on an + /// idle tick). Blittable (Burst-friendly). + struct PendingCleave + { + public float3 From; + public float2 Face; + public float Damage; + public float Range; + public float KnockSpeed; + public int OwnerId; + public uint Stamp; + public uint KnockUntil; + } + + /// + /// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh + /// press (not locked, not mid-dash) it ADVANCES + /// (chain if re-pressed inside [LockUntilTick, LockUntilTick + grace), else reset to 1) and opens a movement-commit + /// lock; the finisher (Step == ComboLength) hits bigger. The Step/SwingStartTick/LockUntilTick anchor is + /// owner-replicated ( [GhostField]s) so a rollback restores the authoritative combo + /// position; every write is an ABSOLUTE function of (restored Step, tick) — re-running a tick re-derives identical + /// state (the DashSystem idempotency idiom; never an in-place prev+1 of a non-restored field — MC-4 review PRED-1). + /// + /// Runs in AFTER (it scales the + /// MoveVelocity that system wrote) and BEFORE (a dash OVERRIDES the swing's movement = + /// dash-cancel) — and so before HealthApplyDamageSystem ([UpdateAfter(DashSystem)]) which drains the cleave's + /// DamageEvent the same tick. Movement-commit re-applies every predicted pass, lower-bounded on SwingStartTick (a + /// re-simulated pre-swing tick must NOT inherit the scale — the DashSystem inDashWindow fix). DAMAGE is SERVER-ONLY + /// (enemies are interpolated ghosts; the client never predicts enemy health) and mirrors ProjectileDamageSystem: + /// queue each swing, then collect ALL living enemies in the per-step cone () ONCE, append + /// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with + /// NetworkTick only; feel knobs live in (MC-0, fallback to Defaults()). + /// + /// + [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] + [UpdateAfter(typeof(PlayerControlSystem))] + [UpdateBefore(typeof(DashSystem))] + [BurstCompile] + public partial struct MeleeComboSystem : ISystem + { + ComponentLookup m_KnockbackLookup; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + if (!SystemAPI.TryGetSingleton(out var netTime) || !netTime.ServerTick.IsValid) + return; + var serverTick = netTime.ServerTick; + uint now = serverTick.TickIndexForValidTick; + + var t = SystemAPI.TryGetSingleton(out var tc) ? tc : TuningConfig.Defaults(); + float baseDamage = math.max(0f, t.MeleeDamage); + float baseRange = math.max(0f, t.MeleeRange); + float cosHalf = math.cos(math.max(0f, t.MeleeConeHalfAngleRad)); + uint recoverTicks = (uint)math.max(1f, t.MeleeRecoverTicks); + uint graceTicks = (uint)math.max(1f, t.MeleeChainGraceTicks); + float moveScale = math.max(0f, t.MeleeSwingMoveScale); + float knockSpeed = math.max(0f, t.MeleeKnockbackSpeed); + float finisherMult = math.max(1f, t.MeleeFinisherMult); + byte comboLen = (byte)math.clamp((int)t.MeleeComboLength, 1, 3); + uint stamp = TickUtil.NonZero(now); + uint knockUntil = TickUtil.NonZero(now + (uint)math.max(1, Tuning.KnockbackDurationTicks)); + + bool isServer = state.WorldUnmanaged.IsServer(); + + // Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only + // when at least one swing actually started — no per-tick enemy gather on idle/client ticks). + var cleaves = isServer ? new NativeList(Allocator.Temp) : default; + + foreach (var (mc, control, input, facing, xform, owner, ds) in + SystemAPI.Query, RefRW, RefRO, + RefRO, RefRO, RefRO, RefRO>() + .WithAll().WithDisabled()) + { + // A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel). + bool dashActive = ds.ValueRO.StartTick != 0u + && !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick) + && ds.ValueRO.RecoverUntilTick != 0u + && new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); + + // Locked: mid swing / recovery (now < LockUntilTick). + bool locked = mc.ValueRO.LockUntilTick != 0u + && new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick); + + // --- ADVANCE (idempotent ABSOLUTE writes; dash wins same-tick ties via !Dash.IsSet) --- + bool swingStarted = false; + byte swingStep = 0; + if (input.ValueRO.Attack.IsSet && !locked && !dashActive && !input.ValueRO.Dash.IsSet) + { + bool inChainWindow = false; + if (mc.ValueRO.LockUntilTick != 0u) + { + var deadline = new NetworkTick(TickUtil.NonZero(mc.ValueRO.LockUntilTick + graceTicks)); + inChainWindow = deadline.IsValid && deadline.IsNewerThan(serverTick); // now < lock+grace (now >= lock since !locked) + } + byte prev = mc.ValueRO.Step; + swingStep = (inChainWindow && prev >= 1 && prev < comboLen) ? (byte)(prev + 1) : (byte)1; + + bool isFin = swingStep >= comboLen; + uint stepRecover = isFin ? (uint)math.max(1f, math.round(recoverTicks * finisherMult)) : recoverTicks; + + mc.ValueRW.Step = swingStep; + mc.ValueRW.SwingStartTick = TickUtil.NonZero(now); + mc.ValueRW.LockUntilTick = TickUtil.NonZero(now + stepRecover); + swingStarted = true; + } + + // --- MOVEMENT COMMIT (every pass; lower-bounded on SwingStartTick; DashSystem overrides later) --- + bool inSwingWindow = mc.ValueRO.SwingStartTick != 0u + && !new NetworkTick(mc.ValueRO.SwingStartTick).IsNewerThan(serverTick) + && mc.ValueRO.LockUntilTick != 0u + && new NetworkTick(mc.ValueRO.LockUntilTick).IsNewerThan(serverTick); + if (inSwingWindow && !dashActive) + control.ValueRW.MoveVelocity *= moveScale; + + // --- SERVER: queue the cleave (resolved after the loop). Damage/cone are SERVER-authoritative. --- + if (swingStarted && isServer) + { + bool isFin = swingStep >= comboLen; + float2 face = facing.ValueRO.Direction; + face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face); + cleaves.Add(new PendingCleave + { + From = xform.ValueRO.Position, + Face = face, + Damage = isFin ? baseDamage * finisherMult : baseDamage, + Range = isFin ? baseRange * finisherMult : baseRange, + KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed, + OwnerId = owner.ValueRO.NetworkId, + Stamp = stamp, + KnockUntil = knockUntil, + }); + } + } + + // Resolve queued cleaves SERVER-ONLY and only when one actually fired: gather living enemies ONCE, then + // append a SourceTick-stamped DamageEvent + stamp KnockbackState for each enemy in each swing's cone. + if (isServer && cleaves.IsCreated && cleaves.Length > 0) + { + var enemyEntities = new NativeList(Allocator.Temp); + var enemyPositions = new NativeList(Allocator.Temp); + foreach (var (xform, health, enemyEntity) in + SystemAPI.Query, RefRO>() + .WithAny() + .WithEntityAccess()) + { + if (health.ValueRO.Current <= 0f) + continue; // skip already-dead enemies (about to despawn) + enemyEntities.Add(enemyEntity); + enemyPositions.Add(xform.ValueRO.Position); + } + + m_KnockbackLookup.Update(ref state); + var ecb = new EntityCommandBuffer(Allocator.Temp); + for (int s = 0; s < cleaves.Length; s++) + { + var c = cleaves[s]; + for (int i = 0; i < enemyEntities.Length; i++) + { + if (!MeleeConeMath.InCone(c.From, c.Face, c.Range, cosHalf, enemyPositions[i])) + continue; + var target = enemyEntities[i]; + ecb.AppendToBuffer(target, new DamageEvent + { + Amount = c.Damage, + SourceNetworkId = c.OwnerId, + SourceTick = c.Stamp, + }); + if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target)) + { + float3 d3 = enemyPositions[i] - c.From; + float2 kdir = new float2(d3.x, d3.z); + kdir = math.lengthsq(kdir) > 1e-6f ? math.normalize(kdir) : c.Face; + m_KnockbackLookup[target] = new KnockbackState { Dir = kdir, Speed = c.KnockSpeed, UntilTick = c.KnockUntil }; + } + } + } + ecb.Playback(state.EntityManager); + ecb.Dispose(); + enemyEntities.Dispose(); + enemyPositions.Dispose(); + } + + if (cleaves.IsCreated) + cleaves.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs.meta b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs.meta new file mode 100644 index 000000000..dd4318a32 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 815ef85fd906e3640b787de314638027 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs index 00bab439e..9e4aee360 100644 --- a/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/PlayerDeathStateSystem.cs @@ -42,6 +42,9 @@ namespace ProjectM.Simulation // no stale i-frames / stuck-fast on respawn (DashSystem skips dead players via .WithDisabled()). if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(DashState)); + // MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn. + if (SystemAPI.HasComponent(entity)) + SystemAPI.SetComponent(entity, default(MeleeCombo)); if (SystemAPI.HasComponent(entity)) { var cc = SystemAPI.GetComponent(entity); diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs index 399e93b41..944050677 100644 --- a/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs +++ b/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs @@ -25,6 +25,10 @@ namespace ProjectM.Simulation /// so one press dashes exactly once; read by the predicted DashSystem (MC-1). [GhostField] public InputEvent Dash; + /// Melee combo attack (MC-4) - the player's PRIMARY verb. InputEvent twin of Fire/Dash: one press = + /// one swing attempt across the frame->tick->rollback boundary; read by the predicted MeleeComboSystem. + [GhostField] public InputEvent Attack; + /// Active input scheme this tick (: 0 = mouse/keyboard, 1 = gamepad). /// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left /// exact. A byte (not an enum): it is compared inside the Burst-compiled AbilityFireSystem. @@ -35,7 +39,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(';'); s.Append(Dash.Count); + s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count); s.Append(';'); s.Append(Attack.Count); return s; } }