Unity 2D 런앤건 게임 #ChronoDiver

주환서·2026년 6월 9일
post-thumbnail

절차적으로 무한히 이어지는 2D 런앤건 맵 만들기 — 5주 팀 프로젝트 회고 (Map 파트)

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


1. 프로젝트 개요

  • 장르: 2D 횡스크롤 런앤건 슈터
  • 기간 / 인원: 5~6주
  • 엔진: Unity
  • 팀원 역할: 코어(Core), 캐릭터(Character), 유틸(Utility)
  • 내 역할: 맵(Map) 담당

"시드에 따른 매 판 다른 맵, 어떤 경우에도 절대 진행이 막히지 않는 맵" 을 만드는 게 목표였습니다.


2. Map 파트가 책임지는 것 / 책임지지 않는 것

협업에서 가장 먼저 정한 건 모듈 경계였습니다. 맵은 혼자 도는 게 아니라 적·NPC·보급·Core와 맞물리기 때문에, "누가 무엇을 인스턴스화하고 누가 좌표만 주는가"를 처음에 못 박았습니다.

대상Map의 역할다른 파트의 역할
지형/플랫폼/구조물생성·배치 전부
적(Enemy)스폰 좌표만 제공Character가 Pool에서 instantiate
NPCMap이 인스턴스화Core가 "스폰 가능" 시점 판단
보급(Supply)낙하 좌표만 제공Character 주도

이 경계 덕분에 5주 내내 "맵 때문에 다른 파트가 막히는" 상황을 거의 안 만들었습니다.

호출 방향 컨벤션 (Request / Report / Call / Noted)

파트 간 통신은 단일 창구 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에 모았습니다.


3. 핵심 기능 1 — 절차적 지형 생성

3-1. 청크에서 순수 절차적으로 갈아엎다

처음엔 손으로 만든 청크 프리팹을 이어 붙이는 방식이었습니다. 그런데:

  • 청크 수가 적으면 금방 패턴이 들킴
  • 청크 경계 처리가 지저분함
  • 확장할수록 프리팹 관리 비용이 폭증

2주차에 청크를 전부 버리고 순수 절차적 생성으로 백지에서 재구축하는 결정을 내렸습니다. 이게 프로젝트에서 가장 큰 의사결정이었습니다.

바꾼 후엔

  • 패턴이 다양해짐
  • 수작업이 아니라 작업 속도 Up
  • 조금 더 고도화 된 기능 구현

결과적으로 절차적 생성 방식으로 바꾼 결정은 짧은 시간의 프로젝트 기간에서 게임의 완성도를 더욱 증폭 시켜주는 결과를 얻어서 기뻤습니다.

3-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;           // 평균 높이 ± 진폭
}

매판 다른 시드

3-3. 3계층 지형 + 타일 구성

지형을 땅 / Block(공중·지면 구조물) / 플랫폼 3계층으로 나누고, 각각 별도 Tilemap에 그렸습니다. 타일도 윗면·옆면·코너·전환면으로 분리해 위치+시드 기반으로 결정론적 variation을 선택하게 했어요. (ScriptableObject 1개 = 1 스테이지 설정)


4. 핵심 기능 2 — 도달성 보장 (가장 중요한 챕터)

절차적 맵의 최대 리스크는 랜덤하게 생성된 지형이 플레이어를 가둬버리는 것입니다. 우리 팀의 절대 원칙은 하나였어요:

어떤 시드에서도 진행 차단은 절대 발생하지 않는다.

런앤건 게임에서 플레이어가 맵의 구조적 문제로 막히는 것은 있을 수 없는 일인게 당연합니다.
이걸 보장하려고 여러 겹의 안전장치를 쌓았습니다.

4-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);

4-2. 절벽엔 발판을 자동으로 깐다

높이 차이가 플레이어의 점프 한계(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)를 따로 넘겨받아 경계에서도 발판을 깔았습니다.
  • 땅속 발판 방지: 플랫폼 배치를 어색하게 하지 않게 하기 위해 지그재그처럼 배치 하는 로직을 썼는데, 발판이 지형에 묻히면 의미가 없으니, 만약 묻히는 상황에서는 그냥 부자연스럽더라도 묻히지 않는 위치에 배치 되도록 구현했습니다.

4-3. 죽음 구덩이도 "안전하게" 넣는다

긴장감을 위해 구덩이(낙사 구간)를 넣되, 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; }   // 막히느니 구덩이를 버린다

