2D 탑다운 로그라이크 게임

Unity 2D 기반. 모듈형 RPG 프레임워크 설계, 커스텀 에디터 툴, A* 길찾기 구현.

2024.10 ~ 2024.11 (10일)|개인 프로젝트 (1인 설계 / 구현)
UnityC#EditorWindowA* Pathfinding
1 / 4

개요

2D 탑다운 로그라이크 게임

Unity 2D 기반 로그라이크 RPG. 플레이어가 스킬과 아이템을 조합해 다수의 몬스터와 전투하는 게임.

  • 개발 형태: 개인 프로젝트 (1인 설계 / 구현)
  • 기간: 2024.10.29 ~ 2024.11.07 (10일)

동기

Unity 2D 기반의 탑다운 로그라이크 RPG를 만들고 싶었다. 플레이어가 스킬과 아이템을 조합해 다수의 몬스터와 싸우는 게임이고, 에디터에서 데이터를 관리하고 런타임에서 그대로 소비하는 일관된 파이프라인을 직접 설계해보는 것이 목표였다.

다양한 RPG 콘텐츠를 구현하면서 반복적으로 부딪히는 문제가 있었다. 스킬, 아이템, 몬스터 데이터를 각각 별도 스크립트로 관리하다 보니 데이터가 늘어날수록 수정 비용이 급격히 올라갔고, 코드를 직접 건드리지 않으면 밸런싱조차 할 수 없는 구조였다. Unity 에디터 내에서 데이터를 시각적으로 관리하고, 런타임 시스템이 그 데이터를 그대로 소비하는 일관된 파이프라인이 필요했다.

접근

에디터와 런타임을 관통하는 데이터 구조를 먼저 설계했다. [Serializable] 클래스로 정의한 데이터를 EditorWindow에서 편집하고, JSON/CSV로 직렬화해서 런타임에서 역직렬화하는 구조다. 스킬, 아이템, 드롭테이블 각각에 커스텀 에디터 윈도우를 만들어 데이터 생성/수정/검증을 Unity 안에서 완결했다.

런타임 시스템은 모듈 단위로 분리했다. 스킬은 ISkillStat 인터페이스 기반의 타입별 캐스팅 구조, 아이템은 상속 + 인터페이스 혼합 설계, 스탯은 StatModifier 패턴으로 장비/버프/디버프의 동적 적용을 처리했다.

몬스터 길찾기는 A* 알고리즘을 직접 구현했고, Linecast 기반 경로 단순화로 자연스러운 이동을 만들었다.

게임 플레이
게임 플레이

핵심 구현

영역내용
데이터 관리EditorWindow 기반 커스텀 에디터, JSON/CSV 직렬화, 유틸리티 클래스
RPG 시스템스킬(ISkillStat 인터페이스), 아이템(상속+인터페이스), 스탯(StatModifier), 인벤토리(MVC)
몬스터 AI타일맵 기반 그리드 노드화, A* 경로 탐색, Linecast 경로 단순화

회고

10일이라는 짧은 기간에 에디터 프레임워크부터 런타임 시스템, 길찾기까지 수직으로 관통하는 구현을 했다. 덕분에 에디터-런타임 데이터 파이프라인의 전체 흐름을 직접 설계하고 검증할 수 있었다.

특히 에디터에서 데이터를 시각적으로 관리하면서 런타임에서 그대로 소비하는 구조가 의도한 대로 잘 맞아떨어지는 것을 보면서, 일관된 데이터 모델이 개발 효율과 유지보수성에 얼마나 큰 영향을 미치는지 체감할 수 있었다.

기술 스택

기술적용 영역
C# / Unity 2022.3런타임 시스템, 에디터 확장, 데이터 직렬화
EditorWindow / EditorGUI아이템/스킬/드롭테이블 커스텀 에디터
JSON / CSV게임 데이터 직렬화 포맷
A* Algorithm타일맵 기반 몬스터 경로 탐색 및 최적화

문서 구성

문서내용
01-데이터-관리-프레임워크EditorWindow 기반 에디터 설계, 직렬화, 데이터 무결성
02-런타임-RPG-시스템스킬/아이템/스탯/인벤토리 모듈 설계와 트러블슈팅
03-몬스터-길찾기A* 경로 탐색, 그리드 노드화, Linecast 경로 단순화
2 / 4

데이터 관리 프레임워크

Unity EditorWindow 기반 데이터 관리 프레임워크

