This commit is contained in:
2026-07-24 18:20:02 +09:00
commit 0b02bacb3d
177 changed files with 5172 additions and 0 deletions

49
.gitattributes vendored Normal file
View File

@@ -0,0 +1,49 @@
## Unity ##
*.cs diff=csharp text
*.cginc text
*.shader text
*.mat merge=unityyamlmerge eol=lf
*.physicsMaterial2D merge=unityyamlmerge eol=lf
*.physicMaterial merge=unityyamlmerge eol=lf
*.meta merge=unityyamlmerge eol=lf
*.controller merge=unityyamlmerge eol=lf
## git-lfs ##
*.anim filter=lfs diff=lfs merge=lfs -text
*.asset filter=lfs diff=lfs merge=lfs -text
*.prefab filter=lfs diff=lfs merge=lfs -text
*.unity filter=lfs diff=lfs merge=lfs -text
#Image
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ai filter=lfs diff=lfs merge=lfs -text
#Audio
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
#Video
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
#3D Object
*.FBX filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.blend filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
#ETC
*.a filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.tga filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.unitypackage filter=lfs diff=lfs merge=lfs -text
*.aif filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.rns filter=lfs diff=lfs merge=lfs -text
*.reason filter=lfs diff=lfs merge=lfs -text
*.lxo filter=lfs diff=lfs merge=lfs -text
*.bytes filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
*.cubemap filter=lfs diff=lfs merge=lfs -text

94
.gitignore vendored Normal file
View File

