Unity 2D 횡스크롤 런앤건 게임
ChronoDiver를 5주간 개발했습니다.
저는 Map 파트를 맡아 절차적 지형 생성, 스트리밍, 파트 간 연동, 백그라운드 등을 담당했습니다.
이 글은 그중 Map 파트의 설계와 시행착오를 정리한 기록입니다.

"시드에 따른 매 판 다른 맵, 어떤 경우에도 절대 진행이 막히지 않는 맵" 을 만드는 게 목표였습니다.
협업에서 가장 먼저 정한 건 모듈 경계였습니다. 맵은 혼자 도는 게 아니라 적·NPC·보급·Core와 맞물리기 때문에, "누가 무엇을 인스턴스화하고 누가 좌표만 주는가"를 처음에 못 박았습니다.
| 대상 | Map의 역할 | 다른 파트의 역할 |
|---|---|---|
| 지형/플랫폼/구조물 | 생성·배치 전부 | — |
| 적(Enemy) | 스폰 좌표만 제공 | Character가 Pool에서 instantiate |
| NPC | Map이 인스턴스화 | Core가 "스폰 가능" 시점 판단 |
| 보급(Supply) | 낙하 좌표만 제공 | Character 주도 |
이 경계 덕분에 5주 내내 "맵 때문에 다른 파트가 막히는" 상황을 거의 안 만들었습니다.
파트 간 통신은 단일 창구 Map_GateWay 하나로만 오가게 했습니다. 메서드 접두사로 방향을 강제했어요.
Call_ : 외부 → Map에 "이거 해라" (예: Call_BuildMap, Call_Tick)Request_ : 외부 → Map에 "이 값 줘" (예: Request_PlayerSpawnPosition)Report_ : Map → 외부에 "이런 일이 생겼다" (예: Report_RunComplete)Noted_ : 외부가 Report를 받았다는 응답// Map_GateWay — 모듈 외부 창구. 내부 로직 없이 Manager로 라우팅만.
public class Map_GateWay : MonoBehaviour
{
// Core가 런 시작 시 1회 호출. Map 시동
public void Call_BuildMap() => _manager.Begin();
// Core가 매 프레임 호출 (Map은 Update 미사용)
public void Call_Tick() => _manager.Tick();
// 맵 끝 도달 → Core에 엔딩 보고
public void Report_RunComplete() => _coreListener.Noted_Report_ReachedEndingChunk();
}
설계 포인트: Map은
Update()를 쓰지 않습니다. Core가Call_Tick()으로 틱을 구동해요.
모듈마다 제멋대로 Update를 돌리면 실행 순서·상호 참조가 폭증하기 때문에, 틱 주도권을 Core에 모았습니다.
처음엔 손으로 만든 청크 프리팹을 이어 붙이는 방식이었습니다. 그런데:
2주차에 청크를 전부 버리고 순수 절차적 생성으로 백지에서 재구축하는 결정을 내렸습니다. 이게 프로젝트에서 가장 큰 의사결정이었습니다.
바꾼 후엔
결과적으로 절차적 생성 방식으로 바꾼 결정은 짧은 시간의 프로젝트 기간에서 게임의 완성도를 더욱 증폭 시켜주는 결과를 얻어서 기뻤습니다.
Perlin Noise는 시드 개념이 없습니다. 그래서 시드로 노이즈 샘플 원점을 옮겨 재현성을 확보했어요.
// 같은 시드 → 같은 맵 (재현/복원 기반)
public class Map_SeedRng
{
public Map_SeedRng(int seed)
{
// seed == -1이면 매번 랜덤 (개발 편의)
Seed = (seed == -1) ? (int)(DateTime.Now.Ticks & int.MaxValue) : seed;
_random = new System.Random(Seed);
}
}
// Perlin은 시드가 없으니 샘플 위치를 시드 기반으로 이동 → 시드 다르면 완전 다른 지형
_noiseOriginX = _rng.NextRange(-10000f, 10000f);
private int SampleHeight(int worldX)
{
float sampleX = (_noiseOriginX + worldX) * _config.noiseFrequency;
float perlin = Mathf.PerlinNoise(sampleX, 0f); // 0~1
float centered = (perlin - 0.5f) * 2f; // -1~1
int delta = Mathf.RoundToInt(centered * _config.noiseAmplitude);
return _config.baseHeightTiles + delta; // 평균 높이 ± 진폭
}
매판 다른 시드


