
게임 레벨 에디팅 자동화를 위한 Python 스크립트 구축
반복적인 오브젝트 배치와 검증 작업을 Python으로 분리해 레벨 제작 속도와 데이터 신뢰도를 함께 높이는 방법을 정리한다. CSV·JSON 기반 파이프라인, 안전한 ID 생성, 검증과 엔진 연동 지점을 예제로 다룬다.
왜 레벨 에디팅에 자동화가 필요한가
레벨 제작에는 사람이 직접 판단해야 하는 일이 많습니다. 전투 공간의 긴장감, 시야 유도, 엄폐물의 밀도처럼 플레이 경험에 영향을 주는 결정은 에디터에서 반복하며 다듬는 편이 좋습니다.
반면 규칙이 명확한 반복 작업은 스크립트로 옮길 수 있습니다. 예를 들어 웨이브 스폰 지점에 번호를 붙이고 정해진 간격으로 조명 프리셋을 배치하며 잘못된 좌표나 중복 ID를 찾는 일은 사람이 매번 처리할 이유가 적습니다. 자동화의 목표는 레벨 디자이너를 대체하는 것이 아니라 판단에 쓸 시간을 확보하는 데 있습니다.
Python은 CSV와 JSON 같은 데이터 파일을 다루기 쉽고 표준 라이브러리만으로도 작은 도구를 빠르게 만들 수 있어 이 용도에 잘 맞습니다. 다만 Unity와 Unreal Engine의 최종 에셋 형식 및 에디터 API는 서로 다르므로 공통 처리와 엔진별 반영 단계를 분리하는 것이 중요합니다.
자동화 범위를 먼저 작게 정하기
처음부터 씬 전체를 생성하는 도구를 만들면 규칙이 빠르게 복잡해집니다. 다음처럼 입력과 출력이 분명한 작업 하나로 시작하는 편이 안전합니다.
- 입력: 스폰 지점 이름, 좌표, 적 종류가 담긴 CSV
- 처리: 좌표 형식 검사, ID 중복 검사, 기본 회전값 보정
- 출력: 엔진에서 읽을 수 있는 JSON과 검증 보고서
이 구조에서는 Python이 엔진 프로젝트를 직접 수정하지 않아도 됩니다. 생성한 JSON을 Unity의 Editor 스크립트나 Unreal의 Editor Utility에서 불러오면 됩니다. 데이터 변환과 엔진 API 호출을 분리하면 엔진을 바꾸거나 데이터 규칙을 수정할 때 영향 범위도 작아집니다.
flowchart LR
A[레벨 데이터 CSV] --> B[Python 변환·검증]
B --> C[배치 데이터 JSON]
B --> D[검증 보고서]
C --> E[Unity Editor Importer]
C --> F[Unreal Editor Utility]
이 흐름에서 CSV는 사람이 편집하기 쉬운 원본이고 JSON은 도구 간 전달 형식입니다. 원본 파일을 직접 덮어쓰지 않고 생성물을 별도 폴더에 두면 변경 내용을 검토하거나 재생성하기도 수월합니다.

