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
@@ -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;