mirror of
https://github.com/HoonTB/Project-AS.git
synced 2025-12-26 20:01:21 +09:00
feat: Add core VN system scripts, package dependencies, and initial project settings.
This commit is contained in:
261
Assets/_MAIN/Scripts/Core/Compiler.cs
Normal file
261
Assets/_MAIN/Scripts/Core/Compiler.cs
Normal 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
|
||||
};
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Compiler.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Compiler.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 08b6e5d2e19561a439ebc7759f94bf27
|
||||
8
Assets/_MAIN/Scripts/Core/Drawer.meta
Normal file
8
Assets/_MAIN/Scripts/Core/Drawer.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ead8d179f3907a48abe140afd52ac29
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
420
Assets/_MAIN/Scripts/Core/Drawer/CharacterDrawer.cs
Normal file
420
Assets/_MAIN/Scripts/Core/Drawer/CharacterDrawer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Drawer/CharacterDrawer.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Drawer/CharacterDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68fc2202d64c2994588badec3b102032
|
||||
25
Assets/_MAIN/Scripts/Core/Drawer/CharacterSlot.cs
Normal file
25
Assets/_MAIN/Scripts/Core/Drawer/CharacterSlot.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Drawer/CharacterSlot.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Drawer/CharacterSlot.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdb0f384e92143842a244a0060b77247
|
||||
80
Assets/_MAIN/Scripts/Core/Drawer/ChoiceDrawer.cs
Normal file
80
Assets/_MAIN/Scripts/Core/Drawer/ChoiceDrawer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Drawer/ChoiceDrawer.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Drawer/ChoiceDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4c992891284d074e8e00b06935a7126
|
||||
141
Assets/_MAIN/Scripts/Core/Drawer/DialogueDrawer.cs
Normal file
141
Assets/_MAIN/Scripts/Core/Drawer/DialogueDrawer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Drawer/DialogueDrawer.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Drawer/DialogueDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f20e3ed16baa4e44a82c064bb09f9f9
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 315eaaf0db7bdb34aad7721b0bf6fb93
|
||||
@@ -1,9 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
public void LoadScene()
|
||||
{
|
||||
UnityEngine.SceneManagement.SceneManager.LoadScene("In-Game");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a080bacd75c6e14aa3db5f6f3b87442
|
||||
@@ -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)
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3f1c6b3f22568a45932d3414362bf0b
|
||||
17
Assets/_MAIN/Scripts/Core/ScriptAction.cs
Normal file
17
Assets/_MAIN/Scripts/Core/ScriptAction.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using Cysharp.Threading.Tasks;
|
||||
|
||||
/// <summary>컴파일된 스크립트 액션 - 실행 가능한 최소 단위</summary>
|
||||
public class ScriptAction
|
||||
{
|
||||
/// <summary>
|
||||
/// 실행 함수 - UniTask<ScriptResult> 반환
|
||||
/// </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; }
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/ScriptAction.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/ScriptAction.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a65a141600ccb940bc39d7ae9575200
|
||||
34
Assets/_MAIN/Scripts/Core/ScriptContext.cs
Normal file
34
Assets/_MAIN/Scripts/Core/ScriptContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/ScriptContext.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/ScriptContext.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 027ddadef4d0dfb4e9e0da81baf913bb
|
||||
15
Assets/_MAIN/Scripts/Core/ScriptResult.cs
Normal file
15
Assets/_MAIN/Scripts/Core/ScriptResult.cs
Normal 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 };
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/ScriptResult.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/ScriptResult.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 418b019e14c19ae489051b54d259ee24
|
||||
8
Assets/_MAIN/Scripts/Core/Store.meta
Normal file
8
Assets/_MAIN/Scripts/Core/Store.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9aeef81e976fdd4418ef0d067b99f4d7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
139
Assets/_MAIN/Scripts/Core/Store/CharacterStore.cs
Normal file
139
Assets/_MAIN/Scripts/Core/Store/CharacterStore.cs
Normal 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;
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Store/CharacterStore.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Store/CharacterStore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 712c294e2c0ec7440aaba77f534a6fe7
|
||||
29
Assets/_MAIN/Scripts/Core/Store/ChoiceStore.cs
Normal file
29
Assets/_MAIN/Scripts/Core/Store/ChoiceStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Store/ChoiceStore.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Store/ChoiceStore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40b00f740563d3b4cabd040759636775
|
||||
8
Assets/_MAIN/Scripts/Core/Store/DialogueStore.cs
Normal file
8
Assets/_MAIN/Scripts/Core/Store/DialogueStore.cs
Normal 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);
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Store/DialogueStore.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Store/DialogueStore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e58974e87ff3dc4d8bce68169b7d9a1
|
||||
11
Assets/_MAIN/Scripts/Core/Store/FlowStore.cs
Normal file
11
Assets/_MAIN/Scripts/Core/Store/FlowStore.cs
Normal 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;
|
||||
}
|
||||
2
Assets/_MAIN/Scripts/Core/Store/FlowStore.cs.meta
Normal file
2
Assets/_MAIN/Scripts/Core/Store/FlowStore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ccd3a8617430cb41b04558da33a86c2
|
||||
@@ -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)
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c585528ddbc2ef4da6d6abda32cfea0
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user