first
This commit is contained in:
49
.gitattributes
vendored
Normal file
49
.gitattributes
vendored
Normal 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
94
.gitignore
vendored
Normal 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
8
Assets/01_Scenes.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15f3618f98b3afe47bcc89f8a9ac83ea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Assets/01_Scenes/SampleScene.unity
LFS
Normal file
BIN
Assets/01_Scenes/SampleScene.unity
LFS
Normal file
Binary file not shown.
7
Assets/01_Scenes/SampleScene.unity.meta
Normal file
7
Assets/01_Scenes/SampleScene.unity.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99c9720ab356a0642a771bea13969a05
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts.meta
Normal file
8
Assets/02_Scripts.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e4f7e411c38464a49b3b6f2309246976
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/Communication.meta
Normal file
8
Assets/02_Scripts/Communication.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb2d49222f1c03f45aa898517c6904fe
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/Communication/Dialog.meta
Normal file
8
Assets/02_Scripts/Communication/Dialog.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5dd56c374fcdca41927418e4717e370
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f38182435621e88449adc22d629f6e6b
|
||||
9
Assets/02_Scripts/Communication/Dialog/DialogChoice.cs
Normal file
9
Assets/02_Scripts/Communication/Dialog/DialogChoice.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
public class DialogChoice
|
||||
{
|
||||
public DialogNode DestinationNode;
|
||||
public string ChoiceText;
|
||||
public string Code; // 선택 시 기록/식별용 코드 (선택 입력, 영문 권장)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd5ea8e7c904dd24e967d91b86004efb
|
||||
62
Assets/02_Scripts/Communication/Dialog/DialogCondition.cs
Normal file
62
Assets/02_Scripts/Communication/Dialog/DialogCondition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f307619d54e9a74990a68cda19bff5b
|
||||
55
Assets/02_Scripts/Communication/Dialog/DialogGestureItem.cs
Normal file
55
Assets/02_Scripts/Communication/Dialog/DialogGestureItem.cs
Normal 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가 선택을 취소해 손에서 놓게 됨
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c11477ec44fef124bb7279924beb66af
|
||||
8
Assets/02_Scripts/Communication/Dialog/DialogGroup.cs
Normal file
8
Assets/02_Scripts/Communication/Dialog/DialogGroup.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(menuName = "Communication/Dialog Group")]
|
||||
public class DialogGroup : ScriptableObject
|
||||
{
|
||||
public string DialogGroupName;
|
||||
public DialogNode StartNode;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72ef984dbd5eb29498ece3d2dae297ea
|
||||
62
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal file
62
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb30abdb4b5671b458096d48d11c7f27
|
||||
520
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
520
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal 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 불가)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9838e0e0a3edefa4e92ddbb98aaa3ce5
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f182cc352a11ed48b27e690bdb10520
|
||||
51
Assets/02_Scripts/Communication/Dialog/DialogVariables.cs
Normal file
51
Assets/02_Scripts/Communication/Dialog/DialogVariables.cs
Normal 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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6bb90f809bd62e409383e02949f32c3
|
||||
8
Assets/02_Scripts/Communication/Dialog/GraphTool.meta
Normal file
8
Assets/02_Scripts/Communication/Dialog/GraphTool.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb0dad36a50363647a4b9cd61d078b20
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bad231f70f838c84288d07f754f51ed7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 →");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25bc0691603f93e4196ae428c028e855
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f25a0745082bd7c439679495337d3598
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ae5ca89bbed445479d9023586f0c041
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be386f8d9f138f8449c9e84034f5e1ef
|
||||
@@ -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 →");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be6c5a15779e30f40910985e5d7cfbd9
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9432f3355b50d3d458cff5e56b6c9206
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15b46c322dec78d438a144f875d67d42
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f76763c19a33214ca518048c8a89799
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ad54cf039f672845a54666166b5021c
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d31004aee38951a4faaff37d886d3a65
|
||||
47
Assets/02_Scripts/Communication/Dialog/GraphTool/README.md
Normal file
47
Assets/02_Scripts/Communication/Dialog/GraphTool/README.md
Normal 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`로 합쳐집니다.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fa58401314123a4b90fa0fda5240a18
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1622ac8790b08674cbb2164b2a8dd1f7
|
||||
8
Assets/02_Scripts/Communication/Voice.meta
Normal file
8
Assets/02_Scripts/Communication/Voice.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98959040e708aa04891672a6c21a9479
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa8a1a66f46e7ef4f99770437e10ae34
|
||||
109
Assets/02_Scripts/Communication/Voice/LipSync.cs
Normal file
109
Assets/02_Scripts/Communication/Voice/LipSync.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Communication/Voice/LipSync.cs.meta
Normal file
2
Assets/02_Scripts/Communication/Voice/LipSync.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae0d9abbe32fe5b4e8e4886143e1e5e2
|
||||
4
Assets/02_Scripts/ISceneInitializable.cs
Normal file
4
Assets/02_Scripts/ISceneInitializable.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
public interface ISceneInitializable
|
||||
{
|
||||
public void OnSceneLoaded();
|
||||
}
|
||||
2
Assets/02_Scripts/ISceneInitializable.cs.meta
Normal file
2
Assets/02_Scripts/ISceneInitializable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38039c1904e850146a01668e869851be
|
||||
8
Assets/02_Scripts/Managers.meta
Normal file
8
Assets/02_Scripts/Managers.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6265a74f863637546a971e91b58529e4
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
23
Assets/02_Scripts/Managers/GameManager.cs
Normal file
23
Assets/02_Scripts/Managers/GameManager.cs
Normal 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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/GameManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 197a05ee04c0ac746adfedb27e01fdf5
|
||||
40
Assets/02_Scripts/Managers/InputManager.cs
Normal file
40
Assets/02_Scripts/Managers/InputManager.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/InputManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/InputManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 657e64bd5c92e4843a0d5b9892e05e45
|
||||
85
Assets/02_Scripts/Managers/LocationManager.cs
Normal file
85
Assets/02_Scripts/Managers/LocationManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/LocationManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/LocationManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6244c1dafce01c04db303bbaf499f989
|
||||
156
Assets/02_Scripts/Managers/SceneLoadManager.cs
Normal file
156
Assets/02_Scripts/Managers/SceneLoadManager.cs
Normal 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("씬 전환 작업이 취소됨");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/SceneLoadManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/SceneLoadManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3e48e3fcdff9c843abca3c5583a43ad
|
||||
237
Assets/02_Scripts/Managers/SoundManager.cs
Normal file
237
Assets/02_Scripts/Managers/SoundManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6de75b1b76588eb4280f8d111def36b7
|
||||
152
Assets/02_Scripts/Managers/StoryManager.cs
Normal file
152
Assets/02_Scripts/Managers/StoryManager.cs
Normal 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;
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/StoryManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/StoryManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f27662344dffe4a867f63cf1605d63
|
||||
8
Assets/02_Scripts/Story.meta
Normal file
8
Assets/02_Scripts/Story.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f3d4248a3d3f7743a90b8f88e1903bc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
20
Assets/02_Scripts/Story/CharacterSlot.cs
Normal file
20
Assets/02_Scripts/Story/CharacterSlot.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Story/CharacterSlot.cs.meta
Normal file
2
Assets/02_Scripts/Story/CharacterSlot.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8df8bed7639e304cbe98206c74e9f13
|
||||
8
Assets/02_Scripts/Story/Editor.meta
Normal file
8
Assets/02_Scripts/Story/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f8b437cb3f4c66489eed95f0c1158a7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
58
Assets/02_Scripts/Story/Editor/StoryBeatDrawer.cs
Normal file
58
Assets/02_Scripts/Story/Editor/StoryBeatDrawer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Story/Editor/StoryBeatDrawer.cs.meta
Normal file
2
Assets/02_Scripts/Story/Editor/StoryBeatDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eade7ff33c9514b479d3f1e4ba2da912
|
||||
14
Assets/02_Scripts/Story/LocationData.cs
Normal file
14
Assets/02_Scripts/Story/LocationData.cs
Normal 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;
|
||||
}
|
||||
2
Assets/02_Scripts/Story/LocationData.cs.meta
Normal file
2
Assets/02_Scripts/Story/LocationData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e92ba75bdc5f28f4983e164d45c3b27d
|
||||
29
Assets/02_Scripts/Story/StoryBeat.cs
Normal file
29
Assets/02_Scripts/Story/StoryBeat.cs
Normal 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;
|
||||
}
|
||||
2
Assets/02_Scripts/Story/StoryBeat.cs.meta
Normal file
2
Assets/02_Scripts/Story/StoryBeat.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b03cd6e1bb5fbb44b82670d620875c3
|
||||
51
Assets/02_Scripts/Story/StoryDatabase.cs
Normal file
51
Assets/02_Scripts/Story/StoryDatabase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Story/StoryDatabase.cs.meta
Normal file
2
Assets/02_Scripts/Story/StoryDatabase.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c7c50927085b814097de120bc52f6c2
|
||||
65
Assets/02_Scripts/Story/StoryState.cs
Normal file
65
Assets/02_Scripts/Story/StoryState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Story/StoryState.cs.meta
Normal file
2
Assets/02_Scripts/Story/StoryState.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47259c7403ae7e42b37a411170bc21c
|
||||
8
Assets/02_Scripts/_Data.meta
Normal file
8
Assets/02_Scripts/_Data.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65db5ab1573a5994aa96d657de132d43
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/_Data/Character.meta
Normal file
8
Assets/02_Scripts/_Data/Character.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7bdce60fa4722c409858d6a5a234ec3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
9
Assets/02_Scripts/_Data/Character/CharacterData.cs
Normal file
9
Assets/02_Scripts/_Data/Character/CharacterData.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(menuName = "Character/CharacterData")]
|
||||
public class CharacterData : ScriptableObject
|
||||
{
|
||||
public string Id;
|
||||
public string Name;
|
||||
public Sprite Portrait;
|
||||
}
|
||||
2
Assets/02_Scripts/_Data/Character/CharacterData.cs.meta
Normal file
2
Assets/02_Scripts/_Data/Character/CharacterData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c44a4226e5f3e344a96eac21f829952f
|
||||
8
Assets/02_Scripts/_Data/Communication.meta
Normal file
8
Assets/02_Scripts/_Data/Communication.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8078f79ae4624044fa52f6d4986a10bd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/_Data/Communication/Dialog.meta
Normal file
8
Assets/02_Scripts/_Data/Communication/Dialog.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 864a90870193471458445ecc75905891
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b705522cfca24d141b3d560aeffb2c14
|
||||
10
Assets/02_Scripts/_Data/Communication/Dialog/GestureData.cs
Normal file
10
Assets/02_Scripts/_Data/Communication/Dialog/GestureData.cs
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7287df3d52d586a4085d412938dae461
|
||||
8
Assets/02_Scripts/_Data/Communication/Voice.meta
Normal file
8
Assets/02_Scripts/_Data/Communication/Voice.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5b1fe96389ab554db24396abacdc278
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/_Data/Communication/Voice/VoiceClip.cs
Normal file
8
Assets/02_Scripts/_Data/Communication/Voice/VoiceClip.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(menuName = "Communication/VoiceClip")]
|
||||
public class VoiceClip : ScriptableObject
|
||||
{
|
||||
public string VoiceCode; //해당 음성 고유코드
|
||||
public AudioClip Clip;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc306ebaeba77b94dbb631e9fdc0e864
|
||||
8
Assets/02_Scripts/_UI.meta
Normal file
8
Assets/02_Scripts/_UI.meta
Normal 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
Reference in New Issue
Block a user