Files
StoryGame_Unity/Assets/02_Scripts/_UI/Communication/DialogEnterHud.cs
2026-07-30 10:49:41 +09:00

150 lines
5.8 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
// "어떤 대화를 걸지" 고르는 메뉴 (역전재판식 "대화하기" — 후보가 여럿이면 골라서 시작).
// DialogEnterUI.uxml의 ListView로 후보를 띄우고, 행을 클릭하면 그 인덱스를 반환한다.
// 대사 도중의 분기 선택지(ChoiceHud)와는 별개다 — 이건 대화를 시작하기 전 단계의 UI.
// DialogPlayer.SelectBeat가 재생 가능한 비트가 여럿일 때 사용한다.
//
// UIDocument의 후속인 PanelRenderer를 쓴다. 요소 참조는 UI 리로드 콜백으로 받는다:
// - rootVisualElement가 준비됐는지 매번 확인하던 지연 초기화(EnsureRefs)가 사라진다.
// - 리로드되면 ListView 인스턴스 자체가 새로 만들어지므로, 콜백에서 매번 다시 연결한다.
[RequireComponent(typeof(PanelRenderer))]
public class DialogEnterHud : MonoBehaviour
{
public static DialogEnterHud Instance { get; private set; }
private PanelRenderer _panelRenderer;
private VisualElement _root; // 전체 토글 대상(#Body)
private ListView _listView;
// 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴)
private int _uiVersion = -1;
private List<DialogChoice> _options;
private AwaitableCompletionSource<int> _completion;
// 현재 표시 상태. 리로드로 요소가 새로 만들어졌을 때 이 값으로 복원한다
// (메뉴를 띄운 채 리로드돼도 대기 중인 Show가 그대로 살아 있다).
private bool _visible;
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
_panelRenderer = GetComponent<PanelRenderer>();
// root가 이미 준비돼 있으면 즉시 호출되고, 이후 UI가 리로드될 때마다 다시 호출된다
_panelRenderer.RegisterUIReloadCallback(OnUIReload);
}
private void OnDestroy()
{
if (_panelRenderer != null)
_panelRenderer.UnregisterUIReloadCallback(OnUIReload);
if (Instance == this) Instance = null;
}
private void OnDisable()
{
// 씬 전환 등으로 비활성화될 때 진행 중 대기 정리
_completion?.TrySetCanceled();
_completion = null;
}
// UI가 (재)구성될 때마다 요소를 다시 잡고 ListView 콜백을 걸고 현재 상태를 되돌린다.
private void OnUIReload(PanelRenderer panelRenderer, VisualElement root, int version)
{
if (_uiVersion == version) return;
_uiVersion = version;
_root = root.Q<VisualElement>("Body");
_listView = root.Q<ListView>();
if (_listView != null)
{
_listView.selectionType = SelectionType.Single;
_listView.bindItem = BindRow; // item-template(DialogEnterRow)이 makeItem을 담당, 바인딩만 우리가
_listView.selectionChanged += OnSelectionChanged;
}
ApplyState(); // 첫 호출에선 _visible=false라 숨김 상태로 시작한다
}
// 대기 중인 선택을 취소하고 메뉴를 닫는다 (다른 NPC와 대화를 새로 시작할 때 등).
// 취소된 쪽의 await Show(...)는 OperationCanceledException으로 빠져나간다.
public void CancelPending() => _completion?.TrySetCanceled();
// 후보 목록을 띄우고 고른 인덱스를 반환한다. 취소되면 OperationCanceledException.
public async Awaitable<int> Show(List<DialogChoice> options)
{
if (options == null || options.Count == 0) return 0;
if (_root == null || _listView == null) return 0; // UI가 아직 준비되지 않음
// 이전 호출이 아직 대기 중이면 먼저 취소한다 (마지막 호출이 이긴다).
CancelPending();
_options = options;
_visible = true;
ApplyState();
var completion = new AwaitableCompletionSource<int>();
_completion = completion;
int result;
try
{
result = await completion.Awaitable;
}
finally
{
// 내가 아직 현재 세션일 때만 정리 — 취소 직후 다른 NPC가 새로 띄운 메뉴를 지우면 안 된다
if (_completion == completion)
{
_completion = null;
Hide();
}
}
return result;
}
// ListView 행(item-template = DialogEnterRow.uxml)에 후보 이름을 채운다.
// 행 템플릿의 Label엔 name이 없어 타입으로 찾는다.
private void BindRow(VisualElement element, int i)
{
var label = element.Q<Label>();
if (label != null && _options != null && i < _options.Count)
label.text = DialogVariables.Format(_options[i].ChoiceText); // {key} 토큰 치환
}
// 행을 클릭(선택)하면 그 인덱스로 결과를 확정한다
private void OnSelectionChanged(IEnumerable<object> _)
{
if (_completion == null) return;
int idx = _listView.selectedIndex;
if (idx < 0) return;
_completion.TrySetResult(idx);
}
private void Hide()
{
_visible = false;
_options = null;
ApplyState();
}
// 캐시된 상태를 실제 요소에 반영한다.
// 요소가 아직 없으면(리로드 콜백 전) 조용히 넘어가고, 콜백이 오면 같은 함수로 복원된다.
private void ApplyState()
{
if (_listView != null)
{
_listView.itemsSource = _visible ? _options : null;
_listView.ClearSelection(); // 이전 선택이 남아 즉시 확정되는 것을 막는다
_listView.Rebuild();
}
if (_root != null)
_root.style.display = _visible ? DisplayStyle.Flex : DisplayStyle.None;
}
}