LANTERN feel pass (DR-052 + gap list): SoD facing, underwater feel, Bathynaut kit, walk/run gait, suit lamp + new Synty anim packs

- SoD facing: PlayerFacing = body-yaw only (move-facing / cast-turn / idle-hold); every fire
  direction re-sourced to FacingMath.ResolveAim (pre-code review blocking catch); TickWindowMath
  shared windows with Movement-skip; reticle/FX coupled to the damage direction; cursor-dash kept.
- Underwater feel: sharpness 15->6, turn 720->360, MoveSpeed 6->4.2; TuningKnob 26-28
  (0 = no-override sentinels, dev-protocol bump on DebugTuningReport); stride footsteps + silt +
  cadence floor; bubbles; underwater ambience bed + distant groans; camera drag + dev scroll zoom.
- Bathynaut kit in-engine: dome/tank/shoulder-lamp + bare head grafted (GraftSmr rigid rebase,
  RecalculateTangents); EmissiveGloamSkinned shader (Rukhanka deformation); shoulder lamp CASTS
  (warm steady spot on body yaw).
- Gait: two-ring walk/run FreeformDirectional tree (walk @0.35, run @1.0) + blended-natural
  StrideScale; additive Posture(Bank) + Lead(chest-lead) layers; idle = AnimationIdles Base;
  banking driven from facing turn rate; flat terrain (Env_SeabedKit seabed squashed - CC is planar).
- New packs: Synty AnimationIdles + AnimationSwordCombat (combat pass queued) + SyntyPropBoneTool;
  four authored clips (sway/trudge/banks/lean) + Anim_Player_Underwater.blend + suit-kit FBX.
- Validation: 411/411 EditMode green; Play smokes (server==client facing, Aim-true projectile,
  bank/stride live-sampled, lamp beam verified); pre-code + post-impl adversarial reviews applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:56:02 -07:00
