주제 : 2D 액션 플랫포머 / 메트로배니아 / 소울라이크 게임
개발 기간 : 2025.06.21. ~ 2025.08.14. (약 8주)
개발 인원 : 기획자(2명), Unity(3명)
사용 기술 : FSM(Interface), ScriptableObject, Delegate
Github : https://github.com/leejy1685/LastSanctuary_Code
SA : LastSanctuary Draw.io
| LastSanctuary |
|---|
 |
주제 선택 사유
- 최종 프로젝트는 시간이 넉넉한 만큼 도전적인 프로젝트를 하고 싶었음.
- 그러던 중 2D 액션 플랫포머 장르를 기획한 기획자에게 제안이 와서 프로젝트를 진행.
- 참고 게임 : Blasphemous, Hollow Knight
기술 스택
FSM(Interface), ScriptableObject, Delegate
기술적 의사결정
FSM(Interface)
| Player 구조도 |
|---|
 |
- 사용 이유 : 객체 지향 프로그래밍의 원칙을 잘 따르고, 유연하고 확장 가능한 시스템을 구축할 수 있기 때문
-
느슨한 결합
- 인터페이스는 특정 클래스가 아닌 '기능'에 초점
- 코드 수정 시 다른 부분에 미치는 영향을 최소화
-
확장성 및 재사용성
- 새로운 상태를 추가하거나 기존 상태를 수정하기가 매우 용이
- 다른 캐릭터나 심지어 적 캐릭터의 상태 관리에도 동일한 인터페이스를 재사용할 수 있게 하여 코드의 재사용성이 높음.
-
단일 책임 원칙
- 각 상태 클래스는 '해당 상태에서의 로직'이라는 단일 책임
- 코드의 가독성을 높이고 유지보수를 용이
-
구현 예시
-
트러블 슈팅
ScriptableObject
| 구조도 |
|---|
 |
- 사용 이유 : 게임의 데이터와 로직을 분리하여 효율적인 개발 환경을 구축하기 위함
-
데이터와 로직의 분리
- MonoBehaviour 컴포넌트는 데이터(변수)와 로직(메서드)이 함께 존재
- ScriptableObject는 데이터만 저장하는 컨테이너 역할
-
메모리 효율성
- ScriptableObject는 프로젝트 내에 한 번만 로드되고, 모든 게임 오브젝트가 그 데이터를 참조해서 사용
- ScriptableObject를 사용하면 모든 몬스터 A가 동일한 ScriptableObject를 참조하므로 메모리 사용량이 획기적으로 줄어듭니다.
-
재사용성 및 유연성
- 하나의 ScriptableObject 에셋을 여러 곳에서 재사용
-
쉬운 유지보수와 워크플로우 개선
- 에디터 내에서 에셋 파일로 존재하기 때문에, 직관적으로 데이터를 생성하고 관리
- 개발 시간을 단축하고, 팀 협업을 용이
-
예시 코드
- 코드 전체
| PlayerSO |
|---|
 |
 |
