+Add : Blocking test & Cleanup

This commit is contained in:
Ferlat Thibaud
2026-03-26 02:37:05 +01:00
parent 0c0a539af3
commit 900cc079c8
85 changed files with 101475 additions and 1084 deletions
@@ -24,7 +24,7 @@ namespace Unheimlich.Features.DetectiveBoard.UI
[SerializeField] private float _maxFanHalfAngle = 40f;
[Header("Placement Settings")]
[SerializeField] private float _placementLerpSpeed = 5f;
// [SerializeField] private float _placementLerpSpeed = 5f;
private readonly List<ClueHandCard> _handCards = new List<ClueHandCard>();
private bool _isPlacing; protected override void OnAwake() { }
@@ -14,6 +14,8 @@ namespace Unheimlich.Features.Dialogue.UI
/// - 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.
/// </summary>
public class FloatingLabel : MonoBehaviour
{
@@ -62,6 +64,25 @@ namespace Unheimlich.Features.Dialogue.UI
[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;
[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] [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 _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] [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;
@@ -71,7 +92,7 @@ namespace Unheimlich.Features.Dialogue.UI
[SerializeField] private float _cullCheckInterval = 0.4f;
// ── State ──────────────────────────────────────────────────────────────
private Camera _camera;
private Camera _camera;
private Vector3 _homePosition;
private Vector3 _baseScale;
@@ -81,16 +102,26 @@ namespace Unheimlich.Features.Dialogue.UI
private float _fadeInAlpha = 1f;
// Shake
private bool _isShaking;
private bool _isShaking;
private Vector3 _shakeOffset;
private Vector3 _shakeTarget;
private float _shakeTimer;
// Ghost trail
private TextMeshPro _ghostText;
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
private float _historyTimer;
private const float HISTORY_SAMPLE_RATE = 0.016f;
private TextMeshPro _ghostText;
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
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 ──────────────────────────────────────────────────────────────
private void Awake()
@@ -117,6 +148,8 @@ namespace Unheimlich.Features.Dialogue.UI
private void Update()
{
if (_permanentlyHidden) return;
if (_camera == null)
{
_camera = Camera.main;
@@ -144,7 +177,28 @@ namespace Unheimlich.Features.Dialogue.UI
if (!_isActive) return;
UpdatePosition();
UpdateVisibility();
// Compute the distance-based alpha first so one-shot and attraction can read it.
float dist = Vector3.Distance(_camera.transform.position, _homePosition);
float alpha = ComputeAlpha(dist) * _fadeInAlpha;
// One-shot: track visibility and trigger permanent hide when the player walks away after seeing the label.
if (_enableOneShot)
{
if (!_hasBeenSeen && alpha >= _seenAlphaThreshold)
_hasBeenSeen = true;
if (_hasBeenSeen && alpha <= 0f)
{
_permanentlyHidden = true;
_textComponent.enabled = false;
if (_ghostText != null) _ghostText.enabled = false;
return;
}
}
UpdateCameraAttraction(alpha);
ApplyAlpha(alpha);
UpdateAdaptiveScale();
if (_isShaking)
@@ -160,29 +214,24 @@ namespace Unheimlich.Features.Dialogue.UI
Vector3 toHome = _homePosition - _camera.transform.position;
float dist = toHome.magnitude;
Vector3 basePos;
if (dist < _minDistance && dist > 0.001f)
{
// Push label away so it stays at least _minDistance from the camera.
transform.position = _camera.transform.position + toHome.normalized * _minDistance;
}
basePos = _camera.transform.position + toHome.normalized * _minDistance;
else
{
transform.position = _homePosition + _shakeOffset;
}
basePos = _homePosition + _shakeOffset;
transform.position = basePos + _attractionOffset;
}
// ── Visibility (distance fade) ─────────────────────────────────────────
private void UpdateVisibility()
private void ApplyAlpha(float alpha)
{
if (_textComponent == null) return;
if (_fadeInAlpha < 1f)
_fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f, Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f));
float dist = Vector3.Distance(_camera.transform.position, _homePosition);
float alpha = ComputeAlpha(dist) * _fadeInAlpha;
SetTextAlpha(_textComponent, alpha);
if (_ghostText != null)
@@ -224,16 +273,64 @@ namespace Unheimlich.Features.Dialogue.UI
float scaleFactor = Mathf.Clamp(dist / Mathf.Max(_referenceDistance, 0.001f), _minScaleFactor, _maxScaleFactor);
transform.localScale = _baseScale * scaleFactor;
// Keep ghost in sync — it has its own BillboardUI for rotation but we drive scale here.
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;
}
// 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;
}
// Angle between the camera's forward direction and the direction to the label.
float angleDeg = Vector3.Angle(_camera.transform.forward, toLabel.normalized);
// 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
);
}
// ── Organic Shake ──────────────────────────────────────────────────────
private Vector3 RandomShakeTarget()
{
// XY only — no depth displacement.
float scaledRadius = _shakeRadius * transform.localScale.x;
Vector2 circle = Random.insideUnitCircle * scaledRadius;
return new Vector3(circle.x, circle.y, 0f);
@@ -284,7 +381,6 @@ namespace Unheimlich.Features.Dialogue.UI
_ghostAlpha
);
// Ghost renderer always sorts behind the main word's renderer.
MeshRenderer mainRenderer = _textComponent.GetComponent<MeshRenderer>();
MeshRenderer ghostRenderer = _ghostText.GetComponent<MeshRenderer>();
if (mainRenderer != null && ghostRenderer != null)
@@ -293,7 +389,6 @@ namespace Unheimlich.Features.Dialogue.UI
ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1;
}
// Ghost must also face the player.
ghostObj.AddComponent<BillboardUI>();
}
@@ -301,7 +396,6 @@ namespace Unheimlich.Features.Dialogue.UI
{
if (!_enableGhostTrail || _ghostText == null) return;
// Sample position history at a fixed rate.
_historyTimer += Time.deltaTime;
if (_historyTimer >= HISTORY_SAMPLE_RATE)
{
@@ -317,11 +411,7 @@ namespace Unheimlich.Features.Dialogue.UI
? _positionHistory.Peek()
: transform.position;
// Push ghost along camera forward (away from camera) so it always sits
// behind the main word in depth — stable regardless of viewing angle.
_ghostText.transform.position = ghostPos + _camera.transform.forward * _ghostDepthOffset;
// Keep text content and size in sync.
_ghostText.text = _textComponent.text;
_ghostText.fontSize = _textComponent.fontSize;
}
@@ -339,8 +429,18 @@ namespace Unheimlich.Features.Dialogue.UI
/// <summary>Teleports the label's home position without animation.</summary>
public void SetHomePosition(Vector3 worldPos)
{
_homePosition = worldPos;
transform.position = 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
@@ -351,4 +451,4 @@ namespace Unheimlich.Features.Dialogue.UI
}
#endif
}
}
}
@@ -23,7 +23,7 @@ namespace Unheimlich.Features.Portals
[SerializeField] private float _timeSyncThreshold = 0.1f;
[SerializeField] private float _minSpread = 60f;
[SerializeField] private float _maxSpread = 180f;
[SerializeField] private float _volumeRolloffPower = 2f;
// [SerializeField] private float _volumeRolloffPower = 2f;
[Header("Debug")]
[SerializeField] private List<AudioSource> _remoteSources = new List<AudioSource>();