Hallucination system

This commit is contained in:
Ferlat Thibaud
2026-03-16 17:06:47 +01:00
parent 10faf74405
commit 0d968f75ab
44 changed files with 2860 additions and 909 deletions
@@ -0,0 +1,122 @@
using UnityEngine;
using UnityEngine.EventSystems;
using Unheimlich.Features.DetectiveBoard.Data;
namespace Unheimlich.Features.DetectiveBoard.UI
{
/// <summary>
/// Wraps a PostItNode to behave as a card in the bottom-screen fan hand.
/// Handles hover pop animation and delegates click to ClueHandUI for board placement.
/// Add this component alongside PostItNode on the same GameObject.
/// </summary>
[RequireComponent(typeof(PostItNode))]
public class ClueHandCard : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler
{
[Header("Hover Settings")]
[SerializeField] private float _hoverRiseAmount = 60f;
[SerializeField] private float _hoverAnimSpeed = 8f;
private PostItNode _node;
private ClueHandUI _handUI;
// Fan resting state assigned by ClueHandUI
private Vector2 _restAnchoredPosition;
private float _restRotation;
// Runtime animation state
private Vector2 _currentAnchoredPosition;
private float _currentRotation;
private bool _isHovered;
private bool _isPlaced;
public ClueData ClueData => _node != null ? _node.ClueData : null;
public PostItNode Node => _node;
public bool IsPlaced => _isPlaced;
private void Awake()
{
if (_node == null)
_node = GetComponent<PostItNode>();
}
/// <summary>
/// Binds this card to its parent hand manager.
/// </summary>
public void Initialize(PostItNode node, ClueHandUI handUI)
{
_node = node;
_handUI = handUI;
}
/// <summary>
/// Assigns the resting fan slot (position + rotation) for this card.
/// </summary>
public void SetFanSlot(Vector2 anchoredPosition, float rotationZ)
{
_restAnchoredPosition = anchoredPosition;
_restRotation = rotationZ;
_currentAnchoredPosition = anchoredPosition;
_currentRotation = rotationZ;
RectTransform rt = _node.RectTransform;
rt.anchoredPosition = anchoredPosition;
rt.localRotation = Quaternion.Euler(0f, 0f, rotationZ);
}
private void Update()
{
if (_isPlaced)
return;
RectTransform rt = _node.RectTransform;
Vector2 targetPos = _isHovered
? _restAnchoredPosition + Vector2.up * _hoverRiseAmount
: _restAnchoredPosition;
float targetRot = _isHovered ? 0f : _restRotation;
_currentAnchoredPosition = Vector2.Lerp(_currentAnchoredPosition, targetPos, Time.deltaTime * _hoverAnimSpeed);
_currentRotation = Mathf.LerpAngle(_currentRotation, targetRot, Time.deltaTime * _hoverAnimSpeed);
rt.anchoredPosition = _currentAnchoredPosition;
rt.localRotation = Quaternion.Euler(0f, 0f, _currentRotation);
}
public void OnPointerEnter(PointerEventData eventData)
{
if (_isPlaced)
return;
_isHovered = true;
_handUI?.OnCardHovered(this);
}
public void OnPointerExit(PointerEventData eventData)
{
if (_isPlaced)
return;
_isHovered = false;
_handUI?.OnCardUnhovered(this);
}
public void OnPointerClick(PointerEventData eventData)
{
if (_isPlaced || eventData.button != PointerEventData.InputButton.Left)
return;
_isHovered = false;
_handUI?.OnCardSelected(this);
}
/// <summary>
/// Hides this card from the hand after placement on the board.
/// </summary>
public void MarkAsPlaced()
{
_isPlaced = true;
gameObject.SetActive(false);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dc7ce79e8bf16554fae5362980fec5b7
@@ -0,0 +1,252 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Core;
using Unheimlich.Features.DetectiveBoard.Data;
namespace Unheimlich.Features.DetectiveBoard.UI
{
/// <summary>
/// Manages the clue hand fan displayed at the bottom of the screen (Screen Space canvas).
/// Reuses the PostItNode prefab for each card — ClueHandCard is added at runtime.
/// Hovering a card pops it up and straightens it; clicking starts a lerp-depth
/// placement animation then hands the clue to DetectiveBoardCanvas.
/// </summary>
public class ClueHandUI : Singleton<ClueHandUI>
{
[Header("References")]
[SerializeField] private GameObject _postItNodePrefab;
[SerializeField] private RectTransform _handContainer;
[Header("Fan Layout Settings")]
[SerializeField] private float _cardSpacingPixels = 80f;
[SerializeField] private float _fanArcRadius = 800f;
[SerializeField] private float _cardBaseY = -120f;
[SerializeField] private float _maxFanHalfAngle = 40f;
[Header("Placement Settings")]
[SerializeField] private float _placementLerpSpeed = 5f;
private readonly List<ClueHandCard> _handCards = new List<ClueHandCard>();
private bool _isPlacing; protected override void OnAwake() { }
protected override void OnEnabled()
{
// With Scene Reload disabled, stale card references from the previous session
// may point to destroyed GameObjects. Clear and rebuild from manager state.
if (_handCards.Count > 0)
{
foreach (ClueHandCard card in _handCards)
{
if (card != null && card.gameObject != null)
Destroy(card.gameObject);
}
_handCards.Clear();
}
if (DetectiveBoardManager.Instance != null)
{
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
{
// Skip clues already placed on the board — they must not reappear in the hand
if (DetectiveBoardCanvas.Instance != null && DetectiveBoardCanvas.Instance.HasNodeForClue(clue.ClueID))
continue;
AddClueCard(clue);
}
}
}
private void Start()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked += OnClueUnlocked;
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
AddClueCard(clue);
}
// Start hidden — shown when the board activates
SetHandVisible(false);
}
private void OnDestroy()
{
if (DetectiveBoardManager.Instance != null)
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
}
private void OnClueUnlocked(ClueData clueData)
{
AddClueCard(clueData);
}
// ──────────────────────────────────────────────────────────────────────
// Card management
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Instantiates a PostItNode prefab, injects ClueHandCard, and adds it to the fan.
/// </summary>
public void AddClueCard(ClueData clueData)
{
if (_postItNodePrefab == null || _handContainer == null)
{
Debug.LogError("[ClueHandUI] Missing _postItNodePrefab or _handContainer reference.");
return;
}
if (_handCards.Exists(c => c.ClueData != null && c.ClueData.ClueID == clueData.ClueID))
return;
// Instantiate the PostItNode prefab inside the screen-space hand container
GameObject cardObj = Instantiate(_postItNodePrefab, _handContainer);
// PostItNode is nested one level inside "Canvas (Environment)" wrapper in the prefab
// — grab it directly
PostItNode node = cardObj.GetComponentInChildren<PostItNode>(true);
if (node == null)
{
Debug.LogError("[ClueHandUI] PostItNode not found on prefab.");
Destroy(cardObj);
return;
}
// Create a dummy NodeData for the hand display (position handled by fan layout)
PostItNodeData dummyData = new PostItNodeData(clueData.ClueID, Vector2.zero);
node.Initialize(clueData, dummyData);
// Add ClueHandCard wrapper on the PostItNode GameObject
ClueHandCard card = node.gameObject.AddComponent<ClueHandCard>();
card.Initialize(node, this);
// Move the PostItNode GameObject up to be a direct child of _handContainer
// so its RectTransform is in screen space
node.transform.SetParent(_handContainer, false);
// Destroy the now-empty prefab root (the "Canvas (Environment)" wrapper)
if (cardObj != node.gameObject)
Destroy(cardObj);
// Normalize scale — PostItNode prefab uses 0.5 scale for world space, reset for screen space
node.RectTransform.localScale = Vector3.one;
_handCards.Add(card);
RebuildFanLayout();
}
/// <summary>
/// Recomputes fan arc positions and rotations for all non-placed cards.
/// Uses fixed pixel spacing between cards, capped by a max half-angle.
/// </summary>
private void RebuildFanLayout()
{
List<ClueHandCard> visible = _handCards.FindAll(c => !c.IsPlaced);
int count = visible.Count;
if (count == 0)
return;
// Derive the angle step from pixel spacing on the arc
float angleStepDeg = (_cardSpacingPixels / _fanArcRadius) * Mathf.Rad2Deg;
// Total spread is (count-1) steps, capped so it never exceeds _maxFanHalfAngle on each side
float totalHalfAngle = angleStepDeg * (count - 1) * 0.5f;
float clampedHalfAngle = Mathf.Min(totalHalfAngle, _maxFanHalfAngle);
for (int i = 0; i < count; i++)
{
float t = count == 1 ? 0.5f : (float)i / (count - 1);
float angle = Mathf.Lerp(-clampedHalfAngle, clampedHalfAngle, t);
float angleRad = angle * Mathf.Deg2Rad;
float x = _fanArcRadius * Mathf.Sin(angleRad);
float y = _cardBaseY - _fanArcRadius * (1f - Mathf.Cos(angleRad));
visible[i].SetFanSlot(new Vector2(x, y), -angle);
}
}
// ──────────────────────────────────────────────────────────────────────
// Hover callbacks
// ──────────────────────────────────────────────────────────────────────
/// <summary>Brings the hovered card in front of its siblings.</summary>
public void OnCardHovered(ClueHandCard card)
{
card.transform.SetAsLastSibling();
}
/// <summary>Restores natural sibling order after hover ends.</summary>
public void OnCardUnhovered(ClueHandCard card)
{
RebuildSiblingOrder();
}
private void RebuildSiblingOrder()
{
int index = 0;
foreach (ClueHandCard c in _handCards)
{
if (!c.IsPlaced)
c.transform.SetSiblingIndex(index++);
}
}
// ──────────────────────────────────────────────────────────────────────
// Selection & placement
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Called when the player clicks a card. Creates the node on the board
/// directly under the mouse cursor and immediately starts drag mode.
/// </summary>
public void OnCardSelected(ClueHandCard card)
{
if (_isPlacing)
return;
DetectiveBoardCanvas boardCanvas = DetectiveBoardCanvas.Instance;
if (boardCanvas == null)
{
Debug.LogWarning("[ClueHandUI] DetectiveBoardCanvas not found.");
return;
}
DetectiveBoardInteraction interaction = DetectiveBoardInteraction.Instance;
if (interaction == null)
{
Debug.LogWarning("[ClueHandUI] DetectiveBoardInteraction not found.");
return;
}
boardCanvas.AddClueNode(card.ClueData);
PostItNode boardNode = boardCanvas.GetNodeForClue(card.ClueData.ClueID);
if (boardNode == null)
{
Debug.LogWarning("[ClueHandUI] Node not found on board after AddClueNode.");
return;
}
// Position node under the mouse on the board canvas plane, then start drag
interaction.SpawnNodeUnderMouseAndDrag(boardNode);
card.MarkAsPlaced();
RebuildFanLayout();
}
// ──────────────────────────────────────────────────────────────────────
// Visibility
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Shows or hides the fan hand. Called by DetectiveBoardController.
/// </summary>
public void SetHandVisible(bool visible)
{
if (_handContainer != null)
_handContainer.gameObject.SetActive(visible);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ace0b6173dad68f47a97505680f54d4e
@@ -131,45 +131,11 @@ namespace Unheimlich.Features.DetectiveBoard.UI
_saveData = new DetectiveBoardSaveData();
}
private void OnEnable()
{
TrySubscribeToManager();
}
private void Start()
{
TrySubscribeToManager();
}
private void TrySubscribeToManager()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
DetectiveBoardManager.Instance.OnClueUnlocked += OnClueUnlocked;
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
{
if (!_activeNodes.ContainsKey(clue.ClueID))
{
AddClueNode(clue);
}
}
}
}
private void OnDisable()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
}
}
private void OnClueUnlocked(ClueData clueData)
{
AddClueNode(clueData);
}
private void OnEnable() { }
private void Start() { }
private void OnDisable() { }
private void Update()
{
@@ -413,6 +379,19 @@ namespace Unheimlich.Features.DetectiveBoard.UI
}
}
/// <summary>
/// Returns the active PostItNode for the given clue ID, or null if not found.
/// Used by ClueHandUI to retrieve the node after placement.
/// </summary>
/// <summary>Returns true if a node for this clue ID is already placed on the board.</summary>
public bool HasNodeForClue(string clueID) => _activeNodes.ContainsKey(clueID);
public PostItNode GetNodeForClue(string clueID)
{
_activeNodes.TryGetValue(clueID, out PostItNode node);
return node;
}
public DetectiveBoardSaveData GetSaveData()
{
return _saveData;
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using Unheimlich.Core;
using Unheimlich.Features.DetectiveBoard;
using Unheimlich.Features.Player;
using Unheimlich.World.CameraSystem;
@@ -14,7 +15,7 @@ namespace Unheimlich.Features.DetectiveBoard.UI
/// Manages hover, selection, dragging, and click forwarding for Post-It nodes.
/// Interactions are only active when the board is in inspection mode.
/// </summary>
public class DetectiveBoardInteraction : MonoBehaviour
public class DetectiveBoardInteraction : Singleton<DetectiveBoardInteraction>
{
[Header("Settings")]
[SerializeField] private LayerMask _boardLayer;
@@ -33,6 +34,8 @@ namespace Unheimlich.Features.DetectiveBoard.UI
private Plane _canvasPlane;
private Vector3 _dragOffset;
protected override void OnAwake() { }
private void Start()
{
_boardCanvas = GetComponent<Canvas>();
@@ -349,6 +352,41 @@ namespace Unheimlich.Features.DetectiveBoard.UI
}
}
/// <summary>
/// Positions a freshly-created board node under the current mouse cursor
/// and immediately enters drag mode on it, so the player can place it freely.
/// Called by ClueHandUI after AddClueNode.
/// </summary>
public void SpawnNodeUnderMouseAndDrag(PostItNode node)
{
if (node == null || _camera == null)
return;
UpdateCanvasPlane();
Vector2 mousePos = Mouse.current != null
? Mouse.current.position.ReadValue()
: new Vector2(Screen.width * 0.5f, Screen.height * 0.5f);
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0f));
if (_canvasPlane.Raycast(ray, out float enter))
{
Vector3 worldHitPoint = ray.GetPoint(enter);
Vector3 localPos = _boardCanvas.transform.InverseTransformPoint(worldHitPoint);
node.SetPosition(new Vector2(localPos.x, localPos.y));
}
// Begin drag with zero offset so the node centre tracks the mouse exactly
if (_selectedNode != null)
_selectedNode.SetSelected(false);
_selectedNode = node;
_selectedNode.SetSelected(true);
_isDragging = true;
_dragOffset = Vector3.zero;
}
private PostItNode RaycastForNode()
{
if (_camera == null || Mouse.current == null)