UnrealEngine

[Unreal,Blueprint] Arrow a row 모작 개발기

SeoHwiDo 2026. 9. 2. 12:50

Arrow a Row의 핵심 흐름을 참고하되, 게임 규칙과 플레이 흐름만 재해석해 만든 Windows용 개인 프로젝트입니다. 원작의 코드·리소스·문구는 사용하지 않았으며, Unreal Engine 5.7.4의 Blueprint만으로 이동, 타일 재활용, 적·투사체 오브젝트 풀링, 전투, 강화, HUD와 메뉴 흐름을 구성했습니다.

프로젝트 한눈에 보기

https://git.seohwido.site/shd/Runner_Demo.git

 

Runner_Demo

Config [mod]프로젝타일 속도 미적용 버그 수정 2026-09-02 10:21:37 +09:00 Content [mod]프로젝타일 속도 미적용 버그 수정 2026-09-02 10:21:37 +09:00 Docs [del]미사용 에셋 제거 2026-09-02 00:07:58 +09:00 .gitattributes Initial

git.seohwido.site

 

항목 내용
프로젝트명 Runner
장르 자동 진행형 러너·슈팅
개발 기간 2026.08.24 ~ 2026.09.02
개발 형태 개인 프로젝트
엔진 Unreal Engine 5.7.4
구현 방식 Blueprint-only
대상 플랫폼 Windows 64-bit
주요 기술 Enhanced Input, 타일·적·투사체 오브젝트 풀링, Blueprint Interface, Data Asset, UMG, Event Dispatcher, GameInstance
형상 관리 Git, Git LFS

이 프로젝트의 조작은 단순하다. 플레이어는 좌우 이동만 직접 조작하고, 전투는 일정 주기로 자동 진행된다. 플레이 도중 두 개의 강화 표지판 중 하나를 통과해 능력치를 올리고, 앞으로 이어지는 타일과 적을 상대한다.

단순한 조작과 달리 내부에서는 장시간 플레이 중 액터가 계속 생성·파괴되는 문제, 여러 종류의 투사체와 피해 처리의 결합, 타일 재사용 시 적과 강화 오브젝트 상태가 남는 문제를 해결해야 했다. 그래서 이번 프로젝트는 콘텐츠 수보다 반복 실행 가능한 구조를 만드는 데 초점을 맞췄다.

시연 영상

https://youtu.be/HeW1g0u7sFc

 

이미지 삽입 후보: 메인 메뉴, 인게임 전체 화면, 강화 표지판 선택 장면

처음 세운 목표와 범위 조정

초기 목표는 자동 전진, 자동 사격, 여러 종류의 적과 보스, 런 중 강화, 영구 성장, 상점, 저장·불러오기까지 하나의 세션으로 연결하는 것이었다. 하지만 제한된 일정 안에서 모든 콘텐츠를 얕게 구현하기보다 다음 핵심 구조가 실제로 동작하도록 범위를 줄였다.

  • 좌우 이동과 고정형 인게임 카메라
  • 재사용되는 맵 타일
  • 적 풀과 투사체 풀
  • 투사체 충돌, 진영 판별, 피해와 사망
  • 런 중 능력치 강화와 현재 능력치 재계산
  • 골드와 GameInstance 기반 메타 스탯 강화
  • 메인 메뉴, 상점, 인게임 HUD, 일시정지·사망 메뉴

초기에 검토했던 펫 구매·장착, 프로세스 재실행 후 복원되는 SaveGame, 다수의 보스와 빌드 조합은 최종 구현 범위에서 제외했다. 포트폴리오와 이 글에서도 설계 문서에만 남아 있는 기능을 완료 기능처럼 적지 않았다.

1. Blueprint-only 프로젝트 기반 구성

프로젝트에는 C++ 모듈을 추가하지 않았다. 게임 규칙을 하나의 거대한 Event Graph에 몰아넣지 않고 역할별 Blueprint로 나눴다.

BP_TileManager              타일 풀 생성·재배치
BP_LevelManager             스테이지 진행·적 배치·강화 표지판 데이터
BP_EnemySpawnManager        적 풀 생성·대여·반환
BP_ProjectileManager       투사체 풀 생성·대여·반환
BP_RunnerPlayer            플레이어 상태·자동 사격·피해·런 강화
BP_RunnerGameInstance      골드·메타 강화 데이터
BP_RunnerPlayerController  입력·HUD·일시정지

