chore: Project Restructuring & Theatre
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8de5fd3bd003994aaaf0ed9c2f5c44d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ee4955d2b51b5e4abe0e243cab7267e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.World.CameraSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Camera pan mode.
|
||||
/// </summary>
|
||||
public enum CameraPanMode
|
||||
{
|
||||
Pan,
|
||||
FreeLook
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Head bob mode for camera pan.
|
||||
/// </summary>
|
||||
public enum HeadBobMode
|
||||
{
|
||||
Normal,
|
||||
Idle,
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScriptableObject configuration for camera pan and player alignment behavior.
|
||||
/// Defines pan speed, borders, angles, alignment parameters, and lerp smoothness.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "CameraPanSettings", menuName = "Unheimlich/Camera/Camera Pan Settings")]
|
||||
public class CameraPanSettings : ScriptableObject
|
||||
{
|
||||
[Header("Pan Mode")]
|
||||
[Tooltip("Pan: Smooth edge-based panning for object inspection | FreeLook: Direct mouse/stick control for detective board")]
|
||||
public CameraPanMode PanMode = CameraPanMode.Pan;
|
||||
|
||||
[Header("Camera Pan")]
|
||||
[Tooltip("Camera pan speed when moving the cursor/look stick")]
|
||||
public float PanSpeed = 2f;
|
||||
|
||||
[Tooltip("Size of the border area for mouse pan (0-1, screen percentage)")]
|
||||
[Range(0f, 0.5f)]
|
||||
public float PanBorderSize = 0.1f;
|
||||
|
||||
[Tooltip("Maximum horizontal angle the camera can pan from center (left/right)")]
|
||||
public float MaxPanAngleX = 15f;
|
||||
|
||||
[Tooltip("Maximum vertical angle the camera can pan from center (up/down)")]
|
||||
public float MaxPanAngleY = 15f;
|
||||
|
||||
[Header("Recentering")]
|
||||
[Tooltip("Enable automatic return to center when idle")]
|
||||
public bool EnableRecentering = true;
|
||||
|
||||
[Tooltip("Speed of automatic recentering (higher = faster return to center)")]
|
||||
public float RecenterSpeed = 2f;
|
||||
|
||||
[Tooltip("Delay before recentering starts (in seconds)")]
|
||||
public float RecenterDelay = 0.1f;
|
||||
|
||||
[Header("Player Alignment")]
|
||||
[Tooltip("Speed of player/camera alignment to target")]
|
||||
public float AlignmentSpeed = 3f;
|
||||
|
||||
[Tooltip("Threshold for alignment completion")]
|
||||
public float AlignmentThreshold = 0.5f;
|
||||
|
||||
[Tooltip("Fixed distance from the player to the target when aligning (in meters)")]
|
||||
public float FixedDistanceFromTarget = 1.5f;
|
||||
|
||||
[Header("Head Bob Control")]
|
||||
[Tooltip("Head bob mode: Normal (default behavior), Idle (force subtle breathing), Disabled (no movement)")]
|
||||
public HeadBobMode HeadBobMode = HeadBobMode.Idle;
|
||||
|
||||
[Header("Lerp Smoothing")]
|
||||
[Tooltip("Use SmoothStep for smoother interpolation (true) or linear lerp (false)")]
|
||||
public bool UseSmoothStep = true;
|
||||
|
||||
[Tooltip("Custom animation curve for alignment (overrides SmoothStep if set)")]
|
||||
public AnimationCurve AlignmentCurve = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e231c75dddecc0469543c190a9234b1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6292657fd6825b4b958453d22c2de26
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,468 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.World.CameraSystem;
|
||||
using Unheimlich.Features.Player;
|
||||
using InputManager = Unheimlich.Core.Input.InputManager;
|
||||
|
||||
namespace Unheimlich.World.CameraSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles camera panning and player/camera alignment to targets.
|
||||
/// </summary>
|
||||
public class CameraPanController : MonoBehaviour
|
||||
{
|
||||
[Header("Default Settings")]
|
||||
[SerializeField] private CameraPanSettings _defaultSettings;
|
||||
|
||||
private CameraPanSettings _settings;
|
||||
private UnityEngine.Camera _camera;
|
||||
private Transform _playerTransform;
|
||||
|
||||
private Vector2 _originalCameraRotation;
|
||||
private Vector3 _originalCameraLocalPosition;
|
||||
private Vector3 _originalPlayerPosition;
|
||||
private Vector3 _originalPlayerRotation;
|
||||
private Vector3 _originalCameraLocalRotation;
|
||||
private Coroutine _alignmentCoroutine;
|
||||
private Coroutine _returnCoroutine;
|
||||
private float _timeSinceLastInput;
|
||||
|
||||
private float _targetYaw;
|
||||
private float _targetPitch;
|
||||
private float _currentYaw;
|
||||
private float _currentPitch;
|
||||
private float _lerpSpeed;
|
||||
private bool _needsUpdate;
|
||||
private CameraPanMode _mode = CameraPanMode.Pan;
|
||||
private Vector2 _lastMousePosition;
|
||||
|
||||
public bool IsPanning { get; private set; }
|
||||
public CameraPanMode Mode => _mode;
|
||||
|
||||
public CameraPanSettings Settings
|
||||
{
|
||||
get => _settings;
|
||||
set => _settings = value;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Defer initialization if PlayerController not ready
|
||||
if (PlayerController.Instance != null)
|
||||
{
|
||||
_camera = PlayerController.Instance.PlayerCamera;
|
||||
_playerTransform = PlayerController.Instance.transform;
|
||||
}
|
||||
|
||||
if (_defaultSettings != null)
|
||||
_settings = _defaultSettings;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Retry if not initialized in Awake
|
||||
if (_camera == null && PlayerController.Instance != null)
|
||||
{
|
||||
_camera = PlayerController.Instance.PlayerCamera;
|
||||
_playerTransform = PlayerController.Instance.transform;
|
||||
}
|
||||
|
||||
if (_camera != null)
|
||||
{
|
||||
_originalCameraLocalPosition = _camera.transform.localPosition;
|
||||
_originalCameraLocalRotation = _camera.transform.localEulerAngles;
|
||||
}
|
||||
|
||||
if (_playerTransform != null)
|
||||
_originalPlayerPosition = _playerTransform.position;
|
||||
_originalPlayerRotation = _playerTransform.eulerAngles;
|
||||
|
||||
if (_settings == null && _defaultSettings != null)
|
||||
_settings = _defaultSettings;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (IsPanning && _needsUpdate)
|
||||
{
|
||||
if (_mode == CameraPanMode.FreeLook)
|
||||
{
|
||||
float normalizedYaw = Mathf.Repeat(_currentYaw, 360f);
|
||||
float normalizedPitch = _currentPitch;
|
||||
|
||||
_playerTransform.eulerAngles = new Vector3(0f, normalizedYaw, 0f);
|
||||
_camera.transform.localEulerAngles = new Vector3(normalizedPitch, 0f, 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentYaw = Mathf.LerpAngle(_currentYaw, _targetYaw, Time.deltaTime * _lerpSpeed);
|
||||
_currentPitch = Mathf.LerpAngle(_currentPitch, _targetPitch, Time.deltaTime * _lerpSpeed);
|
||||
|
||||
float normalizedYaw = Mathf.Repeat(_currentYaw, 360f);
|
||||
float normalizedPitch = _currentPitch;
|
||||
|
||||
_playerTransform.eulerAngles = new Vector3(0f, normalizedYaw, 0f);
|
||||
_camera.transform.localEulerAngles = new Vector3(normalizedPitch, 0f, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts camera pan mode. The mode (Pan or FreeLook) is determined by the current settings.
|
||||
/// </summary>
|
||||
public void StartPan()
|
||||
{
|
||||
if (_settings == null)
|
||||
{
|
||||
Debug.LogError("[CameraPanController] Settings not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
IsPanning = true;
|
||||
_mode = _settings.PanMode;
|
||||
_timeSinceLastInput = 0f;
|
||||
|
||||
if (Mouse.current != null)
|
||||
_lastMousePosition = Mouse.current.position.ReadValue();
|
||||
|
||||
float currentPitch = _camera.transform.localEulerAngles.x;
|
||||
if (currentPitch > 180f)
|
||||
currentPitch -= 360f;
|
||||
|
||||
float currentYaw = _playerTransform.eulerAngles.y;
|
||||
if (currentYaw > 180f)
|
||||
currentYaw -= 360f;
|
||||
|
||||
_originalCameraRotation = new Vector2(currentYaw, currentPitch);
|
||||
_currentYaw = currentYaw;
|
||||
_currentPitch = currentPitch;
|
||||
_targetYaw = currentYaw;
|
||||
_targetPitch = currentPitch;
|
||||
_needsUpdate = false;
|
||||
}
|
||||
|
||||
public void StopPan()
|
||||
{
|
||||
IsPanning = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates camera pan based on input (gamepad or mouse).
|
||||
/// Call this from Update when pan is active.
|
||||
/// </summary>
|
||||
public void UpdatePan()
|
||||
{
|
||||
if (!IsPanning || _camera == null || _settings == null)
|
||||
return;
|
||||
|
||||
if (_mode == CameraPanMode.FreeLook)
|
||||
{
|
||||
UpdateFreeLook();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePanMode();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFreeLook()
|
||||
{
|
||||
float lookX = 0f;
|
||||
float lookY = 0f;
|
||||
bool hasInput = false;
|
||||
|
||||
var lookAction = InputManager.GetLookAction();
|
||||
if (lookAction != null)
|
||||
{
|
||||
Vector2 lookInput = lookAction.ReadValue<Vector2>();
|
||||
if (lookInput.sqrMagnitude > 0.01f)
|
||||
{
|
||||
lookX = lookInput.x;
|
||||
lookY = lookInput.y;
|
||||
hasInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInput)
|
||||
{
|
||||
float sensitivity = _settings.PanSpeed * 0.1f;
|
||||
_currentYaw += lookX * sensitivity;
|
||||
_currentPitch -= lookY * sensitivity;
|
||||
|
||||
_currentYaw = Mathf.Clamp(_currentYaw, _originalCameraRotation.x - _settings.MaxPanAngleX, _originalCameraRotation.x + _settings.MaxPanAngleX);
|
||||
_currentPitch = Mathf.Clamp(_currentPitch, _originalCameraRotation.y - _settings.MaxPanAngleY, _originalCameraRotation.y + _settings.MaxPanAngleY);
|
||||
|
||||
_needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePanMode()
|
||||
{
|
||||
if (!IsPanning || _camera == null || _settings == null)
|
||||
return;
|
||||
|
||||
float panX = 0f;
|
||||
float panY = 0f;
|
||||
bool hasInput = false;
|
||||
|
||||
var lookAction = InputManager.GetLookAction();
|
||||
if (lookAction != null)
|
||||
{
|
||||
Vector2 lookInput = lookAction.ReadValue<Vector2>();
|
||||
if (lookInput.sqrMagnitude > 0.01f)
|
||||
{
|
||||
panX = lookInput.x;
|
||||
panY = lookInput.y;
|
||||
hasInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInput && Mouse.current != null)
|
||||
{
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
|
||||
bool mouseHasMoved = Vector2.Distance(mousePos, _lastMousePosition) > 0.1f;
|
||||
_lastMousePosition = mousePos;
|
||||
|
||||
if (mouseHasMoved)
|
||||
{
|
||||
float screenWidth = Screen.width;
|
||||
float screenHeight = Screen.height;
|
||||
|
||||
float borderPixels = _settings.PanBorderSize * Mathf.Min(screenWidth, screenHeight);
|
||||
|
||||
if (mousePos.x < borderPixels)
|
||||
{
|
||||
panX = -(borderPixels - mousePos.x) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
else if (mousePos.x > screenWidth - borderPixels)
|
||||
{
|
||||
panX = (mousePos.x - (screenWidth - borderPixels)) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
|
||||
if (mousePos.y < borderPixels)
|
||||
{
|
||||
panY = -(borderPixels - mousePos.y) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
else if (mousePos.y > screenHeight - borderPixels)
|
||||
{
|
||||
panY = (mousePos.y - (screenHeight - borderPixels)) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInput)
|
||||
{
|
||||
_timeSinceLastInput = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_timeSinceLastInput += Time.deltaTime;
|
||||
}
|
||||
|
||||
bool shouldRecenter = _settings.EnableRecentering && _timeSinceLastInput >= _settings.RecenterDelay;
|
||||
|
||||
if (hasInput)
|
||||
{
|
||||
_targetYaw = _originalCameraRotation.x + panX * _settings.MaxPanAngleX;
|
||||
_targetPitch = _originalCameraRotation.y - panY * _settings.MaxPanAngleY;
|
||||
_targetYaw = Mathf.Clamp(_targetYaw, _originalCameraRotation.x - _settings.MaxPanAngleX, _originalCameraRotation.x + _settings.MaxPanAngleX);
|
||||
_targetPitch = Mathf.Clamp(_targetPitch, _originalCameraRotation.y - _settings.MaxPanAngleY, _originalCameraRotation.y + _settings.MaxPanAngleY);
|
||||
_lerpSpeed = _settings.PanSpeed;
|
||||
_needsUpdate = true;
|
||||
}
|
||||
else if (shouldRecenter)
|
||||
{
|
||||
_targetYaw = _originalCameraRotation.x;
|
||||
_targetPitch = _originalCameraRotation.y;
|
||||
_lerpSpeed = _settings.RecenterSpeed;
|
||||
_needsUpdate = true;
|
||||
}
|
||||
else if (_needsUpdate)
|
||||
{
|
||||
float yawDiff = Mathf.Abs(Mathf.DeltaAngle(_currentYaw, _targetYaw));
|
||||
float pitchDiff = Mathf.Abs(Mathf.DeltaAngle(_currentPitch, _targetPitch));
|
||||
|
||||
if (yawDiff < 0.01f && pitchDiff < 0.01f)
|
||||
{
|
||||
_needsUpdate = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aligns the player and camera to the target object.
|
||||
/// - Player moves to a fixed distance in front of the target
|
||||
/// - Player rotates to face the target
|
||||
/// - Camera pitch adjusts to look at target center
|
||||
/// </summary>
|
||||
public void AlignToTarget(Transform target, System.Action onComplete = null)
|
||||
{
|
||||
if (_alignmentCoroutine != null)
|
||||
StopCoroutine(_alignmentCoroutine);
|
||||
|
||||
_alignmentCoroutine = StartCoroutine(AlignCameraToTargetCoroutine(target, onComplete));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns player and camera to original position and rotation.
|
||||
/// </summary>
|
||||
public void ReturnToOriginalPosition(System.Action onComplete = null)
|
||||
{
|
||||
if (_returnCoroutine != null)
|
||||
StopCoroutine(_returnCoroutine);
|
||||
|
||||
_returnCoroutine = StartCoroutine(ReturnToOriginalPositionCoroutine(onComplete));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves current player and camera state as the original position.
|
||||
/// Call this before starting alignment.
|
||||
/// </summary>
|
||||
public void SaveOriginalState()
|
||||
{
|
||||
if (_camera != null)
|
||||
{
|
||||
_originalCameraLocalPosition = _camera.transform.localPosition;
|
||||
_originalCameraLocalRotation = _camera.transform.localEulerAngles;
|
||||
}
|
||||
|
||||
_originalPlayerPosition = _playerTransform.position;
|
||||
_originalPlayerRotation = _playerTransform.eulerAngles;
|
||||
}
|
||||
|
||||
private IEnumerator AlignCameraToTargetCoroutine(Transform target, System.Action onComplete)
|
||||
{
|
||||
if (_camera == null || target == null || _settings == null)
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
yield break;
|
||||
}
|
||||
|
||||
SaveOriginalState();
|
||||
|
||||
Vector3 targetCenter = target.position;
|
||||
Renderer targetRenderer = target.GetComponent<Renderer>();
|
||||
if (targetRenderer != null)
|
||||
{
|
||||
targetCenter = targetRenderer.bounds.center;
|
||||
}
|
||||
else
|
||||
{
|
||||
Collider targetCollider = target.GetComponent<Collider>();
|
||||
if (targetCollider != null)
|
||||
targetCenter = targetCollider.bounds.center;
|
||||
}
|
||||
|
||||
Vector3 startPlayerPos = _playerTransform.position;
|
||||
Vector3 startPlayerRotation = _playerTransform.eulerAngles;
|
||||
Vector3 startCameraLocalRotation = _camera.transform.localEulerAngles;
|
||||
|
||||
float startPitch = startCameraLocalRotation.x;
|
||||
if (startPitch > 180f)
|
||||
startPitch -= 360f;
|
||||
|
||||
Vector3 targetForward = target.forward;
|
||||
Vector3 targetPlayerPos = targetCenter - targetForward * _settings.FixedDistanceFromTarget;
|
||||
targetPlayerPos.y = startPlayerPos.y;
|
||||
|
||||
float elapsed = 0f;
|
||||
float duration = 1f / _settings.AlignmentSpeed;
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = GetInterpolationValue(elapsed / duration);
|
||||
|
||||
Vector3 currentCameraPos = _camera.transform.position;
|
||||
Vector3 toTarget = targetCenter - currentCameraPos;
|
||||
Vector3 directionToTarget = toTarget.normalized;
|
||||
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
|
||||
float targetYaw = targetRotation.eulerAngles.y;
|
||||
float targetPitch = -Mathf.Asin(directionToTarget.y) * Mathf.Rad2Deg;
|
||||
|
||||
Vector3 currentPlayerPos = Vector3.Lerp(startPlayerPos, targetPlayerPos, t);
|
||||
_playerTransform.position = currentPlayerPos;
|
||||
|
||||
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, targetYaw, t);
|
||||
_playerTransform.eulerAngles = new Vector3(0f, currentYaw, 0f);
|
||||
|
||||
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
|
||||
_camera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_alignmentCoroutine = null;
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
|
||||
private IEnumerator ReturnToOriginalPositionCoroutine(System.Action onComplete)
|
||||
{
|
||||
if (_camera == null || _settings == null)
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
yield break;
|
||||
}
|
||||
|
||||
Vector3 startPlayerPos = _playerTransform.position;
|
||||
Vector3 startPlayerRotation = _playerTransform.eulerAngles;
|
||||
Vector3 startCameraLocalPos = _camera.transform.localPosition;
|
||||
Vector3 startCameraLocalRotation = _camera.transform.localEulerAngles;
|
||||
|
||||
float startPitch = startCameraLocalRotation.x;
|
||||
if (startPitch > 180f)
|
||||
startPitch -= 360f;
|
||||
|
||||
float targetPitch = _originalCameraLocalRotation.x;
|
||||
if (targetPitch > 180f)
|
||||
targetPitch -= 360f;
|
||||
|
||||
float elapsed = 0f;
|
||||
float duration = 1f / _settings.AlignmentSpeed;
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = GetInterpolationValue(elapsed / duration);
|
||||
|
||||
_playerTransform.position = Vector3.Lerp(startPlayerPos, _originalPlayerPosition, t);
|
||||
|
||||
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, _originalPlayerRotation.y, t);
|
||||
_playerTransform.eulerAngles = new Vector3(0f, currentYaw, 0f);
|
||||
|
||||
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
|
||||
_camera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
|
||||
|
||||
_camera.transform.localPosition = Vector3.Lerp(startCameraLocalPos, _originalCameraLocalPosition, t);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_playerTransform.position = _originalPlayerPosition;
|
||||
_playerTransform.eulerAngles = _originalPlayerRotation;
|
||||
_camera.transform.localPosition = _originalCameraLocalPosition;
|
||||
_camera.transform.localEulerAngles = _originalCameraLocalRotation;
|
||||
|
||||
_returnCoroutine = null;
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
|
||||
private float GetInterpolationValue(float t)
|
||||
{
|
||||
if (_settings.AlignmentCurve != null && _settings.AlignmentCurve.length > 0)
|
||||
return _settings.AlignmentCurve.Evaluate(t);
|
||||
|
||||
if (_settings.UseSmoothStep)
|
||||
return Mathf.SmoothStep(0f, 1f, t);
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69e8150bab500424eb366f0ac8761995
|
||||
@@ -0,0 +1,430 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unheimlich.Features.Dialogue;
|
||||
using Unheimlich.Core;
|
||||
|
||||
namespace Unheimlich.World.CameraSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Virtual camera system for detective board inspection (Alan Wake 2 style).
|
||||
/// Handles smooth transition, edge-based panning, and zoom.
|
||||
/// </summary>
|
||||
public class DetectiveBoardVirtualCamera : Singleton<DetectiveBoardVirtualCamera>
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private Camera _virtualCamera;
|
||||
private Transform _boardTarget;
|
||||
|
||||
[Header("Transition Settings")]
|
||||
[SerializeField] private float _transitionSpeed = 3f;
|
||||
[SerializeField] private AnimationCurve _transitionCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
|
||||
[SerializeField] private float _initialDistance = 1.5f;
|
||||
|
||||
[Header("Pan Settings")]
|
||||
[SerializeField] private float _panSpeed = 2f;
|
||||
[SerializeField] private float _panSmoothTime = 0.2f;
|
||||
[SerializeField][Range(0f, 0.5f)] private float _edgeBorderSize = 0.1f;
|
||||
[SerializeField] private Vector2 _maxPanOffset = new Vector2(1.5f, 0.5f);
|
||||
[SerializeField] private bool _useDynamicPanLimits = true;
|
||||
[SerializeField] private float _panMargin = 0.0f;
|
||||
[SerializeField] private bool _debugLimits = false;
|
||||
|
||||
[Header("Zoom Settings")]
|
||||
[SerializeField] private float _minDistance = 0.5f;
|
||||
[SerializeField] private float _maxDistance = 3f;
|
||||
// [SerializeField] private float _zoomSpeed = 2f;
|
||||
[SerializeField] private float _zoomSmoothTime = 0.15f;
|
||||
[SerializeField] private float _scrollSensitivity = 0.3f;
|
||||
|
||||
private Camera _playerCamera;
|
||||
private Transform _playerCameraTransform;
|
||||
private Vector3 _startPosition;
|
||||
private Quaternion _startRotation;
|
||||
private Vector3 _targetCenterPosition;
|
||||
private Quaternion _targetCenterRotation;
|
||||
private bool _isActive;
|
||||
private bool _isTransitioning;
|
||||
private float _transitionProgress;
|
||||
private Renderer _cachedBoardRenderer;
|
||||
|
||||
private Vector2 _currentPanOffset;
|
||||
private Vector2 _targetPanOffset;
|
||||
private Vector2 _panVelocity;
|
||||
private Vector2 _dynamicMaxPanOffset;
|
||||
|
||||
private float _currentDistance;
|
||||
private float _targetDistance;
|
||||
private float _zoomVelocity;
|
||||
|
||||
private Vector2 _lastMousePosition;
|
||||
|
||||
public bool IsActive => _isActive;
|
||||
public Camera VirtualCamera => _virtualCamera;
|
||||
|
||||
protected override void OnAwake()
|
||||
{
|
||||
if (_virtualCamera == null)
|
||||
_virtualCamera = GetComponent<Camera>();
|
||||
|
||||
if (_virtualCamera != null)
|
||||
_virtualCamera.enabled = false;
|
||||
}
|
||||
|
||||
public void ActivateFromPlayerPosition(Camera playerCamera, Transform boardTarget, Renderer boardRenderer = null)
|
||||
{
|
||||
if (_isActive)
|
||||
return;
|
||||
|
||||
Debug.Log($"[VirtualCam] ActivateFromPlayerPosition - DebugLimits: {_debugLimits}");
|
||||
|
||||
_playerCamera = playerCamera;
|
||||
_playerCameraTransform = playerCamera.transform;
|
||||
_boardTarget = boardTarget;
|
||||
_cachedBoardRenderer = boardRenderer;
|
||||
|
||||
_startPosition = _playerCameraTransform.position;
|
||||
_startRotation = _playerCameraTransform.rotation;
|
||||
|
||||
_virtualCamera.transform.position = _startPosition;
|
||||
_virtualCamera.transform.rotation = _startRotation;
|
||||
|
||||
CalculateTargetPosition();
|
||||
|
||||
_currentDistance = _initialDistance;
|
||||
_targetDistance = _initialDistance;
|
||||
_currentPanOffset = Vector2.zero;
|
||||
_targetPanOffset = Vector2.zero;
|
||||
|
||||
_isActive = true;
|
||||
_isTransitioning = true;
|
||||
_transitionProgress = 0f;
|
||||
|
||||
_virtualCamera.enabled = true;
|
||||
_playerCamera.enabled = false;
|
||||
|
||||
Canvas boardCanvas = _boardTarget.GetComponentInChildren<Canvas>();
|
||||
if (boardCanvas != null && boardCanvas.renderMode == RenderMode.WorldSpace)
|
||||
{
|
||||
boardCanvas.worldCamera = _virtualCamera;
|
||||
}
|
||||
|
||||
if (Mouse.current != null)
|
||||
_lastMousePosition = Mouse.current.position.ReadValue();
|
||||
}
|
||||
|
||||
public void Activate(Camera playerCamera, Transform boardTarget)
|
||||
{
|
||||
if (_isActive)
|
||||
return;
|
||||
|
||||
_playerCamera = playerCamera;
|
||||
_playerCameraTransform = playerCamera.transform;
|
||||
_boardTarget = boardTarget;
|
||||
|
||||
_startPosition = _playerCameraTransform.position;
|
||||
_startRotation = _playerCameraTransform.rotation;
|
||||
|
||||
CalculateTargetPosition();
|
||||
|
||||
_currentDistance = _initialDistance;
|
||||
_targetDistance = _initialDistance;
|
||||
_currentPanOffset = Vector2.zero;
|
||||
_targetPanOffset = Vector2.zero;
|
||||
|
||||
_isActive = true;
|
||||
_isTransitioning = true;
|
||||
_transitionProgress = 0f;
|
||||
|
||||
_virtualCamera.enabled = true;
|
||||
_playerCamera.enabled = false;
|
||||
|
||||
Canvas boardCanvas = _boardTarget.GetComponentInChildren<Canvas>();
|
||||
if (boardCanvas != null && boardCanvas.renderMode == RenderMode.WorldSpace)
|
||||
{
|
||||
boardCanvas.worldCamera = _virtualCamera;
|
||||
}
|
||||
|
||||
if (Mouse.current != null)
|
||||
_lastMousePosition = Mouse.current.position.ReadValue();
|
||||
}
|
||||
|
||||
public void Deactivate(System.Action onComplete = null)
|
||||
{
|
||||
if (!_isActive)
|
||||
{
|
||||
onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(ReturnTransitionCoroutine(onComplete));
|
||||
}
|
||||
|
||||
private void CalculateTargetPosition()
|
||||
{
|
||||
Vector3 boardCenter = _boardTarget.position;
|
||||
Renderer renderer = _boardTarget.GetComponent<Renderer>();
|
||||
if (renderer != null)
|
||||
boardCenter = renderer.bounds.center;
|
||||
else
|
||||
{
|
||||
Collider collider = _boardTarget.GetComponent<Collider>();
|
||||
if (collider != null)
|
||||
boardCenter = collider.bounds.center;
|
||||
}
|
||||
|
||||
_targetCenterPosition = boardCenter - _boardTarget.forward * _initialDistance;
|
||||
_targetCenterRotation = Quaternion.LookRotation(_boardTarget.forward);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_debugLimits)
|
||||
Debug.Log($"[VirtualCam] Update - Active: {_isActive} | Transitioning: {_isTransitioning}");
|
||||
|
||||
if (!_isActive)
|
||||
return;
|
||||
|
||||
if (_isTransitioning)
|
||||
{
|
||||
UpdateTransition();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePanning();
|
||||
UpdateZoom();
|
||||
ApplyCameraTransform();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTransition()
|
||||
{
|
||||
_transitionProgress += Time.deltaTime * _transitionSpeed;
|
||||
float t = _transitionCurve.Evaluate(Mathf.Clamp01(_transitionProgress));
|
||||
|
||||
Vector3 position = Vector3.Lerp(_startPosition, _targetCenterPosition, t);
|
||||
Quaternion rotation = Quaternion.Slerp(_startRotation, _targetCenterRotation, t);
|
||||
|
||||
_virtualCamera.transform.position = position;
|
||||
_virtualCamera.transform.rotation = rotation;
|
||||
|
||||
if (_transitionProgress >= 1f)
|
||||
{
|
||||
_isTransitioning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePanning()
|
||||
{
|
||||
float panX = 0f;
|
||||
float panY = 0f;
|
||||
bool hasInput = false;
|
||||
|
||||
if (Mouse.current != null)
|
||||
{
|
||||
Vector2 mousePos = Mouse.current.position.ReadValue();
|
||||
_lastMousePosition = mousePos;
|
||||
|
||||
float screenWidth = Screen.width;
|
||||
float screenHeight = Screen.height;
|
||||
float borderPixels = _edgeBorderSize * Mathf.Min(screenWidth, screenHeight);
|
||||
|
||||
if (mousePos.x >= 0f && mousePos.x < borderPixels)
|
||||
{
|
||||
panX = -(borderPixels - mousePos.x) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
else if (mousePos.x > screenWidth - borderPixels && mousePos.x <= screenWidth)
|
||||
{
|
||||
panX = (mousePos.x - (screenWidth - borderPixels)) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
|
||||
if (mousePos.y >= 0f && mousePos.y < borderPixels)
|
||||
{
|
||||
panY = -(borderPixels - mousePos.y) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
else if (mousePos.y > screenHeight - borderPixels && mousePos.y <= screenHeight)
|
||||
{
|
||||
panY = (mousePos.y - (screenHeight - borderPixels)) / borderPixels;
|
||||
hasInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInput)
|
||||
{
|
||||
float deltaX = panX * _panSpeed * Time.deltaTime;
|
||||
float deltaY = panY * _panSpeed * Time.deltaTime;
|
||||
|
||||
_targetPanOffset.x += deltaX;
|
||||
_targetPanOffset.y += deltaY;
|
||||
}
|
||||
|
||||
Vector2 maxOffset = _useDynamicPanLimits ? _dynamicMaxPanOffset : _maxPanOffset;
|
||||
|
||||
_targetPanOffset.x = Mathf.Clamp(_targetPanOffset.x, -maxOffset.x, maxOffset.x);
|
||||
_targetPanOffset.y = Mathf.Clamp(_targetPanOffset.y, -maxOffset.y, maxOffset.y);
|
||||
|
||||
if (!hasInput && _useDynamicPanLimits)
|
||||
{
|
||||
if (Mathf.Abs(_targetPanOffset.x) > maxOffset.x)
|
||||
{
|
||||
float targetX = Mathf.Sign(_targetPanOffset.x) * maxOffset.x;
|
||||
_targetPanOffset.x = Mathf.Lerp(_targetPanOffset.x, targetX, Time.deltaTime * 3f);
|
||||
}
|
||||
|
||||
if (Mathf.Abs(_targetPanOffset.y) > maxOffset.y)
|
||||
{
|
||||
float targetY = Mathf.Sign(_targetPanOffset.y) * maxOffset.y;
|
||||
_targetPanOffset.y = Mathf.Lerp(_targetPanOffset.y, targetY, Time.deltaTime * 3f);
|
||||
}
|
||||
}
|
||||
|
||||
_currentPanOffset = Vector2.SmoothDamp(_currentPanOffset, _targetPanOffset, ref _panVelocity, _panSmoothTime);
|
||||
}
|
||||
|
||||
private void UpdateZoom()
|
||||
{
|
||||
if (Mouse.current != null)
|
||||
{
|
||||
float scroll = Mouse.current.scroll.ReadValue().y;
|
||||
if (Mathf.Abs(scroll) > 0.1f)
|
||||
{
|
||||
_targetDistance -= scroll * _scrollSensitivity * Time.deltaTime;
|
||||
_targetDistance = Mathf.Clamp(_targetDistance, _minDistance, _maxDistance);
|
||||
}
|
||||
}
|
||||
|
||||
_currentDistance = Mathf.SmoothDamp(_currentDistance, _targetDistance, ref _zoomVelocity, _zoomSmoothTime);
|
||||
}
|
||||
|
||||
private void ApplyCameraTransform()
|
||||
{
|
||||
if (_debugLimits)
|
||||
Debug.Log($"[VirtualCam] ApplyCameraTransform called - BoardTarget: {(_boardTarget != null ? _boardTarget.name : "NULL")}");
|
||||
|
||||
Vector3 boardCenter = _boardTarget.position;
|
||||
Renderer renderer = _cachedBoardRenderer;
|
||||
if (renderer == null)
|
||||
renderer = _boardTarget.GetComponent<Renderer>();
|
||||
|
||||
if (renderer != null)
|
||||
boardCenter = renderer.bounds.center;
|
||||
else
|
||||
{
|
||||
Collider collider = _boardTarget.GetComponent<Collider>();
|
||||
if (collider != null)
|
||||
boardCenter = collider.bounds.center;
|
||||
}
|
||||
|
||||
if (_debugLimits)
|
||||
Debug.Log($"[VirtualCam] Renderer: {(renderer != null ? "Found" : "NULL")} | UseDynamicLimits: {_useDynamicPanLimits}");
|
||||
|
||||
Vector3 right = _boardTarget.right;
|
||||
Vector3 up = _boardTarget.up;
|
||||
Vector3 forward = _boardTarget.forward;
|
||||
|
||||
if (_useDynamicPanLimits)
|
||||
{
|
||||
CalculateDynamicPanLimits(renderer);
|
||||
}
|
||||
|
||||
Vector3 panOffset = right * _currentPanOffset.x + up * _currentPanOffset.y;
|
||||
Vector3 targetPos = boardCenter + panOffset - forward * _currentDistance;
|
||||
|
||||
_virtualCamera.transform.position = targetPos;
|
||||
_virtualCamera.transform.rotation = Quaternion.LookRotation(forward);
|
||||
}
|
||||
|
||||
private void CalculateDynamicPanLimits(Renderer boardRenderer)
|
||||
{
|
||||
if (_virtualCamera == null)
|
||||
{
|
||||
if (_debugLimits)
|
||||
Debug.LogWarning("[VirtualCam] No VirtualCamera! Using fixed maxPanOffset.");
|
||||
_dynamicMaxPanOffset = _maxPanOffset;
|
||||
return;
|
||||
}
|
||||
|
||||
float distance = _currentDistance;
|
||||
float fov = _virtualCamera.fieldOfView;
|
||||
float aspect = _virtualCamera.aspect;
|
||||
|
||||
float halfHeightAtDistance = distance * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
|
||||
float halfWidthAtDistance = halfHeightAtDistance * aspect;
|
||||
|
||||
float boardHalfWidth;
|
||||
float boardHalfHeight;
|
||||
|
||||
if (boardRenderer != null)
|
||||
{
|
||||
Bounds bounds = boardRenderer.bounds;
|
||||
Vector3 localSize = _boardTarget.InverseTransformVector(bounds.size);
|
||||
|
||||
boardHalfWidth = Mathf.Abs(localSize.x) * 0.5f;
|
||||
boardHalfHeight = Mathf.Abs(localSize.y) * 0.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
RectTransform rectTransform = _boardTarget.GetComponent<RectTransform>();
|
||||
if (rectTransform != null)
|
||||
{
|
||||
Vector2 size = rectTransform.rect.size;
|
||||
Vector3 scale = rectTransform.lossyScale;
|
||||
|
||||
boardHalfWidth = (size.x * scale.x) * 0.5f;
|
||||
boardHalfHeight = (size.y * scale.y) * 0.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_debugLimits)
|
||||
Debug.LogWarning("[VirtualCam] No Renderer or RectTransform found! Using fixed maxPanOffset.");
|
||||
_dynamicMaxPanOffset = _maxPanOffset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_dynamicMaxPanOffset.x = Mathf.Max(0f, boardHalfWidth - halfWidthAtDistance - _panMargin);
|
||||
_dynamicMaxPanOffset.y = Mathf.Max(0f, boardHalfHeight - halfHeightAtDistance - _panMargin);
|
||||
|
||||
if (_debugLimits)
|
||||
{
|
||||
Debug.Log($"[VirtualCam] Distance: {distance:F2} | FOV: {fov:F1} | Aspect: {aspect:F2}");
|
||||
Debug.Log($"[VirtualCam] Viewport: {halfWidthAtDistance*2:F2}x{halfHeightAtDistance*2:F2} | Board: {boardHalfWidth*2:F2}x{boardHalfHeight*2:F2}");
|
||||
Debug.Log($"[VirtualCam] MaxPan: X={_dynamicMaxPanOffset.x:F2} Y={_dynamicMaxPanOffset.y:F2} | Current: X={_targetPanOffset.x:F2} Y={_targetPanOffset.y:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator ReturnTransitionCoroutine(System.Action onComplete)
|
||||
{
|
||||
Vector3 currentPos = _virtualCamera.transform.position;
|
||||
Quaternion currentRot = _virtualCamera.transform.rotation;
|
||||
|
||||
float elapsed = 0f;
|
||||
float duration = 1f / _transitionSpeed;
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = _transitionCurve.Evaluate(Mathf.Clamp01(elapsed / duration));
|
||||
|
||||
_virtualCamera.transform.position = Vector3.Lerp(currentPos, _startPosition, t);
|
||||
_virtualCamera.transform.rotation = Quaternion.Slerp(currentRot, _startRotation, t);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_virtualCamera.enabled = false;
|
||||
_playerCamera.enabled = true;
|
||||
_isActive = false;
|
||||
|
||||
Canvas boardCanvas = _boardTarget.GetComponentInChildren<Canvas>();
|
||||
if (boardCanvas != null && boardCanvas.renderMode == RenderMode.WorldSpace)
|
||||
{
|
||||
boardCanvas.worldCamera = _playerCamera;
|
||||
}
|
||||
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df440cd4d3cc30d4e9f5b44ab4931a12
|
||||
@@ -0,0 +1,106 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Portals;
|
||||
|
||||
namespace Unheimlich.World.CameraSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Dynamically adjusts Unity's shadow rendering distance based on proximity to active portals.
|
||||
/// When the player is near a portal with a loaded room, shadow distance is extended to ensure
|
||||
/// shadows from the remote room are visible through the portal. This improves visual quality
|
||||
/// while maintaining performance when far from portals.
|
||||
/// </summary>
|
||||
public class DynamicShadowDistance : MonoBehaviour
|
||||
{
|
||||
private const float PORTAL_CHECK_DISTANCE = 100f;
|
||||
|
||||
[Header("Shadow Distance Settings")]
|
||||
[SerializeField] private float _baseShadowDistance = 50f;
|
||||
[SerializeField] private float _extendedShadowDistance = 250f;
|
||||
[SerializeField] private float _transitionSpeed = 2f;
|
||||
|
||||
[Header("Detection")]
|
||||
[SerializeField] private float _checkInterval = 0.5f;
|
||||
|
||||
private float _targetShadowDistance;
|
||||
private float _currentShadowDistance;
|
||||
private float _nextCheckTime;
|
||||
private Transform _playerTransform;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_playerTransform = Camera.main != null ? Camera.main.transform : null;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_currentShadowDistance = _baseShadowDistance;
|
||||
_targetShadowDistance = _baseShadowDistance;
|
||||
QualitySettings.shadowDistance = _baseShadowDistance;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.time >= _nextCheckTime)
|
||||
{
|
||||
_nextCheckTime = Time.time + _checkInterval;
|
||||
UpdateTargetShadowDistance();
|
||||
}
|
||||
|
||||
if (!Mathf.Approximately(_currentShadowDistance, _targetShadowDistance))
|
||||
{
|
||||
_currentShadowDistance = Mathf.Lerp(_currentShadowDistance, _targetShadowDistance, Time.deltaTime * _transitionSpeed);
|
||||
QualitySettings.shadowDistance = _currentShadowDistance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any active portal with a loaded room is within range.
|
||||
/// If found, extends shadow distance to cover the remote room.
|
||||
/// </summary>
|
||||
private void UpdateTargetShadowDistance()
|
||||
{
|
||||
bool needsExtendedDistance = false;
|
||||
|
||||
foreach (var portal in Portal.AllPortals)
|
||||
{
|
||||
if (portal == null || !portal.isActiveAndEnabled)
|
||||
continue;
|
||||
|
||||
if (portal.LoadedRoomInstance != null)
|
||||
{
|
||||
float distanceToPortal = Vector3.Distance(_playerTransform.position, portal.transform.position);
|
||||
|
||||
if (distanceToPortal < PORTAL_CHECK_DISTANCE)
|
||||
{
|
||||
needsExtendedDistance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_targetShadowDistance = needsExtendedDistance ? _extendedShadowDistance : _baseShadowDistance;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (_baseShadowDistance < 0)
|
||||
_baseShadowDistance = 0;
|
||||
if (_extendedShadowDistance < _baseShadowDistance)
|
||||
_extendedShadowDistance = _baseShadowDistance;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_playerTransform == null)
|
||||
return;
|
||||
|
||||
Gizmos.color = new Color(1f, 1f, 0f, 0.2f);
|
||||
Gizmos.DrawWireSphere(_playerTransform.position, QualitySettings.shadowDistance);
|
||||
|
||||
UnityEditor.Handles.Label(_playerTransform.position + Vector3.up * 2,
|
||||
$"Shadow Distance: {QualitySettings.shadowDistance:F0}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8f216e6440b9d445ac547aa24daf2b7
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b29704286be24904999abcd0ff0cc448
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Core.Interaction;
|
||||
using Unheimlich.Core.Utils;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
public class DoorInteractable : InteractableBase
|
||||
{
|
||||
[Header("Door Settings")]
|
||||
public bool IsOpen = false;
|
||||
[SerializeField] private float _openAngle = 90f;
|
||||
[SerializeField] private float _animationDuration = 0.5f;
|
||||
[SerializeField] private LeanTweenType _easeType = LeanTweenType.easeInOutQuad;
|
||||
|
||||
private Quaternion _closedRotation;
|
||||
private Quaternion _openRotation;
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
_closedRotation = transform.rotation;
|
||||
_openRotation = _closedRotation * Quaternion.Euler(0, _openAngle, 0);
|
||||
|
||||
InteractionText = IsOpen ? "Close" : "Open";
|
||||
}
|
||||
|
||||
public override void Interact()
|
||||
{
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public void ToggleDoor()
|
||||
{
|
||||
gameObject.CancelAllTweens();
|
||||
|
||||
if (!IsOpen)
|
||||
{
|
||||
TweenHelper.RotateLocal(gameObject, _openRotation.eulerAngles, _animationDuration, null, _easeType);
|
||||
IsOpen = true;
|
||||
InteractionText = "Close";
|
||||
}
|
||||
else
|
||||
{
|
||||
TweenHelper.RotateLocal(gameObject, _closedRotation.eulerAngles, _animationDuration, null, _easeType);
|
||||
IsOpen = false;
|
||||
InteractionText = "Open";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d620ab476d55f2542a31783a6684f189
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 581e1488df9d96149925217dbedad049
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9e77f5824725bb49a41c5775a81bf2b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Hallucination;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for a single stair slot.
|
||||
/// Slot indices are relative: 0 = ground level, positive = up, negative = down.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class StaircaseSlotData
|
||||
{
|
||||
[Tooltip("Slot index this data applies to. 0 = ground, positive = up, negative = down.")]
|
||||
public int SlotIndex;
|
||||
|
||||
[Tooltip("Word displayed on the FloatingLabel of this segment.")]
|
||||
public string Word;
|
||||
|
||||
[Tooltip("Optional hallucination event fired when the player enters this segment's zone.")]
|
||||
public HallucinationEvent HallucinationEvent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScriptableObject holding the full word/event sequence for one staircase.
|
||||
/// Each slot entry binds a display word and an optional hallucination event to a given slot index.
|
||||
/// Slots not listed here receive no word and fire no event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Create via Assets › Create › Unheimlich › Hallucination › Staircase Sequence Data.
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(
|
||||
fileName = "StaircaseSequenceData",
|
||||
menuName = "Unheimlich/Hallucination/Staircase Sequence Data")]
|
||||
public class StaircaseSequenceData : ScriptableObject
|
||||
{
|
||||
[Tooltip("Per-slot configuration. Slots not listed here get no word and no event.")]
|
||||
public List<StaircaseSlotData> Slots = new();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f9be4fbaead86548b21371bf4598477
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18369b1856c5f624cb3fd42a13f0292c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Player;
|
||||
using Unheimlich.Features.Hallucination;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
// ─── Controller ───────────────────────────────────────────────────────────────
|
||||
|
||||
public class InfiniteStaircaseController : MonoBehaviour
|
||||
{
|
||||
// ─── Prefabs ─────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Stair Prefabs")]
|
||||
[Tooltip("Standard segment used to fill the tower.")]
|
||||
[SerializeField] private GameObject _stairPrefab;
|
||||
|
||||
[Tooltip("Segment always placed at the very TOP of the tower (slot +maxIterationsUp).")]
|
||||
[SerializeField] private GameObject _stairTopPrefab;
|
||||
|
||||
[Tooltip("Segment always placed at the very BOTTOM of the tower (slot -maxIterationsDown).")]
|
||||
[SerializeField] private GameObject _stairBottomPrefab;
|
||||
|
||||
[Tooltip("If true, the first child with renderers is reused as slot 0 instead of being instantiated from the prefab.")]
|
||||
[SerializeField] private bool _useExistingChildAsBase = true;
|
||||
|
||||
// ─── Iterations ──────────────────────────────────────────────────────
|
||||
|
||||
[Header("Iterations")]
|
||||
[Tooltip("Number of segments above ground level (upward traversal).")]
|
||||
[SerializeField] private int _maxIterationsUp = 4;
|
||||
|
||||
[Tooltip("Number of segments below ground level (downward traversal).")]
|
||||
[SerializeField] private int _maxIterationsDown = 4;
|
||||
|
||||
// ─── Segment Bounds ──────────────────────────────────────────────────
|
||||
|
||||
[Header("Segment Bounds — Auto-Detection")]
|
||||
[Tooltip("Renderer of the central column mesh. When assigned, segment height is read from its world bounds.")]
|
||||
[SerializeField] private Renderer _centralColumnRenderer;
|
||||
|
||||
[Tooltip("Fallback segment height (m) used when no central column renderer is assigned.")]
|
||||
[SerializeField] private float _segmentHeight = 4.82f;
|
||||
|
||||
[Tooltip("Y offset added to the segment height for fine-tuning snapping.")]
|
||||
[SerializeField] private float _segmentHeightOffset = 0f;
|
||||
|
||||
// ─── Scroll Control ───────────────────────────────────────────────────
|
||||
|
||||
[Header("Scroll Speed Control")]
|
||||
[Tooltip("Proportional gain: scroll speed (m/s) per metre of player deviation from the trigger center.")]
|
||||
[SerializeField] private float _correctionGain = 4f;
|
||||
|
||||
[Tooltip("Maximum tower scroll speed in m/s.")]
|
||||
[SerializeField] private float _maxScrollSpeed = 8f;
|
||||
|
||||
[Tooltip("SmoothDamp time for scroll speed transitions — prevents sudden jolts.")]
|
||||
[SerializeField] private float _scrollSmoothTime = 0.1f;
|
||||
|
||||
// ─── Rotation Offset ─────────────────────────────────────────────────
|
||||
|
||||
[Header("Rotation Offset")]
|
||||
[Tooltip("Y rotation applied to every spawned segment around its geometric bounds center.")]
|
||||
[SerializeField] private float _segmentRotationOffsetY = 0f;
|
||||
|
||||
// ─── Hallucination / Word Data ────────────────────────────────────────
|
||||
|
||||
[Header("Hallucination — Word & Event Data")]
|
||||
[Tooltip("ScriptableObject holding word + event data per slot. " +
|
||||
"Recommended: create a StaircaseSequenceData asset and assign it here.")]
|
||||
[SerializeField] private StaircaseSequenceData _sequenceData;
|
||||
|
||||
[Space]
|
||||
[Tooltip("When true, the word for each slot is picked randomly from _randomWordPool instead of from _sequenceData.")]
|
||||
[SerializeField] private bool _useRandomWords = false;
|
||||
|
||||
[Tooltip("Pool of words to pick from when _useRandomWords is enabled.")]
|
||||
[SerializeField] private List<string> _randomWordPool = new();
|
||||
|
||||
[Tooltip("HallucinationEvent applied to all slots when _useRandomWords is enabled " +
|
||||
"(slot-specific events from _sequenceData are ignored in random mode).")]
|
||||
[SerializeField] private HallucinationEvent _randomModeEvent;
|
||||
|
||||
// ─── Debug ───────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _drawGizmos = true;
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
private enum StairState { Idle, Active, Stopping, Done }
|
||||
private StairState _currentState = StairState.Idle;
|
||||
|
||||
private Transform _playerTransform;
|
||||
|
||||
/// <summary>Single parent moved to scroll the entire tower at once.</summary>
|
||||
private Transform _towerRoot;
|
||||
|
||||
/// <summary>World Y of the trigger collider center, cached on Awake.</summary>
|
||||
private float _triggerCenterWorldY;
|
||||
|
||||
/// <summary>True when the player entered the trigger from below (travelling upward).</summary>
|
||||
private bool _enteredFromBelow;
|
||||
|
||||
/// <summary>
|
||||
/// towerRoot.localPosition.y threshold signalling stock exhaustion.
|
||||
/// Negative when going up, positive when going down.
|
||||
/// </summary>
|
||||
private float _limitLocalY;
|
||||
|
||||
/// <summary>Current scroll speed (signed). Positive means the tower moves downward.</summary>
|
||||
private float _scrollSpeed;
|
||||
private float _scrollSpeedVelocity;
|
||||
|
||||
private readonly Dictionary<int, GameObject> _segmentsBySlot = new();
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
private float EffectiveSegmentHeight => _segmentHeight + _segmentHeightOffset;
|
||||
private float MaxTowerDisplacementUp => _maxIterationsUp * EffectiveSegmentHeight;
|
||||
private float MaxTowerDisplacementDown => _maxIterationsDown * EffectiveSegmentHeight;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
AutoDetectBoundsFromColumn();
|
||||
CacheTriggerCenterY();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
EnsurePlayerCached();
|
||||
|
||||
switch (_currentState)
|
||||
{
|
||||
case StairState.Active:
|
||||
UpdateScrollSpeed();
|
||||
ApplyScrollToTower();
|
||||
CheckLimitReached();
|
||||
break;
|
||||
|
||||
case StairState.Stopping:
|
||||
DecelerateAndFinish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trigger Events ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called by StaircaseTriggerZone when the player enters the collider.
|
||||
/// </summary>
|
||||
public void OnPlayerEnteredTrigger()
|
||||
{
|
||||
if (_currentState != StairState.Idle && _currentState != StairState.Done) return;
|
||||
|
||||
EnsurePlayerCached();
|
||||
if (_playerTransform == null) return;
|
||||
|
||||
_enteredFromBelow = _playerTransform.position.y < _triggerCenterWorldY;
|
||||
_scrollSpeed = 0f;
|
||||
_scrollSpeedVelocity = 0f;
|
||||
|
||||
BuildTower();
|
||||
_currentState = StairState.Active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by StaircaseTriggerZone when the player exits the collider.
|
||||
/// </summary>
|
||||
public void OnPlayerExitedTrigger()
|
||||
{
|
||||
if (_currentState != StairState.Active && _currentState != StairState.Stopping) return;
|
||||
|
||||
bool towerHardlyMoved = _towerRoot != null &&
|
||||
Mathf.Abs(_towerRoot.localPosition.y) < EffectiveSegmentHeight * 0.5f;
|
||||
|
||||
if (towerHardlyMoved)
|
||||
{
|
||||
_segmentsBySlot.TryGetValue(0, out GameObject baseSegment);
|
||||
|
||||
if (baseSegment != null)
|
||||
{
|
||||
baseSegment.transform.SetParent(transform, true);
|
||||
baseSegment.transform.position = transform.position;
|
||||
}
|
||||
|
||||
DestroyTower();
|
||||
|
||||
if (baseSegment != null)
|
||||
_segmentsBySlot[0] = baseSegment;
|
||||
|
||||
_scrollSpeed = 0f;
|
||||
_scrollSpeedVelocity = 0f;
|
||||
_playerTransform = null;
|
||||
_currentState = StairState.Idle;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentState = StairState.Stopping;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tower Construction ───────────────────────────────────────────────
|
||||
|
||||
private void BuildTower()
|
||||
{
|
||||
DestroyTower();
|
||||
|
||||
_towerRoot = new GameObject("_TowerRoot").transform;
|
||||
_towerRoot.SetParent(transform, false);
|
||||
_towerRoot.localPosition = Vector3.zero;
|
||||
_towerRoot.localRotation = Quaternion.identity;
|
||||
_towerRoot.localScale = Vector3.one;
|
||||
|
||||
if (_enteredFromBelow)
|
||||
{
|
||||
for (int i = 0; i <= _maxIterationsUp; i++)
|
||||
SpawnSegmentAtSlot(i);
|
||||
|
||||
_limitLocalY = -MaxTowerDisplacementUp;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i >= -_maxIterationsDown; i--)
|
||||
SpawnSegmentAtSlot(i);
|
||||
|
||||
_limitLocalY = MaxTowerDisplacementDown;
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyTower()
|
||||
{
|
||||
if (_towerRoot != null)
|
||||
{
|
||||
Destroy(_towerRoot.gameObject);
|
||||
_towerRoot = null;
|
||||
}
|
||||
_segmentsBySlot.Clear();
|
||||
}
|
||||
|
||||
private void SpawnSegmentAtSlot(int slotIndex)
|
||||
{
|
||||
if (_segmentsBySlot.ContainsKey(slotIndex)) return;
|
||||
|
||||
float localY = slotIndex * EffectiveSegmentHeight;
|
||||
GameObject segment;
|
||||
|
||||
// Slot 0 — attempt to reuse the existing scene child.
|
||||
if (slotIndex == 0 && _useExistingChildAsBase)
|
||||
{
|
||||
Transform existing = FindExistingStairChild();
|
||||
if (existing != null)
|
||||
{
|
||||
segment = existing.gameObject;
|
||||
segment.transform.SetParent(_towerRoot, false);
|
||||
segment.transform.localPosition = new Vector3(0f, localY, 0f);
|
||||
segment.transform.localRotation = Quaternion.identity;
|
||||
ApplyRotationOffsetFromCenter(segment);
|
||||
ConfigureSegment(segment, slotIndex);
|
||||
_segmentsBySlot[slotIndex] = segment;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
GameObject prefabToUse = PickPrefabForSlot(slotIndex);
|
||||
if (prefabToUse == null) return;
|
||||
|
||||
segment = Instantiate(prefabToUse, _towerRoot);
|
||||
segment.transform.localPosition = new Vector3(0f, localY, 0f);
|
||||
segment.transform.localRotation = Quaternion.identity;
|
||||
ApplyRotationOffsetFromCenter(segment);
|
||||
ConfigureSegment(segment, slotIndex);
|
||||
_segmentsBySlot[slotIndex] = segment;
|
||||
}
|
||||
|
||||
private GameObject PickPrefabForSlot(int slotIndex)
|
||||
{
|
||||
if (slotIndex == _maxIterationsUp && _stairTopPrefab != null) return _stairTopPrefab;
|
||||
if (slotIndex == -_maxIterationsDown && _stairBottomPrefab != null) return _stairBottomPrefab;
|
||||
return _stairPrefab;
|
||||
}
|
||||
|
||||
// ─── Segment Configuration ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the word and event for this slot then calls StaircaseSegment.Configure().
|
||||
/// If the segment has no StaircaseSegment component, this is a silent no-op.
|
||||
/// </summary>
|
||||
private void ConfigureSegment(GameObject segmentGO, int slotIndex)
|
||||
{
|
||||
StaircaseSegment segment = segmentGO.GetComponent<StaircaseSegment>();
|
||||
if (segment == null) return;
|
||||
|
||||
string word = ResolveWord(slotIndex);
|
||||
HallucinationEvent evt = ResolveEvent(slotIndex);
|
||||
segment.Configure(word, evt);
|
||||
}
|
||||
|
||||
private string ResolveWord(int slotIndex)
|
||||
{
|
||||
if (_useRandomWords)
|
||||
{
|
||||
if (_randomWordPool == null || _randomWordPool.Count == 0)
|
||||
return null;
|
||||
return _randomWordPool[Random.Range(0, _randomWordPool.Count)];
|
||||
}
|
||||
|
||||
return FindSlotData(slotIndex)?.Word;
|
||||
}
|
||||
|
||||
private HallucinationEvent ResolveEvent(int slotIndex)
|
||||
{
|
||||
if (_useRandomWords)
|
||||
return _randomModeEvent;
|
||||
|
||||
return FindSlotData(slotIndex)?.HallucinationEvent;
|
||||
}
|
||||
|
||||
/// <summary>Returns the StaircaseSlotData matching slotIndex, or null if not found.</summary>
|
||||
private StaircaseSlotData FindSlotData(int slotIndex)
|
||||
{
|
||||
if (_sequenceData == null) return null;
|
||||
|
||||
foreach (StaircaseSlotData data in _sequenceData.Slots)
|
||||
{
|
||||
if (data.SlotIndex == slotIndex)
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Scroll ───────────────────────────────────────────────────────────
|
||||
|
||||
private void UpdateScrollSpeed()
|
||||
{
|
||||
if (_playerTransform == null) return;
|
||||
|
||||
float error = _playerTransform.position.y - _triggerCenterWorldY;
|
||||
float rawTarget = Mathf.Clamp(error * _correctionGain, -_maxScrollSpeed, _maxScrollSpeed);
|
||||
|
||||
bool towerHasMoved = _enteredFromBelow
|
||||
? _towerRoot != null && _towerRoot.localPosition.y < -0.05f
|
||||
: _towerRoot != null && _towerRoot.localPosition.y > 0.05f;
|
||||
|
||||
float targetSpeed = towerHasMoved
|
||||
? rawTarget
|
||||
: (_enteredFromBelow ? Mathf.Max(rawTarget, 0f) : Mathf.Min(rawTarget, 0f));
|
||||
|
||||
_scrollSpeed = Mathf.SmoothDamp(_scrollSpeed, targetSpeed, ref _scrollSpeedVelocity, _scrollSmoothTime);
|
||||
}
|
||||
|
||||
private void ApplyScrollToTower()
|
||||
{
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
Vector3 pos = _towerRoot.localPosition;
|
||||
pos.y -= _scrollSpeed * Time.deltaTime;
|
||||
|
||||
pos.y = _enteredFromBelow
|
||||
? Mathf.Max(pos.y, _limitLocalY)
|
||||
: Mathf.Min(pos.y, _limitLocalY);
|
||||
|
||||
_towerRoot.localPosition = pos;
|
||||
}
|
||||
|
||||
private void CheckLimitReached()
|
||||
{
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
bool reached = _enteredFromBelow
|
||||
? _towerRoot.localPosition.y <= _limitLocalY
|
||||
: _towerRoot.localPosition.y >= _limitLocalY;
|
||||
|
||||
if (reached)
|
||||
_currentState = StairState.Stopping;
|
||||
}
|
||||
|
||||
private void DecelerateAndFinish()
|
||||
{
|
||||
_scrollSpeed = Mathf.SmoothDamp(_scrollSpeed, 0f, ref _scrollSpeedVelocity, _scrollSmoothTime);
|
||||
ApplyScrollToTower();
|
||||
|
||||
if (Mathf.Abs(_scrollSpeed) < 0.02f)
|
||||
FinishAndCleanup();
|
||||
}
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────
|
||||
|
||||
private void FinishAndCleanup()
|
||||
{
|
||||
_currentState = StairState.Done;
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
int keepSlot = _enteredFromBelow ? _maxIterationsUp : -_maxIterationsDown;
|
||||
_segmentsBySlot.TryGetValue(keepSlot, out GameObject keepSegment);
|
||||
|
||||
if (keepSegment != null)
|
||||
{
|
||||
keepSegment.transform.SetParent(transform, true);
|
||||
keepSegment.transform.position = transform.position;
|
||||
}
|
||||
|
||||
DestroyTower();
|
||||
|
||||
if (keepSegment != null)
|
||||
_segmentsBySlot[0] = keepSegment;
|
||||
|
||||
_scrollSpeed = 0f;
|
||||
_scrollSpeedVelocity = 0f;
|
||||
_playerTransform = null;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private void AutoDetectBoundsFromColumn()
|
||||
{
|
||||
if (_centralColumnRenderer == null) return;
|
||||
_segmentHeight = _centralColumnRenderer.bounds.size.y;
|
||||
}
|
||||
|
||||
private void CacheTriggerCenterY()
|
||||
{
|
||||
StaircaseTriggerZone zone = GetComponentInChildren<StaircaseTriggerZone>();
|
||||
if (zone == null)
|
||||
{
|
||||
Debug.LogWarning("[InfiniteStaircase] No StaircaseTriggerZone found in children.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
Collider col = zone.GetComponent<Collider>();
|
||||
if (col != null)
|
||||
_triggerCenterWorldY = col.bounds.center.y;
|
||||
}
|
||||
|
||||
private void EnsurePlayerCached()
|
||||
{
|
||||
if (_playerTransform != null) return;
|
||||
if (PlayerController.Instance != null)
|
||||
_playerTransform = PlayerController.Instance.transform;
|
||||
}
|
||||
|
||||
private Transform FindExistingStairChild()
|
||||
{
|
||||
foreach (Transform child in transform)
|
||||
{
|
||||
if (child == _towerRoot) continue;
|
||||
if (child.GetComponentInChildren<MeshRenderer>() != null)
|
||||
return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyRotationOffsetFromCenter(GameObject segment)
|
||||
{
|
||||
if (Mathf.Approximately(_segmentRotationOffsetY, 0f)) return;
|
||||
segment.transform.RotateAround(ComputeWorldBoundsCenter(segment), Vector3.up, _segmentRotationOffsetY);
|
||||
}
|
||||
|
||||
private Vector3 ComputeWorldBoundsCenter(GameObject segment)
|
||||
{
|
||||
Renderer[] renderers = segment.GetComponentsInChildren<Renderer>();
|
||||
if (renderers.Length == 0)
|
||||
return segment.transform.position + Vector3.up * (EffectiveSegmentHeight * 0.5f);
|
||||
|
||||
Bounds combined = renderers[0].bounds;
|
||||
for (int i = 1; i < renderers.Length; i++)
|
||||
combined.Encapsulate(renderers[i].bounds);
|
||||
|
||||
return combined.center;
|
||||
}
|
||||
|
||||
// ─── Gizmos ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!_drawGizmos) return;
|
||||
|
||||
float h = EffectiveSegmentHeight;
|
||||
Vector3 o = transform.position;
|
||||
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.35f);
|
||||
for (int i = 1; i <= _maxIterationsUp; i++)
|
||||
Gizmos.DrawWireCube(new Vector3(o.x, o.y + i * h + h * 0.5f, o.z), new Vector3(1.8f, h * 0.95f, 1.8f));
|
||||
|
||||
Gizmos.color = new Color(1f, 0.6f, 0f, 0.25f);
|
||||
for (int i = 1; i <= _maxIterationsDown; i++)
|
||||
Gizmos.DrawWireCube(new Vector3(o.x, o.y - i * h + h * 0.5f, o.z), new Vector3(1.8f, h * 0.95f, 1.8f));
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawLine(
|
||||
new Vector3(o.x - 2f, _triggerCenterWorldY, o.z),
|
||||
new Vector3(o.x + 2f, _triggerCenterWorldY, o.z)
|
||||
);
|
||||
|
||||
if (_stairTopPrefab != null)
|
||||
{
|
||||
Gizmos.color = Color.magenta;
|
||||
Gizmos.DrawWireCube(
|
||||
new Vector3(o.x, o.y + _maxIterationsUp * h + h * 0.5f, o.z),
|
||||
new Vector3(2f, h, 2f)
|
||||
);
|
||||
}
|
||||
|
||||
if (_stairBottomPrefab != null)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireCube(
|
||||
new Vector3(o.x, o.y - _maxIterationsDown * h + h * 0.5f, o.z),
|
||||
new Vector3(2f, h, 2f)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3462960ec1dfaee448d860de31230d02
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Hallucination;
|
||||
using Unheimlich.Features.Dialogue.UI;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Sits on each stair-segment prefab and acts as the single configuration point
|
||||
/// for the InfiniteStaircaseController.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - Holds a reference to the segment's FloatingLabel (set in prefab).
|
||||
/// - Holds a reference to the segment's HallucinationTriggerZone (set in prefab).
|
||||
/// - Exposes Configure() so the controller can push the right word and event
|
||||
/// at spawn time without any GetComponent chains on the controller side.
|
||||
/// </summary>
|
||||
public class StaircaseSegment : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("FloatingLabel present on this prefab. Assign in the prefab inspector.")]
|
||||
[SerializeField] private FloatingLabel _floatingLabel;
|
||||
|
||||
[Tooltip("HallucinationTriggerZone present on this prefab. Assign in the prefab inspector.")]
|
||||
[SerializeField] private HallucinationTriggerZone _triggerZone;
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called by InfiniteStaircaseController right after this segment is spawned.
|
||||
/// Sets the word on the FloatingLabel and overrides the HallucinationEvent on the zone.
|
||||
/// </summary>
|
||||
/// <param name="word">Text to display. Null or empty = label keeps its prefab default.</param>
|
||||
/// <param name="hallucinationEvent">Event to fire when the player enters the zone. Null = no event.</param>
|
||||
public void Configure(string word, HallucinationEvent hallucinationEvent)
|
||||
{
|
||||
if (_floatingLabel != null && !string.IsNullOrEmpty(word))
|
||||
_floatingLabel.SetText(word);
|
||||
|
||||
if (_triggerZone != null)
|
||||
_triggerZone.SetEvent(hallucinationEvent);
|
||||
}
|
||||
|
||||
// ─── Editor helpers ────────────────────────────────────────────────────
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void Reset()
|
||||
{
|
||||
_floatingLabel = GetComponentInChildren<FloatingLabel>();
|
||||
_triggerZone = GetComponentInChildren<HallucinationTriggerZone>();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba6fa2b368d1e4f4bb84bc6af9f777bc
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Placed on the trigger child of an <see cref="InfiniteStaircaseController"/>.
|
||||
/// Forwards Player trigger enter / exit events to the parent controller.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class StaircaseTriggerZone : MonoBehaviour
|
||||
{
|
||||
private const string PlayerTag = "Player";
|
||||
|
||||
private InfiniteStaircaseController _controller;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_controller = GetComponentInParent<InfiniteStaircaseController>();
|
||||
|
||||
if (_controller == null)
|
||||
Debug.LogWarning($"[StaircaseTriggerZone] No InfiniteStaircaseController found in parent of '{name}'.", this);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (_controller == null) return;
|
||||
if (!other.CompareTag(PlayerTag)) return;
|
||||
|
||||
_controller.OnPlayerEnteredTrigger();
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (_controller == null) return;
|
||||
if (!other.CompareTag(PlayerTag)) return;
|
||||
|
||||
_controller.OnPlayerExitedTrigger();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89e1fa0fefb27a245bf290633fa7fdb3
|
||||
@@ -0,0 +1,99 @@
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Player;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
|
||||
public class SecurityCameraController : MonoBehaviour
|
||||
{
|
||||
[Header("Tracking Settings")]
|
||||
[SerializeField] private float _trackingDistance = 10f;
|
||||
[SerializeField] private float _rotationSpeed = 2f;
|
||||
[SerializeField] private Transform _pivotPoint;
|
||||
|
||||
[Header("LED Settings")]
|
||||
[SerializeField] private MeshRenderer _ledRenderer;
|
||||
[SerializeField] private float _blinkSpeed = 2f;
|
||||
[SerializeField] private float _minSmoothness = 0f;
|
||||
[SerializeField] private float _maxSmoothness = 0.5f;
|
||||
|
||||
private CharacterController _playerController;
|
||||
private Material _ledMaterial;
|
||||
private float _blinkTimer;
|
||||
private bool _isTracking;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (PlayerController.Instance != null)
|
||||
_playerController = PlayerController.Instance.GetComponent<CharacterController>();
|
||||
|
||||
if (_ledRenderer != null)
|
||||
{
|
||||
_ledMaterial = _ledRenderer.material;
|
||||
}
|
||||
|
||||
if (_pivotPoint == null)
|
||||
_pivotPoint = transform;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_playerController == null) return;
|
||||
|
||||
float distanceToPlayer = Vector3.Distance(_pivotPoint.position, _playerController.transform.position);
|
||||
_isTracking = distanceToPlayer <= _trackingDistance;
|
||||
|
||||
if (_isTracking)
|
||||
{
|
||||
TrackPlayer();
|
||||
}
|
||||
|
||||
UpdateLedBlink();
|
||||
}
|
||||
|
||||
void TrackPlayer()
|
||||
{
|
||||
Vector3 directionToPlayer = _playerController.transform.position - _pivotPoint.position;
|
||||
|
||||
if (directionToPlayer.sqrMagnitude > 0.001f)
|
||||
{
|
||||
Vector3 forward = -directionToPlayer.normalized;
|
||||
Vector3 right = Vector3.Cross(Vector3.up, forward).normalized;
|
||||
Vector3 up = Vector3.Cross(forward, right);
|
||||
|
||||
Quaternion targetRotation = Quaternion.LookRotation(up, forward);
|
||||
_pivotPoint.rotation = Quaternion.Slerp(_pivotPoint.rotation, targetRotation, Time.deltaTime * _rotationSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateLedBlink()
|
||||
{
|
||||
if (_ledMaterial == null) return;
|
||||
|
||||
_blinkTimer += Time.deltaTime * _blinkSpeed;
|
||||
|
||||
float smoothness = Mathf.Lerp(_minSmoothness, _maxSmoothness, Mathf.PingPong(_blinkTimer, 1f));
|
||||
|
||||
_ledMaterial.SetFloat("_Smoothness", smoothness);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_ledMaterial != null)
|
||||
Destroy(_ledMaterial);
|
||||
}
|
||||
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_pivotPoint == null)
|
||||
_pivotPoint = transform;
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawWireSphere(_pivotPoint.position, _trackingDistance);
|
||||
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawLine(_pivotPoint.position, _pivotPoint.position + _pivotPoint.forward * 2f);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17a573927733b4343acc662995710bc2
|
||||
Reference in New Issue
Block a user