2D Godot 2D 게임 기초
P5 구조와 데이터

19강데이터 주도 설계

코드는 주방 설비, Resource는 레시피 카드.

이 강의 목차 (6)
  1. 문제 — 숫자가 코드에 박혀 있다
  2. 해결 — 커스텀 Resource
  3. 스폰도 데이터로
  4. 데이터를 담는 세 가지 방법
  5. 데이터에 로직을 조금 넣기
  6. 실습 — 적 3종을 코드 없이 추가
이번 강의의 목표
  • 커스텀 Resource로 밸런싱 데이터를 코드에서 분리한다.
  • 적 3종을 코드 수정 없이 추가한다.
  • 데이터 파일 / JSON / 스프레드시트 중 무엇을 언제 쓸지 판단한다.

문제 — 숫자가 코드에 박혀 있다#

적을 만들다 보면 이렇게 됩니다.

gdscript
extends CharacterBody2D

func _ready():
	if enemy_type == "slime":
		hp = 30; speed = 60; damage = 5
	elif enemy_type == "bat":
		hp = 15; speed = 180; damage = 3
	elif enemy_type == "golem":
		hp = 200; speed = 30; damage = 20
	# ... 적 40종이 되면?

문제가 셋입니다.

  1. 기획자가 못 만진다. 숫자 하나 바꾸려면 개발자에게 요청해야 합니다.
  2. 적을 추가할 때마다 코드를 고칩니다. 그때마다 버그 위험이 생깁니다.
  3. 한눈에 안 보입니다. 40종의 밸런스를 비교하려면 코드를 스크롤해야 합니다.
주방과 레시피

코드는 주방 설비입니다. 불, 칼, 냄비 — 자주 안 바뀝니다. 데이터는 레시피 카드입니다. 재료와 양 — 매일 바뀝니다.

레시피를 바꾸려고 주방을 뜯어고치면 안 됩니다.

해결 — 커스텀 Resource#

Godot의 Resource데이터를 담는 파일입니다. 텍스처, 사운드, 씬이 모두 Resource이고, 직접 만들 수도 있습니다.

1) 데이터 정의하기#

res://data/enemy_data.gd

gdscript
class_name EnemyData
extends Resource

@export_group("기본")
@export var display_name := "슬라임"
@export var texture: Texture2D
@export var scale_mul := 1.0

@export_group("전투")
@export var max_hp := 30
@export var damage := 5
@export var attack_cooldown := 1.2

@export_group("이동")
@export var move_speed := 60.0
@export var accel := 400.0

@export_group("감지")
@export var sight_range := 300.0
@export var sight_angle := 90.0

@export_group("보상")
@export var exp_reward := 10
@export var gold_min := 1
@export var gold_max := 5
@export var drop_scene: PackedScene
class_name과 @export_group
  • class_name EnemyData — 이 스크립트를 새로운 타입으로 등록합니다. 이제 에디터의 "리소스 새로 만들기" 목록에 EnemyData가 나타납니다.
  • @export_group("전투") — 인스펙터에서 접히는 그룹을 만듭니다. 항목이 20개를 넘어가면 그룹이 없으면 못 씁니다.

2) 데이터 파일 만들기#

  1. 파일시스템 독에서 우클릭 → 새로 만들기 → 리소스
  2. EnemyData 검색해서 선택
  3. res://data/enemies/slime.tres 로 저장
  4. 인스펙터에서 값을 채웁니다 ← 여기가 핵심

같은 방식으로 bat.tres, golem.tres 를 만듭니다. 코드는 한 줄도 안 썼습니다.

.tres 와 .res
  • .tres — 텍스트 형식. git에서 변경 내역이 보입니다. 개발 중에는 이걸 쓰세요.
  • .res — 바이너리. 빠르고 작지만 diff가 안 보입니다.

팀 작업에서는 .tres가 정답입니다. "누가 슬라임 체력을 30에서 50으로 바꿨나"를 git 로그로 볼 수 있습니다.

3) 적 스크립트는 데이터를 읽기만#

gdscript
extends CharacterBody2D

@export var data: EnemyData

var hp: int
var _target: Node2D
var _cooldown := 0.0

@onready var spr: Sprite2D = $Sprite

func _ready() -> void:
	assert(data != null, "EnemyData가 비어 있습니다")
	hp = data.max_hp
	spr.texture = data.texture
	spr.scale = Vector2.ONE * data.scale_mul
	_target = get_tree().get_first_node_in_group("player")

