From 7a3069fa3c0dbc4b614dc32906b80bcbc9e37345 Mon Sep 17 00:00:00 2001 From: heavycaffiner Date: Sat, 29 Nov 2025 04:15:53 +0900 Subject: [PATCH] feat: refactor character animation system with motion container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Separate character hierarchy into Slot → MotionContainer → Image - Improve expression change with mask-based smooth transition - Replace scene-based navigation with label-based jump system - Add Top direction support and refactor direction vector logic - Minor code improvements (C# index operator, cleaner initialization) --- Assets/_MAIN/Scripts/Core/Script.cs | 12 +- Assets/_MAIN/Scripts/Core/ScriptManager.cs | 102 +++++++---- Assets/_MAIN/Scripts/Core/ScriptParser.cs | 26 +-- .../Scripts/Core/VisualNovelLayoutDirector.cs | 160 +++++++++++++++--- 4 files changed, 222 insertions(+), 78 deletions(-) diff --git a/Assets/_MAIN/Scripts/Core/Script.cs b/Assets/_MAIN/Scripts/Core/Script.cs index 0f851aa..10b171e 100644 --- a/Assets/_MAIN/Scripts/Core/Script.cs +++ b/Assets/_MAIN/Scripts/Core/Script.cs @@ -5,12 +5,12 @@ public class Script { private List _actions; private int _currentIndex = -1; - private Dictionary _sceneMap = new Dictionary(); + private Dictionary _labelMap = new(); - public Script(List actions, Dictionary sceneMap) + public Script(List actions, Dictionary labelMap) { _actions = actions; - _sceneMap = sceneMap; + _labelMap = labelMap; _currentIndex = -1; } @@ -37,10 +37,10 @@ public class Script return null; } - public void JumpTo(string sceneName) + public void JumpTo(string labelName) { - _currentIndex = _sceneMap[sceneName] - 1; // Continue() 호출 시 해당 인덱스가 되도록 -1 - Debug.Log($"Script :: Jump to scene: {sceneName} (Index: {_currentIndex + 1})"); + _currentIndex = _labelMap[labelName] - 1; // Continue() 호출 시 해당 인덱스가 되도록 -1 + Debug.Log($"Script :: Jump to label: {labelName} (Index: {_currentIndex + 1})"); } public void Save() diff --git a/Assets/_MAIN/Scripts/Core/ScriptManager.cs b/Assets/_MAIN/Scripts/Core/ScriptManager.cs index b1bc74f..b009d02 100644 --- a/Assets/_MAIN/Scripts/Core/ScriptManager.cs +++ b/Assets/_MAIN/Scripts/Core/ScriptManager.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Collections; using PrimeTween; using TMPro; using UnityEngine; @@ -38,36 +37,10 @@ public class ScriptManager : MonoBehaviour dialogueText.SetText(" "); dialogueText.ForceMeshUpdate(true); - StartCoroutine(TestAnim()); - _currentScript = ScriptParser.Parse(scriptFile.text); NextStep(); } - IEnumerator TestAnim() - { - director.AddCharacter("chino01", VisualNovelLayoutDirector.EntranceType.Center); - yield return new WaitForSeconds(1f); - director.AddCharacter("chino02", VisualNovelLayoutDirector.EntranceType.Left); - yield return new WaitForSeconds(1f); - director.AddCharacter("chino03", VisualNovelLayoutDirector.EntranceType.Right); - yield return new WaitForSeconds(1f); - director.RemoveCharacter("chino02", VisualNovelLayoutDirector.EntranceType.Left); - yield return new WaitForSeconds(1f); - director.RemoveCharacter("chino03", VisualNovelLayoutDirector.EntranceType.Right); - yield return new WaitForSeconds(1f); - director.PlayAction("chino01", VisualNovelLayoutDirector.ActionType.Jump); - yield return new WaitForSeconds(1f); - director.PlayAction("chino01", VisualNovelLayoutDirector.ActionType.Shake); - yield return new WaitForSeconds(1f); - director.PlayAction("chino01", VisualNovelLayoutDirector.ActionType.Nod); - yield return new WaitForSeconds(1f); - director.PlayAction("chino01", VisualNovelLayoutDirector.ActionType.Punch); - yield return new WaitForSeconds(1f); - director.AddCharacter("chino02", VisualNovelLayoutDirector.EntranceType.Left); - yield return new WaitForSeconds(1f); - director.AddCharacter("chino03", VisualNovelLayoutDirector.EntranceType.Center); - } void Update() { @@ -96,10 +69,10 @@ public class ScriptManager : MonoBehaviour private void ExecuteAction(ScriptAction action) { - if (action.Type == "scene") + if (action.Type == "label") { - string sceneName = action.GetParam("name"); - Debug.Log($"ScriptManager :: Change Scene: {sceneName}"); + string labelName = action.GetParam("content"); + Debug.Log($"ScriptManager :: Change Label: {labelName}"); NextStep(); return; } @@ -110,6 +83,61 @@ public class ScriptManager : MonoBehaviour NextStep(); return; } + if (action.Type == "char") + { + string charFile = action.GetParam("img"); + string charEntrance = action.GetParam("enter"); + if (charEntrance == "") charEntrance = "center"; + if (charEntrance.ToLower() == "center") director.AddCharacter(charFile, VisualNovelLayoutDirector.EntranceType.Center); + if (charEntrance.ToLower() == "left") director.AddCharacter(charFile, VisualNovelLayoutDirector.EntranceType.Left); + if (charEntrance.ToLower() == "right") director.AddCharacter(charFile, VisualNovelLayoutDirector.EntranceType.Right); + if (charEntrance.ToLower() == "bottomleft") director.AddCharacter(charFile, VisualNovelLayoutDirector.EntranceType.BottomLeft); + if (charEntrance.ToLower() == "bottomright") director.AddCharacter(charFile, VisualNovelLayoutDirector.EntranceType.BottomRight); + Debug.Log($"ScriptManager :: Character: {charFile}"); + NextStep(); + return; + } + if (action.Type == "remove") + { + string charName = action.GetParam("target"); + string exitType = action.GetParam("exit"); + if (exitType == "") exitType = "center"; + + VisualNovelLayoutDirector.EntranceType type = VisualNovelLayoutDirector.EntranceType.Center; + if (exitType.ToLower() == "left") type = VisualNovelLayoutDirector.EntranceType.Left; + if (exitType.ToLower() == "right") type = VisualNovelLayoutDirector.EntranceType.Right; + if (exitType.ToLower() == "bottomleft") type = VisualNovelLayoutDirector.EntranceType.BottomLeft; + if (exitType.ToLower() == "bottomright") type = VisualNovelLayoutDirector.EntranceType.BottomRight; + if (exitType.ToLower() == "top") type = VisualNovelLayoutDirector.EntranceType.Top; + + director.RemoveCharacter(charName, type); + Debug.Log($"ScriptManager :: Remove Character: {charName} to {exitType}"); + NextStep(); + return; + } + if (action.Type == "action") + { + string charName = action.GetParam("target"); + string charAnim = action.GetParam("anim"); + if (charAnim == "") charAnim = "center"; + if (charAnim.ToLower() == "jump") director.PlayAction(charName, VisualNovelLayoutDirector.ActionType.Jump); + if (charAnim.ToLower() == "shake") director.PlayAction(charName, VisualNovelLayoutDirector.ActionType.Shake); + if (charAnim.ToLower() == "shakehorizontal") director.PlayAction(charName, VisualNovelLayoutDirector.ActionType.ShakeHorizontal); + if (charAnim.ToLower() == "nod") director.PlayAction(charName, VisualNovelLayoutDirector.ActionType.Nod); + if (charAnim.ToLower() == "punch") director.PlayAction(charName, VisualNovelLayoutDirector.ActionType.Punch); + Debug.Log($"ScriptManager :: Action: {charName} {charAnim}"); + NextStep(); + return; + } + if (action.Type == "expr") + { + string charName = action.GetParam("target"); + string charExpr = action.GetParam("expr"); + director.ChangeExpression(charName, charExpr); + Debug.Log($"ScriptManager :: Expression: {charName} {charExpr}"); + NextStep(); + return; + } if (action.Type == "spk") { string speaker = action.GetParam("name"); @@ -129,8 +157,8 @@ public class ScriptManager : MonoBehaviour } if (action.Type == "goto") { - string targetScene = action.GetParam("scene"); - _currentScript.JumpTo(targetScene); + string targetLabel = action.GetParam("content"); + _currentScript.JumpTo(targetLabel); NextStep(); return; } @@ -182,9 +210,11 @@ public class ScriptManager : MonoBehaviour private bool IsPointerOverInteractiveUI() { - PointerEventData eventData = new PointerEventData(EventSystem.current); - eventData.position = Input.mousePosition; - List results = new List(); + PointerEventData eventData = new(EventSystem.current) + { + position = Input.mousePosition + }; + List results = new(); EventSystem.current.RaycastAll(eventData, results); foreach (RaycastResult result in results) @@ -238,7 +268,7 @@ public class ScriptManager : MonoBehaviour if (linkName == "shake") { - Vector3 offset = new Vector3( + Vector3 offset = new( Random.Range(-shakeAmount, shakeAmount), Random.Range(-shakeAmount, shakeAmount) ); diff --git a/Assets/_MAIN/Scripts/Core/ScriptParser.cs b/Assets/_MAIN/Scripts/Core/ScriptParser.cs index fd081b0..b28581d 100644 --- a/Assets/_MAIN/Scripts/Core/ScriptParser.cs +++ b/Assets/_MAIN/Scripts/Core/ScriptParser.cs @@ -1,16 +1,17 @@ using System.Collections.Generic; using System.Text.RegularExpressions; +using UnityEngine; public class ScriptParser { - private static readonly Regex TagRegex = new Regex(@"^\[(\w+)(?:\s+(.*))?\]$"); - private static readonly Regex AttrRegex = new Regex(@"(\w+)=(""[^""]*""|'[^']*'|[^ \t\]]+)"); - private static readonly Regex ChoiceOptionRegex = new Regex(@"^\*\s*(.+?)\s*>\s*(.+)$"); + private static readonly Regex TagRegex = new(@"^\[(\w+)(?:\s+(.*))?\]$"); + private static readonly Regex AttrRegex = new(@"(\w+)=(""[^""]*""|'[^']*'|[^ \t\]]+)"); + private static readonly Regex ChoiceOptionRegex = new(@"^\*\s*(.+?)\s*>\s*(.+)$"); public static Script Parse(string text) { List actions = new(); - Dictionary sceneMap = new(); + Dictionary labelMap = new(); ScriptAction lastChoice = null; @@ -31,16 +32,19 @@ public class ScriptParser { string tagName = tagMatch.Groups[1].Value; string attrString = tagMatch.Groups[2].Value; + Debug.Log($"ScriptParser :: Tag: {tagName} {attrString}"); var scriptAction = new ScriptAction { Type = tagName }; - ParseAttributes(attrString, scriptAction.Params); - if (tagName == "scene") + if (!attrString.Contains("=")) scriptAction.Params["content"] = attrString; + else ParseAttributes(attrString, scriptAction.Params); + + if (tagName == "label") { - string sceneName = scriptAction.GetParam("name"); - if (!string.IsNullOrEmpty(sceneName) && !sceneMap.ContainsKey(sceneName)) + string label = scriptAction.GetParam("content"); + if (!string.IsNullOrEmpty(label) && !labelMap.ContainsKey(label)) { - sceneMap[sceneName] = actions.Count; + labelMap[label] = actions.Count; } } @@ -71,7 +75,7 @@ public class ScriptParser actions.Add(new ScriptAction { Type = "msg", Params = { { "content", line } } }); } - return new Script(actions, sceneMap); + return new Script(actions, labelMap); } private static void ParseAttributes(string attrString, Dictionary paramDict) @@ -85,7 +89,7 @@ public class ScriptParser string rawValue = m.Groups[2].Value; if (rawValue.Length >= 2 && (rawValue.StartsWith("\"") || rawValue.StartsWith("'"))) - rawValue = rawValue.Substring(1, rawValue.Length - 2); + rawValue = rawValue[1..^1]; paramDict[key] = rawValue; } diff --git a/Assets/_MAIN/Scripts/Core/VisualNovelLayoutDirector.cs b/Assets/_MAIN/Scripts/Core/VisualNovelLayoutDirector.cs index 34d263f..5231b51 100644 --- a/Assets/_MAIN/Scripts/Core/VisualNovelLayoutDirector.cs +++ b/Assets/_MAIN/Scripts/Core/VisualNovelLayoutDirector.cs @@ -1,13 +1,14 @@ +using System.Collections; +using System.Collections.Generic; +using PrimeTween; using UnityEngine; using UnityEngine.UI; -using PrimeTween; -using System.Collections; public class VisualNovelLayoutDirector : MonoBehaviour { // ========================= [Enums] ========================= public enum EntranceType { Left, Right, BottomLeft, BottomRight, Center, Top } - public enum ActionType { Jump, Shake, Nod, Punch } + public enum ActionType { Jump, Shake, Nod, Punch, ShakeHorizontal } [Header("UI 연결")] public Transform characterPanel; @@ -18,6 +19,8 @@ public class VisualNovelLayoutDirector : MonoBehaviour public float defaultDuration = 0.5f; public float moveDistance = 800f; + + // ========================= [1. 등장 (Entry)] ========================= public void AddCharacter(string fileName, EntranceType type) { @@ -50,7 +53,24 @@ public class VisualNovelLayoutDirector : MonoBehaviour newSlot.name = name; LayoutElement layoutElement = newSlot.GetComponent(); - Image charImage = newSlot.transform.GetChild(0).GetComponent(); + + // [변경] MotionContainer 생성 및 계층 구조 변경 + // 기존: Slot -> Image + // 변경: Slot -> MotionContainer -> Image + GameObject motionContainer = new("MotionContainer"); + RectTransform containerRect = motionContainer.AddComponent(); + motionContainer.transform.SetParent(newSlot.transform, false); + + // Container 설정 (부모 꽉 채우기) + containerRect.anchorMin = Vector2.zero; + containerRect.anchorMax = Vector2.one; + containerRect.sizeDelta = Vector2.zero; + + // 기존 Image를 Container 자식으로 이동 + Transform imageTransform = newSlot.transform.GetChild(0); // Prefab의 첫 번째 자식이 Image라고 가정 + imageTransform.SetParent(motionContainer.transform, false); + + Image charImage = imageTransform.GetComponent(); // 2. 초기화 charImage.sprite = sprite; @@ -73,14 +93,15 @@ public class VisualNovelLayoutDirector : MonoBehaviour } // 4. 위치 잡기 및 애니메이션 + // [변경] 움직임은 MotionContainer가 담당 Vector2 startPos = GetDirectionVector(type); - charImage.rectTransform.anchoredPosition = startPos; + containerRect.anchoredPosition = startPos; // Image -> Container charImage.color = new Color(1, 1, 1, 0); yield return new WaitForEndOfFrame(); Tween.Custom(layoutElement, 0f, charWidth, defaultDuration, (t, x) => t.preferredWidth = x, Ease.OutQuart); - Tween.UIAnchoredPosition(charImage.rectTransform, Vector2.zero, defaultDuration, Ease.OutQuart); + Tween.UIAnchoredPosition(containerRect, Vector2.zero, defaultDuration, Ease.OutQuart); // Image -> Container Tween.Alpha(charImage, 1f, defaultDuration); } @@ -102,14 +123,20 @@ public class VisualNovelLayoutDirector : MonoBehaviour private IEnumerator ExitRoutine(Transform slotTransform, EntranceType exitTo) { // 중복 호출 방지를 위해 이름을 바꿔둠 (빠르게 연타했을 때 에러 방지) - slotTransform.name = slotTransform.name + "_Removing"; + slotTransform.name += "_Removing"; LayoutElement layoutElement = slotTransform.GetComponent(); - Image charImage = slotTransform.GetChild(0).GetComponent(); + + // [변경] 계층 구조 반영 + Transform container = slotTransform.GetChild(0); // MotionContainer + RectTransform containerRect = container.GetComponent(); + Image charImage = container.GetChild(0).GetComponent(); // Image + Vector2 targetPos = GetDirectionVector(exitTo); // 이미지 날리기 & 투명화 - Tween.UIAnchoredPosition(charImage.rectTransform, targetPos, defaultDuration, Ease.OutQuart); + // [변경] 움직임은 Container, 투명도는 Image + Tween.UIAnchoredPosition(containerRect, targetPos, defaultDuration, Ease.OutQuart); Tween.Alpha(charImage, 0f, defaultDuration * 0.8f); // 공간 닫기 @@ -121,16 +148,23 @@ public class VisualNovelLayoutDirector : MonoBehaviour // ========================= [3. 액션 (Action)] ========================= public void PlayAction(string characterName, ActionType action) + { + StartCoroutine(PlayActionRoutine(characterName, action)); + } + + private IEnumerator PlayActionRoutine(string characterName, ActionType action) { Transform targetSlot = FindSlot(characterName); if (targetSlot == null) { Debug.LogWarning($"액션 실패: '{characterName}' 캐릭터를 찾을 수 없습니다."); - return; + yield break; } - RectTransform targetImageRect = targetSlot.GetChild(0).GetComponent(); + // [변경] 계층 구조 반영: Slot -> Container -> Image + // 액션은 Image에만 적용 (Container는 이동 담당) + RectTransform targetImageRect = targetSlot.GetChild(0).GetChild(0).GetComponent(); // 기존 애니메이션 정지 및 초기화 Tween.StopAll(targetImageRect); @@ -148,6 +182,11 @@ public class VisualNovelLayoutDirector : MonoBehaviour Tween.ShakeLocalPosition(targetImageRect, new Vector3(50f, 0, 0), 0.5f, frequency: 10); break; + case ActionType.ShakeHorizontal: + // 상하 흔들기 (진동 횟수 10번) + Tween.PunchLocalPosition(targetImageRect, new Vector3(0, 50f, 0), 0.5f, frequency: 10); + break; + case ActionType.Nod: // (Sequence는 변경 없음) Sequence.Create() @@ -162,6 +201,84 @@ public class VisualNovelLayoutDirector : MonoBehaviour } } + // ========================= [4. 표정 변경 (Change Expression)] ========================= + public void ChangeExpression(string characterName, string spriteName) + { + Transform targetSlot = FindSlot(characterName); + if (targetSlot == null) return; + + // [변경] 계층 구조 반영 + Image charImage = targetSlot.GetChild(0).GetChild(0).GetComponent(); + Sprite newSprite = Resources.Load("Images/Characters/" + spriteName); + + if (newSprite != null) + { + // [수정] 기존 이미지를 복제하여 오버레이 생성 + // Instantiate는 원본의 위치, 회전, 크기(Scale)를 그대로 복사하므로 + // 별도로 위치나 스케일을 0/1로 초기화하면 안 됨 (좌우 반전된 캐릭터 등이 원상복구 되어버릴 수 있음) + // 1. 마스크 컨테이너 생성 (Softness 효과를 위해) + GameObject maskObj = new("MaskContainer"); + maskObj.transform.SetParent(charImage.transform, false); // [변경] 부모를 이미지로 설정하여 액션(Scale/Move) 동기화 + + RectTransform maskRect = maskObj.AddComponent(); + maskRect.anchorMin = new Vector2(0.5f, 1f); // Top Center + maskRect.anchorMax = new Vector2(0.5f, 1f); + maskRect.pivot = new Vector2(0.5f, 1f); + + // 마스크 영역을 위로 올려서 상단 Softness가 이미지에 영향을 주지 않도록 함 + float softnessOffset = 100f; + float currentWidth = charImage.rectTransform.rect.width; // [변경] 실제 이미지 너비 사용 + maskRect.anchoredPosition = new Vector2(0, softnessOffset); + maskRect.sizeDelta = new Vector2(currentWidth, 0); // 너비는 캐릭터 폭, 높이는 0부터 시작 + + RectMask2D rectMask = maskObj.AddComponent(); + rectMask.softness = new Vector2Int(0, (int)softnessOffset); // 세로 방향 Softness 설정 + + // 2. 오버레이 이미지 생성 및 설정 + // [변경] Instantiate 대신 직접 생성 (이미지에 자식이 있을 경우 복제 방지) + GameObject overlayObj = new("ExpressionOverlay"); + overlayObj.transform.SetParent(maskObj.transform, false); + + Image overlayImage = overlayObj.AddComponent(); + 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); // 원위치 유지 + + overlayImage.SetNativeSize(); + + // 렌더링 순서 보장 (마스크 컨테이너를 가장 앞으로) + maskObj.transform.SetAsLastSibling(); + + // 3. 애니메이션 실행 (마스크 높이를 키워서 이미지를 드러냄) + // 목표 높이: 캐릭터 이미지 높이 + 오프셋 + float targetHeight = overlayRect.sizeDelta.y + softnessOffset; + + Tween.UISizeDelta(maskRect, new Vector2(currentWidth, targetHeight), 0.5f, Ease.OutQuart) + .OnComplete(() => + { + // 원본 교체 및 정리 + charImage.sprite = newSprite; + charImage.SetNativeSize(); + + Destroy(maskObj); // 마스크 컨테이너 삭제 (자식인 오버레이도 같이 삭제됨) + }); + } + else + { + Debug.LogError($"표정 스프라이트를 찾을 수 없습니다: {spriteName}"); + } + } + // [Helper] 이름으로 슬롯 찾기 private Transform FindSlot(string name) { @@ -171,20 +288,13 @@ public class VisualNovelLayoutDirector : MonoBehaviour private Vector2 GetDirectionVector(EntranceType type) { - switch (type) + return type switch { - case EntranceType.Left: - return new Vector2(-moveDistance, 0); - case EntranceType.Right: - return new Vector2(moveDistance, 0); - case EntranceType.Center: - case EntranceType.BottomLeft: - case EntranceType.BottomRight: - return new Vector2(0, -moveDistance); - case EntranceType.Top: - return new Vector2(0, moveDistance); - default: - return Vector2.zero; - } + EntranceType.Left => new Vector2(-moveDistance, 0), + EntranceType.Right => new Vector2(moveDistance, 0), + EntranceType.Center or EntranceType.BottomLeft or EntranceType.BottomRight => new Vector2(0, -moveDistance), + EntranceType.Top => new Vector2(0, moveDistance), + _ => Vector2.zero, + }; } } \ No newline at end of file