wip : scarecrow fix & subtitle event implementation

This commit is contained in:
Ferlat Thibaud
2026-06-01 12:45:40 +02:00
parent fde7b39206
commit 28c951f50f
14 changed files with 1088 additions and 567 deletions
@@ -1,4 +1,6 @@
using System.Collections;
using System.Collections.Generic;
using Unheimlich.VFX;
using UnityEngine;
using UnityEngine.Rendering;
@@ -8,7 +10,6 @@ public class ScarecrowFire : MonoBehaviour
public class FireElement
{
public GameObject VFX;
/// <summary>Original scale captured at initialization, used as lerp target.</summary>
[HideInInspector] public Vector3 OriginalScale;
}
@@ -23,14 +24,9 @@ public class ScarecrowFire : MonoBehaviour
[SerializeField] private FireElement _fireBig;
[Header("Propagation Timings")]
[Tooltip("Duration of the scale lerp for each fire element (seconds).")]
[SerializeField] private float _growDuration = 3f;
[Tooltip("How early the next stage starts before the current one finishes (0 = no overlap, 1 = instant).")]
[Range(0f, 1f)]
[SerializeField] private float _overlapFactor = 0.4f;
[Tooltip("Curve driving the scale growth — tweak directly in the Inspector.")]
[SerializeField] private AnimationCurve _growCurve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 0f),
new Keyframe(0.3f, 0.1f, 0f, 2f),
@@ -39,10 +35,7 @@ public class ScarecrowFire : MonoBehaviour
);
[Header("Post Process")]
[Tooltip("Volume driving the heat haze effect — weight is driven up as fire spreads.")]
[SerializeField] private Volume _heatVolume;
[Tooltip("Curve driving all heat effects over the full sequence duration.")]
[SerializeField] private AnimationCurve _heatCurve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 0.5f),
new Keyframe(0.5f, 0.4f, 1f, 1f),
@@ -50,17 +43,18 @@ public class ScarecrowFire : MonoBehaviour
);
[Header("Heat Shader")]
[Tooltip("Material using the HeatHaze shader — _Weight property is driven from 0 to 1.")]
[SerializeField] private Material _heatMaterial;
[Header("Light")]
[Tooltip("LightFlicker component to drive — baseIntensity is ramped up with the fire.")]
[SerializeField] private MonoLimboStudio.LightFlicker _lightFlicker;
[Header("Lights")]
[SerializeField] private List<LightEntry> _lights = new List<LightEntry>();
[Tooltip("Final light intensity reached when the fire is fully ablaze.")]
[SerializeField] private float _targetLightIntensity = 3f;
[System.Serializable]
public class LightEntry
{
public LightFlicker Flicker;
public float TargetIntensity = 3f;
}
// Flag set when the last GrowFire completes — DriveHeatEffects waits on this
private bool _fireSequenceComplete = false;
private float _totalSequenceDuration = 0f;
@@ -77,16 +71,13 @@ public class ScarecrowFire : MonoBehaviour
InitElement(_fireHead);
InitElement(_fireBig);
// Used as a time reference for _heatCurve mapping — doesn't need to be exact
// since DriveHeatEffects now syncs to _fireSequenceComplete rather than this value
float overlapDelay = _growDuration * (1f - _overlapFactor);
_totalSequenceDuration = _growDuration + (7f * overlapDelay);
// Ensure everything starts at zero
SetHeatShaderWeight(0f);
if (_lightFlicker)
_lightFlicker.SetBaseIntensity(0f);
foreach (var entry in _lights)
if (entry.Flicker) entry.Flicker.SetBaseIntensity(0f);
}
void Start()
@@ -94,10 +85,6 @@ public class ScarecrowFire : MonoBehaviour
TriggerFire();
}
/// <summary>
/// Captures the original Inspector scale, then sets the starting scale to
/// (x * 0.2, y * 0.1, z * 0.2) so each element grows from near-nothing.
/// </summary>
private void InitElement(FireElement el)
{
if (!el.VFX) return;
@@ -113,51 +100,36 @@ public class ScarecrowFire : MonoBehaviour
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Starts the full fire propagation sequence.</summary>
public void TriggerFire()
{
_fireSequenceComplete = false;
StartCoroutine(FireSequence());
}
/// <summary>
/// Propagates fire bottom-up with overlapping stages for an organic feel.
/// Sets _fireSequenceComplete when the last element finishes growing.
/// </summary>
private IEnumerator FireSequence()
{
float overlapDelay = _growDuration * (1f - _overlapFactor);
// Drive all heat effects independently — syncs via _fireSequenceComplete
StartCoroutine(DriveHeatEffects());
// Feet
StartCoroutine(GrowFire(_fireBase));
yield return new WaitForSeconds(overlapDelay);
// Torso overlaps with feet still growing
StartCoroutine(GrowFire(_fireTorso));
yield return new WaitForSeconds(overlapDelay);
// Both arms ignite simultaneously, each propagating from shoulder to tip
StartCoroutine(PropagateArm(_fireArmLeft, _fireArmLeftEnd, overlapDelay));
StartCoroutine(PropagateArm(_fireArmRight, _fireArmRightEnd, overlapDelay));
yield return new WaitForSeconds(overlapDelay * 2f);
// Head
StartCoroutine(GrowFire(_fireHead));
yield return new WaitForSeconds(overlapDelay);
// Full blaze — wait for it to fully complete before flagging done
yield return StartCoroutine(GrowFire(_fireBig));
_fireSequenceComplete = true;
}
/// <summary>
/// Propagates fire along one arm: shoulder ignites first,
/// then tip starts after overlapDelay rather than waiting for shoulder to finish.
/// </summary>
private IEnumerator PropagateArm(FireElement shoulder, FireElement tip, float overlapDelay)
{
StartCoroutine(GrowFire(shoulder));
@@ -165,10 +137,6 @@ public class ScarecrowFire : MonoBehaviour
yield return StartCoroutine(GrowFire(tip));
}
/// <summary>
/// Activates a fire element and lerps its scale from near-zero to its original
/// Inspector scale, driven by _growCurve for an organic progression.
/// </summary>
private IEnumerator GrowFire(FireElement el)
{
if (!el.VFX) yield break;
@@ -195,57 +163,54 @@ public class ScarecrowFire : MonoBehaviour
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Drives all heat effects (post-process volume, heat shader, light intensity)
/// using _heatCurve until _fireSequenceComplete is set, then snaps cleanly to 1.
/// Decoupled from individual fire element timing for smoother blending.
/// </summary>
private IEnumerator DriveHeatEffects()
{
float elapsed = 0f;
float elapsed = 0f;
float lastWeight = 0f;
// Phase 1 : monte avec la heatCurve jusqu'à ce que la séquence feu soit finie.
// On snapshote lastWeight à chaque frame pour partir de la vraie valeur courante
// en phase 2, évitant tout creux si la courbe redescend après t=1.
while (!_fireSequenceComplete)
{
elapsed += Time.deltaTime;
float t = elapsed / _totalSequenceDuration;
float weight = _heatCurve.Evaluate(Mathf.Min(t, 0.99f));
if (_heatVolume)
_heatVolume.weight = weight;
SetHeatShaderWeight(weight);
if (_lightFlicker)
_lightFlicker.SetBaseIntensity(_targetLightIntensity * weight);
float t = elapsed / _totalSequenceDuration;
lastWeight = _heatCurve.Evaluate(Mathf.Clamp01(t));
ApplyHeatWeight(lastWeight);
yield return null;
}
float lerpElapsed = 0f;
// Phase 2 : lerp propre de la valeur courante vers 1, sans jamais redescendre.
float lerpElapsed = 0f;
float lerpDuration = 8f;
float startWeight = _heatCurve.Evaluate(0.99f);
float startWeight = lastWeight; // ← valeur réelle au moment du flag, pas heatCurve(0.99)
while (lerpElapsed < lerpDuration)
{
lerpElapsed += Time.deltaTime;
float weight = Mathf.Lerp(startWeight, 1f, lerpElapsed / lerpDuration);
if (_heatVolume) _heatVolume.weight = weight;
SetHeatShaderWeight(weight);
if (_lightFlicker) _lightFlicker.SetBaseIntensity(_targetLightIntensity * weight);
ApplyHeatWeight(weight);
yield return null;
}
if (_heatVolume) _heatVolume.weight = 1f;
SetHeatShaderWeight(1f);
if (_lightFlicker) _lightFlicker.SetBaseIntensity(_targetLightIntensity);
// Phase 3 : maintien à 1 à l'infini.
ApplyHeatWeight(1f);
}
/// <summary>Applique un weight normalisé à tous les effets de chaleur + lumière.</summary>
private void ApplyHeatWeight(float weight)
{
if (_heatVolume) _heatVolume.weight = weight;
SetHeatShaderWeight(weight);
// ScarecrowFire pilote la base — LightFlicker applique le flicker par-dessus
// sans écrire directement sur targetLight.intensity.
foreach (var entry in _lights)
if (entry.Flicker) entry.Flicker.SetBaseIntensity(entry.TargetIntensity * weight);
}
/// <summary>
/// Drives the heat shader via a single normalized weight (0-1).
/// All visual parameters are tweaked directly on the material in the Inspector.
/// </summary>
private void SetHeatShaderWeight(float weight)
{
if (!_heatMaterial) return;
@@ -7,12 +7,15 @@ namespace Unheimlich.World.CameraSystem
/// Overlays a "lost footage / handheld camcorder" shake on a dedicated pivot
/// sitting between CameraPivot and Camera Player.
///
/// Layered in three passes:
/// Layered in four passes:
/// 1. Continuous Perlin drift — organic idle tremor, scales with movement and stress.
/// 2. Micro-jolts — randomised snap/recoil spikes, only fire when the camera
/// is steady (attenuated during active mouse look).
/// 2. Micro-jolts — randomised snap/recoil spikes, only fire when the
/// camera is steady (attenuated during active mouse look).
/// 3. Inertia swinging — the camera "lags" behind head rotation on all axes,
/// simulating the physical weight of a handheld camcorder.
/// 4. Spinning/dizzy drift — a slow, persistent rotational pull on Y (and a subtle
/// rolling tilt on Z) that builds with stress, simulating
/// a confused/disoriented head.
///
/// Hierarchy expected:
/// OctoPlayerFP (mouse-look root)
@@ -20,13 +23,32 @@ namespace Unheimlich.World.CameraSystem
/// └── LostFootagePivot ← this component
/// └── Camera Player
///
/// Mouse-look speed is sampled on OctoPlayerFP (grandparent) to avoid being
/// polluted by the continuous rotations CameraHeadBob writes on CameraPivot.
/// Movement blend (moveT) is smoothed to avoid noise amplitude spikes on
/// direction changes.
/// Quick setup:
/// • Toggle "Is Active" in the inspector (or call SetActive()) to enable/disable with fade.
/// • "Master Intensity" scales everything: noise, jolts, inertia and spin.
/// • "Stress Level" can be driven externally (e.g. by a StressController) or left at 0.
/// </summary>
public class LostFootageRig : MonoBehaviour
{
// ── Inspector — Global ────────────────────────────────────────────────────
[Header("Global")]
[Tooltip("Enable or disable the whole rig (with fade). Can also be toggled at runtime via SetActive().")]
[SerializeField] private bool _isActive = true;
[Tooltip("Master scale applied to ALL effects (noise amplitude, jolt amplitude, inertia strength, spin strength). 0 = off, 1 = nominal.")]
[SerializeField] [Range(0f, 3f)] private float _masterIntensity = 1f;
// ── Inspector — Stress ────────────────────────────────────────────────────
[Header("Stress")]
[Tooltip("Stress level in [0, 1]. Can be driven externally (StressController.Instance → rig.StressLevel = ...) or set manually here for testing.")]
[SerializeField] [Range(0f, 1f)] private float _stressLevel = 0f;
[Header("Stress — noise scale")]
[SerializeField] private float _stressAmplitudeMul = 2.5f;
[SerializeField] private float _stressSpeedMul = 1f;
// ── Inspector — Noise ─────────────────────────────────────────────────────
[Header("Noise — idle")]
@@ -41,12 +63,8 @@ namespace Unheimlich.World.CameraSystem
[SerializeField] private float _runAmplitude = 5f;
[SerializeField] private float _runNoiseSpeed = 0.6f;
[Header("Noise — stress scale")]
[SerializeField] private float _stressAmplitudeMul = 2.5f;
[SerializeField] private float _stressSpeedMul = 1f;
[Header("Noise — smoothing")]
[Tooltip("How fast the noise amplitude blends between idle/walk/run. Lower = smoother transitions.")]
[Tooltip("How fast the noise amplitude blends between idle/walk/run.")]
[SerializeField] private float _moveTSmoothSpeed = 4f;
// ── Inspector — Micro-jolts ───────────────────────────────────────────────
@@ -64,56 +82,73 @@ namespace Unheimlich.World.CameraSystem
[SerializeField] private Vector2 _runJoltIntervalRange = new Vector2(1f, 2f);
[Header("Micro-jolts — shared")]
[Tooltip("How fast the jolt snaps to its target (fraction of interval).")]
[SerializeField] private float _joltSnapFraction = 0.1f;
[Tooltip("Jolt interval is divided by this value when stress = 1.")]
[SerializeField] private float _stressJoltFreqMul = 2f;
[Tooltip("Jolt amplitude is multiplied by this value when stress = 1.")]
[SerializeField] private float _stressJoltAmpMul = 1.4f;
[Tooltip("Look speed (deg/s) above which jolts are fully suppressed. Sampled on OctoPlayerFP to bypass CameraHeadBob noise.")]
[SerializeField] private float _joltLookSuppressSpeed = 5f;
[SerializeField] private float _joltSnapFraction = 0.1f;
[SerializeField] private float _stressJoltFreqMul = 2f;
[SerializeField] private float _stressJoltAmpMul = 1.4f;
[Tooltip("Look speed (deg/s) above which jolts are fully suppressed.")]
[SerializeField] private float _joltLookSuppressSpeed = 5f;
// ── Inspector — Inertia swing ─────────────────────────────────────────────
[Header("Inertia swing")]
[Tooltip("How strongly the camera lags behind movement. Higher = more swing.")]
[SerializeField] private float _inertiaStrength = 4f;
[Tooltip("How fast the inertia offset returns to zero. Lower = longer swing tail.")]
[SerializeField] private float _inertiaDamping = 6f;
[Tooltip("Maximum swing angle (degrees) on each axis to avoid excessive drift.")]
[SerializeField] private float _inertiaMaxAngle = 8f;
// ── Inspector — Spinning / dizzy head ─────────────────────────────────────
[Header("Spinning / dizzy head")]
[Tooltip("Maximum Y-axis spin angle (degrees) at stress = 1 and masterIntensity = 1.")]
[SerializeField] private float _spinMaxYAngle = 6f;
[Tooltip("Maximum Z-axis roll angle (degrees) at stress = 1 — the subtle listing tilt.")]
[SerializeField] private float _spinMaxZAngle = 2.5f;
[Tooltip("Speed of the Perlin noise driving the spin drift. Lower = slower, more nauseating.")]
[SerializeField] private float _spinNoiseSpeed = 0.08f;
[Tooltip("How fast the spin offset interpolates toward its target. Lower = more sluggish/drunk.")]
[SerializeField] private float _spinDamping = 2f;
// ── Inspector — Fade ──────────────────────────────────────────────────────
[Header("Fade")]
[Tooltip("Duration (seconds) of the fade-in / fade-out when toggling the rig.")]
[SerializeField] private float _fadeDuration = 0.6f;
// ── Inspector — Debug ─────────────────────────────────────────────────────
[Header("Debug")]
[SerializeField] private bool _debugSustainStress;
[SerializeField] [Range(0f, 1f)] private float _debugStressValue;
[SerializeField] private bool _debugOverrideStress;
[SerializeField] [Range(0f, 1f)] private float _debugStressOverride;
// ── Runtime state ─────────────────────────────────────────────────────────
// ── Public API ────────────────────────────────────────────────────────────
/// <summary>Current stress level in [0, 1]. Driven by <see cref="StressController"/>.</summary>
public float StressLevel { get; set; }
/// <summary>
/// Current stress level in [0, 1].
/// Set this from an external controller (e.g. StressController) each frame,
/// or leave it at the inspector default.
/// </summary>
public float StressLevel
{
get => _stressLevel;
set => _stressLevel = Mathf.Clamp01(value);
}
/// <summary>Whether the rig is currently active (or fading in/out).</summary>
public bool IsActive { get; private set; }
// ── Runtime state ─────────────────────────────────────────────────────────
private float _weight;
private float _noiseTimer;
private float _initialMasterIntensity; // value set in inspector, restored on Enable()
private float _noiseTimer;
private float _spinTimer;
private Vector3 _noiseOffset;
private Vector3 _joltRotation;
private Vector3 _inertiaOffset;
private Vector3 _spinOffset; // current smoothed spin (Y, Z) rotation
private float _smoothMoveT;
// Smoothed movement blend — avoids amplitude spikes on direction changes.
private float _smoothMoveT;
// Mouse-look speed sampled on OctoPlayerFP (grandparent) — used to gate
// micro-jolts so they only fire when the player is not actively looking around.
private Quaternion _lastLookRootRotation;
private float _lookSpeed;
@@ -129,65 +164,87 @@ namespace Unheimlich.World.CameraSystem
Random.Range(0f, 100f),
Random.Range(0f, 100f)
);
// Spin uses its own offset to decouple from the main noise.
_spinTimer = Random.Range(0f, 100f);
}
private void Start()
{
// Store the inspector value so Enable() can restore it even if the rig
// started disabled (and _masterIntensity was faded to 0).
_initialMasterIntensity = _masterIntensity;
// Sync inspector toggle on first frame.
IsActive = _isActive;
_weight = _isActive ? 1f : 0f;
Transform lookRoot = transform.parent?.parent;
_lastLookRootRotation = lookRoot != null
? lookRoot.localRotation
: Quaternion.identity;
if (_isActive)
_joltCoroutine = StartCoroutine(JoltCoroutine());
}
private void OnValidate()
{
// Allow toggling _isActive from the inspector during Play mode.
if (!Application.isPlaying) return;
SetActive(_isActive);
}
private void Update()
{
if (_debugSustainStress)
StressLevel = _debugStressValue;
if (_debugOverrideStress)
_stressLevel = _debugStressOverride;
float stress = Mathf.Clamp01(_stressLevel);
float intensity = _masterIntensity;
UpdateInertia();
if (_weight <= 0f)
{
UpdateInertia();
transform.localRotation = Quaternion.identity;
return;
}
float stress = Mathf.Clamp01(StressLevel);
// ── 1. Resolve movement state ─────────────────────────────────────────
// ── 1. Movement state ─────────────────────────────────────────────────
float horizontalSpeed = 0f;
bool isRunning = false;
bool running = IsRunning();
if (Unheimlich.Features.Player.PlayerController.Instance?.CharController != null)
{
var vel = Unheimlich.Features.Player.PlayerController.Instance.CharController.velocity;
var vel = Unheimlich.Features.Player.PlayerController.Instance.CharController.velocity;
horizontalSpeed = new Vector3(vel.x, 0f, vel.z).magnitude;
isRunning = OctoCore.Components.OctoPlayer.Instance != null
&& OctoCore.Components.OctoPlayer.Instance.State.IsRunning;
}
const float WalkSpeedMs = 10f / 3.6f;
const float RunSpeedMs = 20f / 3.6f;
float maxSpeed = isRunning ? RunSpeedMs : WalkSpeedMs;
float rawMoveT = Mathf.Clamp01(horizontalSpeed / maxSpeed);
float maxSpeed = running ? RunSpeedMs : WalkSpeedMs;
float rawMoveT = Mathf.Clamp01(horizontalSpeed / maxSpeed);
_smoothMoveT = Mathf.Lerp(_smoothMoveT, rawMoveT, Time.deltaTime * _moveTSmoothSpeed);
// Smooth moveT to avoid abrupt amplitude jumps on direction changes.
_smoothMoveT = Mathf.Lerp(_smoothMoveT, rawMoveT, Time.deltaTime * _moveTSmoothSpeed);
// ── 2. Noise parameters ───────────────────────────────────────────────
// ── 2. Interpolate noise parameters from movement state ───────────────
float targetAmp = isRunning
float targetAmp = running
? Mathf.Lerp(_walkAmplitude, _runAmplitude, _smoothMoveT)
: Mathf.Lerp(_idleAmplitude, _walkAmplitude, _smoothMoveT);
float targetSpeed = isRunning
float targetSpeed = running
? Mathf.Lerp(_walkNoiseSpeed, _runNoiseSpeed, _smoothMoveT)
: Mathf.Lerp(_idleNoiseSpeed, _walkNoiseSpeed, _smoothMoveT);
float amp = (targetAmp + stress * _stressAmplitudeMul) * _weight;
float spd = (targetSpeed + stress * _stressSpeedMul);
float amp = (targetAmp + stress * _stressAmplitudeMul) * _weight * intensity;
float spd = targetSpeed + stress * _stressSpeedMul;
_noiseTimer += Time.deltaTime * spd;
if (_noiseTimer > 1000f) _noiseTimer -= 1000f; // wrap to avoid float precision loss
if (_noiseTimer > 1000f) _noiseTimer -= 1000f;
float t = _noiseTimer;
// ── 3. Perlin drift ───────────────────────────────────────────────────
@@ -198,74 +255,101 @@ namespace Unheimlich.World.CameraSystem
// ── 4. Inertia swing ──────────────────────────────────────────────────
UpdateInertia();
Vector3 swing = _inertiaOffset * _weight;
Vector3 swing = _inertiaOffset * _weight * intensity;
// ── 5. Compose and apply ──────────────────────────────────────────────
// ── 5. Spinning / dizzy drift ─────────────────────────────────────────
//
// Two independent Perlin channels drive a slow yaw drift (Y) and a
// listing roll (Z). Both scale with stress and masterIntensity.
// The offset is smoothed via _spinDamping so it feels heavy and sluggish
// rather than jittery — like a head that's struggling to stay level.
_spinTimer += Time.deltaTime * _spinNoiseSpeed;
if (_spinTimer > 1000f) _spinTimer -= 1000f;
float spinScale = stress * _weight * intensity;
float targetSpinY = (Mathf.PerlinNoise(_noiseOffset.x * 0.5f + _spinTimer, 10f) - 0.5f)
* _spinMaxYAngle * 2f * spinScale;
float targetSpinZ = (Mathf.PerlinNoise(_noiseOffset.z * 0.5f + _spinTimer, 20f) - 0.5f)
* _spinMaxZAngle * 2f * spinScale;
Vector3 targetSpin = new Vector3(0f, targetSpinY, targetSpinZ);
_spinOffset = Vector3.Lerp(_spinOffset, targetSpin, Time.deltaTime * _spinDamping);
// ── 6. Compose and apply ──────────────────────────────────────────────
transform.localRotation = Quaternion.Euler(
rx + _joltRotation.x + swing.x,
ry + _joltRotation.y + swing.y,
rz + _joltRotation.z + swing.z
ry + _joltRotation.y + swing.y + _spinOffset.y,
rz + _joltRotation.z + swing.z + _spinOffset.z
);
}
// ── Public API ────────────────────────────────────────────────────────────
/// <summary>UnityEvent-friendly wrapper — enables with the inspector master intensity.</summary>
public void Enable() => Enable(_initialMasterIntensity, _stressLevel);
/// <summary>UnityEvent-friendly wrapper — disables with a fade to 0.</summary>
public void Disable() => Disable(0f);
/// <summary>
/// Enables or disables the lost-footage effect with a smooth fade.
/// Safe to call multiple times — an in-progress fade is cancelled and reversed.
/// Enables the rig and lerps <see cref="_masterIntensity"/> toward
/// <paramref name="targetIntensity"/> and <see cref="_stressLevel"/> toward
/// <paramref name="targetStress"/> over <see cref="_fadeDuration"/> seconds.
/// Pass the values you want to reach — the current inspector values are left
/// untouched if you don't call this.
/// </summary>
public void SetActive(bool active)
public void Enable(float targetIntensity, float targetStress = 0f)
{
if (IsActive == active)
return;
_isActive = true;
IsActive = true;
IsActive = active;
if (_fadeCoroutine != null) StopCoroutine(_fadeCoroutine);
_fadeCoroutine = StartCoroutine(FadeCoroutine(true, targetIntensity, targetStress));
if (_fadeCoroutine != null)
StopCoroutine(_fadeCoroutine);
_fadeCoroutine = StartCoroutine(FadeCoroutine(active));
if (active)
{
if (_joltCoroutine != null)
StopCoroutine(_joltCoroutine);
_joltCoroutine = StartCoroutine(JoltCoroutine());
}
if (_joltCoroutine != null) StopCoroutine(_joltCoroutine);
_joltCoroutine = StartCoroutine(JoltCoroutine());
}
/// <summary>
/// Disables the rig, lerping <see cref="_masterIntensity"/> toward
/// <paramref name="targetIntensity"/> (default 0) over <see cref="_fadeDuration"/> seconds.
/// </summary>
public void Disable(float targetIntensity = 0f)
{
_isActive = false;
IsActive = false;
if (_fadeCoroutine != null) StopCoroutine(_fadeCoroutine);
_fadeCoroutine = StartCoroutine(FadeCoroutine(false, targetIntensity, _stressLevel));
}
/// <summary>Backwards-compatible toggle — calls Enable() or Disable().</summary>
public void SetActive(bool active) { if (active) Enable(); else Disable(); }
// ── Private helpers ───────────────────────────────────────────────────────
/// <summary>
/// Drives inertia swing from CharController velocity projected into local camera space,
/// avoiding the rotation-delta approach which caused spikes whenever CameraHeadBob
/// changed phase. Also samples OctoPlayerFP (grandparent) rotation delta to measure
/// true mouse-look speed for jolt gating.
/// </summary>
private void UpdateInertia()
{
// ── Inertia from CharController velocity ──────────────────────────────
Vector3 velocity = Vector3.zero;
if (Unheimlich.Features.Player.PlayerController.Instance?.CharController != null)
velocity = Unheimlich.Features.Player.PlayerController.Instance.CharController.velocity;
// Project velocity into local camera space so the swing feels attached to the view.
Vector3 localVel = transform.parent != null
Vector3 localVel = transform.parent != null
? transform.parent.InverseTransformDirection(velocity)
: velocity;
Vector3 targetInertia = new Vector3(-localVel.z, 0f, localVel.x) * (_inertiaStrength * 0.01f);
targetInertia = Vector3.ClampMagnitude(targetInertia, _inertiaMaxAngle);
Vector3 targetInertia = new Vector3(-localVel.z, 0f, localVel.x)
* (_inertiaStrength * _masterIntensity * 0.01f);
targetInertia = Vector3.ClampMagnitude(targetInertia, _inertiaMaxAngle);
_inertiaOffset = Vector3.Lerp(_inertiaOffset, targetInertia, _inertiaDamping * Time.deltaTime);
// ── Mouse-look speed from OctoPlayerFP (grandparent) ─────────────────
// Mouse-look speed from OctoPlayerFP (grandparent) — gates micro-jolts.
Transform lookRoot = transform.parent?.parent;
Quaternion lookRot = lookRoot != null ? lookRoot.localRotation : Quaternion.identity;
Quaternion lookDelta = lookRot * Quaternion.Inverse(_lastLookRootRotation);
@@ -277,21 +361,28 @@ namespace Unheimlich.World.CameraSystem
_lastLookRootRotation = lookRot;
}
private IEnumerator FadeCoroutine(bool fadeIn)
private IEnumerator FadeCoroutine(bool fadeIn, float targetIntensity, float targetStress)
{
float start = _weight;
float target = fadeIn ? 1f : 0f;
float elapsed = 0f;
float startIntensity = _masterIntensity;
float startStress = _stressLevel;
float elapsed = 0f;
if (fadeIn && _weight <= 0f) _weight = 0.001f;
while (elapsed < _fadeDuration)
{
elapsed += Time.deltaTime;
_weight = Mathf.Lerp(start, target, elapsed / _fadeDuration);
elapsed += Time.deltaTime;
float t = elapsed / _fadeDuration;
_masterIntensity = Mathf.Lerp(startIntensity, targetIntensity, t);
_stressLevel = Mathf.Lerp(startStress, targetStress, t);
_weight = _masterIntensity > 0f ? 1f : 0f;
yield return null;
}
_weight = target;
_fadeCoroutine = null;
_masterIntensity = targetIntensity;
_stressLevel = targetStress;
_weight = targetIntensity > 0f ? 1f : 0f;
_fadeCoroutine = null;
if (!fadeIn && _joltCoroutine != null)
{
@@ -304,14 +395,14 @@ namespace Unheimlich.World.CameraSystem
{
while (true)
{
float stress = Mathf.Clamp01(StressLevel);
float stress = Mathf.Clamp01(_stressLevel);
bool running = IsRunning();
// Use the same smoothed moveT as Update to keep jolt parameters consistent.
float joltAmp = isRunning()
float joltAmp = running
? Mathf.Lerp(_walkJoltAmplitude, _runJoltAmplitude, _smoothMoveT)
: Mathf.Lerp(_idleJoltAmplitude, _walkJoltAmplitude, _smoothMoveT);
Vector2 intervalRange = isRunning()
Vector2 intervalRange = running
? Vector2.Lerp(_walkJoltIntervalRange, _runJoltIntervalRange, _smoothMoveT)
: Vector2.Lerp(_idleJoltIntervalRange, _walkJoltIntervalRange, _smoothMoveT);
@@ -320,10 +411,8 @@ namespace Unheimlich.World.CameraSystem
yield return new WaitForSeconds(interval);
// Jolts only fire when the camera is steady — steadyFactor is 1 when still,
// 0 when the player is actively looking around.
float steadyFactor = 1f - Mathf.Clamp01(_lookSpeed / _joltLookSuppressSpeed);
float ampScale = (1f + stress * _stressJoltAmpMul) * _weight * steadyFactor;
float ampScale = (1f + stress * _stressJoltAmpMul) * _weight * _masterIntensity * steadyFactor;
Vector3 target = new Vector3(
Random.Range(-1f, 1f) * joltAmp * ampScale,
@@ -335,17 +424,12 @@ namespace Unheimlich.World.CameraSystem
}
}
/// <summary>Returns true if the player is currently running.</summary>
private bool isRunning()
private bool IsRunning()
{
return OctoCore.Components.OctoPlayer.Instance != null
&& OctoCore.Components.OctoPlayer.Instance.State.IsRunning;
}
/// <summary>
/// Snaps to <paramref name="target"/> in the first fraction of the interval,
/// then eases back to zero for the remainder — simulating a hand recoil.
/// </summary>
private IEnumerator ApplyJolt(Vector3 target, float totalDuration)
{
float snapDur = totalDuration * _joltSnapFraction;
@@ -1,74 +1,39 @@
using System.Collections;
using UnityEngine;
using UnityEngine.Rendering;
namespace MonoLimboStudio
namespace Unheimlich.VFX
{
public class LightFlicker : MonoBehaviour
{
[Header("Light Settings")]
public Light targetLight;
[Tooltip("Base intensity set externally (e.g. by ScarecrowFire). Flicker oscillates around this value.")]
public float baseIntensity = 1f;
[Tooltip("Flicker oscillates around this value. Driven each frame by ScarecrowFire.")]
public float baseIntensity = 0f;
[Tooltip("Flicker range applied relative to baseIntensity (e.g. 0.3 = ±30%).")]
[Range(0f, 1f)]
public float flickerRange = 0.3f;
[Header("Perlin Flicker (smooth)")]
[Range(0f, 1f)] public float flickerRange = 0.3f;
public float flickerSpeed = 5f;
[Header("Glitch Flicker (bursts)")]
[Range(0f, 1f)] public float glitchChance = 0.15f;
public float glitchMinDuration = 0.02f;
public float glitchMaxDuration = 0.12f;
[Range(0f, 1f)] public float glitchChance = 0.15f;
public float glitchMinDuration = 0.02f;
public float glitchMaxDuration = 0.12f;
[Header("Material Settings")]
public Renderer targetRenderer;
public string opacityProperty = "_Opacity";
[Range(0f, 1f)] public float minOpacity = 0.2f;
[Range(0f, 1f)] public float maxOpacity = 1.0f;
private Material _materialInstance;
private float _randomOffset;
// ─────────────────────────────────────────────────────────────────────
private float _randomOffset;
void Start()
{
_randomOffset = Random.Range(0f, 1000f);
if (targetRenderer != null)
{
_materialInstance = targetRenderer.material;
if (!_materialInstance.HasProperty(opacityProperty))
Debug.LogWarning($"Property '{opacityProperty}' not found on material.");
}
StartCoroutine(FlickerRoutine());
}
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Sets the base intensity that the flicker oscillates around.
/// Called each frame by ScarecrowFire as the fire grows.
/// </summary>
public void SetBaseIntensity(float value)
{
baseIntensity = value;
}
// ─────────────────────────────────────────────────────────────────────
/// <summary>Called each frame by ScarecrowFire to update the base the flicker oscillates around.</summary>
public void SetBaseIntensity(float value) => baseIntensity = value;
private IEnumerator FlickerRoutine()
{
while (true)
{
float perlinValue = Mathf.PerlinNoise(Time.time * flickerSpeed + _randomOffset, 0f);
ApplyFlicker(perlinValue);
float t = Mathf.PerlinNoise(Time.time * flickerSpeed + _randomOffset, 0f);
ApplyFlicker(t);
if (Random.value < glitchChance)
yield return StartCoroutine(GlitchBurst());
@@ -79,8 +44,8 @@ namespace MonoLimboStudio
private IEnumerator GlitchBurst()
{
float duration = Random.Range(glitchMinDuration, glitchMaxDuration);
float elapsed = 0f;
float duration = Random.Range(glitchMinDuration, glitchMaxDuration);
while (elapsed < duration)
{
@@ -90,26 +55,12 @@ namespace MonoLimboStudio
}
}
/// <summary>
/// Applies flicker as an offset around baseIntensity rather than
/// absolute min/max, so external intensity control stays intact.
/// </summary>
private void ApplyFlicker(float t)
{
if (targetLight != null)
{
float offset = Mathf.Lerp(-flickerRange, flickerRange, t);
targetLight.intensity = Mathf.Max(0f, baseIntensity + offset);
}
if (_materialInstance != null && _materialInstance.HasProperty(opacityProperty))
_materialInstance.SetFloat(opacityProperty, Mathf.Lerp(minOpacity, maxOpacity, t));
}
void OnDestroy()
{
if (_materialInstance != null)
Destroy(_materialInstance);
if (targetLight == null) return;
float offset = Mathf.Lerp(-baseIntensity * flickerRange,
baseIntensity * flickerRange, t);
targetLight.intensity = Mathf.Max(0f, baseIntensity + offset);
}
}
}