@@ -0,0 +1,94 @@
# ---> Unity
# This .gitignore file should be placed at the root of your Unity project directory
#
# Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore
#
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
/[Uu]ser[Ss]ettings/
# MemoryCaptures can get excessive in size.
# They also could contain extremely sensitive data
/[Mm]emoryCaptures/
# Recordings can get excessive in size
/[Rr]ecordings/
# Uncomment this line if you wish to ignore the asset store tools plugin
# /[Aa]ssets/AssetStoreTools*
# Autogenerated Jetbrains Rider plugin
/[Aa]ssets/Plugins/Editor/JetBrains*
# Visual Studio cache directory
.vs/
# IDE settings (자동 생성 — 사용자별 설정)
.vscode/
.idea/
# OS generated files
.DS_Store
Thumbs.db
desktop.ini
# Crash dumps
*.dmp
mono_crash*
# Gradle cache directory
.gradle/
# Autogenerated VS/MD/Consulo solution and project files
ExportedObj/
.consulo/
*.csproj
*.unityproj
*.sln
*.slnx
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
*.mdb.meta
# Unity3D generated file on crash reports
sysinfo.txt
# Builds
*.apk
*.aab
*.unitypackage
*.unitypackage.meta
*.app
# Crashlytics generated file
crashlytics-build.properties
# Packed Addressables
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
# Temporary auto-generated Android Assets
/[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
Assets/XR/AndroidXR/AndroidXRSettingsInitializer*
ProjectSettings/Packages/com.unity.learn.iet-framework/Settings.json
ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json
*.log

8
Assets/01_Scenes.meta Normal file
View File

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

Binary file not shown.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 99c9720ab356a0642a771bea13969a05
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Assets/02_Scripts.meta Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
using System;
using UnityEngine;
// 호감도 비교 연산자. (호감도 [연산자] Value)
public enum AffectionCompare
{
[InspectorName("≥")] AtLeast,
[InspectorName(">")] GreaterThan,
[InspectorName("≤")] AtMost,
[InspectorName("<")] LessThan,
[InspectorName("=")] Equal,
[InspectorName("≠")] NotEqual,
}
// 다음 조건과 묶는 방식. AND가 OR보다 우선순위가 높다 (A and B or C → (A and B) or C).
public enum AffectionJoin
{
[InspectorName("AND")] And,
[InspectorName("OR")] Or,
}
// 호감도 분기 노드가 검사하는 조건 하나. (캐릭터, 연산자, 값) + 다음 조건과의 연결자.
// 여러 개를 이어 붙여 조건식을 만든다 — DialogNode.AffectionRequirements 참고.
[Serializable]
public class AffectionRequirement
{
// 검사 대상 캐릭터. 비우면 대화 주인 NPC의 호감도를 본다.
public CharacterData Character;
// 호감도를 Value와 어떻게 비교할지
public AffectionCompare Compare = AffectionCompare.AtLeast;
// 비교 기준값
public int Value;
// 다음 조건과 묶는 방식 (마지막 조건에서는 무시된다)
public AffectionJoin JoinWithNext = AffectionJoin.And;
// 이 조건 하나의 성립 여부
public bool IsMet(int affection) => Compare switch
{
AffectionCompare.AtLeast => affection >= Value,
AffectionCompare.GreaterThan => affection > Value,
AffectionCompare.AtMost => affection <= Value,
AffectionCompare.LessThan => affection < Value,
AffectionCompare.Equal => affection == Value,
AffectionCompare.NotEqual => affection != Value,
_ => false,
};
}

View File

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

View File

@@ -0,0 +1,9 @@
using System;
[Serializable]
public class DialogChoice
{
public DialogNode DestinationNode;
public string ChoiceText;
public string Code; // 선택 시 기록/식별용 코드 (선택 입력, 영문 권장)
}

View File

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

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// DialogGroup 하나가 활성화되기 위한 조건 묶음. 모든 항목을 만족해야 한다(AND).
// 진행도/호감도는 범위(양끝 포함)로 지정한다. 기본 범위 = 조건 없음. 트리거 목록은 비워두면 조건 없음.
[Serializable]
public class DialogCondition : ISerializationCallbackReceiver
{
const int MaxProgressDefault = 9999;
const int MaxAffectionDefault = 100;
[Tooltip("메인 진행도가 이 범위 안이어야 함 (양끝 포함). 기본 0~9999 = 조건 없음")]
public IntRange MainProgress = new(0, MaxProgressDefault);
[Tooltip("이 NPC의 호감도가 이 범위 안이어야 함 (양끝 포함). 기본 0~100 = 조건 없음. " +
"예: 0~10 = 호감도가 낮을 때만, 50~100 = 호감도가 쌓인 뒤에만")]
public IntRange Affection = new(0, MaxAffectionDefault);
[Tooltip("켜져 있어야 하는 트리거 Id들 (전부 필요). StoryManager.SetTrigger로 켠다")]
public List<string> RequiredTriggerIds = new();
[Tooltip("켜지면 이 대화가 비활성화되는 트리거 Id들 (하나라도 켜졌으면 비활성화). " +
"예: 특정 사건 이후엔 더 못 보는 대사")]
public List<string> ExcludedTriggerIds = new();
// 인스펙터에서 리스트에 새로 추가된 항목은 필드 초기화식이 무시되고 전부 0으로 생성된다.
// 그대로면 범위가 0~0(사실상 잠긴 대화)이 되므로, 처음 만들어진 인스턴스에만 기본 범위를 넣어준다.
[SerializeField, HideInInspector] private bool _initialized = true;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize()
{
if (_initialized) return;
_initialized = true;
MainProgress = new IntRange(0, MaxProgressDefault);
Affection = new IntRange(0, MaxAffectionDefault);
}
// affectionTarget: 호감도 조건을 검사할 캐릭터 (보통 대화를 거는 NPC 자신)
public bool IsMet(CharacterData affectionTarget)
{
var story = StoryManager.Instance;
if (!MainProgress.Contains(story.MainProgress))
return false;
if (!Affection.Contains(story.GetAffection(affectionTarget)))
return false;
foreach (var id in RequiredTriggerIds)
if (!string.IsNullOrEmpty(id) && !story.HasTrigger(id))
return false;
foreach (var id in ExcludedTriggerIds)
if (!string.IsNullOrEmpty(id) && story.HasTrigger(id))
return false;
return true;
}
}

View File

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

View File

@@ -0,0 +1,55 @@
using UnityEngine;
using UnityEngine.Events;
// 대화 중 "건네기/터치" 제스처를 수행하는 오브젝트(꽃다발·김치·손 등)에 붙인다.
// 이 오브젝트가 NPC의 받는 존(_targetTag 태그의 트리거)에 닿으면, 자신의 GestureKey로 히든 분기를 발동시킨다.
//
// 어떤 아이템인지는 GestureKey로 구분된다:
// - 노드가 HiddenGestureKey="kimchi"로 무장 → 김치(GestureKey="kimchi")만 발동, 꽃다발("bouquet")은 무시.
// 그래서 "김치를 줘야 하는데 꽃다발을 주면 이벤트가 안 진행"이 자동으로 보장된다.
//
// 콜라이더가 있는 오브젝트에 붙일 것 (잡는 아이템은 보통 XRGrabInteractable이 있는 루트).
// 존은 무장 여부와 무관하게 항상 닿을 수 있지만, 무장 안 된 순간에 닿으면 아무 일도 안 일어난다.
public class DialogGestureItem : MonoBehaviour
{
[Tooltip("이 오브젝트의 제스처 키. 대화 노드의 HiddenGestureKey와 일치할 때만 발동 (예: kimchi, bouquet, slap)")]
[SerializeField] private string _gestureKey;
[Tooltip("이 태그를 가진 트리거(NPC 받는 존)에 닿아야 발동. 비우면 태그 무시(아무 트리거나)")]
[SerializeField] private string _targetTag = "GiveZone";
[Header("Consume (건넨 뒤 사라지게)")]
[Tooltip("켜면 발동 시 이 오브젝트가 사라진다(건넨 연출). 손처럼 사라지면 안 되는 건 꺼둘 것")]
[SerializeField] private bool _consumeOnGiven = true;
[Tooltip("사라지는 방식: 켜면 완전 파괴(Destroy), 끄면 비활성화(SetActive false — 재사용 가능)")]
[SerializeField] private bool _destroy = true;
[Tooltip("사라지기 직전 호출 (파티클·사운드 연출용)")]
[SerializeField] private UnityEvent _onConsumed;
private void OnTriggerEnter(Collider other)
{
if (!string.IsNullOrEmpty(_targetTag) && !other.CompareTag(_targetTag)) return;
// 노드가 이 키로 무장 중일 때만 true — 그때만 소비한다(엉뚱한 타이밍/엉뚱한 아이템은 안 사라짐).
if (!HiddenBranchResolver.Fire(_gestureKey)) return;
if (_consumeOnGiven)
Consume();
}
private void Consume()
{
// 스크립트가 자식 콜라이더에 붙어 있어도 아이템 전체(Rigidbody 루트)를 처리
var body = GetComponentInParent<Rigidbody>();
var target = body != null ? body.gameObject : gameObject;
_onConsumed?.Invoke();
if (_destroy)
Destroy(target); // XRGrabInteractable도 파괴 시 손에서 자동 해제됨
else
target.SetActive(false); // 비활성화도 XRI가 선택을 취소해 손에서 놓게 됨
}
}

View File

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

View File

@@ -0,0 +1,8 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Dialog Group")]
public class DialogGroup : ScriptableObject
{
public string DialogGroupName;
public DialogNode StartNode;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72ef984dbd5eb29498ece3d2dae297ea

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Dialog Node")]
public class DialogNode : ScriptableObject
{
[Header("Speaker")]
public CharacterData Speaker;
[Tooltip("비우면 Speaker의 이름 그대로, 채우면 이 이름으로 표시 (예: ???)")]
public string SpeakerNameOverride;
[Header("Content")]
[TextArea(2,5)] public string TalkText;
public GestureData Gesture;
public ExpressionData Expression;
public VoiceClip Voice;
// 진행은 항상 플레이어 입력(OnDialogNext)으로 넘어간다.
[Header("Presentation")]
public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀
[Header("Flow")]
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기
[Header("Hidden Branch")]
[Tooltip("이 대사가 재생되는 동안 특정 행동(제스처)을 하면 Next/선택지 대신 이 노드로 몰래 분기. " +
"비우면 히든 분기 없음 — 평소엔 존재하지 않는 것처럼 보인다")]
public DialogNode HiddenBranch;
[Tooltip("히든 분기를 여는 제스처 키 (아이템의 DialogGestureItem의 Gesture Key와 일치). " +
"예: kimchi, bouquet, slap. 비우면 아무 제스처 오브젝트나 발동 가능")]
public string HiddenGestureKey;
[Tooltip("히든 분기를 탔을 때 기록할 선택지 Code (선택지의 Code와 동일한 용도). " +
"비우면 기록 안 함")]
public string HiddenCode;
[Header("Affection Branch")]
[Tooltip("켜면 이 노드는 대사 없이 호감도 조건만 검사해 즉시 라우팅한다: " +
"조건을 만족하면 AffectionPassBranch로, 아니면 Next로")]
public bool AffectionCheck;
[Tooltip("검사할 호감도 조건들 (캐릭터·연산자·값 + 다음 조건과의 and/or). " +
"캐릭터를 비우면 대화 주인 NPC. AND가 OR보다 우선. 비어 있으면 무조건 통과")]
public List<AffectionRequirement> AffectionRequirements = new();
[Tooltip("조건을 만족했을 때 갈 노드 (실패하면 Next로)")]
public DialogNode AffectionPassBranch;
[Header("ChoiceQuestion")]
[TextArea(2,5)] public string ChoiceQuestion;
[Header("Story")]
[Tooltip("0이 아니면 이 노드 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감")]
public int Affection;
[Tooltip("0이 아니면 이 노드 재생 시 메인 진행도를 이만큼 증가. " +
"분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도를 다르게 줄 수 있다")]
public int Progress;
}

View File

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

View File

@@ -0,0 +1,520 @@
using System;
using System.Collections.Generic;
using UnityEngine.InputSystem;
using UnityEngine;
[RequireComponent(typeof(CharacterVoiceObject))]
public class DialogPlayer : MonoBehaviour
{
// 대화 후보는 인스펙터가 아니라 StoryDatabase에서 온다 —
// 현재 장소(LocationManager.Current) + 이 캐릭터의 비트 중 조건을 만족하는 것들.
// 여럿이면 선택 메뉴가 뜨고, DB 목록에서 위에 있을수록 우선순위가 높다.
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
private Animator _animator;
// 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원.
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
private readonly Dictionary<Animator, (int gestureHash, int expressionHash, bool hasExpression)> _touchedAnimators = new();
public bool IsPlaying { get; private set; }
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
// 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작).
private static DialogPlayer _entryInProgress;
// ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ────────
// 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true.
// DialogInteractionBlocker가 구독해서 인터랙터를 잠근다.
public static bool IsAnyActive => _activeCount > 0;
public static event Action<bool> AnyActiveChanged;
private static int _activeCount;
private static void PushActive()
{
if (++_activeCount == 1) AnyActiveChanged?.Invoke(true);
}
private static void PopActive()
{
if (_activeCount <= 0) return;
if (--_activeCount == 0) AnyActiveChanged?.Invoke(false);
}
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 상태가 안 남게
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
_entryInProgress = null;
_activeCount = 0;
AnyActiveChanged = null;
}
private void Awake()
{
_voice = GetComponent<CharacterVoiceObject>();
_animator = GetComponentInChildren<Animator>(); // 화자를 못 찾을 때의 폴백 Animator
}
private void OnDestroy()
{
// 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록
if (_entryInProgress == this) _entryInProgress = null;
}
public async Awaitable Play()
{
if (IsPlaying) return;
// 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단
if (_entryInProgress != null) return;
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
if (ChoiceHud.Instance != null)
ChoiceHud.Instance.CancelPending();
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
IsPlaying = true;
PushActive();
try
{
var playable = FindPlayableBeats();
if (playable.Count == 0)
{
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
return;
}
StoryBeat beat = playable.Count == 1 ? playable[0] : await SelectBeat(playable);
if (beat == null) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등)
_entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시
await PlayBeat(beat);
}
catch (OperationCanceledException)
{
// 대화 도중 선택지 대기가 취소됨 (씬 전환으로 ChoiceHud 비활성화 등) — PlayBeat의 finally에서 정리됨
}
finally
{
if (_entryInProgress == this) _entryInProgress = null;
IsPlaying = false;
PopActive();
}
}
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (EventZoneTrigger 등 UnityEvent 연결용).
// DB에 등록된 비트면 그 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 비트로 재생한다.
// 완료 여부는 검사하지 않으므로(강제) 1회성이 필요하면 호출하는 쪽(존의 Trigger Once)에서 보장할 것.
public void PlayGroup(DialogGroup group)
{
_ = PlayGroupForced(group);
}
private async Awaitable PlayGroupForced(DialogGroup group)
{
if (group == null || IsPlaying) return;
if (_entryInProgress != null) return; // 다른 NPC가 대사 재생 중
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소
if (ChoiceHud.Instance != null)
ChoiceHud.Instance.CancelPending();
IsPlaying = true;
PushActive();
try
{
// DB에 등록된 비트가 있으면 그 설정(진행도 보상 등)을 그대로 사용, 없으면 임시 비트로 재생
var lm = LocationManager.Instance;
StoryBeat beat = lm != null && lm.Database != null ? lm.Database.FindBeat(group) : null;
beat ??= new StoryBeat { Group = group };
_entryInProgress = this;
await PlayBeat(beat);
}
catch (OperationCanceledException)
{
// 씬 전환 등으로 취소됨 — PlayBeat의 finally에서 정리됨
}
finally
{
if (_entryInProgress == this) _entryInProgress = null;
IsPlaying = false;
PopActive();
}
}
// 현재 장소에서 이 캐릭터가 걸 수 있는 대화 비트들 (DB 목록 순서 = 우선순위)
private List<StoryBeat> FindPlayableBeats()
{
var lm = LocationManager.Instance;
if (lm == null || lm.Database == null)
{
Debug.LogWarning($"[DialogPlayer] LocationManager 또는 StoryDatabase가 없음: {name}");
return new List<StoryBeat>();
}
return lm.Database.GetPlayableBeats(lm.Current, _voice.Character);
}
// 재생 가능한 대화가 여럿일 때 ChoiceHud로 플레이어에게 고르게 한다. 취소되면 null.
private async Awaitable<StoryBeat> SelectBeat(List<StoryBeat> playable)
{
if (ChoiceHud.Instance == null)
return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선
var options = new List<DialogChoice>(playable.Count);
foreach (var beat in playable)
{
string label = !string.IsNullOrWhiteSpace(beat.MenuLabel)
? beat.MenuLabel
: beat.Group.DialogGroupName;
options.Add(new DialogChoice { ChoiceText = label }); // Code 없음 → 선택 이력에 기록 안 됨
}
try
{
int picked = await ChoiceHud.Instance.Show(null, options);
return playable[picked];
}
catch (OperationCanceledException)
{
// 대기 중 ChoiceHud가 비활성화됨(씬 전환 등) — 재생하지 않음
return null;
}
}
private async Awaitable PlayBeat(StoryBeat beat)
{
try
{
var node = beat.Group.StartNode;
int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단
while (node != null)
{
// 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다)
if (node.AffectionCheck)
{
if (++routingHops > 100)
{
Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {beat.Group.name}");
break;
}
node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next;
continue;
}
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다.
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
bool armed = node.HiddenBranch != null;
if (armed) HiddenBranchResolver.Arm(node.HiddenGestureKey);
try
{
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 제스처 감시 포함)
if (diverted)
{
RecordHiddenChoice(node);
node = node.HiddenBranch; // 대사 도중 제스처 발동 → 몰래 분기
}
else if (node.Choices != null && node.Choices.Count > 0)
{
int picked = await WaitForChoice(node); // 메뉴(무장 시 제스처 감시 포함)
if (picked == DivertIndex)
{
RecordHiddenChoice(node);
node = node.HiddenBranch; // 메뉴 도중 제스처 발동 → 메뉴엔 없던 히든 분기
}
else
{
RecordChoice(node, picked);
node = node.Choices[picked].DestinationNode;
}
}
else
{
node = node.Next;
}
}
finally
{
if (armed) HiddenBranchResolver.Disarm();
}
}
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
var story = StoryManager.Instance;
bool firstTime = story.MarkDialogCompleted(beat.Group.name);
if (firstTime && beat.ProgressOnComplete > 0)
story.MainProgress += beat.ProgressOnComplete;
story.Save();
Debug.Log($"[DialogPlayer] 대화 종료: {beat.Group.name}");
}
finally
{
if (DialogHud.Instance != null)
DialogHud.Instance.Hide();
if (SoundManager.Instance != null)
SoundManager.Instance.ClearOverrideBGM(); // 대화가 끝나면 기본 BGM으로 복귀
RestoreDefaultAnimations();
}
}
// 대화 중 제스처/표정을 재생했던 모든 Animator(끼어든 NPC 포함)를 원래 상태로 복원
private void RestoreDefaultAnimations()
{
foreach (var kvp in _touchedAnimators)
{
var anim = kvp.Key;
if (anim == null) continue;
anim.CrossFade(kvp.Value.gestureHash, 0.3f, 0, normalizedTimeOffset: 0f);
if (kvp.Value.hasExpression)
anim.CrossFade(kvp.Value.expressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
}
_touchedAnimators.Clear();
}
// 처음 건드리는 Animator면 현재 상태를 기억해 둔다 (대화 종료 시 복원 기준)
private void CaptureInitialAnimState(Animator anim)
{
if (_touchedAnimators.ContainsKey(anim)) return;
bool hasExpression = anim.layerCount > 1;
_touchedAnimators[anim] = (
anim.GetCurrentAnimatorStateInfo(0).fullPathHash,
hasExpression ? anim.GetCurrentAnimatorStateInfo(1).fullPathHash : 0,
hasExpression);
}
// 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행
private async Awaitable<bool> PlayNode(DialogNode node)
{
// DialogHud에 대사 표시 (이름은 노드 오버라이드 우선)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride);
// 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC
if (node.Affection != 0)
{
var affectionTarget = node.Speaker != null ? node.Speaker : _voice.Character;
StoryManager.Instance.AddAffection(affectionTarget, node.Affection);
}
// 메인 진행도 증가 — 분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도가 달라진다
if (node.Progress != 0)
StoryManager.Instance.MainProgress += node.Progress;
// 전용 BGM: 설정돼 있으면 교체, 비어 있으면 기본 BGM으로 복귀
if (SoundManager.Instance != null)
{
if (node.Bgm != null)
SoundManager.Instance.PlayOverrideBGM(node.Bgm);
else
SoundManager.Instance.ClearOverrideBGM();
}
// 보이스 재생
if (node.Voice != null && node.Speaker != null)
{
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
if (voiceObj != null && node.Voice.Clip != null)
voiceObj.Play(node.Voice.Clip);
}
// 제스처/표정은 화자(Target)의 Animator에 적용 — 끼어든 NPC의 대사/연출 노드도 그 캐릭터가 움직인다.
// 화자를 못 찾으면 대화 주인 NPC의 Animator로 폴백.
if (node.Gesture != null || node.Expression != null)
{
var gestureObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null;
var anim = gestureObj != null ? gestureObj.GetComponentInChildren<Animator>() : _animator;
if (anim == null) anim = _animator;
if (anim != null)
{
CaptureInitialAnimState(anim); // 대화 종료 시 복원할 원래 상태 기억
if (node.Gesture != null)
anim.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
if (node.Expression != null)
anim.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
}
}
// 진행은 항상 플레이어 입력 대기.
// 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다.
if (HiddenBranchResolver.Armed)
return await WaitAdvanceOrDivert();
await WaitForAdvanceInput();
return false;
}
// 호감도 분기 노드의 조건식 평가.
// 각 조건의 JoinWithNext로 이어 붙이며, AND가 OR보다 우선순위가 높다:
// A and B or C → (A and B) or C
// 즉 "AND 그룹들을 OR로 합치는" 형태로 계산한다. 조건이 없으면 통과로 본다.
private bool IsAffectionMet(DialogNode node)
{
var requirements = node.AffectionRequirements;
if (requirements == null || requirements.Count == 0) return true;
var story = StoryManager.Instance;
bool result = false; // 지금까지 닫힌 AND 그룹들을 OR로 합친 값
bool group = true; // 현재 진행 중인 AND 그룹
for (int i = 0; i < requirements.Count; i++)
{
var req = requirements[i];
if (req == null) continue;
var target = req.Character != null ? req.Character : _voice.Character;
group &= req.IsMet(story.GetAffection(target));
// 다음 연결자가 OR이거나 마지막이면 AND 그룹을 닫고 OR로 합친다
bool isLast = i == requirements.Count - 1;
if (isLast || req.JoinWithNext == AffectionJoin.Or)
{
result |= group;
group = true;
}
}
return result;
}
// 선택 기록: 선택지 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} 치환 → 동적으로 생성된 코드 반영
if (!string.IsNullOrEmpty(code))
StoryManager.Instance.RecordChoice(code);
}
// 히든 분기 기록: 노드의 HiddenCode를 선택지 Code와 동일하게 StoryState에 영구 기록
private void RecordHiddenChoice(DialogNode node)
{
string code = DialogVariables.Format(node.HiddenCode); // {token} 치환
if (!string.IsNullOrEmpty(code))
StoryManager.Instance.RecordChoice(code);
}
// 히든 분기 신호값 — 선택지 인덱스(0..n-1)와 절대 겹치지 않는 값.
private const int DivertIndex = -1;
private async Awaitable<int> WaitForChoice(DialogNode node)
{
if (ChoiceHud.Instance == null)
{
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
return 0;
}
// 무장 안 된 노드는 기존과 동일 — 그냥 메뉴 선택을 기다린다.
if (!HiddenBranchResolver.Armed)
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
// 무장된 노드: 메뉴가 떠 있는 동안 제스처가 들어오면 메뉴를 취소하고 히든으로 분기.
if (HiddenBranchResolver.Fired)
return DivertIndex; // 메뉴가 뜨기 전에 이미 발동한 경우
void OnFired() => ChoiceHud.Instance.CancelPending(); // 제스처 → 메뉴 즉시 취소
HiddenBranchResolver.FiredEvent += OnFired;
try
{
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
}
catch (OperationCanceledException)
{
// 우리가 제스처로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
if (HiddenBranchResolver.Fired) return DivertIndex;
throw;
}
finally
{
HiddenBranchResolver.FiredEvent -= OnFired;
}
}
// 노드 대기 + 히든 제스처 감시(레이스). 무장된 노드에서만 호출된다.
// 반환: true = 대기 도중 제스처 발동(→히든 분기), false = 평범하게 진행(입력)
private async Awaitable<bool> WaitAdvanceOrDivert()
{
var im = InputManager.Instance;
bool advance = false;
void OnAdvance() => advance = true;
if (im != null) im.OnDialogNext_Event += OnAdvance;
// InputManager가 없으면 기존 WaitForAdvanceInput처럼 1초 후 진행
float timeLeft = im == null ? 1f : -1f;
try
{
while (true)
{
if (HiddenBranchResolver.Fired) return true; // 제스처 발동 → 히든 분기
if (advance) return false; // 진행 입력 → 평범하게 진행
if (timeLeft >= 0f)
{
timeLeft -= Time.deltaTime;
if (timeLeft <= 0f) return false;
}
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
}
catch (OperationCanceledException)
{
return false; // 대기 중 파괴/씬 전환 — 기존과 동일하게 조용히 종료
}
finally
{
if (im != null) im.OnDialogNext_Event -= OnAdvance;
}
}
// 대화 진행 입력(OnDialogNext) 한 번을 대기
private async Awaitable WaitForAdvanceInput()
{
var im = InputManager.Instance;
if (im == null)
{
// 입력 매니저 없으면 안전하게 잠깐 대기 후 진행
await Awaitable.WaitForSecondsAsync(1f);
return;
}
bool pressed = false;
void Handler() => pressed = true;
im.OnDialogNext_Event += Handler;
try
{
while (!pressed)
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
catch (OperationCanceledException)
{
// 대기 중 오브젝트 파괴 시 조용히 종료
}
finally
{
im.OnDialogNext_Event -= Handler;
}
}
//테스트용
private void Update()
{
if (Mouse.current == null) return;
if (!Mouse.current.leftButton.wasPressedThisFrame) return;
if (Camera.main == null) return;
var ray = Camera.main.ScreenPointToRay(Mouse.current.position.ReadValue());
if (Physics.Raycast(ray, out var hit) && hit.transform.IsChildOf(transform))
{
Debug.Log("캐릭터 클릭");
_ = Play(); // 테스트용 fire-and-forget (Update는 await 불가)
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9838e0e0a3edefa4e92ddbb98aaa3ce5

View File

@@ -0,0 +1,12 @@
using UnityEngine;
// 인스펙터에 지정한 key로 DialogVariables에 값을 넣는 헬퍼.
// 예: TMP_InputField의 On End Edit(string) → 이 컴포넌트의 Set(string) 에 연결하면
// 플레이어가 입력한 글자가 {key} 토큰으로 대화에 들어간다.
public class DialogVariableSetter : MonoBehaviour
{
[SerializeField] private string _key;
public void Set(string value) => DialogVariables.Set(_key, value); // UnityEvent<string> 연결용
public void SetKey(string key) => _key = key;
}

View File

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

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
// 대화 텍스트의 {key} 토큰을 런타임 값으로 치환하는 전역 저장소.
// 예) DialogVariables.Set("playerName", "철수");
// 대사 "안녕 {playerName}!" → "안녕 철수!"
//
// 표시 직전(DialogHud / ChoiceHud)에서 Format()을 거치므로, 그래프엔 그냥 {key}만 써두면 된다.
public static class DialogVariables
{
private static readonly Dictionary<string, string> _values = new();
public static void Set(string key, string value) => _values[key] = value ?? string.Empty;
public static void Remove(string key) => _values.Remove(key);
public static void Clear() => _values.Clear();
public static bool TryGet(string key, out string value) => _values.TryGetValue(key, out value);
// "{key}" 토큰을 등록된 값으로 치환. 등록 안 된 키는 그대로 둔다(빠진 값 디버깅용).
public static string Format(string text)
{
if (string.IsNullOrEmpty(text) || text.IndexOf('{') < 0) return text;
var sb = new StringBuilder(text.Length);
int i = 0;
while (i < text.Length)
{
if (text[i] == '{')
{
int close = text.IndexOf('}', i + 1);
if (close > i)
{
string key = text.Substring(i + 1, close - i - 1);
if (_values.TryGetValue(key, out var val))
{
sb.Append(val);
i = close + 1;
continue;
}
}
}
sb.Append(text[i]);
i++;
}
return sb.ToString();
}
// 플레이 시작마다 초기화 (Enter Play Mode에서 도메인 리로드를 꺼도 이전 값이 안 남게)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetOnPlay() => _values.Clear();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
using System;
using Unity.GraphToolkit.Editor;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 호감도 분기 노드. 대사 없이 호감도 조건식만 검사해 두 경로 중 하나로 즉시 라우팅한다.
// DialogNode(AffectionCheck=true) 하나로 변환된다.
//
// Condition Count로 조건 줄을 늘린다. 조건 하나는 [Target / 연산자 / 값] 세 줄이고,
// 조건 사이마다 [and · or] 연결자 줄이 하나씩 생긴다:
//
// Target 1 윤지후
// Compare 1 ≥
// Affection 1 30
// Join 1 AND
// Target 2 잔디
// Compare 2 <
// Affection 2 20
// ├─ True →
// └─ False →
//
// AND가 OR보다 우선순위가 높다 (A and B or C → (A and B) or C).
[Serializable]
internal class DialogAffectionNode : DialogGraphNode
{
public const string PORT_PASS_OUT = "PassOut";
public const string PORT_FAIL_OUT = "FailOut";
public const string OPTION_CONDITION_COUNT = "ConditionCount";
// 조건별 포트 이름 규칙 (임포터와 공유)
public static string TargetPort(int i) => $"Target{i}";
public static string ComparePort(int i) => $"Compare{i}";
public static string ValuePort(int i) => $"Value{i}";
public static string JoinPort(int i) => $"Join{i}";
protected override void OnDefineOptions(IOptionDefinitionContext context)
{
context.AddOption<int>(OPTION_CONDITION_COUNT)
.WithDisplayName("Condition Count")
.WithTooltip("검사할 호감도 조건 개수 (캐릭터마다 하나씩)")
.WithDefaultValue(1)
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecInput(context);
int conditionCount = 1;
GetNodeOptionByName(OPTION_CONDITION_COUNT)?.TryGetValue(out conditionCount);
if (conditionCount < 1) conditionCount = 1;
for (int i = 0; i < conditionCount; i++)
{
context.AddInputPort<CharacterData>(TargetPort(i))
.WithDisplayName($"Target {i + 1}")
.WithTooltip("누구의 호감도를 검사할지. 비우면 대화 주인 NPC")
.Build();
context.AddInputPort<AffectionCompare>(ComparePort(i))
.WithDisplayName($"Compare {i + 1}")
.WithTooltip("호감도를 아래 값과 어떻게 비교할지")
.Build();
context.AddInputPort<int>(ValuePort(i))
.WithDisplayName($"Affection {i + 1}")
.WithTooltip("비교 기준값")
.Build();
// 조건 사이에만 연결자를 둔다 (마지막 조건 뒤에는 이어질 게 없으므로 생략)
if (i < conditionCount - 1)
{
context.AddInputPort<AffectionJoin>(JoinPort(i))
.WithDisplayName($"Join {i + 1}")
.WithTooltip("다음 조건과 묶는 방식. AND가 OR보다 우선")
.Build();
}
}
AddExecOutput(context, PORT_PASS_OUT, "True →");
AddExecOutput(context, PORT_FAIL_OUT, "False →");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 25bc0691603f93e4196ae428c028e855

View File

@@ -0,0 +1,43 @@
using System.Linq;
using Unity.GraphToolkit.Editor;
using UnityEditor;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 대화 그래프.
// 기존 Communication/Dialog 시스템(DialogGroup / DialogNode / DialogChoice)을
// 노드 그래프로 저작하기 위한 에디터 전용 그래프 타입이다.
// 임포트 시 DialogGraphImporter가 이 그래프를 DialogGroup 에셋으로 변환한다.
[Graph(AssetExtension)]
internal class DialogGraph : Graph
{
// ScriptedImporter가 사용하는 확장자. 프로젝트 내에서 유일해야 한다.
public const string AssetExtension = "dlg"; // DiaLoG
const string k_DefaultName = "New Dialog Graph";
[MenuItem("Assets/Create/Communication/Dialog Graph")]
static void CreateAssetFile()
{
GraphDatabase.PromptInProjectBrowserToCreateNewAsset<DialogGraph>(k_DefaultName);
}
// 그래프가 바뀔 때마다 호출되어 에러/경고를 보고한다.
public override void OnGraphChanged(GraphLogger infos)
{
base.OnGraphChanged(infos);
var startNodes = GetNodes().OfType<DialogStartNode>().ToList();
switch (startNodes.Count)
{
case 0:
infos.LogError("Start 노드가 필요합니다. (Dialog Start Node를 추가하세요)", this);
break;
case >= 2:
foreach (var extra in startNodes.Skip(1))
infos.LogWarning("Start 노드는 하나만 사용됩니다. 가장 먼저 생성된 노드만 적용됩니다.", extra);
break;
}
}
}
}

View File

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

View File

@@ -0,0 +1,236 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Unity.GraphToolkit.Editor;
using UnityEditor.AssetImporters;
using UnityEngine;
namespace DinoLove.Dialog.GraphTool.Editor
{
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(15, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
internal class DialogGraphImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
{
var graph = GraphDatabase.LoadGraphForImporter<DialogGraph>(ctx.assetPath);
if (graph == null)
{
Debug.LogError($"[DialogGraphImporter] 그래프 로드 실패: {ctx.assetPath}");
return;
}
// 메인 에셋: DialogGroup (이름은 파일명 기준 — StoryState 대화 이력의 키로 쓰임)
var groupName = Path.GetFileNameWithoutExtension(ctx.assetPath);
var group = ScriptableObject.CreateInstance<DialogGroup>();
group.name = groupName;
group.DialogGroupName = groupName;
ctx.AddObjectToAsset("Group", group);
ctx.SetMainObject(group);
var startNode = graph.GetNodes().OfType<DialogStartNode>().FirstOrDefault();
if (startNode == null)
return; // OnGraphChanged에서 에러 로깅됨
var firstGraphNode = GetConnectedNode(startNode, DialogGraphNode.EXEC_OUT);
if (firstGraphNode == null)
return; // Start만 있고 연결 없음
// 1패스: 도달 가능한 모든 라인 노드 → DialogNode 인스턴스 생성 (중복 제거)
var map = new Dictionary<INode, DialogNode>();
var order = new List<INode>();
var queue = new Queue<INode>();
queue.Enqueue(firstGraphNode);
while (queue.Count > 0)
{
var gn = queue.Dequeue();
if (gn == null || map.ContainsKey(gn)
|| (gn is not DialogLineNode && gn is not DialogAffectionNode))
continue;
var dn = ScriptableObject.CreateInstance<DialogNode>();
dn.Choices = new List<DialogChoice>();
map[gn] = dn;
order.Add(gn);
foreach (var next in GetSuccessors(gn))
if (next != null && !map.ContainsKey(next))
queue.Enqueue(next);
}
// 서브에셋 등록 + 이름 지정
for (int i = 0; i < order.Count; i++)
{
var dn = map[order[i]];
dn.name = $"Node_{i:00}";
ctx.AddObjectToAsset(dn.name, dn);
}
// 2패스: 데이터/링크 채우기
foreach (var gn in order)
{
var dn = map[gn];
// 호감도 분기 노드 — 대사 없이 라우팅만: 조건 통과 → AffectionPassBranch, 실패 → Next
if (gn is DialogAffectionNode affectionNode)
{
dn.AffectionCheck = true;
int conditionCount = 1;
var countOption = affectionNode.GetNodeOptionByName(DialogAffectionNode.OPTION_CONDITION_COUNT);
if (countOption != null && countOption.TryGetValue(out int storedCount) && storedCount > 0)
conditionCount = storedCount;
dn.AffectionRequirements = new List<AffectionRequirement>(conditionCount);
for (int i = 0; i < conditionCount; i++)
{
dn.AffectionRequirements.Add(new AffectionRequirement
{
Character = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogAffectionNode.TargetPort(i))),
Compare = GetInputPortValue<AffectionCompare>(gn.GetInputPortByName(DialogAffectionNode.ComparePort(i))),
Value = GetInputPortValue<int>(gn.GetInputPortByName(DialogAffectionNode.ValuePort(i))),
// 마지막 조건에는 Join 포트가 없다 → 기본 And (평가 시 무시됨)
JoinWithNext = i < conditionCount - 1
? GetInputPortValue<AffectionJoin>(gn.GetInputPortByName(DialogAffectionNode.JoinPort(i)))
: AffectionJoin.And
});
}
var passDest = GetConnectedNode(gn, DialogAffectionNode.PORT_PASS_OUT);
dn.AffectionPassBranch = passDest != null && map.TryGetValue(passDest, out var passDn) ? passDn : null;
var failDest = GetConnectedNode(gn, DialogAffectionNode.PORT_FAIL_OUT);
dn.Next = failDest != null && map.TryGetValue(failDest, out var failDn) ? failDn : null;
continue;
}
var line = (DialogLineNode)gn;
dn.Speaker = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogLineNode.PORT_SPEAKER));
dn.SpeakerNameOverride = GetInputPortValue<DialogShortText>(gn.GetInputPortByName(DialogLineNode.PORT_NAME_OVERRIDE)).Value;
dn.TalkText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.PORT_TALK)).Value;
dn.Gesture = GetInputPortValue<GestureData>(gn.GetInputPortByName(DialogLineNode.PORT_GESTURE));
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION));
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM));
dn.Affection = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_AFFECTION));
dn.Progress = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_PROGRESS));
int choiceCount = 0;
line.GetNodeOptionByName(DialogLineNode.OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
var next = GetConnectedNode(gn, DialogGraphNode.EXEC_OUT);
dn.Next = next != null && map.TryGetValue(next, out var nextDn) ? nextDn : null;
}
else
{
dn.ChoiceQuestion = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.PORT_QUESTION)).Value;
for (int i = 0; i < choiceCount; i++)
{
var choiceText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.ChoiceTextPort(i))).Value;
var choiceCode = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.ChoiceCodePort(i)));
var dest = GetConnectedNode(gn, DialogLineNode.ChoiceOutPort(i));
dn.Choices.Add(new DialogChoice
{
ChoiceText = choiceText,
Code = choiceCode,
DestinationNode = dest != null && map.TryGetValue(dest, out var destDn) ? destDn : null
});
}
}
// 히든 분기 (켜져 있으면) — 목적지 노드 + 제스처 키 + 기록 코드
bool hasHidden = false;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
{
var hiddenDest = GetConnectedNode(gn, DialogLineNode.PORT_HIDDEN_OUT);
dn.HiddenBranch = hiddenDest != null && map.TryGetValue(hiddenDest, out var hiddenDn) ? hiddenDn : null;
string gestureKey = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_GESTURE)?.TryGetValue(out gestureKey);
dn.HiddenGestureKey = gestureKey;
string hiddenCode = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_CODE)?.TryGetValue(out hiddenCode);
dn.HiddenCode = hiddenCode;
}
}
group.StartNode = map.TryGetValue(firstGraphNode, out var startDn) ? startDn : null;
}
// 노드의 실행 흐름상 후속 노드들 (선형이면 1개, N지선다면 N개)
static IEnumerable<INode> GetSuccessors(INode node)
{
// 호감도 분기 노드는 두 출력 모두 후속
if (node is DialogAffectionNode)
{
yield return GetConnectedNode(node, DialogAffectionNode.PORT_PASS_OUT);
yield return GetConnectedNode(node, DialogAffectionNode.PORT_FAIL_OUT);
yield break;
}
if (node is not DialogLineNode line)
yield break;
int choiceCount = 0;
line.GetNodeOptionByName(DialogLineNode.OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
yield return GetConnectedNode(node, DialogGraphNode.EXEC_OUT);
}
else
{
for (int i = 0; i < choiceCount; i++)
yield return GetConnectedNode(node, DialogLineNode.ChoiceOutPort(i));
}
// 히든 분기 후속도 도달 가능해야 DialogNode로 생성된다
bool hasHidden = false;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
yield return GetConnectedNode(node, DialogLineNode.PORT_HIDDEN_OUT);
}
// 출력 실행 포트에 연결된 노드 (없으면 null)
static INode GetConnectedNode(INode node, string outputPortName)
{
var port = node.GetOutputPortByName(outputPortName);
return port?.FirstConnectedPort?.GetNode();
}
// 입력 포트 값 읽기. (연결된 변수/상수 노드 → 임베드 값 → 기본값 순)
static T GetInputPortValue<T>(IPort port)
{
T value = default;
if (port == null)
return value;
if (port.IsConnected)
{
switch (port.FirstConnectedPort.GetNode())
{
case IVariableNode variableNode:
variableNode.Variable.TryGetDefaultValue<T>(out value);
return value;
case IConstantNode constantNode:
constantNode.TryGetValue<T>(out value);
return value;
}
}
else
{
port.TryGetValue(out value);
}
return value;
}
}
}

