Hallucination system setup & implementation with the infinite staircase + Fake volumetric lights test
This commit is contained in:
@@ -1,98 +1,77 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Unheimlich.Features.Hallucination;
|
||||
|
||||
namespace Unheimlich.Features.Dialogue.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-interactive floating word/phrase for world-space storytelling (thoughts, guidance, atmosphere).
|
||||
/// Uses TextMeshPro 3D (not Canvas). Designed to sit alongside BillboardUI on the same prefab.
|
||||
/// Non-interactive floating word/phrase for world-space storytelling.
|
||||
///
|
||||
/// Features:
|
||||
/// - Adaptive scale: label always appears the same angular size in the player's camera.
|
||||
/// - Max distance: fades out progressively when player is far away.
|
||||
/// - Min distance: label is pushed away from the camera so it never clips into the player's face.
|
||||
/// - Optional organic shake: label drifts gently around its home position.
|
||||
/// - Optional ghost trail: a semi-transparent copy follows with a position delay.
|
||||
/// - Optional camera attraction: label is gently pulled toward screen center when the player looks near it.
|
||||
/// - Optional one-shot: label permanently disappears after being seen and moving away.
|
||||
/// Changes from previous version:
|
||||
/// - Registers/unregisters itself with HallucinationLabelRegistry on enable/disable
|
||||
/// so the HallucinationManager can drive baseline behaviour without FindObjectsOfType.
|
||||
/// - Exposes SetShakeParams() and SetGhostTrail() for runtime overrides.
|
||||
/// - ApplyBaseline() / ApplyOverride() drive the two-layer system.
|
||||
/// </summary>
|
||||
public class FloatingLabel : MonoBehaviour
|
||||
{
|
||||
// ─── Inspector ─────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Text")]
|
||||
[Tooltip("Leave empty to keep text set directly on the TextMeshPro component.")]
|
||||
[SerializeField] [TextArea] private string _overrideText;
|
||||
[SerializeField] private TextMeshPro _textComponent;
|
||||
|
||||
[Header("Visibility")]
|
||||
[Tooltip("Player distance beyond which the label becomes invisible.")]
|
||||
[SerializeField] private float _maxVisibleDistance = 10f;
|
||||
[Tooltip("Distance range over which the label fades in/out near the max visibility boundary.")]
|
||||
[SerializeField] private float _fadeRange = 1.5f;
|
||||
|
||||
[Header("Min Distance")]
|
||||
[Tooltip("When the camera is closer than this, the label slides away to stay readable.")]
|
||||
[SerializeField] private float _minDistance = 0.5f;
|
||||
[Tooltip("Distance range over which the label fades out as the player approaches min distance.")]
|
||||
[SerializeField] private float _nearFadeRange = 0.8f;
|
||||
|
||||
[Header("Adaptive Scale")]
|
||||
[Tooltip("Scale adjusts so the label always appears the same size in the camera view.")]
|
||||
[SerializeField] private bool _enableAdaptiveScale = true;
|
||||
[Tooltip("Distance at which the label renders at its Inspector-set scale (1:1 reference).")]
|
||||
[SerializeField] private float _referenceDistance = 3f;
|
||||
[Tooltip("Minimum scale multiplier (relative to base scale). Prevents the label from shrinking too much up close.")]
|
||||
[SerializeField] private float _minScaleFactor = 0.2f;
|
||||
[Tooltip("Maximum scale multiplier (relative to base scale). Prevents the label from growing too large at a distance.")]
|
||||
[SerializeField] private float _maxScaleFactor = 3f;
|
||||
|
||||
[Header("Organic Shake (Optional)")]
|
||||
[SerializeField] private bool _enableOrganicShake = false;
|
||||
[Tooltip("Max drift radius in world units around the home position.")]
|
||||
[SerializeField] private float _shakeRadius = 0.05f;
|
||||
[Tooltip("How often a new shake target is picked (per second).")]
|
||||
[SerializeField] private float _shakeSpeed = 1.5f;
|
||||
[Tooltip("Higher = smoother drift transition.")]
|
||||
[SerializeField] private float _shakeSmoothness = 2f;
|
||||
[Header("Organic Shake — Baseline")]
|
||||
[Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")]
|
||||
[SerializeField] private bool _enableOrganicShake = false;
|
||||
[SerializeField] private float _shakeRadius = 0.05f;
|
||||
[SerializeField] private float _shakeSpeed = 1.5f;
|
||||
[SerializeField] private float _shakeSmoothness = 2f;
|
||||
|
||||
[Header("Ghost Trail (Optional)")]
|
||||
[SerializeField] private bool _enableGhostTrail = false;
|
||||
[Tooltip("Opacity of the ghost copy.")]
|
||||
[Header("Ghost Trail — Baseline")]
|
||||
[Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")]
|
||||
[SerializeField] private bool _enableGhostTrail = false;
|
||||
[SerializeField] [Range(0f, 1f)] private float _ghostAlpha = 0.3f;
|
||||
[Tooltip("How many seconds the ghost lags behind the main label.")]
|
||||
[SerializeField] private float _ghostDelay = 0.15f;
|
||||
[Tooltip("How far behind the main label the ghost is pushed (along the camera-to-label ray). Ensures the ghost is always occluded by the main word regardless of viewing angle.")]
|
||||
[SerializeField] private float _ghostDepthOffset = 0.02f;
|
||||
[SerializeField] private float _ghostDelay = 0.15f;
|
||||
[SerializeField] private float _ghostDepthOffset = 0.02f;
|
||||
|
||||
[Header("Camera Attraction (Optional)")]
|
||||
[Tooltip("When enabled, the label is gently pulled toward the screen center when the player looks near it.")]
|
||||
[SerializeField] private bool _enableCameraAttraction = false;
|
||||
[Tooltip("Half-angle (degrees) within which attraction is active. " +
|
||||
"Below this angle the label is drawn toward the screen center. " +
|
||||
"Keep small (e.g. 8-15°) so it only triggers when the player is really looking at the word.")]
|
||||
[SerializeField] private bool _enableCameraAttraction = false;
|
||||
[SerializeField] [Range(1f, 45f)] private float _attractionAngle = 10f;
|
||||
[Tooltip("Maximum world-space offset applied toward the screen center at full attraction strength.")]
|
||||
[SerializeField] private float _attractionMaxOffset = 0.3f;
|
||||
[Tooltip("How quickly the attraction offset interpolates toward its target (higher = snappier).")]
|
||||
[SerializeField] private float _attractionMaxOffset = 0.3f;
|
||||
[SerializeField] private float _attractionSmoothness = 4f;
|
||||
|
||||
[Header("One-Shot (Optional)")]
|
||||
[Tooltip("When enabled, the label permanently disappears once the player has seen it and then moved away.")]
|
||||
[SerializeField] private bool _enableOneShot = false;
|
||||
[Tooltip("Alpha threshold above which the label is considered 'seen'. " +
|
||||
"The player must look at the label long enough to exceed this visibility before the one-shot condition can trigger.")]
|
||||
[SerializeField] private bool _enableOneShot = false;
|
||||
[SerializeField] [Range(0.01f, 1f)] private float _seenAlphaThreshold = 0.5f;
|
||||
|
||||
[Header("Reappear Fade")]
|
||||
[Tooltip("Duration of the fade-in when the label reappears after being culled.")]
|
||||
[SerializeField] private float _fadeInDuration = 0.5f;
|
||||
|
||||
[Header("Performance")]
|
||||
[Tooltip("How often (in seconds) distance culling is evaluated. Higher = cheaper but slightly delayed visibility toggle.")]
|
||||
[SerializeField] private float _cullCheckInterval = 0.4f;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
// ─── Runtime state ─────────────────────────────────────────────────────
|
||||
|
||||
private Camera _camera;
|
||||
private Vector3 _localHomePosition;
|
||||
private Vector3 _homePosition;
|
||||
private Vector3 _baseScale;
|
||||
|
||||
@@ -101,75 +80,94 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
private float _cullTimer;
|
||||
private float _fadeInAlpha = 1f;
|
||||
|
||||
// Shake
|
||||
// Shake — live values driven by baseline or override
|
||||
private bool _isShaking;
|
||||
private Vector3 _shakeOffset;
|
||||
private Vector3 _shakeTarget;
|
||||
private float _shakeTimer;
|
||||
private float _liveShakeRadius;
|
||||
private float _liveShakeSpeed;
|
||||
private float _liveShakeSmoothness;
|
||||
|
||||
// Ghost trail
|
||||
private TextMeshPro _ghostText;
|
||||
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
|
||||
// Ghost trail — live values
|
||||
private bool _liveGhostEnabled;
|
||||
private float _liveGhostAlpha;
|
||||
private TextMeshPro _ghostText;
|
||||
private Queue<Vector3> _positionHistory = new();
|
||||
private float _historyTimer;
|
||||
private const float HISTORY_SAMPLE_RATE = 0.016f;
|
||||
|
||||
// Camera attraction
|
||||
// World-space offset currently applied on top of the home+shake position.
|
||||
private Vector3 _attractionOffset;
|
||||
|
||||
// One-shot
|
||||
// True once the label has been visible above the threshold at least once.
|
||||
private bool _hasBeenSeen;
|
||||
// True once the one-shot has fired and the label should stay invisible forever.
|
||||
private bool _permanentlyHidden;
|
||||
|
||||
// ── Unity ──────────────────────────────────────────────────────────────
|
||||
// Override coroutine
|
||||
private Coroutine _overrideCoroutine;
|
||||
|
||||
// Baseline snapshot (so revert after override is accurate)
|
||||
private float _baselineShakeRadius;
|
||||
private float _baselineShakeSpeed;
|
||||
private float _baselineShakeSmoothness;
|
||||
private bool _baselineGhostEnabled;
|
||||
private float _baselineGhostAlpha;
|
||||
|
||||
// ─── Unity ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_camera = Camera.main;
|
||||
|
||||
if (_textComponent == null)
|
||||
_textComponent = GetComponent<TextMeshPro>();
|
||||
|
||||
// Initialise live values from inspector defaults
|
||||
_liveShakeRadius = _shakeRadius;
|
||||
_liveShakeSpeed = _shakeSpeed;
|
||||
_liveShakeSmoothness = _shakeSmoothness;
|
||||
_liveGhostEnabled = _enableGhostTrail;
|
||||
_liveGhostAlpha = _ghostAlpha;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_homePosition = transform.position;
|
||||
_localHomePosition = transform.localPosition;
|
||||
_baseScale = transform.localScale;
|
||||
|
||||
if (_textComponent != null && !string.IsNullOrEmpty(_overrideText))
|
||||
_textComponent.text = _overrideText;
|
||||
|
||||
|
||||
|
||||
SaveBaselineSnapshot();
|
||||
SetupGhostTrail();
|
||||
|
||||
if (_enableOrganicShake)
|
||||
StartShake();
|
||||
StartShakeInternal();
|
||||
}
|
||||
|
||||
private void OnEnable() => HallucinationLabelRegistry.Register(this);
|
||||
private void OnDisable() => HallucinationLabelRegistry.Unregister(this);
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_permanentlyHidden) return;
|
||||
|
||||
if (_camera == null)
|
||||
{
|
||||
_camera = Camera.main;
|
||||
return;
|
||||
}
|
||||
if (_camera == null) { _camera = Camera.main; return; }
|
||||
|
||||
// Low-frequency distance cull: skip all logic and disable renderers when out of range.
|
||||
// ── Distance cull ──────────────────────────────────────────────────
|
||||
_cullTimer += Time.deltaTime;
|
||||
if (_cullTimer >= _cullCheckInterval)
|
||||
{
|
||||
_cullTimer = 0f;
|
||||
bool shouldBeActive = Vector3.Distance(_camera.transform.position, _homePosition)
|
||||
<= _maxVisibleDistance + _fadeRange;
|
||||
bool shouldBeActive = Vector3.Distance(_camera.transform.position, transform.position)
|
||||
<= _maxVisibleDistance + _fadeRange;
|
||||
if (shouldBeActive != _isActive)
|
||||
{
|
||||
_isActive = shouldBeActive;
|
||||
_textComponent.enabled = _isActive;
|
||||
if (_ghostText != null) _ghostText.enabled = _isActive;
|
||||
|
||||
// Reset fade-in so the label doesn't pop in abruptly.
|
||||
if (_ghostText != null) _ghostText.enabled = _isActive && _liveGhostEnabled;
|
||||
if (_isActive) _fadeInAlpha = 0f;
|
||||
}
|
||||
}
|
||||
@@ -178,19 +176,16 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
|
||||
UpdatePosition();
|
||||
|
||||
// Compute the distance-based alpha first so one-shot and attraction can read it.
|
||||
float dist = Vector3.Distance(_camera.transform.position, _homePosition);
|
||||
float dist = Vector3.Distance(_camera.transform.position, transform.position);
|
||||
float alpha = ComputeAlpha(dist) * _fadeInAlpha;
|
||||
|
||||
// One-shot: track visibility and trigger permanent hide when the player walks away after seeing the label.
|
||||
// ── One-shot ───────────────────────────────────────────────────────
|
||||
if (_enableOneShot)
|
||||
{
|
||||
if (!_hasBeenSeen && alpha >= _seenAlphaThreshold)
|
||||
_hasBeenSeen = true;
|
||||
|
||||
if (!_hasBeenSeen && alpha >= _seenAlphaThreshold) _hasBeenSeen = true;
|
||||
if (_hasBeenSeen && alpha <= 0f)
|
||||
{
|
||||
_permanentlyHidden = true;
|
||||
_permanentlyHidden = true;
|
||||
_textComponent.enabled = false;
|
||||
if (_ghostText != null) _ghostText.enabled = false;
|
||||
return;
|
||||
@@ -200,55 +195,149 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
UpdateCameraAttraction(alpha);
|
||||
ApplyAlpha(alpha);
|
||||
UpdateAdaptiveScale();
|
||||
|
||||
if (_isShaking)
|
||||
UpdateShake();
|
||||
|
||||
if (_isShaking) UpdateShake();
|
||||
UpdateGhostTrail();
|
||||
}
|
||||
|
||||
// ── Position ───────────────────────────────────────────────────────────
|
||||
// ─── Public API — two-layer system ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Pushes sanity-driven baseline values. Ignored while an override is active.
|
||||
/// Called every frame by HallucinationManager for labels within range.
|
||||
/// </summary>
|
||||
public void ApplyBaseline(float shakeRadius, float shakeSpeed, float shakeSmoothness,
|
||||
bool ghostEnabled, float ghostAlpha)
|
||||
{
|
||||
if (_overrideCoroutine != null) return;
|
||||
|
||||
SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness);
|
||||
SetGhostTrail(ghostEnabled, ghostAlpha);
|
||||
|
||||
// Keep snapshot fresh so revert after override is correct
|
||||
_baselineShakeRadius = shakeRadius;
|
||||
_baselineShakeSpeed = shakeSpeed;
|
||||
_baselineShakeSmoothness = shakeSmoothness;
|
||||
_baselineGhostEnabled = ghostEnabled;
|
||||
_baselineGhostAlpha = ghostAlpha;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a timed override then reverts to the last baseline snapshot.
|
||||
/// Called by HallucinationEvent at trigger time.
|
||||
/// </summary>
|
||||
public void ApplyOverride(float shakeRadius, float shakeSpeed, float shakeSmoothness,
|
||||
bool ghostEnabled, float ghostAlpha,
|
||||
float duration)
|
||||
{
|
||||
if (_overrideCoroutine != null) StopCoroutine(_overrideCoroutine);
|
||||
_overrideCoroutine = StartCoroutine(OverrideRoutine(
|
||||
shakeRadius, shakeSpeed, shakeSmoothness,
|
||||
ghostEnabled, ghostAlpha, duration));
|
||||
}
|
||||
|
||||
private IEnumerator OverrideRoutine(float shakeRadius, float shakeSpeed, float shakeSmoothness,
|
||||
bool ghostEnabled, float ghostAlpha, float duration)
|
||||
{
|
||||
SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness);
|
||||
SetGhostTrail(ghostEnabled, ghostAlpha);
|
||||
|
||||
yield return new WaitForSeconds(duration);
|
||||
|
||||
SetShakeParams(_baselineShakeRadius, _baselineShakeSpeed, _baselineShakeSmoothness);
|
||||
SetGhostTrail(_baselineGhostEnabled, _baselineGhostAlpha);
|
||||
_overrideCoroutine = null;
|
||||
}
|
||||
|
||||
// ─── Low-level setters (public for editor tooling / direct calls) ───────
|
||||
|
||||
public void SetShakeParams(float radius, float speed, float smoothness)
|
||||
{
|
||||
_liveShakeRadius = radius;
|
||||
_liveShakeSpeed = speed;
|
||||
_liveShakeSmoothness = smoothness;
|
||||
|
||||
if (radius > 0f && !_isShaking) StartShakeInternal();
|
||||
else if (radius <= 0f) _isShaking = false;
|
||||
}
|
||||
|
||||
public void SetGhostTrail(bool enabled, float alpha)
|
||||
{
|
||||
_liveGhostEnabled = enabled;
|
||||
_liveGhostAlpha = alpha;
|
||||
if (_ghostText != null) _ghostText.enabled = _isActive && enabled;
|
||||
}
|
||||
|
||||
// ─── Existing API ──────────────────────────────────────────────────────
|
||||
|
||||
public void SetText(string text)
|
||||
{
|
||||
_overrideText = text;
|
||||
if (_textComponent != null) _textComponent.text = text;
|
||||
if (_ghostText != null) _ghostText.text = text;
|
||||
}
|
||||
|
||||
public void SetHomePosition(Vector3 worldPos)
|
||||
{
|
||||
_homePosition = worldPos;
|
||||
transform.position = worldPos;
|
||||
}
|
||||
|
||||
public void ResetOneShot()
|
||||
{
|
||||
_hasBeenSeen = false;
|
||||
_permanentlyHidden = false;
|
||||
if (_textComponent != null) _textComponent.enabled = true;
|
||||
if (_ghostText != null) _ghostText.enabled = true;
|
||||
_fadeInAlpha = 0f;
|
||||
}
|
||||
|
||||
// ─── Internals ─────────────────────────────────────────────────────────
|
||||
|
||||
private void SaveBaselineSnapshot()
|
||||
{
|
||||
_baselineShakeRadius = _shakeRadius;
|
||||
_baselineShakeSpeed = _shakeSpeed;
|
||||
_baselineShakeSmoothness = _shakeSmoothness;
|
||||
_baselineGhostEnabled = _enableGhostTrail;
|
||||
_baselineGhostAlpha = _ghostAlpha;
|
||||
}
|
||||
|
||||
private void UpdatePosition()
|
||||
{
|
||||
if (transform.parent != null)
|
||||
_homePosition = transform.parent.TransformPoint(_localHomePosition);
|
||||
|
||||
Vector3 toHome = _homePosition - _camera.transform.position;
|
||||
float dist = toHome.magnitude;
|
||||
|
||||
Vector3 basePos;
|
||||
if (dist < _minDistance && dist > 0.001f)
|
||||
basePos = _camera.transform.position + toHome.normalized * _minDistance;
|
||||
else
|
||||
basePos = _homePosition + _shakeOffset;
|
||||
Vector3 basePos = (dist < _minDistance && dist > 0.001f)
|
||||
? _camera.transform.position + toHome.normalized * _minDistance
|
||||
: _homePosition + _shakeOffset;
|
||||
|
||||
transform.position = basePos + _attractionOffset;
|
||||
}
|
||||
|
||||
// ── Visibility (distance fade) ─────────────────────────────────────────
|
||||
|
||||
private void ApplyAlpha(float alpha)
|
||||
{
|
||||
if (_textComponent == null) return;
|
||||
|
||||
if (_fadeInAlpha < 1f)
|
||||
_fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f, Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f));
|
||||
_fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f,
|
||||
Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f));
|
||||
|
||||
SetTextAlpha(_textComponent, alpha);
|
||||
|
||||
if (_ghostText != null)
|
||||
SetTextAlpha(_ghostText, alpha * _ghostAlpha);
|
||||
if (_ghostText != null && _liveGhostEnabled)
|
||||
SetTextAlpha(_ghostText, alpha * _liveGhostAlpha);
|
||||
}
|
||||
|
||||
private float ComputeAlpha(float dist)
|
||||
{
|
||||
// Far fade-out
|
||||
if (dist >= _maxVisibleDistance)
|
||||
return 0f;
|
||||
if (dist >= _maxVisibleDistance) return 0f;
|
||||
|
||||
float farFadeStart = _maxVisibleDistance - _fadeRange;
|
||||
if (dist > farFadeStart && _fadeRange > 0f)
|
||||
return 1f - (dist - farFadeStart) / _fadeRange;
|
||||
|
||||
// Near fade-out
|
||||
float nearFadeEnd = _minDistance + _nearFadeRange;
|
||||
if (dist < nearFadeEnd && _nearFadeRange > 0f)
|
||||
return Mathf.Clamp01((dist - _minDistance) / _nearFadeRange);
|
||||
@@ -263,104 +352,62 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
text.color = c;
|
||||
}
|
||||
|
||||
// ── Adaptive Scale ─────────────────────────────────────────────────────
|
||||
|
||||
private void UpdateAdaptiveScale()
|
||||
{
|
||||
if (!_enableAdaptiveScale) return;
|
||||
|
||||
float dist = Vector3.Distance(_camera.transform.position, transform.position);
|
||||
float scaleFactor = Mathf.Clamp(dist / Mathf.Max(_referenceDistance, 0.001f), _minScaleFactor, _maxScaleFactor);
|
||||
float scaleFactor = Mathf.Clamp(dist / Mathf.Max(_referenceDistance, 0.001f),
|
||||
_minScaleFactor, _maxScaleFactor);
|
||||
transform.localScale = _baseScale * scaleFactor;
|
||||
|
||||
if (_ghostText != null)
|
||||
_ghostText.transform.localScale = transform.localScale;
|
||||
if (_ghostText != null) _ghostText.transform.localScale = transform.localScale;
|
||||
}
|
||||
|
||||
// ── Camera Attraction ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the label toward the screen center when the player is looking near it.
|
||||
/// Strength uses a quadratic falloff so it only kicks in meaningfully
|
||||
/// when the gaze angle is truly small.
|
||||
/// </summary>
|
||||
private void UpdateCameraAttraction(float currentAlpha)
|
||||
{
|
||||
if (!_enableCameraAttraction)
|
||||
{
|
||||
_attractionOffset = Vector3.zero;
|
||||
return;
|
||||
}
|
||||
if (!_enableCameraAttraction) { _attractionOffset = Vector3.zero; return; }
|
||||
|
||||
// Direction from camera to the label's home position.
|
||||
Vector3 toLabel = _homePosition - _camera.transform.position;
|
||||
float dist = toLabel.magnitude;
|
||||
if (dist < 0.001f) { _attractionOffset = Vector3.zero; return; }
|
||||
|
||||
if (dist < 0.001f)
|
||||
{
|
||||
_attractionOffset = Vector3.zero;
|
||||
return;
|
||||
}
|
||||
|
||||
// Angle between the camera's forward direction and the direction to the label.
|
||||
float angleDeg = Vector3.Angle(_camera.transform.forward, toLabel.normalized);
|
||||
float t = Mathf.Clamp01(1f - angleDeg / Mathf.Max(_attractionAngle, 0.001f));
|
||||
float strength = t * t * currentAlpha;
|
||||
|
||||
// Quadratic falloff: 1 at angleDeg==0, 0 at angleDeg==_attractionAngle and beyond.
|
||||
float t = Mathf.Clamp01(1f - angleDeg / Mathf.Max(_attractionAngle, 0.001f));
|
||||
float strength = t * t; // quadratic — only strong when really close to center
|
||||
|
||||
// Also scale with the distance-based alpha so attraction is zero when the label is faded out.
|
||||
strength *= currentAlpha;
|
||||
|
||||
// Target offset: move the label to where Camera.forward ray at distance |toLabel| lands.
|
||||
Vector3 labelOnCenterRay = _camera.transform.position + _camera.transform.forward * dist;
|
||||
Vector3 targetOffset = (labelOnCenterRay - _homePosition) * strength;
|
||||
|
||||
// Clamp magnitude so it never overrides the home position completely.
|
||||
if (targetOffset.magnitude > _attractionMaxOffset)
|
||||
targetOffset = targetOffset.normalized * _attractionMaxOffset;
|
||||
|
||||
_attractionOffset = Vector3.Lerp(
|
||||
_attractionOffset,
|
||||
targetOffset,
|
||||
Time.deltaTime * _attractionSmoothness
|
||||
);
|
||||
_attractionOffset = Vector3.Lerp(_attractionOffset, targetOffset,
|
||||
Time.deltaTime * _attractionSmoothness);
|
||||
}
|
||||
|
||||
// ── Organic Shake ──────────────────────────────────────────────────────
|
||||
|
||||
private Vector3 RandomShakeTarget()
|
||||
{
|
||||
float scaledRadius = _shakeRadius * transform.localScale.x;
|
||||
Vector2 circle = Random.insideUnitCircle * scaledRadius;
|
||||
return new Vector3(circle.x, circle.y, 0f);
|
||||
}
|
||||
|
||||
private void StartShake()
|
||||
private void StartShakeInternal()
|
||||
{
|
||||
_isShaking = true;
|
||||
_shakeTimer = 0f;
|
||||
_shakeTarget = RandomShakeTarget();
|
||||
}
|
||||
|
||||
private void UpdateShake()
|
||||
private Vector3 RandomShakeTarget()
|
||||
{
|
||||
_shakeTimer += Time.deltaTime * _shakeSpeed;
|
||||
|
||||
if (_shakeTimer >= 1f)
|
||||
{
|
||||
_shakeTimer = 0f;
|
||||
_shakeTarget = RandomShakeTarget();
|
||||
}
|
||||
|
||||
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * _shakeSmoothness);
|
||||
float scaledRadius = _liveShakeRadius * transform.localScale.x;
|
||||
Vector2 circle = Random.insideUnitCircle * scaledRadius;
|
||||
return new Vector3(circle.x, circle.y, 0f);
|
||||
}
|
||||
|
||||
// ── Ghost Trail ────────────────────────────────────────────────────────
|
||||
private void UpdateShake()
|
||||
{
|
||||
_shakeTimer += Time.deltaTime * _liveShakeSpeed;
|
||||
if (_shakeTimer >= 1f) { _shakeTimer = 0f; _shakeTarget = RandomShakeTarget(); }
|
||||
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * _liveShakeSmoothness);
|
||||
}
|
||||
|
||||
private void SetupGhostTrail()
|
||||
{
|
||||
if (!_enableGhostTrail || _textComponent == null) return;
|
||||
// Ghost GO is always created when the feature might be enabled at runtime
|
||||
if (_textComponent == null) return;
|
||||
|
||||
GameObject ghostObj = new GameObject($"Ghost_{gameObject.name}");
|
||||
ghostObj.transform.SetParent(transform.parent, false);
|
||||
@@ -369,17 +416,13 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
ghostObj.transform.localScale = transform.localScale;
|
||||
ghostObj.layer = gameObject.layer;
|
||||
|
||||
_ghostText = ghostObj.AddComponent<TextMeshPro>();
|
||||
_ghostText.font = _textComponent.font;
|
||||
_ghostText.fontSize = _textComponent.fontSize;
|
||||
_ghostText.alignment = _textComponent.alignment;
|
||||
_ghostText.text = _textComponent.text;
|
||||
_ghostText.color = new Color(
|
||||
_textComponent.color.r,
|
||||
_textComponent.color.g,
|
||||
_textComponent.color.b,
|
||||
_ghostAlpha
|
||||
);
|
||||
_ghostText = ghostObj.AddComponent<TextMeshPro>();
|
||||
_ghostText.font = _textComponent.font;
|
||||
_ghostText.fontSize = _textComponent.fontSize;
|
||||
_ghostText.alignment = _textComponent.alignment;
|
||||
_ghostText.text = _textComponent.text;
|
||||
_ghostText.color = new Color(_textComponent.color.r, _textComponent.color.g,
|
||||
_textComponent.color.b, _liveGhostAlpha);
|
||||
|
||||
MeshRenderer mainRenderer = _textComponent.GetComponent<MeshRenderer>();
|
||||
MeshRenderer ghostRenderer = _ghostText.GetComponent<MeshRenderer>();
|
||||
@@ -390,59 +433,30 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
}
|
||||
|
||||
ghostObj.AddComponent<BillboardUI>();
|
||||
|
||||
// Start hidden — the manager or an override will enable it when needed
|
||||
_ghostText.enabled = _liveGhostEnabled;
|
||||
}
|
||||
|
||||
private void UpdateGhostTrail()
|
||||
{
|
||||
if (!_enableGhostTrail || _ghostText == null) return;
|
||||
if (!_liveGhostEnabled || _ghostText == null) return;
|
||||
|
||||
_historyTimer += Time.deltaTime;
|
||||
if (_historyTimer >= HISTORY_SAMPLE_RATE)
|
||||
{
|
||||
_positionHistory.Enqueue(transform.position);
|
||||
_historyTimer = 0f;
|
||||
|
||||
int maxSize = Mathf.CeilToInt(_ghostDelay / HISTORY_SAMPLE_RATE);
|
||||
while (_positionHistory.Count > maxSize)
|
||||
_positionHistory.Dequeue();
|
||||
while (_positionHistory.Count > maxSize) _positionHistory.Dequeue();
|
||||
}
|
||||
|
||||
Vector3 ghostPos = _positionHistory.Count > 0
|
||||
? _positionHistory.Peek()
|
||||
: transform.position;
|
||||
|
||||
Vector3 ghostPos = _positionHistory.Count > 0 ? _positionHistory.Peek() : transform.position;
|
||||
_ghostText.transform.position = ghostPos + _camera.transform.forward * _ghostDepthOffset;
|
||||
_ghostText.text = _textComponent.text;
|
||||
_ghostText.fontSize = _textComponent.fontSize;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Sets the displayed text at runtime.</summary>
|
||||
public void SetText(string text)
|
||||
{
|
||||
_overrideText = text;
|
||||
if (_textComponent != null) _textComponent.text = text;
|
||||
if (_ghostText != null) _ghostText.text = text;
|
||||
}
|
||||
|
||||
/// <summary>Teleports the label's home position without animation.</summary>
|
||||
public void SetHomePosition(Vector3 worldPos)
|
||||
{
|
||||
_homePosition = worldPos;
|
||||
transform.position = worldPos;
|
||||
}
|
||||
|
||||
/// <summary>Resets the one-shot state so the label can be seen again.</summary>
|
||||
public void ResetOneShot()
|
||||
{
|
||||
_hasBeenSeen = false;
|
||||
_permanentlyHidden = false;
|
||||
if (_textComponent != null) _textComponent.enabled = true;
|
||||
if (_ghostText != null) _ghostText.enabled = true;
|
||||
_fadeInAlpha = 0f;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user