From 519e351fa5ea221c6a2411d3254936709aa6a834 Mon Sep 17 00:00:00 2001 From: nakjun Date: Thu, 30 Jul 2026 13:20:13 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=ED=99=94=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/01_Scenes/TestScene.unity | 4 +- .../Communication/Dialog/DialogPlayer.cs | 29 +- .../Voice/CharacterVoiceObject.cs | 14 +- Assets/02_Scripts/Managers/LocationManager.cs | 11 + Assets/02_Scripts/Story/StoryBeat.cs | 7 +- Assets/02_Scripts/Story/StoryDatabase.cs | 11 + .../Communication/Dialog/TypewriterStyle.cs | 11 + .../02_Scripts/_UI/Communication/DialogHud.cs | 39 +- Assets/02_Scripts/_UI/DialogMarkup.cs | 54 ++ Assets/02_Scripts/_UI/DialogMarkup.cs.meta | 2 + Assets/02_Scripts/_UI/TextGlow.cs | 58 ++ Assets/02_Scripts/_UI/TextGlow.cs.meta | 2 + Assets/07_Data/DialogGraph/TestDescDialog.dlg | 539 ++++++++++++++++++ .../DialogGraph/TestDescDialog.dlg.meta | 10 + Assets/07_Data/StoryDatabase.asset | 4 +- 15 files changed, 778 insertions(+), 17 deletions(-) create mode 100644 Assets/02_Scripts/_UI/DialogMarkup.cs create mode 100644 Assets/02_Scripts/_UI/DialogMarkup.cs.meta create mode 100644 Assets/02_Scripts/_UI/TextGlow.cs create mode 100644 Assets/02_Scripts/_UI/TextGlow.cs.meta create mode 100644 Assets/07_Data/DialogGraph/TestDescDialog.dlg create mode 100644 Assets/07_Data/DialogGraph/TestDescDialog.dlg.meta diff --git a/Assets/01_Scenes/TestScene.unity b/Assets/01_Scenes/TestScene.unity index 110e5b8..20cb13b 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:abccd4ada0664ca3d00ecaa3a0f8a4d1b7a54100b225540882161489af785313 -size 23502 +oid sha256:ab572ec364cfc41966a65a731a9e9902485717eb39c9764fad510a58309d25e9 +size 24810 diff --git a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs index 17a1268..a6a27c7 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs @@ -3,16 +3,23 @@ using UnityEngine.InputSystem; using UnityEngine; -[RequireComponent(typeof(CharacterVoiceObject))] public class DialogPlayer : MonoBehaviour { // 대화 후보는 인스펙터가 아니라 StoryDatabase에서 온다 — // 현재 장소(LocationManager.Current) + 이 캐릭터의 비트 중 조건을 만족하는 것들. // 여럿이면 선택 메뉴가 뜨고, DB 목록에서 위에 있을수록 우선순위가 높다. + // + // CharacterVoiceObject는 선택이다: + // 있으면 → NPC 플레이어. 그 캐릭터의 비트(말을 걸어야 시작되는 대화)를 재생한다. + // 없으면 → 씬 플레이어. Character가 빈 "장소 비트"를 재생한다 (서술·진입 연출 등). + // LocationManager가 장소 입장 직후 자동으로 호출한다. - private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상) + private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (없으면 씬 플레이어) private Animator _animator; + // 이 플레이어가 담당하는 캐릭터. 씬 플레이어는 null이고, 그게 곧 "장소 비트" 조회 키가 된다. + private CharacterData OwnerCharacter => _voice != null ? _voice.Character : null; + // 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원. // (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다) private readonly Dictionary _touchedAnimators = new(); @@ -61,7 +68,12 @@ private void OnDestroy() if (_entryInProgress == this) _entryInProgress = null; } - public async Awaitable Play() + // 장소 비트를 자동으로 시작한다 (LocationManager가 장소 입장 직후 호출). + // 선택 메뉴를 띄우지 않는다 — 메뉴는 플레이어가 "말을 건" 경우의 UI다. + public void PlayAuto() => _ = Play(autoSelect: true); + + // autoSelect가 true면 후보가 여럿이어도 메뉴 없이 최상단(우선순위 1위) 비트를 바로 재생한다. + public async Awaitable Play(bool autoSelect = false) { if (IsPlaying) return; @@ -85,7 +97,9 @@ public async Awaitable Play() return; } - StoryBeat beat = playable.Count == 1 ? playable[0] : await SelectBeat(playable); + StoryBeat beat = playable.Count == 1 || autoSelect + ? playable[0] + : await SelectBeat(playable); if (beat == null) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등) _entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시 @@ -153,7 +167,7 @@ private List FindPlayableBeats() Debug.LogWarning($"[DialogPlayer] LocationManager 또는 StoryDatabase가 없음: {name}"); return new List(); } - return lm.Database.GetPlayableBeats(lm.Current, _voice.Character); + return lm.Database.GetPlayableBeats(lm.Current, OwnerCharacter); } // 재생 가능한 대화가 여럿일 때 DialogEnterHud로 플레이어에게 고르게 한다. 취소되면 null. @@ -310,7 +324,7 @@ private async Awaitable PlayNode(DialogNode node) // 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC if (node.Affection != 0) { - var affectionTarget = node.Speaker != null ? node.Speaker : _voice.Character; + var affectionTarget = node.Speaker != null ? node.Speaker : OwnerCharacter; StoryManager.Instance.AddAffection(affectionTarget, node.Affection); } @@ -380,7 +394,7 @@ private bool IsAffectionMet(DialogNode node) var req = requirements[i]; if (req == null) continue; - var target = req.Character != null ? req.Character : _voice.Character; + var target = req.Character != null ? req.Character : OwnerCharacter; group &= req.IsMet(story.GetAffection(target)); // 다음 연결자가 OR이거나 마지막이면 AND 그룹을 닫고 OR로 합친다 @@ -556,6 +570,7 @@ private async Awaitable WaitForAdvanceInput() //테스트용 — 캐릭터를 클릭하면 대화 시작 (Collider2D가 있어야 잡힌다) private void Update() { + if (_voice == null) return; //씬 플레이어(장소 비트 담당)는 클릭 대상이 아니다 if (Mouse.current == null) return; if (!Mouse.current.leftButton.wasPressedThisFrame) return; if (Camera.main == null) return; diff --git a/Assets/02_Scripts/Communication/Voice/CharacterVoiceObject.cs b/Assets/02_Scripts/Communication/Voice/CharacterVoiceObject.cs index e07abd7..c9fdc7e 100644 --- a/Assets/02_Scripts/Communication/Voice/CharacterVoiceObject.cs +++ b/Assets/02_Scripts/Communication/Voice/CharacterVoiceObject.cs @@ -8,8 +8,18 @@ public class CharacterVoiceObject : MonoBehaviour private static readonly Dictionary _registry = new(); - private void OnEnable() => _registry[Character] = this; - private void OnDisable() => _registry.Remove(Character); + // Character를 비워 두면 Dictionary 널 키로 예외가 나므로 등록하지 않는다. + // (화자 없는 대화는 CharacterVoiceObject 자체를 붙이지 않는 씬 플레이어로 처리한다) + private void OnEnable() + { + if (Character != null) _registry[Character] = this; + else Debug.LogWarning($"[CharacterVoiceObject] Character가 비어 있어 등록하지 않음: {name}"); + } + + private void OnDisable() + { + if (Character != null) _registry.Remove(Character); + } public static CharacterVoiceObject Find(CharacterData data) => _registry.TryGetValue(data, out var obj) ? obj : null; diff --git a/Assets/02_Scripts/Managers/LocationManager.cs b/Assets/02_Scripts/Managers/LocationManager.cs index 4961180..1726409 100644 --- a/Assets/02_Scripts/Managers/LocationManager.cs +++ b/Assets/02_Scripts/Managers/LocationManager.cs @@ -14,6 +14,11 @@ public class LocationManager : MonoBehaviour [Tooltip("게임 시작 시 입장할 장소")] [SerializeField] private LocationData _startLocation; + [Tooltip("장소 비트(StoryBeat의 Character를 비워 둔 항목)를 재생할 DialogPlayer. " + + "CharacterVoiceObject 없는 씬 오브젝트에 DialogPlayer만 붙여 연결한다. " + + "비우면 장소 자동 진행을 쓰지 않는다")] + [SerializeField] private DialogPlayer _scenePlayer; + public StoryDatabase Database => _database; public LocationData Current { get; private set; } @@ -66,6 +71,12 @@ public void MoveTo(LocationData location) ? Instantiate(location.Prefab, _locationRoot) : null; RefreshSlots(); + + // 장소 비트 자동 진행 — 프리팹 생성과 슬롯 갱신이 끝난 뒤에 시작한다. + // 조건을 만족하는 장소 비트가 없으면 아무 일도 일어나지 않는다. + // 반복 재생을 막으려면 그 비트의 OnceOnly를 켜 둘 것. + if (_scenePlayer != null) + _scenePlayer.PlayAuto(); } // 대화로 진행도/트리거가 바뀌면 등장 캐릭터도 달라질 수 있다 diff --git a/Assets/02_Scripts/Story/StoryBeat.cs b/Assets/02_Scripts/Story/StoryBeat.cs index 2e6f536..81ea8ac 100644 --- a/Assets/02_Scripts/Story/StoryBeat.cs +++ b/Assets/02_Scripts/Story/StoryBeat.cs @@ -12,7 +12,8 @@ public class StoryBeat [Tooltip("이 대화가 일어나는 장소")] public LocationData Location; - [Tooltip("대화를 거는 캐릭터")] + [Tooltip("대화를 거는 캐릭터. 비우면 '장소 비트' — 말을 거는 대상 없이 " + + "이 장소에 입장하면 자동으로 재생된다 (서술·챕터 진입 연출 등)")] public CharacterData Character; [Tooltip("재생할 대화 그래프 (.dlg = DialogGroup)")] @@ -21,6 +22,10 @@ public class StoryBeat [Tooltip("활성 조건 (진행도/호감도 범위 + 트리거)")] public DialogCondition Condition = new(); + [Tooltip("켜면 한 번 완료한 뒤에는 다시 후보에 오르지 않는다. " + + "장소 비트처럼 입장할 때마다 반복되면 안 되는 대화에 필수")] + public bool OnceOnly; + [Tooltip("이 대화를 처음 완료하면 메인 진행도 +N (필수 대화가 아니면 0)")] [Min(0)] public int ProgressOnComplete; diff --git a/Assets/02_Scripts/Story/StoryDatabase.cs b/Assets/02_Scripts/Story/StoryDatabase.cs index 7d681f7..4b2f63a 100644 --- a/Assets/02_Scripts/Story/StoryDatabase.cs +++ b/Assets/02_Scripts/Story/StoryDatabase.cs @@ -42,10 +42,21 @@ public StoryBeat FindBeat(DialogGroup group) return null; } + // character가 null이면 "장소 비트"(Character를 비워 둔 항목)를 찾는다 — + // 말을 거는 대상 없이 장소 입장 시 자동 재생되는 대화다. private static bool IsPlayable(StoryBeat beat, LocationData location, CharacterData character) { if (beat.Location != location || beat.Character != character || beat.Group == null) return false; + + // 1회성 비트는 완료 이력이 있으면 후보에서 빠진다 (장소 비트가 입장마다 반복되는 것 방지) + if (beat.OnceOnly) + { + var story = StoryManager.Instance; + if (story != null && story.IsDialogCompleted(beat.Group.name)) + return false; + } + return beat.Condition == null || beat.Condition.IsMet(character); } } diff --git a/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs index 640aac6..482f821 100644 --- a/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs +++ b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs @@ -24,6 +24,17 @@ public class TypewriterStyle : ScriptableObject public Color TextColor = Color.white; + [Header("Emphasis")] + [Tooltip("대사에 [[강조]] 로 감싼 부분이 이 색으로 표시된다")] + public Color EmphasisColor = new Color(1f, 0.23f, 0.19f); + + [Tooltip("강조 단어가 밝아지며 맥동하는 세기. 0이면 맥동 없이 색만 바뀐다 " + + "(0이면 매 프레임 다시 그리지 않으므로 비용도 없다)")] + [Range(0f, 1f)] public float GlowStrength = 0.45f; + + [Tooltip("맥동 속도 (초당 왕복 횟수)")] + [Min(0.01f)] public float GlowSpeed = 1.5f; + [Header("Sound")] [Tooltip("타이핑되는 동안 루프로 재생할 사운드. 비우면 무음. " + "글자마다 개별 재생이 아니라, 타이핑이 끝나거나 스킵되면 멈춘다")] diff --git a/Assets/02_Scripts/_UI/Communication/DialogHud.cs b/Assets/02_Scripts/_UI/Communication/DialogHud.cs index e518fb1..e20d9ed 100644 --- a/Assets/02_Scripts/_UI/Communication/DialogHud.cs +++ b/Assets/02_Scripts/_UI/Communication/DialogHud.cs @@ -28,6 +28,10 @@ public class DialogHud : MonoBehaviour private string _speakerText = string.Empty; private Typewriter _typewriter; + // 강조 단어([[...]])의 맥동 연출. 지금 대사에 강조가 있을 때만 매 프레임 다시 그린다. + private readonly TextGlow _glow = new(); + private bool _hasEmphasis; + // 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴) private int _uiVersion = -1; @@ -66,9 +70,21 @@ private void OnUIReload(PanelRenderer panelRenderer, VisualElement root, int ver _speakerName = root.Q