feat: Add core VN system scripts, package dependencies, and initial project settings.

This commit is contained in:
2025-12-10 16:15:23 +09:00
parent 50aa8b6b02
commit 6bd2f87ff5
166 changed files with 9883 additions and 1027 deletions

View File

@@ -0,0 +1,261 @@
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using UnityEngine;
/// <summary>Command 배열을 ScriptAction 배열로 컴파일</summary>
public class Compiler
{
private readonly Dictionary<string, int> _labelMap;
private Compiler(Dictionary<string, int> labelMap)
{
_labelMap = labelMap;
}
/// <summary>컴파일 시점에만 사용되는 선택지 데이터</summary>
private class ChoiceData
{
public string RawText;
public string TargetLabel;
public int TargetIndex;
}
public static ScriptAction[] Compile(List<Command> commands, Dictionary<string, int> labelMap)
{
var compiler = new Compiler(labelMap);
return commands.Select(compiler.CompileCommand).ToArray();
}
private ScriptAction CompileCommand(Command cmd)
{
return cmd.Type switch
{
"label" => CompileLabel(cmd),
"msg" => CompileMsg(cmd),
"spk" => CompileSpk(cmd),
"char" => CompileChar(cmd),
"remove" => CompileRemove(cmd),
"action" => CompileAction(cmd),
"expr" => CompileExpr(cmd),
"goto" => CompileGoto(cmd),
"choices" => CompileChoices(cmd),
"var" => CompileVar(cmd),
"add" => CompileAdd(cmd),
"bg" => CompileBg(cmd),
"script" => CompileScript(cmd),
_ => CompileUnknown(cmd)
};
}
// ===== 헬퍼 메서드 =====
private static ScriptAction SyncAction(string type, string debugInfo, System.Action<ScriptContext> execute)
{
return new ScriptAction
{
DebugType = type,
DebugInfo = debugInfo,
Execute = ctx =>
{
execute(ctx);
return UniTask.FromResult(ScriptResult.Continue);
}
};
}
// ===== 개별 컴파일러 =====
private ScriptAction CompileLabel(Command cmd)
{
return new ScriptAction
{
DebugType = "label",
DebugInfo = cmd.GetParam("content"),
Execute = _ => UniTask.FromResult(ScriptResult.Continue)
};
}
private ScriptAction CompileMsg(Command cmd)
{
var rawContent = cmd.GetParam("content");
return new ScriptAction
{
DebugType = "msg",
DebugInfo = rawContent.Length > 20 ? rawContent[..20] + "..." : rawContent,
Execute = ctx => ExecuteMsg(ctx, rawContent)
};
}
private static UniTask<ScriptResult> ExecuteMsg(ScriptContext ctx, string rawContent)
{
string content = ctx.VariableStore.ReplaceVariables(rawContent);
ctx.DialogueStore.Dialogue.Value = content;
return UniTask.FromResult(
ctx.PeekNextType?.Invoke() == "choices"
? ScriptResult.Continue
: ScriptResult.Wait);
}
private ScriptAction CompileSpk(Command cmd)
{
var rawName = cmd.GetParam("name");
return SyncAction("spk", rawName, ctx =>
ctx.DialogueStore.Speaker.Value = ctx.VariableStore.ReplaceVariables(rawName));
}
private ScriptAction CompileChar(Command cmd)
{
var img = cmd.GetParam("img");
var direction = ParseDirectionType(cmd.GetParam("enter"));
return SyncAction("char", img, ctx =>
{
var sprite = ctx.LoadCharacterSprite(img);
if (sprite != null)
ctx.CharacterStore.Add(img, sprite, direction);
});
}
private ScriptAction CompileRemove(Command cmd)
{
var target = cmd.GetParam("target");
var direction = ParseDirectionType(cmd.GetParam("exit"));
return SyncAction("remove", target, ctx =>
ctx.CharacterStore.Remove(target, direction));
}
private ScriptAction CompileAction(Command cmd)
{
var target = cmd.GetParam("target");
var animationType = ParseAnimationType(cmd.GetParam("anim"));
return SyncAction("action", $"{target}:{animationType}", ctx =>
ctx.CharacterStore.PlayAction(target, animationType));
}
private ScriptAction CompileExpr(Command cmd)
{
var target = cmd.GetParam("target");
var expr = cmd.GetParam("expr").ToLower();
return SyncAction("expr", $"{target}:{expr}", ctx =>
{
var sprite = ctx.LoadCharacterSprite(expr);
if (sprite != null)
ctx.CharacterStore.ChangeExpression(target, sprite);
});
}
private ScriptAction CompileGoto(Command cmd)
{
var targetLabel = cmd.GetParam("content");
var targetIndex = _labelMap[targetLabel];
return new ScriptAction
{
DebugType = "goto",
DebugInfo = targetLabel,
Execute = _ => UniTask.FromResult(ScriptResult.JumpTo(targetIndex))
};
}
private ScriptAction CompileChoices(Command cmd)
{
var choices = cmd.Choices.Select(c => new ChoiceData
{
RawText = c["content"],
TargetLabel = c["goto"],
TargetIndex = _labelMap[c["goto"]]
}).ToList();
return new ScriptAction
{
DebugType = "choices",
DebugInfo = $"{choices.Count} options",
Execute = ctx => ExecuteChoices(ctx, choices)
};
}
private static UniTask<ScriptResult> ExecuteChoices(ScriptContext ctx, List<ChoiceData> choices)
{
var options = choices.Select(c => new ChoiceOption
{
Text = ctx.VariableStore.ReplaceVariables(c.RawText),
TargetLabel = c.TargetLabel,
TargetIndex = c.TargetIndex
}).ToList();
ctx.ChoiceStore.Show(options);
return UniTask.FromResult(ScriptResult.Wait);
}
private ScriptAction CompileVar(Command cmd)
{
var pairs = cmd.Params.ToDictionary(p => p.Key, p => p.Value.ToString());
return SyncAction("var", string.Join(",", pairs.Keys), ctx =>
{
foreach (var kvp in pairs)
ctx.VariableStore.SetVariable(kvp.Key, kvp.Value);
});
}
private ScriptAction CompileAdd(Command cmd)
{
var pairs = cmd.Params.ToDictionary(p => p.Key, p => p.Value.ToString());
return SyncAction("add", string.Join(",", pairs.Keys), ctx =>
{
foreach (var kvp in pairs)
ctx.VariableStore.AddVariable(kvp.Key, kvp.Value);
});
}
private ScriptAction CompileScript(Command cmd)
{
var scriptPath = cmd.GetParam("file");
return new ScriptAction
{
DebugType = "script",
DebugInfo = scriptPath,
Execute = ctx =>
{
ctx.OnScriptChange?.Invoke(scriptPath);
return UniTask.FromResult(ScriptResult.End);
}
};
}
private ScriptAction CompileBg(Command cmd)
{
var file = cmd.GetParam("file");
return SyncAction("bg", file, ctx =>
Debug.Log($"Background: {file}")); // TODO: 배경 변경 로직
}
private ScriptAction CompileUnknown(Command cmd)
{
return SyncAction(cmd.Type, "unknown", ctx =>
Debug.LogWarning($"Unknown command: {cmd.Type}"));
}
// ===== Enum 파서 (컴파일 타임 변환) =====
private static AnimationType ParseAnimationType(string str) => str.ToLower() switch
{
"jump" => AnimationType.Jump,
"shake" => AnimationType.Shake,
"run" => AnimationType.Run,
"nod" => AnimationType.Nod,
"punch" => AnimationType.Punch,
_ => AnimationType.Nod
};
private static DirectionType ParseDirectionType(string str) => str.ToLower() switch
{
"left" => DirectionType.Left,
"right" => DirectionType.Right,
"center" => DirectionType.Center,
"bottomleft" => DirectionType.BottomLeft,
"bottomright" => DirectionType.BottomRight,
"top" => DirectionType.Top,
"runleft" => DirectionType.RunLeft,
"runright" => DirectionType.RunRight,
_ => DirectionType.Center
};
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08b6e5d2e19561a439ebc7759f94bf27

View File

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

View File

@@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using PrimeTween;
using R3;
using UnityEngine;
using UnityEngine.UI;
/// <summary>CharacterStore의 시그널을 구독하여 실제 UI 렌더링을 담당</summary>
public class CharacterDrawer : MonoBehaviour
{
[Header("UI 연결")]
public Transform characterPanel;
[Header("설정")]
public float charWidth = 500f;
public float defaultDuration = 0.5f;
public float moveDistance = 800f;
private CharacterStore _store;
private FlowStore _flow;
private readonly Dictionary<string, CharacterSlot> _slots = new();
private readonly CompositeDisposable _disposables = new();
// ========================= [Async Queue System] =========================
private readonly Dictionary<string, SemaphoreSlim> _locks = new();
private CancellationTokenSource _globalCts = new();
public void Bind(CharacterStore store, FlowStore flow)
{
_store = store;
_flow = flow;
_store.OnCharacterAdded += HandleCharacterAdded;
_store.OnCharacterExiting += HandleCharacterExiting;
_store.OnCharacterRemoved += HandleCharacterRemoved;
_store.OnActionRequested += HandleActionRequested;
_store.OnExpressionRequested += HandleExpressionRequested;
_flow.IsSkipping.Subscribe(isSkipping =>
{
if (isSkipping)
{
CompleteAll();
}
}).AddTo(_disposables);
}
private void OnDestroy()
{
if (_store != null)
{
_store.OnCharacterAdded -= HandleCharacterAdded;
_store.OnCharacterExiting -= HandleCharacterExiting;
_store.OnCharacterRemoved -= HandleCharacterRemoved;
_store.OnActionRequested -= HandleActionRequested;
_store.OnExpressionRequested -= HandleExpressionRequested;
}
_disposables.Dispose();
_globalCts.Dispose();
foreach (var sem in _locks.Values) sem.Dispose();
}
// ========================= [Queue Processing] =========================
private SemaphoreSlim GetOrCreateLock(string charName)
{
if (!_locks.TryGetValue(charName, out var sem))
{
sem = new SemaphoreSlim(1, 1);
_locks[charName] = sem;
}
return sem;
}
private async void EnqueueAsync(string charName, Func<bool, UniTask> action)
{
var sem = GetOrCreateLock(charName);
var token = _globalCts.Token;
await sem.WaitAsync();
try
{
bool isImmediate = token.IsCancellationRequested;
await action(isImmediate);
}
catch (OperationCanceledException)
{
// 취소된 경우 무시
}
finally
{
sem.Release();
}
}
public void CompleteAll()
{
_globalCts.Cancel();
_globalCts.Dispose();
_globalCts = new CancellationTokenSource();
Tween.CompleteAll();
}
// ========================= [Event Handlers] =========================
private void HandleCharacterAdded(string name, CharacterState state, DirectionType direction)
{
EnqueueAsync(name, isImmediate => SpawnCharacterAsync(name, state, direction, isImmediate));
}
private void HandleCharacterExiting(string name, CharacterState state, DirectionType direction)
{
EnqueueAsync(name, isImmediate => ProcessExitAsync(name, state, direction, isImmediate));
}
private async UniTask ProcessExitAsync(string name, CharacterState state, DirectionType direction, bool isImmediate)
{
if (!_slots.TryGetValue(name, out var slot))
{
Debug.LogWarning($"퇴장 실패: '{name}' 슬롯을 찾을 수 없습니다.");
return;
}
_slots.Remove(name);
slot.gameObject.name = name + "_Removing";
await ExitCharacterAsync(slot, state, direction, isImmediate);
}
private void HandleCharacterRemoved(string name)
{
// 슬롯은 ExitCharacterAsync에서 직접 파괴
}
private void HandleActionRequested(string name, AnimationType action)
{
if (_slots.TryGetValue(name, out var slot))
{
EnqueueAsync(name, isImmediate => PlayActionOnSlotAsync(slot, action, isImmediate));
}
else
{
Debug.LogWarning($"액션 실패: '{name}' 슬롯을 찾을 수 없습니다.");
}
}
private void HandleExpressionRequested(string name, Sprite newSprite)
{
if (_slots.TryGetValue(name, out var slot))
{
EnqueueAsync(name, isImmediate => ChangeExpressionOnSlotAsync(slot, newSprite, isImmediate));
}
else
{
Debug.LogWarning($"표정 변경 실패: '{name}' 슬롯을 찾을 수 없습니다.");
}
}
// ========================= [Spawn] =========================
private async UniTask SpawnCharacterAsync(string name, CharacterState state, DirectionType direction, bool isImmediate)
{
var slot = CreateSlot(name);
_slots[name] = slot;
slot.SetSprite(state.Sprite.Value);
FitImageToScreen(slot.image);
slot.layoutElement.preferredWidth = 0;
slot.layoutElement.minWidth = 0;
ArrangeSlotOrder(slot.transform, direction);
Vector2 startPos = GetDirectionVector(direction);
slot.containerRect.anchoredPosition = startPos;
slot.image.color = new Color(1, 1, 1, 0);
if (!isImmediate)
await UniTask.WaitForEndOfFrame(this);
if (isImmediate)
{
// 즉시 모드: 값 직접 설정
slot.layoutElement.preferredWidth = charWidth;
slot.containerRect.anchoredPosition = Vector2.zero;
slot.image.color = Color.white;
}
else
{
TriggerRunAnimationIfNeeded(slot, direction, isImmediate);
await Sequence.Create()
.Group(Tween.Custom(slot.layoutElement, 0f, charWidth, defaultDuration, (t, x) => t.preferredWidth = x, Ease.OutQuart))
.Group(Tween.UIAnchoredPosition(slot.containerRect, Vector2.zero, defaultDuration, Ease.OutQuart))
.Group(Tween.Alpha(slot.image, 1f, defaultDuration));
}
state.Alpha.Value = 1f;
state.SlotWidth.Value = charWidth;
state.Position.Value = Vector2.zero;
}
// ========================= [Exit] =========================
private async UniTask ExitCharacterAsync(CharacterSlot slot, CharacterState state, DirectionType direction, bool isImmediate)
{
if (slot == null || slot.gameObject == null) return;
Vector2 targetPos = GetDirectionVector(direction);
if (isImmediate)
{
// 즉시 모드: 값 직접 설정
slot.containerRect.anchoredPosition = targetPos;
slot.image.color = new Color(1, 1, 1, 0);
slot.layoutElement.preferredWidth = 0;
}
else
{
TriggerRunAnimationIfNeeded(slot, direction, isImmediate);
await Sequence.Create()
.Group(Tween.UIAnchoredPosition(slot.containerRect, targetPos, defaultDuration, Ease.OutQuart))
.Group(Tween.Alpha(slot.image, 0f, defaultDuration * 0.8f))
.Group(Tween.Custom(slot.layoutElement, slot.layoutElement.preferredWidth, 0f, defaultDuration, (t, x) => t.preferredWidth = x, Ease.OutQuart));
}
// 항상 실행되어야 하는 정리 작업
_store.FinalRemove(state.Name);
if (slot != null && slot.gameObject != null)
{
Destroy(slot.gameObject);
}
}
// ========================= [Action] =========================
private async UniTask PlayActionOnSlotAsync(CharacterSlot slot, AnimationType action, bool isImmediate)
{
RectTransform targetRect = slot.imageRect;
Tween.StopAll(targetRect);
targetRect.anchoredPosition = Vector2.zero;
if (isImmediate) return;
switch (action)
{
case AnimationType.Jump:
await Tween.PunchLocalPosition(targetRect, new Vector3(0, 100f, 0), 0.5f, frequency: 2);
break;
case AnimationType.Shake:
await Tween.ShakeLocalPosition(targetRect, new Vector3(50f, 0, 0), 0.5f, frequency: 10);
break;
case AnimationType.Run:
await Tween.PunchLocalPosition(targetRect, new Vector3(0, 50f, 0), 0.5f, frequency: 10);
break;
case AnimationType.Nod:
await Sequence.Create()
.Chain(Tween.UIAnchoredPositionY(targetRect, -30f, 0.15f, Ease.OutQuad))
.Chain(Tween.UIAnchoredPositionY(targetRect, 0f, 0.15f, Ease.InQuad));
break;
case AnimationType.Punch:
await Tween.PunchScale(targetRect, new Vector3(0.2f, 0.2f, 0), 0.4f, frequency: 1);
break;
}
}
// ========================= [Expression] =========================
private async UniTask ChangeExpressionOnSlotAsync(CharacterSlot slot, Sprite newSprite, bool isImmediate)
{
if (isImmediate)
{
slot.SetSprite(newSprite);
FitImageToScreen(slot.image);
return;
}
var (maskObj, maskRect, overlayRect) = SetupMaskAndOverlay(slot.image, newSprite);
float softnessOffset = 100f;
float targetHeight = overlayRect.sizeDelta.y + softnessOffset;
float currentWidth = slot.image.rectTransform.rect.width;
float duration = 0.5f;
// Tween을 직접 await
await Tween.UISizeDelta(maskRect, new Vector2(currentWidth, targetHeight), duration, Ease.OutQuart);
slot.SetSprite(newSprite);
FitImageToScreen(slot.image);
Destroy(maskObj);
}
// ========================= [Slot Creation] =========================
private CharacterSlot CreateSlot(string name)
{
GameObject slotObj = new(name);
slotObj.transform.SetParent(characterPanel, false);
LayoutElement layoutElement = slotObj.AddComponent<LayoutElement>();
GameObject motionContainer = new("MotionContainer");
RectTransform containerRect = motionContainer.AddComponent<RectTransform>();
motionContainer.transform.SetParent(slotObj.transform, false);
containerRect.anchorMin = Vector2.zero;
containerRect.anchorMax = Vector2.one;
containerRect.sizeDelta = Vector2.zero;
GameObject imageObj = new("Image");
imageObj.transform.SetParent(motionContainer.transform, false);
Image charImage = imageObj.AddComponent<Image>();
RectTransform imageRect = charImage.rectTransform;
CharacterSlot slot = slotObj.AddComponent<CharacterSlot>();
slot.Initialize(layoutElement, containerRect, charImage, imageRect);
return slot;
}
private (GameObject maskObj, RectTransform maskRect, RectTransform overlayRect) SetupMaskAndOverlay(Image charImage, Sprite newSprite)
{
GameObject maskObj = new("MaskContainer");
maskObj.transform.SetParent(charImage.transform, false);
RectTransform maskRect = maskObj.AddComponent<RectTransform>();
maskRect.anchorMin = new Vector2(0.5f, 1f);
maskRect.anchorMax = new Vector2(0.5f, 1f);
maskRect.pivot = new Vector2(0.5f, 1f);
float softnessOffset = 100f;
float currentWidth = charImage.rectTransform.rect.width;
maskRect.anchoredPosition = new Vector2(0, softnessOffset);
maskRect.sizeDelta = new Vector2(currentWidth, 0);
RectMask2D rectMask = maskObj.AddComponent<RectMask2D>();
rectMask.softness = new Vector2Int(0, (int)softnessOffset);
GameObject overlayObj = new("ExpressionOverlay");
overlayObj.transform.SetParent(maskObj.transform, false);
Image overlayImage = overlayObj.AddComponent<Image>();
overlayImage.sprite = newSprite;
overlayImage.color = charImage.color;
overlayImage.material = charImage.material;
overlayImage.raycastTarget = charImage.raycastTarget;
overlayImage.type = Image.Type.Simple;
overlayImage.preserveAspect = true;
RectTransform overlayRect = overlayImage.rectTransform;
overlayRect.anchorMin = new Vector2(0.5f, 1f);
overlayRect.anchorMax = new Vector2(0.5f, 1f);
overlayRect.pivot = new Vector2(0.5f, 1f);
overlayRect.anchoredPosition = new Vector2(0, -softnessOffset);
FitImageToScreen(overlayImage);
maskObj.transform.SetAsLastSibling();
return (maskObj, maskRect, overlayRect);
}
// ========================= [Helpers] =========================
private void ArrangeSlotOrder(Transform slotTransform, DirectionType type)
{
int totalCount = characterPanel.childCount;
switch (type)
{
case DirectionType.Left:
case DirectionType.RunLeft:
case DirectionType.BottomLeft:
slotTransform.SetSiblingIndex(0);
break;
case DirectionType.Right:
case DirectionType.RunRight:
case DirectionType.BottomRight:
slotTransform.SetSiblingIndex(totalCount - 1);
break;
case DirectionType.Center:
case DirectionType.Top:
List<Transform> activeChildren = new();
for (int i = 0; i < totalCount; i++)
{
Transform child = characterPanel.GetChild(i);
if (child != slotTransform && !child.name.Contains("_Removing"))
activeChildren.Add(child);
}
int targetIndex = activeChildren.Count / 2;
if (targetIndex < activeChildren.Count)
slotTransform.SetSiblingIndex(activeChildren[targetIndex].GetSiblingIndex());
else
slotTransform.SetSiblingIndex(totalCount - 1);
break;
}
}
private void TriggerRunAnimationIfNeeded(CharacterSlot slot, DirectionType direction, bool isImmediate)
{
if (direction is DirectionType.RunLeft or DirectionType.RunRight)
PlayActionOnSlotAsync(slot, AnimationType.Run, isImmediate).Forget();
}
private Vector2 GetDirectionVector(DirectionType type) => type switch
{
DirectionType.Left or DirectionType.RunLeft => new Vector2(-moveDistance, 0),
DirectionType.Right or DirectionType.RunRight => new Vector2(moveDistance, 0),
DirectionType.Center or DirectionType.BottomLeft or DirectionType.BottomRight => new Vector2(0, -moveDistance),
DirectionType.Top => new Vector2(0, moveDistance),
_ => Vector2.zero,
};
private void FitImageToScreen(Image image)
{
image.SetNativeSize();
float maxHeight = Screen.height * 0.95f;
if (image.rectTransform.rect.height > maxHeight)
{
float aspectRatio = image.rectTransform.rect.width / image.rectTransform.rect.height;
image.rectTransform.sizeDelta = new Vector2(maxHeight * aspectRatio, maxHeight);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 68fc2202d64c2994588badec3b102032

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using UnityEngine.UI;
/// <summary>개별 캐릭터 슬롯 - 상태 구독 및 UI 참조 보유</summary>
public class CharacterSlot : MonoBehaviour
{
public LayoutElement layoutElement;
public RectTransform containerRect;
public Image image;
public RectTransform imageRect;
public void Initialize(LayoutElement layout, RectTransform container, Image img, RectTransform imgRect)
{
layoutElement = layout;
containerRect = container;
image = img;
imageRect = imgRect;
}
public void SetSprite(Sprite sprite)
{
if (sprite != null)
image.sprite = sprite;
}
}

View File

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

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using PrimeTween;
using R3;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>ChoiceStore의 시그널을 구독하여 선택지 UI 렌더링을 담당</summary>
public class ChoiceDrawer : MonoBehaviour
{
[Header("UI 연결")]
public Transform buttonContainer;
public Image background;
public GameObject buttonPrefab;
[Header("설정")]
public float fadeDuration = 0.3f;
private ChoiceStore _state;
private readonly CompositeDisposable _disposables = new();
/// <summary>선택지 클릭 시 발생하는 이벤트 (targetLabel, targetIndex 전달)</summary>
public event Action<string, int> OnChoiceSelected;
private void Awake()
{
// 초기 상태: 배경 투명
if (background != null)
{
var color = background.color;
color.a = 0f;
background.color = color;
}
}
public void Bind(ChoiceStore state)
{
_state = state;
_state.IsVisible.Subscribe(OnVisibilityChanged).AddTo(_disposables);
_state.Options.Subscribe(OnOptionsChanged).AddTo(_disposables);
}
private void OnVisibilityChanged(bool isVisible)
{
if (background == null) return;
var color = background.color;
color.a = isVisible ? 0.8f : 0f;
background.color = color;
}
private void OnOptionsChanged(List<ChoiceOption> options)
{
// 기존 버튼 정리
foreach (Transform child in buttonContainer)
Destroy(child.gameObject);
if (options == null || options.Count == 0) return;
// 새 버튼 생성
foreach (var option in options)
{
var buttonObj = Instantiate(buttonPrefab, buttonContainer);
buttonObj.GetComponentInChildren<TextMeshProUGUI>().text = option.Text;
var targetLabel = option.TargetLabel;
var targetIndex = option.TargetIndex;
buttonObj.GetComponent<Button>().onClick.AddListener(() =>
{
OnChoiceSelected?.Invoke(targetLabel, targetIndex);
});
}
}
private void OnDestroy()
{
_disposables.Dispose();
}
}

View File

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

View File

@@ -0,0 +1,141 @@
using PrimeTween;
using R3;
using TMPro;
using UnityEngine;
class DialogueDrawer : MonoBehaviour
{
[Header("Dialogue Settings")]
public GameObject speakerPanel;
public TextMeshProUGUI dialogueTMP;
public TextMeshProUGUI speakerTMP;
public float charsPerSecond = 50f;
private Tween dialogueTween;
private DialogueStore _state;
private FlowStore _flow;
private readonly CompositeDisposable _disposables = new();
private void Awake()
{
// VNManager.Start()보다 먼저 실행되어야 함
speakerTMP.SetText(" ");
speakerTMP.ForceMeshUpdate(true);
dialogueTMP.SetText(" ");
dialogueTMP.ForceMeshUpdate(true);
}
private void Update()
{
DisplayEffects(dialogueTMP);
}
public void Bind(DialogueStore state, FlowStore flow)
{
_state = state;
_flow = flow;
_state.Dialogue.Subscribe(x =>
{
_state.IsDrawing.Value = true;
DrawDialogue(x);
}).AddTo(_disposables);
_state.Speaker.Subscribe(x => DrawSpeaker(x)).AddTo(_disposables);
// FlowStore에서 IsSkipping 구독
_flow.IsSkipping.Subscribe(x =>
{
if (x && dialogueTween.isAlive)
{
dialogueTween.Complete();
_state.IsDrawing.Value = false;
_flow.ResetSkip();
}
}).AddTo(_disposables);
}
private void DrawSpeaker(string speaker)
{
if (speaker == "")
{
speakerPanel.SetActive(false);
return;
}
speakerPanel.SetActive(true);
speakerTMP.SetText(speaker);
speakerTMP.ForceMeshUpdate(true);
}
private void DrawDialogue(string text)
{
// Unity 내부 최적화로 인해 줄이 바뀔 시 LinkInfo 배열이 초기화되지 않음.
// 따라서 수동으로 초기화를 수행.
dialogueTMP.textInfo.linkInfo = new TMP_LinkInfo[0];
dialogueTMP.SetText(text);
dialogueTMP.ForceMeshUpdate(true);
int charCount = dialogueTMP.textInfo.characterCount;
// 빈 텍스트면 즉시 완료
if (charCount == 0)
{
dialogueTMP.maxVisibleCharacters = 0;
_state.IsDrawing.Value = false;
return;
}
dialogueTMP.maxVisibleCharacters = 0;
dialogueTween = Tween.Custom(
startValue: 0f,
endValue: charCount,
duration: charCount / charsPerSecond,
onValueChange: x => dialogueTMP.maxVisibleCharacters = Mathf.RoundToInt(x),
ease: Ease.Linear
).OnComplete(() => _state.IsDrawing.Value = false);
}
private void DisplayEffects(TextMeshProUGUI textObj)
{
textObj.ForceMeshUpdate(true);
TMP_TextInfo textInfo = textObj.textInfo;
TMP_LinkInfo[] linkInfo = textInfo.linkInfo;
Mesh mesh = textObj.mesh;
Vector3[] vertices = mesh.vertices;
foreach (var link in linkInfo)
{
string linkName = link.GetLinkID();
int start = link.linkTextfirstCharacterIndex;
int end = link.linkTextfirstCharacterIndex + link.linkTextLength;
for (var i = start; i < end; i++)
{
TMP_CharacterInfo c = textInfo.characterInfo[i];
int idx = c.vertexIndex;
if (!c.isVisible)
continue;
if (linkName == "shake")
{
Vector3 offset = new(Random.Range(-1.1f, 1.1f), Random.Range(-1.1f, 1.1f));
for (byte j = 0; j < 4; j++)
vertices[idx + j] += offset;
}
}
}
mesh.vertices = vertices;
textObj.canvasRenderer.SetMesh(mesh);
}
private void OnDestroy()
{
_disposables.Dispose();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f20e3ed16baa4e44a82c064bb09f9f9

View File

@@ -1,21 +0,0 @@
// Enums - Director
public enum DirectionType
{
Left,
Right,
BottomLeft,
BottomRight,
Center,
Top,
RunLeft,
RunRight
}
public enum AnimationType
{
Jump,
Shake,
Nod,
Punch,
Run
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 315eaaf0db7bdb34aad7721b0bf6fb93

View File

@@ -1,9 +0,0 @@
using UnityEngine;
public class GameManager : MonoBehaviour
{
public void LoadScene()
{
UnityEngine.SceneManagement.SceneManager.LoadScene("In-Game");
}
}

View File

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

View File

@@ -1,13 +1,28 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
public class Parser
/// <summary>파싱된 스크립트 명령</summary>
public class Command
{
public string Type { get; set; }
public Dictionary<string, object> Params { get; set; } = new();
public List<Dictionary<string, string>> Choices { get; set; }
public string GetParam(string key, string defaultValue = "")
{
return Params.TryGetValue(key, out var val) ? val.ToString() : defaultValue;
}
}
/// <summary>스크립트 텍스트를 Command 리스트로 파싱</summary>
public static class Parser
{
private static readonly Regex TagRegex = new(@"^\[(\w+)(?:\s+(.*))?\]$");
private static readonly Regex AttrRegex = new(@"(\w+)=(""[^""]*""|'[^']*'|[^ \t\]]+)");
private static readonly Regex ChoiceRegex = new(@"^\*\s*(.+?)\s*>\s*(.+)$");
public static Script Parse(string text)
/// <summary>스크립트 텍스트를 파싱하여 (Commands, LabelMap) 튜플 반환</summary>
public static (List<Command> Commands, Dictionary<string, int> LabelMap) Parse(string text)
{
List<Command> commands = new();
Dictionary<string, int> labelMap = new();
@@ -73,7 +88,7 @@ public class Parser
commands.Add(new Command { Type = "msg", Params = { { "content", line } } });
}
return new Script(commands, labelMap);
return (commands, labelMap);
}
private static void ParseAttributes(string attrString, Dictionary<string, object> paramDict)

View File

@@ -1,63 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
public class Command
{
public string Type { get; set; }
public Dictionary<string, object> Params { get; set; } = new();
public List<Dictionary<string, string>> Choices { get; set; }
public string GetParam(string key, string defaultValue = "")
{
return Params.TryGetValue(key, out var val) ? val.ToString() : defaultValue;
}
}
public class Script
{
private List<Command> _commands;
private int _currentIndex = -1;
private Dictionary<string, int> _labelMap = new();
public Script(List<Command> commands, Dictionary<string, int> labelMap)
{
_commands = commands;
_labelMap = labelMap;
_currentIndex = -1;
}
public bool HasNextCommand()
{
return _currentIndex < _commands.Count - 1;
}
public Command Continue()
{
if (!HasNextCommand())
return null;
_currentIndex++;
Command currentCommand = _commands[_currentIndex];
return currentCommand;
}
public Command GetCurrent()
{
if (_currentIndex >= 0 && _currentIndex < _commands.Count)
return _commands[_currentIndex];
return null;
}
public Command PeekNext()
{
if (_currentIndex < _commands.Count - 1)
return _commands[_currentIndex + 1];
return null;
}
public void JumpTo(string labelName)
{
_currentIndex = _labelMap[labelName] - 1;
Debug.Log($"Script :: Jump to label: {labelName} (Index: {_currentIndex + 1})");
}
}

View File

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

View File

@@ -0,0 +1,17 @@
using System;
using Cysharp.Threading.Tasks;
/// <summary>컴파일된 스크립트 액션 - 실행 가능한 최소 단위</summary>
public class ScriptAction
{
/// <summary>
/// 실행 함수 - UniTask&lt;ScriptResult&gt; 반환
/// </summary>
public Func<ScriptContext, UniTask<ScriptResult>> Execute { get; set; }
/// <summary>디버깅용 원본 타입 (label, msg, char 등)</summary>
public string DebugType { get; set; }
/// <summary>디버깅용 추가 정보</summary>
public string DebugInfo { get; set; }
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a65a141600ccb940bc39d7ae9575200

View File

@@ -0,0 +1,34 @@
using System;
using UnityEngine;
/// <summary>스크립트 실행에 필요한 컨텍스트</summary>
public class ScriptContext
{
// ===== Stores =====
public DialogueStore DialogueStore { get; set; }
public ChoiceStore ChoiceStore { get; set; }
public CharacterStore CharacterStore { get; set; }
public FlowStore FlowStore { get; set; }
/// <summary>변수 저장소</summary>
public VariableStore VariableStore { get; set; }
// ===== 흐름 제어 =====
/// <summary>다음 액션의 타입 확인 (choices 자동 진행 등에 사용)</summary>
public Func<string> PeekNextType { get; set; }
/// <summary>스크립트 교체 요청 콜백 (scriptPath)</summary>
public Action<string> OnScriptChange { get; set; }
// ===== 리소스 로딩 헬퍼 =====
private const string CharacterPathPrefix = "Images/Characters/";
public Sprite LoadCharacterSprite(string fileName)
{
string path = CharacterPathPrefix + fileName;
Sprite sprite = Resources.Load<Sprite>(path);
if (sprite == null)
Debug.LogError($"캐릭터 이미지 로드 실패: {path}");
return sprite;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 027ddadef4d0dfb4e9e0da81baf913bb

View File

@@ -0,0 +1,15 @@
/// <summary>스크립트 실행 결과 - 흐름 제어</summary>
public struct ScriptResult
{
public enum ResultType { Continue, Wait, Jump, End }
public ResultType Type;
public int NextIndex;
public static ScriptResult Continue => new() { Type = ResultType.Continue };
public static ScriptResult Wait => new() { Type = ResultType.Wait };
public static ScriptResult End => new() { Type = ResultType.End };
public static ScriptResult JumpTo(int index) =>
new() { Type = ResultType.Jump, NextIndex = index };
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 418b019e14c19ae489051b54d259ee24

View File

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

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using R3;
using UnityEngine;
// ===== 캐릭터 관련 Enums =====
public enum DirectionType
{
Left,
Right,
BottomLeft,
BottomRight,
Center,
Top,
RunLeft,
RunRight
}
public enum AnimationType
{
Jump,
Shake,
Nod,
Punch,
Run
}
/// <summary>개별 캐릭터의 상태</summary>
public class CharacterState
{
public string Name { get; }
// 시각적 상태
public ReactiveProperty<Sprite> Sprite { get; } = new(null);
public ReactiveProperty<float> Alpha { get; } = new(1f);
public ReactiveProperty<float> SlotWidth { get; } = new(0f);
public ReactiveProperty<Vector2> Position { get; } = new(Vector2.zero);
// 퇴장 상태
public ReactiveProperty<bool> IsExiting { get; } = new(false);
public ReactiveProperty<DirectionType?> ExitDirection { get; } = new(null);
public CharacterState(string name)
{
Name = name;
}
}
/// <summary>캐릭터 전체 상태 관리 Store</summary>
public class CharacterStore
{
private readonly Dictionary<string, CharacterState> _characters = new();
public event Action<string, CharacterState, DirectionType> OnCharacterAdded;
public event Action<string, CharacterState, DirectionType> OnCharacterExiting;
public event Action<string> OnCharacterRemoved;
// 명령형 이벤트 (ReactiveProperty 대신 직접 이벤트 사용으로 빠른 연타 시에도 씹히지 않음)
public event Action<string, AnimationType> OnActionRequested;
public event Action<string, Sprite> OnExpressionRequested;
public CharacterState Get(string name)
{
if (!_characters.ContainsKey(name))
{
_characters[name] = new CharacterState(name);
}
return _characters[name];
}
public bool Exists(string name) => _characters.ContainsKey(name);
public void Add(string name, Sprite sprite, DirectionType direction)
{
if (Exists(name))
{
Debug.LogWarning($"이미 존재하는 캐릭터입니다: {name}");
return;
}
var state = Get(name);
state.Sprite.Value = sprite;
state.Alpha.Value = 0f;
state.SlotWidth.Value = 0f;
state.Position.Value = Vector2.zero;
state.IsExiting.Value = false;
state.ExitDirection.Value = null;
OnCharacterAdded?.Invoke(name, state, direction);
}
public void Remove(string name, DirectionType direction)
{
if (!Exists(name))
{
Debug.LogWarning($"존재하지 않는 캐릭터입니다: {name}");
return;
}
var state = Get(name);
state.IsExiting.Value = true;
state.ExitDirection.Value = direction;
// 즉시 Store에서 제거 (퇴장 애니메이션은 Drawer에서 독립적으로 처리)
_characters.Remove(name);
OnCharacterExiting?.Invoke(name, state, direction);
}
public void FinalRemove(string name)
{
// 이미 Remove에서 삭제했으므로 남은 정리 작업만
OnCharacterRemoved?.Invoke(name);
}
public void PlayAction(string name, AnimationType action)
{
if (!Exists(name))
{
Debug.LogWarning($"액션 실패: '{name}' 캐릭터를 찾을 수 없습니다.");
return;
}
OnActionRequested?.Invoke(name, action);
}
public void ChangeExpression(string name, Sprite newSprite)
{
if (!Exists(name))
{
Debug.LogWarning($"표정 변경 실패: '{name}' 캐릭터를 찾을 수 없습니다.");
return;
}
OnExpressionRequested?.Invoke(name, newSprite);
}
public IEnumerable<string> AllNames => _characters.Keys;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 712c294e2c0ec7440aaba77f534a6fe7

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using R3;
/// <summary>선택지 옵션 (런타임용 - 변수 치환 완료)</summary>
public class ChoiceOption
{
public string Text { get; set; }
public string TargetLabel { get; set; }
public int TargetIndex { get; set; }
}
/// <summary>선택지 상태 관리 Store</summary>
public class ChoiceStore
{
public ReactiveProperty<bool> IsVisible { get; } = new(false);
public ReactiveProperty<List<ChoiceOption>> Options { get; } = new(new());
public void Show(List<ChoiceOption> choices)
{
Options.Value = choices;
IsVisible.Value = true;
}
public void Hide()
{
IsVisible.Value = false;
Options.Value = new();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 40b00f740563d3b4cabd040759636775

View File

@@ -0,0 +1,8 @@
using R3;
public class DialogueStore
{
public ReactiveProperty<string> Dialogue { get; } = new("");
public ReactiveProperty<string> Speaker { get; } = new("");
public ReactiveProperty<bool> IsDrawing { get; } = new(false);
}

View File

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

View File

@@ -0,0 +1,11 @@
using R3;
/// <summary>게임 흐름 상태 (모든 Drawer가 공통으로 구독)</summary>
public class FlowStore
{
/// <summary>대화 스킵 중 여부 - DialogueDrawer, CharacterDrawer 등이 구독</summary>
public ReactiveProperty<bool> IsSkipping { get; } = new(false);
public void RequestSkip() => IsSkipping.Value = true;
public void ResetSkip() => IsSkipping.Value = false;
}

View File

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

View File

@@ -2,11 +2,8 @@ using System.Collections.Generic;
using UnityEngine;
using System.Text.RegularExpressions;
public class Store
public class VariableStore
{
private static Store _instance;
public static Store Instance => _instance ??= new Store();
private Dictionary<string, object> _variables = new();
public void SetVariable(string name, string value)

View File

@@ -1,479 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.UI;
public class VNDirector : MonoBehaviour
{
[Header("UI 연결")]
public Transform characterPanel;
[Header("설정")]
public float charWidth = 350f;
public float defaultDuration = 0.5f;
public float moveDistance = 800f;
private const string CharacterPathPrefix = "Images/Characters/";
// ========================= [Queue System] =========================
// bool argument: isImmediate (skip animation)
private Dictionary<string, Queue<Func<bool, UniTask>>> actionQueues = new();
private Dictionary<string, CancellationTokenSource> activeCTS = new();
private void EnqueueAction(string charName, Func<bool, UniTask> actionFactory)
{
if (!actionQueues.ContainsKey(charName))
{
actionQueues[charName] = new Queue<Func<bool, UniTask>>();
}
actionQueues[charName].Enqueue(actionFactory);
if (!activeCTS.ContainsKey(charName) || activeCTS[charName] == null)
{
var cts = new CancellationTokenSource();
activeCTS[charName] = cts;
ProcessActionQueue(charName, cts.Token).Forget();
}
}
public void CompleteAllActions()
{
// 1. Stop all active processing
foreach (var kvp in activeCTS)
{
kvp.Value?.Cancel();
kvp.Value?.Dispose();
}
activeCTS.Clear();
// 2. Process remaining items in queues immediately
foreach (var queue in actionQueues.Values)
{
while (queue.Count > 0)
{
var actionFactory = queue.Dequeue();
// Execute immediately (skipping animations)
actionFactory(true).Forget();
}
}
// 3. Ensure all tweens are done (visuals snap to end)
Tween.CompleteAll();
}
private async UniTaskVoid ProcessActionQueue(string charName, CancellationToken token)
{
try
{
while (actionQueues.ContainsKey(charName) && actionQueues[charName].Count > 0)
{
if (token.IsCancellationRequested) break;
var actionFactory = actionQueues[charName].Dequeue();
await actionFactory(false); // Normal execution
}
}
catch (OperationCanceledException)
{
// Cancelled
}
finally
{
// Only remove if this is the CTS we started with (handling race conditions slightly)
if (activeCTS.ContainsKey(charName) && activeCTS[charName].Token == token)
{
var cts = activeCTS[charName];
activeCTS.Remove(charName);
cts.Dispose();
}
}
}
// ========================= [1. 등장 (Entry)] =========================
public void AddCharacter(string fileName, string type)
{
string path = CharacterPathPrefix + fileName;
Sprite loadedSprite = Resources.Load<Sprite>(path);
Debug.Log($"VisualNovelLayoutDirector :: AddCharacter: {fileName} ({path})");
if (loadedSprite != null)
{
EnqueueAction(fileName, (isImmediate) => SpawnAsync(fileName, loadedSprite, GetDirectionType(type), isImmediate));
}
else
{
Debug.LogError($"이미지 로드 실패: {path}");
}
}
private async UniTask SpawnAsync(string name, Sprite sprite, DirectionType type, bool isImmediate)
{
if (FindSlot(name) != null)
{
Debug.LogWarning($"이미 존재하는 캐릭터입니다: {name}");
return;
}
// 1. 슬롯 생성 (Programmatic)
var (newSlot, layoutElement, containerRect, charImage) = CreateCharacterSlot(name);
// 2. 초기화
charImage.sprite = sprite;
FitImageToScreen(charImage);
layoutElement.preferredWidth = 0;
layoutElement.minWidth = 0;
// 3. 순서 재배치
ArrangeSlotOrder(newSlot.transform, type);
// 4. 위치 잡기 및 애니메이션
Vector2 startPos = GetDirectionVector(type);
containerRect.anchoredPosition = startPos;
charImage.color = new Color(1, 1, 1, 0);
if (!isImmediate) await UniTask.WaitForEndOfFrame(this);
// Tween 실행 및 대기
float duration = isImmediate ? 0f : defaultDuration;
if (type == DirectionType.RunLeft || type == DirectionType.RunRight)
{
PlayActionAsync(newSlot.transform, AnimationType.Run, isImmediate).Forget();
}
await Sequence.Create()
.Group(Tween.Custom(layoutElement, 0f, charWidth, duration, (t, x) => t.preferredWidth = x, Ease.OutQuart))
.Group(Tween.UIAnchoredPosition(containerRect, Vector2.zero, duration, Ease.OutQuart))
.Group(Tween.Alpha(charImage, 1f, duration))
.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
}
// ========================= [2. 퇴장 (Exit)] =========================
public void RemoveCharacter(string characterName, string exitTo)
{
EnqueueAction(characterName, (isImmediate) => ExitAsync(characterName, GetDirectionType(exitTo), isImmediate));
}
private async UniTask ExitAsync(string characterName, DirectionType exitTo, bool isImmediate)
{
Transform targetSlot = FindSlot(characterName);
if (targetSlot == null)
{
Debug.LogWarning($"삭제 실패: '{characterName}' 캐릭터를 찾을 수 없습니다.");
return;
}
// 중복 호출 방지를 위해 이름을 바꿔둠
targetSlot.name += "_Removing";
LayoutElement layoutElement = targetSlot.GetComponent<LayoutElement>();
Transform container = targetSlot.GetChild(0); // MotionContainer
RectTransform containerRect = container.GetComponent<RectTransform>();
Image charImage = container.GetChild(0).GetComponent<Image>(); // Image
Vector2 targetPos = GetDirectionVector(exitTo);
float duration = isImmediate ? 0f : defaultDuration;
if (exitTo == DirectionType.RunLeft || exitTo == DirectionType.RunRight)
{
PlayActionAsync(targetSlot, AnimationType.Run, isImmediate).Forget();
}
// 이미지 날리기 & 투명화 & 공간 닫기 (동시 실행 및 대기)
await Sequence.Create()
.Group(Tween.UIAnchoredPosition(containerRect, targetPos, duration, Ease.OutQuart))
.Group(Tween.Alpha(charImage, 0f, duration * 0.8f))
.Group(Tween.Custom(layoutElement, layoutElement.preferredWidth, 0f, duration, (t, x) => t.preferredWidth = x, Ease.OutQuart))
.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
Destroy(targetSlot.gameObject);
}
// ========================= [3. 액션 (Action)] =========================
public void PlayAction(string characterName, string action)
{
EnqueueAction(characterName, (isImmediate) => PlayActionAsync(characterName, GetAnimationType(action), isImmediate));
}
private async UniTask PlayActionAsync(string characterName, AnimationType action, bool isImmediate)
{
Transform targetSlot = FindSlot(characterName);
if (targetSlot == null)
{
Debug.LogWarning($"액션 실패: '{characterName}' 캐릭터를 찾을 수 없습니다.");
return;
}
await PlayActionAsync(targetSlot, action, isImmediate);
}
private async UniTask PlayActionAsync(Transform targetSlot, AnimationType action, bool isImmediate)
{
// [변경] 계층 구조 반영: Slot -> Container -> Image
// 액션은 Image에만 적용 (Container는 이동 담당)
RectTransform targetImageRect = targetSlot.GetChild(0).GetChild(0).GetComponent<RectTransform>();
// 기존 애니메이션 정지 및 초기화
Tween.StopAll(targetImageRect);
targetImageRect.anchoredPosition = Vector2.zero;
if (isImmediate) return; // 즉시 실행 시 애니메이션 스킵
Tween actionTween = default;
Sequence actionSequence = default;
bool isSequence = false;
switch (action)
{
case AnimationType.Jump:
actionTween = Tween.PunchLocalPosition(targetImageRect, new Vector3(0, 100f, 0), 0.5f, frequency: 2);
break;
case AnimationType.Shake:
actionTween = Tween.ShakeLocalPosition(targetImageRect, new Vector3(50f, 0, 0), 0.5f, frequency: 10);
break;
case AnimationType.Run:
actionTween = Tween.PunchLocalPosition(targetImageRect, new Vector3(0, 50f, 0), 0.5f, frequency: 10);
break;
case AnimationType.Nod:
isSequence = true;
actionSequence = Sequence.Create()
.Chain(Tween.UIAnchoredPositionY(targetImageRect, -30f, 0.15f, Ease.OutQuad))
.Chain(Tween.UIAnchoredPositionY(targetImageRect, 0f, 0.15f, Ease.InQuad));
break;
case AnimationType.Punch:
actionTween = Tween.PunchScale(targetImageRect, new Vector3(0.2f, 0.2f, 0), 0.4f, frequency: 1);
break;
}
if (isSequence)
{
if (actionSequence.isAlive) await actionSequence.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
}
else
{
if (actionTween.isAlive) await actionTween.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
}
}
// ========================= [4. 표정 변경 (Change Expression)] =========================
public void ChangeExpression(string characterName, string spriteName)
{
EnqueueAction(characterName, (isImmediate) => ChangeExpressionAsync(characterName, spriteName, isImmediate));
}
private async UniTask ChangeExpressionAsync(string characterName, string spriteName, bool isImmediate)
{
Transform targetSlot = FindSlot(characterName);
if (targetSlot == null) return;
Image charImage = targetSlot.GetChild(0).GetChild(0).GetComponent<Image>();
Sprite newSprite = Resources.Load<Sprite>(CharacterPathPrefix + spriteName);
if (newSprite != null)
{
if (isImmediate)
{
charImage.sprite = newSprite;
FitImageToScreen(charImage);
return;
}
// 마스크 및 오버레이 설정
var (maskObj, maskRect, overlayRect) = SetupMaskAndOverlay(charImage, newSprite);
// 3. 애니메이션 실행 (마스크 높이를 키워서 이미지를 드러냄)
float softnessOffset = 100f;
float targetHeight = overlayRect.sizeDelta.y + softnessOffset;
float currentWidth = charImage.rectTransform.rect.width;
await Tween.UISizeDelta(maskRect, new Vector2(currentWidth, targetHeight), 0.5f, Ease.OutQuart)
.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
// 원본 교체 및 정리
charImage.sprite = newSprite;
FitImageToScreen(charImage);
Destroy(maskObj);
}
else
{
Debug.LogError($"표정 스프라이트를 찾을 수 없습니다: {spriteName}");
}
}
// ========================= [Helpers] =========================
private (GameObject slot, LayoutElement layout, RectTransform container, Image image) CreateCharacterSlot(string name)
{
GameObject newSlot = new GameObject(name);
newSlot.transform.SetParent(characterPanel, false);
LayoutElement layoutElement = newSlot.AddComponent<LayoutElement>();
GameObject motionContainer = new("MotionContainer");
RectTransform containerRect = motionContainer.AddComponent<RectTransform>();
motionContainer.transform.SetParent(newSlot.transform, false);
containerRect.anchorMin = Vector2.zero;
containerRect.anchorMax = Vector2.one;
containerRect.sizeDelta = Vector2.zero;
GameObject imageObj = new GameObject("Image");
imageObj.transform.SetParent(motionContainer.transform, false);
Image charImage = imageObj.AddComponent<Image>();
return (newSlot, layoutElement, containerRect, charImage);
}
private (GameObject maskObj, RectTransform maskRect, RectTransform overlayRect) SetupMaskAndOverlay(Image charImage, Sprite newSprite)
{
GameObject maskObj = new("MaskContainer");
maskObj.transform.SetParent(charImage.transform, false);
RectTransform maskRect = maskObj.AddComponent<RectTransform>();
maskRect.anchorMin = new Vector2(0.5f, 1f); // Top Center
maskRect.anchorMax = new Vector2(0.5f, 1f);
maskRect.pivot = new Vector2(0.5f, 1f);
float softnessOffset = 100f;
float currentWidth = charImage.rectTransform.rect.width;
maskRect.anchoredPosition = new Vector2(0, softnessOffset);
maskRect.sizeDelta = new Vector2(currentWidth, 0);
RectMask2D rectMask = maskObj.AddComponent<RectMask2D>();
rectMask.softness = new Vector2Int(0, (int)softnessOffset);
GameObject overlayObj = new("ExpressionOverlay");
overlayObj.transform.SetParent(maskObj.transform, false);
Image overlayImage = overlayObj.AddComponent<Image>();
overlayImage.sprite = newSprite;
overlayImage.color = charImage.color;
overlayImage.material = charImage.material;
overlayImage.raycastTarget = charImage.raycastTarget;
overlayImage.type = Image.Type.Simple;
overlayImage.preserveAspect = true;
RectTransform overlayRect = overlayImage.rectTransform;
overlayRect.anchorMin = new Vector2(0.5f, 1f);
overlayRect.anchorMax = new Vector2(0.5f, 1f);
overlayRect.pivot = new Vector2(0.5f, 1f);
overlayRect.anchoredPosition = new Vector2(0, -softnessOffset);
FitImageToScreen(overlayImage);
maskObj.transform.SetAsLastSibling();
return (maskObj, maskRect, overlayRect);
}
private void ArrangeSlotOrder(Transform slotTransform, DirectionType type)
{
int totalCount = characterPanel.childCount;
switch (type)
{
case DirectionType.Left:
case DirectionType.RunLeft:
case DirectionType.BottomLeft:
slotTransform.SetSiblingIndex(0); break;
case DirectionType.Right:
case DirectionType.RunRight:
case DirectionType.BottomRight:
slotTransform.SetSiblingIndex(totalCount - 1); break;
case DirectionType.Center:
case DirectionType.Top:
List<Transform> activeChildren = new();
for (int i = 0; i < totalCount; i++)
{
Transform child = characterPanel.GetChild(i);
if (child != slotTransform && !child.name.Contains("_Removing"))
{
activeChildren.Add(child);
}
}
int targetIndex = activeChildren.Count / 2;
if (targetIndex < activeChildren.Count)
{
slotTransform.SetSiblingIndex(activeChildren[targetIndex].GetSiblingIndex());
}
else
{
slotTransform.SetSiblingIndex(totalCount - 1);
}
break;
}
}
private Transform FindSlot(string name)
{
return characterPanel.Find(name);
}
private Vector2 GetDirectionVector(DirectionType type)
{
return type switch
{
DirectionType.Left or DirectionType.RunLeft => new Vector2(-moveDistance, 0),
DirectionType.Right or DirectionType.RunRight => new Vector2(moveDistance, 0),
DirectionType.Center or DirectionType.BottomLeft or DirectionType.BottomRight => new Vector2(0, -moveDistance),
DirectionType.Top => new Vector2(0, moveDistance),
_ => Vector2.zero,
};
}
private void FitImageToScreen(Image image)
{
image.SetNativeSize();
float maxHeight = Screen.height * 0.95f;
if (image.rectTransform.rect.height > maxHeight)
{
float aspectRatio = image.rectTransform.rect.width / image.rectTransform.rect.height;
float newHeight = maxHeight;
float newWidth = newHeight * aspectRatio;
image.rectTransform.sizeDelta = new Vector2(newWidth, newHeight);
}
}
private AnimationType GetAnimationType(string str)
{
return str switch
{
"jump" => AnimationType.Jump,
"shake" => AnimationType.Shake,
"run" => AnimationType.Run,
"nod" => AnimationType.Nod,
"punch" => AnimationType.Punch,
_ => AnimationType.Nod
};
}
private DirectionType GetDirectionType(string str)
{
return str switch
{
"left" => DirectionType.Left,
"right" => DirectionType.Right,
"center" => DirectionType.Center,
"bottomleft" => DirectionType.BottomLeft,
"bottomright" => DirectionType.BottomRight,
"top" => DirectionType.Top,
"runleft" => DirectionType.RunLeft,
"runright" => DirectionType.RunRight,
_ => DirectionType.Center
};
}
}

View File

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

View File

@@ -1,224 +1,152 @@
using System.Collections.Generic;
using PrimeTween;
using TMPro;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class VNManager : MonoBehaviour
{
[SerializeField]
TextAsset scriptFile;
private TextAsset _scriptFile;
[SerializeField]
TextMeshProUGUI speakerText;
private DialogueDrawer _dialogueDrawer;
[SerializeField]
GameObject speakerSprite;
private ChoiceDrawer _choiceDrawer;
[SerializeField]
TextMeshProUGUI dialogueText;
[SerializeField]
private GameObject choiceButtonPrefab;
[SerializeField]
private Transform choiceButtonContainer;
[SerializeField]
private Image choiceBackground;
[SerializeField]
float charsPerSecond = 45f;
private CharacterDrawer _characterDrawer;
public VNDirector director;
private bool isChoiceAvailable = false;
private Tween dialogueTween;
private Script _currentScript;
public static string NextScriptPath = "";
// Stores
private DialogueStore _dialogueStore;
private ChoiceStore _choiceStore;
private CharacterStore _characterStore;
private FlowStore _flowStore;
private VariableStore _variableStore;
void Start()
// 컴파일된 스크립트
private ScriptAction[] _actions;
private ScriptContext _context;
private int _currentIndex = 0;
private bool _inputReceived = false;
private void Awake()
{
speakerText.SetText(" ");
speakerText.ForceMeshUpdate(true);
dialogueText.SetText(" ");
dialogueText.ForceMeshUpdate(true);
// Store 초기화
_dialogueStore = new();
_choiceStore = new();
_characterStore = new();
_flowStore = new();
_variableStore = new VariableStore();
if (!string.IsNullOrEmpty(NextScriptPath))
{
TextAsset loadedScript = Resources.Load<TextAsset>($"NovelScripts/{NextScriptPath}");
if (loadedScript != null)
{
_currentScript = Parser.Parse(loadedScript.text);
NextScriptPath = "";
}
else
{
Debug.LogError($"ScriptManager :: Cannot find script: {NextScriptPath}");
_currentScript = Parser.Parse(scriptFile.text);
}
}
else
{
_currentScript = Parser.Parse(scriptFile.text);
}
// 모든 Drawer 바인딩
_dialogueDrawer.Bind(_dialogueStore, _flowStore);
_choiceDrawer.Bind(_choiceStore);
_characterDrawer.Bind(_characterStore, _flowStore);
NextStep();
// 선택지 선택 이벤트 구독
_choiceDrawer.OnChoiceSelected += OnChoiceSelected;
}
void Update()
private void Start()
{
DisplayEffects(dialogueText);
if (!isChoiceAvailable && !IsPointerOverInteractiveUI() && (Input.GetMouseButtonDown(0) || Input.GetKeyDown(KeyCode.Space)))
// 스크립트 로드 및 컴파일
var (commands, labelMap) = Parser.Parse(_scriptFile.text);
_actions = Compiler.Compile(commands, labelMap);
// 컨텍스트 구성
_context = new ScriptContext
{
if (dialogueTween.isAlive)
{
director.CompleteAllActions();
dialogueTween.Complete();
}
else
NextStep();
}
DialogueStore = _dialogueStore,
ChoiceStore = _choiceStore,
CharacterStore = _characterStore,
FlowStore = _flowStore,
VariableStore = _variableStore,
PeekNextType = () =>
_currentIndex + 1 < _actions.Length
? _actions[_currentIndex + 1].DebugType
: null,
OnScriptChange = HandleScriptChange
};
// 실행 시작
ExecuteScriptAsync().Forget();
}
private void NextStep()
private void HandleScriptChange(string scriptPath)
{
if (_currentScript.HasNextCommand())
TextAsset script = Resources.Load<TextAsset>($"NovelScripts/{scriptPath}");
if (script == null)
{
Command command = _currentScript.Continue();
Execute(command);
Debug.LogError($"ScriptManager :: Cannot find script: {scriptPath}");
return;
}
var (commands, labelMap) = Parser.Parse(script.text);
_actions = Compiler.Compile(commands, labelMap);
_currentIndex = 0;
ExecuteScriptAsync().Forget();
}
private async UniTaskVoid ExecuteScriptAsync()
{
while (_currentIndex < _actions.Length)
{
var action = _actions[_currentIndex];
// choices가 아닌 다른 명령어 실행 시 선택지 UI 숨김
if (_choiceStore.IsVisible.Value && action.DebugType != "choices")
_choiceStore.Hide();
var result = await action.Execute(_context);
switch (result.Type)
{
case ScriptResult.ResultType.Continue:
_currentIndex++;
break;
case ScriptResult.ResultType.Wait:
await UniTask.WaitUntil(() => _inputReceived);
_inputReceived = false;
_currentIndex++;
break;
case ScriptResult.ResultType.Jump:
_currentIndex = result.NextIndex;
break;
case ScriptResult.ResultType.End:
Debug.Log("ScriptManager :: Script ended");
return;
}
}
Debug.Log("ScriptManager :: End of Script");
}
private void Execute(Command command)
private void Update()
{
switch (command.Type)
// 선택지 표시 중에는 입력 무시
if (_choiceStore.IsVisible.Value) return;
if (!IsPointerOverInteractiveUI() && (Input.GetMouseButtonDown(0) || Input.GetKeyDown(KeyCode.Space)))
{
case "label":
Debug.Log($"ScriptManager :: Change Label: {command.GetParam("content")}");
NextStep();
return;
case "bg":
Debug.Log($"ScriptManager :: Change Background: {command.GetParam("file")}");
NextStep();
return;
case "char":
director.AddCharacter(command.GetParam("img"), command.GetParam("enter").ToLower());
Debug.Log($"ScriptManager :: Character: {command.GetParam("img")}");
NextStep();
return;
case "remove":
director.RemoveCharacter(command.GetParam("target"), command.GetParam("exit").ToLower());
Debug.Log($"ScriptManager :: Remove Character: {command.GetParam("target")} to {command.GetParam("exit").ToLower()}");
NextStep();
return;
case "action":
director.PlayAction(command.GetParam("target"), command.GetParam("anim").ToLower());
Debug.Log($"ScriptManager :: Action: {command.GetParam("target")} {command.GetParam("anim").ToLower()}");
NextStep();
return;
case "expr":
director.ChangeExpression(command.GetParam("target"), command.GetParam("expr").ToLower());
Debug.Log($"ScriptManager :: Expression: {command.GetParam("target")} {command.GetParam("expr").ToLower()}");
NextStep();
return;
case "spk":
if (speakerSprite.activeSelf == false)
speakerSprite.SetActive(true);
if (command.GetParam("name") == "")
speakerSprite.SetActive(false);
string speaker = Store.Instance.ReplaceVariables(command.GetParam("name"));
Debug.Log($"ScriptManager :: Speaker: {speaker}");
speakerText.SetText(speaker);
speakerText.ForceMeshUpdate(true);
NextStep();
return;
case "msg":
string dialogue = command.GetParam("content");
dialogue = Store.Instance.ReplaceVariables(dialogue);
DisplayDialogue(dialogue);
if (_currentScript.PeekNext()?.Type == "choices")
{
NextStep();
}
return;
case "goto":
string targetLabel = command.GetParam("content");
_currentScript.JumpTo(targetLabel);
NextStep();
return;
case "choices":
Debug.Log("ScriptManager :: Show Choices");
isChoiceAvailable = true;
// WTF.. is this shit
Color tempColor = choiceBackground.color;
tempColor.a = 0.8f;
choiceBackground.color = tempColor;
foreach (var choice in command.Choices)
{
string text = Store.Instance.ReplaceVariables(choice["content"]);
string target = choice["goto"];
GameObject buttonObj = Instantiate(choiceButtonPrefab, choiceButtonContainer);
buttonObj.GetComponentInChildren<TextMeshProUGUI>().text = text;
buttonObj
.GetComponent<Button>()
.onClick.AddListener(() =>
{
foreach (Transform child in choiceButtonContainer)
Destroy(child.gameObject);
isChoiceAvailable = false;
// shitty code
tempColor.a = 0f;
choiceBackground.color = tempColor;
_currentScript.JumpTo(target);
NextStep();
});
}
return;
case "var":
foreach (var entry in command.Params)
{
Store.Instance.SetVariable(entry.Key, entry.Value.ToString());
}
NextStep();
return;
case "add":
foreach (var entry in command.Params)
{
Store.Instance.AddVariable(entry.Key, entry.Value.ToString());
}
NextStep();
return;
case "scene":
string sceneName = command.GetParam("file");
string nextScript = command.GetParam("script");
Debug.Log($"ScriptManager :: Load Scene: {sceneName}, Next Script: {nextScript}");
NextScriptPath = nextScript;
SceneManager.LoadScene(sceneName);
return;
default:
Debug.LogWarning($"ScriptManager :: Unknown command: {command.Type}");
NextStep();
return;
if (_dialogueStore.IsDrawing.Value)
{
// 공통 FlowStore로 스킵 신호 전달
_flowStore.RequestSkip();
}
else
{
_inputReceived = true;
}
}
}
public void DebugReload()
/// <summary>ChoiceDrawer에서 선택 시 호출</summary>
private void OnChoiceSelected(string targetLabel, int targetIndex)
{
speakerText.SetText(" ");
speakerText.ForceMeshUpdate(true);
dialogueText.SetText(" ");
dialogueText.ForceMeshUpdate(true);
_currentScript = Parser.Parse(scriptFile.text);
_currentIndex = targetIndex;
_inputReceived = true;
}
private bool IsPointerOverInteractiveUI()
@@ -236,64 +164,4 @@ public class VNManager : MonoBehaviour
return false;
}
private void DisplayDialogue(string text)
{
// Unity 내부 최적화로 인해 줄이 바뀔 시 LinkInfo 배열이 초기화되지 않음.
// 따라서 수동으로 초기화를 수행.
dialogueText.textInfo.linkInfo = new TMP_LinkInfo[0];
dialogueText.SetText(text);
dialogueText.ForceMeshUpdate(true);
dialogueText.maxVisibleCharacters = 0;
dialogueTween = Tween.Custom(
startValue: 0f,
endValue: dialogueText.textInfo.characterCount,
duration: dialogueText.textInfo.characterCount / charsPerSecond,
onValueChange: x => dialogueText.maxVisibleCharacters = Mathf.RoundToInt(x),
ease: Ease.Linear
);
}
public bool IsDialoguePlaying()
{
return dialogueTween.isAlive;
}
private void DisplayEffects(TextMeshProUGUI text)
{
text.ForceMeshUpdate(true);
TMP_TextInfo textInfo = text.textInfo;
TMP_LinkInfo[] linkInfo = textInfo.linkInfo;
Mesh mesh = text.mesh;
Vector3[] vertices = mesh.vertices;
foreach (var link in linkInfo)
{
string linkName = link.GetLinkID();
int start = link.linkTextfirstCharacterIndex;
int end = link.linkTextfirstCharacterIndex + link.linkTextLength;
for (var i = start; i < end; i++)
{
TMP_CharacterInfo c = textInfo.characterInfo[i];
int idx = c.vertexIndex;
if (!c.isVisible)
continue; // 공백은 VertexIndex 0 Return -> Visible이 안 되므로
if (linkName == "shake")
{
Vector3 offset = new(Random.Range(-1.1f, 1.1f), Random.Range(-1.1f, 1.1f));
for (byte j = 0; j < 4; j++)
vertices[idx + j] += offset;
}
}
}
mesh.vertices = vertices;
text.canvasRenderer.SetMesh(mesh);
}
}