feat: refactor character animation system with motion container

- 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)
This commit is contained in:
2025-11-29 04:15:53 +09:00
parent 4230966305
commit 7a3069fa3c
4 changed files with 222 additions and 78 deletions

View File

@@ -5,12 +5,12 @@ public class Script
{
private List<ScriptAction> _actions;
private int _currentIndex = -1;
private Dictionary<string, int> _sceneMap = new Dictionary<string, int>();
private Dictionary<string, int> _labelMap = new();
public Script(List<ScriptAction> actions, Dictionary<string, int> sceneMap)
public Script(List<ScriptAction> actions, Dictionary<string, int> 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()

View File

@@ -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<RaycastResult> results = new List<RaycastResult>();
PointerEventData eventData = new(EventSystem.current)
{
position = Input.mousePosition
};
List<RaycastResult> 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)
);

View File

@@ -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<ScriptAction> actions = new();
Dictionary<string, int> sceneMap = new();
Dictionary<string, int> 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<string, object> 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;
}

View File

@@ -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<LayoutElement>();
Image charImage = newSlot.transform.GetChild(0).GetComponent<Image>();
// [변경] MotionContainer 생성 및 계층 구조 변경
// 기존: Slot -> Image
// 변경: Slot -> MotionContainer -> Image
GameObject motionContainer = new("MotionContainer");
RectTransform containerRect = motionContainer.AddComponent<RectTransform>();
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<Image>();
// 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<LayoutElement>();
Image charImage = slotTransform.GetChild(0).GetComponent<Image>();
// [변경] 계층 구조 반영
Transform container = slotTransform.GetChild(0); // MotionContainer
RectTransform containerRect = container.GetComponent<RectTransform>();
Image charImage = container.GetChild(0).GetComponent<Image>(); // 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<RectTransform>();
// [변경] 계층 구조 반영: Slot -> Container -> Image
// 액션은 Image에만 적용 (Container는 이동 담당)
RectTransform targetImageRect = targetSlot.GetChild(0).GetChild(0).GetComponent<RectTransform>();
// 기존 애니메이션 정지 및 초기화
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<Image>();
Sprite newSprite = Resources.Load<Sprite>("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<RectTransform>();
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<RectMask2D>();
rectMask.softness = new Vector2Int(0, (int)softnessOffset); // 세로 방향 Softness 설정
// 2. 오버레이 이미지 생성 및 설정
// [변경] Instantiate 대신 직접 생성 (이미지에 자식이 있을 경우 복제 방지)
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); // 원위치 유지
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,
};
}
}