Combat: MC-4 combo-chain melee as the primary verb (DR-030)

Melee combo (left-click / pad-West) becomes the player's primary verb; the ranged projectile is demoted to right-click / pad-left-trigger. Predicted, owner-replicated combo Step (path-dependent -> [GhostField] anchor + absolute-write idempotency, NOT derived like the dash), server-only cleave mirroring ProjectileDamageSystem (SourceTick-stamped DamageEvent + KnockbackState), dash-cancellable movement-commit, 9 live TuningConfig knobs, and swing juice scaling with the combo step. The MC-6 archetype byte is deferred (the melee is its own verb). See DR-030.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:22:57 -07:00
parent 08f16b689f
commit 3409c53148
13 changed files with 430 additions and 10 deletions
@@ -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();
@@ -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<UnityEngine.Vector2>();
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();
}
}
@@ -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<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
// 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<MeleeCombo>(_localPlayer))
{
var mc = EntityManager.GetComponentData<MeleeCombo>(_localPlayer);
if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick)
{
int step = math.max(1, (int)mc.Step);
Vector3 face = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
{
var d = EntityManager.GetComponentData<PlayerFacing>(_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<TuningConfig>(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);