입력 데이터 형식 설계
다음은 spawn_points.csv의 예입니다. 좌표는 엔진 단위로 통일하고 방향은 도 단위의 Y축 회전값으로 정했습니다. 어떤 축을 위쪽으로 쓸지 좌표계 변환이 필요한지는 프로젝트 규칙으로 명시해야 합니다.
id,prefab,x,y,z,yaw,enemy_type
spawn_plaza_01,EnemySpawn,12.5,0,8.0,90,grunt
spawn_plaza_02,EnemySpawn,16.0,0,8.0,90,grunt
spawn_rooftop_01,EnemySpawn,4.0,6.5,-3.0,180,ranged
열 이름은 도구의 계약입니다. 예를 들어 yaw를 제거하고 회전을 쿼터니언으로 바꾸려면 변환 스크립트, 엔진 임포터, 문서를 함께 수정해야 합니다. 따라서 필요한 필드만 유지하고 선택 항목에는 기본값을 두는 것이 좋습니다.
또한 이름으로 좌표를 추측하지 않는 편이 좋습니다. spawn_rooftop_01은 사람이 이해하기 위한 이름일 뿐, 스크립트는 실제 y 값이 지상보다 높은지와 같은 명시적인 규칙을 검증해야 합니다.
CSV를 JSON으로 변환하고 검증하기
아래 스크립트는 CSV를 읽어 스폰 지점 데이터를 JSON으로 내보냅니다. 중복 ID, 필수값 누락, 숫자가 아닌 좌표, 허용 범위를 벗어난 높이를 오류로 기록합니다. 오류가 하나라도 있으면 JSON을 만들지 않으므로 잘못된 데이터가 엔진으로 들어가는 일을 막을 수 있습니다.
from __future__ import annotations
import csv
import json
from dataclasses import asdict, dataclass
from pathlib import Path
INPUT_PATH = Path("data/spawn_points.csv")
OUTPUT_PATH = Path("generated/spawn_points.json")
REPORT_PATH = Path("generated/spawn_points_report.txt")
MAX_HEIGHT = 100.0
@dataclass
class SpawnPoint:
id: str
prefab: str
position: dict[str, float]
yaw: float
enemy_type: str
def required(row: dict[str, str], key: str, line: int) -> str:
value = row.get(key, "").strip()
if not value:
raise ValueError(f"{line}행: '{key}' 값이 비어 있습니다.")
return value
def number(row: dict[str, str], key: str, line: int) -> float:
try:
return float(required(row, key, line))
except ValueError as error:
raise ValueError(f"{line}행: '{key}'는 숫자여야 합니다.") from error
def load_spawn_points(path: Path) -> tuple[list[SpawnPoint], list[str]]:
points: list[SpawnPoint] = []
errors: list[str] = []
ids: set[str] = set()
with path.open("r", encoding="utf-8-sig", newline="") as file:
for line, row in enumerate(csv.DictReader(file), start=2):
try:
point_id = required(row, "id", line)
if point_id in ids:
raise ValueError(f"{line}행: 중복 ID '{point_id}'가 있습니다.")
y = number(row, "y", line)
if not 0.0 <= y <= MAX_HEIGHT:
raise ValueError(
f"{line}행: y 값은 0~{MAX_HEIGHT} 범위여야 합니다."
)
point = SpawnPoint(
id=point_id,
prefab=required(row, "prefab", line),
position={
"x": number(row, "x", line),
"y": y,
"z": number(row, "z", line),
},
yaw=number(row, "yaw", line) % 360.0,
enemy_type=required(row, "enemy_type", line),
)
ids.add(point_id)
points.append(point)
except ValueError as error:
errors.append(str(error))
return points, errors
def main() -> None:
points, errors = load_spawn_points(INPUT_PATH)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
if errors:
REPORT_PATH.write_text("\n".join(errors) + "\n", encoding="utf-8")
print(f"검증 실패: {len(errors)}개 오류를 {REPORT_PATH}에 기록했습니다.")
raise SystemExit(1)
payload = {"version": 1, "spawn_points": [asdict(point) for point in points]}
OUTPUT_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
REPORT_PATH.write_text(f"검증 통과: {len(points)}개 스폰 지점\n", encoding="utf-8")
print(f"{len(points)}개 스폰 지점을 {OUTPUT_PATH}에 저장했습니다.")
if __name__ == "__main__":
main()
utf-8-sig로 파일을 열면 스프레드시트 프로그램이 붙인 UTF-8 BOM이 있어도 헤더의 첫 글자가 깨지는 문제를 피할 수 있습니다. yaw % 360.0은 450도처럼 입력된 값도 90도로 정규화합니다. 단, 회전의 범위나 높이 제한은 예시일 뿐이므로 프로젝트의 좌표계와 플레이 영역에 맞춰 결정해야 합니다.
검증 규칙은 데이터 오류의 비용을 기준으로 만든다
검증은 많을수록 좋은 것이 아니라 실패했을 때 원인을 바로 알 수 있을 만큼 분명해야 합니다. 처음에는 다음 규칙이 실용적입니다.
반드시 막아야 하는 오류
- ID 중복: 엔진에서 기존 오브젝트를 잘못 갱신할 수 있습니다.
- 필수 열 누락 또는 빈 값: 어느 데이터가 문제인지 행 번호와 함께 알려야 합니다.
- 숫자 범위 오류: 맵 밖이나 비정상적인 높이에 오브젝트가 생기는 일을 줄입니다.
- 존재하지 않는 프리팹 또는 에셋 키: 엔진 임포트 전에 에셋 목록과 대조합니다.
경고로 남길 수 있는 항목
- 서로 너무 가까운 스폰 지점
- 같은 종류의 적이 특정 구역에 과도하게 몰린 경우
- 이름 규칙과 맞지 않는 ID
경고까지 오류로 처리하면 작업 흐름이 불필요하게 멈출 수 있습니다. 반대로 플레이를 깨뜨리는 조건은 반드시 실패 처리해야 합니다. 오류와 경고의 기준을 코드 주석이나 프로젝트 문서에 남겨 두면 도구를 사용하는 사람이 규칙의 이유를 이해하기 쉽습니다.
Unity와 Unreal에서의 연결 지점
Python 출력은 가능한 한 엔진에 독립적인 데이터로 유지합니다. 이후 엔진 안에서는 해당 엔진의 공식 에디터 확장 방식으로 JSON을 읽고 프리팹이나 액터를 배치합니다.
Unity에서는 Editor 전용 C# 코드가 JSON을 읽어 PrefabUtility.InstantiatePrefab 같은 API로 프리팹을 생성하는 구조를 생각할 수 있습니다. Undo 기록을 남기고 씬을 더티 상태로 표시해야 에디터 사용자가 일반적인 작업처럼 되돌리기와 저장을 할 수 있습니다.
Unreal Engine에서는 Python 플러그인이나 Editor Utility Blueprint/Widget이 JSON을 읽은 뒤 에디터 레벨 라이브러리로 액터를 배치하는 구성이 가능합니다. 어떤 방식을 택하든 런타임 코드가 아닌 에디터 전용 도구인지 생성 결과를 반복 실행해도 안전하게 갱신하는지 확인해야 합니다.
핵심은 매번 새 오브젝트만 추가하지 않는 것입니다. 생성한 오브젝트에 CSV의 id를 메타데이터나 전용 컴포넌트로 저장하고 다음 실행 때 같은 ID를 찾으면 갱신하도록 만드세요. 그래야 스크립트를 여러 번 실행해도 중복 배치가 쌓이지 않습니다.
반복 실행 가능한 도구로 다듬기
자동화 스크립트는 한 번 성공하는 것보다 같은 입력에서 같은 결과를 내는 것이 중요합니다. 다음 원칙을 적용하면 유지보수가 쉬워집니다.
- 입력 파일과 생성 파일을 분리합니다.
- 생성 파일의 순서를 ID 기준으로 고정해 코드 리뷰에서 불필요한 변경을 줄입니다.
- 오류 보고서에 파일명과 행 번호를 포함합니다.
- JSON에
version필드를 넣어 형식 변경을 추적합니다. - 엔진에서 배치하기 전에는 미리보기 또는 드라이런을 제공합니다.
작은 도구라도 자동 테스트를 붙일 수 있습니다. 예를 들어 중복 ID CSV를 넣었을 때 오류가 나고 정상 CSV를 넣었을 때 기대한 JSON이 만들어지는지 검사하면 규칙을 변경할 때 회귀를 줄일 수 있습니다.
from pathlib import Path
from level_export import load_spawn_points
def test_duplicate_id_is_reported(tmp_path: Path) -> None:
source = tmp_path / "duplicate.csv"
source.write_text(
"id,prefab,x,y,z,yaw,enemy_type\n"
"spawn_01,EnemySpawn,0,0,0,0,grunt\n"
"spawn_01,EnemySpawn,1,0,0,0,grunt\n",
encoding="utf-8",
)
points, errors = load_spawn_points(source)
assert points == [] or len(points) == 1
assert any("중복 ID" in error for error in errors)
이 테스트에서는 중복을 발견한 뒤에도 앞 행의 정상 데이터는 읽힐 수 있으므로 중요한 조건은 오류가 확실히 보고되는지입니다. 전체 데이터를 원자적으로 처리하려면 현재처럼 오류가 있으면 최종 JSON 출력을 중단하면 됩니다.
마무리
좋은 레벨 자동화는 복잡한 기능보다 명확한 데이터 계약에서 시작합니다. 사람이 편집하기 쉬운 원본을 정하고 Python으로 검증 가능한 규칙을 적용한 뒤 엔진은 검증된 결과를 배치하는 역할만 맡기세요.
이렇게 경계를 나누면 레벨 디자이너는 반복 입력보다 공간과 플레이 경험을 다듬는 데 집중할 수 있고 프로그래머는 엔진별 배치 로직을 데이터 변환 코드와 독립적으로 관리할 수 있습니다.