아이템, 스킬, 드롭테이블 데이터를 Unity 에디터 내에서 생성/편집/검증하고, JSON/CSV로 직렬화하여 런타임에서 그대로 소비하는 구조.


문제

RPG 게임에서 아이템, 스킬, 몬스터 데이터가 늘어날수록 관리가 어려워졌다. 초기에는 콘텐츠마다 별도 스크립트를 작성해 데이터를 관리했지만, 데이터 포맷이 제각각이고 수정할 때마다 코드를 직접 건드려야 했다. 인스펙터만으로는 복잡한 RPG 데이터의 계층 구조를 다루기에 한계가 있었다.

접근: EditorWindow 기반 커스텀 에디터

Unity의 EditorWindow를 이용해 아이템 에디터와 스킬 에디터를 별도로 구현했다. 각 에디터 윈도우는 좌측에 데이터 리스트, 우측에 상세 편집 패널을 배치하여 한 화면에서 탐색과 편집을 동시에 처리한다.

아이템 데이터 에디터
아이템 데이터 에디터

스킬 데이터 에디터
스킬 데이터 에디터


데이터 시각화 및 편집 UI

구현

EditorGUILayout의 BeginVertical, BeginScrollView로 계층 구조를 만들고, Foldout과 HelpBox로 구역을 시각적으로 구분했다. EditorGUI.BeginDisabledGroup으로 데이터 상태에 따라 입력 가능 항목을 제한하여, 사용자가 잘못된 값을 입력하는 것을 원천 차단했다.

레벨별 스킬 스탯 설정 Foldout
레벨별 스킬 스탯 설정 Foldout

트러블슈팅: 레이아웃 에러

스크롤 영역 내에서 Begin/End 계열 메서드의 짝이 맞지 않으면 GUILayout: Mismatched LayoutGroup 에러가 발생했다. 복잡한 UI에서 분기 처리 중 End 호출이 누락되는 경우가 원인이었고, try-finally 패턴으로 EndScrollView, EndVertical 등이 항상 호출되도록 보장하여 해결했다.


UI-데이터 동기화

구현

아이템 이펙트 등 고유 데이터를 추가할 때, GenericMenu에서 이미 포함된 항목을 실시간으로 필터링해 추가 가능한 데이터만 표시했다. 중복 추가를 원천 방지하고, 데이터 변경 시 즉시 JSON/CSV로 저장하며 Repaint와 데이터 로드를 연동해 UI와 데이터를 항상 동기화했다.

GenericMenu 항목 필터링
GenericMenu 항목 필터링

트러블슈팅: 변경사항 미반영

에디터 UI에서 데이터를 수정해도 변경 사항이 즉시 반영되지 않거나, 이미지 미리보기가 갱신되지 않는 문제가 있었다. EditorApplication.delayCall을 활용해 데이터 로드와 Repaint를 다음 프레임에 실행하여, UI가 항상 최신 상태를 반영하도록 개선했다.


데이터 저장 및 유틸리티

SkillDataEditorUtility와 ItemDataEditorUtility를 static 클래스로 구현했다. 저장, 불러오기, 직렬화/역직렬화, 무결성 검증, 기본값 초기화 등 공통 로직을 이 클래스들로 일원화했다.

데이터 저장 코드
데이터 저장 코드

초기에는 데이터 처리 로직이 여러 곳에 분산되어 포맷 불일치나 참조 오류가 빈번했다. 모든 데이터 입출력과 검증을 유틸리티로 통합하고, 리소스 경로와 직렬화 방식을 통일해 데이터 손상 문제를 해결했다.


게임 데이터 구조 설계

유닛, 아이템, 스킬, 드롭테이블 등 주요 데이터는 [Serializable]로 선언하여 JSON/CSV 직렬화가 가능하도록 했다. 각 데이터는 컬렉션과 Guid로 관리하며, 스탯/효과/드롭테이블은 전용 메서드로 동적 관리가 가능한 구조다.

JSON 데이터 구조
JSON 데이터 구조

초기에는 단순 리스트로 관리하다 데이터 추가/삭제 시 참조 오류와 누락이 빈번했다. 계층적 구조와 컬렉션, 고유 ID 기반 관리로 전환하고 동적 관리 메서드를 도입해 데이터 무결성을 확보했다.

3 / 4

런타임 RPG 시스템

스킬, 아이템, 스탯, 인벤토리 모듈 설계