View File

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

View File

@@ -0,0 +1,33 @@
using System;
using Unity.GraphToolkit.Editor;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 대화 그래프 노드들의 공통 베이스.
// 실행 흐름(Execution) 포트를 추가하는 헬퍼를 제공한다.
// 실행 포트는 화살촉(Arrowhead) 커넥터를 쓰고, 데이터 포트(원형)와 구분된다.
[Serializable]
internal abstract class DialogGraphNode : Node
{
public const string EXEC_IN = "In";
public const string EXEC_OUT = "Out";
// 입력 실행 포트 (이 노드로 들어오는 흐름)
protected void AddExecInput(IPortDefinitionContext context)
{
context.AddInputPort(EXEC_IN)
.WithDisplayName(string.Empty)
.WithConnectorUI(PortConnectorUI.Arrowhead)
.Build();
}
// 출력 실행 포트 (이 노드에서 나가는 흐름)
protected void AddExecOutput(IPortDefinitionContext context, string portName, string displayName)
{
context.AddOutputPort(portName)
.WithDisplayName(displayName)
.WithConnectorUI(PortConnectorUI.Arrowhead)
.Build();
}
}
}

View File

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

View File

@@ -0,0 +1,117 @@
using System;
using Unity.GraphToolkit.Editor;
using UnityEngine;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 대사 한 노드. DialogNode 한 개로 변환된다.
//
// ChoiceCount 옵션으로 분기 방식을 정한다:
// - 0 : 선형 진행. 출력 실행 포트 "Out" 하나(→ DialogNode.Next)
// - 1 이상 : 가변 N지선다. ChoiceQuestion + 각 선택지마다
// [Choice{i} Text 입력 포트] + [Choice{i} 출력 실행 포트] 생성
// (→ DialogNode.Choices / ChoiceQuestion)
[Serializable]
internal class DialogLineNode : DialogGraphNode
{
public const string PORT_SPEAKER = "Speaker";
public const string PORT_NAME_OVERRIDE = "SpeakerNameOverride";
public const string PORT_TALK = "TalkText";
public const string PORT_GESTURE = "Gesture";
public const string PORT_EXPRESSION = "Expression";
public const string PORT_VOICE = "Voice";
public const string PORT_BGM = "Bgm";
public const string PORT_AFFECTION = "Affection";
public const string PORT_PROGRESS = "Progress";
public const string PORT_QUESTION = "ChoiceQuestion";
public const string PORT_HIDDEN_OUT = "HiddenOut";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
public const string OPTION_HIDDEN_BRANCH = "HasHiddenBranch";
public const string OPTION_HIDDEN_GESTURE = "HiddenGestureKey";
public const string OPTION_HIDDEN_CODE = "HiddenCode";
// 선택지별 포트 이름 규칙 (임포터와 공유)
public static string ChoiceTextPort(int i) => $"Choice{i}Text";
public static string ChoiceCodePort(int i) => $"Choice{i}Code";
public static string ChoiceOutPort(int i) => $"Choice{i}Out";
protected override void OnDefineOptions(IOptionDefinitionContext context)
{
context.AddOption<int>(OPTION_CHOICE_COUNT)
.WithDisplayName("Choice Count")
.WithTooltip("0이면 선형 진행(Next), 1 이상이면 가변 N지선다 분기")
.WithDefaultValue(0)
.Delayed();
context.AddOption<bool>(OPTION_HIDDEN_BRANCH)
.WithDisplayName("Hidden Branch")
.WithTooltip("켜면 맨 아래에 'Hidden →' 출력 포트가 생긴다. 이 대사 재생 중 특정 제스처(싸대기/터치)를 하면 " +
"Next/선택지 대신 그 포트로 연결된 노드로 몰래 분기한다")
.WithDefaultValue(false);
context.AddOption<string>(OPTION_HIDDEN_GESTURE)
.WithDisplayName("Hidden Gesture Key")
.WithTooltip("히든 분기를 여는 제스처 키 (DialogGestureZone의 Gesture Key와 일치). 비우면 아무 제스처 존이나 발동")
.Delayed();
context.AddOption<string>(OPTION_HIDDEN_CODE)
.WithDisplayName("Hidden Code")
.WithTooltip("히든 분기를 탔을 때 기록할 선택지 Code. 비우면 기록 안 함")
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecInput(context);
// DialogNode의 라인 데이터 (모두 선택 입력, 비워두면 default)
context.AddInputPort<CharacterData>(PORT_SPEAKER).WithDisplayName("Speaker").Build();
context.AddInputPort<DialogShortText>(PORT_NAME_OVERRIDE).WithDisplayName("Name Override")
.WithTooltip("비우면 Speaker 이름 그대로, 채우면 이 이름으로 표시 (예: ???)").Build();
context.AddInputPort<DialogText>(PORT_TALK).WithDisplayName("Talk Text").Build();
context.AddInputPort<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM")
.WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build();
context.AddInputPort<int>(PORT_AFFECTION).WithDisplayName("Affection ±")
.WithTooltip("0이 아니면 이 대사 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감").Build();
context.AddInputPort<int>(PORT_PROGRESS).WithDisplayName("Progress +")
.WithTooltip("0이 아니면 이 대사 재생 시 메인 진행도를 이만큼 증가 (분기 끝 노드에 달아 선택지별로 다르게)").Build();
int choiceCount = 0;
GetNodeOptionByName(OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
// 선형 진행
AddExecOutput(context, EXEC_OUT, string.Empty);
}
else
{
// 가변 N지선다
// (string 포트는 GraphToolkit 기본 에디터의 IME 중복입력 버그가 있어 DialogText로 통일)
context.AddInputPort<DialogText>(PORT_QUESTION).WithDisplayName("Choice Question").Build();
for (int i = 0; i < choiceCount; i++)
{
context.AddInputPort<DialogText>(ChoiceTextPort(i))
.WithDisplayName($"Choice {i + 1} Text")
.Build();
context.AddInputPort<string>(ChoiceCodePort(i))
.WithDisplayName($"Choice {i + 1} Code")
.Build();
AddExecOutput(context, ChoiceOutPort(i), $"Choice {i + 1} →");
}
}
// 히든 분기 출력 — 켜져 있으면 선형/선택지 관계없이 맨 아래에 붙는다.
// 이 포트에 연결된 노드가 DialogNode.HiddenBranch가 된다.
bool hasHidden = false;
GetNodeOptionByName(OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
AddExecOutput(context, PORT_HIDDEN_OUT, "Hidden →");
}
}
}