Delegate
- 사용 이유 : 객체 간의 결합도를 낮추고 유연성과 확장성을 확보하기 위함
-
느슨한 결합
- 이벤트를 발생시키는 객체와 이벤트에 반응하는 객체는 서로 독립적
-
확장성 및 재사용성
- 새로운 기능을 추가하거나 기존 기능을 변경할 때 매우 유연하게 대처
- 개발 속도를 높이고 재사용성을 극대화
-
단일 책임 원칙
- 델리게이트를 사용하면 각 클래스의 역할이 명확
- 역할 분리는 코드를 더 간결하고 이해하기 쉽게 만들어 유지보수성을 크게 향상
-
구현 예시
-
트러블 슈팅
배운 점
Interface방식 FSM
코드 리펙토링 : 플레이어 공격 개선
- 개선 이유
- 어차피 공격의 방식은 Weapon에 대미지 할당 방식인데, 자식 스크립트에서 전부 할당중.
- 점프 어택은 콤보 어택1을 활용 중이나 인풋 방식 때문에 따로 구현되어 있음.
- 콤보 어택은 이어지는 공격을 계속해서 다음 정보를 가져오는 방식임.
- 기대 효과 : 코드 압축 및 가독성 증가
- 개선 방안
- 점프 어택, 대쉬 어택 같은 후속타가 없는 것은 PlayerAttackState 스크립트 하나로 AttackInfo를 할당해서 변수 이름만 다르게 하여 구성, 후속타가 있는 ComboAttackState는 리스트로 구현하여 연결.
- 예시 코드
- 연속 공격 처럼 특수한 경우를 제외하고 PlayerAttackState 하나로 구현 가능하게 변경. => 확장성 증가
트러블 슈팅 : 자연스러운 몬스터 패턴 구현
-
개선 이유
- 몬스터가 공격 애니메이션 중에 움직여서 부자연스러움.
- 공격 범위와 공격 시작 범위에 대한 인식 문제.
-
개선 방안
- 몬스터의 애니메이션이 종료 후 모든 행동을 시작하는 방식으로 코드 변경.
- 공격 사정거리와 추적 사정거리로 분류하여 코드를 작성.
-
예시 코드
- 몬스터의 애니메이션이 종료 후 모든 행동을 시작하는 방식으로 변경.
| 이전 코드 | 개선된 코드 |
|---|
 |  |
| 개선된 코드 |
|---|
 |
Interface방식 FSM 구조에서 코루틴 사용법
-
실패한 경험
- Interface방식 FSM 구조 클래스 안에서 코루틴을 사용하고 싶음.
- Interface인 Istate를 할당방은 BaseState에 MonoBehavior를 상속.
- 오브젝트에 할당되지 않은 MonoBehavior 클래스에서 코루틴을 실행.
- 오류 발생.
-
성공한 경험
- 이후 프로젝트를 진행하던 중 실행 방법을 발견.
- 인터페이스만 상속 받은 클래스에서 코루틴을 정의.
- 코드를 실행 시, MonoBehavior를 상속 받은 클래스에 실행을 할당.
- ex) (MonoBehavior를 상속 받고 오브젝트에 할당된 클래스).StartCoroutine(정의한 코루틴);
| 성공한 경험 |
|---|
 |
 |
아이템 슬롯 UI
-
구현 이유
- 하나의 UI에 좌/우클릭 기능을 나눠서 구현 필요.
- 좌클릭 : 아이템 정보 확인
- 우클릭 : 아이템 장착/해제
-
구현 방법
- 슬롯 UI에 IPointerClickHandler를 상속.
- 좌/우 클릭 시 작동하는 Delegate 선언.
- 상위 UI에 기능을 정의 후 Delegate에 할당.
-
구현 예시 : 다양한 클릭 처리
| 기능 실행(SlotUI.cs) | 기능 할당(RelicUI.cs) |
|---|
 |  |
-
트러블 슈팅 : Delegate 사용시 유의점
- 오류 발생 : 장비가 장착되지 않는 오류가 발생.
- 원인 분석 : UI에 들어갈 때 마다 이벤트가 계속해서 등록됨.
- 해결 방안 1 : UnifiedUI에서 3번 연속 등록된 콜백 함수를 들어갈 때 넣어주고 나갈 때 초기화 해주기.
| 이전 코드 : 생성자에서 등록(상속 받은 상태머신 마다 실행 됨) |
|---|
 |
| 개선된 코드 : Enter메서드에서 등록(상태머신에 들어갈 때 등록) |
|---|
 |
- 해결 방안 2 : RelicUI에 들어갈 때 델리게이트에 등록하고 나갈 때 델리게이트 초기화 해주기.
| RelicUI Enter메서드에서 실행 |
|---|
 |
| RelicUI Exit메서드에서 실행 |
|---|
 |
