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:
@@ -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 (<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 >= 1, value knobs >= 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user