데이터는 Enum, Struct, Data Asset으로 분리했다. 예를 들어 적 정의에는 ID, 타입, 클래스, 초기 풀 크기, 등장 가능 스테이지, 가중치, 기본 체력·공격력과 공격 방식을 담았다. 투사체 발사 요청도 발사자, 진영, Transform, 피해량, 속도, 최대 이동 거리, Mesh, Scale을 하나의 구조체로 묶었다.

이렇게 역할과 데이터를 먼저 나눈 덕분에 Player와 Enemy가 같은 투사체 시스템을 사용하고, 적 종류가 늘어나더라도 Manager의 핵심 흐름을 복제하지 않을 수 있었다.

2. Enhanced Input과 카메라 분리

좌우 이동은 IA_HorizontalMove와 IMC_Gameplay로 구성했다. A/D 입력을 1차원 축으로 받아 PlayerController에서 플레이어의 오른쪽 벡터 방향으로 Add Movement Input을 전달했다.

초기에는 자동 전진과 좌우 이동을 모두 AddMovementInput으로 처리하는 방법도 검토했다. 그러나 CharacterMovement는 두 입력을 하나의 벡터로 합친 뒤 MaxWalkSpeed로 제한하기 때문에, 대각선 입력 시 전진 속도와 횡이동 속도를 완전히 독립시키기 어렵다.

프로젝트에서는 플레이어 조작과 월드 진행 책임을 분리하는 방향으로 정리했다. 플레이어는 횡이동에 집중하고, 맵 타일은 재배치를 통해 계속 이어진다. 카메라는 BP_InGameCamera Actor로 분리하고 Spring Arm을 적용해 플레이어와 맵을 안정적으로 바라보도록 했다.

일시정지는 별도의 IA_Pause 입력으로 처리한다. 메뉴가 열리면 게임을 정지하고 UI 입력 모드와 마우스 커서를 활성화하며, 복귀 시 Gameplay Mapping Context와 Game Only 입력 상태를 복원한다.

3. 끊김 없는 길을 위한 타일 오브젝트 풀링

러너 게임의 길을 매번 Spawn·Destroy하면 장시간 플레이에서 생성 비용과 가비지 컬렉션 부담이 반복된다. 그래서 시작 시 정해진 수의 BP_BaseTile을 만들고, 뒤로 지나간 타일을 가장 앞쪽으로 옮기는 구조를 사용했다.

타일 크기를 상수로 중복 입력하지 않고 첫 타일의 RecycleCheck.GetScaledBoxExtent에서 가져온 것이 핵심이다. PoolSize가 N일 때 Manager의 Box Collision 안에는 N-1개의 타일이 들어가고, 마지막 한 개는 재배치를 위한 여유 타일로 남긴다.

ContainedTileCount = PoolSize - 1
CollisionExtent.X  = TileExtent.X * ContainedTileCount
FirstTileCenter.X  = BoxCenter.X - CollisionExtent.X + TileExtent.X

첫 타일을 배치한 뒤에는 길이를 다시 계산하지 않는다. 각 타일의 NextSpawnPoint 월드 위치를 다음 타일의 기준점으로 사용해 연속 배치한다.

초기화
→ PoolSize만큼 BP_BaseTile 생성
→ 첫 타일 Bounds로 Manager Box 크기 설정
→ NextSpawnPoint를 따라 나머지 타일 연결

재사용
→ 해당 타일 소유 Enemy 반환
→ 강화 선택 상태와 표지판 초기화
→ 마지막 타일 NextSpawnPoint 앞으로 이동
→ 새 Stage·Enemy 데이터 적용

타일이 재사용될 때는 위치만 바꾸면 안 된다. 이전 타일의 적 배열, 강화 표지판 표시·Collision, 이미 선택했다는 Boolean이 남을 수 있기 때문이다. BP_BaseTile에 초기화 함수를 두고 재사용 시 관련 상태를 함께 되돌렸다.

이미지 삽입 후보: BP_TileManager 초기 풀 생성 그래프, 타일 9개 + 여유 타일 1개를 보여 주는 에디터 화면

4. 타입별 적 풀과 타일 소유권