MuffleSound 구현
MuffleSound 구현
-
구현 이유
- 플레이어가 피격 시 사운드가 잠시 먹먹해지는 효과가 필요.
-
구현 방법
- 이미 사용 중이던 Audio Mixer의 기능을 활용.
- Snapshot에 Low Pass Filter를 적용한 사운드를 저장.
- MuffleSound가 필요할 때 해당 Snapshot을 호출하는 방식으로 MuffleSound를 구현.
-
구현 예시
| SnapShot Normal 설정 |
|---|
 |
| SnapShot muffle 설정 |
|---|
 |
| SoundManager.cs |
|---|
 |
 |
- string 값으로 해당 SnapShout을 가져와서 저장.
- 메서드를 구현하여 필요할 때 마다 호출.
구조체로 필요한 데이터 묶기(코드 리펙토링)
공격에 필요한 매개변수 구조체로 묶어보기
-
구현 이유
- 공격 시 필요한 정보가 늘어날 때 마다 수정해야 할 코드가 너무 많아 필요한 데이터를 구조체로 묶기로 하였다.
- 기대 효과 : 적은 노력으로 기능 확장 가능
-
구현 방법
- 공격 시 필요한 정보를 구조체 생성(공격력, 넉백 파워, 그로기 대미지 등)
- 공격 시 사용되는 다양한 인터페이스(대미지 전달, 넉백, 그로기 대미지 등)의 매개변수를 구조체로 수정.
-
구현 예시
| 이전 코드 | 개선한 코드 |
|---|
 |  |
- 전부 다 공격에서 사용되는 함수들이지만, 중첩되는 데이터도 있고 아닌 데이터도 있다.
- 그러한 데이터들을 전부 WeaponInpo 구조체에 묶었다.
| WeaponInfo 구조체 |
|---|
 |
partial Class 사용(코드 리펙토링)
partial class 사용해보기
-
구현 이유
- PlayerCondition에 기능이 너무 많이 할당되어 있어 가독성이 나쁨.
- 플레이어의 스탯 정보와 전투에 필요한 메서드를 나눠서 작성.
- 기대 효과 : 가독성 증가
-
구현 방법
- PlayerCondition을 partial Class로 변경.
- 스탯 정보와 관련된 변수, 프로퍼티, 메서드는 PlayerCondition에 작성.
- 전투 정보와 관련된 메서드는 PlayerConditionBattle에 작성.
- Player의 오브젝트에는 PlayerCondition 컴포넌트만 넣어도 클래스 둘 다 실행.
-
구현 예시
| 이전 코드 |
|---|
 |
 |
- 가독성을 위하여 region으로 묶긴하였지만, 코드가 너무 긺(285줄)
| 개선된 코드 |
|---|
 |
 |
- 플레이어의 상태와 전투에 필요한 상태 메서드를 나눠서 작성하였다.
| 추가 개선 |
|---|
 |
- 프로퍼티의 장점을 살려서 최소 값과 최대 값을 넘지 못하게 요소들을 설정해주었다.
Gizmo 활용한 상호작용 오브젝트 표시
Gizmo 활용한 상호작용 오브젝트 표시
-
구현 이유
- MoveObject는 Lever를 작동시키면 맵에 변화가 생기는 오브젝트들
- 작동된 이후의 오브젝트의 모습을 눈으로 확인하기 힘들어서 배치에 어려움.
- 기대 효과 : 맵 구현 편의성 증가.
-
구현 방법
- Gizmo 코드를 추가하여 작동된 모습을 시각적으로 표현.
-
구현 예시
구현 기능
- 플레이어
- 대부분의 상태 구현
- 피격 연출
- 퍼펙트 가드 연출
- 몬스터
- 보스
- 첫 번째 보스 공격 구조 구현
- 첫 번째 보스 등장 연출 구현
- 두 번째 보스 모든 상태 및 연출 구현
- UI
- 전투 구조 구현
- Weapon 상속 구조로 대미지 전달
- 추상 클래스와 인터페이스 활용한 다양한 Conditon 구조
- 아이템
- 사운드
시연 영상
플레이 해보기
다운로드 링크