수정사항
This commit is contained in:
@@ -9,16 +9,6 @@ public enum DialogNodeKind
|
||||
[InspectorName("종료")] End, // 대화를 끝낸다 (BGM 처리만 정함)
|
||||
}
|
||||
|
||||
// 이 대사에서 이미 떠 있던 화면 그림을 걷어낼지.
|
||||
// 그림 지정이 "비우면 변경 없음"(BGM과 같은 규칙)이라 지우는 길을 따로 열어 둔 것이다.
|
||||
public enum SpriteClear
|
||||
{
|
||||
[InspectorName("없음")] None,
|
||||
[InspectorName("앞 그림")] Front,
|
||||
[InspectorName("옆 그림")] Side,
|
||||
[InspectorName("둘 다")] Both,
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Communication/Dialog Node")]
|
||||
public class DialogNode : ScriptableObject
|
||||
{
|
||||
@@ -51,20 +41,25 @@ public class DialogNode : ScriptableObject
|
||||
"비우면 DialogHud의 기본 스타일을 쓴다")]
|
||||
public TypewriterStyle Typewriter;
|
||||
|
||||
[Header("Sprite")]
|
||||
[Tooltip("이 대사 동안 화면 앞쪽(가운데 아래)에 세울 그림. 인물이 보통이지만 물건·서류도 된다. " +
|
||||
"비우면 변경 없음 — BGM과 같은 규칙이라 인물이 서 있는 동안 매 대사에 다시 꽂을 필요가 없다. " +
|
||||
"대화가 끝나면 대화 전에 떠 있던 그림으로 되돌아간다")]
|
||||
[Header("Stage")]
|
||||
[Tooltip("이 대사부터 바꿀 포즈. '변경 없음'이 기본이라 인물이 서 있는 동안 매 대사에 " +
|
||||
"다시 꽂을 필요가 없고, '숨김'을 고르면 화면에서 내린다. " +
|
||||
"대화가 끝나면 대화 전 포즈로 되돌아간다")]
|
||||
public Pose Pose;
|
||||
|
||||
[Tooltip("위 Pose를 적용할 대상. 화자와 다를 수 있어서 Speaker와 별개다 — " +
|
||||
"나루호도가 말하는 동안 화면의 마요이가 표정을 바꾸는 식. " +
|
||||
"Pose가 '변경 없음'이면 비워두면 되고, 포즈를 골랐는데 비어 있으면 아무 일도 일어나지 않는다")]
|
||||
public CharacterData PoseTarget;
|
||||
|
||||
[Tooltip("이 대사에만 번쩍 뜨는 그림 — '이의있음!' '받아랏!' 같은 연출. " +
|
||||
"다음 대사로 넘어가면 자동으로 사라진다 (SFX와 같은 1회성). 비우면 안 뜬다")]
|
||||
public Sprite FrontSprite;
|
||||
|
||||
[Tooltip("이 대사 동안 화면 옆(왼쪽)에 놓을 그림. 제시한 물건을 놓고 설명할 때 쓴다. " +
|
||||
"비우면 변경 없음. 대화가 끝나면 자동으로 걷힌다")]
|
||||
"비우면 사라진다 — 여러 대사에 걸쳐 보이려면 그 대사마다 같은 그림을 지정할 것")]
|
||||
public Sprite SideSprite;
|
||||
|
||||
[Tooltip("이미 떠 있던 그림을 이 대사에서 걷어낸다. 그림 지정이 '비우면 변경 없음'이라 " +
|
||||
"지울 때만 쓴다. 같은 노드에서 새 그림도 지정했다면 새 그림이 이긴다")]
|
||||
public SpriteClear ClearSprite;
|
||||
|
||||
[Header("Flow")]
|
||||
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
|
||||
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기
|
||||
|
||||
@@ -23,6 +23,10 @@ public class DialogPlayer : MonoBehaviour
|
||||
// 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원.
|
||||
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
|
||||
private readonly Dictionary<Animator, (int gestureHash, int expressionHash, bool hasExpression)> _touchedAnimators = new();
|
||||
|
||||
// 대화 중 포즈를 바꾼 캐릭터들의 원래 포즈 — 대화 종료 시 전부 복원.
|
||||
// (끼어든 다른 NPC의 포즈도 포함되므로 Animator와 같은 방식으로 딕셔너리에 추적한다)
|
||||
private readonly Dictionary<CharacterPoses, Pose> _touchedPoses = new();
|
||||
public bool IsPlaying { get; private set; }
|
||||
|
||||
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
|
||||
@@ -72,6 +76,10 @@ private void OnDestroy()
|
||||
// 선택 메뉴를 띄우지 않는다 — 메뉴는 플레이어가 "말을 건" 경우의 UI다.
|
||||
public void PlayAuto() => _ = Play(autoSelect: true);
|
||||
|
||||
// 「이야기한다」 버튼용 — 선택 메뉴를 띄우고 대화를 시작한다.
|
||||
// PlayAuto와 달리 후보가 여럿이면 DialogEnterHud로 고르게 한다. (UnityEvent 연결용 void)
|
||||
public void Talk() => _ = Play();
|
||||
|
||||
// autoSelect가 true면 후보가 여럿이어도 메뉴 없이 최상단(우선순위 1위) 비트를 바로 재생한다.
|
||||
public async Awaitable Play(bool autoSelect = false)
|
||||
{
|
||||
@@ -202,12 +210,6 @@ private async Awaitable PlayBeat(StoryBeat beat)
|
||||
// 도달한 종료 노드가 정한다. 중간에 끊기면(씬 전환 등) false로 남아 장소 BGM으로 복귀한다.
|
||||
bool keepBgmOnEnd = false;
|
||||
|
||||
// 대화 전에 떠 있던 화면 그림 — 끝나면 이 상태로 되돌린다.
|
||||
// 제스처·표정 Animator를 되돌리는 것과 같은 취지다: 대화는 장소 화면을 잠시 빌려 쓸 뿐이라
|
||||
// 장소에 서 있던 인물이 대화 한 번 했다고 사라지거나 표정이 굳어 있으면 안 된다.
|
||||
var frontBefore = FrontSprite.Instance != null ? FrontSprite.Instance.Current : null;
|
||||
var sideBefore = SideSprite.Instance != null ? SideSprite.Instance.Current : null;
|
||||
|
||||
try
|
||||
{
|
||||
var node = beat.Group.StartNode;
|
||||
@@ -291,10 +293,11 @@ private async Awaitable PlayBeat(StoryBeat beat)
|
||||
if (SoundManager.Instance != null && !keepBgmOnEnd)
|
||||
SoundManager.Instance.ClearOverrideBGM();
|
||||
|
||||
// 화면 그림 복원 — Show(null)은 Hide()와 같으므로 "대화 전에 아무것도 없었다"도 그대로 처리된다
|
||||
if (FrontSprite.Instance != null) FrontSprite.Instance.Show(frontBefore);
|
||||
if (SideSprite.Instance != null) SideSprite.Instance.Show(sideBefore);
|
||||
// 앞/옆 그림은 대화 전용 오버레이라 대화가 끝나면 무조건 걷는다
|
||||
if (FrontSprite.Instance != null) FrontSprite.Instance.Hide();
|
||||
if (SideSprite.Instance != null) SideSprite.Instance.Hide();
|
||||
|
||||
RestoreDefaultPoses();
|
||||
RestoreDefaultAnimations();
|
||||
}
|
||||
}
|
||||
@@ -351,8 +354,8 @@ private async Awaitable<bool> PlayNode(DialogNode node)
|
||||
if (node.Sfx != null && SoundManager.Instance != null)
|
||||
SoundManager.Instance.PlaySFX(node.Sfx);
|
||||
|
||||
// 화면 그림(앞/옆) — BGM과 같은 규칙으로 비우면 변경 없음
|
||||
ApplySprites(node);
|
||||
// 포즈 + 화면 그림
|
||||
ApplyStageVisuals(node);
|
||||
|
||||
// 보이스 재생
|
||||
if (node.Voice != null && node.Speaker != null)
|
||||
@@ -389,21 +392,53 @@ private async Awaitable<bool> PlayNode(DialogNode node)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 노드가 지정한 화면 그림을 반영한다.
|
||||
// 비어 있는 지정은 "변경 없음"이라(BGM과 같은 규칙) 인물이 서 있는 동안 매 대사에 다시 꽂을 필요가 없다.
|
||||
// 지우기를 먼저, 지정을 나중에 처리한다 — 한 노드에서 둘 다 걸면 새 그림이 이긴다.
|
||||
private static void ApplySprites(DialogNode node)
|
||||
// 노드가 지정한 무대 연출(포즈 + 앞/옆 그림)을 반영한다. 셋의 수명 규칙이 각각 다르다:
|
||||
// Pose — 지속. '변경 없음'이 기본이라 인물이 서 있는 동안 다시 꽂을 필요가 없다
|
||||
// FrontSprite — 1회성. 지정 안 한 노드에선 사라진다 ("이의있음!" 같은 번쩍 연출)
|
||||
// SideSprite — 지정한 대사에서만. 비우면 사라진다
|
||||
// 뒤의 둘은 Show(null)이 곧 Hide라 그대로 넘기면 두 경우가 한 줄로 처리된다.
|
||||
private void ApplyStageVisuals(DialogNode node)
|
||||
{
|
||||
var front = FrontSprite.Instance;
|
||||
var side = SideSprite.Instance;
|
||||
bool hasFront = front != null;
|
||||
bool hasSide = side != null;
|
||||
if (FrontSprite.Instance != null) FrontSprite.Instance.Show(node.FrontSprite);
|
||||
if (SideSprite.Instance != null) SideSprite.Instance.Show(node.SideSprite);
|
||||
|
||||
if (hasFront && node.ClearSprite is SpriteClear.Front or SpriteClear.Both) front.Hide();
|
||||
if (hasSide && node.ClearSprite is SpriteClear.Side or SpriteClear.Both) side.Hide();
|
||||
if (node.Pose == Pose.Keep) return; // 변경 없음 — 지금 포즈를 그대로 둔다
|
||||
|
||||
if (hasFront && node.FrontSprite != null) front.Show(node.FrontSprite);
|
||||
if (hasSide && node.SideSprite != null) side.Show(node.SideSprite);
|
||||
// 대상을 안 정했으면 아무 일도 하지 않는다.
|
||||
// 화자나 장소 인물로 자동 유추할 수도 있지만, 그러면 "누가 바뀌는지"가 노드만 봐서는
|
||||
// 보이지 않는다. 말하는 사람과 화면에 비치는 사람이 다를 수 있는 이상 명시를 요구하는 편이 낫다.
|
||||
if (node.PoseTarget == null)
|
||||
{
|
||||
Debug.LogWarning($"[DialogPlayer] Pose({node.Pose})를 지정했으나 Pose Target이 비어 있음 — 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
var poses = FindPoses(node.PoseTarget);
|
||||
if (poses == null)
|
||||
{
|
||||
Debug.LogWarning($"[DialogPlayer] {node.PoseTarget.name}의 CharacterPoses를 찾지 못함 — 포즈 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
// 대화 종료 시 복원할 원래 포즈를 처음 건드릴 때만 기억한다
|
||||
if (!_touchedPoses.ContainsKey(poses)) _touchedPoses[poses] = poses.Current;
|
||||
poses.Apply(node.Pose);
|
||||
}
|
||||
|
||||
// 포즈를 적용할 대상의 CharacterPoses. 그 캐릭터가 지금 씬에 없으면 null.
|
||||
// 제스처/표정과 달리 화자를 전혀 보지 않는다 — 대상은 노드가 명시한 것 하나뿐이다.
|
||||
private static CharacterPoses FindPoses(CharacterData target)
|
||||
{
|
||||
var obj = CharacterVoiceObject.Find(target);
|
||||
return obj != null ? obj.GetComponentInChildren<CharacterPoses>(includeInactive: true) : null;
|
||||
}
|
||||
|
||||
// 대화 중 포즈를 바꾼 모든 캐릭터(끼어든 NPC 포함)를 원래 포즈로 복원
|
||||
private void RestoreDefaultPoses()
|
||||
{
|
||||
foreach (var kvp in _touchedPoses)
|
||||
if (kvp.Key != null) kvp.Key.Apply(kvp.Value);
|
||||
_touchedPoses.Clear();
|
||||
}
|
||||
|
||||
// 호감도 분기 노드의 조건식 평가.
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
|
||||
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
|
||||
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
|
||||
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
|
||||
[ScriptedImporter(21, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
|
||||
[ScriptedImporter(24, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
|
||||
internal class DialogGraphImporter : ScriptedImporter
|
||||
{
|
||||
public override void OnImportAsset(AssetImportContext ctx)
|
||||
@@ -127,9 +127,10 @@ public override void OnImportAsset(AssetImportContext ctx)
|
||||
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.Pose = GetInputPortValue<Pose>(gn.GetInputPortByName(DialogLineNode.PORT_POSE));
|
||||
dn.PoseTarget = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogLineNode.PORT_POSE_TARGET));
|
||||
dn.FrontSprite = GetInputPortValue<Sprite>(gn.GetInputPortByName(DialogLineNode.PORT_FRONT_SPRITE));
|
||||
dn.SideSprite = GetInputPortValue<Sprite>(gn.GetInputPortByName(DialogLineNode.PORT_SIDE_SPRITE));
|
||||
dn.ClearSprite = GetInputPortValue<SpriteClear>(gn.GetInputPortByName(DialogLineNode.PORT_CLEAR_SPRITE));
|
||||
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
|
||||
dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM));
|
||||
dn.Sfx = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_SFX));
|
||||
|
||||
@@ -19,9 +19,10 @@ internal class DialogLineNode : DialogGraphNode
|
||||
public const string PORT_TALK = "TalkText";
|
||||
public const string PORT_GESTURE = "Gesture";
|
||||
public const string PORT_EXPRESSION = "Expression";
|
||||
public const string PORT_POSE = "Pose";
|
||||
public const string PORT_POSE_TARGET = "PoseTarget";
|
||||
public const string PORT_FRONT_SPRITE = "FrontSprite";
|
||||
public const string PORT_SIDE_SPRITE = "SideSprite";
|
||||
public const string PORT_CLEAR_SPRITE = "ClearSprite";
|
||||
public const string PORT_VOICE = "Voice";
|
||||
public const string PORT_BGM = "Bgm";
|
||||
public const string PORT_SFX = "Sfx";
|
||||
@@ -72,16 +73,28 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
|
||||
context.AddInputPort<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
|
||||
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
|
||||
|
||||
// 화면에 세우는 그림 — Gesture/Expression(Animator)과 달리 2D 스프라이트를 직접 갈아끼운다
|
||||
// 포즈 — 캐릭터 그림 갈아끼우기. 어떤 그림이 뜨는지는 캐릭터 프리팹의 CharacterPoses가 정한다.
|
||||
//
|
||||
// Pose를 Target보다 먼저 두는 것은 읽는 순서 때문이다. "변경 없음 → (대상 없음)"은
|
||||
// 위에서부터 읽으면 자연스럽지만, 순서가 반대면 빈 Target이 덜 채운 항목처럼 보인다.
|
||||
// enum의 0번이 Keep이라, 이 포트를 건드리지 않으면 자동으로 "변경 없음"이 된다.
|
||||
context.AddInputPort<Pose>(PORT_POSE).WithDisplayName("Pose")
|
||||
.WithTooltip("이 대사부터 바꿀 포즈. '변경 없음'이 기본이라 인물이 서 있는 동안 " +
|
||||
"매 대사에 다시 꽂을 필요가 없고, '숨김'을 고르면 화면에서 내린다").Build();
|
||||
|
||||
// 대상이 Speaker와 별개인 것이 중요하다: 말하는 사람과 화면에 비치는 사람이 다를 수 있다.
|
||||
context.AddInputPort<CharacterData>(PORT_POSE_TARGET).WithDisplayName("Pose Target")
|
||||
.WithTooltip("위 Pose를 적용할 대상. 화자와 다를 수 있다 — 나루호도가 말하는 동안 " +
|
||||
"화면의 마요이가 표정을 바꾸는 식. " +
|
||||
"Pose가 '변경 없음'이면 비워두면 되고, 포즈를 골랐는데 비어 있으면 " +
|
||||
"아무 일도 일어나지 않는다").Build();
|
||||
|
||||
context.AddInputPort<Sprite>(PORT_FRONT_SPRITE).WithDisplayName("Front Sprite")
|
||||
.WithTooltip("이 대사 동안 화면 앞쪽(가운데 아래)에 세울 그림. 인물이 보통이지만 물건·서류도 된다. " +
|
||||
"비우면 변경 없음 — BGM과 같은 규칙이라 인물이 서 있는 동안 매 대사에 다시 꽂을 필요가 없다").Build();
|
||||
.WithTooltip("이 대사에만 번쩍 뜨는 그림 — '이의있음!' '받아랏!' 같은 연출. " +
|
||||
"다음 대사로 넘어가면 자동으로 사라진다 (SFX와 같은 1회성)").Build();
|
||||
context.AddInputPort<Sprite>(PORT_SIDE_SPRITE).WithDisplayName("Side Sprite")
|
||||
.WithTooltip("이 대사 동안 화면 옆(왼쪽)에 놓을 그림. 제시한 물건을 놓고 설명할 때 쓴다. " +
|
||||
"비우면 변경 없음").Build();
|
||||
context.AddInputPort<SpriteClear>(PORT_CLEAR_SPRITE).WithDisplayName("Clear Sprite")
|
||||
.WithTooltip("이미 떠 있던 그림을 이 대사에서 걷어낸다. 그림 지정이 '비우면 변경 없음'이라 " +
|
||||
"지울 때만 쓴다. 같은 노드에서 새 그림도 지정했다면 새 그림이 이긴다").Build();
|
||||
"비우면 사라진다 — 여러 대사에 걸쳐 보이려면 그 대사마다 같은 그림을 지정할 것").Build();
|
||||
|
||||
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
|
||||
context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM")
|
||||
|
||||
@@ -8,21 +8,43 @@ public class CharacterVoiceObject : MonoBehaviour
|
||||
|
||||
private static readonly Dictionary<CharacterData, CharacterVoiceObject> _registry = new();
|
||||
|
||||
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 세션의 등록이 남지 않게
|
||||
// (DialogPlayer·HiddenBranchResolver의 static과 같은 이유)
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStatics() => _registry.Clear();
|
||||
|
||||
// Character를 비워 두면 Dictionary 널 키로 예외가 나므로 등록하지 않는다.
|
||||
// (화자 없는 대화는 CharacterVoiceObject 자체를 붙이지 않는 씬 플레이어로 처리한다)
|
||||
private void OnEnable()
|
||||
{
|
||||
if (Character != null) _registry[Character] = this;
|
||||
else Debug.LogWarning($"[CharacterVoiceObject] Character가 비어 있어 등록하지 않음: {name}");
|
||||
if (Character == null)
|
||||
{
|
||||
Debug.LogWarning($"[CharacterVoiceObject] Character가 비어 있어 등록하지 않음: {name}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 캐릭터의 오브젝트가 둘 이상 켜져 있으면 나중 것이 등록을 가져간다.
|
||||
// 조용히 덮으면 "포즈·보이스가 엉뚱한 오브젝트로 간다"는 재현 어려운 버그가 되므로 알린다.
|
||||
if (_registry.TryGetValue(Character, out var existing) && existing != null && existing != this)
|
||||
Debug.LogWarning($"[CharacterVoiceObject] {Character.name} 오브젝트가 둘 이상 활성 상태 — " +
|
||||
$"'{existing.name}' 대신 '{name}'이 등록된다. 한쪽을 꺼 둘 것");
|
||||
|
||||
_registry[Character] = this;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (Character != null) _registry.Remove(Character);
|
||||
// 내가 등록돼 있을 때만 지운다.
|
||||
// 같은 캐릭터의 오브젝트가 둘일 때 무조건 지우면, 먼저 꺼지는 쪽이 살아 있는 다른 쪽의
|
||||
// 등록까지 날려서 Find가 null을 반환하게 된다 — 포즈도 보이스도 조용히 죽는 상태다.
|
||||
if (Character != null
|
||||
&& _registry.TryGetValue(Character, out var registered) && registered == this)
|
||||
_registry.Remove(Character);
|
||||
}
|
||||
|
||||
// data가 null이면 Dictionary가 예외를 던지므로 먼저 막는다 (화자 없는 대사 경로에서 올 수 있다)
|
||||
public static CharacterVoiceObject Find(CharacterData data)
|
||||
=> _registry.TryGetValue(data, out var obj) ? obj : null;
|
||||
=> data != null && _registry.TryGetValue(data, out var obj) ? obj : null;
|
||||
|
||||
public void Play(AudioClip clip) => VoiceSource.PlayOneShot(clip);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,25 @@ public class LocationManager : MonoBehaviour
|
||||
"비우면 장소 자동 진행을 쓰지 않는다")]
|
||||
[SerializeField] private DialogPlayer _scenePlayer;
|
||||
|
||||
[Tooltip("이 게임에 등장하는 캐릭터 슬롯들 (씬에 상주하는 인물 명단).\n\n" +
|
||||
"장소 프리팹은 이동할 때마다 파괴·재생성되지만 이 슬롯들은 씬에 남는다. " +
|
||||
"어느 장소에 나타날지는 배치가 아니라 StoryDatabase의 비트가 정하므로, " +
|
||||
"캐릭터를 장소에 추가하는 일이 비트 하나 등록으로 끝나고 장소마다 복제할 필요가 없다.\n\n" +
|
||||
"한 캐릭터는 한 번만 등록할 것 — 같은 인물의 슬롯이 둘 이상 활성이면 " +
|
||||
"CharacterVoiceObject 등록이 충돌한다")]
|
||||
[SerializeField] private CharacterSlot[] _characterSlots;
|
||||
|
||||
public StoryDatabase Database => _database;
|
||||
public LocationData Current { get; private set; }
|
||||
|
||||
// 지금 이 장소에 있는 인물의 대화 재생기 (아무도 없으면 null).
|
||||
// 「이야기한다」처럼 장소 단위로 눌리는 버튼이 "누구에게" 말을 걸지 여기서 가져간다 —
|
||||
// 버튼이 캐릭터를 직접 참조하면 장소마다 다시 배선해야 하지만, 이 방식이면 버튼은 그대로 둔다.
|
||||
//
|
||||
// 한 장소에 한 명을 전제로 한다. 슬롯이 여럿 켜져 있으면 목록상 첫 번째가 잡힌다
|
||||
// (배경 2장짜리 장소처럼 인물이 둘 이상 필요해지면 이 자리에 선택 단계를 넣으면 된다).
|
||||
public DialogPlayer CurrentCharacter { get; private set; }
|
||||
|
||||
private GameObject _currentInstance;
|
||||
|
||||
// 대화 중 진행도/트리거가 바뀌었을 때의 슬롯 갱신 예약.
|
||||
@@ -95,8 +111,38 @@ private void OnDialogActiveChanged(bool active)
|
||||
|
||||
private void RefreshSlots()
|
||||
{
|
||||
if (_currentInstance == null || _database == null) return;
|
||||
foreach (var slot in _currentInstance.GetComponentsInChildren<CharacterSlot>(includeInactive: true))
|
||||
slot.Refresh(_database, Current);
|
||||
CurrentCharacter = null; // 장소를 떠났거나 아무도 안 남을 수 있으므로 항상 먼저 비운다
|
||||
if (_database == null) return;
|
||||
|
||||
// ① 씬의 인물 명단 — 어느 장소에 나타날지는 각 슬롯이 DB를 보고 스스로 정한다
|
||||
if (_characterSlots != null)
|
||||
foreach (var slot in _characterSlots)
|
||||
RefreshSlot(slot);
|
||||
|
||||
// ② 장소 프리팹 안에 슬롯을 둔 경우 (그 장소에만 나오는 단역 등).
|
||||
// ①과 완전히 같은 판정을 타지만, 같은 인물을 양쪽에 두면 등록이 충돌한다.
|
||||
if (_currentInstance != null)
|
||||
foreach (var slot in _currentInstance.GetComponentsInChildren<CharacterSlot>(includeInactive: true))
|
||||
RefreshSlot(slot);
|
||||
}
|
||||
|
||||
// 슬롯 하나 갱신 + 「이야기한다」 대상 후보 판정.
|
||||
// 대상은 "지금 걸 대화가 있는" 첫 슬롯이다 — 동행자로만 있는 슬롯은 IsPresent가 false라
|
||||
// 후보에 오르지 않고, 그 장소·인물 비트가 생기는 순간 자연히 후보가 된다.
|
||||
private void RefreshSlot(CharacterSlot slot)
|
||||
{
|
||||
if (slot == null) return;
|
||||
slot.Refresh(_database, Current);
|
||||
|
||||
if (CurrentCharacter == null && slot.IsPresent)
|
||||
CurrentCharacter = slot.Player;
|
||||
}
|
||||
|
||||
// 「이야기한다」 버튼용 — 지금 장소에 있는 인물에게 말을 건다.
|
||||
// 걸 수 있는 대화가 여럿이면 DialogPlayer가 DialogEnterHud로 고르게 한다.
|
||||
// 아무도 없으면 조용히 넘어간다 (버튼을 눌러도 아무 일도 일어나지 않는다).
|
||||
public void TalkToCurrent()
|
||||
{
|
||||
if (CurrentCharacter != null) CurrentCharacter.Talk();
|
||||
}
|
||||
}
|
||||
|
||||
89
Assets/02_Scripts/Story/CharacterPoses.cs
Normal file
89
Assets/02_Scripts/Story/CharacterPoses.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// 캐릭터 본체에 붙는다. Pose(공통 감정 축) → 실제 그림 오브젝트 매핑.
|
||||
//
|
||||
// 왜 스프라이트만 갈아끼우지 않고 포즈마다 오브젝트를 따로 두는가:
|
||||
// 콜라이더 때문이다. 클릭 판정과 부위 터치(DialogTouchZone)는 포즈마다 다른 자리에 있어야 하는데,
|
||||
// SpriteRenderer의 sprite만 바꾸면 콜라이더는 그대로 남아 손이 엉뚱한 곳에 있게 된다.
|
||||
// 포즈를 통째로 켜고 끄면 그림과 판정이 항상 같이 움직인다.
|
||||
//
|
||||
// 배치:
|
||||
// MayaFey CharacterVoiceObject + DialogPlayer + CharacterPoses
|
||||
// ├── Pose_기본 SpriteRenderer + Collider2D (+ DialogTouchZone…)
|
||||
// ├── Pose_긍정 SpriteRenderer + Collider2D (+ DialogTouchZone…)
|
||||
// └── Pose_부루퉁 …
|
||||
//
|
||||
// 그 캐릭터에 없는 포즈를 요청하면 경고만 남기고 무시한다 —
|
||||
// 대사는 공통 축으로 쓰되 캐릭터마다 가진 그림이 다를 수 있기 때문이다.
|
||||
//
|
||||
// "오브젝트가 켜져 있다"와 "화면에 보인다"는 별개다. 캐릭터 본체는 계속 활성이어야
|
||||
// CharacterVoiceObject가 등록돼 대사가 찾을 수 있는데, 그렇다고 그림까지 보일 필요는 없다.
|
||||
// Start Pose를 '숨김'으로 두면 살아는 있되 안 보이는 상태가 되고, 이게 동행자(마요이)처럼
|
||||
// 장소에 서 있지 않고 대화 중에만 끼어드는 캐릭터의 기본 상태다.
|
||||
public class CharacterPoses : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
private struct Entry
|
||||
{
|
||||
[Tooltip("공통 감정 축")]
|
||||
public Pose Pose;
|
||||
|
||||
[Tooltip("이 포즈일 때 켤 오브젝트 (그림 + 콜라이더 + 터치존)")]
|
||||
public GameObject Object;
|
||||
}
|
||||
|
||||
[Tooltip("이 캐릭터가 가진 포즈들")]
|
||||
[SerializeField] private Entry[] _entries;
|
||||
|
||||
[Tooltip("시작할 때 켤 포즈.\n\n" +
|
||||
"'숨김'으로 두면 오브젝트는 살아 있되 화면에는 안 나온다 — 장소에 서 있는 인물이 아니라 " +
|
||||
"대화 중에만 끼어드는 동행자(마요이 등)가 이 경우다. 대사가 부를 때 나타났다가 " +
|
||||
"대화가 끝나면 다시 숨겨진다.\n\n" +
|
||||
"'변경 없음'이면 목록에서 실제 포즈인 첫 항목을 쓴다")]
|
||||
[SerializeField] private Pose _startPose = Pose.Keep;
|
||||
|
||||
// 지금 켜져 있는 포즈. DialogPlayer가 대화 전 상태를 기억했다가 되돌리는 데 쓴다.
|
||||
public Pose Current { get; private set; } = Pose.Hide;
|
||||
|
||||
// 인스펙터에서 여러 포즈를 켜 둔 채 저장해도 시작 시 한 장만 남게 정리한다
|
||||
private void Awake() => Apply(DefaultPose());
|
||||
|
||||
// 시작할 때 켤 포즈. 지정했으면 그대로, '변경 없음'이면 목록의 실제 포즈 첫 항목.
|
||||
// 인스펙터에서 배열에 항목을 추가하면 Pose가 0(변경 없음)으로 시작하므로,
|
||||
// 그걸 그대로 기본값으로 쓰면 아무것도 안 켜진 채 조용히 넘어간다. 그래서 건너뛴다.
|
||||
private Pose DefaultPose()
|
||||
{
|
||||
if (_startPose != Pose.Keep) return _startPose;
|
||||
|
||||
if (_entries != null)
|
||||
foreach (var e in _entries)
|
||||
if (e.Pose is not (Pose.Keep or Pose.Hide)) return e.Pose;
|
||||
return Pose.Hide;
|
||||
}
|
||||
|
||||
// Pose.Keep은 "변경 없음"이라 여기까지 오면 안 된다 — 호출하는 쪽에서 걸러낸다.
|
||||
public void Apply(Pose pose)
|
||||
{
|
||||
if (_entries == null || pose == Pose.Keep) return;
|
||||
|
||||
if (pose != Pose.Hide && !Has(pose))
|
||||
{
|
||||
Debug.LogWarning($"[CharacterPoses] {name}에 없는 포즈: {pose} — 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide면 전부 꺼진다 (어느 항목과도 일치하지 않으므로)
|
||||
foreach (var e in _entries)
|
||||
if (e.Object != null) e.Object.SetActive(e.Pose == pose);
|
||||
|
||||
Current = pose;
|
||||
}
|
||||
|
||||
private bool Has(Pose pose)
|
||||
{
|
||||
foreach (var e in _entries)
|
||||
if (e.Pose == pose) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Story/CharacterPoses.cs.meta
Normal file
2
Assets/02_Scripts/Story/CharacterPoses.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1afc24429162fa646b3e858362cab257
|
||||
@@ -1,20 +1,46 @@
|
||||
using UnityEngine;
|
||||
|
||||
// 장소 프리팹 안의 캐릭터 자리 하나.
|
||||
// 이 캐릭터가 지금 이 장소에서 걸 수 있는 대화(StoryBeat)가 하나라도 있으면 캐릭터를 켜고, 없으면 끈다.
|
||||
// 캐릭터 하나의 등장 자리.
|
||||
// 이 캐릭터가 지금 장소에서 걸 수 있는 대화(StoryBeat)가 하나라도 있으면 캐릭터를 켜고, 없으면 끈다.
|
||||
// 갱신은 LocationManager가 장소 입장 시 / 스토리 상태 변화 시 Refresh를 호출해서 이뤄진다.
|
||||
//
|
||||
// 슬롯은 장소 프리팹이 아니라 **씬**에 두고 LocationManager의 Character Slots에 연결한다.
|
||||
// 어느 장소에 나타날지는 오브젝트 배치가 아니라 StoryDatabase가 정한다 —
|
||||
// 배치까지 같은 사실을 표현하면 원본이 둘이 되어 "비트는 넣었는데 슬롯을 안 넣음" 같은
|
||||
// 어긋남이 생기기 때문이다. 캐릭터가 늘 같은 자리에 선다는 전제이고,
|
||||
// 장소마다 자리가 달라져야 하면 그때 LocationData에 앵커를 두고 옮기면 된다.
|
||||
//
|
||||
// 배치법: 이 컴포넌트는 항상 켜져 있는 빈 오브젝트에 붙이고,
|
||||
// 캐릭터 본체(CharacterVoiceObject + DialogPlayer)는 그 자식으로 두고 _character에 연결.
|
||||
// 참고: 조건 없는 잡담 비트를 하나 등록해 두면 그 캐릭터는 항상 등장한다.
|
||||
// 참고: 조건 없는 잡담 비트를 하나 등록해 두면 그 캐릭터는 그 장소에 항상 등장한다.
|
||||
public class CharacterSlot : MonoBehaviour
|
||||
{
|
||||
[Tooltip("캐릭터 본체 (CharacterVoiceObject + DialogPlayer가 붙은 자식 오브젝트)")]
|
||||
[SerializeField] private CharacterVoiceObject _character;
|
||||
|
||||
[Tooltip("켜면 걸 대화가 없어도 오브젝트를 끄지 않는다.\n\n" +
|
||||
"대화 중에만 끼어드는 동행자(마요이 등)용이다. 오브젝트가 살아 있어야 " +
|
||||
"CharacterVoiceObject가 등록돼 대사가 포즈·보이스로 찾을 수 있는데, " +
|
||||
"그렇다고 화면에 서 있어야 하는 건 아니다 — 화면 표시는 CharacterPoses의 " +
|
||||
"Start Pose를 '숨김'으로 두어 따로 정한다.\n\n" +
|
||||
"이 인물이 그 장소의 대화 상대가 되는 때(예: 구치소에 갇힘)에는 " +
|
||||
"그 장소·인물의 StoryBeat를 조건과 함께 등록하면 된다 — " +
|
||||
"그러면 아래 IsPresent가 켜지면서 「이야기한다」 대상이 된다")]
|
||||
[SerializeField] private bool _alwaysActive;
|
||||
|
||||
// 이 슬롯의 캐릭터에게 지금 걸 수 있는 대화가 있는가 = 이 장소의 대화 상대인가.
|
||||
// 동행자 슬롯은 오브젝트가 켜져 있어도 이 값이 false라 「이야기한다」 대상이 되지 않는다.
|
||||
public bool IsPresent { get; private set; }
|
||||
|
||||
// 이 슬롯 캐릭터의 대화 재생기 (캐릭터 본체에 같이 붙어 있다)
|
||||
public DialogPlayer Player => _character != null ? _character.GetComponent<DialogPlayer>() : null;
|
||||
|
||||
public void Refresh(StoryDatabase database, LocationData location)
|
||||
{
|
||||
IsPresent = false;
|
||||
if (_character == null || _character.Character == null) return;
|
||||
_character.gameObject.SetActive(database.HasPlayableBeat(location, _character.Character));
|
||||
|
||||
IsPresent = database.HasPlayableBeat(location, _character.Character);
|
||||
_character.gameObject.SetActive(IsPresent || _alwaysActive);
|
||||
}
|
||||
}
|
||||
|
||||
28
Assets/02_Scripts/_Data/Character/Pose.cs
Normal file
28
Assets/02_Scripts/_Data/Character/Pose.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using UnityEngine;
|
||||
|
||||
// 대사 노드가 지정하는 캐릭터 포즈.
|
||||
//
|
||||
// 캐릭터마다 그림은 달라도 감정 축은 공유한다 — 마요이의 "볼빵빵"과 다른 인물의 삐진 그림이
|
||||
// 똑같이 Sulky다. 덕분에 새 캐릭터를 추가해도 이 목록은 늘어나지 않는다.
|
||||
// 실제로 어떤 그림이 뜨는지는 캐릭터 프리팹의 CharacterPoses가 정한다.
|
||||
//
|
||||
// Keep = 0 인 것이 중요하다. enum의 기본값이 0이라, 그래프에서 이 포트를 건드리지 않으면
|
||||
// 자동으로 "변경 없음"이 된다 — BGM과 같은 규칙이라 인물이 서 있는 동안 매 대사에 다시 꽂을
|
||||
// 필요가 없다. 그래서 별도의 "지우기" 필드 없이 Hide 항목 하나로 숨김까지 처리된다.
|
||||
public enum Pose
|
||||
{
|
||||
[InspectorName("변경 없음")] Keep = 0,
|
||||
[InspectorName("숨김")] Hide,
|
||||
|
||||
[InspectorName("기본")] Normal,
|
||||
[InspectorName("웃음")] Smile,
|
||||
[InspectorName("긍정")] Confident,
|
||||
[InspectorName("부정")] Deny,
|
||||
[InspectorName("놀람")] Surprised,
|
||||
[InspectorName("당황")] Flustered,
|
||||
[InspectorName("생각")] Thinking,
|
||||
[InspectorName("화남")] Angry,
|
||||
[InspectorName("슬픔")] Sad,
|
||||
[InspectorName("부루퉁")] Sulky,
|
||||
[InspectorName("재촉")] Hurry,
|
||||
}
|
||||
2
Assets/02_Scripts/_Data/Character/Pose.cs.meta
Normal file
2
Assets/02_Scripts/_Data/Character/Pose.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66075435253d3d04c889fe62e6b76b8c
|
||||
Reference in New Issue
Block a user