에디터에서 직렬화한 데이터를 런타임에서 역직렬화하여 소비하는 구조. 각 시스템은 모듈 단위로 분리되어 독립적으로 확장 가능하다.


스킬 시스템

구현

JSON에서 역직렬화한 SkillData와 CSV에서 파싱한 ISkillStat을 레벨별로 매칭하여 관리했다. 스킬 레벨 업이나 효과 적용 시 SkillData에서 현재 레벨의 ISkillStat을 가져오고, 스킬 타입(투사체, 범위, 패시브)에 따라 ProjectileSkillStat, AreaSkillStat, PassiveSkillStat으로 캐스팅하여 각 타입별 효과를 처리한다.

SkillLevelData 클래스
SkillLevelData 클래스

에디터와 런타임이 동일한 데이터 구조를 사용하기 때문에, 에디터에서 편집한 스킬 스탯이 런타임에서 그대로 반영된다. ISkillStat 인터페이스로 스킬 효과 로직을 통합하여 새로운 스킬 타입 추가 시 기존 코드 수정 없이 확장 가능한 구조를 만들었다.

런타임 스킬 인스펙터
런타임 스킬 인스펙터

트러블슈팅: 타입 캐스팅 오류

SkillLevelData에서 ISkillStat을 타입별로 캐스팅할 때 타입 불일치나 null 반환, 여러 레벨의 스탯이 혼합되는 문제가 발생했다. 타입 정보를 명확히 지정하고 레벨별로 ISkillStat을 분리 저장하며, 항상 현재 레벨의 스탯만 참조하도록 로직을 일원화해 해결했다.

회고

현재 구조는 ISkillStat을 사용할 때마다 as 연산자로 직접 캐스팅해야 해서, 런타임 오류 위험과 코드 복잡도가 높다. 제네릭 기반으로 관리했다면 컴파일 타임에 타입이 보장되어 더 안전하게 코드를 작성할 수 있었을 것이다.


아이템 시스템

구현

에디터에서 JSON으로 저장한 아이템 데이터를 런타임에서 역직렬화하여 관리했다. 각 아이템은 고유 식별자, 타입, 능력치, 효과를 포함하며 플레이어 인벤토리에는 List<Item> 형태로 참조를 저장한다.

ItemData 클래스
ItemData 클래스

핵심 데이터 구조는 구체 클래스와 상속 위주로 설계하되, 효과 적용과 상호작용에는 ISkillInteractionEffect 등 인터페이스를 도입했다. 새로운 효과나 상호작용 로직을 기존 코드 수정 없이 확장할 수 있는 구조를 의도했다.

트러블슈팅: 효과 중복 적용

아이템 효과가 중복 적용되거나, 해제 시 정상적으로 제거되지 않는 문제가 있었다. 효과 적용/제거 로직을 명확히 분리하고, 데이터 구조는 상속으로 효과와 상호작용은 인터페이스로 분리하여 관리 일관성을 확보했다.


드롭테이블 시스템

JSON으로 저장한 드롭테이블 데이터를 런타임에서 Dictionary<MonsterType, DropTableData>로 관리했다. 몬스터 타입별로 드롭 아이템, 확률, 수량, 희귀도 정보를 DropTableEntry 리스트로 구성하고, 몬스터 처치 시 확률에 따라 아이템을 동적으로 생성한다.

드롭 확률 오적용과 수량 계산 오류가 발생해, DropTableEntry의 확률/수량 필드와 드롭 로직을 일관되게 검증하고 역직렬화 시 데이터 누락/타입 불일치 방지를 위해 파싱 로직을 강화했다.


스탯 시스템

구현

각 유닛의 스탯을 Dictionary<StatType, float>로 관리하며, List<StatModifier>로 장비, 버프, 디버프, 스킬 효과 등에 의한 스탯 변동을 동적으로 적용한다. StatModifier는 적용 대상 스탯, 출처(Source), 보정 방식(Flat/Multiply), 수치를 포함한다.

StatModifier 클래스
StatModifier 클래스

스탯 변경 시 RecalculateStats를 통해 모든 보정값을 Flat 먼저, Multiply 나중 순서로 합산/곱셈 처리하여 최종 스탯을 산출한다. 이 순서가 일관되어야 여러 효과가 중첩될 때 예측 가능한 결과가 나온다.

스탯 재계산 로직
스탯 재계산 로직

트러블슈팅: 보정 중복/누락

