AI/Unity

AnityGravity를 이용한 SRPG만들기 6일차

blacknabis 2026. 1. 18. 23:22

[개발 일지] 6일차: 생동감 있는 전투와 개발 생산성의 조화 (with Antigravity)

*본 개발 일지는 Google의 차세대 AI 에이전트 **'Antigravity'*와 함께 작성되었습니다.


 

AI를 사용하여 캐릭터와 애니메이션 적용 영상

 

🏗️ 6일차 개발 목표

오늘은 시스템적인 기능을 넘어 게임의 **'보여지는 맛'**을 살리는 데 집중했습니다. 밋밋하게 움직이던 유닛에 숨결을 불어넣고, 이를 효율적으로 다듬기 위한 전용 도구들을 제작했습니다.

  1. 애니메이션 시스템 연동 (Visuals)
  2. 개발자를 위한 도구 구축 (Dev Tools)
  3. 연출의 MSG: 카메라 액션 (Polish)

1. 애니메이션 시스템 연동 (Visuals)

🎭 상태 머신(State Machine) 구축

단순히 이동하고 멈추는 것을 넘어, 유니티 Mecanim을 활용해 정교한 상태 전이를 구현했습니다.

  • Smart Triggers: 공격(Attack)과 마법(Cast)을 구분했습니다. 코드가 스킬 타입(Heal/Buff)을 감지하여, 마법사는 이제 지팡이로 때리는 대신 주문을 외우는 고유 모션을 취합니다.
  • Flow Control: Idle <-> Run 자동 전환 및 액션 종료 후 복귀 로직을 완성했습니다.

⚔️ 공방의 리얼리티: 반격(Counter-Attack)

가장 큰 변화는 '반격' 메커니즘의 시각화입니다.

Before: A 공격 -> B 체력 감소 (멀뚱멀뚱) -> 끝

After: A 공격 -> B 피격 -> B가 A를 향해 몸을 돌림 -> B의 반격 모션 -> A 피격

코드를 재사용 가능한 구조(PerformAttackAnimation)로 리팩토링하여, 복잡한 공방 순서를 깔끔하게 정리했습니다.


2. 개발자를 위한 도구 (Dev Tools)

게임을 만드는 과정을 '게임'처럼 쾌적하게 만들기 위해 두 가지 도구를 직접 개발했습니다.

🎬 Scene Switcher (씬 스위처)

프로젝트가 커지면서 씬 이동이 번거로워졌습니다.

  • 해결: 상단 메뉴에 전용 툴을 달아, 클릭 한 번으로 씬을 오가도록 만들었습니다.
  • 디테일: Play Mode일 때 실수로 이동하지 않도록 안전장치까지 꼼꼼히 챙겼습니다.
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.IO;

namespace SRPG.Editor
{
    public class SceneSwitcherWindow : EditorWindow
    {
        [MenuItem("Tools/Scene Switcher")]
        public static void ShowWindow()
        {
            GetWindow<SceneSwitcherWindow>("Scene Switcher");
        }

        private Vector2 scrollPos;

        private void OnGUI()
        {
            GUILayout.Label("Project Scenes", EditorStyles.boldLabel);

            scrollPos = EditorGUILayout.BeginScrollView(scrollPos);

            string[] guids = AssetDatabase.FindAssets("t:Scene");
            foreach (string guid in guids)
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                string sceneName = Path.GetFileNameWithoutExtension(path);

                // Filter out non-project scenes if necessary (e.g., packages)
                if (!path.StartsWith("Assets/")) continue;

                if (GUILayout.Button(sceneName))
                {
                    OpenScene(path);
                }
            }

            EditorGUILayout.EndScrollView();
        }

        private void OpenScene(string scenePath)
        {
            if (EditorApplication.isPlaying)
            {
                if (EditorUtility.DisplayDialog("Scene Switcher", "Play Mode를 종료하고 씬을 이동하시겠습니까?", "예", "아니오"))
                {
                    EditorApplication.isPlaying = false;
                }
                else
                {
                    return;
                }
            }

            if (EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
            {
                EditorSceneManager.OpenScene(scenePath);
            }
        }
    }
}

⏱️ Animation Tester (애니메이션 테스터)

  • 문제: "공격 모션이 칼에 닿기도 전에 데미지가 뜨네?" -> 수정 -> 빌드 -> 확인 -> 반복...
  • 해결: 전용 테스트 씬에서 슬라이더로 타격 타이밍(Delay)을 0.01초 단위로 조절하고, 버튼 하나로 데이터에 즉시 저장하는 툴을 구축했습니다. 이제 감각적인 타격감을 1분 만에 깎을 수 있습니다.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using SRPG.Character;
using SRPG.Data;

#if UNITY_EDITOR
using UnityEditor;
#endif

namespace SRPG.Tools
{
    public class AnimationTestManager : MonoBehaviour
    {
        [Header("Test Settings")]
        public List<UnitData> unitDataList;
        public GameObject targetDummyPrefab;

        [Header("Current Test")]
        public int selectedUnitIndex = 0;
        public UnitData currentUnitData;
        public Unit currentUnitInstance;
        public Unit dummyInstance;

        [Header("Timing Adjustment")]
        [Range(0f, 2.0f)]
        public float testDelay = 0.4f;

        private void Start()
        {
            if (targetDummyPrefab != null)
            {
                GameObject dummyObj = Instantiate(targetDummyPrefab, new Vector3(2, 0, 0), Quaternion.identity);
                dummyInstance = dummyObj.GetComponent<Unit>();
                if (dummyInstance == null) dummyInstance = dummyObj.AddComponent<Unit>();
                
                // Minimal setup for dummy
                dummyInstance.unitName = "Dummy";
                dummyInstance.stats = new UnitStats();
                dummyInstance.stats.maxHP = 9999;
                dummyInstance.stats.currentHP = 9999;
            }
            
            SpawnSelectedUnit();
        }