적은 BP_EnemySpawnManager가 관리한다. DA_EnemyCatalog의 적 정의를 읽어 게임 시작 시 필요한 수만큼 미리 생성하고 비활성화한다.

풀 데이터에는 Enemy ID, 타입, 클래스, 풀 크기와 대기 중인 Enemy 배열이 들어간다. 조회 비용과 역할을 줄이기 위해 다음 인덱스를 함께 관리했다.

  • Enemy ID → 해당 풀 인덱스
  • Enemy Type → 해당 타입에 속한 풀 인덱스 목록
  • 활성 Enemy Set

핵심 API는 InitEnemyPool, AcquireEnemy, ReturnEnemy다.

AcquireEnemy
→ 요청 타입·ID의 Pool 탐색
→ Available Enemy 존재 여부 확인
→ 하나를 꺼내 Active Set에 등록
→ ActivateFromPool(Transform, OwningTile)

ReturnEnemy
→ Active Set 포함 여부 확인
→ DeactivateFromPool
→ 원래 Pool의 Available 배열에 복귀

풀이 비었을 때 런타임 Spawn으로 크기를 늘리지 않았다. 프레임 비용이 갑자기 증가하는 것보다 해당 배치나 발사를 생략하는 쪽을 선택했다. 반환할 때도 Active Set 포함 여부를 확인해 같은 Enemy가 중복 반환되는 상황을 막았다.

BP_LevelManager는 타일과 적 풀 사이를 중개한다. Early, Mid, Boss Phase와 타일 순번을 계산하고, Normal·Elite·Boss 타입에 맞는 배치점을 선택한다. 실제 최종 콘텐츠는 Normal과 Elite 중심의 최소 구성으로 제한했지만, 데이터와 Enum에는 단계 확장을 위한 경계를 남겼다.

각 BP_BaseTile은 자신에게 배치된 TileEnemys 배열을 가진다. 타일을 재사용하기 전에 이 배열을 기준으로 Enemy를 풀에 반환한다. Get All Actors Of Class로 월드 전체를 검색하지 않아도 되며, 타일 소유권이 반환 범위를 명확하게 만든다.

5. 적 활성화·비활성화의 상태 대칭

풀링 Actor는 숨기기만 해서는 충분하지 않다. Collision, Tick, 이동, 공격 Timer가 남으면 화면에 보이지 않는 Enemy가 피해를 주거나 연산을 계속할 수 있다.

BP_EnemyBase는 활성화와 비활성화 작업을 대칭적으로 구성했다.

ActivateFromPool
→ 위치와 소유 타일 설정
→ 상태 초기화
→ 표시 활성화
→ Collision·Overlap 활성화
→ 필요한 Tick·공격 Timer 활성화

DeactivateFromPool
→ 공격 Timer 정리
→ Collision·Overlap 비활성화
→ Tick 비활성화
→ Actor 숨김
→ 풀 반환 가능한 상태로 전환

피해를 받아 HP가 0 이하가 되면 CheckDie에서 중복 사망을 막고, 보상 골드를 GameInstance에 전달한 뒤 Enemy를 풀에 반환한다. 적 Actor를 Destroy하지 않기 때문에 다음 배치에서 같은 인스턴스를 재사용할 수 있다.

6. Player와 Enemy가 공유하는 범용 투사체 풀

처음에는 Player 전용 투사체 관리도 생각했지만, Player와 Enemy가 각자 별도 시스템을 가지면 발사·반환·충돌 코드를 반복하게 된다. 최종 구조는 월드에 하나의 BP_ProjectileManager를 두고 양쪽이 같은 풀을 사용하도록 했다.

발사자는 ST_ProjectileRequest를 만들어 Manager에 전달한다.

필드 역할
Shooter 자기 자신과의 충돌 제외, 피해 출처 기록
Team Player·Enemy 진영 판별
SpawnTransform 시작 위치와 방향
Damage 이번 발사의 피해량
Speed 투사체 이동 속도
MaxTravelDistance 자동 반환 거리
ProjectileMesh / MeshScale 같은 Actor의 외형 변경

RequestProjectile은 대기 배열에서 하나를 꺼내 활성 배열로 옮기고 ActivateProjectile을 호출한다. 활성화 시 Transform, Mesh, Scale, 속도, Collision과 Tick을 설정한다. 반환 시에는 이동을 즉시 멈추고 Collision·Tick을 끈 뒤 숨겨 대기 배열에 돌려놓는다.