StatModifier의 비교 기준이 모호해서 보정이 중복 적용되거나 누락되는 문제가 있었고, 계산 순서도 일관되지 않았다. Source 관리 방식을 명확히 하고 Flat -> Multiply 순서로 계산 로직을 통일해 해결했다.

회고

현재 StatModifier의 Source가 object 타입이라, 조회나 일괄 해제 시 매번 타입 체크와 캐스팅이 필요하다. Source를 제네릭 타입으로 정의하면 컴파일 타임에 타입이 고정되고, 특정 Source에 대한 Modifier를 효율적으로 관리할 수 있을 것이다.


인벤토리 시스템

구현

데이터(Inventory, InventorySlot)와 UI(ItemSlot, InventoryPanel)를 분리해 설계했다. UI는 데이터를 참조해 표시하고, 입력(클릭, 드래그)을 받아 데이터에 반영한다. MVC 패턴을 유사하게 적용하여 데이터, UI, 입력 처리를 분리했다.

인벤토리 UI
인벤토리 UI

아이템 툴팁
아이템 툴팁

트러블슈팅: 드래그 앤 드롭 불일치

드래그 앤 드롭 시 슬롯 데이터와 UI가 불일치하거나, 장착/해제 후 UI가 즉시 갱신되지 않았다. 드래그 프리뷰 오브젝트가 raycast를 막아 드래그 로직이 의도대로 동작하지 않는 문제도 있었다. 모든 슬롯 상호작용 후 UI를 즉시 갱신하고, 프리뷰 오브젝트의 raycast target을 비활성화하여 해결했다.

4 / 4

몬스터 길찾기

A* 기반 경로 탐색 및 Linecast 경로 단순화

타일맵 데이터를 기반으로 그리드 노드를 생성하고, A* 알고리즘으로 최적 경로를 탐색한 뒤, Linecast로 불필요한 노드를 제거하여 자연스러운 이동 경로를 만드는 시스템.


몬스터 시스템

몬스터는 초기화 시 ScriptableObject로 전달받은 데이터를 기반으로 동작한다. 공격/피격 시 스탯을 반영하고, 슬로우, 스턴, 도트 데미지, 방어력 디버프 등 상태이상 효과는 각각 코루틴으로 독립 관리한다. 중복 적용 시 기존 효과를 종료하고 새 효과를 비동기로 시작한다.

몬스터 데이터
몬스터 데이터

트러블슈팅: 상태이상 코루틴 잔존

상태이상 코루틴이 중첩되거나 몬스터가 비활성화/사망할 때 코루틴이 정상 종료되지 않아 스탯이 비정상적으로 남는 문제가 있었다. 예를 들어 슬로우 상태에서 몬스터가 사망하면, 이동속도 감소 StatModifier가 해제되지 않은 채 오브젝트 풀로 반환되어 다음 스폰 시 느린 상태로 등장했다.

OnDisable에서 모든 활성 코루틴을 명시적으로 중지하고, 적용 중인 StatModifier를 전부 제거하도록 처리하여 해결했다.


그리드 노드화

문제

몬스터가 플레이어를 추적하려면 맵의 장애물을 인식하고 우회할 수 있어야 한다. 타일맵 데이터를 기반으로 장애물과 이동 가능 영역을 자동 인식하고, 각 셀을 노드로 관리하는 시스템을 구현했다. 각 노드는 월드 좌표, 그리드 좌표, 장애물 여부 정보를 포함한다.

트러블슈팅: 동적 생성의 함정

초기에는 성능을 위해 카메라 뷰포트 내에서만 노드를 동적으로 생성했다. 그러나 이 방식은 매 프레임 노드 생성/파괴 오버헤드가 오히려 커졌고, 경로 탐색 중 뷰포트 밖의 노드가 누락되어 경로가 끊기는 문제가 발생했다.

동적 그리드 생성 시 부정확한 그리드
동적 그리드 생성 시 부정확한 그리드

맵 전체를 초기화 시 한 번에 노드화하고, 노드 중심 좌표를 기준으로 경로를 계산하는 방식으로 전환했다. 초기 비용은 늘었지만 탐색 안정성과 일관성이 확보됐다.


A* 경로 탐색

A* 경로 탐색 기즈모
A* 경로 탐색 기즈모

구현

A* 알고리즘으로 시작점과 목표점 사이의 최적 경로를 탐색한다. openSet은 f값이 가장 낮은 노드를 반복적으로 선택해야 하므로 List로, closedSet은 탐색 여부를 빠르게 확인하기 위해 HashSet으로 구현했다.