지형을 땅 / Block(공중·지면 구조물) / 플랫폼 3계층으로 나누고, 각각 별도 Tilemap에 그렸습니다. 타일도 윗면·옆면·코너·전환면으로 분리해 위치+시드 기반으로 결정론적 variation을 선택하게 했어요. (ScriptableObject 1개 = 1 스테이지 설정)
절차적 맵의 최대 리스크는 랜덤하게 생성된 지형이 플레이어를 가둬버리는 것입니다. 우리 팀의 절대 원칙은 하나였어요:
어떤 시드에서도 진행 차단은 절대 발생하지 않는다.
런앤건 게임에서 플레이어가 맵의 구조적 문제로 막히는 것은 있을 수 없는 일인게 당연합니다.
이걸 보장하려고 여러 겹의 안전장치를 쌓았습니다.
폭 1칸짜리 언덕은 플레이어를 가두거나 어색하게 만듭니다. 평탄화 작업을 수렴할 때까지 반복(do-while) 돌려서 제거했습니다.
do {
changed = false;
for (int i = 1; i < size - 1; i++) {
// 양옆보다 높은 1칸 봉우리 → 낮은 쪽 높이로 깎음
if (heights[i-1] < heights[i] && heights[i+1] < heights[i]) {
heights[i] = Mathf.Max(heights[i-1], heights[i+1]);
changed = true;
}
// 양옆보다 낮은 1칸 골짜기 → 메움
else if (heights[i-1] > heights[i] && heights[i+1] > heights[i]) {
heights[i] = Mathf.Min(heights[i-1], heights[i+1]);
changed = true;
}
}
// width 2 케이스, 세그먼트 첫 column(이전 세그먼트 연결)도 동일 처리…
} while (changed);
높이 차이가 플레이어의 점프 한계(maxJumpHeightTiles)를 넘으면 등반이 불가능합니다. 이때 낮은 쪽에서 높은 쪽까지 점프 가능한 간격으로 플랫폼을 자동으로 설치 되도록 설계했습니다.
int diff = rightH - leftH;
if (Mathf.Abs(diff) <= maxJump) continue; // 점프로 넘을 수 있으면 발판 불필요
// 절벽 감지 → 낮은 쪽 X부터 maxJump 간격으로 발판을 쌓아 올림
int y = lowH + maxJump;
while (y < highH) {
// 한 줄 사다리처럼 보이지 않게 시드 기반 jitter로 좌우 흔듦
int hash = unchecked((segmentIdx * 73856093) ^ (lowX * 19349663) ^ (y * 83492791));
int offset = direction * Mathf.Min(idx + (hash & 1), 2);
platforms.Add(new Vector2Int(lowX + offset, y));
y += maxJump;
}
여기서 신경 쓴 디테일:
prevLastHeight)와 다음 첫 높이(nextFirstHeight)를 따로 넘겨받아 경계에서도 발판을 깔았습니다.긴장감을 위해 구덩이(낙사 구간)를 넣되, NPC·구조물이 있는 안전 영역과 겹치지 않게 했습니다. 겹치면 최대 5번 재시도 후, 그래도 안 되면 구덩이 생성을 포기합니다(진행 우선).
for (int attempt = 0; attempt < 5; attempt++) {
int candidate = margin + (hash2 % range);
if (!OverlapsAnySafe(worldStart, worldEnd, safeWorldRanges)) { startX = candidate; break; }
}
if (startX < 0) { Debug.Log("pit skipped (안전영역 겹침 5회)"); return; } // 막히느니 구덩이를 버린다
구덩이를 판 뒤엔 양쪽 지면을 같은 높이로 맞추고(낮은 쪽으로 떨어지지 않게), 그 여파로 생긴 절벽까지 체인으로 평탄화했습니다.
평탄화 → 구덩이 주입 → 다시 평탄화 → 세그먼트 마지막 경사 강제 평탄화 → 그로 인해 생긴 봉우리 재보강. 한 번으로 끝내지 않고 여러 패스로 겹쳐서 "어떤 단계의 부작용도 다음 패스가 잡도록" 설계했습니다.
이 챕터가 길었던 이유: 처음엔 "평탄화 한 번이면 되겠지" 했는데, 구덩이 주입이 새 절벽을 만들고, 발판 삽입이 또 다른 경계를 만드는 연쇄가 계속 터졌습니다. "한 번에 완벽한 규칙" 대신 수렴할 때까지 반복 + 최후의 강제 평탄화 조합이 답이었습니다.
맵 전체를 한 번에 만들면 메모리·로딩이 부담됩니다. 세그먼트(60타일) 단위로 플레이어 앞을 미리 생성(lookahead)하고 뒤를 정리(lookbehind) 했어요. Map은 Update를 안 쓰니, Core가 주는 Call_Tick(playerX)마다 스트리밍을 갱신합니다.
[SerializeField] private int _prewarmCount = 3;
[SerializeField] private float _lookaheadUnit = 180f;
[SerializeField] private float _lookbehindUnit = 180f;
스테이지마다 별도 Config(SO)를 두고, 세그먼트 인덱스를 스테이지로 매핑해 스테이지가 바뀌면 난이도·테마 파라미터가 자연스럽게 바뀌도록 했습니다.
Map은 적·보스·NPC를 직접 만들지 않습니다. 대신 좌표 마커를 두고, Map_GateWay를 통해 다른 파트가 사용할 수 있게 했습니다.
// 적 배치: Map은 "예산"만 주고 Character가 조합 결정
public List<GameObject> Request_EnemyPrefabs(int populationBudget)
=> _coreCaller.Call_CharacterMReturnEnemyList(populationBudget);
// 보스: Map은 좌표만, Character가 인스턴스화
public Vector3 Request_BossSpawnPosition() => _bossSpawnPoint.position;
덕분에 "맵이 적을 어떻게 만들지"와 "캐릭터 파트가 적을 어떻게 풀링할지"가 완전히 분리됐습니다. 한쪽이 바뀌어도 인터페이스만 지키면 서로 깨지지 않았습니다.
세그먼트를 생성하고 같은 프레임에 bounds/Raycast를 쐈더니 위치가 안 맞았습니다. 원인은 Unity의 Transform→물리 동기화가 다음 FixedUpdate까지 지연되기 때문이었습니다. 생성·이동 직후 전에 명시적으로 동기화해 해결했습니다.
Physics2D.SyncTransforms(); // Instantiate + 이동 후 같은 프레임 쿼리 시 필수
플랫폼과 윗면 타일의 콜라이더가 붙어 있을 때 계속 끼이는 문제가 발생했습니다. 한참 캐릭터 물리를 의심했는데, 진짜 원인은 타일 콜라이더를 평탄화하는 에디터 스크립트에서 Upper(16px)와 Platform(20px)의 상단 깎기 값이 달랐던 것. 두 값을 18px로 통일해 해결했습니다.
교훈: "물리 버그처럼 보여도 원인은 데이터(콜라이더 셋업)에 있을 수 있다." 의심 범위를 캐릭터→타일 셋업으로 넓힌 게 해결의 열쇠였습니다.
잘한 것
아쉬운 것
Debug.Log가 곳곳에 남아 있어, 후반에 정리 비용이 들었습니다.다음에 다르게 한다면
절차적 맵 생성에서 "재현성"과 "도달성 보장"을 어떻게 양립시켰는지가 이 프로젝트의 핵심이었습니다.
좋은 인사이트 감사합니다.