Feat(camera) : Add LostFootageRig - handheld camera shake, micro-jolts and inertia swing
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e774c6c7706766428a1bd334a02cba8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,337 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.World.CameraSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Overlays a "lost footage / handheld camcorder" shake on a dedicated pivot
|
||||
/// sitting between CameraPivot and Camera Player.
|
||||
///
|
||||
/// Layered in three passes:
|
||||
/// 1. Continuous Perlin drift — organic idle tremor, scales with movement and stress.
|
||||
/// 2. Micro-jolts — randomised snap/recoil spikes, more frequent under stress.
|
||||
/// 3. Inertia swinging — the camera "lags" behind head rotation on all axes,
|
||||
/// simulating the physical weight of a handheld camcorder.
|
||||
///
|
||||
/// Relies on its own dedicated GameObject (LostFootagePivot) so it never conflicts
|
||||
/// with <see cref="CameraHeadBob"/> which owns the parent pivot's rotation.
|
||||
/// </summary>
|
||||
public class LostFootageRig : MonoBehaviour
|
||||
{
|
||||
// ── Inspector — Noise ─────────────────────────────────────────────────────
|
||||
|
||||
[Header("Noise — idle")]
|
||||
[SerializeField] private float _idleAmplitude = 0.25f;
|
||||
[SerializeField] private float _idleNoiseSpeed = 0.6f;
|
||||
|
||||
[Header("Noise — walk")]
|
||||
[SerializeField] private float _walkAmplitude = 0.8f;
|
||||
[SerializeField] private float _walkNoiseSpeed = 1.4f;
|
||||
|
||||
[Header("Noise — run")]
|
||||
[SerializeField] private float _runAmplitude = 1.8f;
|
||||
[SerializeField] private float _runNoiseSpeed = 2.5f;
|
||||
|
||||
[Header("Noise — stress scale")]
|
||||
[SerializeField] private float _stressAmplitudeMul = 2.5f;
|
||||
[SerializeField] private float _stressSpeedMul = 1.8f;
|
||||
|
||||
// ── Inspector — Micro-jolts ───────────────────────────────────────────────
|
||||
|
||||
[Header("Micro-jolts — idle")]
|
||||
[SerializeField] private float _idleJoltAmplitude = 0.5f;
|
||||
[SerializeField] private Vector2 _idleJoltIntervalRange = new Vector2(2f, 4f);
|
||||
|
||||
[Header("Micro-jolts — walk")]
|
||||
[SerializeField] private float _walkJoltAmplitude = 1.2f;
|
||||
[SerializeField] private Vector2 _walkJoltIntervalRange = new Vector2(0.8f, 2f);
|
||||
|
||||
[Header("Micro-jolts — run")]
|
||||
[SerializeField] private float _runJoltAmplitude = 2.5f;
|
||||
[SerializeField] private Vector2 _runJoltIntervalRange = new Vector2(0.2f, 0.8f);
|
||||
|
||||
[Header("Micro-jolts — shared")]
|
||||
[SerializeField] private float _joltSnapFraction = 0.5f; // plus rapide que ton 0.25 original
|
||||
[SerializeField] private float _stressJoltFreqMul = 5f;
|
||||
[SerializeField] private float _stressJoltAmpMul = 3f;
|
||||
|
||||
// ── Inspector — Inertia swing ─────────────────────────────────────────────
|
||||
|
||||
[Header("Inertia swing")]
|
||||
[SerializeField] private float _inertiaStrength = 4f;
|
||||
[SerializeField] private float _inertiaDamping = 6f;
|
||||
[SerializeField] private float _inertiaMaxAngle = 8f;
|
||||
|
||||
// ── Inspector — Fade ──────────────────────────────────────────────────────
|
||||
|
||||
[Header("Fade")]
|
||||
[SerializeField] private float _fadeDuration = 0.6f;
|
||||
// ── Inspector — Debug ─────────────────────────────────────────────────────
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _debugSustainStress;
|
||||
[SerializeField] [Range(0f, 1f)] private float _debugStressValue;
|
||||
|
||||
// ── Runtime state ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Current stress level in [0, 1]. Driven by <see cref="StressController"/>.</summary>
|
||||
public float StressLevel { get; set; }
|
||||
|
||||
/// <summary>Whether the rig is currently active (or fading in/out).</summary>
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
private float _weight;
|
||||
private Vector3 _noiseOffset;
|
||||
private Vector3 _joltRotation;
|
||||
|
||||
// Inertia — tracks angular velocity of the parent to derive swing offset.
|
||||
private Quaternion _lastParentRotation;
|
||||
private Vector3 _inertiaOffset;
|
||||
|
||||
private Coroutine _fadeCoroutine;
|
||||
private Coroutine _joltCoroutine;
|
||||
|
||||
// ── Unity lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_noiseOffset = new Vector3(
|
||||
Random.Range(0f, 100f),
|
||||
Random.Range(0f, 100f),
|
||||
Random.Range(0f, 100f)
|
||||
);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_lastParentRotation = transform.parent != null
|
||||
? transform.parent.rotation
|
||||
: transform.rotation;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_debugSustainStress)
|
||||
StressLevel = _debugStressValue;
|
||||
|
||||
if (_weight <= 0f)
|
||||
{
|
||||
_UpdateInertia(); // keep tracking even when invisible
|
||||
return;
|
||||
}
|
||||
|
||||
float stress = Mathf.Clamp01(StressLevel);
|
||||
|
||||
// ── 1. Resolve movement state ─────────────────────────────────────────
|
||||
|
||||
float horizontalSpeed = 0f;
|
||||
bool isRunning = false;
|
||||
|
||||
if (Unheimlich.Features.Player.PlayerController.Instance?.CharController != null)
|
||||
{
|
||||
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.IsRunning;
|
||||
}
|
||||
|
||||
const float WalkSpeedMs = 10f / 3.6f;
|
||||
const float RunSpeedMs = 20f / 3.6f;
|
||||
|
||||
float maxSpeed = isRunning ? RunSpeedMs : WalkSpeedMs;
|
||||
float moveT = Mathf.Clamp01(horizontalSpeed / maxSpeed);
|
||||
|
||||
// ── 2. Interpolate noise parameters from movement state ───────────────
|
||||
|
||||
float targetAmp = isRunning
|
||||
? Mathf.Lerp(_walkAmplitude, _runAmplitude, moveT)
|
||||
: Mathf.Lerp(_idleAmplitude, _walkAmplitude, moveT);
|
||||
|
||||
float targetSpeed = isRunning
|
||||
? Mathf.Lerp(_walkNoiseSpeed, _runNoiseSpeed, moveT)
|
||||
: Mathf.Lerp(_idleNoiseSpeed, _walkNoiseSpeed, moveT);
|
||||
|
||||
float amp = (targetAmp + stress * _stressAmplitudeMul) * _weight;
|
||||
float spd = (targetSpeed + stress * _stressSpeedMul);
|
||||
float t = Time.time * spd;
|
||||
|
||||
// ── 3. Perlin drift ───────────────────────────────────────────────────
|
||||
|
||||
float rx = (Mathf.PerlinNoise(_noiseOffset.x + t, 0f) - 0.5f) * amp * 2.0f;
|
||||
float ry = (Mathf.PerlinNoise(_noiseOffset.y + t, 0.5f) - 0.5f) * amp * 1.4f;
|
||||
float rz = (Mathf.PerlinNoise(_noiseOffset.z + t, 1.0f) - 0.5f) * amp * 0.6f;
|
||||
|
||||
// ── 4. Inertia swing ──────────────────────────────────────────────────
|
||||
|
||||
_UpdateInertia();
|
||||
Vector3 swing = _inertiaOffset * _weight;
|
||||
|
||||
// ── 5. Compose and apply ──────────────────────────────────────────────
|
||||
|
||||
transform.localRotation = Quaternion.Euler(
|
||||
rx + _joltRotation.x + swing.x,
|
||||
ry + _joltRotation.y + swing.y,
|
||||
rz + _joltRotation.z + swing.z
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (IsActive == active)
|
||||
return;
|
||||
|
||||
IsActive = active;
|
||||
|
||||
if (_fadeCoroutine != null)
|
||||
StopCoroutine(_fadeCoroutine);
|
||||
|
||||
_fadeCoroutine = StartCoroutine(FadeCoroutine(active));
|
||||
|
||||
if (active)
|
||||
{
|
||||
if (_joltCoroutine != null)
|
||||
StopCoroutine(_joltCoroutine);
|
||||
|
||||
_joltCoroutine = StartCoroutine(JoltCoroutine());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Computes the inertia swing offset from the delta between the parent's
|
||||
/// current and previous rotation, then damps it back toward zero.
|
||||
/// </summary>
|
||||
private void _UpdateInertia()
|
||||
{
|
||||
Quaternion parentRot = transform.parent != null
|
||||
? transform.parent.rotation
|
||||
: Quaternion.identity;
|
||||
|
||||
Quaternion delta = parentRot * Quaternion.Inverse(_lastParentRotation);
|
||||
delta.ToAngleAxis(out float angle, out Vector3 axis);
|
||||
|
||||
// Wrap angle to [-180, 180] to avoid 360° flips.
|
||||
if (angle > 180f) angle -= 360f;
|
||||
|
||||
// Angular velocity this frame → drive inertia offset.
|
||||
Vector3 angularVel = axis * (angle / Mathf.Max(Time.deltaTime, 0.0001f));
|
||||
_inertiaOffset -= angularVel * (_inertiaStrength * Time.deltaTime);
|
||||
_inertiaOffset = Vector3.ClampMagnitude(_inertiaOffset, _inertiaMaxAngle);
|
||||
|
||||
// Damp back toward zero.
|
||||
_inertiaOffset = Vector3.Lerp(_inertiaOffset, Vector3.zero, _inertiaDamping * Time.deltaTime);
|
||||
|
||||
_lastParentRotation = parentRot;
|
||||
}
|
||||
|
||||
private IEnumerator FadeCoroutine(bool fadeIn)
|
||||
{
|
||||
float start = _weight;
|
||||
float target = fadeIn ? 1f : 0f;
|
||||
float elapsed = 0f;
|
||||
|
||||
while (elapsed < _fadeDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
_weight = Mathf.Lerp(start, target, elapsed / _fadeDuration);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_weight = target;
|
||||
_fadeCoroutine = null;
|
||||
|
||||
if (!fadeIn && _joltCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_joltCoroutine);
|
||||
_joltCoroutine = null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator JoltCoroutine()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
float stress = Mathf.Clamp01(StressLevel);
|
||||
|
||||
// Resolve jolt parameters from movement state.
|
||||
float horizontalSpeed = 0f;
|
||||
bool isRunning = false;
|
||||
|
||||
if (Unheimlich.Features.Player.PlayerController.Instance?.CharController != null)
|
||||
{
|
||||
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.IsRunning;
|
||||
}
|
||||
|
||||
const float WalkSpeedMs = 10f / 3.6f;
|
||||
float maxSpeed = isRunning ? 20f / 3.6f : WalkSpeedMs;
|
||||
float moveT = Mathf.Clamp01(horizontalSpeed / maxSpeed);
|
||||
|
||||
float joltAmp = isRunning
|
||||
? Mathf.Lerp(_walkJoltAmplitude, _runJoltAmplitude, moveT)
|
||||
: Mathf.Lerp(_idleJoltAmplitude, _walkJoltAmplitude, moveT);
|
||||
|
||||
Vector2 intervalRange = isRunning
|
||||
? Vector2.Lerp(_walkJoltIntervalRange, _runJoltIntervalRange, moveT)
|
||||
: Vector2.Lerp(_idleJoltIntervalRange, _walkJoltIntervalRange, moveT);
|
||||
|
||||
float interval = Random.Range(intervalRange.x, intervalRange.y);
|
||||
interval /= Mathf.Lerp(1f, _stressJoltFreqMul, stress);
|
||||
|
||||
yield return new WaitForSeconds(interval);
|
||||
|
||||
float ampScale = (1f + stress * _stressJoltAmpMul) * _weight;
|
||||
Vector3 target = new Vector3(
|
||||
Random.Range(-1f, 1f) * joltAmp * ampScale,
|
||||
Random.Range(-1f, 1f) * joltAmp * ampScale,
|
||||
Random.Range(-0.3f, 0.3f) * joltAmp * ampScale
|
||||
);
|
||||
|
||||
yield return StartCoroutine(ApplyJolt(target, interval));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
float easeDur = totalDuration * (1f - _joltSnapFraction);
|
||||
|
||||
float elapsed = 0f;
|
||||
Vector3 start = _joltRotation;
|
||||
|
||||
while (elapsed < snapDur)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / snapDur;
|
||||
_joltRotation = Vector3.Lerp(start, target, t * t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
elapsed = 0f;
|
||||
start = _joltRotation;
|
||||
|
||||
while (elapsed < easeDur)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / easeDur;
|
||||
_joltRotation = Vector3.Lerp(start, Vector3.zero, t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_joltRotation = Vector3.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 320cde26f18d40b40a31c85e85ee4e59
|
||||
@@ -0,0 +1,95 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Features.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages the player's stress level and forwards it to <see cref="Unheimlich.World.CameraSystem.LostFootageRig"/>.
|
||||
/// Stress rises instantly to the requested target and decays back to zero over time when not sustained.
|
||||
///
|
||||
/// Intended to sit on <c>OctoPlayerFP</c> alongside other player-level components.
|
||||
/// Call <see cref="AddStress"/>, <see cref="SetStress"/>, or <see cref="CalmDown"/> from
|
||||
/// gameplay systems (proximity triggers, jump-scares, scripted events, etc.).
|
||||
/// </summary>
|
||||
public class StressController : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Header("References")]
|
||||
[Tooltip("The LostFootageRig component on CameraPivot.")]
|
||||
[SerializeField] private Unheimlich.World.CameraSystem.LostFootageRig _rig;
|
||||
|
||||
[Header("Decay")]
|
||||
[Tooltip("Rate at which stress falls back to zero per second when not sustained.")]
|
||||
[SerializeField] private float _decaySpeed = 0.3f;
|
||||
|
||||
[Tooltip("Stress must stay below this threshold for the rig to auto-deactivate.")]
|
||||
[SerializeField] private float _deactivateThreshold = 0.05f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _debugSustainStress;
|
||||
[SerializeField] [Range(0f, 1f)] private float _debugStressValue;
|
||||
|
||||
// ── Runtime state ─────────────────────────────────────────────────────────
|
||||
|
||||
private float _currentStress;
|
||||
private float _targetStress;
|
||||
private bool _sustained; // true while an external system holds a target this frame
|
||||
|
||||
// ── Unity lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_debugSustainStress)
|
||||
SetStress(_debugStressValue);
|
||||
|
||||
_sustained = false;
|
||||
|
||||
if (_currentStress > _targetStress)
|
||||
_currentStress = Mathf.MoveTowards(_currentStress, _targetStress, _decaySpeed * Time.deltaTime);
|
||||
else
|
||||
_currentStress = _targetStress;
|
||||
|
||||
_targetStress = 0f; // reset each frame; callers must re-assert every Update if sustained
|
||||
|
||||
if (_rig == null)
|
||||
return;
|
||||
|
||||
_rig.StressLevel = _currentStress;
|
||||
|
||||
// Auto-toggle the rig based on stress level.
|
||||
if (!_rig.IsActive && _currentStress > _deactivateThreshold)
|
||||
_rig.SetActive(true);
|
||||
else if (_rig.IsActive && _currentStress <= _deactivateThreshold)
|
||||
_rig.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sets a one-shot stress spike that will decay naturally next frame.
|
||||
/// For sustained stress, call this every frame from a trigger zone or event.
|
||||
/// </summary>
|
||||
public void SetStress(float value)
|
||||
{
|
||||
_targetStress = Mathf.Max(_targetStress, Mathf.Clamp01(value));
|
||||
_currentStress = Mathf.Max(_currentStress, _targetStress);
|
||||
}
|
||||
|
||||
/// <summary>Adds <paramref name="delta"/> on top of the current target stress.</summary>
|
||||
public void AddStress(float delta) => SetStress(_targetStress + delta);
|
||||
|
||||
/// <summary>Immediately zeroes stress and deactivates the rig.</summary>
|
||||
public void CalmDown()
|
||||
{
|
||||
_currentStress = 0f;
|
||||
_targetStress = 0f;
|
||||
_rig?.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directly activates or deactivates the rig regardless of stress level.
|
||||
/// Useful for scripted sequences where you want the effect without the stress system.
|
||||
/// </summary>
|
||||
public void ForceRig(bool active) => _rig?.SetActive(active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b711f35d53cba9d43b8b6f55a40cedbc
|
||||
Reference in New Issue
Block a user