using System.Collections.Generic; using UnityEngine; using Unheimlich.Features.Player; namespace Unheimlich.World.Environment { /// /// Infinite parallax staircase — tower approach. /// /// When the player enters the trigger zone from below: /// - A full tower of (_maxIterations + 1) segments is spawned instantly above ground. /// - The tower scrolls at a speed proportional to how far the player's Y deviates /// from the trigger center — keeping the player locked near that height. /// If the player stops moving, scroll speed drops to zero. /// - When the topmost segment reaches ground level the stock is exhausted: the tower /// decelerates, extras are destroyed, and the system resets. /// /// Entering from above mirrors the logic (tower ascends, stock is below ground). /// Re-triggerable indefinitely. /// /// Setup: /// 1. Add this component to the InfinityStair root. /// 2. Add to the Trigger child. /// 3. Assign . /// public class InfiniteStaircaseController : MonoBehaviour { // ─── Prefab ────────────────────────────────────────────────────────── [Header("Stair Prefab")] [Tooltip("One staircase segment prefab used to build the tower.")] [SerializeField] private GameObject _stairPrefab; [Tooltip("If true, the first child with renderers is reused as slot 0 rather than instantiated from the prefab.")] [SerializeField] private bool _useExistingChildAsBase = true; // ─── Iterations ────────────────────────────────────────────────────── [Header("Iterations")] [Tooltip("Number of full segment heights the player can traverse. Tower = (maxIterations + 1) segments.")] [SerializeField] private int _maxIterations = 4; // ─── Segment Bounds ────────────────────────────────────────────────── [Header("Segment Bounds — Auto-Detection")] [Tooltip("Renderer of the central column mesh in the stair asset. 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 (not its pivot).")] [SerializeField] private float _segmentRotationOffsetY = 0f; // ─── 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 in Awake. private float _triggerCenterWorldY; /// True when the player entered from below (going up). 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 down. private float _scrollSpeed; private float _scrollSpeedVelocity; private readonly Dictionary _segmentsBySlot = new(); // ─── Properties ────────────────────────────────────────────────────── private float EffectiveSegmentHeight => _segmentHeight + _segmentHeightOffset; private float MaxTowerDisplacement => _maxIterations * 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 when the player enters the collider. /// Detects entry direction, builds the tower, and starts the effect. /// 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 when the player exits. No action needed — the limit check drives the stop. public void OnPlayerExitedTrigger() { } // ─── Tower Construction ─────────────────────────────────────────────── /// /// Spawns the full tower under a single _towerRoot Transform. /// UP: slots 0..+N above ground. Limit = -N*h (descend to exhaust stock). /// DOWN: slots 0..-N below ground. Limit = +N*h (ascend to exhaust stock). /// 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 <= _maxIterations; i++) SpawnSegmentAtSlot(i); _limitLocalY = -MaxTowerDisplacement; } else { for (int i = 0; i >= -_maxIterations; i--) SpawnSegmentAtSlot(i); _limitLocalY = MaxTowerDisplacement; } } 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: try to reuse the existing scene child to avoid a visible pop. 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); _segmentsBySlot[slotIndex] = segment; return; } } if (_stairPrefab == null) return; segment = Instantiate(_stairPrefab, _towerRoot); segment.transform.localPosition = new Vector3(0f, localY, 0f); segment.transform.localRotation = Quaternion.identity; ApplyRotationOffsetFromCenter(segment); _segmentsBySlot[slotIndex] = segment; } // ─── Scroll ─────────────────────────────────────────────────────────── /// /// Proportional controller: error = player.y - triggerCenter.y. /// Positive error (player above center) → tower moves down → positive speed. /// Zero error (player at center) → speed converges to zero → tower stops. /// Speed is clamped so it can only push the tower in the intended direction, /// preventing the initial negative error from making the tower snap upward. /// 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; // Clamp to the stock limit — never overshoot the last segment. 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 // going up, tower descended enough : _towerRoot.localPosition.y >= _limitLocalY; // going down, tower ascended enough if (reached) _currentState = StairState.Stopping; } /// /// Smoothly brings the tower to rest before triggering cleanup. /// private void DecelerateAndFinish() { _scrollSpeed = Mathf.SmoothDamp(_scrollSpeed, 0f, ref _scrollSpeedVelocity, _scrollSmoothTime); ApplyScrollToTower(); if (Mathf.Abs(_scrollSpeed) < 0.02f) FinishAndCleanup(); } // ─── Cleanup ───────────────────────────────────────────────────────── /// /// Extracts the segment now at ground level, destroys the rest, and resets state. /// private void FinishAndCleanup() { _currentState = StairState.Done; if (_towerRoot == null) return; int keepSlot = _enteredFromBelow ? _maxIterations : -_maxIterations; _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; } /// /// Returns the first direct child of InfinityStair (excluding _towerRoot) that has renderers. /// 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 <= _maxIterations; 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 <= _maxIterations; 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)); } } }