Files
Project-M/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs
T
kronic 7b65912f30 Melee feel overhaul: SwordCombat anims at heavy cadence, salvage axe, damage-at-contact + buffer + dash law (guidelines forks 1-6)
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>
2026-07-20 21:03:18 -07:00

367 lines
22 KiB
C#

using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// 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,
/// 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
/// 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;
// 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;
// 07-20 melee feel forks (guidelines G2/G7, design review wf_000bc247):
/// <summary>Finisher REACH multiplier (reach honesty G2.2) — MeleeFinisherMult keeps damage/recover/knockback only.</summary>
public float MeleeFinisherRangeMult;
/// <summary>Attack input-buffer window (ticks before unlock a press is remembered). 0 = off.</summary>
public float MeleeBufferTicks;
/// <summary>Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave).</summary>
public float MeleeContactTicks;
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
public float StructureAggroWeight;
// Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel
// (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap.
public float StaggerKnockbackSpeed;
public float SeparationMaxSpeed;
// Facing/underwater feel (07-15): DEV OVERRIDES with a 0 = NO-OVERRIDE sentinel (0 falls back to the
// deterministic source: authored stat / compile-time const / authored sharpness). Unlike other knobs
// these default to 0 and clamp >= 0 — consumers substitute their source, a 0 never reaches an integrator.
public float TurnRateDeg; // locomotion body-turn (deg/s); 0 = EffectiveCharacterStats.TurnRateRadiansPerSec
public float CastTurnRateDeg; // cast-window body-turn (deg/s); 0 = PlayerAimSystem's cast-turn const
public float MoveSharpness; // grounded accel sharpness; 0 = CharacterComponent.DefaultGroundedSharpness
/// <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 = 14f, // tune: was 12 (0.20s) -> 0.23s, i-frames better cover a reacted telegraph
RecoverTailTicks = 9f,
DashCooldownTicks = 36f, // tune: was 45 (0.75s) -> 0.60s, snappier horde-kiter cadence
DashSharpness = 200f,
ChargerWindupTicks = Tuning.ChargerWindupTicks,
ChargerLungeSpeed = 16f,
ChargerLungeDurationTicks = 18f,
ChargerWhiffStaggerTicks = 36f,
GruntWindupTicks = Tuning.AttackWindupTicks, // canonical Grunt-windup source (TelegraphTests couples to it)
MeleeDamage = 30f, // 07-19 heavy-weapon retime: bigger hit, slower cadence (DPS ~held: 18/0.27s -> 30/0.50s)
MeleeRange = 2.2f, // 07-20 G2.2 reach honesty: axe tip ~1.8m + a 0.4m player-favoring margin (was 2.6)
MeleeConeHalfAngleRad = 0.9f, // ~51.5 deg half-angle (~103 deg cleave arc)
MeleeRecoverTicks = 30f, // heavy-swing lock/recovery (~0.50s; 07-19 weighty-melee retime)
MeleeChainGraceTicks = 24f, // window after the lock to chain the next hit (~0.4s)
MeleeSwingMoveScale = 0.35f, // movement-commit while swinging (0..1)
MeleeKnockbackSpeed = 6f,
MeleeFinisherMult = 1.5f, // finisher (last hit) scales dmg/range/recover/knockback (07-19: 1.8 -> 1.5, recover was ballooning at the heavy cadence)
MeleeComboLength = 3f, // light, light, finisher
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
MeleeBufferTicks = 8f, // 07-20 G7: attack press remembered in the last ~0.13s of a lock (0 = off)
MeleeContactTicks = 16f, // 07-20 G2.1: blade lands ~0.27s after swing start (per-step via MeleeTiming; 0 = legacy immediate)
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
TurnRateDeg = 0f, // 07-15 facing feel: 0 = no override (authored stat wins)
CastTurnRateDeg = 0f, // 0 = no override (PlayerAimSystem cast-turn const wins)
MoveSharpness = 0f, // 0 = no override (DefaultGroundedSharpness wins)
};
/// <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:
case TuningKnob.MeleeDamage:
case TuningKnob.MeleeRange:
case TuningKnob.MeleeConeHalfAngleRad:
case TuningKnob.MeleeSwingMoveScale:
case TuningKnob.MeleeKnockbackSpeed:
case TuningKnob.MeleeFinisherMult:
case TuningKnob.StructureAggroWeight:
case TuningKnob.StaggerKnockbackSpeed:
case TuningKnob.TurnRateDeg: // 07-15: 0 = no-override sentinel (never reaches an integrator)
case TuningKnob.CastTurnRateDeg:
case TuningKnob.MoveSharpness:
case TuningKnob.SeparationMaxSpeed:
case TuningKnob.MeleeFinisherRangeMult:
case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10)
case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave)
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
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;
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;
case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break;
case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break;
case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break;
case TuningKnob.TurnRateDeg: c.TurnRateDeg = value; break;
case TuningKnob.CastTurnRateDeg: c.CastTurnRateDeg = value; break;
case TuningKnob.MoveSharpness: c.MoveSharpness = value; break;
case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break;
case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break;
case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = 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;
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;
case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight;
case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed;
case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed;
case TuningKnob.TurnRateDeg: return c.TurnRateDeg;
case TuningKnob.CastTurnRateDeg: return c.CastTurnRateDeg;
case TuningKnob.MoveSharpness: return c.MoveSharpness;
case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks;
case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult;
case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks;
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,
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,
StructureAggroWeight = c.StructureAggroWeight,
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
SeparationMaxSpeed = c.SeparationMaxSpeed,
TurnRateDeg = c.TurnRateDeg,
CastTurnRateDeg = c.CastTurnRateDeg,
MoveSharpness = c.MoveSharpness,
MeleeBufferTicks = c.MeleeBufferTicks,
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
MeleeContactTicks = c.MeleeContactTicks,
};
/// <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,
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,
StructureAggroWeight = r.StructureAggroWeight,
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
SeparationMaxSpeed = r.SeparationMaxSpeed,
TurnRateDeg = r.TurnRateDeg,
CastTurnRateDeg = r.CastTurnRateDeg,
MoveSharpness = r.MoveSharpness,
MeleeBufferTicks = r.MeleeBufferTicks,
MeleeFinisherRangeMult = r.MeleeFinisherRangeMult,
MeleeContactTicks = r.MeleeContactTicks,
};
}
/// <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;
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;
public const byte StructureAggroWeight = 19;
// RETIRED knob ids (LANTERN purge — keep 20-23 reserved, never renumber):
// 20 = CoreDamagePerHusk · 21 = CoreRegenIntervalTicks · 22 = CoreOverrunDrainPct · 23 = FinalSiegeMultiplier
public const byte StaggerKnockbackSpeed = 24;
public const byte SeparationMaxSpeed = 25;
// 07-15 facing/underwater feel dev-overrides (0 = no-override sentinel; see TuningConfig fields):
public const byte TurnRateDeg = 26;
public const byte CastTurnRateDeg = 27;
public const byte MoveSharpness = 28;
public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel)
public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2
public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel)
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 32;
}
/// <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;
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;
public float StructureAggroWeight;
public float StaggerKnockbackSpeed;
public float SeparationMaxSpeed;
public float TurnRateDeg;
public float CastTurnRateDeg;
public float MoveSharpness;
public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers)
public float MeleeFinisherRangeMult;
public float MeleeContactTicks;
}
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
}