func _physics_process(delta: float) -> void:
	_cooldown = maxf(_cooldown - delta, 0.0)
	if _target and _can_see():
		var dir := global_position.direction_to(
			_target.global_position)
		velocity = velocity.move_toward(
			dir * data.move_speed, data.accel * delta)
	else:
		velocity = velocity.move_toward(
			Vector2.ZERO, data.accel * delta)
	move_and_slide()

func take_damage(amount: int) -> void:
	hp -= amount
	if hp <= 0:
		_die()

func _die() -> void:
	Game.add_exp(data.exp_reward)
	Game.add_gold(randi_range(data.gold_min, data.gold_max))
	if data.drop_scene:
		var d := data.drop_scene.instantiate()
		d.global_position = global_position
		get_tree().current_scene.add_child(d)
	queue_free()
무엇이 달라졌나

enemy.tscn딱 하나입니다. 스테이지에 놓고 인스펙터의 Data 칸에 slime.tres / bat.tres / golem.tres 중 하나를 끌어다 놓으면 전혀 다른 적이 됩니다.

  • 적을 40종 추가해도 코드는 그대로
  • 기획자가 .tres 파일을 열어 직접 밸런스 조정
  • 값이 바뀌면 그 데이터를 쓰는 모든 적이 동시에 바뀜

스폰도 데이터로#

한 걸음 더 나가면 웨이브 구성도 데이터가 됩니다.

gdscript
class_name WaveData
extends Resource

@export var enemies: Array[EnemyData] = []
@export var counts: Array[int] = []
@export var interval := 0.8
@export var delay_before := 3.0
gdscript
func spawn_wave(wave: WaveData) -> void:
	await get_tree().create_timer(wave.delay_before).timeout
	for i in wave.enemies.size():
		for j in wave.counts[i]:
			var e := ENEMY_SCENE.instantiate()
			e.data = wave.enemies[i]
			e.global_position = _random_spawn_point()
			add_child(e)
			await get_tree().create_timer(wave.interval).timeout

이제 스테이지 난이도 곡선을 기획자가 직접 만듭니다.

데이터를 담는 세 가지 방법#

무엇을 언제 쓸까
방식 장점 단점 언제
Resource (.tres) 에디터에서 편집, 타입 안전, 텍스처·씬 참조 가능 항목이 많으면 비교가 어려움 대부분의 경우
JSON / CSV 스프레드시트에서 대량 편집, 외부 도구 연동 타입 검사 없음, 오타가 런타임 오류 항목 100개 이상
코드 상수 가장 빠름 기획자가 못 만짐 절대 안 바뀌는 값

JSON을 쓰는 경우의 최소 예:

gdscript
func load_enemies(path: String) -> Dictionary:
	var f := FileAccess.open(path, FileAccess.READ)
	if f == null:
		push_error("파일 없음: " + path)
		return {}
	var parsed = JSON.parse_string(f.get_as_text())
	if parsed == null:
		push_error("JSON 문법 오류: " + path)
		return {}
	return parsed
JSON은 오타를 잡아주지 않는다

"max_hp""maxhp" 로 잘못 쓰면 조용히 0이 됩니다. Resource 방식은 인스펙터에 칸이 있으므로 이런 실수가 원천적으로 불가능합니다.

JSON을 쓴다면 로딩 시 검증 코드를 반드시 넣으세요.

gdscript
const REQUIRED := ["id", "max_hp", "damage", "speed"]

func validate(d: Dictionary, name: String) -> bool:
	for key in REQUIRED:
		if not d.has(key):
			push_error("%s: '%s' 누락" % [name, key])
			return false
	return true
스프레드시트 워크플로

실무에서 흔한 구성입니다.

text
구글 스프레드시트 (기획자가 편집)
  ↓ CSV 내보내기
res://data/enemies.csv
  ↓ 에디터 플러그인 or 스크립트
res://data/enemies/*.tres  (자동 생성)
  ↓
게임

장점 — 기획자는 익숙한 표에서 40종을 한눈에 비교하며 작업. 단점 — 변환 단계가 하나 늘고, 그 스크립트를 누군가 관리해야 함.

항목이 30개를 넘어가면 이 방식이 이깁니다. 그 아래면 Resource 직접 편집이 빠릅니다.

데이터에 로직을 조금 넣기#

Resource에도 함수를 넣을 수 있습니다. 계산식을 데이터 쪽에 두면 편할 때가 있습니다.

gdscript
class_name EnemyData
extends Resource

@export var base_hp := 30
@export var hp_per_level := 8.0
@export var hp_curve: Curve  # 그래프로 편집 가능!

