diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index faa2c3314..b50f86319 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -86,6 +86,8 @@ namespace ProjectM.Client AudioClip _telegraphClip; AudioClip _dashClip; AudioClip _swingClip; + AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep + Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion) Entity _localPlayer = Entity.Null; uint _lastLocalFireTick; @@ -108,6 +110,9 @@ namespace ProjectM.Client _telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false); _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 + _strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep } protected override void OnStartRunning() @@ -219,6 +224,8 @@ namespace ProjectM.Client PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); + if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) + RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); if (isEnemy) { // MC-3: net-new player-dealt-hit camera punch — scales with the bite size @@ -227,6 +234,10 @@ namespace ProjectM.Client float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage)); PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs); ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge + // Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read. + EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor); + if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) + RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); } } @@ -268,6 +279,9 @@ namespace ProjectM.Client PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); + EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop + if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) + RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); } _cache.Remove(_stale[i]); } @@ -331,13 +345,52 @@ namespace ProjectM.Client float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; - TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen); // arc sweeps + ramps per step (MC-4 clarity) + // MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE + // "you bit" read (the authoritative server damage spark/number arrives a few ticks later). + bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue; + float cosHalf = Mathf.Cos(slashHalf); + float2 fdir = new float2(face.x, face.z); + foreach (var kv in _cache) + { + if (!kv.Value.IsEnemy) continue; + if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos)) + { + float d2 = math.distancesq(localPos, kv.Value.Pos); + if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; } + } + } + TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect + if (connected) + { + Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount); + PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume); + PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs); + if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) + RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); + } if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); } _lastLocalSwingTick = mc.SwingStartTick; _swingTickInit = true; } + // Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence. + 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) + { + PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume); + _footTimer = FeelConfig.FootstepIntervalSec; + } + } + _lastFootPos = lp; _footInit = true; + } + RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse UpdateProjectileTrails(cfg); PruneVfx(); AnimateNumbers(dt, cam); @@ -354,6 +407,15 @@ namespace ProjectM.Client return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath; } + // Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop + // without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit). + void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color) + { + if (ps == null || count <= 0) return; + var ep = new ParticleSystem.EmitParams { position = pos, startColor = color }; + ps.Emit(ep, count); + } + void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count) { if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity); @@ -497,12 +559,14 @@ namespace ProjectM.Client fn.Active = true; fn.Age = 0f; - fn.Life = 0.7f; + float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger + fn.Life = Mathf.Lerp(0.6f, 0.95f, mag); fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString(); fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit) fn.Tm.color = fn.BaseColor; fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f); fn.Vel = new Vector3(0f, 2.2f, 0f); + fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag); fn.Tr.gameObject.SetActive(true); if (cam != null) fn.Tr.rotation = cam.transform.rotation; } @@ -668,7 +732,7 @@ namespace ProjectM.Client // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS // the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a // directional, escalating cleave rather than a static flash. - void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen) + void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected) { if (_slashMr == null || _slashMat == null) return; bool finisher = step >= comboLen; @@ -685,6 +749,7 @@ namespace ProjectM.Client _slashTint = finisher ? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash : Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step + if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read) _slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t); _slashAge = 0f; _slashActive = true; diff --git a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs index 5eac220f0..fb36d18bd 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeelConfig.cs @@ -109,6 +109,32 @@ namespace ProjectM.Client /// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction). public static bool DashHitSuppress; + // ---- Combat feel pass (2026-06): connect cue, hit-flash, kill pop, footsteps, rumble, telegraph beep ---- + /// Hit-flash puff density (a colored particle burst in HitFlashColor on an enemy damage edge). + public static int HitFlashBurstCount; + /// Extra FOV punch (deg) when the LOCAL melee cleave is confirmed to connect (vs a whiff). + public static float MeleeConnectFovKick; + /// Volume of the meaty melee connect "thunk". + public static float MeleeConnectVolume; + /// Kill-pop colored flash density on an enemy death. + public static int KillFlashBurstCount; + /// Soft footstep SFX volume. + public static float FootstepVolume; + /// Seconds between footsteps while the local player is moving. + public static float FootstepIntervalSec; + /// Local player speed (u/s) above which footsteps play. + public static float FootstepMinSpeed; + /// Master gate for gamepad rumble (no-op on KBM). + public static bool RumbleEnabled; + /// Rumble strength on a local hit taken / dealt. + public static float RumbleHit; + /// Rumble strength on a kill. + public static float RumbleKill; + /// Rumble strength on dash / local death (the heaviest). + public static float RumbleHeavy; + /// Seconds a rumble pulse lasts before it auto-stops. + public static float RumbleDurationSec; + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] public static void ResetDefaults() { @@ -161,6 +187,20 @@ namespace ProjectM.Client DashSfxVolume = 0.55f; DashShimmerPerFrame = 2; DashHitSuppress = true; + + // Combat feel pass (2026-06) + HitFlashBurstCount = 16; + MeleeConnectFovKick = 0.8f; + MeleeConnectVolume = 0.55f; + KillFlashBurstCount = 20; + FootstepVolume = 0.16f; + FootstepIntervalSec = 0.32f; + FootstepMinSpeed = 1.5f; + RumbleEnabled = true; + RumbleHit = 0.25f; + RumbleKill = 0.45f; + RumbleHeavy = 0.6f; + RumbleDurationSec = 0.12f; } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs b/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs new file mode 100644 index 000000000..820a49c3e --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs @@ -0,0 +1,54 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +namespace ProjectM.Client +{ + /// + /// Gamepad rumble for combat feel — a static bridge (mirrors / ). + /// sets the motors and stamps a stop time; (called once per frame from + /// CombatFeedbackSystem) stops them when the pulse elapses OR the app loses focus, so a rumble never sticks. + /// A no-op when no pad is connected; the CALLER gates to the Gamepad scheme. Statics survive fast-enter-playmode + /// reloads, so re-arms clean on play-enter and stops any leaked motor (the AimPresentation + /// reset idiom). Presentation-only, main-thread, never touches the simulation. + /// + public static class RumbleUtil + { + static float s_StopTime; + static bool s_Active; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetState() + { + s_Active = false; + s_StopTime = 0f; + Stop(); + } + + /// Pulse both motors at the given low/high strengths for durSec, then auto-stop. No-op without a pad. + public static void Pulse(float low, float high, float durSec) + { + var pad = Gamepad.current; + if (pad == null) return; + pad.SetMotorSpeeds(Mathf.Clamp01(low), Mathf.Clamp01(high)); + s_StopTime = Time.unscaledTime + Mathf.Max(0.02f, durSec); + s_Active = true; + } + + /// Call once per frame: stops the motors when the pulse elapses or focus is lost. + public static void Tick() + { + if (!s_Active) return; + if (!Application.isFocused || Time.unscaledTime >= s_StopTime) + { + Stop(); + s_Active = false; + } + } + + static void Stop() + { + var pad = Gamepad.current; + if (pad != null) pad.ResetHaptics(); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs.meta new file mode 100644 index 000000000..c000b0243 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RumbleUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f7005e66cfc2ce4d825ebad3cdc9eac \ No newline at end of file