Tuning Knobs

This commit is contained in:
2026-06-10 15:22:30 -07:00
parent da522efe7a
commit 08f16b689f
20 changed files with 11045 additions and 18 deletions
@@ -38,6 +38,8 @@ namespace ProjectM.Client
public static void Kill() => Send(DebugOp.KillPlayer);
public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by);
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => s_Pending.Clear();
@@ -17,6 +17,8 @@ namespace ProjectM.Client
bool _open = true;
int _siegeSize = 5;
int _grantAmount = 50;
bool _tuningOpen;
Vector2 _scroll;
void OnDisable() => AimPresentation.ForceCursorVisible = false;
@@ -29,8 +31,10 @@ namespace ProjectM.Client
if (!_open)
return;
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, 540), GUI.skin.box);
float panelH = Mathf.Min(760f, Screen.height - 50f);
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, panelH), GUI.skin.box);
GUILayout.Label("DEV TOOLS");
_scroll = GUILayout.BeginScrollView(_scroll);
GUILayout.Label("- World -");
_siegeSize = IntField("Siege size", _siegeSize);
@@ -76,6 +80,23 @@ namespace ProjectM.Client
GUILayout.Label("(waiting for server telemetry...)");
}
GUILayout.Space(6);
_tuningOpen = GUILayout.Toggle(_tuningOpen, "- Tuning (MC-0) -");
if (_tuningOpen)
{
TuningRow("Dash dist", TuningKnob.DashDistance, 0.5f, "0.0");
TuningRow("Dash iframe t", TuningKnob.IFrameWindowTicks, 1f, "0");
TuningRow("Dash recover t", TuningKnob.RecoverTailTicks, 1f, "0");
TuningRow("Dash cd t", TuningKnob.DashCooldownTicks, 1f, "0");
TuningRow("Dash sharp", TuningKnob.DashSharpness, 25f, "0");
TuningRow("Chgr windup t", TuningKnob.ChargerWindupTicks, 1f, "0");
TuningRow("Chgr lunge spd", TuningKnob.ChargerLungeSpeed, 1f, "0.0");
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.EndScrollView();
GUILayout.EndArea();
}
@@ -87,6 +108,25 @@ namespace ProjectM.Client
GUILayout.EndHorizontal();
return int.TryParse(s, out var v) ? v : value;
}
// One live-tuning row: shows the current value (from the readout) + step buttons. A nudge fires the
// authoritative server RPC AND an optimistic local apply (so the tuner's own predicted dash uses it now).
static void TuningRow(string label, byte knob, float step, string fmt)
{
float cur = ProjectM.Simulation.TuningConfig.Get(TuningReadout.Current, knob);
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(92));
GUILayout.Label(cur.ToString(fmt), GUILayout.Width(40));
if (GUILayout.Button("-", GUILayout.Width(26))) Nudge(knob, cur - step);
if (GUILayout.Button("+", GUILayout.Width(26))) Nudge(knob, cur + step);
GUILayout.EndHorizontal();
}
static void Nudge(byte knob, float value)
{
DebugCommandSendSystem.SetTuning(knob, value); // authoritative (server applies + broadcasts; clamped)
TuningReadout.SetLocal(knob, value); // optimistic local (instant feel for the tuner; clamped)
}
}
}
#endif
@@ -0,0 +1,82 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// MC-0 — EDITOR-ONLY client owner of the local <see cref="TuningConfig"/> singleton (the value the PREDICTED
/// DashSystem reads on this client). Each tick it (1) drains any authoritative <see cref="DebugTuningReport"/>
/// into the <see cref="TuningReadout"/> static, then (2) pushes <see cref="TuningReadout.Current"/> into the
/// client TuningConfig singleton. The overlay mutates the readout OPTIMISTICALLY via
/// <see cref="TuningReadout.SetLocal"/> (so the tuner's own dash uses a nudged value instantly — instant, then
/// EVENTUALLY CONSISTENT with the server within ~1 RTT once the SetTuning RPC lands and the next report
/// reconciles). Runs EVERY tick (no RequireForUpdate) so an overlay-only change with no report still reaches the
/// singleton. Plain client <see cref="SimulationSystemGroup"/> (mutated at most once/tick → the predicted
/// re-sim reads a per-tick-constant value); non-Burst (touches a managed static). This DELIBERATELY extends the
/// DevTelemetry pattern (which writes only a static) with a per-world singleton; the release path has no such
/// system, so DashSystem's <c>TryGetSingleton ? : Defaults()</c> fallback is the permanent release behaviour.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DevTuningReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
if (state.GetEntityQuery(ComponentType.ReadWrite<TuningConfig>()).IsEmpty)
{
var e = state.EntityManager.CreateEntity(typeof(TuningConfig));
state.EntityManager.SetComponentData(e, TuningConfig.Defaults());
}
}
public void OnUpdate(ref SystemState state)
{
// (1) Authoritative server snapshot wins — overwrite the readout (FULL state).
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (report, reqEntity) in
SystemAPI.Query<RefRO<DebugTuningReport>>()
.WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
{
TuningReadout.Current = TuningConfig.FromReport(report.ValueRO);
TuningReadout.Initialized = true;
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
// (2) Push the readout (authoritative, or optimistic SetLocal, or Defaults()) into the client singleton.
if (SystemAPI.TryGetSingletonEntity<TuningConfig>(out var cfgEntity))
state.EntityManager.SetComponentData(cfgEntity, TuningReadout.Current);
}
}
/// <summary>
/// MC-0 — static bridge from the ECS tuning receiver to the IMGUI <c>DebugOverlay</c> (so the overlay reads/sets
/// a plain struct, never ECS state). <see cref="Current"/> seeds to <see cref="TuningConfig.Defaults"/> on
/// play-enter (so it matches the server's seeded Defaults before the first report). <see cref="SetLocal"/> is the
/// overlay's OPTIMISTIC apply — it runs the same <see cref="TuningConfig.Apply"/> clamp so an overlay nudge can
/// never feed a 0/negative into the predicted DashSystem.
/// </summary>
public static class TuningReadout
{
public static TuningConfig Current;
/// <summary>True once an authoritative server report has been received (else <see cref="Current"/> is Defaults()).</summary>
public static bool Initialized;
/// <summary>Optimistic local apply (overlay button) — clamped via <see cref="TuningConfig.Apply"/>.</summary>
public static void SetLocal(byte knob, float value) => TuningConfig.Apply(ref Current, knob, value);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Reset()
{
Current = TuningConfig.Defaults();
Initialized = false;
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f4b5a00778a9f44f8c07543d471beb6
@@ -61,6 +61,8 @@ namespace ProjectM.Server
float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
uint now = serverTick.TickIndexForValidTick;
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
var ecb = new EntityCommandBuffer(Allocator.Temp);
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
@@ -165,7 +167,7 @@ namespace ProjectM.Server
}
if (ready)
{
uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks);
uint windupTicks = (uint)math.max(1f, tune.GruntWindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks);
}
}
@@ -173,10 +175,12 @@ 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
// Charger feel knobs — live-tunable via TuningConfig (MC-0), guarded at the read site. Server-only
// (clients never simulate Chargers); the >=1-tick floor avoids a degenerate instant/no-travel lunge.
float ChargerLungeSpeed = math.max(0f, tune.ChargerLungeSpeed); // units/s while lunging
uint ChargerLungeDurationTicks = (uint)math.max(1f, tune.ChargerLungeDurationTicks); // committed travel
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
uint chargerWhiffsThisTick = 0;
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
@@ -165,6 +165,13 @@ namespace ProjectM.Server
SystemAPI.SetComponent(cycleEntity, ts);
}
break;
case DebugOp.SetTuning:
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
{
TuningConfig.Apply(ref tuningCfg, (byte)cmd.ArgA, cmd.ArgB / 1000f);
SystemAPI.SetSingleton(tuningCfg);
}
break;
}
ecb.DestroyEntity(reqEntity);
@@ -0,0 +1,59 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// MC-0 — EDITOR-ONLY server owner of the authoritative <see cref="TuningConfig"/> singleton. Ensures+seeds it
/// to <see cref="TuningConfig.Defaults"/> in OnCreate, then every <see cref="BroadcastPeriodTicks"/> ships the
/// FULL config to every connection as a <see cref="DebugTuningReport"/> (so a PREDICTING client's DashSystem —
/// including an MPPM thin client that has no overlay — converges on the tuned values; full state, not a delta,
/// so a late-joiner converges in one report). The singleton is mutated by <c>DebugCommandReceiveSystem</c>'s
/// SetTuning op (same plain server group). Verbatim <c>DevTelemetrySystem</c> shape. Plain server
/// <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple, editor-only).
/// Stripped from builds; the wire TYPE <see cref="DebugTuningReport"/> + <see cref="TuningConfig"/> are
/// unconditional, so in a release build no system creates the singleton and consumers fall back to Defaults().
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct TuningBroadcastSystem : ISystem
{
const uint BroadcastPeriodTicks = 15;
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
if (state.GetEntityQuery(ComponentType.ReadWrite<TuningConfig>()).IsEmpty)
{
var e = state.EntityManager.CreateEntity(typeof(TuningConfig));
state.EntityManager.SetComponentData(e, TuningConfig.Defaults());
}
}
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
if (now == 0 || (now % BroadcastPeriodTicks) != 0)
return;
var report = TuningConfig.ToReport(SystemAPI.GetSingleton<TuningConfig>());
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: 58b4be546da44eb499b7cba0c7b04ea9
@@ -60,5 +60,8 @@ namespace ProjectM.Simulation
/// <summary>Set ThreatState.Heat to ArgA (inert until the Heat source ships).</summary>
public const byte SetHeat = 11;
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
public const byte SetTuning = 12;
}
}
@@ -0,0 +1,176 @@
using Unity.Entities;
using Unity.Mathematics;
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
/// 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).
/// <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
/// behaviour-preserving. Tick knobs clamp to &gt;= 1 and value knobs to &gt;= 0 (<see cref="ClampKnob"/>) on EVERY
/// write path (<see cref="Apply"/> + the client-side optimistic SetLocal), and DashSystem additionally clamps the
/// i-frame window AT the divide site — a 0 there NaNs the kinematic body permanently (MC-0 design-review F1).
/// </para>
/// </summary>
public struct TuningConfig : IComponentData
{
public float DashDistance;
public float IFrameWindowTicks;
public float RecoverTailTicks;
public float DashCooldownTicks;
public float DashSharpness;
public float ChargerWindupTicks;
public float ChargerLungeSpeed;
public float ChargerLungeDurationTicks;
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
/// <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
{
DashDistance = 4.0f,
IFrameWindowTicks = 12f,
RecoverTailTicks = 9f,
DashCooldownTicks = 45f,
DashSharpness = 200f,
ChargerWindupTicks = 30f,
ChargerLungeSpeed = 16f,
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
};
/// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 0. Used by every write path
/// (<see cref="Apply"/> + the client-side optimistic SetLocal) so no 0/negative ever reaches a consumer.</summary>
public static float ClampKnob(byte knob, float value)
{
switch (knob)
{
// value knobs: non-negative
case TuningKnob.DashDistance:
case TuningKnob.DashSharpness:
case TuningKnob.ChargerLungeSpeed:
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:
return math.max(1f, value);
}
}
/// <summary>Authoritatively set one knob (clamped) by its <see cref="TuningKnob"/> index. Unknown index = no-op.</summary>
public static void Apply(ref TuningConfig c, byte knob, float value)
{
value = ClampKnob(knob, value);
switch (knob)
{
case TuningKnob.DashDistance: c.DashDistance = value; break;
case TuningKnob.IFrameWindowTicks: c.IFrameWindowTicks = value; break;
case TuningKnob.RecoverTailTicks: c.RecoverTailTicks = value; break;
case TuningKnob.DashCooldownTicks: c.DashCooldownTicks = value; break;
case TuningKnob.DashSharpness: c.DashSharpness = value; break;
case TuningKnob.ChargerWindupTicks: c.ChargerWindupTicks = value; break;
case TuningKnob.ChargerLungeSpeed: c.ChargerLungeSpeed = value; break;
case TuningKnob.ChargerLungeDurationTicks: c.ChargerLungeDurationTicks = value; break;
case TuningKnob.ChargerWhiffStaggerTicks: c.ChargerWhiffStaggerTicks = value; break;
case TuningKnob.GruntWindupTicks: c.GruntWindupTicks = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
}
/// <summary>Read one knob by its <see cref="TuningKnob"/> index (overlay display). Unknown index = 0.</summary>
public static float Get(in TuningConfig c, byte knob)
{
switch (knob)
{
case TuningKnob.DashDistance: return c.DashDistance;
case TuningKnob.IFrameWindowTicks: return c.IFrameWindowTicks;
case TuningKnob.RecoverTailTicks: return c.RecoverTailTicks;
case TuningKnob.DashCooldownTicks: return c.DashCooldownTicks;
case TuningKnob.DashSharpness: return c.DashSharpness;
case TuningKnob.ChargerWindupTicks: return c.ChargerWindupTicks;
case TuningKnob.ChargerLungeSpeed: return c.ChargerLungeSpeed;
case TuningKnob.ChargerLungeDurationTicks: return c.ChargerLungeDurationTicks;
case TuningKnob.ChargerWhiffStaggerTicks: return c.ChargerWhiffStaggerTicks;
case TuningKnob.GruntWindupTicks: return c.GruntWindupTicks;
default: return 0f;
}
}
/// <summary>Project the full config onto the wire snapshot.</summary>
public static DebugTuningReport ToReport(in TuningConfig c) => new DebugTuningReport
{
DashDistance = c.DashDistance,
IFrameWindowTicks = c.IFrameWindowTicks,
RecoverTailTicks = c.RecoverTailTicks,
DashCooldownTicks = c.DashCooldownTicks,
DashSharpness = c.DashSharpness,
ChargerWindupTicks = c.ChargerWindupTicks,
ChargerLungeSpeed = c.ChargerLungeSpeed,
ChargerLungeDurationTicks = c.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = c.ChargerWhiffStaggerTicks,
GruntWindupTicks = c.GruntWindupTicks,
};
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
public static TuningConfig FromReport(in DebugTuningReport r) => new TuningConfig
{
DashDistance = r.DashDistance,
IFrameWindowTicks = r.IFrameWindowTicks,
RecoverTailTicks = r.RecoverTailTicks,
DashCooldownTicks = r.DashCooldownTicks,
DashSharpness = r.DashSharpness,
ChargerWindupTicks = r.ChargerWindupTicks,
ChargerLungeSpeed = r.ChargerLungeSpeed,
ChargerLungeDurationTicks = r.ChargerLungeDurationTicks,
ChargerWhiffStaggerTicks = r.ChargerWhiffStaggerTicks,
GruntWindupTicks = r.GruntWindupTicks,
};
}
/// <summary>Byte indices for <see cref="TuningConfig"/> knobs (bytes — never an enum on a Bursted/RPC path).</summary>
public static class TuningKnob
{
public const byte DashDistance = 0;
public const byte IFrameWindowTicks = 1;
public const byte RecoverTailTicks = 2;
public const byte DashCooldownTicks = 3;
public const byte DashSharpness = 4;
public const byte ChargerWindupTicks = 5;
public const byte ChargerLungeSpeed = 6;
public const byte ChargerLungeDurationTicks = 7;
public const byte ChargerWhiffStaggerTicks = 8;
public const byte GruntWindupTicks = 9;
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 10;
}
/// <summary>
/// MC-0 — server → dev-client snapshot of the full <see cref="TuningConfig"/> (sent periodically by the
/// editor-only broadcaster). UNCONDITIONAL wire type (like <see cref="DebugTelemetryReport"/>) for RpcCollection
/// hash parity; only the send/receive SYSTEMS are <c>#if UNITY_EDITOR</c>. FULL state (not a delta) so a
/// late-joining client converges in one report.
/// </summary>
public struct DebugTuningReport : IRpcCommand
{
public float DashDistance;
public float IFrameWindowTicks;
public float RecoverTailTicks;
public float DashCooldownTicks;
public float DashSharpness;
public float ChargerWindupTicks;
public float ChargerLungeSpeed;
public float ChargerLungeDurationTicks;
public float ChargerWhiffStaggerTicks;
public float GruntWindupTicks;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24ab752e5f2d5a8428b4580f2b2588e4
@@ -29,12 +29,9 @@ namespace ProjectM.Simulation
[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
// 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 = 15f; // CharacterComponent.GetDefault() base
const float SimTickRate = 60f;
@@ -45,7 +42,12 @@ namespace ProjectM.Simulation
return;
var serverTick = netTime.ServerTick;
uint now = serverTick.TickIndexForValidTick;
float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate);
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;
foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
@@ -64,9 +66,9 @@ namespace ProjectM.Simulation
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);
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) ---
@@ -84,7 +86,7 @@ namespace ProjectM.Simulation
{
float2 d = ds.ValueRO.Dir;
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
character.ValueRW.GroundedMovementSharpness = DashSharpness;
character.ValueRW.GroundedMovementSharpness = dashSharpness;
}
else if (recoverActive)
{