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:
@@ -87,6 +87,8 @@ namespace ProjectM.Authoring
|
||||
// MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready.
|
||||
AddComponent<DashState>(entity);
|
||||
AddComponent(entity, new DashCooldown { NextTick = 0 });
|
||||
// MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero.
|
||||
AddComponent<MeleeCombo>(entity);
|
||||
|
||||
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
|
||||
// plus the server-only respawn timer.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic cone hit-test for the MC-4 melee cleave. Shares the EXACT range + bearing predicate with
|
||||
/// <see cref="AutoTarget"/> (planar XZ distance gate + <c>dot(bearing, facing) >= cos(halfAngle)</c>) but as a
|
||||
/// per-candidate boolean, so the cleave can collect ALL enemies in the cone — <see cref="AutoTarget.Resolve"/> 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 <c>MeleeComboSystem</c>.
|
||||
/// </summary>
|
||||
public static class MeleeConeMath
|
||||
{
|
||||
/// <summary>
|
||||
/// True iff <paramref name="targetPos"/> lies within <paramref name="range"/> (planar XZ) of
|
||||
/// <paramref name="from"/> AND its bearing from <paramref name="from"/> is within the cone half-angle of
|
||||
/// <paramref name="facingDir"/> (<paramref name="cosHalfAngle"/> = cos(halfAngle)). A coincident
|
||||
/// (zero-distance) target is excluded (undefined bearing). <paramref name="facingDir"/> must be
|
||||
/// caller-normalized; a zero-length facing or a non-positive range returns false.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f2769f11116e304d8674e254ef9bd75
|
||||
@@ -5,14 +5,14 @@ using Unity.NetCode;
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>#if UNITY_EDITOR</c>. Consumers (DashSystem, EnemyAISystem) read it
|
||||
/// via <c>TryGetSingleton</c> and FALL BACK to <see cref="Defaults"/> when it is absent (release builds +
|
||||
/// EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton exists. NOT a
|
||||
/// <c>[GhostField]</c> (no ghost-hash change / re-bake); the server broadcasts it to clients via
|
||||
/// <see cref="DebugTuningReport"/> 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 <c>#if UNITY_EDITOR</c>. Consumers (DashSystem, EnemyAISystem,
|
||||
/// MeleeComboSystem) read it via <c>TryGetSingleton</c> and FALL BACK to <see cref="Defaults"/> when it is absent
|
||||
/// (release builds + EditMode worlds), so behaviour is identical to the old baked consts when no dev singleton
|
||||
/// exists. NOT a <c>[GhostField]</c> (no ghost-hash change / re-bake); the server broadcasts it to clients via
|
||||
/// <see cref="DebugTuningReport"/> so a PREDICTING client's DashSystem/MeleeComboSystem stays in sync (an MPPM thin
|
||||
/// client with no overlay learns tuned values ONLY through this broadcast).
|
||||
/// <para>
|
||||
/// Values match the historical baked consts (DashSystem / EnemyAISystem / <see cref="Tuning.AttackWindupTicks"/>);
|
||||
/// <c>TuningConfigTests</c> pins <see cref="Defaults"/> 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;
|
||||
|
||||
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>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,
|
||||
};
|
||||
|
||||
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||
public const byte Count = 10;
|
||||
public const byte Count = 19;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="Step"/> 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 <c>[GhostField]</c>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 <c>TickUtil.NonZero</c>; compared via
|
||||
/// <see cref="NetworkTick"/> 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).
|
||||
/// </summary>
|
||||
public struct MeleeCombo : IComponentData
|
||||
{
|
||||
/// <summary>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).</summary>
|
||||
[GhostField] public byte Step;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[GhostField] public uint SwingStartTick;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[GhostField] public uint LockUntilTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0dfbfd9f7db58e84e8bc59402cebaafb
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-4 — the predicted melee combo (Hades-style 2-3 hit chain; the player's PRIMARY verb). On a fresh
|
||||
/// <see cref="PlayerInput.Attack"/> press (not locked, not mid-dash) it ADVANCES <see cref="MeleeCombo.Step"/>
|
||||
/// (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 (<see cref="MeleeCombo"/> [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).
|
||||
/// <para>
|
||||
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it scales the
|
||||
/// MoveVelocity that system wrote) and BEFORE <see cref="DashSystem"/> (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 (<see cref="MeleeConeMath"/>) ONCE, append
|
||||
/// a SourceTick-stamped DamageEvent + stamp KnockbackState. All ticks via TickUtil.NonZero, compared with
|
||||
/// NetworkTick only; feel knobs live in <see cref="TuningConfig"/> (MC-0, fallback to Defaults()).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerControlSystem))]
|
||||
[UpdateBefore(typeof(DashSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct MeleeComboSystem : ISystem
|
||||
{
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
[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;
|
||||
|
||||
var t = SystemAPI.TryGetSingleton<TuningConfig>(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<PendingCleave>(Allocator.Temp) : default;
|
||||
|
||||
foreach (var (mc, control, input, facing, xform, owner, ds) in
|
||||
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
|
||||
RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>()
|
||||
.WithAll<Simulate>().WithDisabled<Dead>())
|
||||
{
|
||||
// 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<Entity>(Allocator.Temp);
|
||||
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (xform, health, enemyEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
.WithAny<EnemyTag, TrainingDummyTag>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 815ef85fd906e3640b787de314638027
|
||||
@@ -42,6 +42,9 @@ namespace ProjectM.Simulation
|
||||
// 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));
|
||||
// MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn.
|
||||
if (SystemAPI.HasComponent<MeleeCombo>(entity))
|
||||
SystemAPI.SetComponent(entity, default(MeleeCombo));
|
||||
if (SystemAPI.HasComponent<CharacterComponent>(entity))
|
||||
{
|
||||
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
|
||||
|
||||
@@ -25,6 +25,10 @@ namespace ProjectM.Simulation
|
||||
/// so one press dashes exactly once; read by the predicted DashSystem (MC-1).</summary>
|
||||
[GhostField] public InputEvent Dash;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[GhostField] public InputEvent Attack;
|
||||
|
||||
/// <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
|
||||
/// exact. A byte (not an enum): it is compared inside the Burst-compiled <c>AbilityFireSystem</c>.</summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user