멸망엔딩

This commit is contained in:
2026-07-24 12:08:24 +09:00
parent ffa6320e4d
commit ad15f47c75
28 changed files with 2140 additions and 10 deletions

View File

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

View File

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

View File

@@ -60,6 +60,18 @@ public class DialogNode : ScriptableObject
"비우면 기록 안 함. 이후 대화 조건(RequiredChoiceCodes)에서 검사 가능")]
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;

View File

@@ -281,8 +281,22 @@ private async Awaitable PlayEntry(DialogEntry entry)
try
{
var node = entry.Group.StartNode;
int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단
while (node != null)
{
// 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다)
if (node.AffectionCheck)
{
if (++routingHops > 100)
{
Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {entry.Group.name}");
break;
}
node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next;
continue;
}
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다.
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
bool armed = node.HiddenBranch != null;
@@ -604,6 +618,39 @@ private async Awaitable<bool> PlayNode(DialogNode node)
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;
}
// 노드의 EventKey와 같은 Key를 가진 이벤트들을 호출
private void RaiseNodeEvent(string key)
{

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(10, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
[ScriptedImporter(13, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
internal class DialogGraphImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
@@ -47,7 +47,8 @@ public override void OnImportAsset(AssetImportContext ctx)
while (queue.Count > 0)
{
var gn = queue.Dequeue();
if (gn == null || map.ContainsKey(gn) || (gn is not DialogLineNode && gn is not DialogStagingNode))
if (gn == null || map.ContainsKey(gn)
|| (gn is not DialogLineNode && gn is not DialogStagingNode && gn is not DialogAffectionNode))
continue;
var dn = ScriptableObject.CreateInstance<DialogNode>();
@@ -82,6 +83,39 @@ public override void OnImportAsset(AssetImportContext ctx)
continue;
}
// 호감도 분기 노드 — 대사 없이 라우팅만: 조건 통과 → 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));
@@ -184,6 +218,14 @@ static IEnumerable<INode> GetSuccessors(INode node)
yield break;
}
// 호감도 분기 노드는 두 출력 모두 후속
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;