각 노드는 다음 데이터를 갖는다:

필드설명
walkable이동 가능 여부
worldPosition월드 좌표
gridX, gridY그리드 좌표
gCost시작점에서 현재 노드까지의 실제 누적 비용
hCost현재 노드에서 목표점까지의 예상 비용 (휴리스틱)
fCostgCost + hCost, 경로의 총 예상 비용
previousNode경로 역추적용 이전 노드

휴리스틱은 맨해튼 거리에 대각선 가중치를 적용하여 목표까지의 예상 비용을 계산했다.

알고리즘 순서

1. 경로 탐색용 노드 및 자료구조 초기화

시작 노드와 목표 노드를 월드 좌표에서 그리드 좌표로 변환하고, openSet에 시작 노드를 삽입하여 탐색을 준비한다.

Start Node, Target Node 설정 및 초기화
Start Node, Target Node 설정 및 초기화

경로 탐색용 노드 자료구조 초기화
경로 탐색용 노드 자료구조 초기화

경로 탐색용 노드 자료구조 초기화
경로 탐색용 노드 자료구조 초기화

2. f값이 가장 낮은 노드 선택 및 확장

openSet에서 fCost가 가장 낮은 노드를 꺼내고, 해당 노드의 이웃 8방향을 탐색하여 각각의 gCost, hCost를 갱신한다. 이미 closedSet에 있거나 walkable이 아닌 노드는 건너뛴다.

f값이 가장 낮은 노드 선택 및 확장
f값이 가장 낮은 노드 선택 및 확장

3. 노드 확정 및 반복 탐색

선택된 노드를 closedSet으로 이동하고, 이웃 노드 중 더 짧은 경로가 발견되면 previousNode를 갱신한다. 목표 노드가 closedSet에 들어올 때까지 이 과정을 반복한다.

노드 확정 및 반복 탐색
노드 확정 및 반복 탐색

노드 확정 및 반복 탐색
노드 확정 및 반복 탐색
노드 확정 및 반복 탐색
노드 확정 및 반복 탐색

4. 단순화 전 경로 산출

목표 노드에 도달하면 previousNode를 역추적하여 시작점까지의 전체 경로를 구성한다. 이 시점의 경로는 그리드 단위로 구불구불한 상태이며, 다음 단계에서 단순화한다.

단순화 전 경로 산출
단순화 전 경로 산출


Linecast 경로 단순화

문제

A*가 반환하는 경로는 그리드 단위로 구불구불하다. 노드 하나하나를 따라가면 몬스터의 이동이 부자연스럽고 불필요한 방향 전환이 많아진다.

접근

경로 완성 후 Physics2D.Linecast를 활용해 불필요한 중간 노드를 제거한다. 출발점에서 모든 다음 노드까지 Linecast를 쏴서, 장애물에 막히지 않는 가장 먼 노드를 직행 목표로 설정한다. 몬스터 크기를 고려한 별도의 Linecast로 clearance도 확인하여, 벽에 몸이 걸리지 않도록 했다.

단순화 절차

1. 출발점에서 순차적으로 Linecast 및 clearance 검사

출발점에서 경로상의 다음 노드들까지 순차적으로 Linecast를 수행한다. 장애물 레이어에 충돌하지 않으면서 몬스터 크기만큼의 여유 공간이 확보되는 가장 먼 노드를 찾는다.

경로 검사
경로 검사

경로 검사
경로 검사
경로 검사
경로 검사

2. 경유점 확정 및 반복

장애물에 막히는 지점 직전 노드를 경유점(waypoint)으로 확정하고, 해당 경유점을 새로운 출발점으로 삼아 다시 Linecast를 반복한다. 목표 노드에 도달할 때까지 이 과정을 이어간다.

경유점 확정 및 반복
경유점 확정 및 반복

3. 단순화된 최종 경로 반환

모든 경유점이 확정되면, 원래의 그리드 단위 경로 대신 경유점만으로 구성된 단순화 경로를 반환한다.

경로 확정 및 반환
경로 확정 및 반환

경로 확정 및 반환
경로 확정 및 반환

이 과정을 통해 그리드 단위의 지그재그 경로가 자연스러운 직선 조합으로 변환된다. 몬스터가 벽 모서리를 자연스럽게 돌아가면서도 최단 경로에 가까운 이동을 하게 된다.

2D 탑다운 로그라이크 게임 · 2024.10 ~ 2024.11 (10일)