구덩이를 판 뒤엔 양쪽 지면을 같은 높이로 맞추고(낮은 쪽으로 떨어지지 않게), 그 여파로 생긴 절벽까지 체인으로 평탄화했습니다.

4-4. 다중 패스 안전망

평탄화 → 구덩이 주입 → 다시 평탄화 → 세그먼트 마지막 경사 강제 평탄화 → 그로 인해 생긴 봉우리 재보강. 한 번으로 끝내지 않고 여러 패스로 겹쳐서 "어떤 단계의 부작용도 다음 패스가 잡도록" 설계했습니다.

이 챕터가 길었던 이유: 처음엔 "평탄화 한 번이면 되겠지" 했는데, 구덩이 주입이 새 절벽을 만들고, 발판 삽입이 또 다른 경계를 만드는 연쇄가 계속 터졌습니다. "한 번에 완벽한 규칙" 대신 수렴할 때까지 반복 + 최후의 강제 평탄화 조합이 답이었습니다.


5. 핵심 3 — 스트리밍과 파트 간 전환

5-1. 세그먼트 단위 스트리밍

맵 전체를 한 번에 만들면 메모리·로딩이 부담됩니다. 세그먼트(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)를 두고, 세그먼트 인덱스를 스테이지로 매핑해 스테이지가 바뀌면 난이도·테마 파라미터가 자연스럽게 바뀌도록 했습니다.

5-2. 마커 + Gateway → 다른 파트로 핸드오프

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;

덕분에 "맵이 적을 어떻게 만들지"와 "캐릭터 파트가 적을 어떻게 풀링할지"가 완전히 분리됐습니다. 한쪽이 바뀌어도 인터페이스만 지키면 서로 깨지지 않았습니다.


6. 트러블슈팅 — 기억에 남는 버그 2개

6-1. Instantiate 직후 좌표가 한 프레임 어긋난다

세그먼트를 생성하고 같은 프레임에 bounds/Raycast를 쐈더니 위치가 안 맞았습니다. 원인은 Unity의 Transform→물리 동기화가 다음 FixedUpdate까지 지연되기 때문이었습니다. 생성·이동 직후 전에 명시적으로 동기화해 해결했습니다.

Physics2D.SyncTransforms();   // Instantiate + 이동 후 같은 프레임 쿼리 시 필수

6-2. 플랫폼 위에서 캐릭터가 미묘하게 끼이거나 떨어진다

플랫폼과 윗면 타일의 콜라이더가 붙어 있을 때 계속 끼이는 문제가 발생했습니다. 한참 캐릭터 물리를 의심했는데, 진짜 원인은 타일 콜라이더를 평탄화하는 에디터 스크립트에서 Upper(16px)와 Platform(20px)의 상단 깎기 값이 달랐던 것. 두 값을 18px로 통일해 해결했습니다.

교훈: "물리 버그처럼 보여도 원인은 데이터(콜라이더 셋업)에 있을 수 있다." 의심 범위를 캐릭터→타일 셋업으로 넓힌 게 해결의 열쇠였습니다.


7. 회고

잘한 것

  • 모듈 경계와 호출 컨벤션을 처음에 못 박은 것. 5주 내내 파트 간 충돌이 적었습니다.
  • "진행 차단 절대 금지"를 단일 규칙으로 세우고, 여러 안전망을 겹친 것.
  • 청크를 과감히 버리고 절차적으로 재구축한 결정.

아쉬운 것

  • 평탄화 로직이 반복 패스가 쌓이며 길어졌습니다. 규칙 기반으로 한 번에 정리할 여지가 있었습니다.
  • 디버그 Debug.Log가 곳곳에 남아 있어, 후반에 정리 비용이 들었습니다.

다음에 다르게 한다면

  • 도달성 보장을 "사후 평탄화"가 아니라 생성 단계에서 제약으로 거는 방식(예: 높이 변화량 자체를 점프 한계 내로 clamp)을 먼저 검토해보고 싶습니다.
  • 절차적 생성 결과를 자동 검증하는 테스트(랜덤 시드 N개 생성 → 도달성 자동 체크)를 붙이면 훨씬 좋았을것같습니다.

절차적 맵 생성에서 "재현성"과 "도달성 보장"을 어떻게 양립시켰는지가 이 프로젝트의 핵심이었습니다.

1개의 댓글

comment-user-thumbnail
2026년 6월 16일

좋은 인사이트 감사합니다.

답글 달기