인물정보, 소지품 분리
This commit is contained in:
310
Assets/02_Scripts/_UI/Evidence/EvidenceHud.cs
Normal file
310
Assets/02_Scripts/_UI/Evidence/EvidenceHud.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user