268 lines
11 KiB
C#
268 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using UnityEngine.UIElements;
|
|
|
|
// 증거품창 (역전재판의 법정기록). ItemListUI.uxml을 띄우고 가진 아이템을 좌우 화살표로 넘겨 본다.
|
|
//
|
|
// 제시(들이대기): 대화 노드가 히든 분기로 무장돼 있는 동안 아이템 그림을 클릭하면
|
|
// HiddenBranchResolver.Fire(아이템 Id)를 쏜다 — DialogTouchZone의 부위 키와 완전히 같은 통로라서,
|
|
// 노드에 Key = 아이템 Id 인 히든 분기를 달아두면 그 분기가 열린다. 발동하면 창은 자동으로 닫힌다.
|
|
// 무장돼 있지 않으면(평소) 클릭해도 아무 일도 없다 — 그냥 열람용 창이다.
|
|
//
|
|
// 목록은 StoryManager의 아이템 이력(Id)을 ItemDatabase로 복원해 만든다. 획득 순서 = 표시 순서.
|
|
[RequireComponent(typeof(PanelRenderer))]
|
|
public class ItemHud : MonoBehaviour
|
|
{
|
|
public static ItemHud Instance { get; private set; }
|
|
|
|
[Tooltip("Id → ItemData 복원용 아이템 전체 목록 (게임에 하나)")]
|
|
[SerializeField] private ItemDatabase _database;
|
|
|
|
[Tooltip("증거품이 하나도 없을 때 설명 칸에 표시할 문구")]
|
|
[SerializeField] private string _emptyText = "가지고 있는 증거품이 없습니다.";
|
|
|
|
[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; // 슬라이드 대상 (.ItemNode — 그림+설명 묶음)
|
|
private Image _image;
|
|
private Label _label;
|
|
|
|
// Step마다 증가 — 연타하면 진행 중이던 슬라이드가 즉시 물러나고 새 슬라이드가 이어받는다
|
|
// (Typewriter의 세대 패턴과 동일)
|
|
private int _slideGen;
|
|
|
|
// 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴)
|
|
private int _uiVersion = -1;
|
|
|
|
// 현재 표시 상태. 리로드로 요소가 새로 만들어져도 이 값으로 복원된다.
|
|
private bool _visible;
|
|
private int _index;
|
|
private readonly List<ItemData> _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: "ItemNode");
|
|
_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 연결용) ────────────────────────
|
|
public void Toggle() { if (_visible) Hide(); else Show(); }
|
|
|
|
public void Show()
|
|
{
|
|
_visible = true;
|
|
_slideGen++; // 진행 중이던 슬라이드 취소
|
|
ResetSlideStyle();
|
|
ApplyState();
|
|
}
|
|
|
|
public void Hide()
|
|
{
|
|
_visible = false;
|
|
_slideGen++; // 진행 중이던 슬라이드 취소
|
|
ResetSlideStyle();
|
|
ApplyState();
|
|
}
|
|
|
|
// ── 내부 ─────────────────────────────────────────────────────
|
|
private ItemData 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.ItemIds)
|
|
{
|
|
var item = _database.FindById(id);
|
|
if (item != null) _owned.Add(item);
|
|
else Debug.LogWarning($"[ItemHud] ItemDatabase에 없는 아이템 Id: {id}");
|
|
}
|
|
}
|
|
|
|
// 캐시된 상태를 실제 요소에 반영한다. 요소가 아직 없으면(리로드 콜백 전) 조용히 넘어간다.
|
|
private void ApplyState()
|
|
{
|
|
RebuildOwned();
|
|
if (_index >= _owned.Count) _index = Mathf.Max(0, _owned.Count - 1);
|
|
|
|
var item = CurrentItem;
|
|
if (_image != null)
|
|
{
|
|
_image.sprite = item != null ? item.Icon : null;
|
|
_image.style.visibility = item != null ? Visibility.Visible : Visibility.Hidden;
|
|
}
|
|
if (_label != null)
|
|
{
|
|
_label.text = item != null
|
|
? $"<b>{item.Name}</b>\n\n{item.Description}"
|
|
: _emptyText;
|
|
}
|
|
|
|
if (_root != null)
|
|
_root.style.display = _visible ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
}
|