func hp_at(level: int) -> int:
	if hp_curve:
		return int(base_hp * hp_curve.sample(level / 50.0))
	return int(base_hp + hp_per_level * (level - 1))
Curve 리소스 — 그래프로 밸런싱

Curve 타입으로 선언하면 인스펙터에 드래그로 편집하는 그래프가 나옵니다. "레벨에 따른 체력 증가 곡선"을 수식이 아니라 눈으로 만들 수 있습니다.

기획자에게 이걸 열어주면 밸런싱 속도가 크게 달라집니다.

실습 — 적 3종을 코드 없이 추가#

이번 강의의 핵심 실습
  1. EnemyData 스크립트를 만듭니다.
  2. enemy.tscn 을 하나 만들고 위 스크립트를 붙입니다.
  3. slime.tres, bat.tres, golem.tres 를 만들어 값을 다르게 채웁니다.
슬라임 박쥐 골렘
max_hp 30 15 200
move_speed 60 180 30
damage 5 3 20
sight_range 200 400 150
exp_reward 10 8 60
  1. 스테이지에 enemy.tscn 을 6개 놓고, Data 칸에 세 가지를 섞어 지정합니다.
  2. 실행해서 세 종류가 다르게 행동하는지 확인합니다.

여기서 코드는 한 번만 썼습니다. 적 4번째, 5번째는 파일 복사 + 숫자 수정으로 끝납니다.

추가 실험
  1. golem.tresmove_speed를 200으로 바꾸고 실행해 보세요. 게임을 껐다 켜지 않아도 다시 실행하면 바로 반영됩니다.
  2. 새 적 ghost.tres 를 만들어 보세요. 코드를 고쳐야 합니까?
  3. EnemyData@export var is_flying := false 를 추가해 보세요. 기존 .tres 파일 3개는 어떻게 됩니까? (기본값이 들어갑니다 — 안 깨집니다)
Resource는 기본적으로 공유된다
gdscript
@export var data: EnemyData

같은 slime.tres를 쓰는 적 10마리는 같은 Resource 객체를 공유합니다. 그래서 한 마리가 data.max_hp = 1 로 바꾸면 10마리 전부 바뀝니다.

규칙 — 데이터는 읽기만 하고, 변하는 값(현재 hp 등)은 노드 쪽 변수에 두세요. 꼭 복사해야 한다면 data = data.duplicate() 를 씁니다.

  • 개발자 — "이 값이 나중에 바뀔까?"를 기준으로 판단하세요. 바뀔 것 같으면 처음부터 @export나 Resource로 빼는 게 결국 더 빠릅니다. 단, 모든 것을 데이터화하면 구조가 복잡해집니다. 균형이 필요합니다.
  • 기획자 — 이 회차가 여러분의 무기고입니다. 개발자에게 요청할 때 "이 값을 데이터로 빼주세요"라고 말하면 됩니다. 그다음부터 밸런싱은 요청 없이 직접 합니다. 반복 주기가 몇 배 빨라집니다.
  • 디자이너 — Resource에는 텍스처·사운드·씬도 담깁니다. 적의 그림을 바꾸는 것도 .tres 파일에서 이미지를 끌어다 놓으면 끝입니다. 개발자를 거치지 않아도 됩니다.
자주 나는 오류

"리소스 새로 만들기 목록에 내 타입이 안 보인다"class_name 을 안 썼거나, 스크립트를 저장한 뒤 에디터가 갱신되지 않았습니다. 프로젝트 → 프로젝트 다시 불러오기를 해보세요.

"data가 null이라고 나온다" → 씬 인스턴스의 Data 칸이 비어 있습니다. assert로 일찍 잡으세요.

"한 마리 체력을 깎았는데 전부 깎인다" → Resource를 직접 수정하고 있습니다. 노드 변수를 쓰세요.

"데이터를 바꿨는데 반영이 안 된다"_ready()에서 한 번만 읽으므로 실행 중 변경은 반영되지 않습니다. 에디터에서 저장 후 다시 실행하세요.

여기까지 확인
  • class_name + extends Resource로 데이터 타입을 만들었다
  • .tres 파일 3개로 적 3종을 만들었다
  • 적을 추가할 때 코드를 안 고쳐도 된다
  • Resource가 공유된다는 걸 안다
  • Resource / JSON / 상수를 언제 쓸지 판단할 수 있다

다음 강의 — 적은 준비됐습니다. 이제 적이 돌아다닐 무대를 만듭니다. 타일맵으로 레벨을 그립니다.