Files
2026-07-31 11:51:50 +09:00

311 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UIElements;
// 증거품창. 가진 항목을 좌우 화살표로 넘겨 본다.
//
// 하위 분류가 둘이고 Evidence 버튼 하나로 순환한다: 닫힘 → 소지품 → 인물 정보 → 닫힘.
// 두 분류는 같은 창·같은 레이아웃을 쓰고, 보고 있는 분류의 항목만 목록에 오른다.
//
// 제시(들이대기): 대화 노드가 히든 분기로 무장돼 있는 동안 그림을 클릭하면
// HiddenBranchResolver.Fire(항목 Id)를 쏜다 — DialogTouchZone의 부위 키와 완전히 같은 통로라서,
// 노드에 Key = 항목 Id 인 히든 분기를 달아두면 그 분기가 열린다. 발동하면 창은 자동으로 닫힌다.
// 소지품과 인물 모두 제시할 수 있다. 무장돼 있지 않으면(평소) 클릭해도 아무 일도 없다.
//
// 목록은 StoryManager의 획득 이력(Id)을 EvidenceDatabase로 복원해 만든다. 획득 순서 = 표시 순서.
[RequireComponent(typeof(PanelRenderer))]
public class EvidenceHud : MonoBehaviour
{
public static EvidenceHud Instance { get; private set; }
[Tooltip("Id → EvidenceData 복원용 전체 목록 (소지품·인물 모두 포함, 게임에 하나)")]
[SerializeField] private EvidenceDatabase _database;
[Tooltip("창 상단에 표시할 소지품 분류 이름")]
[SerializeField] private string _itemCategoryLabel = "소지품";
[Tooltip("창 상단에 표시할 인물 정보 분류 이름")]
[SerializeField] private string _profileCategoryLabel = "인물 정보";
[Tooltip("가진 소지품이 없을 때 설명 칸에 표시할 문구")]
[SerializeField] private string _emptyItemText = "가지고 있는 소지품이 없습니다.";
[Tooltip("등록된 인물이 없을 때 설명 칸에 표시할 문구")]
[SerializeField] private string _emptyProfileText = "등록된 인물이 없습니다.";
[Tooltip("제시가 실제로 발동했을 때 호출 (연출·사운드용). 발동하지 않은 클릭에는 불리지 않는다")]
[SerializeField] private UnityEvent _onPresented;
[Header("Slide")]
[Tooltip("아이템 전환 슬라이드 전체 시간(초). 0이면 연출 없이 즉시 교체")]
[Min(0f)] [SerializeField] private float _slideDuration = 0.16f;
[Tooltip("슬라이드로 밀려나는 거리(px)")]
[Min(0f)] [SerializeField] private float _slideDistance = 500f;
private PanelRenderer _panelRenderer;
private VisualElement _root; // 전체 토글 대상(#Body)
private VisualElement _itemNode; // 슬라이드 대상 (.EvidenceNode — 그림+설명 묶음)
private Label _categoryLabel; // 창 상단 분류 이름 (#EvidenceCategory)
private Image _image;
private Label _label;
// Step마다 증가 — 연타하면 진행 중이던 슬라이드가 즉시 물러나고 새 슬라이드가 이어받는다
// (Typewriter의 세대 패턴과 동일)
private int _slideGen;
// 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴)
private int _uiVersion = -1;
// 현재 표시 상태. 리로드로 요소가 새로 만들어져도 이 값으로 복원된다.
private bool _visible;
private EvidenceCategory _category = EvidenceCategory.Item; // 지금 보고 있는 분류
private int _index;
private readonly List<EvidenceData> _owned = new();
// 창이 떠 있는가 (DialogTouchZone이 창 뒤 월드 클릭을 무시하는 데 쓴다)
public bool IsOpen => _visible;
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
_panelRenderer = GetComponent<PanelRenderer>();
_panelRenderer.RegisterUIReloadCallback(OnUIReload);
}
private void Start()
{
// 창이 열려 있는 동안 아이템을 얻으면 목록이 바로 갱신되게
if (StoryManager.Instance != null)
StoryManager.Instance.Changed += OnStoryChanged;
// 증거품창 버튼 (Evidence 액션) — 입력 해석은 InputManager가, 여닫는 행동은 창 자신이 맡는다
if (InputManager.Instance != null)
{
InputManager.Instance.OnEvidence_Event += Toggle;
InputManager.Instance.OnEvidencePrev_Event += OnPrevPressed;
InputManager.Instance.OnEvidenceNext_Event += OnNextPressed;
}
}
private void OnDestroy()
{
if (StoryManager.Instance != null)
StoryManager.Instance.Changed -= OnStoryChanged;
if (InputManager.Instance != null)
{
InputManager.Instance.OnEvidence_Event -= Toggle;
InputManager.Instance.OnEvidencePrev_Event -= OnPrevPressed;
InputManager.Instance.OnEvidenceNext_Event -= OnNextPressed;
}
if (_panelRenderer != null)
_panelRenderer.UnregisterUIReloadCallback(OnUIReload);
if (Instance == this) Instance = null;
}
// UI가 (재)구성될 때마다 요소를 다시 잡고 클릭 콜백을 걸고 현재 상태를 되돌린다.
private void OnUIReload(PanelRenderer panelRenderer, VisualElement root, int version)
{
if (_uiVersion == version) return;
_uiVersion = version;
_root = root.Q<VisualElement>("Body");
_itemNode = root.Q(className: "EvidenceNode");
_categoryLabel = root.Q<Label>("EvidenceCategory");
_image = root.Q<Image>("Image");
_label = root.Q<Label>("Label");
root.Q<Image>("PrevArrow")?.RegisterCallback<ClickEvent>(_ => Step(-1));
root.Q<Image>("NextArrow")?.RegisterCallback<ClickEvent>(_ => Step(+1));
_image?.RegisterCallback<ClickEvent>(_ => TryPresent());
ApplyState(); // 첫 호출에선 _visible=false라 숨김 상태로 시작한다
}
// ── 공개 API (버튼 UnityEvent 연결용) ────────────────────────
// Evidence 버튼: 닫힘 → 소지품 → 인물 정보 → 닫힘 순으로 순환한다.
// 한 버튼으로 열기·분류 전환·닫기를 다 처리한다.
public void Toggle()
{
if (!_visible)
Show(EvidenceCategory.Item); // 닫힘 → 소지품
else if (_category == EvidenceCategory.Item)
Show(EvidenceCategory.Profile); // 소지품 → 인물 정보
else
Hide(); // 인물 정보 → 닫힘
}
public void Show() => Show(EvidenceCategory.Item);
public void Show(EvidenceCategory category)
{
// 분류가 바뀌면 목록 자체가 달라지므로 첫 항목부터 다시 본다
if (!_visible || _category != category) _index = 0;
_visible = true;
_category = category;
_slideGen++; // 진행 중이던 슬라이드 취소
ResetSlideStyle();
ApplyState();
}
public void Hide()
{
_visible = false;
_slideGen++; // 진행 중이던 슬라이드 취소
ResetSlideStyle();
ApplyState();
}
// ── 내부 ─────────────────────────────────────────────────────
private EvidenceData CurrentItem =>
_owned.Count > 0 && _index >= 0 && _index < _owned.Count ? _owned[_index] : null;
private void Step(int delta)
{
if (_owned.Count == 0) return;
_index = (_index + delta + _owned.Count) % _owned.Count; // 양끝에서 반대편으로 순환
// 슬라이드 연출: 현재 카드가 진행 방향으로 빠지고 → (빈 순간에 내용 교체) →
// 반대쪽에서 새 카드가 들어온다. 연출 불가 조건이면 즉시 교체.
if (_itemNode == null || _slideDuration <= 0f || _owned.Count < 2)
{
ApplyState();
return;
}
_ = SlideSwap(delta, ++_slideGen);
}
// 두 단계 슬라이드. 연타로 세대가 바뀌면 그 자리에서 물러난다 —
// 새 Step의 SlideSwap이 리셋부터 다시 시작하므로 상태가 꼬이지 않는다.
private async Awaitable SlideSwap(int dir, int gen)
{
float half = _slideDuration * 0.5f;
try
{
ResetSlideStyle(); // 이전 슬라이드가 중간에 밀려났어도 항상 제자리에서 시작
// 1단계: 현재 카드가 진행 방향으로 빠지며 사라진다
if (!await Animate(0f, -dir * _slideDistance, 1f, 0f, half, gen)) return;
ApplyState(); // 화면 밖(투명)인 순간 내용 교체
// 2단계: 새 카드가 반대쪽에서 들어온다
if (!await Animate(dir * _slideDistance, 0f, 0f, 1f, half, gen)) return;
}
catch (OperationCanceledException)
{
// 오브젝트 파괴/씬 전환 — 조용히 종료
}
finally
{
// 내 세대일 때만 제자리로 — 이미 다음 슬라이드가 시작됐다면 그쪽이 처리한다
if (gen == _slideGen) ResetSlideStyle();
}
}
// fromX→toX(px), fromA→toA(불투명도)로 부드럽게 이동. 세대가 바뀌면 false.
private async Awaitable<bool> Animate(float fromX, float toX, float fromA, float toA,
float duration, int gen)
{
float t = 0f;
while (t < duration)
{
if (gen != _slideGen || _itemNode == null) return false;
t += Time.deltaTime;
float k = Mathf.Clamp01(t / duration);
float e = k * k * (3f - 2f * k); // SmoothStep — 끝에서 감속해 카드 무게감이 생긴다
_itemNode.style.translate = new Translate(Mathf.Lerp(fromX, toX, e), 0f);
_itemNode.style.opacity = Mathf.Lerp(fromA, toA, e);
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
return gen == _slideGen;
}
private void ResetSlideStyle()
{
if (_itemNode == null) return;
_itemNode.style.translate = new Translate(0f, 0f);
_itemNode.style.opacity = 1f;
}
// 키 입력(EvidencePrev/Next) — 창이 떠 있을 때만 반응한다.
// 화살표 클릭과 같은 Step을 타므로 마우스·키보드 어느 쪽으로 넘겨도 동작이 같다.
private void OnPrevPressed() { if (_visible) Step(-1); }
private void OnNextPressed() { if (_visible) Step(+1); }
// 아이템 그림 클릭 = 제시. 무장된 히든 분기의 Key와 일치(또는 catch-all)할 때만 발동한다.
private void TryPresent()
{
var item = CurrentItem;
if (item == null || !HiddenBranchResolver.Armed) return;
string id = string.IsNullOrEmpty(item.Id) ? item.name : item.Id;
if (HiddenBranchResolver.Fire(id))
{
_onPresented?.Invoke();
Hide(); // 발동 성공 — 창을 닫고 대화가 히든 분기로 흘러가게 한다
}
}
private void OnStoryChanged()
{
if (_visible) ApplyState();
}
// 가진 항목 중 "지금 보고 있는 분류"만 추린다. 획득 순서 = 표시 순서.
private void RebuildOwned()
{
_owned.Clear();
var story = StoryManager.Instance;
if (story == null || _database == null) return;
foreach (var id in story.EvidenceIds)
{
// 분류는 항목이 아니라 DB의 어느 목록에 들어 있는지가 정한다
var entry = _database.FindById(id, out var category);
if (entry == null)
{
Debug.LogWarning($"[EvidenceHud] EvidenceDatabase에 없는 Id: {id}");
continue;
}
if (category == _category) _owned.Add(entry);
}
}
// 캐시된 상태를 실제 요소에 반영한다. 요소가 아직 없으면(리로드 콜백 전) 조용히 넘어간다.
private void ApplyState()
{
RebuildOwned();
if (_index >= _owned.Count) _index = Mathf.Max(0, _owned.Count - 1);
// 분류 이름은 항목 유무와 무관하게 항상 지금 보고 있는 분류를 가리킨다
if (_categoryLabel != null)
_categoryLabel.text = _category == EvidenceCategory.Item
? _itemCategoryLabel : _profileCategoryLabel;
var entry = CurrentItem;
if (_image != null)
{
_image.sprite = entry != null ? entry.Icon : null;
_image.style.visibility = entry != null ? Visibility.Visible : Visibility.Hidden;
}
if (_label != null)
{
_label.text = entry != null
? $"<b>{entry.Name}</b>\n\n{entry.Description}"
: (_category == EvidenceCategory.Item ? _emptyItemText : _emptyProfileText);
}
if (_root != null)
_root.style.display = _visible ? DisplayStyle.Flex : DisplayStyle.None;
}
}