parent 385d0de08e
commit 7571091394
2637 changed files with 1810753 additions and 303 deletions
@@ -14,8 +14,8 @@ namespace ProjectM.Authoring
public FrameKind Id = FrameKind.Default;
public string DisplayName = "Character";
[Min(0f)] public float MoveSpeed = 6f;
[Min(0f)] public float TurnRateDegreesPerSec = 720f;
[Min(0f)] public float MoveSpeed = 4.2f; // 07-16e underwater feel: matched near the run clip's natural pace (asset value is authoritative)
[Min(0f)] public float TurnRateDegreesPerSec = 360f; // 07-15 underwater feel: heavier body swing (asset value is authoritative)
[Min(0f)] public float MaxHealth = 100f;
}
}
@@ -108,6 +108,11 @@ namespace ProjectM.Client
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0");
GUILayout.Space(4);
TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures
// 07-15 facing/underwater feel (0 = no override: authored stat / cast const / authored sharpness)
TuningRow("Turn rate deg", TuningKnob.TurnRateDeg, 30f, "0");
TuningRow("Cast turn deg", TuningKnob.CastTurnRateDeg, 60f, "0");
TuningRow("Move sharp", TuningKnob.MoveSharpness, 0.5f, "0.0");
}
@@ -61,17 +61,19 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<PlayerFacing>();
EntityManager.CompleteDependencyBeforeRO<Health>();
foreach (var (xform, facing) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
EntityManager.CompleteDependencyBeforeRO<PlayerInput>();
foreach (var (xform, facing, input) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<PlayerInput>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
float3 playerPos = xform.ValueRO.Position;
if (scheme == InputSchemeId.Gamepad)
{
float2 dir = facing.ValueRO.Direction;
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
dir = math.normalize(dir);
// 07-15 move-facing model: the ring shows the FIRE direction (raw right stick), not the body
// yaw — facing tracks movement outside casts and would lie about where a cast goes.
float2 dir = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
ringPos = playerPos + new float3(dir.x, 0f, dir.y) * ReticleDistance;
}
else
@@ -94,7 +96,9 @@ namespace ProjectM.Client
ringPos.y += ReticleLiftY;
lpPos = playerPos;
lpFacing = facing.ValueRO.Direction;
// Tether cone keys off the same resolved fire direction (matches the server assist seed, which
// also reads ResolveAim(Aim, facing) since 07-15).
lpFacing = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
haveTarget = true;
break;
}
@@ -111,9 +115,7 @@ namespace ProjectM.Client
if (_tetherLine != null && haveTarget && FeelConfig.LockOnEnabled
&& (!FeelConfig.LockOnGamepadOnly || scheme == InputSchemeId.Gamepad))
{
float2 fdir = lpFacing;
if (math.lengthsq(fdir) < 1e-6f) fdir = new float2(0f, 1f);
fdir = math.normalize(fdir);
float2 fdir = lpFacing; // ResolveAim output: always normalized, never zero
float rangeSq = FeelConfig.LockOnRange * FeelConfig.LockOnRange;
float cone = math.cos(math.radians(FeelConfig.LockOnArcDegrees));
float bestSq = float.MaxValue;
@@ -25,11 +25,15 @@ namespace ProjectM.Client
int _lastCountdownSec = -1;
bool _bossRoared;
const float AmbientBaseVolume = 0.10f; // low ambient bed
// 07-16 underwater re-voice: the bed volume + groan cadence live on FeelConfig (live-tunable).
AudioSource _groanSrc;
AudioClip _groanClip;
float _nextGroanIn = 12f; // first distant groan ~12s into a session
protected override void OnCreate()
{
_ambientClip = MakeDrone();
_groanClip = MakeGroan();
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
}
@@ -43,8 +47,12 @@ namespace ProjectM.Client
_ambient.loop = true;
_ambient.playOnAwake = false;
_ambient.spatialBlend = 0f; // 2D bed
_ambient.volume = AmbientBaseVolume * GameVolume.Music;
_ambient.volume = FeelConfig.AmbienceVolume * GameVolume.Music;
_ambient.Play();
_groanSrc = _root.AddComponent<AudioSource>();
_groanSrc.playOnAwake = false;
_groanSrc.spatialBlend = 0f; // distant, directionless
_groanSrc.loop = false;
}
protected override void OnDestroy()
@@ -56,7 +64,21 @@ namespace ProjectM.Client
{
if (_ambient == null) return;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, AmbientBaseVolume * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
float dt = SystemAPI.Time.DeltaTime;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, FeelConfig.AmbienceVolume * GameVolume.Music, dt * 0.25f);
// Distant groans (07-16 gap-list): a slow low sweep with a soft attack, jittered interval + pitch —
// the murk answering back. Presentation-only wall-clock randomness is fine here.
if (_groanSrc != null && FeelConfig.GroanIntervalSec > 0f)
{
_nextGroanIn -= dt;
if (_nextGroanIn <= 0f)
{
_nextGroanIn = FeelConfig.GroanIntervalSec * (0.6f + Random.value * 0.8f);
_groanSrc.pitch = 0.85f + Random.value * 0.3f;
_groanSrc.PlayOneShot(_groanClip, FeelConfig.GroanVolume * GameVolume.Music);
}
}
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
@@ -94,27 +116,54 @@ namespace ProjectM.Client
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
// (freq snapped to k/duration) so the loop point has no click. A slow tremolo adds motion.
// A low, seamless-looping UNDERWATER pressure bed (07-16 re-voice): sub-heavy partials + a pair of
// detuned subs whose beat completes exactly once per loop (Snap keeps every partial click-free at the
// loop point) — reads as slow water-column pressure swell rather than a musical pad.
static AudioClip MakeDrone()
{
const int rate = 44100;
const float dur = 4f;
const float dur = 8f;
int len = (int)(dur * rate);
var clip = AudioClip.Create("ambient_drone", len, 1, rate, false);
var clip = AudioClip.Create("underwater_bed", len, 1, rate, false);
var data = new float[len];
float f0 = Snap(55f, dur); // sub
float f1 = Snap(110f, dur); // root
float f2 = Snap(164.81f, dur); // fifth-ish
float f3 = Snap(220f, dur);
float trem = Snap(0.5f, dur); // slow amplitude wobble
float f0 = Snap(38f, dur); // deep sub
float f0b = f0 + 1f / dur; // detuned sub: beats exactly once per loop (seamless)
float f1 = Snap(55f, dur);
float f2 = Snap(82.4f, dur); // faint upper body
float swell = Snap(0.25f, dur); // very slow amplitude swell
for (int i = 0; i < len; i++)
{
float t = i / (float)rate;
float s = 0.50f * Mathf.Sin(2f * Mathf.PI * f0 * t)
+ 0.35f * Mathf.Sin(2f * Mathf.PI * f1 * t)
+ 0.18f * Mathf.Sin(2f * Mathf.PI * f2 * t)
+ 0.10f * Mathf.Sin(2f * Mathf.PI * f3 * t);
float amp = 0.75f + 0.25f * Mathf.Sin(2f * Mathf.PI * trem * t);
data[i] = s * amp * 0.5f; // peak ~0.57, no clipping
float s = 0.46f * Mathf.Sin(2f * Mathf.PI * f0 * t)
+ 0.34f * Mathf.Sin(2f * Mathf.PI * f0b * t)
+ 0.22f * Mathf.Sin(2f * Mathf.PI * f1 * t)
+ 0.07f * Mathf.Sin(2f * Mathf.PI * f2 * t);
float amp = 0.7f + 0.3f * Mathf.Sin(2f * Mathf.PI * swell * t);
data[i] = s * amp * 0.5f;
}
clip.SetData(data, 0);
return clip;
}
// A distant whale-adjacent groan: slow 58->34Hz sweep with a soft sin^2 attack/decay window and a
// subtle wobble — never a jump-scare, just the deep being large somewhere off-screen.
static AudioClip MakeGroan()
{
const int rate = 44100;
const float dur = 3.4f;
int len = (int)(dur * rate);
var clip = AudioClip.Create("distant_groan", len, 1, rate, false);
var data = new float[len];
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t01 = i / (float)len;
float t = i / (float)rate;
float freq = Mathf.Lerp(58f, 34f, t01) * (1f + 0.03f * Mathf.Sin(2f * Mathf.PI * 1.7f * t));
phase += 2f * Mathf.PI * freq / rate;
float window = Mathf.Sin(Mathf.PI * t01);
float env = window * window; // soft attack AND release
data[i] = (0.8f * Mathf.Sin(phase) + 0.2f * Mathf.Sin(2f * phase)) * env * 0.55f;
}
clip.SetData(data, 0);
return clip;
@@ -85,8 +85,12 @@ namespace ProjectM.Client
AudioClip _telegraphClip;
AudioClip _dashClip;
AudioClip _swingClip;
AudioClip _meleeConnectClip, _footstepClip; // combat feel pass: connect thunk / footstep
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
AudioClip _meleeConnectClip; // combat feel pass: connect thunk
readonly AudioClip[] _footstepClips = new AudioClip[3]; // 07-15: heavy underwater thud variants (random pick per step)
ParticleSystem _stepFx; // 07-15: silt puff per footstep
ParticleSystem _bubbleFx; float _bubbleTimer; // 07-16: dome bubble exhaust (+1 per footstep, loose sync)
Light _lampLight; // 07-16e: the shoulder lamp CASTS — a warm spot beam riding the body yaw
Vector3 _lastFootPos; float _footDistAccum; float _footStepGap; bool _footInit; // stride-distance footsteps (local player)
Entity _localPlayer = Entity.Null;
uint[] _lastSocketFire = new uint[SocketId.Count]; // LANTERN per-socket fire-edge cache (was _lastLocalFireTick)
@@ -110,7 +114,11 @@ namespace ProjectM.Client
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
// 07-15 underwater feel: three deep muffled thud variants (noise burst, low sweep, long decay) —
// a random pick + volume jitter per step reads as weight (PlayClipAtPoint has no pitch control).
_footstepClips[0] = MakeClip("step_a", 95f, 38f, 0.13f, 0.5f, noise: true, decay: 7f);
_footstepClips[1] = MakeClip("step_b", 108f, 42f, 0.12f, 0.5f, noise: true, decay: 7f);
_footstepClips[2] = MakeClip("step_c", 120f, 45f, 0.11f, 0.5f, noise: true, decay: 7f);
}
protected override void OnStartRunning()
@@ -124,6 +132,12 @@ namespace ProjectM.Client
_muzzleFx = MakeBurst(_fxRoot, "Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
_dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
_swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
// 07-15: silt puff per footstep — dark, slow, hangs briefly (underwater weight read; dark = no bloom).
_stepFx = MakeBurst(_fxRoot, "SiltPuff", mat, new Color(0.06f, 0.10f, 0.11f, 0.85f), 0.20f, 0.8f, 1.1f, 128, gravity: 0.03f, shapeRadius: 0.18f, sizeTail: 1.5f);
// 07-16: bubble exhaust — tiny pale spheres that RISE (negative gravity) and drift from the dome.
_bubbleFx = MakeBurst(_fxRoot, "Bubbles", mat, new Color(0.75f, 0.9f, 1.0f, 0.85f), 0.055f, 0.25f, 2.4f, 128, gravity: -0.45f, shapeRadius: 0.06f, sizeTail: 0.35f);
BuildSlash();
for (int i = 0; i < NumberPoolSize; i++)
@@ -160,6 +174,8 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<DashState>();
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
EntityManager.CompleteDependencyBeforeRO<PlayerInput>(); // 07-15: local FX read the fire direction
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
@@ -326,13 +342,15 @@ namespace ProjectM.Client
var socks = EntityManager.GetBuffer<AbilitySocket>(_localPlayer, true);
var effs = EntityManager.GetBuffer<EffectiveSocketStats>(_localPlayer, true);
ref var fireAdb = ref fireDb.Value.Value;
Vector3 sface = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
// 07-15: the cue must match the DAMAGE direction — ResolveAim(Aim, facing), the same source the
// sim fire sites read; PlayerFacing alone is body-yaw and can be up to 180° off under move-facing.
float2 sfdir = new float2(0f, 1f);
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
var sfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(sfd) > 1e-6f) sface = new Vector3(sfd.x, 0f, sfd.y).normalized;
sfdir = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
}
float2 sfdir = new float2(sface.x, sface.z);
int sn = math.min(SocketId.Count, math.min(socks.Length, effs.Length));
for (int sk = 0; sk < sn; sk++)
{
@@ -342,7 +360,11 @@ namespace ProjectM.Client
if (!edge) continue;
byte sid = socks[sk].SparkId;
if (sid == 0) continue;
bool coneSpark = fireAdb.TryGetAbility(sid, out var sdef) && sdef.Archetype == (byte)AbilityArchetype.Cone;
bool haveDef = fireAdb.TryGetAbility(sid, out var sdef);
// 07-16 review: a blink (Movement archetype) stamps its cooldown row too — it's a dodge,
// not a cast; no muzzle/fire cue (mirrors TickWindowMath's Movement skip).
if (haveDef && sdef.Archetype == (byte)AbilityArchetype.Movement) continue;
bool coneSpark = haveDef && sdef.Archetype == (byte)AbilityArchetype.Cone;
if (!coneSpark)
{
Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8);
@@ -405,11 +427,14 @@ namespace ProjectM.Client
if (_swingTickInit && mc.SwingStartTick != 0 && mc.SwingStartTick != _lastLocalSwingTick)
{
int step = math.max(1, (int)mc.Step);
// 07-15: match the cleave's DAMAGE direction (ResolveAim(Aim, facing) — same as MeleeComboSystem).
Vector3 face = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
{
var d = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(d) > 1e-6f) face = new Vector3(d.x, 0f, d.y).normalized;
var d = FacingMath.ResolveAim(
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
face = new Vector3(d.x, 0f, d.y);
}
EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5);
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
@@ -458,18 +483,86 @@ namespace ProjectM.Client
}
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
// Shoulder-lamp beam (07-16e, operator: "make the lamp actually light up" — LANTERN's light-is-
// territory read starts on the suit). A warm STEADY spot (steady = true light) mounted at the kit
// lamp's clavicle offset, riding the BODY yaw (PlayerFacing — the lamp is bolted to the suit, so it
// sweeps with the body, not the cursor), tilted down onto the seabed ahead. Local player only.
if (_localPlayer != Entity.Null && FeelConfig.ShoulderLampIntensity > 0f)
{
if (_lampLight == null)
{
var lampGo = new GameObject("~ShoulderLamp");
lampGo.transform.SetParent(_fxRoot, false);
_lampLight = lampGo.AddComponent<Light>();
_lampLight.type = LightType.Spot;
_lampLight.color = new Color(1f, 0.78f, 0.45f); // warm gold — ours, steady
_lampLight.shadows = LightShadows.None;
_lampLight.spotAngle = 58f;
_lampLight.innerSpotAngle = 26f;
}
_lampLight.intensity = FeelConfig.ShoulderLampIntensity;
_lampLight.range = FeelConfig.ShoulderLampRange;
float2 lampFace = EntityManager.HasComponent<PlayerFacing>(_localPlayer)
? EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction
: new float2(0f, 1f);
if (math.lengthsq(lampFace) < 1e-6f) lampFace = new float2(0f, 1f);
var lampYaw = Quaternion.LookRotation(new Vector3(lampFace.x, 0f, lampFace.y));
_lampLight.transform.SetPositionAndRotation(
(Vector3)localPos + lampYaw * new Vector3(0.27f, 0.55f, 0.05f), // the kit lamp's shoulder offset
lampYaw * Quaternion.Euler(26f, 0f, 0f)); // down-tilt puts the hotspot ~3m ahead (14° landed ~6m out — too diffuse)
}
else if (_lampLight != null && _lampLight.intensity > 0f)
{
_lampLight.intensity = 0f;
}
// Bubble exhaust (07-16 gap-list): a periodic trickle from the dome (entity origin ≈ chest; dome
// sits ~0.78 up), jittered so it never reads as a metronome. One extra bubble rides each footstep.
if (_localPlayer != Entity.Null && FeelConfig.BubbleIntervalSec > 0f && FeelConfig.BubbleBurstCount > 0)
{
_bubbleTimer -= dt;
if (_bubbleTimer <= 0f)
{
_bubbleTimer = FeelConfig.BubbleIntervalSec * (0.8f + UnityEngine.Random.value * 0.4f);
EmitAt(_bubbleFx, (Vector3)localPos + Vector3.up * 0.78f, FeelConfig.BubbleBurstCount);
}
}
// Footsteps (07-15 underwater feel): stride-DISTANCE stepping — accumulate planar travel and step
// every FootstepStrideMeters, so cadence tracks the real (drifting) velocity instead of a fixed
// timer. Heavier read: deep thud variant + volume jitter + a silt puff at the feet.
if (_localPlayer != Entity.Null)
{
Vector3 lp = (Vector3)localPos;
if (_footInit)
{
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
_footTimer -= dt;
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
float planar = new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude;
float sp = dt > 1e-4f ? planar / dt : 0f;
_footStepGap -= dt;
if (sp >= FeelConfig.FootstepMinSpeed)
{
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
_footTimer = FeelConfig.FootstepIntervalSec;
_footDistAccum += planar;
// 07-16 review: the seconds floor stops a dash/blink from machine-gunning 2-4 thuds in ~0.1s
// (the old fixed-interval timer capped this implicitly).
if (_footDistAccum >= FeelConfig.FootstepStrideMeters && _footStepGap <= 0f)
{
_footDistAccum = 0f;
_footStepGap = 0.18f;
float j = FeelConfig.FootstepJitter;
var stepClip = _footstepClips[UnityEngine.Random.Range(0, _footstepClips.Length)];
PlayClip(stepClip, lp, FeelConfig.FootstepVolume * (1f + UnityEngine.Random.Range(-j, j)));
if (FeelConfig.FootstepPuffCount > 0)
EmitAt(_stepFx, lp + Vector3.down * 0.85f, FeelConfig.FootstepPuffCount); // entity origin = capsule center -> feet
if (FeelConfig.BubbleBurstCount > 0)
EmitAt(_bubbleFx, lp + Vector3.up * 0.78f, 1); // exertion bubble, loosely step-synced
}
}
else
{
_footDistAccum = FeelConfig.FootstepStrideMeters * 0.6f; // primed: the first step lands quickly on move-start
}
}
_lastFootPos = lp; _footInit = true;
@@ -805,6 +898,9 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
// NOTE (07-15 SoD facing, accepted asymmetry): remote arcs deliberately do NOT use FacingMath.ResolveAim —
// PlayerInput.Aim is owner-only (never replicated to non-owners), so body-yaw PlayerFacing is the closest
// replicated proxy; the cast-turn converges facing onto the aim within a few ticks. Do not "fix" this to Aim.
void UpdateRemoteSwings(float dt)
{
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;
@@ -116,12 +116,44 @@ namespace ProjectM.Client
public static float MeleeConnectVolume;
/// <summary>Kill-pop colored flash density on an enemy death.</summary>
public static int KillFlashBurstCount;
/// <summary>Soft footstep SFX volume.</summary>
/// <summary>Footstep SFX volume (07-15: deep muffled underwater thud).</summary>
public static float FootstepVolume;
/// <summary>Seconds between footsteps while the local player is moving.</summary>
public static float FootstepIntervalSec;
/// <summary>Planar meters travelled per footstep — stride-DISTANCE stepping, so cadence tracks the
/// real (drifting) velocity instead of a fixed timer (07-15 underwater feel).</summary>
public static float FootstepStrideMeters;
/// <summary>Local player speed (u/s) above which footsteps play.</summary>
public static float FootstepMinSpeed;
/// <summary>Per-step random volume jitter (±fraction) — breaks the metronome read.</summary>
public static float FootstepJitter;
/// <summary>Silt-puff particles per footstep (0 = off).</summary>
public static int FootstepPuffCount;
// ---- 07-16 underwater feel (gap-list): bubbles, ambience, camera weight, banking ----
/// <summary>Seconds between dome bubble-exhaust puffs (0 = off).</summary>
public static float BubbleIntervalSec;
/// <summary>Bubbles per exhaust puff.</summary>
public static int BubbleBurstCount;
/// <summary>Underwater ambience bed volume (Music bus).</summary>
public static float AmbienceVolume;
/// <summary>Mean seconds between distant groans (0 = off; ±40% jitter).</summary>
public static float GroanIntervalSec;
/// <summary>Distant-groan volume (Music bus).</summary>
public static float GroanVolume;
/// <summary>Camera follow-sharpness multiplier (&lt;1 = the camera drags through the water).</summary>
public static float CameraDragMult;
/// <summary>The facing turn rate (deg/s) at which the additive body bank reaches full (±1).</summary>
public static float BankFullTurnDegPerSec;
/// <summary>The WALK ring's natural travel speed (u/s; measured off the pack's root-motion variant).
/// StrideScale fine-corrects playback against the walk→run blended natural (07-16b/e).</summary>
public static float TrudgeNaturalSpeed;
/// <summary>The RUN ring's natural travel speed (u/s; measured).</summary>
public static float RunNaturalSpeed;
/// <summary>Shoulder-lamp spot intensity (0 = lamp off). LANTERN: light is territory — the suit lamp CASTS.</summary>
public static float ShoulderLampIntensity;
/// <summary>Shoulder-lamp beam range (m).</summary>
public static float ShoulderLampRange;
/// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
public static bool RumbleEnabled;
/// <summary>Rumble strength on a local hit taken / dealt.</summary>
@@ -228,9 +260,24 @@ namespace ProjectM.Client
MeleeConnectFovKick = 0.8f;
MeleeConnectVolume = 0.55f;
KillFlashBurstCount = 20;
FootstepVolume = 0.16f;
FootstepIntervalSec = 0.32f;
FootstepMinSpeed = 1.5f;
FootstepVolume = 0.30f; // 07-15: heavier, more present underwater thud
FootstepStrideMeters = 1.5f; // one step per ~1.5m of planar travel
FootstepMinSpeed = 1.0f;
FootstepJitter = 0.12f;
FootstepPuffCount = 5;
BubbleIntervalSec = 1.6f; // 07-16: dome bubble exhaust cadence
BubbleBurstCount = 2;
AmbienceVolume = 0.14f; // underwater bed (Music bus)
GroanIntervalSec = 24f; // distant groans, ±40% jitter
GroanVolume = 0.35f;
CameraDragMult = 0.8f; // heavier camera through the water (multiplies FollowSharpness)
BankFullTurnDegPerSec = 270f;
TrudgeNaturalSpeed = 1.46f; // measured: A_Walk_FwdStrafeF_RootMotion_Masc averageSpeed (walk ring)
RunNaturalSpeed = 2.6f; // measured: A_Run_FwdStrafeF_RootMotion_Masc averageSpeed (run ring)
ShoulderLampIntensity = 8f; // 07-16e: the suit lamp actually lights (3 got lost in the murk ambient)
ShoulderLampRange = 12f;
RumbleEnabled = true;
RumbleHit = 0.25f;
RumbleKill = 0.45f;
@@ -46,17 +46,26 @@ namespace ProjectM.Client
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
static readonly FastAnimatorParameter k_IsFiring = new FastAnimatorParameter("IsFiring"); // Blender-authored A_Fire_OneHand (B7)
static readonly FastAnimatorParameter k_IsDashing = new FastAnimatorParameter("IsDashing"); // Blender-authored A_Dash_Lean (local player only - DashState is not replicated)
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
static readonly FastAnimatorParameter k_Bank = new FastAnimatorParameter("Bank");
static readonly FastAnimatorParameter k_StrideScale = new FastAnimatorParameter("StrideScale"); // 07-16b: Locomotion playback = planarSpeed / TrudgeNaturalSpeed (foot-skate fix) // 07-16: additive Posture layer — signed facing turn rate, local player only (subtle; not derived for remotes)
static readonly FastAnimatorParameter k_ComboStep = new FastAnimatorParameter("ComboStep"); // replicated MeleeCombo.Step -> per-step swing clips (Swing1/2/3)
static readonly FastAnimatorParameter k_IsCone = new FastAnimatorParameter("IsCone"); // ability archetype (replicated AbilityRef + blob): Cone -> two-hand slam, else the shot
// Ticks after a swing-start that IsAttacking stays true (drives the MeleeSwing state). Kept < the swing lock
// (MeleeRecoverTicks ~16) so a CHAINED swing re-pulses the bool false->true and re-triggers the Any State
// transition per hit. ~0.22s @ 60Hz. Presentation-only.
const uint k_AttackAnimTicks = 13;
const uint k_AttackAnimTicks = PlayerAimSystem.CastFacingTicks; // structurally tied (07-16 review): body-yaw cast-turn and the swing anim pulse must agree
// Remote prevPos cache (per ghost Entity). Pruned every frame (a vanished remote = a despawn).
NativeParallelHashMap<Entity, float3> _prevPos;
// 07-16 banking: previous local-player facing + the smoothed bank value (main-thread state; one entity).
float2 _prevLocalFacing;
bool _bankInit;
float _bankSmoothed;
protected override void OnCreate()
{
_prevPos = new NativeParallelHashMap<Entity, float3>(16, Allocator.Persistent);
@@ -77,13 +86,37 @@ namespace ProjectM.Client
// Ability blob for the per-class special read (Cone -> slam anim); default(BlobAssetReference) if absent.
var abilityBlob = SystemAPI.TryGetSingleton<AbilityDatabase>(out var adbSingleton) ? adbSingleton.Value : default;
// --- LOCAL owner (CC velocity) ---
// 07-16 banking: signed facing turn rate -> Bank (-1..1, + = bank right, leaning INTO the turn).
// Sampled main-thread (a single local player), smoothed so the additive pose eases in and out.
float bankTarget = 0f;
foreach (var facingRO in SystemAPI.Query<RefRO<PlayerFacing>>().WithAll<GhostOwnerIsLocal>())
{
float2 f = facingRO.ValueRO.Direction;
if (_bankInit && math.lengthsq(f) > 1e-6f && math.lengthsq(_prevLocalFacing) > 1e-6f)
{
float2 a2 = math.normalize(_prevLocalFacing);
float2 b2 = math.normalize(f);
float signed = math.atan2(a2.x * b2.y - a2.y * b2.x, math.clamp(math.dot(a2, b2), -1f, 1f));
float degPerSec = math.degrees(signed) / dt;
bankTarget = math.clamp(-degPerSec / math.max(30f, FeelConfig.BankFullTurnDegPerSec), -1f, 1f);
}
_prevLocalFacing = f;
_bankInit = true;
break;
}
_bankSmoothed = math.lerp(_bankSmoothed, bankTarget, 1f - math.exp(-6f * dt));
// --- LOCAL owner (CC velocity) ---
var localJob = new LocalDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isDead = k_IsDead,
isAttacking = k_IsAttacking, isFiring = k_IsFiring, isDashing = k_IsDashing,
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
bank = k_Bank, bankValue = _bankSmoothed,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
};
Dependency = localJob.ScheduleParallel(Dependency);
@@ -96,6 +129,8 @@ namespace ProjectM.Client
comboStep = k_ComboStep, isCone = k_IsCone, abilityDb = abilityBlob,
serverTick = serverTick, attackTicks = k_AttackAnimTicks,
dt = dt,
strideScale = k_StrideScale, trudgeNaturalSpeed = math.max(0.5f, FeelConfig.TrudgeNaturalSpeed),
runNaturalSpeed = math.max(0.5f, FeelConfig.RunNaturalSpeed),
prevPos = _prevPos,
seen = seen,
};
@@ -113,53 +148,9 @@ namespace ProjectM.Client
if (!seen.Contains(keys[i])) _prevPos.Remove(keys[i]);
}
// True while now is within [SwingStartTick, SwingStartTick + animTicks) -- a per-swing pulse that re-triggers
// on each chained swing. NetworkTick arithmetic (wrap-safe). Presentation-only, Burst-safe.
static bool SwingActive(in MeleeCombo mc, NetworkTick serverTick, uint animTicks)
{
if (mc.SwingStartTick == 0u || !serverTick.IsValid) return false;
var start = new NetworkTick(mc.SwingStartTick);
var end = new NetworkTick(TickUtil.NonZero(mc.SwingStartTick + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
// B7: the class FIRE ability finally plays a body animation - a stateless window derived from the
// replicated AbilityCooldown ([GhostField] NextFireTick) minus the locally-derived CooldownTicks
// (EffectiveAbilityStats recomputes identically on every world), so it works for the predicted local
// player AND interpolated remotes with no cached edges (review B7: edge-caches false-fire on
// relevancy/join/rollback). Reuses the swing clip until a dedicated fire clip exists.
static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks)
{
if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false;
uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
var start = new NetworkTick(startRaw);
var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
// LANTERN 4-socket fire/cone anim resolution (any-socket model): firing if ANY socketed Spark's
// per-socket cooldown window is mid-fire; cone if any such active socket holds a Cone-archetype Spark.
// Replaces the single-ability FireActive + IsCone reads (AbilityCooldown/EffectiveAbilityStats/AbilityRef).
static void SocketFireAndCone(in SocketCooldown cd, DynamicBuffer<AbilitySocket> sockets,
DynamicBuffer<EffectiveSocketStats> effSockets, BlobAssetReference<AbilityDatabaseBlob> abilityDb,
NetworkTick serverTick, uint animTicks, out bool firing, out bool cone)
{
firing = false; cone = false;
int n = math.min(SocketId.Count, math.min(sockets.Length, effSockets.Length));
bool haveDb = abilityDb.IsCreated;
for (int sk = 0; sk < n; sk++)
{
byte sid = sockets[sk].SparkId;
if (sid == 0) continue;
if (!FireActive(cd.Get(sk), effSockets[sk].CooldownTicks, serverTick, animTicks)) continue;
firing = true;
if (haveDb)
{
ref var adb = ref abilityDb.Value;
if (adb.TryGetAbility(sid, out var d) && d.Archetype == (byte)AbilityArchetype.Cone) cone = true;
}
}
}
// The swing/fire tick-window predicates (SwingActive/FireActive/SocketFireAndCone) moved to
// TickWindowMath (Simulation/Combat) 07-15 — shared verbatim with the predicted facing path
// (PlayerAimSystem); this system passes k_AttackAnimTicks at the call sites.
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> so alive
// (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the
@@ -169,7 +160,13 @@ namespace ProjectM.Client
[WithPresent(typeof(Dead))]
partial struct LocalDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public FastAnimatorParameter bank;
public float bankValue;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public FastAnimatorParameter moveX, moveZ, speed, isDead, isAttacking, isFiring, isDashing, comboStep, isCone;
public BlobAssetReference<AbilityDatabaseBlob> abilityDb;
public NetworkTick serverTick;
public uint attackTicks;
@@ -190,8 +187,8 @@ namespace ProjectM.Client
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks));
SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingL, out bool coneL);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingL, out bool coneL);
if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingL);
// Dash lean: the LOCAL player's predicted DashState window [StartTick, IFrameUntilTick+tail).
bool dashActive = false;
@@ -202,6 +199,10 @@ namespace ProjectM.Client
dashActive = dStart.IsValid && dEnd.IsValid && !dStart.IsNewerThan(serverTick) && dEnd.IsNewerThan(serverTick);
}
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, dashActive);
if (a.HasParameter(bank)) a.SetParameterValue(bank, bankValue);
if (a.HasParameter(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(body.RelativeVelocity.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneL);
}
@@ -219,6 +220,9 @@ namespace ProjectM.Client
public NetworkTick serverTick;
public uint attackTicks;
public float dt;
public FastAnimatorParameter strideScale;
public float trudgeNaturalSpeed;
public float runNaturalSpeed;
public NativeParallelHashMap<Entity, float3> prevPos;
public NativeParallelHashSet<Entity> seen;
@@ -245,16 +249,30 @@ namespace ProjectM.Client
var a = new AnimatorParametersAspect(parametersArr, indexTable);
float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed);
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks));
SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingR, out bool coneR);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, TickWindowMath.SwingActive(melee, serverTick, attackTicks));
TickWindowMath.SocketFireAndCone(socketCd, sockets, effSockets, abilityDb, serverTick, attackTicks, out bool firingR, out bool coneR);
if (a.HasParameter(isFiring)) a.SetParameterValue(isFiring, firingR);
if (a.HasParameter(isDashing)) a.SetParameterValue(isDashing, false); // DashState is not replicated to remotes
if (a.HasParameter(strideScale))
a.SetParameterValue(strideScale, StrideScaleValue(math.length(vel.xz), stats.MoveSpeed, trudgeNaturalSpeed, runNaturalSpeed));
if (a.HasParameter(comboStep)) a.SetParameterValue(comboStep, (int)melee.Step);
if (a.HasParameter(isCone)) a.SetParameterValue(isCone, coneR);
}
}
// ParameterValue has implicit float/bool operators -> SetParameterValue(key, float) / (key, bool) compile.
// 07-16e: playback correction against the walk→run BLENDED natural — the two-ring gait tree picks
// the clip; this keeps residual cadence honest between/beyond the rings. Pure, Burst-safe.
static float StrideScaleValue(float planarSpeed, float maxSpeed, float walkNatural, float runNatural)
{
float maxSp = math.max(0.5f, maxSpeed);
float walkRing = walkNatural / maxSp;
float t = math.saturate((planarSpeed / maxSp - walkRing) / math.max(0.05f, 1f - walkRing));
float natural = math.lerp(walkNatural, runNatural, t);
return math.clamp(planarSpeed / math.max(0.3f, natural), 0.6f, 2.2f);
}
// ParameterValue has implicit float/bool operators -> SetParameterValue(key, float) / (key, bool) compile.
static void Write(ref AnimatorParametersAspect a, float3 p, bool isDeadVal,
FastAnimatorParameter moveX, FastAnimatorParameter moveZ,
FastAnimatorParameter speed, FastAnimatorParameter isDead)
@@ -96,6 +96,15 @@ namespace ProjectM.Client
Camera _cam;
Vector3 _leadOffset; // smoothed look-ahead offset (world units), eased toward the desired lead each frame
// 07-16c DEV ZOOM (LoL/SoD-style scroll-to-inspect; may not ship): the wheel moves a smoothed
// distance target between ZoomMinDistance and the rig's authored Distance — zoom IN only, scroll
// back out returns to the gameplay framing. Windows wheels report ±120/notch, some devices ±1.
const float ZoomMinDistance = 3.5f;
const float ZoomStepPerNotch = 1.1f;
const float ZoomSharpness = 10f;
float _zoomTarget = -1f;
float _zoomDist = -1f;
void Awake() => _cam = GetComponent<Camera>();
void LateUpdate()
@@ -127,10 +136,26 @@ namespace ProjectM.Client
_leadOffset = Vector3.Lerp(_leadOffset, desiredLead, leadK);
target += _leadOffset;
var rot = Quaternion.Euler(Pitch, Yaw, 0f);
Vector3 desired = target - (rot * Vector3.forward) * Distance;
// Dev zoom: scroll up = closer; clamped [ZoomMinDistance, authored Distance].
if (_zoomTarget < 0f) { _zoomTarget = Distance; _zoomDist = Distance; }
var scrollMouse = UnityEngine.InputSystem.Mouse.current;
if (scrollMouse != null)
{
float raw = scrollMouse.scroll.ReadValue().y;
float notches = Mathf.Abs(raw) >= 100f ? raw / 120f : raw;
if (Mathf.Abs(notches) > 0.01f)
_zoomTarget = Mathf.Clamp(_zoomTarget - notches * ZoomStepPerNotch, ZoomMinDistance, Distance);
}
_zoomTarget = Mathf.Min(_zoomTarget, Distance); // authored Distance can change live in the inspector
_zoomDist = Mathf.Lerp(_zoomDist, _zoomTarget, 1f - Mathf.Exp(-ZoomSharpness * Time.deltaTime));
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-FollowSharpness * Time.deltaTime);
var rot = Quaternion.Euler(Pitch, Yaw, 0f);
Vector3 desired = target - (rot * Vector3.forward) * _zoomDist;
// 07-16 underwater feel: CameraDragMult (<1) makes the follow DRAG through the water (0 = knob
// uninitialized -> no change). Multiplies the serialized FollowSharpness so scenes stay untouched.
float dragSharp = FollowSharpness * Mathf.Max(0.05f, FeelConfig.CameraDragMult <= 0f ? 1f : FeelConfig.CameraDragMult);
float k = FollowSharpness <= 0f ? 1f : 1f - Mathf.Exp(-dragSharp * Time.deltaTime);
Vector3 basePos;
if (s_holdFrames > 0) { s_holdFrames--; basePos = transform.position; } // C4: brief hit-stop hold (freeze the follow a couple frames)
else basePos = Vector3.Lerp(transform.position, desired, k);
@@ -126,6 +126,471 @@ namespace ProjectM.EditorTools
AssetDatabase.Refresh();
}
/// <summary>LANTERN suit-attachment follow-up (DR-051): graft the Bathynaut kitbash attachments (brass
/// diving-bell dome + porthole, back tank pack, shoulder dive-lamp — exported rigid-skinned to
/// Head/Spine_03/Clavicle_L in Suit_Bathynaut_Kitbash.blend) onto Player.prefab IN PLACE (GUID preserved,
/// visual-only: ghost surface unchanged, same class of edit as the P5 body swap). The kit FBX's two SMRs
/// are re-bound onto the player's existing flattened skeleton by bone NAME. Brass rides the shared
/// M_Skinned_Palette (AnimatedLitShader + PaletteAtlas); glow rides M_EmissiveGloam_WarmSkinned
/// (steady warm = true light). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Attach Bathynaut Kit (LANTERN)")]
public static void AttachBathynautKit()
{
const string kitFbx = "Assets/_Project/Art/Models/SM_Suit_BathynautKit.fbx";
const string brassMat = "Assets/_Project/Art/Materials/M_Skinned_Palette.mat";
const string glowMat = "Assets/_Project/Art/Materials/M_EmissiveGloam_WarmSkinned.mat";
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string barePrefab = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_Male_01.prefab";
const string output = "Assets/_Project/Prefabs/Player.prefab";
var kit = AssetDatabase.LoadAssetAtPath<GameObject>(kitFbx);
var mBrass = AssetDatabase.LoadAssetAtPath<Material>(brassMat);
var mGlow = AssetDatabase.LoadAssetAtPath<Material>(glowMat);
var mBody = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (kit == null) { Debug.LogError($"[PlayerRigTools] Kit FBX missing: {kitFbx}"); return; }
if (mBrass == null) { Debug.LogError($"[PlayerRigTools] Brass material missing: {brassMat}"); return; }
if (mGlow == null) { Debug.LogError($"[PlayerRigTools] Glow material missing: {glowMat}"); return; }
var root = PrefabUtility.LoadPrefabContents(output);
try
{
// Bone map: every transform under the player root by name (the flattened Synty skeleton).
var bones = new System.Collections.Generic.Dictionary<string, Transform>();
foreach (var t in root.GetComponentsInChildren<Transform>(true))
if (!bones.ContainsKey(t.name)) bones.Add(t.name, t);
foreach (var src in kit.GetComponentsInChildren<SkinnedMeshRenderer>(true))
GraftSmr(root, bones, src, src.name.Contains("Glow") ? mGlow : mBrass);
// 07-15 operator fork: brass dome + BARE head (the kitbash look) — drop the Synty sci-fi helmet
// and graft the bare head SMR so the face reads through the amber porthole up close.
var helmet = root.transform.Find("SM_Chr_Attach_SpaceSoldier_Male_Helmet_01");
if (helmet != null) Object.DestroyImmediate(helmet.gameObject);
// Purge any wrong-variant head graft (the Synty container holds Female/Male heads) before re-grafting.
for (int i = root.transform.childCount - 1; i >= 0; i--)
{
var c = root.transform.GetChild(i);
if (c.name.StartsWith("SM_Chr_SpaceSoldier_Head_") && c.name != "SM_Chr_SpaceSoldier_Head_Male_01")
Object.DestroyImmediate(c.gameObject);
}
if (root.transform.Find("SM_Chr_SpaceSoldier_Head_Male_01") == null)
{
var bare = AssetDatabase.LoadAssetAtPath<GameObject>(barePrefab);
SkinnedMeshRenderer headSrc = null;
if (bare != null)
foreach (var s in bare.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (s.name == "SM_Chr_SpaceSoldier_Head_Male_01") { headSrc = s; break; }
if (headSrc != null && mBody != null) GraftSmr(root, bones, headSrc, mBody, rebase: false); // meter-scale Synty source + blend-skinned neck: native bindposes are correct
else Debug.LogWarning("[PlayerRigTools] Bare head SMR (or body material) not found — helmet removed without a head.");
}
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[PlayerRigTools] Bathynaut kit attached to Player.prefab (in place).");
}
/// <summary>Re-bind a source SkinnedMeshRenderer onto the player's existing flattened skeleton by bone
/// NAME and drop it in as a fresh child (idempotent: replaces a same-named child). Blender dedup
/// suffixes ("Finger_01.001" — the Synty rig duplicates L/R finger names) fall back to the base name;
/// safe because the kit meshes only WEIGHT Head/Spine_03/Clavicle_L.</summary>
static void GraftSmr(GameObject root, System.Collections.Generic.Dictionary<string, Transform> bones,
SkinnedMeshRenderer src, Material material, bool rebase = true)
{
// Resolve the bone rebind FIRST (07-16 review): destroying the old child before validation meant a
// bone-name mismatch on a re-run silently stripped the piece from the prefab.
var srcBones = src.bones;
var dst = new Transform[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
string bn = srcBones[i] != null ? srcBones[i].name : null;
Transform t = null;
if (bn != null && !bones.TryGetValue(bn, out t))
{
// Blender dedup suffix ("Finger_01.001" — the Synty rig duplicates L/R finger names): strip
// and take the first base-name match. Safe: the kit only WEIGHTS Head/Spine_03/Clavicle_L.
int dot = bn.LastIndexOf('.');
if (dot > 0) bones.TryGetValue(bn.Substring(0, dot), out t);
}
if (t == null)
{
Debug.LogError($"[PlayerRigTools] Player skeleton missing bone '{bn ?? "<null>"}' for {src.name}; skipped (existing graft left untouched).");
return;
}
dst[i] = t;
}
Mesh mesh;
if (!rebase)
{
// Source skeleton already matches the player's conventions (meter-scale Synty prefab, e.g. the
// bare head): keep the native mesh + bindposes; only the bones array is remapped.
mesh = src.sharedMesh;
}
else
{
// REBASE (07-15/16): a Blender FBX roundtrip imports cm bones under a 0.01 armature (Synty
// prefabs are meter bones) so raw bindpose reuse explodes ×100 on rebind. Bake the SOURCE rest
// pose into world-space vertices and compute bindposes as the inverse of the RIGID
// (scale-stripped) rest matrices — self-consistent feet-at-0 rest space regardless of exporter
// scaling; the player's Root feet-offset applies at runtime exactly like the body meshes.
var srcMesh = src.sharedMesh;
var srcBind = srcMesh.bindposes;
var restWorld = new Matrix4x4[srcBones.Length];
var skinRest = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
restWorld[i] = srcBones[i].localToWorldMatrix;
skinRest[i] = restWorld[i] * srcBind[i];
}
var verts = srcMesh.vertices;
var norms = srcMesh.normals;
var weights = srcMesh.boneWeights;
var newVerts = new Vector3[verts.Length];
var newNorms = new Vector3[norms.Length];
for (int v = 0; v < verts.Length; v++)
{
// The rigid rebase is only valid for 100%-single-bone skins (07-16 review guard): a
// blend-skinned mesh (e.g. the bare head's neck weights) would distort — graft it rebase:false.
if (weights[v].weight0 < 0.999f)
{
Debug.LogError($"[PlayerRigTools] {src.name} vertex {v} is blend-skinned (w0={weights[v].weight0:0.###}); rigid rebase would distort — skipped (graft with rebase:false instead).");
return;
}
int b = weights[v].boneIndex0;
newVerts[v] = skinRest[b].MultiplyPoint3x4(verts[v]);
newNorms[v] = skinRest[b].rotation * norms[v];
}
var newBind = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
var m = restWorld[i];
newBind[i] = Matrix4x4.TRS((Vector3)m.GetColumn(3), m.rotation, Vector3.one).inverse;
}
// Persist the rebased mesh GUID-stably (Clear+refill keeps an existing asset's GUID on re-runs).
string meshPath = $"Assets/_Project/Art/Models/Rebased_{src.name}.asset";
mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
bool fresh = mesh == null;
if (fresh) mesh = new Mesh();
else mesh.Clear();
mesh.name = "Rebased_" + src.name;
mesh.vertices = newVerts;
mesh.normals = newNorms;
mesh.uv = srcMesh.uv;
mesh.subMeshCount = srcMesh.subMeshCount;
for (int s = 0; s < srcMesh.subMeshCount; s++) mesh.SetTriangles(srcMesh.GetTriangles(s), s);
mesh.boneWeights = weights;
mesh.bindposes = newBind;
mesh.RecalculateBounds();
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh (BatchMeshID missing)
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath);
else EditorUtility.SetDirty(mesh);
}
var old = root.transform.Find(src.name);
if (old != null) Object.DestroyImmediate(old.gameObject);
var go = new GameObject(src.name);
go.transform.SetParent(root.transform, false);
var smr = go.AddComponent<SkinnedMeshRenderer>();
smr.sharedMesh = mesh;
smr.bones = dst;
smr.rootBone = src.rootBone != null && bones.TryGetValue(src.rootBone.name, out var rb) ? rb : dst[0];
if (!rebase)
{
smr.localBounds = src.localBounds;
}
else
{
// Bounds in rootBone space: rebased (feet-at-0 world) verts through the RIGID root rest inverse.
var rootRest = src.rootBone != null ? src.rootBone.localToWorldMatrix : srcBones[0].localToWorldMatrix;
var rootRestInv = Matrix4x4.TRS((Vector3)rootRest.GetColumn(3), rootRest.rotation, Vector3.one).inverse;
var mv = mesh.vertices;
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
for (int v = 0; v < mv.Length; v++)
{
var p = rootRestInv.MultiplyPoint3x4(mv[v]);
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p);
}
var lb = new Bounds(); lb.SetMinMax(bmin, bmax);
smr.localBounds = lb;
}
smr.sharedMaterials = new[] { material };
Debug.Log($"[PlayerRigTools] Grafted {src.name} ({mesh.vertexCount}v, {dst.Length} bones{(rebase ? ", rebased" : "")}).");
}
/// <summary>07-15 underwater feel: heavier gait — slow the Locomotion blend-state playback and lengthen
/// the Idle⇄Locomotion transition blends (weightier starts/stops). The velocity-side drift (sharpness
/// 15→6) already slows the Speed/MoveX/MoveZ ramps; this makes the cycle itself read heavy. Uses the
/// AnimatorController API per the manage_animation gotcha. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Retime Locomotion Gait (LANTERN)")]
public static void RetimeLocomotionGait()
{
const float gaitSpeed = 0.85f;
const float blend = 0.25f;
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var sm = ac.layers[0].stateMachine;
int retimed = 0, blends = 0;
foreach (var cs in sm.states)
{
if (cs.state.name == "Locomotion" && !Mathf.Approximately(cs.state.speed, gaitSpeed))
{
cs.state.speed = gaitSpeed;
retimed++;
}
foreach (var tr in cs.state.transitions)
{
if (tr.destinationState == null) continue;
bool idleLoco = (cs.state.name == "Idle" && tr.destinationState.name == "Locomotion")
|| (cs.state.name == "Locomotion" && tr.destinationState.name == "Idle");
if (idleLoco && !Mathf.Approximately(tr.duration, blend)) { tr.duration = blend; blends++; }
}
}
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Locomotion gait retimed (speed {gaitSpeed}: {retimed} state(s); idle⇄locomotion blends {blend}s: {blends} transition(s)).");
}
/// <summary>07-16 gap-list: configure the four Blender underwater-feel clips (A_Idle_Sway, A_Walk_Trudge,
/// A_Bank_L/R) — HUMANOID + CreateFromThisModel (CopyFromOther fails on Blender's Armature node), root
/// motion baked into pose (lock+keepOriginal ×3 — the CC owns transforms), loops; the bank poses import
/// as ADDITIVE (playback frames 8-12 held pose, additive reference = frame 1's base stance) for the
/// Posture layer. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - 1 Import Underwater Clips (LANTERN)")]
public static void ImportUnderwaterClips()
{
var specs = new (string file, float first, float last, bool additive)[]
{
("A_Idle_Sway", 1f, 121f, false),
("A_Walk_Trudge", 1f, 33f, false), // v2: 32f long-stride cycle (foot-skate fix)
("A_Bank_L", 8f, 12f, true),
("A_Bank_R", 8f, 12f, true),
};
foreach (var s in specs)
{
string path = $"Assets/_Project/Animation/Authored/{s.file}.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp == null) { Debug.LogError($"[PlayerRigTools] Missing clip fbx: {path}"); continue; }
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport(); // two-pass: takes only enumerate AFTER the rig import runs
var clips = imp.defaultClipAnimations;
if (clips.Length == 0) { Debug.LogError($"[PlayerRigTools] No takes in {path}"); continue; }
var c = clips[0];
c.name = s.file;
c.firstFrame = s.first;
c.lastFrame = s.last;
c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
if (s.additive)
{
c.hasAdditiveReferencePose = true;
c.additiveReferencePoseFrame = 1f; // the base stance key — the layer plays bank-minus-stance
}
imp.clipAnimations = new[] { c };
imp.SaveAndReimport();
Debug.Log($"[PlayerRigTools] Imported {s.file} (frames {s.first}-{s.last}{(s.additive ? ", additive" : "")}).");
}
// A_Lean_Fwd: ONE take -> TWO additive clips (the held lean + a zero-delta companion so the Lead
// layer's 1D Speed tree has a neutral end at 0).
{
string path = "Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp != null)
{
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport();
var takes = imp.defaultClipAnimations;
if (takes.Length > 0)
{
ModelImporterClipAnimation Mk(string n, float f0, float f1)
{
var c = imp.defaultClipAnimations[0];
c.name = n; c.firstFrame = f0; c.lastFrame = f1; c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
c.hasAdditiveReferencePose = true; c.additiveReferencePoseFrame = 1f;
return c;
}
imp.clipAnimations = new[] { Mk("A_Lean_Fwd", 8f, 12f), Mk("A_Lean_Zero", 1f, 2f) };
imp.SaveAndReimport();
Debug.Log("[PlayerRigTools] Imported A_Lean_Fwd + A_Lean_Zero (additive pair).");
}
else Debug.LogError("[PlayerRigTools] No takes in A_Lean_Fwd.fbx");
}
}
}
static AnimationClip LoadAuthoredClip(string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath($"Assets/_Project/Animation/Authored/{file}.fbx"))
if (o is AnimationClip clip && !clip.name.StartsWith("__preview")) return clip;
return null;
}
/// <summary>07-16 gap-list: wire the underwater clips into AC_PlayerTopDown — Idle state → A_Idle_Sway
/// (buoyant sway replaces the static idle); the Locomotion 2D tree's FORWARD motion → A_Walk_Trudge
/// (chest-lead heavy walk; strafe/back clips stay Synty — they mostly show mid-cast); plus an ADDITIVE
/// "Posture" layer with a 1D Bank tree (A_Bank_L @ -1 … A_Bank_R @ +1, symmetric so Bank=0 cancels to
/// neutral) driven by PlayerAnimationDriveSystem's turn-rate Bank param. Idempotent.</summary>
[MenuItem("ProjectM/Animation/Player - 2 Wire Underwater Clips (LANTERN)")]
public static void WireUnderwaterFeelClips()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var bankL = LoadAuthoredClip("A_Bank_L");
var bankR = LoadAuthoredClip("A_Bank_R");
if (bankL == null || bankR == null)
{ Debug.LogError("[PlayerRigTools] Underwater clips not found — run Import Underwater Clips first."); return; }
// 07-16d (operator): idles come from the AnimationIdles pack (professionally-authored base loop).
AnimationClip packIdle = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/Synty/AnimationIdles/Animations/Polygon/Masculine/Base/Stances/A_POLY_IDL_Base_Masc.fbx"))
if (o is AnimationClip pcl && !pcl.name.StartsWith("__preview")) packIdle = pcl;
if (packIdle == null) Debug.LogError("[PlayerRigTools] AnimationIdles base idle not found — Idle keeps its current motion.");
const string locoDir = "Assets/Synty/AnimationBaseLocomotion/Animations/Polygon/Masculine/Locomotion/";
AnimationClip Clip(string sub, string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(locoDir + sub + "/" + file + ".fbx"))
if (o is AnimationClip cl && !cl.name.StartsWith("__preview")) return cl;
Debug.LogError($"[PlayerRigTools] Locomotion clip missing: {sub}/{file}");
return null;
}
// 07-16e TWO-RING gait tree: MoveX/MoveZ magnitude = speed/MoveSpeed, so a WALK ring at the walk
// clip's natural fraction (1.46/4.2 MoveSpeed ≈ 0.35) and a RUN ring at full deflection blend
// walk→run as the underwater accel ramps velocity — "walk first, run when held". StrideScale
// fine-corrects residual cadence against the blended natural (PlayerAnimationDriveSystem).
const float walkRing = 0.35f; // = FeelConfig.TrudgeNaturalSpeed / Character_Default MoveSpeed (4.2)
var dirs = new (Vector2 dir, string walk, string run)[]
{
(new Vector2(0f, 1f), "A_Walk_FwdStrafeF_Masc", "A_Run_FwdStrafeF_Masc"),
(new Vector2(0.7f, 0.7f), "A_Walk_FwdStrafeFR_Masc", "A_Run_FwdStrafeFR_Masc"),
(new Vector2(1f, 0f), "A_Walk_FwdStrafeR_Masc", "A_Run_FwdStrafeR_Masc"),
(new Vector2(0.7f, -0.7f), "A_Walk_BckStrafeBR_Masc", "A_Run_FwdStrafeBR_Masc"),
(new Vector2(0f, -1f), "A_Walk_BckStrafeB_Masc", "A_Run_BckStrafeB_Masc"),
(new Vector2(-0.7f, -0.7f), "A_Walk_BckStrafeBL_Masc", "A_Run_BckStrafeBL_Masc"),
(new Vector2(-1f, 0f), "A_Walk_FwdStrafeL_Masc", "A_Run_FwdStrafeL_Masc"),
(new Vector2(-0.7f, 0.7f), "A_Walk_FwdStrafeFL_Masc", "A_Run_FwdStrafeFL_Masc"),
};
var sm = ac.layers[0].stateMachine;
foreach (var cs in sm.states)
{
if (cs.state.name == "Idle")
{
if (packIdle != null) cs.state.motion = packIdle;
}
else if (cs.state.name == "Locomotion" && cs.state.motion is BlendTree tree)
{
cs.state.speed = 1f;
cs.state.speedParameterActive = true;
cs.state.speedParameter = "StrideScale";
tree.useAutomaticThresholds = false;
tree.children = new UnityEditor.Animations.ChildMotion[0]; // full rebuild (idempotent)
if (packIdle != null) tree.AddChild(packIdle, Vector2.zero);
foreach (var d in dirs)
{
var w = Clip("Walk", d.walk);
var r = Clip("Run", d.run);
if (w != null) tree.AddChild(w, d.dir.normalized * walkRing);
if (r != null) tree.AddChild(r, d.dir);
}
Debug.Log($"[PlayerRigTools] Locomotion rebuilt: idle center + walk ring @{walkRing} + run ring @1.0 ({tree.children.Length} children).");
}
}
if (!AnimRigUtil.HasParam(ac, "Bank"))
ac.AddParameter("Bank", AnimatorControllerParameterType.Float);
if (!AnimRigUtil.HasParam(ac, "StrideScale"))
{
ac.AddParameter("StrideScale", AnimatorControllerParameterType.Float);
var ps = ac.parameters;
for (int i = 0; i < ps.Length; i++) if (ps[i].name == "StrideScale") ps[i].defaultFloat = 1f;
ac.parameters = ps;
}
// Additive Posture layer (Bank: symmetric poses cancel at 0).
int postureIdx = EnsureAdditiveLayer(ac, "Posture");
var psm = ac.layers[postureIdx].stateMachine;
AnimatorState bankState = null;
foreach (var cs in psm.states) if (cs.state.name == "Bank") bankState = cs.state;
if (bankState == null) bankState = psm.AddState("Bank");
psm.defaultState = bankState;
if (!(bankState.motion is BlendTree))
{
var bankTree = new BlendTree { name = "BankTree", blendType = BlendTreeType.Simple1D, blendParameter = "Bank", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(bankTree, ac);
bankTree.AddChild(bankL, -1f);
bankTree.AddChild(bankR, 1f);
bankState.motion = bankTree;
}
bankState.writeDefaultValues = false;
// Additive Lead layer (underwater chest-lead scales with Speed).
var leanFwd = LoadAuthoredClip("A_Lean_Fwd");
AnimationClip leanZero = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx"))
if (o is AnimationClip cl && cl.name == "A_Lean_Zero") leanZero = cl;
if (leanFwd != null && leanZero != null)
{
int leadIdx = EnsureAdditiveLayer(ac, "Lead");
var lsm = ac.layers[leadIdx].stateMachine;
AnimatorState leadState = null;
foreach (var cs in lsm.states) if (cs.state.name == "Lean") leadState = cs.state;
if (leadState == null) leadState = lsm.AddState("Lean");
lsm.defaultState = leadState;
if (!(leadState.motion is BlendTree))
{
var leadTree = new BlendTree { name = "LeadTree", blendType = BlendTreeType.Simple1D, blendParameter = "Speed", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(leadTree, ac);
leadTree.AddChild(leanZero, 0.1f);
leadTree.AddChild(leanFwd, 0.85f);
leadState.motion = leadTree;
}
leadState.writeDefaultValues = false;
}
else Debug.LogError("[PlayerRigTools] Lean clips missing — run Import Underwater Clips first.");
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] Underwater clips wired v5 (pack idle, walk+run rings + StrideScale, Bank + Lead additive layers).");
}
static int EnsureAdditiveLayer(AnimatorController ac, string name)
{
int idx = -1;
for (int i = 0; i < ac.layers.Length; i++) if (ac.layers[i].name == name) idx = i;
if (idx < 0)
{
ac.AddLayer(name);
idx = ac.layers.Length - 1;
}
var layers = ac.layers; // array copy — modify + assign back
layers[idx].blendingMode = UnityEditor.Animations.AnimatorLayerBlendingMode.Additive;
layers[idx].defaultWeight = 1f;
ac.layers = layers;
return idx;
}
static AnimationClip FindIdleClip(AnimatorController ac)
{
@@ -171,8 +171,7 @@ namespace ProjectM.Simulation
{
if (isServer)
{
float2 cFace = facing.ValueRO.Direction;
cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
float cRange = math.max(0.1f, es.Range);
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
@@ -206,8 +205,7 @@ namespace ProjectM.Simulation
if (abilityPrefabs[i].Id == sparkId) { aoePrefab = abilityPrefabs[i].Prefab; break; }
if (aoePrefab != Entity.Null)
{
float2 aFace = facing.ValueRO.Direction;
aFace = math.lengthsq(aFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(aFace);
float2 aFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15)
float3 spawnPos = xform.ValueRO.Position + new float3(aFace.x, 0f, aFace.y) * k_AoeCastAhead;
spawnPos.y = xform.ValueRO.Position.y;
uint expire = adef.DurationTicks > 0
@@ -239,8 +237,7 @@ namespace ProjectM.Simulation
{
if (isServer)
{
float2 hFace = facing.ValueRO.Direction;
hFace = math.lengthsq(hFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(hFace);
float2 hFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15)
float hRange = math.max(0.1f, es.Range);
uint hStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
for (int hi = 0; hi < coneTargets.Length; hi++)
@@ -276,8 +273,9 @@ namespace ProjectM.Simulation
uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;
float2 rawAim = facing.ValueRO.Direction;
rawAim = math.lengthsq(rawAim) < 1e-6f ? new float2(0f, 1f) : math.normalize(rawAim);
// Manual-aim (07-15): the projectile (and the server's auto-target seed below) fires along the
// CURRENT tick's replicated Aim — PlayerFacing is body-yaw only under the SoD facing model.
float2 rawAim = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
// Client fires along raw aim; only the server applies the gamepad auto-target assist.
float2 dir = rawAim;
@@ -0,0 +1,75 @@
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure tick-window predicates over replicated combat state (MeleeCombo swing window, per-socket
/// cooldown-derived fire windows). Extracted VERBATIM from PlayerAnimationDriveSystem (MC-4/B7) so the
/// predicted facing path (PlayerAimSystem) and the client animation path share one implementation.
/// Window lengths stay CALLER parameters (the anim path passes its k_AttackAnimTicks; the facing path
/// passes its own) — never baked constants. Movement-archetype sockets (dash/blink) NEVER count as
/// firing: a blink is a dodge, not a cast (this also kills the latent cooldown-tail phantom IsFiring
/// pulse the old include-all check produced — the blink socket's stamped cooldown row used to open a
/// fake window ~2.5s after each blink). Burst-safe statics (byte archetypes, no enums), EditMode-tested.
/// </summary>
public static class TickWindowMath
{
/// <summary>True while now is within [SwingStartTick, SwingStartTick + animTicks) — a per-swing pulse
/// that re-triggers on each chained swing. NetworkTick arithmetic (wrap-safe).</summary>
public static bool SwingActive(in MeleeCombo mc, NetworkTick serverTick, uint animTicks)
{
if (mc.SwingStartTick == 0u || !serverTick.IsValid) return false;
var start = new NetworkTick(mc.SwingStartTick);
var end = new NetworkTick(TickUtil.NonZero(mc.SwingStartTick + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
/// <summary>True while now is within the stateless fire window [NextFireTick - CooldownTicks, +animTicks)
/// reconstructed from the replicated per-socket cooldown stamp minus the locally-derived CooldownTicks
/// (recomputes identically on every world; no cached edges — edge-caches false-fire on
/// relevancy/join/rollback). Window start shifts if cooldown modifiers change mid-window — documented
/// acceptable (B7).</summary>
public static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks)
{
if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false;
uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
var start = new NetworkTick(startRaw);
var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks));
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
}
/// <summary>LANTERN 4-socket fire/cone resolution (any-socket model): firing if ANY socketed,
/// NON-Movement Spark's per-socket window is mid-fire; cone if any such active socket holds a
/// Cone-archetype Spark. Movement sockets are skipped OUTRIGHT (blink = dodge, not cast). Without a
/// blob the archetype is unknown — the socket is included (matches the old fallback behavior).</summary>
public static void SocketFireAndCone(in SocketCooldown cd, DynamicBuffer<AbilitySocket> sockets,
DynamicBuffer<EffectiveSocketStats> effSockets, BlobAssetReference<AbilityDatabaseBlob> abilityDb,
NetworkTick serverTick, uint animTicks, out bool firing, out bool cone)
{
firing = false; cone = false;
int n = math.min(SocketId.Count, math.min(sockets.Length, effSockets.Length));
bool haveDb = abilityDb.IsCreated;
for (int sk = 0; sk < n; sk++)
{
byte sid = sockets[sk].SparkId;
if (sid == 0) continue;
bool isCone = false;
if (haveDb)
{
ref var adb = ref abilityDb.Value;
if (adb.TryGetAbility(sid, out var d))
{
if (d.Archetype == (byte)AbilityArchetype.Movement) continue; // dodge, never a cast
isCone = d.Archetype == (byte)AbilityArchetype.Cone;
}
}
if (!FireActive(cd.Get(sk), effSockets[sk].CooldownTicks, serverTick, animTicks)) continue;
firing = true;
if (isCone) cone = true;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 573dd7baac36e8142a3c746b488b8c74
@@ -56,6 +56,13 @@ namespace ProjectM.Simulation
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
{
@@ -81,6 +88,10 @@ namespace ProjectM.Simulation
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
@@ -101,6 +112,9 @@ namespace ProjectM.Simulation
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:
return math.max(0f, value);
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
@@ -138,6 +152,10 @@ namespace ProjectM.Simulation
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;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
}
}
@@ -169,6 +187,10 @@ namespace ProjectM.Simulation
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;
default: return 0f;
}
}
@@ -198,6 +220,10 @@ namespace ProjectM.Simulation
StructureAggroWeight = c.StructureAggroWeight,
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
SeparationMaxSpeed = c.SeparationMaxSpeed,
TurnRateDeg = c.TurnRateDeg,
CastTurnRateDeg = c.CastTurnRateDeg,
MoveSharpness = c.MoveSharpness,
};
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
@@ -225,6 +251,10 @@ namespace ProjectM.Simulation
StructureAggroWeight = r.StructureAggroWeight,
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
SeparationMaxSpeed = r.SeparationMaxSpeed,
TurnRateDeg = r.TurnRateDeg,
CastTurnRateDeg = r.CastTurnRateDeg,
MoveSharpness = r.MoveSharpness,
};
}
@@ -255,9 +285,13 @@ namespace ProjectM.Simulation
// 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;
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 26;
public const byte Count = 29;
}
/// <summary>
@@ -290,5 +324,11 @@ namespace ProjectM.Simulation
public float StructureAggroWeight;
public float StaggerKnockbackSpeed;
public float SeparationMaxSpeed;
public float TurnRateDeg;
public float CastTurnRateDeg;
public float MoveSharpness;
}
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
}
@@ -0,0 +1,63 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure facing/aim math for the Shape-of-Dreams facing model (body yaw follows movement; turns toward
/// the aim only inside a cast window; holds when idle; the cursor is never passively tracked).
/// RotateToward is the verbatim extraction of PlayerAimSystem's rate-limited planar turn so it stays
/// the tested-in-play math. ResolveAim is THE gameplay fire-direction resolver — every damage/spawn
/// direction (AbilityFireSystem archetypes, MeleeComboSystem cleave) and the aim-readout presentation
/// (reticle, local slash arcs) must route through it so sim and FX can never diverge. EditMode-tested,
/// Burst-safe, no World needed.
/// </summary>
public static class FacingMath
{
/// <summary>Gameplay fire direction: raw replicated Aim when meaningful, else the current body facing
/// (resting gamepad right stick — preserves controller-first "zero aim = movement heading" because
/// facing tracks Move under the SoD model), else world +Z. Always normalized.</summary>
public static float2 ResolveAim(float2 aim, float2 facing)
{
if (math.lengthsq(aim) > 1e-6f) return math.normalize(aim);
if (math.lengthsq(facing) > 1e-6f) return math.normalize(facing);
return new float2(0f, 1f);
}
/// <summary>The shared Aim→Move→hold cascade. castActive grants Aim PRIORITY only — a zero Aim
/// (resting gamepad stick mid-cast) falls through to Move, then to "no target" (hold previous
/// facing). Returns false when there is no target this tick.</summary>
public static bool SelectTarget(bool castActive, float2 aim, float2 move, out float2 target)
{
if (castActive && math.lengthsq(aim) > 1e-6f)
{
target = math.normalize(aim);
return true;
}
if (math.lengthsq(move) > 1e-6f)
{
target = math.normalize(move);
return true;
}
target = default;
return false;
}
/// <summary>Rate-limited planar rotate toward a normalized target: snaps when uninitialized or within
/// reach this step, else rotates by maxStepRadians toward the target. Deterministic pure math
/// (fixed-step dt at the caller) so it replays identically on rollback re-simulation.</summary>
public static float2 RotateToward(float2 current, float2 target, float maxStepRadians)
{
if (math.lengthsq(current) < 1e-6f)
return target; // uninitialized facing -> snap to target
float2 cur = math.normalize(current);
float angle = math.acos(math.clamp(math.dot(cur, target), -1f, 1f));
if (angle <= maxStepRadians)
return target; // within reach this step
float sign = (cur.x * target.y - cur.y * target.x) >= 0f ? 1f : -1f;
math.sincos(maxStepRadians * sign, out float sn, out float cs);
return math.normalize(new float2(cur.x * cs - cur.y * sn, cur.x * sn + cur.y * cs));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00ded88e4e66e02469b8f67c4864634f
@@ -61,6 +61,10 @@ namespace ProjectM.Simulation
m_SocketLookup.Update(ref state);
m_SocketCdLookup.Update(ref state);
float blinkSpeed = k_BlinkDistance / (k_BlinkWindowTicks / SimTickRate); // window>=1 -> no div-by-0
// 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const).
var t = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness;
foreach (var (blink, control, character, input, facing, dash, entity) in
SystemAPI.Query<RefRW<BlinkState>, RefRW<CharacterControl>, RefRW<CharacterComponent>,
@@ -97,8 +101,11 @@ namespace ProjectM.Simulation
bool inWindow = blink.ValueRO.UntilTick != 0u && new NetworkTick(blink.ValueRO.UntilTick).IsNewerThan(serverTick);
if (ready && !inWindow)
{
// 07-15 fork: Move → cursor Aim → last facing (stationary blink keeps going toward the cursor).
float2 mv = input.ValueRO.Move;
float2 dir = math.lengthsq(mv) > 1e-4f ? mv : facing.ValueRO.Direction;
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);
blink.ValueRW.Dir = dir;
@@ -124,7 +131,7 @@ namespace ProjectM.Simulation
else if (!dashActive && character.ValueRO.GroundedMovementSharpness == k_BlinkSharpness)
{
// restore only what WE raised (dash owns its own restore); never stomp an active dash.
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
character.ValueRW.GroundedMovementSharpness = baseSharpness;
}
}
}
@@ -17,8 +17,10 @@ namespace ProjectM.Simulation
public struct CharacterComponent : IComponentData
{
/// <summary>The CC's default grounded-movement smoothing sharpness — the single source for GetDefault, the
/// authoring default, DashSystem's base, and the death-state reset.</summary>
public const float DefaultGroundedSharpness = 15f;
/// authoring default, DashSystem's base, and the death-state reset. 07-15 underwater feel: 15 → 6 (slow
/// build-up + glidey stop — the seabed drag read); Player.prefab's serialized value must match (serialized
/// wins over this initializer at bake). Dev-override: TuningKnob.MoveSharpness (0 = this const).</summary>
public const float DefaultGroundedSharpness = 6f;
/// <summary>How quickly RelativeVelocity is lerped toward the target velocity on the ground.</summary>
public float GroundedMovementSharpness;
@@ -48,6 +48,9 @@ namespace ProjectM.Simulation
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;
foreach (var (ds, cd, control, character, input, facing) in
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
@@ -61,10 +64,13 @@ namespace ProjectM.Simulation
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
{
// C1: dash toward MOVEMENT input when moving (a panic 'dash away' while aiming at the threat now
// works), else toward facing (a stationary aim-and-dash). Pure fn of replicated input -> idempotent.
// 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 : facing.ValueRO.Direction;
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;
@@ -94,12 +100,12 @@ namespace ProjectM.Simulation
else if (recoverActive)
{
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
character.ValueRW.GroundedMovementSharpness = baseSharpness;
}
else
{
if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness)
character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // restore after the dash
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
@@ -160,8 +160,9 @@ namespace ProjectM.Simulation
bool hasMods = m_StatModLookup.HasBuffer(entity);
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
float2 face = facing.ValueRO.Direction;
face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face);
// Manual-aim cleave (07-15): the cone follows the CURRENT tick's replicated Aim (SoD skillshot
// grammar) — PlayerFacing is body-yaw only; the facing fallback covers a resting gamepad stick.
float2 face = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
cleaves.Add(new PendingCleave
{
From = xform.ValueRO.Position,
@@ -7,62 +7,66 @@ using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted aim/facing: writes <see cref="PlayerFacing"/> from twin-stick Aim, falling back to
/// the movement direction when Aim is zero (controller-first directional aim). Also turns the
/// ghost transform toward the facing direction for top-down presentation. When there is no input
/// this tick the previous facing is held. Deterministic (pure math); filtered to
/// <see cref="Simulate"/> so it runs only for predicted ghosts.
/// 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 = 13;
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (facing, transform, input, stats) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>().WithDisabled<Dead>())
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())
{
float2 aim = input.ValueRO.Aim;
if (math.lengthsq(aim) < 1e-6f)
aim = input.ValueRO.Move; // fall back to movement heading
if (math.lengthsq(aim) < 1e-6f)
continue; // no input this tick: keep last facing
// 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 _);
aim = math.normalize(aim);
if (!FacingMath.SelectTarget(castActive, input.ValueRO.Aim, input.ValueRO.Move, out float2 target))
continue; // no target this tick: keep last facing
// Rate-limited turn: rotate the current facing toward the aim target by at most
// TurnRateRadiansPerSec * dt this tick. Deterministic (pure planar math, fixed-step dt)
// so it replays correctly on rollback; the first tick (uninitialized facing) snaps.
float2 cur = facing.ValueRO.Direction;
float2 dir;
if (math.lengthsq(cur) < 1e-6f)
{
dir = aim; // uninitialized facing -> snap to target
}
else
{
cur = math.normalize(cur);
float maxStep = stats.ValueRO.TurnRateRadiansPerSec * dt;
float angle = math.acos(math.clamp(math.dot(cur, aim), -1f, 1f));
if (angle <= maxStep)
{
dir = aim; // within reach this tick
}
else
{
float sign = (cur.x * aim.y - cur.y * aim.x) >= 0f ? 1f : -1f;
math.sincos(maxStep * sign, out float sn, out float cs);
dir = math.normalize(new float2(cur.x * cs - cur.y * sn, cur.x * sn + cur.y * cs));
}
}
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;
float3 forward = new float3(dir.x, 0f, dir.y);
transform.ValueRW.Rotation = quaternion.LookRotationSafe(forward, math.up());
transform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(dir.x, 0f, dir.y), math.up());
}
}
}
@@ -48,7 +48,9 @@ namespace ProjectM.Simulation
if (SystemAPI.HasComponent<CharacterComponent>(entity))
{
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
cc.GroundedMovementSharpness = CharacterComponent.DefaultGroundedSharpness;
// 07-15: honor the MoveSharpness dev-override (0 = authored const) like the dash/blink restores.
var tdc = SystemAPI.TryGetSingleton<TuningConfig>(out var tdcv) ? tdcv : TuningConfig.Defaults();
cc.GroundedMovementSharpness = tdc.MoveSharpness > 0f ? tdc.MoveSharpness : CharacterComponent.DefaultGroundedSharpness;
SystemAPI.SetComponent(entity, cc);
}
}