548 lines
22 KiB
C#
548 lines
22 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using Unheimlich.Features.Player;
|
|
using Unheimlich.Features.Hallucination;
|
|
|
|
namespace Unheimlich.World.Environment
|
|
{
|
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <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.
|
|
/// Create via Assets > Create > Unheimlich > Hallucination > Staircase Sequence Data.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
// ─── 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)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} |