View File

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

View File

@@ -0,0 +1,18 @@
using System;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 그래프 노드의 한 줄짜리 텍스트 포트용 래퍼 타입 (이름 오버라이드 등 짧은 텍스트).
// DialogText와 마찬가지로 string 포트의 IME 중복입력 버그를 피하기 위한 타입이며,
// 전용 DialogShortTextDrawer가 한 줄 TextField로 렌더한다.
// 임포트 시 Value 문자열만 전달된다(런타임은 이 타입을 모름).
//
// public인 이유: GraphToolkit이 포트 임베드 값을 편집할 때 이 타입을 감싸는
// 래퍼 ScriptableObject를 Reflection.Emit으로 다른 어셈블리에 생성하므로
// 접근 가능해야 한다.
[Serializable]
public struct DialogShortText
{
public string Value;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9432f3355b50d3d458cff5e56b6c9206

View File

@@ -0,0 +1,22 @@
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
namespace DinoLove.Dialog.GraphTool.Editor
{
// DialogShortText를 한 줄 입력 필드로 그린다. (DialogTextDrawer의 싱글라인 버전)
[CustomPropertyDrawer(typeof(DialogShortText))]
internal class DialogShortTextDrawer : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var valueProp = property.FindPropertyRelative(nameof(DialogShortText.Value));
var field = new TextField();
if (valueProp != null)
field.BindProperty(valueProp);
return field;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15b46c322dec78d438a144f875d67d42

View File

@@ -0,0 +1,16 @@
using System;
using Unity.GraphToolkit.Editor;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 대화의 진입점. 출력 실행 포트 하나만 가진다.
// 임포터는 이 노드에 연결된 첫 노드를 DialogGroup.StartNode로 설정한다.
[Serializable]
internal class DialogStartNode : DialogGraphNode
{
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecOutput(context, EXEC_OUT, string.Empty);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f76763c19a33214ca518048c8a89799

View File

@@ -0,0 +1,17 @@
using System;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 그래프 노드의 TalkText 포트를 여러 줄(멀티라인)로 편집하기 위한 래퍼 타입.
// 전용 DialogTextDrawer가 multiline TextField로 렌더한다.
// 임포트 시 Value 문자열만 DialogNode.TalkText로 전달된다(런타임은 이 타입을 모름).
//
// public인 이유: GraphToolkit이 포트 임베드 값을 편집할 때 이 타입을 감싸는
// 래퍼 ScriptableObject를 Reflection.Emit으로 다른 어셈블리에 생성하므로
// 접근 가능해야 한다.
[Serializable]
public struct DialogText
{
public string Value;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ad54cf039f672845a54666166b5021c

View File

@@ -0,0 +1,30 @@
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
namespace DinoLove.Dialog.GraphTool.Editor
{
// DialogText를 여러 줄 입력 필드로 그린다.
// GraphToolkit의 포트 값 에디터(ConstantField)는 CustomPropertyDrawer가 있는 타입을
// Unity PropertyField로 렌더하므로, 이 드로어가 노드/인스펙터의 TalkText 칸을 멀티라인으로 만든다.
[CustomPropertyDrawer(typeof(DialogText))]
internal class DialogTextDrawer : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var valueProp = property.FindPropertyRelative(nameof(DialogText.Value));
var field = new TextField
{
multiline = true
};
field.style.minHeight = 72; // 약 4~5줄 높이
field.style.whiteSpace = WhiteSpace.Normal; // 줄바꿈(wrap) 허용
if (valueProp != null)
field.BindProperty(valueProp);
return field;
}
}
}

View File

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

View File

@@ -0,0 +1,47 @@
# Dialog Graph Tool
`Communication/Dialog` 시스템(`DialogGroup` / `DialogNode` / `DialogChoice`)을
**노드 그래프로 저작**하기 위한 에디터 전용 도구입니다.
Unity GraphToolkit(`com.unity.graphtoolkit`, experimental) 기반.
## 동작 개요
- `.dlg` 그래프 에셋을 노드로 편집 → 저장하면 `DialogGraphImporter`가
기존 런타임 타입(`DialogGroup` + 여러 `DialogNode`)으로 자동 변환합니다.
- 변환 결과: **메인 에셋 = `DialogGroup`**, 서브에셋 = 각 `DialogNode`.
- `DialogPlayer`는 **수정 없이** 임포트된 `.dlg`(=DialogGroup)를 그대로 사용합니다.
## 사용법
1. Project 창에서 우클릭 → `Create > Communication > Dialog Graph` 로 `.dlg` 생성.
2. 더블클릭해 그래프 에디터를 엽니다.
3. 노드 추가:
- **Dialog Start Node** : 진입점. 출력 화살표를 첫 대사 노드에 연결. (그래프당 1개)
- **Dialog Line Node** : 대사 1줄. Speaker/TalkText/Gesture/Expression/Voice/BGM 입력.
진행은 항상 플레이어 입력으로 넘어갑니다.
- `Choice Count = 0` → 선형. `Out` 출력을 다음 노드로 연결(= `DialogNode.Next`).
- `Choice Count = N` → 가변 N지선다. `Choice Question` + 선택지마다
`Choice i Text`(텍스트) 와 `Choice i →`(분기 출력) 생성.
각 분기 출력을 목적지 노드에 연결(= `DialogNode.Choices[i].DestinationNode`).
4. 저장(임포트)되면 `.dlg` 에셋이 `DialogGroup`이 됩니다.
이를 `StoryDatabase`의 비트(장소·캐릭터·조건)에 등록하면 끝 —
해당 장소에서 그 캐릭터에게 말을 걸면 조건에 따라 재생됩니다.
## 구성 파일 (모두 Editor 전용)
- `DialogGraph.cs` — 그래프 타입/생성 메뉴/검증
- `DialogGraphNode.cs` — 공통 베이스(실행 포트 헬퍼)
- `DialogStartNode.cs` — 진입 노드
- `DialogLineNode.cs` — 대사 + 가변 N지선다 노드
- `DialogText.cs` — TalkText 멀티라인 입력용 래퍼 타입
- `DialogTextDrawer.cs` — DialogText를 여러 줄 TextField로 그리는 CustomPropertyDrawer
- `DialogGraphImporter.cs`— .dlg → DialogGroup/DialogNode 변환
## TalkText 멀티라인
- TalkText 포트는 `string`이 아니라 `DialogText` 타입을 쓴다.
- GraphToolkit은 `[CustomPropertyDrawer]`가 있는 타입을 Unity PropertyField로 렌더하므로,
`DialogTextDrawer`가 노드의 TalkText 칸을 여러 줄(멀티라인)로 만든다.
- 임포터는 `DialogText.Value`만 꺼내 `DialogNode.TalkText`(string)에 넣는다 — 런타임은 영향 없음.
- 높이를 더 키우려면 `DialogTextDrawer`의 `minHeight` 값을 조정.
## 메모
- GraphToolkit은 experimental(0.4.0-exp.2)이라 API가 바뀔 수 있습니다.
- 분기 출력이 비어 있으면 해당 선택지의 `DestinationNode`는 null이 되어 대화가 종료됩니다.
- 여러 경로에서 같은 노드로 연결하면(루프 포함) 하나의 `DialogNode`로 합쳐집니다.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1fa58401314123a4b90fa0fda5240a18
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,60 @@
using System;
using UnityEngine;
// 히든 분기 감시 허브 (씬 오브젝트 불필요, 순수 static).
//
// 대화 노드가 재생되는 동안 DialogPlayer가 Arm(key)로 무장하고, 끝나면 Disarm()한다.
// 그 창(무장 구간) 안에서 DialogGestureZone이 Fire(key)를 호출하면 Fired가 켜지고,
// DialogPlayer가 이를 감지해 Next(또는 선택지) 대신 노드의 HiddenBranch로 몰래 분기한다.
//
// 무장돼 있지 않으면 어떤 Fire도 무시된다 → 평소엔 존재하지 않는 것처럼 동작(=히든).
public static class HiddenBranchResolver
{
private static string _armedKey;
// 지금 히든 분기를 받을 수 있는 상태인가 (노드 재생 중)
public static bool Armed { get; private set; }
// 이번 무장 구간에서 제스처가 발동했는가
public static bool Fired { get; private set; }
// 발동 즉시 알림 — 떠 있는 선택지 메뉴를 그 자리에서 취소시키는 용도
public static event Action FiredEvent;
// Enter Play Mode(도메인 리로드 off)에서 이전 상태가 안 남게
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
_armedKey = null;
Armed = false;
Fired = false;
FiredEvent = null;
}
// 노드 진입 시 무장. key가 비면 아무 제스처 존이나 발동시킬 수 있다.
public static void Arm(string key)
{
_armedKey = key;
Armed = true;
Fired = false;
}
// 노드 종료 시 해제.
public static void Disarm()
{
Armed = false;
_armedKey = null;
Fired = false;
}
// 제스처 존이 호출. 무장 중이고 key가 맞을 때만 1회 발동.
// 반환: 이번 호출로 실제 발동했으면 true (호출 측이 "그때만" 아이템 소비 등에 쓸 수 있게).
public static bool Fire(string key)
{
if (!Armed || Fired) return false;
if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return false;
Fired = true;
FiredEvent?.Invoke();
return true;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1622ac8790b08674cbb2164b2a8dd1f7

View File

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

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using UnityEngine;
public class CharacterVoiceObject : MonoBehaviour
{
public CharacterData Character;
public AudioSource VoiceSource;
private static readonly Dictionary<CharacterData, CharacterVoiceObject> _registry = new();
private void OnEnable() => _registry[Character] = this;
private void OnDisable() => _registry.Remove(Character);
public static CharacterVoiceObject Find(CharacterData data)
=> _registry.TryGetValue(data, out var obj) ? obj : null;
public void Play(AudioClip clip) => VoiceSource.PlayOneShot(clip);
}

View File

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

View File

@@ -0,0 +1,109 @@
using System;
using UnityEngine;
// 보이스 진폭에 따라 입 관련 블렌드셰이프 그룹의 weight를 직접 제어
// LateUpdate에서 갱신해 Animator가 같은 프레임에 0으로 세팅한 값을 덮어씀
[RequireComponent(typeof(CharacterVoiceObject))]
public class LipSync : MonoBehaviour
{
[Serializable]
private struct LipShape
{
public string Name;
[Range(0f, 100f)] public float MaxWeight; // amplitude=1일 때 도달할 weight
}
[Header("Refs")]
[SerializeField] private SkinnedMeshRenderer _meshRenderer;
// BMAC_OpenMouse_Big 클립의 입 관련 셰이프 프리셋
[Header("Mouth Preset (입 최대 시 weight)")]
[SerializeField] private LipShape[] _shapes =
{
new() { Name = "Expression_SurpriesedMouth", MaxWeight = 50f },
new() { Name = "Expression_MouthSad_L", MaxWeight = 10f },
new() { Name = "Expression_MouthSad_R", MaxWeight = 10f },
new() { Name = "Expression_MouthWide_L", MaxWeight = 30f },
new() { Name = "Expression_MouthWide_R", MaxWeight = 30f },
new() { Name = "Expression_LipsOh", MaxWeight = 100f },
new() { Name = "Expression_LipsO", MaxWeight = 5f },
};
[Header("Tuning")]
[SerializeField, Range(0f, 20f)] private float _amplitudeScale = 6f; // RMS → 0~1 매핑 배수
[SerializeField, Range(0f, 0.05f)] private float _noiseFloor = 0.005f;
[SerializeField, Range(0f, 30f)] private float _smoothingSpeed = 15f;
[SerializeField] private int _sampleSize = 256;
private AudioSource _audioSource;
private int[] _indices;
private float[] _sampleBuffer;
private float _currentAmplitude;
private void Awake()
{
var voiceObj = GetComponent<CharacterVoiceObject>();
_audioSource = voiceObj != null ? voiceObj.VoiceSource : null;
// 메시 자동 탐색 — 첫 번째 셰이프 이름을 가진 SkinnedMeshRenderer 사용
if (_meshRenderer == null && _shapes.Length > 0)
{
string probe = _shapes[0].Name;
foreach (var smr in GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.sharedMesh != null && smr.sharedMesh.GetBlendShapeIndex(probe) >= 0)
{
_meshRenderer = smr;
break;
}
}
}
// 인덱스 캐시
_indices = new int[_shapes.Length];
if (_meshRenderer != null && _meshRenderer.sharedMesh != null)
{
var mesh = _meshRenderer.sharedMesh;
for (int i = 0; i < _shapes.Length; i++)
{
_indices[i] = mesh.GetBlendShapeIndex(_shapes[i].Name);
if (_indices[i] < 0)
Debug.LogWarning($"[LipSync] 블렌드셰이프 없음: {_shapes[i].Name}", this);
}
}
else
{
for (int i = 0; i < _indices.Length; i++) _indices[i] = -1;
}
if (_audioSource == null)
Debug.LogWarning("[LipSync] CharacterVoiceObject.VoiceSource 미할당", this);
_sampleBuffer = new float[_sampleSize];
}
private void LateUpdate()
{
if (_audioSource == null || _meshRenderer == null) return;
// PlayOneShot도 잡히도록 항상 샘플링 — 무음은 노이즈 플로어로 컷
_audioSource.GetOutputData(_sampleBuffer, 0);
float sumSq = 0f;
for (int i = 0; i < _sampleBuffer.Length; i++)
sumSq += _sampleBuffer[i] * _sampleBuffer[i];
float rms = Mathf.Sqrt(sumSq / _sampleBuffer.Length);
rms = Mathf.Max(0f, rms - _noiseFloor);
float target = Mathf.Clamp01(rms * _amplitudeScale);
_currentAmplitude = Mathf.Lerp(_currentAmplitude, target, Time.deltaTime * _smoothingSpeed);
// Animator가 같은 프레임에 0으로 덮은 값을 LateUpdate에서 다시 씌움
for (int i = 0; i < _shapes.Length; i++)
{
if (_indices[i] < 0) continue;
_meshRenderer.SetBlendShapeWeight(_indices[i], _currentAmplitude * _shapes[i].MaxWeight);
}
}
}

View File

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

View File

@@ -0,0 +1,4 @@
public interface ISceneInitializable
{
public void OnSceneLoaded();
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38039c1904e850146a01668e869851be

View File

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

View File

@@ -0,0 +1,23 @@
using UnityEngine;
public class GameManager : MonoBehaviour,ISceneInitializable
{
public static GameManager Instance { get; private set; }
private void Awake()
{
if (Instance == null)
{
Instance = this; //만들어진 자신을 인스턴스로 설정
}
else
{
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
}
}
public void OnSceneLoaded()
{
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 197a05ee04c0ac746adfedb27e01fdf5

View File

@@ -0,0 +1,40 @@
using System;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
public class InputManager : MonoBehaviour, GameInput.IPlayerActions
{
// 외부에서 InputManager.Instance.OnXxx_Event += handler 형태로 구독.
public static InputManager Instance { get; private set; }
private GameInput _input;
public event Action OnDialogNext_Event;
private void Awake()
{
if (Instance == null)
{
Instance = this; //만들어진 자신을 인스턴스로 설정
}
else
{
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
}
_input = new GameInput();
_input.Player.SetCallbacks(this);
}
// GameInput은 활성/비활성 토글이 필요한 자원 ?. 처리로 Awake보다 OnEnable이 먼저 호출되는 경우 보호.
private void OnEnable() => _input?.Player.Enable();
private void OnDisable() => _input?.Player.Disable();
private void OnDestroy() => _input?.Dispose();
public void OnDialogNext(InputAction.CallbackContext ctx)
{
if (ctx.phase == InputActionPhase.Started)
OnDialogNext_Event?.Invoke();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 657e64bd5c92e4843a0d5b9892e05e45

View File

@@ -0,0 +1,85 @@
using UnityEngine;
// 게임 씬에 하나. 현재 장소 프리팹을 띄우고, 장소 안 캐릭터 슬롯들의 등장 여부를 갱신한다.
// DialogPlayer가 대화 후보를 조회할 때 (Database, Current)를 여기서 가져간다.
public class LocationManager : MonoBehaviour
{
public static LocationManager Instance { get; private set; }
[SerializeField] private StoryDatabase _database;
[Tooltip("장소 프리팹을 붙일 부모 (비우면 이 오브젝트)")]
[SerializeField] private Transform _locationRoot;
[Tooltip("게임 시작 시 입장할 장소")]
[SerializeField] private LocationData _startLocation;
public StoryDatabase Database => _database;
public LocationData Current { get; private set; }
private GameObject _currentInstance;
// 대화 중 진행도/트리거가 바뀌었을 때의 슬롯 갱신 예약.
// 대화 도중 화자를 꺼 버리면 재생이 깨지므로, 대화가 끝난 뒤로 미룬다.
private bool _refreshPending;
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
if (_locationRoot == null) _locationRoot = transform;
}
private void Start()
{
if (StoryManager.Instance != null)
StoryManager.Instance.Changed += OnStoryChanged;
DialogPlayer.AnyActiveChanged += OnDialogActiveChanged;
if (_startLocation != null)
MoveTo(_startLocation);
}
private void OnDestroy()
{
if (Instance != this) return;
if (StoryManager.Instance != null)
StoryManager.Instance.Changed -= OnStoryChanged;
DialogPlayer.AnyActiveChanged -= OnDialogActiveChanged;
Instance = null;
}
// 장소 이동 (이동 버튼의 UnityEvent 연결용). 대화 중에는 무시된다.
public void MoveTo(LocationData location)
{
if (location == null || DialogPlayer.IsAnyActive) return;
Current = location;
if (_currentInstance != null) Destroy(_currentInstance);
_currentInstance = location.Prefab != null
? Instantiate(location.Prefab, _locationRoot)
: null;
RefreshSlots();
}
// 대화로 진행도/트리거가 바뀌면 등장 캐릭터도 달라질 수 있다
private void OnStoryChanged()
{
if (DialogPlayer.IsAnyActive) { _refreshPending = true; return; }
RefreshSlots();
}
private void OnDialogActiveChanged(bool active)
{
if (active || !_refreshPending) return;
_refreshPending = false;
RefreshSlots();
}
private void RefreshSlots()
{
if (_currentInstance == null || _database == null) return;
foreach (var slot in _currentInstance.GetComponentsInChildren<CharacterSlot>(includeInactive: true))
slot.Refresh(_database, Current);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6244c1dafce01c04db303bbaf499f989

View File

@@ -0,0 +1,156 @@
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
public class SceneLoadManager : MonoBehaviour
{
public static SceneLoadManager Instance { get; private set; }
[SerializeField] private GameObject _loadingRoot;
[SerializeField] private LoadingScreen _loadingScreen;
private void Awake()
{
if (Instance == null)
{
Instance = this; // 만들어진 자신을 인스턴스로 설정
}
else
{
Destroy(gameObject); // 이미 인스턴스가 있으면 자신을 파괴
}
}
private void Start()
{
SceneManager.sceneLoaded += OnSceneLoaded;
OnSceneLoaded(SceneManager.GetActiveScene(), LoadSceneMode.Single);
}
private void OnDestroy()
{
if (Instance == this)
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
MonoBehaviour[] allObjs = FindObjectsByType<MonoBehaviour>();
foreach (var obj in allObjs)
{
if (obj is ISceneInitializable initializable)
{
// 씬에서 ISceneInitializable 인터페이스를 가진 오브젝트의 초기화 로직을 실행
initializable.OnSceneLoaded();
}
}
}
public void SetSceneLoadingProgressValue(float value)
{
}
public async Awaitable SetSceneLoadingActive(bool isActive,float alphaTime)
{
if (isActive)
{
_loadingRoot.SetActive(true);
}
if (alphaTime > 0f)
{
await FadeLoadingCanvas(!isActive,alphaTime);
}
if (!isActive)
_loadingRoot.SetActive(false);
}
public async Awaitable FadeLoadingCanvas(bool isOut,float fadeTime)
{
float startAlpha = isOut ? 1f : 0f;
float endAlpha = isOut ? 0f : 1f;
float timer = 0;
_loadingScreen.LoadingScreenCanvasGroup.alpha = startAlpha;
while(timer < fadeTime)
{
timer += Time.deltaTime;
_loadingScreen.LoadingScreenCanvasGroup.alpha = Mathf.Lerp(startAlpha, endAlpha, timer / fadeTime);
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
}
_loadingScreen.LoadingScreenCanvasGroup.alpha = endAlpha;
}
public void RequestSceneChange(string sceneName)
{
_ = SceneChange(sceneName);
}
private async Awaitable SceneChange(string sceneName)
{
try
{
//로딩바 수치 0으로 설정
SetSceneLoadingProgressValue(0f);
//로딩창을 1초에 걸쳐 나타나게함
await SetSceneLoadingActive(true,1f);
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
//자동 전환을 하고 싶지 않을 경우 해당값을 false로 두었다가 true로 바꾸면 그 때 전환됨
op.allowSceneActivation = false;
//화면에 보여줄 로딩 수치
float displayProgress = 0f;
//op.progress 0.9가 데이터 로딩이 끝난 기준 allowSceneActivation이 트루면 다음으로 넘어가면서 op.isDone이 true가 된다.
while (op.progress < 0.9f)
{
//실제 로딩 수치
float realProgress = Mathf.Clamp01(op.progress / 0.9f);
//보여줄 값을 실제값을 향해 부드럽게 이동
displayProgress = Mathf.MoveTowards(displayProgress, realProgress, Time.deltaTime * 0.5f);
// 로딩바 UI에 값 적용
SetSceneLoadingProgressValue(displayProgress);
//자기자신이 파괴될때 토큰에 취소요청을 보냄
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
}
//로딩바 수치 1(100%)로 설정 (데이터 로딩은 이미 끝이기 때문에)
SetSceneLoadingProgressValue(1);
// 잠시 대기했다가 전환
await Awaitable.WaitForSecondsAsync(1.0f, this.destroyCancellationToken);
// 로딩바가 1초에 걸쳐 사라짐
await SetSceneLoadingActive(false,1f);
// 다음씬으로 넘어가도 됨을 알림
op.allowSceneActivation = true;
// 씬 활성화가 완전히 끝날 때까지 대기
// allowSceneActivation가 true가 되고 완전히 전환되기까지는 몇프레임 걸림. op.isDone 은 이 과정이 끝난 뒤에 true가 됨.
while(!op.isDone)
{
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
}
}
catch (OperationCanceledException)
{
Debug.Log("씬 전환 작업이 취소됨");
}
}
}

View File

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

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using UnityEngine.Audio;
public class SoundManager : MonoBehaviour, ISceneInitializable
{
public static SoundManager Instance { get; private set; }
[Header("Mixer")]
[SerializeField] private AudioMixer _mainMixer; //메인 믹서 하나
[SerializeField] private AudioMixerGroup _bgmGroup;
[SerializeField] private AudioMixerGroup _sfxGroup;
[Header("BGM")]
[SerializeField] private AudioSource _bgmSource; //BGM 전용 단일 오디오 소스
[SerializeField, Range(0f, 1f)] private float _bgmVolume = 1f;
[SerializeField] private float _bgmFadeDuration = 1.5f;
[SerializeField] private AudioClip _defaultBgm; //평상시 BGM (시작 시 자동 재생)
//대화 전용 BGM 등 일시적으로 기본 BGM을 덮는 곡. null이면 기본 BGM 재생 중.
private AudioClip _overrideBgm;
[Header("SFX Pool")]
[SerializeField] private int _sfxPoolSize = 20; //시작 시 미리 생성할 SFX 소스 개수
//SFX 풀 - 대기중인 소스
private readonly Queue<AudioSource> _sfxPool = new();
//BGM 전환(페이드)을 취소하기 위한 토큰.
private CancellationTokenSource _bgmCts;
private void Awake()
{
if (Instance == null)
{
Instance = this; //만들어진 자신을 인스턴스로 설정
Initialize();
}
else
{
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 (씬마다 놓아도 첫 번째만 남음)
}
}
// 씬이 바뀌면 이전 씬에서 남은 전용(Override) BGM을 정리한다 (ISceneInitializable).
// 새 씬의 SceneBgm.Start가 SetDefaultBGM으로 그 씬의 곡을 넘겨주면 그대로 재생된다.
public void OnSceneLoaded() => ClearOverrideBGM();
private void Initialize()
{
//BGM 소스 기본 설정
if (_bgmSource != null)
{
_bgmSource.outputAudioMixerGroup = _bgmGroup;
_bgmSource.playOnAwake = false;
_bgmSource.loop = true;
}
//SFX 풀 미리 생성
for (int i = 0; i < _sfxPoolSize; i++)
{
_sfxPool.Enqueue(CreateSfxSource());
}
}
private void Start()
{
if (_defaultBgm != null)
StartBgmChange(_defaultBgm);
}
//=========================== BGM ===========================
//기본 BGM 교체 (씬/분위기 전환용). 전용 BGM 재생 중이면 곡은 유지되고 복귀 시 반영됨.
public void SetDefaultBGM(AudioClip clip)
{
if (_defaultBgm == clip) return; //같은 곡이면 페이드 재시작 없이 유지
_defaultBgm = clip;
if (_overrideBgm == null)
StartBgmChange(clip);
}
//전용 BGM으로 전환 (대화 노드 등). 이미 같은 곡이 재생 중이면 아무것도 안 함.
public void PlayOverrideBGM(AudioClip clip)
{
if (clip == null || _overrideBgm == clip) return;
_overrideBgm = clip;
StartBgmChange(clip);
}
//전용 BGM을 멈추고 기본 BGM으로 복귀. 전용 BGM이 없었으면 아무것도 안 함.
public void ClearOverrideBGM()
{
if (_overrideBgm == null) return;
_overrideBgm = null;
StartBgmChange(_defaultBgm);
}
//진행 중이던 전환(페이드)을 취소하고 새 전환을 시작
private void StartBgmChange(AudioClip clip)
{
if (_bgmSource == null) return;
_bgmCts?.Cancel();
_bgmCts?.Dispose();
_bgmCts = new CancellationTokenSource();
_ = ChangeBGM(clip, _bgmCts.Token);
}
//페이드아웃 → 클립 교체 → 페이드인 (단일 소스라 순차 진행)
private async Awaitable ChangeBGM(AudioClip clip, CancellationToken token)
{
try
{
//현재 재생중이면 먼저 페이드아웃
if (_bgmSource.isPlaying)
{
await BGMFade(_bgmSource.volume, 0f, token);
_bgmSource.Stop();
}
if (clip == null) return; //BGM이 없으면 무음 유지
//새 BGM 페이드인
_bgmSource.clip = clip;
_bgmSource.volume = 0f;
_bgmSource.Play();
await BGMFade(0f, _bgmVolume, token);
}
catch (OperationCanceledException)
{
//전환이 취소됨
}
}
//BGM 소스 볼륨을 from→to로 부드럽게 이동
private async Awaitable BGMFade(float from, float to, CancellationToken token)
{
float t = 0f;
while (t < _bgmFadeDuration)
{
t += Time.deltaTime;
_bgmSource.volume = Mathf.Lerp(from, to, t / _bgmFadeDuration);
await Awaitable.NextFrameAsync(token);
}
_bgmSource.volume = to;
}
//=========================== SFX ===========================
//2D SFX 재생 (UI 사운드 등 위치 무관)
public void PlaySFX(AudioClip clip, float volume = 1f)
{
if (clip == null) return;
AudioSource source = GetSfxSource();
source.transform.localPosition = Vector3.zero;
source.spatialBlend = 0f; //2D
Play(source, clip, volume);
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
}
//3D SFX 재생 (VR 공간음향 - 특정 위치에서 들림)
//minDistance: 이 거리 안에선 풀 볼륨으로 들림. 하늘 높이 터지는 폭죽처럼 멀리서 나는 큰 소리는
//크게(예: 30~50) 잡아야 거리 감쇠로 사라지지 않는다. maxDistance: 감쇠 계산 상한.
public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f,
float minDistance = 1f, float maxDistance = 500f)
{
if (clip == null) return;
AudioSource source = GetSfxSource();
source.transform.position = position;
source.spatialBlend = 1f; //3D
source.rolloffMode = AudioRolloffMode.Logarithmic;
source.minDistance = minDistance;
source.maxDistance = maxDistance;
Play(source, clip, volume);
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
}
private void Play(AudioSource source, AudioClip clip, float volume)
{
source.clip = clip;
source.volume = volume;
source.Play();
}
//풀에서 사용 가능한 SFX 소스를 가져옴 (없으면 새로 생성 - 자동 증설)
private AudioSource GetSfxSource()
{
AudioSource source = _sfxPool.Count > 0 ? _sfxPool.Dequeue() : CreateSfxSource();
source.gameObject.SetActive(true);
return source;
}
//재생 길이만큼 대기 후 소스를 풀로 반납
private async Awaitable ReturnAfterPlay(AudioSource source, float duration)
{
try
{
await Awaitable.WaitForSecondsAsync(duration, this.destroyCancellationToken);
}
catch (OperationCanceledException)
{
return; //매니저 파괴 시 종료
}
source.Stop();
source.clip = null;
source.gameObject.SetActive(false);
_sfxPool.Enqueue(source);
}
//SFX용 AudioSource를 자식 오브젝트로 생성
private AudioSource CreateSfxSource()
{
GameObject go = new("SFX_Source");
go.transform.SetParent(transform);
go.SetActive(false);
AudioSource source = go.AddComponent<AudioSource>();
source.outputAudioMixerGroup = _sfxGroup;
source.playOnAwake = false;
return source;
}
//=========================== Mixer ===========================
//믹서 노출 파라미터로 볼륨 조절 (0~1 → dB 변환)
public void SetVolume(string exposedParam, float normalized)
{
//0에 가까우면 -80dB(무음), 그 외에는 로그 스케일로 변환
float db = normalized <= 0.0001f ? -80f : Mathf.Log10(normalized) * 20f;
_mainMixer.SetFloat(exposedParam, db);
}
}

View File

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

View File

@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
// 이야기 상태(StoryState)의 유일한 접근 창구.
// 진행도/호감도/이력의 조회·변경과 저장/로드를 담당한다.
// 씬에 미리 배치하지 않아도 첫 접근 때 자동 생성된다.
public class StoryManager : MonoBehaviour
{
public static StoryManager Instance { get; private set; }
[Tooltip("플레이 시작 시 메인 진행도 초기값 — 씬을 직접 열어 테스트할 때 그 씬 기준 진행도로 시작하게. " +
"이 매니저가 살아남는 씬(플레이를 시작한 씬)의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")]
[Min(0)] [SerializeField] private int _initialMainProgress;
[Serializable]
private struct InitialAffection
{
public CharacterData Character;
public int Amount;
}
[Tooltip("플레이 시작 시 호감도 초기값 — 이 씬만 직접 열어 테스트할 때 앞 과정 없이 원하는 호감도로 시작하게. " +
"진행도 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")]
[SerializeField] private List<InitialAffection> _initialAffections = new();
private StoryState _state = new();
// 상태가 바뀔 때마다 발행 (호감도 게이지 등 UI 갱신용)
public event Action Changed;
private void Awake()
{
if (Instance == null)
{
Instance = this; //만들어진 자신을 인스턴스로 설정
_state.MainProgress = _initialMainProgress; // 테스트용 초기 진행도 (씬 전환으로 넘어온 중복은 여기 못 옴)
// 테스트용 초기 호감도 (첫 인스턴스에서만 — 씬 전환으로 넘어온 경우엔 이 자리에 못 옴)
foreach (var init in _initialAffections)
if (init.Character != null)
_state.Affection[IdOf(init.Character)] = init.Amount;
}
else
{
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
}
}
// ── 메인 진행도 ──────────────────────────────────────────────
public int MainProgress
{
get => _state.MainProgress;
set
{
if (_state.MainProgress == value) return;
_state.MainProgress = value;
Changed?.Invoke();
}
}
// UnityEvent 연결용 (버튼·이벤트존·노드 이벤트 등에서 진행도 조작)
public void AddMainProgress(int amount) => MainProgress += amount;
public void SetMainProgress(int value) => MainProgress = value;
// ── 호감도 ──────────────────────────────────────────────────
public int GetAffection(CharacterData character)
=> character != null && _state.Affection.TryGetValue(IdOf(character), out var v) ? v : 0;
public void AddAffection(CharacterData character, int delta)
{
if (character == null || delta == 0) return;
_state.Affection[IdOf(character)] = GetAffection(character) + delta;
Changed?.Invoke();
}
// CharacterData의 Id를 키로 사용 (비어있으면 에셋 이름)
private static string IdOf(CharacterData c) => string.IsNullOrEmpty(c.Id) ? c.name : c.Id;
// ── 대화 이력 ────────────────────────────────────────────────
public bool IsDialogCompleted(string groupName) => _state.CompletedDialogs.Contains(groupName);
// 완료 기록. 처음 완료한 경우에만 true (진행도 보상 중복 방지용)
public bool MarkDialogCompleted(string groupName)
{
if (string.IsNullOrEmpty(groupName) || !_state.CompletedDialogs.Add(groupName))
return false;
Changed?.Invoke();
return true;
}
// ── 트리거 ──────────────────────────────────────────────────
public bool HasTrigger(string id) => _state.Triggers.Contains(id);
// UnityEvent 연결용 (버튼·이벤트 등에서 트리거 켜기). 대화 조건 RequiredTriggerIds에서 검사한다.
public void SetTrigger(string id)
{
if (string.IsNullOrEmpty(id) || !_state.Triggers.Add(id)) return;
Changed?.Invoke();
}
// ── 선택 이력 ────────────────────────────────────────────────
public bool HasChosen(string code) => _state.ChosenCodes.Contains(code);
public void RecordChoice(string code)
{
if (string.IsNullOrEmpty(code) || !_state.ChosenCodes.Add(code)) return;
Changed?.Invoke();
}
// ── 저장 / 로드 ──────────────────────────────────────────────
// 플레이 시작 시엔 항상 빈 상태로 시작한다 (테스트 반복이 꼬이지 않게).
// 이어하기를 만들 때 타이틀 화면 등에서 Load()를 호출하면 된다.
private static string SavePath => Path.Combine(Application.persistentDataPath, "story_state.json");
public void Save() => File.WriteAllText(SavePath, _state.ToJson());
public void CallLoad() => Load();
// 저장 파일이 있으면 불러온다. 성공 여부 반환.
public bool Load()
{
if (!File.Exists(SavePath))
{
Debug.LogWarning("세이브 파일이 없습니다.");
return false;
}
try
{
var loaded = StoryState.FromJson(File.ReadAllText(SavePath));
if (loaded == null)
{
Debug.LogWarning("세이브 파일이 잘못되었습니다.");
return false;
}
_state = loaded;
Changed?.Invoke();
return true;
}
catch (Exception e)
{
Debug.LogWarning($"[StoryManager] 저장 파일 로드 실패: {e.Message}");
return false;
}
}
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 인스턴스 참조가 안 남게
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics() => Instance = null;
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
using UnityEngine;
// 장소 프리팹 안의 캐릭터 자리 하나.
// 이 캐릭터가 지금 이 장소에서 걸 수 있는 대화(StoryBeat)가 하나라도 있으면 캐릭터를 켜고, 없으면 끈다.
// 갱신은 LocationManager가 장소 입장 시 / 스토리 상태 변화 시 Refresh를 호출해서 이뤄진다.
//
// 배치법: 이 컴포넌트는 항상 켜져 있는 빈 오브젝트에 붙이고,
// 캐릭터 본체(CharacterVoiceObject + DialogPlayer)는 그 자식으로 두고 _character에 연결.
// 참고: 조건 없는 잡담 비트를 하나 등록해 두면 그 캐릭터는 항상 등장한다.
public class CharacterSlot : MonoBehaviour
{
[Tooltip("캐릭터 본체 (CharacterVoiceObject + DialogPlayer가 붙은 자식 오브젝트)")]
[SerializeField] private CharacterVoiceObject _character;
public void Refresh(StoryDatabase database, LocationData location)
{
if (_character == null || _character.Character == null) return;
_character.gameObject.SetActive(database.HasPlayableBeat(location, _character.Character));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
using UnityEditor;
using UnityEngine;
// StoryBeat를 리스트에서 접었을 때 "Element N" 대신 요약 제목을 보여준다.
// Note가 있으면 Note 우선, 없으면 "[진행도] 장소 · 캐릭터 · 대화"를 자동 조합.
[CustomPropertyDrawer(typeof(StoryBeat))]
public class StoryBeatDrawer : PropertyDrawer
{
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
=> EditorGUI.GetPropertyHeight(property, label, includeChildren: true);
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
string summary = BuildSummary(property);
if (!string.IsNullOrEmpty(summary))
label = new GUIContent(summary, label.tooltip);
EditorGUI.PropertyField(position, property, label, includeChildren: true);
}
private static string BuildSummary(SerializedProperty property)
{
string note = property.FindPropertyRelative("Note").stringValue;
if (!string.IsNullOrWhiteSpace(note))
return note;
string location = NameOf(property.FindPropertyRelative("Location").objectReferenceValue);
string character = NameOf(property.FindPropertyRelative("Character").objectReferenceValue);
string group = NameOf(property.FindPropertyRelative("Group").objectReferenceValue);
// 아무것도 안 채워진 새 항목은 기본 라벨(Element N) 유지
if (location == null && character == null && group == null)
return null;
var progress = property.FindPropertyRelative("Condition").FindPropertyRelative("MainProgress");
int min = progress.FindPropertyRelative("Min").intValue;
int max = progress.FindPropertyRelative("Max").intValue;
string range = max >= 9999 ? $"[{min}~]" : $"[{min}~{max}]";
return $"{range} {location ?? "?"} · {character ?? "?"} · {group ?? "?"}";
}
// 에셋별 표시 이름 (지정 안 됐으면 에셋 이름, 그마저 없으면 null)
private static string NameOf(Object obj)
{
switch (obj)
{
case LocationData loc:
return string.IsNullOrWhiteSpace(loc.DisplayName) ? loc.name : loc.DisplayName;
case CharacterData ch:
return string.IsNullOrWhiteSpace(ch.Name) ? ch.name : ch.Name;
case DialogGroup grp:
return string.IsNullOrWhiteSpace(grp.DialogGroupName) ? grp.name : grp.DialogGroupName;
default:
return obj != null ? obj.name : null;
}
}
}

View File

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

View File

@@ -0,0 +1,14 @@
using UnityEngine;
// 게임 속 장소 하나 (로비, 사무실, 법정 …).
// StoryBeat가 "어느 장소의 대화인지"를 이 에셋으로 가리키고,
// LocationManager가 이동 시 Prefab을 띄운다.
[CreateAssetMenu(menuName = "Story/Location")]
public class LocationData : ScriptableObject
{
[Tooltip("장소 표시 이름 (이동 메뉴 등 UI용)")]
public string DisplayName;
[Tooltip("이 장소의 프리팹 — 배경 + 캐릭터 슬롯(CharacterSlot)들 + 이동 버튼 등")]
public GameObject Prefab;
}

View File

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

View File

@@ -0,0 +1,29 @@
using System;
using UnityEngine;
// 스토리 비트 하나 — "어느 장소에서, 어느 캐릭터가, 어떤 조건일 때, 무슨 대화를 하는가".
// StoryDatabase의 목록이 게임 전체 스토리 흐름의 단일 원본이다.
[Serializable]
public class StoryBeat
{
[Tooltip("에디터 식별용 메모 (게임에는 안 나옴)")]
public string Note;
[Tooltip("이 대화가 일어나는 장소")]
public LocationData Location;
[Tooltip("대화를 거는 캐릭터")]
public CharacterData Character;
[Tooltip("재생할 대화 그래프 (.dlg = DialogGroup)")]
public DialogGroup Group;
[Tooltip("활성 조건 (진행도/호감도 범위 + 트리거)")]
public DialogCondition Condition = new();
[Tooltip("이 대화를 처음 완료하면 메인 진행도 +N (필수 대화가 아니면 0)")]
[Min(0)] public int ProgressOnComplete;
[Tooltip("대화 선택 메뉴에 표시할 이름 (비우면 그룹 이름 사용)")]
public string MenuLabel;
}

View File

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

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using UnityEngine;
// 스토리 비트 전체 목록 — "누가 어디서 언제 무슨 대화를 하는가"의 단일 원본.
// 캐릭터 슬롯 활성화(HasPlayableBeat)와 DialogPlayer의 대화 후보(GetPlayableBeats)가
// 전부 이 에셋을 조회한다. 게임에 하나만 만들어 LocationManager에 꽂아 쓴다.
[CreateAssetMenu(menuName = "Story/Story Database")]
public class StoryDatabase : ScriptableObject
{
[Tooltip("스토리 비트 목록 — 위에 있을수록 대화 선택 메뉴에서 우선순위가 높다. " +
"진행도 순으로 정렬해 두면 이 목록이 곧 시나리오 진행표가 된다")]
[SerializeField] private List<StoryBeat> _beats = new();
public IReadOnlyList<StoryBeat> Beats => _beats;
// 이 장소·캐릭터의 비트 중 지금 조건을 만족하는 것들 (목록 순서 유지)
public List<StoryBeat> GetPlayableBeats(LocationData location, CharacterData character)
{
var result = new List<StoryBeat>();
foreach (var beat in _beats)
if (IsPlayable(beat, location, character))
result.Add(beat);
return result;
}
// 이 장소에서 이 캐릭터가 걸 수 있는 대화가 하나라도 있는가 (캐릭터 슬롯 활성화 판정)
public bool HasPlayableBeat(LocationData location, CharacterData character)
{
foreach (var beat in _beats)
if (IsPlayable(beat, location, character))
return true;
return false;
}
// 강제 재생(DialogPlayer.PlayGroup)용 — 이 그룹이 등록된 첫 비트 (없으면 null)
public StoryBeat FindBeat(DialogGroup group)
{
if (group == null) return null;
foreach (var beat in _beats)
if (beat.Group == group)
return beat;
return null;
}
private static bool IsPlayable(StoryBeat beat, LocationData location, CharacterData character)
{
if (beat.Location != location || beat.Character != character || beat.Group == null)
return false;
return beat.Condition == null || beat.Condition.IsMet(character);
}
}

View File

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

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// 이야기 진행 상태 데이터 (메인 진행도 / 호감도 / 대화 이력 / 선택 이력).
// 상태 보관과 JSON 변환만 담당한다 — 게임 로직에서는 StoryManager를 통해 접근할 것.
public class StoryState
{
public int MainProgress;
public readonly Dictionary<string, int> Affection = new(); // 캐릭터 Id → 호감도
public readonly HashSet<string> CompletedDialogs = new(); // 완료한 DialogGroup 이름
public readonly HashSet<string> ChosenCodes = new(); // 골랐던 선택지 Code
public readonly HashSet<string> Triggers = new(); // 켜진 트리거 Id (대화 조건 검사용)
public void Clear()
{
MainProgress = 0;
Affection.Clear();
CompletedDialogs.Clear();
ChosenCodes.Clear();
Triggers.Clear();
}
// ── JSON 변환 ────────────────────────────────────────────────
// JsonUtility가 Dictionary/HashSet을 직렬화하지 못해 리스트로 바꿔 저장한다.
[Serializable]
private class JsonData
{
public int MainProgress;
public List<string> AffectionIds = new();
public List<int> AffectionValues = new();
public List<string> CompletedDialogs = new();
public List<string> ChosenCodes = new();
public List<string> Triggers = new();
}
public string ToJson()
{
var data = new JsonData { MainProgress = MainProgress };
foreach (var kvp in Affection)
{
data.AffectionIds.Add(kvp.Key);
data.AffectionValues.Add(kvp.Value);
}
data.CompletedDialogs.AddRange(CompletedDialogs);
data.ChosenCodes.AddRange(ChosenCodes);
data.Triggers.AddRange(Triggers);
return JsonUtility.ToJson(data, prettyPrint: true);
}
// json이 유효하지 않으면 null 반환
public static StoryState FromJson(string json)
{
var data = JsonUtility.FromJson<JsonData>(json);
if (data == null) return null;
var state = new StoryState { MainProgress = data.MainProgress };
for (int i = 0; i < data.AffectionIds.Count && i < data.AffectionValues.Count; i++)
state.Affection[data.AffectionIds[i]] = data.AffectionValues[i];
state.CompletedDialogs.UnionWith(data.CompletedDialogs);
state.ChosenCodes.UnionWith(data.ChosenCodes);
state.Triggers.UnionWith(data.Triggers ?? new List<string>());
return state;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Character/CharacterData")]
public class CharacterData : ScriptableObject
{
public string Id;
public string Name;
public Sprite Portrait;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Expression")]
public class ExpressionData : ScriptableObject
{
[HideInInspector] public int AnimationLayer = 1;
public string StateName;
public float CrossFadeDuration = 0.2f;
public AnimationClip AnimClip;
}

View File

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

View File

@@ -0,0 +1,10 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Gesture")]
public class GestureData : ScriptableObject
{
[HideInInspector] public int AnimationLayer = 0;
public string StateName;
public float CrossFadeDuration = 0.2f;
public AnimationClip AnimClip;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7287df3d52d586a4085d412938dae461

View File

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

View File

@@ -0,0 +1,8 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/VoiceClip")]
public class VoiceClip : ScriptableObject
{
public string VoiceCode; //해당 음성 고유코드
public AudioClip Clip;
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More