투사체는 Initial Life Span이나 Destroy를 사용하지 않는다. 명중, 벽 충돌 또는 최대 이동 거리 도달 시 Manager로 반환한다. 최대 이동 거리 계산은 활성 투사체의 Tick에서만 실행하고, 비활성화 시 Tick도 함께 끈다.

DistanceSquared(CurrentLocation, StartLocation)
>= MaxTravelDistance²
→ ReturnProjectile

제곱 거리 비교를 사용해 불필요한 제곱근 계산을 피하고, 곡선 이동이 없는 현재 직선 투사체에 맞는 최소 로직으로 구성했다.

7. Blueprint Interface로 결합도를 낮춘 피해 처리

투사체가 Cast To BP_RunnerPlayer, Cast To BP_EnemyBase를 반복하지 않도록 두 개의 Interface를 만들었다.

BPI_CombatUnit
└─ GetCombatTeam → Player / Enemy

BPI_Damageable
└─ ReceiveDamage(ST_DamageInfo)

Overlap이 발생하면 발사자 본인인지 확인하고, 상대가 BPI_CombatUnit을 구현했는지 검사한다. 다른 진영이면서 BPI_Damageable을 구현한 대상에만 피해 정보를 전달한 뒤 투사체를 반환한다.

이 구조에서 투사체는 맞은 대상이 Player인지 Enemy인지 알 필요가 없다. 이후 새로운 전투 Actor를 추가해도 Interface만 구현하면 같은 투사체 흐름에 참여할 수 있다.

마지막 통합 단계에서는 ST_ProjectileRequest.Speed가 ProjectileMovement에 제대로 적용되지 않아 요청 속도와 실제 속도가 달라지는 문제가 있었다. 활성화 시 Initial Speed, Max Speed, Local Velocity에 요청 속도를 모두 반영하도록 수정했다.

이미지 삽입 후보: ST_ProjectileRequest, BP_Projectile.ActivateProjectile, Interface 기반 Overlap 그래프

8. Map 기반 스탯 합산과 런 중 강화

플레이어 스탯은 E_Stats → Float Map으로 관리했다.

  • ArrowDamage
  • ArrowRange
  • ArrowSpeed
  • ArrowCount
  • HorizonSpeed

최종 능력치는 세 계층을 합산한다.

CurrentStats = BaseStats + RunBonusStats + PlayerPermanentBonusStats

BaseStats는 캐릭터 기본값, RunBonusStats는 현재 플레이에서 표지판으로 얻은 값, PlayerPermanentBonusStats는 GameInstance의 메타 강화 값이다. UpdateStats는 StatsList를 순회하며 세 Map에서 같은 Key를 찾고 합산한 결과를 CurrentStats에 만든다.

강화 표지판은 왼쪽과 오른쪽에 서로 다른 스탯과 값을 표시한다. 플레이어가 Collision에 들어오면 TryApplySignUpgrade가 해당 능력치를 RunBonusStats에 누적하고 UpdateStats를 다시 호출한다.

두 표지판의 Collision이 같은 프레임에 겹칠 가능성을 막기 위해 타일 단위 bUpgradeSelected Guard를 사용했다. 적용을 시작하기 전에 먼저 true로 설정하고, 타일을 재사용할 때만 false로 초기화한다. 통합 과정에서 표지판을 활성화하면서 이 값을 반대로 초기화하던 버그가 발견되어 초기화 시점을 타일 재사용 경로로 옮겼다.

스탯이 갱신되면 OnStatUpdate Event Dispatcher가 호출되고, PlayerController와 HUD가 현재 값을 다시 표시한다. 화살 피해·사거리·속도·수량과 횡이동 속도 역시 이 최종 스탯을 사용한다.

9. HUD, 메뉴, 상점 흐름

기본 시작 맵은 Main이고, 인게임은 Ingame 맵으로 분리했다.

Main
├─ 게임 시작 → Ingame 열기
└─ 상점 → WidgetSwitcher로 Shop 표시

Ingame
├─ Player HUD
├─ 일시정지 메뉴
├─ 사망 상태 표시
└─ 메인 메뉴 복귀

