7b65912f30
07-18/19/20 arc: LightCombo01/HeavyCombo01 clips onto Swing1-3/Slam at ~natural speed (26-tick window, 30-tick recover, DPS-held damage retune), Menacing01 combat idle (InCombat), SM_Wep_Axe_Large_01 rigid-skinned to Hand_R at x1.25, LANTERN FX retone + blade-smear ribbon. Forks 1-6 per Combat_Attack_Feel_Guidelines (design review wf_000bc247): cleave resolves at the CONTACT tick (MeleeCleavePending schedule-and-consume, knob 31, 0=legacy; connect cues moved to contact), MeleeRange 2.2 + reach-only finisher mult 1.25 (knob 30), BufferedAttackTick GhostField input buffer (knob 29, unlock-edge validity), dash refused pre-contact (lookup-based). Tests: MeleeComboTests pin legacy knobs; +3 fork tests (414 green); live server proof: HP drop exactly at swing+16 under tick batching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
9.4 KiB
C#
146 lines
9.4 KiB
C#
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<Simulate>().WithDisabled<Dead>()</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
|
|
{
|
|
// Feel knobs are LIVE-tunable via the TuningConfig singleton (MC-0): OnUpdate reads it each tick and falls
|
|
// back to TuningConfig.Defaults() when absent (release builds / EditMode), so behaviour is identical to the
|
|
// old baked consts. DefaultSharpness (the restore target) + SimTickRate stay compile-time (not tuned).
|
|
const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; // CharacterComponent.GetDefault() base
|
|
const float SimTickRate = 60f;
|
|
|
|
// 07-20 G7 dash law (design review wf_000bc247 C4): a LOOKUP, not a query add -- melee-less entities
|
|
// (dash test harnesses) must keep dashing; only entities that carry MeleeCombo get the commitment rule.
|
|
ComponentLookup<MeleeCombo> m_MeleeLookup;
|
|
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
m_MeleeLookup = state.GetComponentLookup<MeleeCombo>(isReadOnly: true);
|
|
}
|
|
|
|
[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();
|
|
uint iFrameTicks = (uint)math.max(1f, t.IFrameWindowTicks);
|
|
uint recoverTicks = (uint)math.max(1f, t.RecoverTailTicks);
|
|
uint cooldownTicks = (uint)math.max(1f, t.DashCooldownTicks);
|
|
float dashSpeed = t.DashDistance / (iFrameTicks / SimTickRate); // iFrameTicks>=1 -> never div-by-0 (review F1)
|
|
float dashSharpness = t.DashSharpness;
|
|
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
|
|
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
|
|
float contactKnob = math.max(0f, t.MeleeContactTicks); // 07-20 G7: pre-contact swing = dash refused
|
|
m_MeleeLookup.Update(ref state);
|
|
|
|
|
|
foreach (var (ds, cd, control, character, input, facing, entity) in
|
|
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
|
|
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
|
|
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
|
|
{
|
|
// --- 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);
|
|
// 07-20 G7 (the Bathynaut COMMITS): a dash start is refused while a swing is PRE-CONTACT -- the
|
|
// blade hasn't landed; post-contact recovery dash-cancel stays allowed. Same wrap-safe window
|
|
// family as the anim pulse (TickWindowMath.SwingActive over the per-step contact ticks);
|
|
// contact knob 0 -> zero-length window -> no refusal (legacy). Deterministic both worlds.
|
|
bool preContactCommit = false;
|
|
if (m_MeleeLookup.HasComponent(entity))
|
|
{
|
|
var mcRO = m_MeleeLookup[entity];
|
|
preContactCommit = TickWindowMath.SwingActive(mcRO, serverTick, MeleeTiming.ContactTicks(mcRO.Step, contactKnob));
|
|
}
|
|
if (input.ValueRO.Dash.IsSet && ready && !inWindow && !preContactCommit)
|
|
{
|
|
// C1 + 07-15 fork: dash toward MOVEMENT input when moving (a panic 'dash away' still works); else
|
|
// toward the CURSOR aim (stationary aim-and-dash keeps today's KBM feel under move-facing —
|
|
// operator fork 07-15); else last facing (resting gamepad stick). Pure replicated input -> idempotent.
|
|
float2 mv = input.ValueRO.Move;
|
|
float2 dir = math.lengthsq(mv) > 1e-4f ? mv
|
|
: math.lengthsq(input.ValueRO.Aim) > 1e-6f ? input.ValueRO.Aim
|
|
: 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 + iFrameTicks);
|
|
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + iFrameTicks + recoverTicks);
|
|
cd.ValueRW.NextTick = TickUtil.NonZero(now + cooldownTicks);
|
|
}
|
|
|
|
// --- 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 = baseSharpness;
|
|
}
|
|
else
|
|
{
|
|
if (character.ValueRO.GroundedMovementSharpness != baseSharpness)
|
|
character.ValueRW.GroundedMovementSharpness = baseSharpness; // 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|