증거품 UI

This commit is contained in:
2026-07-30 16:26:39 +09:00
parent 519e351fa5
commit 23e93a67f8
127 changed files with 55106 additions and 4 deletions

View File

@@ -31,6 +31,10 @@ private void Update()
// 무장 안 된 순간에는 클릭을 아예 보지 않는다
if (!HiddenBranchResolver.Armed) return;
// 증거품창이 떠 있으면 월드 클릭을 받지 않는다 — 창이 전체 화면 오버레이라서,
// 창 위 클릭이 이 존까지 뚫고 들어와 엉뚱한 분기를 여는 것을 막는다
if (ItemHud.Instance != null && ItemHud.Instance.IsOpen) return;
if (Mouse.current == null || !Mouse.current.leftButton.wasPressedThisFrame) return;
var cam = Camera.main;

View File

@@ -10,7 +10,10 @@ public class InputManager : MonoBehaviour, GameInput.IPlayerActions
private GameInput _input;
public event Action OnDialogNext_Event;
public event Action OnDialogNext_Event;
public event Action OnEvidence_Event;
public event Action OnEvidencePrev_Event;
public event Action OnEvidenceNext_Event;
private void Awake()
{
@@ -37,4 +40,22 @@ public void OnDialogNext(InputAction.CallbackContext ctx)
if (ctx.phase == InputActionPhase.Started)
OnDialogNext_Event?.Invoke();
}
public void OnEvidence(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)
OnEvidence_Event?.Invoke();
}
public void OnEvidencePrev(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)
OnEvidencePrev_Event?.Invoke();
}
public void OnEvidenceNext(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)
OnEvidenceNext_Event?.Invoke();
}
}

View File

@@ -25,6 +25,11 @@ private struct InitialAffection
"진행도 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")]
[SerializeField] private List<InitialAffection> _initialAffections = new();
[Tooltip("플레이 시작 시 소지 아이템 — 이 씬만 직접 열어 테스트할 때 앞 과정 없이 증거품을 들고 시작하게. " +
"다른 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다. " +
"실제 게임에서의 획득은 AddItem(터치존·버튼 UnityEvent 등)으로")]
[SerializeField] private List<ItemData> _initialItems = new();
private StoryState _state = new();
// 상태가 바뀔 때마다 발행 (호감도 게이지 등 UI 갱신용)
@@ -41,6 +46,11 @@ private void Awake()
foreach (var init in _initialAffections)
if (init.Character != null)
_state.Affection[IdOf(init.Character)] = init.Amount;
// 테스트용 초기 소지 아이템
foreach (var item in _initialItems)
if (item != null && !_state.Items.Contains(ItemIdOf(item)))
_state.Items.Add(ItemIdOf(item));
}
else
{
@@ -100,6 +110,30 @@ public void SetTrigger(string id)
Changed?.Invoke();
}
// ── 아이템(증거품) ───────────────────────────────────────────
public IReadOnlyList<string> ItemIds => _state.Items;
public bool HasItem(ItemData item) => item != null && _state.Items.Contains(ItemIdOf(item));
// UnityEvent 연결용 (대화 터치존·월드 버튼 등에서 증거품 획득). 중복 획득은 무시된다.
public void AddItem(ItemData item)
{
if (item == null) return;
string id = ItemIdOf(item);
if (_state.Items.Contains(id)) return;
_state.Items.Add(id);
Changed?.Invoke();
}
public void RemoveItem(ItemData item)
{
if (item == null || !_state.Items.Remove(ItemIdOf(item))) return;
Changed?.Invoke();
}
// ItemData의 Id를 키로 사용 (비어있으면 에셋 이름) — 캐릭터 IdOf와 같은 규칙
private static string ItemIdOf(ItemData i) => string.IsNullOrEmpty(i.Id) ? i.name : i.Id;
// ── 선택 이력 ────────────────────────────────────────────────
public bool HasChosen(string code) => _state.ChosenCodes.Contains(code);

View File

@@ -11,6 +11,7 @@ public class StoryState
public readonly HashSet<string> CompletedDialogs = new(); // 완료한 DialogGroup 이름
public readonly HashSet<string> ChosenCodes = new(); // 골랐던 선택지 Code
public readonly HashSet<string> Triggers = new(); // 켜진 트리거 Id (대화 조건 검사용)
public readonly List<string> Items = new(); // 가진 아이템 Id (획득 순서 유지 — 증거품창 표시 순서)
public void Clear()
{
@@ -19,6 +20,7 @@ public void Clear()
CompletedDialogs.Clear();
ChosenCodes.Clear();
Triggers.Clear();
Items.Clear();
}
// ── JSON 변환 ────────────────────────────────────────────────
@@ -32,6 +34,7 @@ private class JsonData
public List<string> CompletedDialogs = new();
public List<string> ChosenCodes = new();
public List<string> Triggers = new();
public List<string> Items = new();
}
public string ToJson()
@@ -45,6 +48,7 @@ public string ToJson()
data.CompletedDialogs.AddRange(CompletedDialogs);
data.ChosenCodes.AddRange(ChosenCodes);
data.Triggers.AddRange(Triggers);
data.Items.AddRange(Items);
return JsonUtility.ToJson(data, prettyPrint: true);
}
@@ -60,6 +64,7 @@ public static StoryState FromJson(string json)
state.CompletedDialogs.UnionWith(data.CompletedDialogs);
state.ChosenCodes.UnionWith(data.ChosenCodes);
state.Triggers.UnionWith(data.Triggers ?? new List<string>());
state.Items.AddRange(data.Items ?? new List<string>());
return state;
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d08e6466f285165448cc7a8751239a65
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
using UnityEngine;
// 증거품/아이템 하나 (역전재판의 법정기록 항목).
//
// Id가 곧 "제시 키"다 — 대화 노드의 HiddenBranch.Key와 같은 값을 쓰면,
// 그 노드가 재생되는 동안 증거품창에서 이 아이템을 클릭했을 때 해당 히든 분기가 열린다.
// (DialogTouchZone의 부위 키와 완전히 같은 통로를 쓴다)
[CreateAssetMenu(menuName = "Item/Item Data")]
public class ItemData : ScriptableObject
{
[Tooltip("고유 Id — 저장 파일과 히든 분기 Key에 쓰인다. 비우면 에셋 이름")]
public string Id;
[Tooltip("증거품창에 표시할 이름")]
public string Name;
[Tooltip("증거품창에 표시할 그림")]
public Sprite Icon;
[Tooltip("증거품창에 표시할 설명")]
[TextArea(2, 5)] public string Description;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70ed680488473ed44ad380007f918f67

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
using UnityEngine;
// 게임에 존재하는 모든 아이템의 목록 (StoryDatabase와 같은 "단일 원본" 역할).
// 저장 파일에는 Id 문자열만 남으므로, Id → ItemData 복원은 이 에셋이 담당한다.
// 게임에 하나만 만들어 ItemHud에 꽂아 쓴다.
[CreateAssetMenu(menuName = "Item/Item Database")]
public class ItemDatabase : ScriptableObject
{
[SerializeField] private List<ItemData> _items = new();
public IReadOnlyList<ItemData> Items => _items;
public ItemData FindById(string id)
{
if (string.IsNullOrEmpty(id)) return null;
foreach (var item in _items)
{
if (item == null) continue;
string itemId = string.IsNullOrEmpty(item.Id) ? item.name : item.Id;
if (itemId == id) return item;
}
return null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6c0a1fdc73b78049aa22e3aa4e84290

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0c3122efb3a7a304ca03b2bfaa0fad18
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,267 @@
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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6d73c406f0f990445acb325393d12110