2026-07-05 대화시스템 수정

This commit is contained in:
2026-07-05 19:51:17 +09:00
parent 8e992fe35d
commit d340446b19
22 changed files with 1301 additions and 91 deletions

View File

@@ -7,30 +7,31 @@
[RequireComponent(typeof(CharacterVoiceObject))]
public class DialogPlayer : MonoBehaviour
{
// 대화 후보 하나. 리스트에서 위에 있을수록 우선순위가 높다.
// (스토리 대화를 위에, 조건 없는 기본 잡담을 맨 아래에 두는 것을 권장)
[System.Serializable]
public struct RegionGroup
public struct DialogEntry
{
public string Region; // 영역 이름 (NPC마다 자유롭게 지정 — 그룹 이름과 무관)
public DialogGroup Group;
public DialogCondition Condition;
[Tooltip("켜면 완료 후에도 반복 재생 가능(잡담용). 끄면 1회성(스토리 대화)")]
public bool Repeatable;
[Tooltip("이 대화를 처음 완료하면 메인 진행도 +N (필수 대화가 아니면 0)")]
[Min(0)] public int ProgressOnComplete;
}
// 마지막 선택지 코드(LastChoiceCode)를 인자로 넘기는 UnityEvent (인스펙터 노출용 구체 타입)
[System.Serializable]
public class ChoiceCodeEvent : UnityEvent<string> { }
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트. 인자로 LastChoiceCode가 전달됨.
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트.
[System.Serializable]
public struct NodeEvent
{
public string Key;
public ChoiceCodeEvent Event;
public UnityEvent Event;
}
[Tooltip("영역 이름 ↔ 그 영역에서 재생할 DialogGroup")]
[SerializeField] private List<RegionGroup> _regionGroups;
[Header("Region")]
[SerializeField] private string _currentRegion; // 현재 영역 이름. DialogRegion 트리거가 갱신
[Tooltip("이 NPC의 대화 후보들. 위에서부터 조건을 검사해 첫 번째로 만족하는 대화를 재생")]
[SerializeField] private List<DialogEntry> _dialogs = new();
[Header("Dialog HUD Placement")] // 씬에서 캐릭터 위치/주변(벽 등)에 맞춰 조절
[SerializeField] private float _hudChestHeight = 1.2f; // 화자 발 기준 가슴 높이
@@ -41,7 +42,7 @@ public struct NodeEvent
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
[SerializeField] private List<NodeEvent> _nodeEvents = new();
private Dictionary<string, DialogGroup> _regionMap;
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
private Animator _animator;
private int _initialGestureHash;
private int _initialExpressionHash;
@@ -49,17 +50,9 @@ public struct NodeEvent
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
public bool IsPlaying { get; private set; }
// 마지막으로 고른 선택지 (인덱스/코드). DialogVariables에도 lastChoiceIndex / lastChoiceCode 로 저장됨
public int LastChoiceIndex { get; private set; } = -1;
public string LastChoiceCode { get; private set; }
public event Action<int, string> OnChoiceSelected; // (index, code)
private void Awake()
{
_regionMap = new Dictionary<string, DialogGroup>();
foreach (var e in _regionGroups)
if (e.Group != null) _regionMap[e.Region] = e.Group;
_voice = GetComponent<CharacterVoiceObject>();
_animator = GetComponentInChildren<Animator>();
if (_animator != null)
@@ -74,38 +67,38 @@ private void Awake()
}
public async Awaitable Play()
{
var region = ResolveRegion();
if (region != null)
await Play(region);
}
// 현재 영역. 영역이 없거나 매칭 그룹이 없으면 리스트 첫 항목으로 폴백.
private string ResolveRegion()
{
if (!string.IsNullOrEmpty(_currentRegion) && _regionMap.ContainsKey(_currentRegion))
return _currentRegion;
return _regionGroups.Count > 0 ? _regionGroups[0].Region : null;
}
// 영역 전환 (DialogRegion 트리거가 호출). 다음 Play()부터 해당 영역 대화가 재생됨.
public void SetRegion(string region) => _currentRegion = region;
public string CurrentRegion => _currentRegion;
public async Awaitable Play(string region)
{
if (IsPlaying) return;
if (!_regionMap.TryGetValue(region, out var group))
int index = FindPlayableIndex();
if (index < 0)
{
Debug.LogWarning($"[DialogPlayer] 영역 대화 없음: {region}");
Debug.Log($"[DialogPlayer] 조건에 맞는 대화 없음: {name}");
return;
}
await PlayEntry(_dialogs[index]);
}
// 리스트 순서 = 우선순위. 조건을 만족하고 (반복 가능하거나 아직 안 한) 첫 대화.
private int FindPlayableIndex()
{
for (int i = 0; i < _dialogs.Count; i++)
{
var entry = _dialogs[i];
if (entry.Group == null) continue;
if (!entry.Repeatable && StoryState.IsDialogCompleted(entry.Group.name)) continue;
if (entry.Condition != null && !entry.Condition.IsMet(_voice.Character)) continue;
return i;
}
return -1;
}
private async Awaitable PlayEntry(DialogEntry entry)
{
IsPlaying = true;
try
{
var node = group.StartNode;
var node = entry.Group.StartNode;
while (node != null)
{
await PlayNode(node);
@@ -121,6 +114,15 @@ public async Awaitable Play(string region)
node = node.Next;
}
}
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
bool firstTime = StoryState.MarkDialogCompleted(entry.Group.name);
if (firstTime && entry.ProgressOnComplete > 0)
StoryState.MainProgress += entry.ProgressOnComplete;
StoryState.Save();
Debug.Log($"[DialogPlayer] 대화 종료: {entry.Group.name}");
}
finally
{
@@ -130,8 +132,6 @@ public async Awaitable Play(string region)
RestoreDefaultAnimations();
RestoreRotations();
}
Debug.Log("[DialogPlayer] 대화 종료");
}
private void RestoreDefaultAnimations()
@@ -242,37 +242,31 @@ private void RaiseNodeEvent(string key)
{
if (string.IsNullOrEmpty(key)) return;
foreach (var e in _nodeEvents)
if (e.Key == key) e.Event?.Invoke(LastChoiceCode); // 마지막 선택지 코드를 인자로 전달
if (e.Key == key) e.Event?.Invoke();
}
// 선택 결과 기록: 인덱스/코드를 프로퍼티 + DialogVariables에 저장하고 이벤트 발행
// 선택 기록: 선택지 Code를 StoryState에 영구 기록 (대화 활성화 조건 판정에 사용)
private void RecordChoice(DialogNode node, int index)
{
string code = (node.Choices != null && index >= 0 && index < node.Choices.Count)
? node.Choices[index].Code : null;
code = DialogVariables.Format(code); // {token} 치환 → 동적으로 생성된 코드 반영
LastChoiceIndex = index;
LastChoiceCode = code;
DialogVariables.Set("lastChoiceIndex", index.ToString());
if (!string.IsNullOrEmpty(code))
DialogVariables.Set("lastChoiceCode", code);
OnChoiceSelected?.Invoke(index, code);
StoryState.RecordChoice(code);
}
private async Awaitable<int> WaitForChoice(DialogNode node)
{
//선택을 기다리는 함수 수정해서 사용할것
if (ChoiceHud.Instance == null)
{
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
return 0;
}
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
}
// 대화 진행 입력(OnDialogNext = VR B버튼) 한 번을 대기