인게임 HUD의 스탯 행은 WBP_HUDStatRow를 동적으로 생성한다. 표시할 Enum 목록을 순회하고 CurrentStats Map의 값을 찾아 Row에 전달한다. 갱신 때 기존 자식을 Clear한 뒤 다시 만들어 중복 Row가 쌓이지 않게 했다.

골 지점 진행도는 매 Tick 계산하지 않는다. BP_LevelManager.ReportTilePassed가 통과 타일 수를 변경할 때 OnGoalProgressChanged Event Dispatcher를 호출하고, PlayerController가 HUD의 UpdateGoalProgress를 실행한다.

GoalPercent = Clamp(PassedTileCount / Max(GoalTileCount, 1), 0, 1)

플레이어 머리 위 체력바는 현재 HP와 최대 HP의 비율을 Progress Bar에 전달한다. 초기 구현에서 현재 HP를 그대로 0~1로 Clamp해 거의 항상 100%로 보이는 문제가 있어 CurrentHP / MaxHP 비율로 수정했다.

상점은 현재 골드와 E_Stats별 강화 레벨·비용·증가량을 보여 준다. 구매 요청은 GameInstance.TryBuyUpgrade로 모으고, 골드가 비용 이상일 때만 차감과 보너스 Map 갱신을 수행한다. 이 데이터는 레벨 전환 동안 유지되지만, 별도 SaveGame이 완성되지 않았으므로 프로그램 재실행 후 복원되는 구조는 아니다.

10. 데이터와 이벤트 중심으로 Tick 줄이기

이번 프로젝트에서 Tick 사용 기준을 명확히 하려고 했다.

  • 연속 이동이 필요한 타일·활성 투사체만 Tick 사용
  • 비활성 풀 Actor는 Tick과 Collision을 모두 비활성화
  • 자동 사격과 적 공격은 Timer 사용
  • 스탯·HUD·진행도는 Event Dispatcher로 변경 시점에만 갱신
  • 타일 소유 Enemy 배열을 사용해 월드 전체 Actor 검색 제거
  • 풀 고갈 시 런타임 확장 대신 요청 생략

Blueprint-only 프로젝트에서도 Actor 수와 이벤트 호출 시점을 관리하면 불필요한 프레임 연산과 생성 비용을 줄일 수 있었다.

11. 통합 과정에서 수정한 문제

스탯 이벤트 시그니처 불일치

OnStatUpdate의 입력 시그니처를 변경한 뒤 PlayerController의 기존 Event 노드에 사라진 Pin이 남아 전체 컴파일이 실패했다. 관련 Event를 새 시그니처에 맞춰 갱신하고 연결을 다시 구성했다.

표지판 선택 Guard 초기화 오류

표지판을 활성화할 때 bUpgradeSelected를 잘못된 값으로 초기화해 선택이 막히거나 중복 처리될 수 있었다. Guard는 타일 초기화 시 false, 선택 적용 직전 true가 되도록 생명주기를 정리했다.

투사체 속도 미적용

요청 구조체에는 속도가 있었지만 실제 ProjectileMovement의 속도와 Local Velocity에 모두 반영되지 않았다. 활성화 경로에서 세 값을 같은 Request Speed로 맞췄다.

Git LFS 접근 문제

Unreal의 .uasset, .umap을 Git LFS로 관리하던 중 로컬 LFS 객체 권한 문제로 Push가 막혔다. 손상 범위를 확인하고 ACL을 백업한 뒤 필요한 객체 권한을 복구했다. 미사용 외부 에셋과 불필요한 BuiltData가 원격 이력에 포함되지 않도록 커밋 이력도 정리했다.

 

12. 회고

블루프린트만으로 게임을 개발하는 과정은 불편한 점이 많았다. 단순한 데이터관리도 사전작업도 많고, 관리 체계도 한눈에 들어오지않아서 프로젝트를 추적하는데 어려움을 느꼈다. 또한 코딩을 몰라도 할수 있다라는 장점이 무색하게, 개발 시작전 완전하게 개발 구조와 모델을 확정짓고, 이해도가 높지 않은 이상 중간중간 디버깅이나 버그 추적이 굉장히 어려웠다.
다만, 간단한 게임이라면 어떤 순서로 액터들이 활성화되고, 작동하는지 라이프 사이클을 관찰하기에는 좋았다.