(6/10) 2D SRPG 만들기 선행학습 — 데이터 설계와 세이브/로드
Summary
5편까지로 전투가 돌아가는 게임 골격이 생겼어요. 그런데 지금 유닛 능력치가 전부 make_unit(1, 1, "ally") 같은 코드 속에 박혀 있습니다. 캐릭터 20명과 스킬 50개를 이 방식으로 만들면, 밸런스 수치 하나 바꿀 때마다 코드를 뒤져야 해요.
이번 편은 이 문제를 푸는 데이터 주도 설계(data-driven design) 와, 게임을 껐다 켜도 이어할 수 있게 하는 세이브/로드를 다룹니다. 여기까지가 게임의 뼈대예요. 그리고 뼈대에서 더 나아가려면 무엇을 배우면 되는지 로드맵도 남겨둘게요.
💡 이 글에서 다루는 것
- 데이터 주도 설계 — 코드는 규칙만, 콘텐츠는 JSON 으로
- 템플릿과 인스턴스 — “기사 클래스”와 “전장 위의 기사 3호” 구분하기
- 스킬을 데이터로 정의하고 코드로 해석하기
- 세이브/로드 — 무엇을 저장하고 무엇을 저장하지 않는가
- 시리즈 이후의 학습 로드맵 — 맵 에디터, 사운드, 배포, 엔진 이전
1. 코드는 규칙만, 콘텐츠는 데이터로
상용 게임 개발의 대원칙 하나를 소개할게요. 코드는 “어떻게 동작하는가”(규칙)만 담고, “무엇이 있는가”(콘텐츠)는 데이터 파일로 뺀다 — 이걸 데이터 주도 설계라고 합니다.
| 코드에 남는 것 (규칙) | 데이터로 빠지는 것 (콘텐츠) | |
|---|---|---|
| 유닛 | 데미지 공식, 이동 알고리즘 | 이름, HP, 공격력, 이동력 |
| 스킬 | 효과 해석기 (데미지/회복 처리) | 스킬 이름, 위력, 사거리, 효과 종류 |
| 맵 | 타일 렌더링, 통행 판정 | 타일 배열, 배치, 이벤트 |
이렇게 나누면 밸런스 조정이 게임 코드를 안 건드리고 JSON 편집만으로 끝나요. 지형 데이터를 2편에서 MOVE_COST 딕셔너리로, 데미지 공식을 5편에서 함수로 분리해온 것도 전부 이 원칙의 실천이었습니다. 오늘은 유닛과 스킬까지 마저 빼냅니다.
data/units.json 을 이렇게 만들어요.
{
"version": 1,
"units": [
{"id": "knight", "name": "기사", "max_hp": 24, "atk": 12, "dfn": 9, "mov": 3,
"skills": ["slash"]},
{"id": "archer", "name": "궁수", "max_hp": 18, "atk": 13, "dfn": 5, "mov": 4,
"skills": ["arrow_shot"]},
{"id": "goblin", "name": "고블린", "max_hp": 16, "atk": 10, "dfn": 4, "mov": 4,
"skills": ["slash"]}
]
}
2. 템플릿과 인스턴스 — 기사와 기사 3호
데이터를 읽을 때 꼭 구분해야 하는 개념이 있어요. units.json 의 “기사”는 템플릿(설계도)이고, 전장 위에 서 있는 기사 한 명 한 명은 인스턴스(실체)입니다. 인스턴스는 템플릿에 없는 자기만의 상태 — 현재 위치, 현재 HP, 행동 여부 — 를 따로 가져요.
import json
def load_templates(path="data/units.json"):
with open(path, encoding="utf-8") as f:
return {t["id"]: t for t in json.load(f)["units"]}
def spawn(templates, tid, gx, gy, team):
t = templates[tid]
return {**t, # 템플릿 복사 (이름·능력치)
"gx": gx, "gy": gy, "team": team,
"hp": t["max_hp"], # 인스턴스 상태는 여기서 시작
"acted": False, "path": []}
templates = load_templates()
u = spawn(templates, "knight", 1, 1, "ally")
print(u["name"], u["hp"], "/", u["max_hp"], "@", (u["gx"], u["gy"]))
기사 24 / 24 @ (1, 1)
{**t, ...} 한 줄이 템플릿을 복사한 뒤 인스턴스 상태를 덧씌웁니다. 5편의 make_unit 을 이 spawn 으로 갈아끼우면, 이제 새 유닛 종류 추가는 JSON 에 한 줄 추가로 끝나요. 코드 변경 없이요.
⚠️
{**t}는 얕은 복사라서, 템플릿 안에 리스트(예:skills)가 있으면 인스턴스들이 같은 리스트를 공유합니다. 인스턴스가 그 리스트를 수정할 일이 있다면(스킬 습득 등)copy.deepcopy(t)로 바꾸세요. 읽기만 하면 얕은 복사로 충분해요.
3. 스킬도 데이터로 — 작은 해석기 패턴
스킬은 “종류가 다양하지만 구조는 비슷한” 전형적인 콘텐츠라, 데이터화의 효과가 가장 큰 곳입니다. 요령은 스킬의 효과를 종류(effect) + 수치(power) 조합으로 표준화하는 거예요.
{
"version": 1,
"skills": [
{"id": "slash", "name": "베기", "effect": "damage", "power": 1.0, "range": 1},
{"id": "arrow_shot", "name": "활쏘기", "effect": "damage", "power": 0.8, "range": 3},
{"id": "first_aid", "name": "응급처치", "effect": "heal", "power": 8, "range": 1}
]
}
코드 쪽엔 이 데이터를 해석하는 작은 해석기 하나만 둡니다.
def apply_skill(skill, user, target):
if skill["effect"] == "damage":
dmg, crit = calc_damage(round(user["atk"] * skill["power"]), target["dfn"])
target["hp"] = max(0, target["hp"] - dmg)
return f"{skill['name']}! {dmg} 데미지"
elif skill["effect"] == "heal":
healed = min(skill["power"], target["max_hp"] - target["hp"])
target["hp"] += healed
return f"{skill['name']}! {healed} 회복"
새 데미지 스킬 10개를 만들어도 코드는 그대로고, effect 종류가 늘어날 때만(버프, 상태이상 등) 분기 하나를 추가합니다. 파랜드 택틱스의 그 많은 마법들도 결국 “종류 몇 가지 × 수치 조합”이에요. 사거리(range)는 4편의 movement_range 를 재활용해 “스킬 사거리 내 칸 하이라이트”로 그대로 확장됩니다.
4. 세이브/로드 — 상태만 저장한다
세이브의 제1원칙은 “다시 계산할 수 있는 것은 저장하지 않는다” 입니다. 저장할 것은 오직 되돌릴 수 없는 상태뿐이에요.
| 저장한다 (상태) | 저장하지 않는다 (파생/데이터) |
|---|---|
| 유닛 위치·현재 HP·행동 여부 | 능력치 템플릿 (units.json 에 있음) |
| 현재 턴 수, 누구 턴인지 | 이동 범위, 경로 (다시 계산 가능) |
| 진행 플래그 (스토리, 획득 아이템) | 화면 좌표, 애니메이션 프레임 |
이 원칙대로면 세이브 파일이 놀랄 만큼 작아집니다. 인스턴스에서 상태 필드만 골라 JSON 으로 내리면 돼요.
SAVE_FIELDS = ("id", "gx", "gy", "team", "hp", "acted")
def save_game(units, turn, path="save.json"):
data = {"version": 1, "turn": turn,
"units": [{k: u[k] for k in SAVE_FIELDS} for u in units]}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_game(templates, path="save.json"):
with open(path, encoding="utf-8") as f:
data = json.load(f)
units = []
for s in data["units"]:
u = spawn(templates, s["id"], s["gx"], s["gy"], s["team"])
u["hp"], u["acted"] = s["hp"], s["acted"] # 상태 복원
units.append(u)
return units, data["turn"]
units = [spawn(templates, "knight", 1, 1, "ally"),
spawn(templates, "goblin", 6, 2, "enemy")]
units[1]["hp"] = 9 # 전투로 깎였다 치고
save_game(units, turn=3)
loaded, turn = load_game(templates)
print(turn, "턴,", [(u["name"], u["hp"]) for u in loaded])
3 턴, [('기사', 24), ('고블린', 9)]
로드가 spawn 을 다시 거치는 게 포인트예요. 능력치는 항상 최신 units.json 에서 오고, 세이브에는 상태만 있으니 — 밸런스 패치를 해도 옛 세이브가 그대로 호환됩니다. 능력치까지 통째로 저장했다면 패치 전 수치가 세이브에 박제됐겠죠.
💡
"version": 1필드는 지금은 아무 일도 안 하지만, 나중에 세이브 구조를 바꿀 때 “옛 버전이면 변환해서 읽기” 분기를 걸 수 있게 해주는 보험입니다. 데이터 파일에는 처음부터 버전을 박아두는 습관을 추천드려요.
5. 뼈대 완성 — 다음 로드맵
여섯 편으로 파랜드 택틱스 같은 게임의 뼈대에 필요한 선행지식은 전부 다뤘습니다. 정리하면 이렇게 쌓였어요.
| 편 | 배운 것 | 게임에서의 역할 |
|---|---|---|
| 1편 | 게임 루프, 델타타임 | 심장 박동 |
| 2편 | 타일맵, 쿼터뷰 좌표 변환 | 전장 |
| 3편 | 스프라이트, 애니메이션 | 살아있는 캐릭터 |
| 4편 | 다익스트라, A*, 경로 보간 | 이동 규칙 |
| 5편 | 상태머신, 턴, 데미지, 적 AI | 전투 |
| 6편 | 데이터 주도 설계, 세이브 | 콘텐츠와 지속성 |
여기서 “내 게임”으로 나아가는 다음 단계들을 남겨둡니다. 급한 순서대로예요.
- 맵 에디터 — 배열 손편집은 금방 한계가 와요. Tiled 라는 무료 에디터로 맵을 그리고,
pytmx라이브러리로 읽어오는 조합이 표준입니다. 2편의 배열 자리에 그대로 꽂혀요. 가장 급한 항목이라 바로 다음 7편에서 다룹니다. - UI 와 메뉴 — 유닛 정보창, 행동 메뉴, 전투 예측창. 5편의 상태머신에 상태를 추가하는 연습으로 최적입니다. 8편에서 다룹니다.
- 사운드 —
pygame.mixer로 BGM 과 효과음. 타격음 하나만 넣어도 게임의 체감이 확 달라져요. 9편에서 다룹니다. - 연출 — 데미지 숫자 팝업, 화면 흔들림, 페이드 전환. 전부 델타타임 응용입니다. 10편에서 다룹니다.
- 배포 —
pyinstaller로 실행 파일 묶기. 친구에게 보내보는 순간 동기부여가 다시 차올라요. - 엔진 이전 — 규모가 커지면 Godot 을 추천합니다. 이 시리즈의 개념(씬 트리=상태머신, TileMap 노드=2편, AnimationPlayer=3편)이 이름만 바뀐 채 그대로 나와서, 학습이 아니라 번역에 가까울 거예요.
✅ 가장 중요한 조언은 이거예요 — 처음부터 20챕터짜리 대작을 만들지 마세요. 맵 하나, 아군 셋, 적 다섯, 승리 조건 하나인 “1챕터 게임”을 끝까지 완성해보는 게, 미완성 대작 열 개보다 백 배 많이 가르쳐줍니다.
이 시리즈의 코드 조각들은 전부 이어 붙이면 돌아가도록 짰으니, 꼭 직접 실행하고 숫자를 바꿔보면서 자기 것으로 만드시길 바랍니다.
일단 오늘은 여기까지….. 다음 글에서는 로드맵의 1번, Tiled 맵 에디터를 정리해볼게요.
← 이전 글: (5/10) 2D SRPG 만들기 선행학습 — 턴제 전투와 상태머신 | 다음 글 →: (7/10) 2D SRPG 만들기 선행학습 — Tiled 맵 에디터로 전장 만들기