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

@@ -0,0 +1,11 @@
using UnityEngine;
// 인스펙터 이벤트에서 호감도를 조작하기 위한 헬퍼.
// 예: 선택지 뒤 노드에 EventKey를 걸고 → DialogPlayer의 NodeEvent →
// 이 컴포넌트의 Add(5)를 연결하면 그 선택을 했을 때 호감도가 오른다.
public class AffectionModifier : MonoBehaviour
{
[SerializeField] private CharacterData _character;
public void Add(int delta) => StoryState.AddAffection(_character, delta);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 04fdb10aa14325a4c8767f7d3cce49de

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// DialogGroup 하나가 활성화되기 위한 조건 묶음. 모든 항목을 만족해야 한다(AND).
// 값이 0이거나 목록이 비어있으면 그 항목은 조건 없음으로 통과된다.
[Serializable]
public class DialogCondition
{
[Tooltip("메인 진행도가 이 값 이상이어야 함 (0 = 조건 없음)")]
[Min(0)] public int MinMainProgress;
[Tooltip("이 NPC의 호감도가 이 값 이상이어야 함 (0 = 조건 없음)")]
[Min(0)] public int MinAffection;
[Tooltip("먼저 완료했어야 하는 대화들 (전부 완료 필요)")]
public List<DialogGroup> RequiredDialogs = new();
[Tooltip("골랐어야 하는 선택지 Code들 (전부 필요)")]
public List<string> RequiredChoiceCodes = new();
// affectionTarget: 호감도 조건을 검사할 캐릭터 (보통 대화를 거는 NPC 자신)
public bool IsMet(CharacterData affectionTarget)
{
if (StoryState.MainProgress < MinMainProgress)
return false;
if (MinAffection > 0 && StoryState.GetAffection(affectionTarget) < MinAffection)
return false;
foreach (var group in RequiredDialogs)
if (group != null && !StoryState.IsDialogCompleted(group.name))
return false;
foreach (var code in RequiredChoiceCodes)
if (!string.IsNullOrEmpty(code) && !StoryState.HasChosen(code))
return false;
return true;
}
}

View File

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

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버튼) 한 번을 대기

View File

@@ -1,27 +0,0 @@
using UnityEngine;
// 영역 트리거. 이 콜라이더(isTrigger) 안으로 NPC(DialogPlayer 보유)가 들어오면
// 그 NPC의 현재 영역을 _regionKey로 전환한다.
// _regionKey는 해당 영역에서 재생할 DialogGroup의 이름과 일치해야 한다 (예: "Coast", "Hill").
//
// 주의: OnTriggerEnter가 동작하려면 들어오는 쪽(또는 트리거 쪽)에 Rigidbody가 있어야 하고,
// 이 오브젝트의 Collider는 Is Trigger여야 한다.
[RequireComponent(typeof(Collider))]
public class DialogRegion : MonoBehaviour
{
[SerializeField] private string _regionKey; // DialogGroup 이름과 일치
private void Reset()
{
// 컴포넌트 추가 시 편의상 트리거로 설정
var col = GetComponent<Collider>();
if (col != null) col.isTrigger = true;
}
private void OnTriggerEnter(Collider other)
{
var player = other.GetComponentInParent<DialogPlayer>();
if (player != null)
player.SetRegion(_regionKey);
}
}

View File

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

View File

@@ -22,7 +22,7 @@ public override void OnImportAsset(AssetImportContext ctx)
return;
}
// 메인 에셋: DialogGroup (이름은 파일명 기준 — DialogPlayer가 이름으로 조회)
// 메인 에셋: DialogGroup (이름은 파일명 기준 — StoryState 대화 이력의 키로 쓰임)
var groupName = Path.GetFileNameWithoutExtension(ctx.assetPath);
var group = ScriptableObject.CreateInstance<DialogGroup>();
group.name = groupName;