using System.Collections.Generic; using UnityEngine; using Unheimlich.Features.Player; using Unheimlich.Features.Hallucination; namespace Unheimlich.World.Environment { // ─── Data ───────────────────────────────────────────────────────────────────── /// /// Configuration for a single stair slot. /// Slot indices are relative: 0 = ground level, positive = up, negative = down. /// [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; } /// /// ScriptableObject holding the full word/event sequence for one staircase. /// Create via Assets > Create > Unheimlich > Hallucination > Staircase Sequence Data. /// [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 Slots = new(); } // ─── 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 _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; /// Single parent moved to scroll the entire tower at once. private Transform _towerRoot; /// World Y of the trigger collider center, cached on Awake. private float _triggerCenterWorldY; /// True when the player entered the trigger from below (travelling upward). private bool _enteredFromBelow; /// /// towerRoot.localPosition.y threshold signalling stock exhaustion. /// Negative when going up, positive when going down. /// private float _limitLocalY; /// Current scroll speed (signed). Positive means the tower moves downward. private float _scrollSpeed; private float _scrollSpeedVelocity; private readonly Dictionary _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 ─────────────────────────────────────────────────── /// /// Called by StaircaseTriggerZone when the player enters the collider. /// 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; } /// /// Called by StaircaseTriggerZone when the player exits the collider. /// 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 ──────────────────────────────────────────── /// /// 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. /// private void ConfigureSegment(GameObject segmentGO, int slotIndex) { StaircaseSegment segment = segmentGO.GetComponent(); 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; } /// Returns the StaircaseSlotData matching slotIndex, or null if not found. 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(); if (zone == null) { Debug.LogWarning("[InfiniteStaircase] No StaircaseTriggerZone found in children.", this); return; } Collider col = zone.GetComponent(); 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() != 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(); 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) ); } } } }