diff --git a/Assets/01_Scenes/TestScene.unity b/Assets/01_Scenes/TestScene.unity index 20cb13b..c5f4569 100644 --- a/Assets/01_Scenes/TestScene.unity +++ b/Assets/01_Scenes/TestScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab572ec364cfc41966a65a731a9e9902485717eb39c9764fad510a58309d25e9 -size 24810 +oid sha256:3d5b8efdb03418fb6f6e97810e8d305e9415246c7a46064cb27689d6e9f9ad26 +size 28648 diff --git a/Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs b/Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs index dd2f8f2..87f5d2e 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs @@ -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; diff --git a/Assets/02_Scripts/Managers/InputManager.cs b/Assets/02_Scripts/Managers/InputManager.cs index 6361ae3..27b86dc 100644 --- a/Assets/02_Scripts/Managers/InputManager.cs +++ b/Assets/02_Scripts/Managers/InputManager.cs @@ -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(); + } } diff --git a/Assets/02_Scripts/Managers/StoryManager.cs b/Assets/02_Scripts/Managers/StoryManager.cs index 84a2c7e..f8c3dbc 100644 --- a/Assets/02_Scripts/Managers/StoryManager.cs +++ b/Assets/02_Scripts/Managers/StoryManager.cs @@ -25,6 +25,11 @@ private struct InitialAffection "진행도 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")] [SerializeField] private List _initialAffections = new(); + [Tooltip("플레이 시작 시 소지 아이템 — 이 씬만 직접 열어 테스트할 때 앞 과정 없이 증거품을 들고 시작하게. " + + "다른 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다. " + + "실제 게임에서의 획득은 AddItem(터치존·버튼 UnityEvent 등)으로")] + [SerializeField] private List _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 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); diff --git a/Assets/02_Scripts/Story/StoryState.cs b/Assets/02_Scripts/Story/StoryState.cs index 42ad2a8..d02cb8e 100644 --- a/Assets/02_Scripts/Story/StoryState.cs +++ b/Assets/02_Scripts/Story/StoryState.cs @@ -11,6 +11,7 @@ public class StoryState public readonly HashSet CompletedDialogs = new(); // 완료한 DialogGroup 이름 public readonly HashSet ChosenCodes = new(); // 골랐던 선택지 Code public readonly HashSet Triggers = new(); // 켜진 트리거 Id (대화 조건 검사용) + public readonly List 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 CompletedDialogs = new(); public List ChosenCodes = new(); public List Triggers = new(); + public List 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()); + state.Items.AddRange(data.Items ?? new List()); return state; } } diff --git a/Assets/02_Scripts/_Data/Item.meta b/Assets/02_Scripts/_Data/Item.meta new file mode 100644 index 0000000..c95e297 --- /dev/null +++ b/Assets/02_Scripts/_Data/Item.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d08e6466f285165448cc7a8751239a65 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/_Data/Item/ItemData.cs b/Assets/02_Scripts/_Data/Item/ItemData.cs new file mode 100644 index 0000000..17aa2e4 --- /dev/null +++ b/Assets/02_Scripts/_Data/Item/ItemData.cs @@ -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; +} diff --git a/Assets/02_Scripts/_Data/Item/ItemData.cs.meta b/Assets/02_Scripts/_Data/Item/ItemData.cs.meta new file mode 100644 index 0000000..fd7b8d9 --- /dev/null +++ b/Assets/02_Scripts/_Data/Item/ItemData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 70ed680488473ed44ad380007f918f67 \ No newline at end of file diff --git a/Assets/02_Scripts/_Data/Item/ItemDatabase.cs b/Assets/02_Scripts/_Data/Item/ItemDatabase.cs new file mode 100644 index 0000000..832be96 --- /dev/null +++ b/Assets/02_Scripts/_Data/Item/ItemDatabase.cs @@ -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 _items = new(); + + public IReadOnlyList 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; + } +} diff --git a/Assets/02_Scripts/_Data/Item/ItemDatabase.cs.meta b/Assets/02_Scripts/_Data/Item/ItemDatabase.cs.meta new file mode 100644 index 0000000..fb9c7dc --- /dev/null +++ b/Assets/02_Scripts/_Data/Item/ItemDatabase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6c0a1fdc73b78049aa22e3aa4e84290 \ No newline at end of file diff --git a/Assets/02_Scripts/_UI/Item.meta b/Assets/02_Scripts/_UI/Item.meta new file mode 100644 index 0000000..d5d5ffa --- /dev/null +++ b/Assets/02_Scripts/_UI/Item.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c3122efb3a7a304ca03b2bfaa0fad18 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/_UI/Item/ItemHud.cs b/Assets/02_Scripts/_UI/Item/ItemHud.cs new file mode 100644 index 0000000..fca9f3d --- /dev/null +++ b/Assets/02_Scripts/_UI/Item/ItemHud.cs @@ -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 _owned = new(); + + // 창이 떠 있는가 (DialogTouchZone이 창 뒤 월드 클릭을 무시하는 데 쓴다) + public bool IsOpen => _visible; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + + _panelRenderer = GetComponent(); + _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("Body"); + _itemNode = root.Q(className: "ItemNode"); + _image = root.Q("Image"); + _label = root.Q