        public void SpawnSelectedUnit()
        {
            if (currentUnitInstance != null)
            {
                #if UNITY_EDITOR
                if (Selection.activeGameObject == currentUnitInstance.gameObject)
                {
                    Selection.activeGameObject = null;
                }
                #endif
                Destroy(currentUnitInstance.gameObject);
            }

            if (unitDataList != null && unitDataList.Count > 0)
            {
                selectedUnitIndex = Mathf.Clamp(selectedUnitIndex, 0, unitDataList.Count - 1);
                currentUnitData = unitDataList[selectedUnitIndex];
                testDelay = currentUnitData.attackHitDelay;

                if (currentUnitData.unitPrefab != null)
                {
                    GameObject unitObj = Instantiate(currentUnitData.unitPrefab, Vector3.zero, Quaternion.identity);
                    currentUnitInstance = unitObj.GetComponent<Unit>();
                    if (currentUnitInstance != null)
                    {
                        currentUnitInstance.Setup(currentUnitData, Vector2Int.zero);
                    }
                }
            }
        }

        public void TestAttack()
        {
            if (currentUnitInstance == null || dummyInstance == null) return;
            
            currentUnitInstance.transform.position = Vector3.zero;
            currentUnitInstance.StartCoroutine(PerformTestAttack());
        }

        private IEnumerator PerformTestAttack()
        {
            // Apply temp delay for testing logic (Unit.cs logic might need slight override or just set data)
            // But to test "real" logic, we should probably modify the SO in memory or existing Unit instance.
            
            // For true WYSIWYG, let's update the data in memory
            if (currentUnitData != null)
            {
                currentUnitData.attackHitDelay = testDelay;
                // Dirty the Unit to ensure it uses fresh data if it cached anything (Unit.cs reads from originData directly currently)
            }

            yield return currentUnitInstance.StartCoroutine("PerformAttackVisuals", dummyInstance);
        }

        public void SaveDelayToData()
        {
            #if UNITY_EDITOR
            if (currentUnitData != null)
            {
                currentUnitData.attackHitDelay = testDelay;
                EditorUtility.SetDirty(currentUnitData);
                AssetDatabase.SaveAssets();
                Debug.Log($"Saved delay {testDelay} to {currentUnitData.name}");
            }
            #endif
        }
        
        private void OnGUI()
        {
            GUILayout.BeginArea(new Rect(10, 10, 300, 400));
            
            GUILayout.Label("Animation Tester", GUI.skin.box);
            
            if (unitDataList != null)
            {
                string[] options = new string[unitDataList.Count];
                for (int i = 0; i < unitDataList.Count; i++) options[i] = unitDataList[i].name;
                
                int newIndex = EditorGUILayout.Popup("Select Unit", selectedUnitIndex, options);
                if (newIndex != selectedUnitIndex)
                {
                    selectedUnitIndex = newIndex;
                    SpawnSelectedUnit();
                }
            }
            
            GUILayout.Space(10);
            
            if (GUILayout.Button("Spawn / Reset"))
            {
                SpawnSelectedUnit();
            }
            
            GUILayout.Space(20);
            
            GUILayout.Label($"Hit Delay: {testDelay:F2} sec");
            testDelay = GUILayout.HorizontalSlider(testDelay, 0f, 2.0f);
            
            if (GUILayout.Button("Test Attack"))
            {
                TestAttack();
            }
            
            GUILayout.Space(10);
            
            if (GUILayout.Button("Save to Data Asset"))
            {
                SaveDelayToData();
            }

            GUILayout.EndArea();
        }
    }
}

3. 연출의 MSG: 카메라 액션 (Polish)

🎥 Action Zoom

크리티컬이나 스킬 발동 순간, 카메라가 확 줌인되는 연출을 고도화했습니다.

  • 단순한 선형 이동(Lerp)이 아니라, Animation Curve를 적용하여 "스윽- 탁!" 하고 꽂히는 쫀득한 손맛을 구현했습니다.
  • 개발자가 코드 수정 없이 인스펙터에서 줌인 강도와 시간을 자유롭게 튜닝할 수 있게 되었습니다.

📊 6일차 개발 통계

항목 내용
구현된 애니메이션 상태 6종 (Idle, Run, Attack, Cast, Hit, Die)
신규 개발 도구 2종 (SceneSwitcher, AnimationTester)
리팩토링 반격 로직 분리 및 카메라 줌 시스템 고도화
생산성 향상 애니메이션 타이밍 조절 시간 90% 단축 (체감)

📝 6일차 개발을 마치며

"퀄리티는 반복(Iteration)에서 나오고, 도구는 그 반복을 돕습니다."

오늘은 눈에 보이는 화려함 뒤에, 그것을 지탱하는 **'개발 환경'**을 개선하는 데 많은 시간을 썼습니다. Antigravity가 제안한 Animation Tester 덕분에, 앞으로 추가될 수십 종의 유닛들의 타격감을 쉽고 빠르게 잡아낼 수 있는 기반이 마련되었습니다.

다음 단계: 뼈대와 살이 붙었으니, 이제 **'화려한 옷'**을 입힐 차례입니다. 다음 시간에는 타격감을 극대화할 **'VFX(이펙트) 시스템'**을 연동하고, 실제 전투의 박진감을 높여보겠습니다.

"도구가 편해지면, 개발자는 디테일에 집착할 여유가 생깁니다."

반응형