222 lines
8.9 KiB
C#
222 lines
8.9 KiB
C#
using System;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
|
|
// 화면에 그림 한 장을 띄우는 패널의 공통 뼈대.
|
|
// FrontSprite(가운데 아래 크게 서는 그림)와 SideSprite(옆에 놓고 설명하는 그림)가 이걸 공유한다.
|
|
//
|
|
// 왜 CharacterData가 아니라 Sprite인가:
|
|
// 인물만 서는 자리가 아니다 — 대사 도중 물건·서류·풍경이 같은 자리에 뜰 수 있어서 표시 단위를
|
|
// Sprite로 잡았다. (CharacterData.Portrait은 증거품창 인물 정보 사진 전용이라 여기선 쓰지 않는다)
|
|
//
|
|
// 그림 교체 규칙 — 셋을 구분하는 게 이 클래스의 핵심이다:
|
|
// 숨김 → 표시 : 슬라이드 + 페이드 인
|
|
// 표시 중 교체 : 즉시. 표정만 바뀌는데 매번 페이드하면 대사 흐름이 늘어진다
|
|
// 표시 → 숨김 : 슬라이드 + 페이드 아웃
|
|
//
|
|
// 제네릭 상속인 것은 파생 타입마다 자기 Instance를 갖게 하기 위해서다 —
|
|
// FrontSprite.Instance와 SideSprite.Instance가 각각 따로 존재한다.
|
|
[RequireComponent(typeof(PanelRenderer))]
|
|
public abstract class SpritePanel<T> : MonoBehaviour where T : SpritePanel<T>
|
|
{
|
|
public static T Instance { get; private set; }
|
|
|
|
[Header("Transition")]
|
|
[Tooltip("등장·퇴장 연출 시간(초). 0이면 연출 없이 즉시 나타나고 사라진다")]
|
|
[Min(0f)] [SerializeField] private float _fadeDuration = 0.18f;
|
|
|
|
[Tooltip("등장할 때 밀려 들어오는 시작 위치(px). 퇴장할 땐 이 방향으로 되밀려 나간다. " +
|
|
"(0,0)이면 제자리에서 페이드만 한다")]
|
|
[SerializeField] private Vector2 _slideFrom = new(0f, 80f);
|
|
|
|
// UXML에서 그림을 담을 Image의 이름. 파생 클래스가 정한다
|
|
protected abstract string ImageName { get; }
|
|
|
|
// 파생 클래스가 컴포넌트 추가 시점의 기본 슬라이드 방향을 정할 수 있게 (Reset에서 쓴다)
|
|
protected Vector2 SlideFrom { get => _slideFrom; set => _slideFrom = value; }
|
|
|
|
private PanelRenderer _panelRenderer;
|
|
private VisualElement _root; // 전체 토글 대상(#Body)
|
|
private Image _image;
|
|
|
|
// 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴)
|
|
private int _uiVersion = -1;
|
|
|
|
// 현재 표시 상태. 리로드로 요소가 새로 만들어져도 이 값으로 복원된다.
|
|
private bool _visible;
|
|
private Sprite _sprite;
|
|
|
|
// 연출 세대 — 새 연출이 시작되면 진행 중이던 연출이 그 자리에서 물러난다
|
|
// (EvidenceHud의 슬라이드·Typewriter와 같은 패턴)
|
|
private int _gen;
|
|
|
|
// 지금 떠 있는 그림 (없으면 null).
|
|
// DialogPlayer가 대화 전 상태를 기억했다가 되돌리는 데 쓴다.
|
|
public Sprite Current => _visible ? _sprite : null;
|
|
|
|
public bool IsVisible => _visible;
|
|
|
|
private bool CanAnimate => _image != null && _fadeDuration > 0f;
|
|
|
|
protected virtual void Awake()
|
|
{
|
|
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
|
Instance = (T)this;
|
|
|
|
_panelRenderer = GetComponent<PanelRenderer>();
|
|
// root가 이미 준비돼 있으면 즉시 호출되고, 이후 UI가 리로드될 때마다 다시 호출된다
|
|
_panelRenderer.RegisterUIReloadCallback(OnUIReload);
|
|
}
|
|
|
|
protected virtual void OnDestroy()
|
|
{
|
|
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");
|
|
_image = root.Q<Image>(ImageName);
|
|
|
|
if (_image == null)
|
|
Debug.LogWarning($"[{GetType().Name}] UXML에서 Image '{ImageName}'을 찾지 못함: {name}");
|
|
|
|
_gen++; // 진행 중이던 연출은 새로 만들어진 요소에 의미가 없다
|
|
ResetStyle();
|
|
ApplyState(); // 첫 호출에선 _visible=false라 숨김 상태로 시작한다
|
|
}
|
|
|
|
// ── 공개 API (버튼·트리거의 UnityEvent에도 그대로 연결된다) ──
|
|
|
|
// 그림을 띄운다. null을 넘기면 Hide()와 같다 —
|
|
// 덕분에 "대화 전 그림으로 되돌리기"를 Show(기억해둔값) 한 줄로 처리할 수 있다.
|
|
public void Show(Sprite sprite)
|
|
{
|
|
if (sprite == null) { Hide(); return; }
|
|
if (_visible && _sprite == sprite) return; // 같은 그림 재지정 — 연출을 다시 돌리지 않는다
|
|
|
|
bool wasVisible = _visible;
|
|
_sprite = sprite;
|
|
_visible = true;
|
|
|
|
// 이미 떠 있으면 그림만 갈아끼운다 (표정 교체가 매번 페이드되면 대사가 늘어진다)
|
|
if (wasVisible) { ApplyState(); return; }
|
|
|
|
if (!CanAnimate) { ResetStyle(); ApplyState(); return; }
|
|
_ = Transition(fadeIn: true, ++_gen);
|
|
}
|
|
|
|
// 증거품·인물 항목을 그대로 띄운다 (제시 연출에서 SideSprite에 바로 넘기기 위한 편의 오버로드)
|
|
public void Show(EvidenceData evidence) => Show(evidence != null ? evidence.Icon : null);
|
|
|
|
public void Hide()
|
|
{
|
|
if (!_visible) return;
|
|
|
|
if (!CanAnimate)
|
|
{
|
|
_visible = false;
|
|
_gen++;
|
|
ResetStyle();
|
|
ApplyState();
|
|
return;
|
|
}
|
|
_ = Transition(fadeIn: false, ++_gen);
|
|
}
|
|
|
|
// 연출 없이 즉시 반영 (씬 전환·초기 배치처럼 연출이 방해가 되는 자리에서)
|
|
public void SetImmediate(Sprite sprite)
|
|
{
|
|
_sprite = sprite;
|
|
_visible = sprite != null;
|
|
_gen++; // 진행 중이던 연출 취소
|
|
ResetStyle();
|
|
ApplyState();
|
|
}
|
|
|
|
// ── 내부 ─────────────────────────────────────────────────────
|
|
|
|
// 등장/퇴장 한 번. 세대가 바뀌면(새 Show/Hide가 끼어들면) 그 자리에서 물러난다.
|
|
private async Awaitable Transition(bool fadeIn, int gen)
|
|
{
|
|
Vector2 fromT = fadeIn ? _slideFrom : Vector2.zero;
|
|
Vector2 toT = fadeIn ? Vector2.zero : _slideFrom;
|
|
float fromA = fadeIn ? 0f : 1f;
|
|
float toA = fadeIn ? 1f : 0f;
|
|
|
|
try
|
|
{
|
|
// 들어올 때는 화면에 붙이기 전에 시작 스타일(투명 + 밀린 위치)부터 세운다 —
|
|
// 순서가 반대면 첫 프레임에 그림이 제자리에서 번쩍한다
|
|
if (fadeIn)
|
|
{
|
|
SetStyle(fromT, fromA);
|
|
ApplyState();
|
|
}
|
|
|
|
await Animate(fromT, toT, fromA, toA, gen);
|
|
if (gen != _gen) return; // 도중에 새 연출이 이어받음 — 뒷정리는 그쪽이 한다
|
|
|
|
// 다 사라진 뒤에 실제로 감춘다 (먼저 감추면 퇴장 연출이 보이지 않는다)
|
|
if (!fadeIn)
|
|
{
|
|
_visible = false;
|
|
ApplyState();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 오브젝트 파괴/씬 전환 — 조용히 종료
|
|
}
|
|
finally
|
|
{
|
|
// 내 세대일 때만 제자리로 — 이미 다음 연출이 시작됐다면 그쪽이 처리한다
|
|
if (gen == _gen) ResetStyle();
|
|
}
|
|
}
|
|
|
|
// fromT→toT(px), fromA→toA(불투명도)로 부드럽게 이동
|
|
private async Awaitable Animate(Vector2 fromT, Vector2 toT, float fromA, float toA, int gen)
|
|
{
|
|
float t = 0f;
|
|
while (t < _fadeDuration)
|
|
{
|
|
if (gen != _gen || _image == null) return;
|
|
|
|
t += Time.deltaTime;
|
|
float k = Mathf.Clamp01(t / _fadeDuration);
|
|
float e = k * k * (3f - 2f * k); // SmoothStep — 끝에서 감속해 무게감이 생긴다
|
|
SetStyle(Vector2.Lerp(fromT, toT, e), Mathf.Lerp(fromA, toA, e));
|
|
|
|
await Awaitable.NextFrameAsync(destroyCancellationToken);
|
|
}
|
|
SetStyle(toT, toA);
|
|
}
|
|
|
|
private void SetStyle(Vector2 translate, float alpha)
|
|
{
|
|
if (_image == null) return;
|
|
_image.style.translate = new Translate(translate.x, translate.y);
|
|
_image.style.opacity = alpha;
|
|
}
|
|
|
|
private void ResetStyle() => SetStyle(Vector2.zero, 1f);
|
|
|
|
// 캐시된 상태를 실제 요소에 반영한다. 요소가 아직 없으면(리로드 콜백 전) 조용히 넘어가고,
|
|
// 콜백이 오면 같은 함수로 복원된다.
|
|
private void ApplyState()
|
|
{
|
|
if (_image != null)
|
|
_image.sprite = _visible ? _sprite : null;
|
|
|
|
if (_root != null)
|
|
_root.style.display = _visible ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
}
|