Files
Project-M/Assets/_Project/Scripts/Simulation/Player/PlayerAimSystem.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

74 lines
4.8 KiB
C#

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted body facing — the Shape-of-Dreams model (07-15): while MOVING the body turns toward the
/// movement direction; during a CAST window (melee swing, any non-Movement socket fire window) it turns
/// toward the aim at a snappier rate; idle holds the last facing. The cursor is never passively tracked.
/// PlayerFacing is presentation/body-yaw ONLY — every gameplay fire direction reads
/// FacingMath.ResolveAim(PlayerInput.Aim, facing) at its own site (AbilityFireSystem, MeleeComboSystem).
/// The rate-limited turn is an incremental integrator over the snapshot-restored [GhostField]
/// PlayerFacing — NO IsFirstTimeFullyPredictingTick guard; it must re-integrate on EVERY predicted pass
/// so rollback re-simulation converges (gating it freezes facing at the rollback-tick value and diverges
/// from the server). Partial-tick writes are discarded by the prediction restore before the next full
/// tick. [UpdateAfter(MeleeComboSystem)] is a hygiene pin (sorter tie-breaks are deterministic and
/// cross-world-identical) so the melee cast window opens the same tick the swing starts; socket windows
/// open 1 tick late by construction (AbilityFireSystem stamps AFTER this system) — cosmetic-only.
/// Turn rates: locomotion = EffectiveCharacterStats.TurnRateRadiansPerSec; cast = a const — both
/// dev-overridable via TuningKnob.TurnRateDeg / CastTurnRateDeg (0 = no override; Defaults() fallback
/// keeps release worlds server==client). Deterministic (pure math, fixed-step dt); Simulate-filtered.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(MeleeComboSystem))]
[BurstCompile]
public partial struct PlayerAimSystem : ISystem
{
/// <summary>Cast-window turn rate (rad/s): snappy turn toward the aim mid-swing/fire (1080 deg/s).</summary>
public const float DefaultCastTurnRateRadiansPerSec = 18.8495559f;
/// <summary>Ticks facing stays aimed after a swing/fire starts (~0.22s @60Hz — matches the anim pulse
/// k_AttackAnimTicks so body yaw and the swing animation agree).</summary>
public const uint CastFacingTicks = 26; // 07-19 heavy-weapon retime: cast-turn + swing-anim window tracks the full ~0.43s swing (was 13; MUST stay < MeleeRecoverTicks so the anim pulse re-triggers between chained swings)
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
var abilityDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
var tc = SystemAPI.TryGetSingleton<TuningConfig>(out var tcs) ? tcs : TuningConfig.Defaults();
var effSocketLookup = SystemAPI.GetBufferLookup<EffectiveSocketStats>(true);
foreach (var (facing, transform, input, stats, melee, socketCd, sockets, entity) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>,
RefRO<EffectiveCharacterStats>, RefRO<MeleeCombo>, RefRO<SocketCooldown>,
DynamicBuffer<AbilitySocket>>()
.WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
{
// Cast window: melee swing first (cheap), else any non-Movement socket fire window
// (TickWindowMath skips Movement sockets — a blink is a dodge, never a cast).
bool castActive = TickWindowMath.SwingActive(melee.ValueRO, serverTick, CastFacingTicks);
if (!castActive && effSocketLookup.HasBuffer(entity))
TickWindowMath.SocketFireAndCone(socketCd.ValueRO, sockets, effSocketLookup[entity],
abilityDb, serverTick, CastFacingTicks, out castActive, out _);
if (!FacingMath.SelectTarget(castActive, input.ValueRO.Aim, input.ValueRO.Move, out float2 target))
continue; // no target this tick: keep last facing
float rate = castActive
? (tc.CastTurnRateDeg > 0f ? math.radians(tc.CastTurnRateDeg) : DefaultCastTurnRateRadiansPerSec)
: (tc.TurnRateDeg > 0f ? math.radians(tc.TurnRateDeg) : stats.ValueRO.TurnRateRadiansPerSec);
float2 dir = FacingMath.RotateToward(facing.ValueRO.Direction, target, rate * dt);
facing.ValueRW.Direction = dir;
transform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(dir.x, 0f, dir.y), math.up());
}
}
}
}