21강게임 상태 관리와 저장
씬 전환, 오토로드, 세이브 파일.
- 오토로드(싱글톤) 로 전역 상태를 관리한다.
- 씬 전환과 로딩 화면을 만든다.
user://에 저장하고 불러온다.
씬을 넘나들면 데이터가 사라진다#
지금까지는 씬 하나 안에서 놀았습니다. 실제 게임은 이렇게 흘러갑니다.
타이틀 → 스테이지 1 → 결과 → 스테이지 2 → ... → 엔딩씬을 바꾸면 이전 씬의 모든 노드가 사라집니다. 점수도, 인벤토리도 같이 사라지죠. 그래서 씬 밖에 사는 무언가가 필요합니다.
오토로드 — 항상 살아 있는 노드#
게임이 시작될 때 자동으로 만들어져서 끝날 때까지 절대 사라지지 않는 노드.
씬 트리의 최상단(/root) 바로 아래에 붙습니다.
어느 스크립트에서든 이름만으로 접근할 수 있습니다.
만들기#
res://autoload/game.gd스크립트 작성프로젝트 → 프로젝트 설정 → 전역(Autoload)탭- 파일 지정, 노드 이름을
Game으로, 활성화 체크
# res://autoload/game.gd
extends Node
signal score_changed(value: int)
signal gold_changed(value: int)
var score := 0:
set(v):
score = v
score_changed.emit(score)
var gold := 0:
set(v):
gold = v
gold_changed.emit(gold)
var current_stage := 1
var best_score := 0
func add_score(v: int) -> void:
score += v
func add_gold(v: int) -> void:
gold += v
func reset_run() -> void:
score = 0
gold = 0
current_stage = 1이제 어디서든 이렇게 씁니다.
Game.add_score(100)
print(Game.gold)
Game.score_changed.connect(_on_score_changed)편하다고 모든 것을 오토로드에 넣으면, 모든 코드가 모든 것에 접근 가능해집니다. 그러면 "이 값을 누가 바꿨지?"를 추적할 수 없게 됩니다.
오토로드에 넣을 만한 것 (보통 3~6개면 충분)
Game— 진행 상황, 점수, 설정SceneManager— 씬 전환과 로딩Audio— BGM/SFX 재생Save— 저장/불러오기Events— 전역 이벤트 버스
넣으면 안 되는 것
- 플레이어 노드 자체 (씬에 있어야 함)
- 특정 스테이지에서만 쓰는 값
이벤트 버스 패턴
시그널만 모아놓은 오토로드를 하나 두면, 서로 모르는 시스템끼리 통신할 수 있습니다.
# res://autoload/events.gd
extends Node
signal enemy_died(enemy_type: String, pos: Vector2)
signal player_leveled_up(level: int)
signal boss_spawned# 적이 죽을 때
Events.enemy_died.emit(data.display_name, global_position)
# 업적 시스템 (적을 전혀 모른다)
func _ready():
Events.enemy_died.connect(_count_kill)장점 — 완전히 분리됨. 시스템을 통째로 빼도 나머지가 동작. 단점 — 누가 듣고 있는지 코드로 추적이 어려움. 시그널 이름 관리가 중요.
씬 전환#
가장 단순한 방법#
get_tree().change_scene_to_file("res://scenes/stage_1.tscn")한 줄이지만 문제가 있습니다. 큰 씬이면 몇 초간 화면이 멈춥니다.
페이드 + 로딩이 있는 씬 전환#
# res://autoload/scene_manager.gd
extends CanvasLayer
@onready var fade: ColorRect = $Fade
var _loading_path := ""
func _ready() -> void:
layer = 128 # 항상 맨 위
fade.color = Color(0, 0, 0, 0)
fade.mouse_filter = Control.MOUSE_FILTER_IGNORE
fade.visible = false
func goto(path: String, fade_time := 0.3) -> void:
if _loading_path != "":
return # 전환 중이면 무시
_loading_path = path
# 1) 어두워지기
fade.visible = true
var tw := create_tween()
tw.tween_property(fade, "color:a", 1.0, fade_time)
await tw.finished
# 2) 백그라운드 로딩 시작
ResourceLoader.load_threaded_request(path)
while true:
var progress: Array = []
var st := ResourceLoader.load_threaded_get_status(
path, progress)
if st == ResourceLoader.THREAD_LOAD_LOADED:
break
if st == ResourceLoader.THREAD_LOAD_FAILED:
push_error("씬 로드 실패: " + path)
_loading_path = ""
return
# progress[0] 이 0.0~1.0 진행률
await get_tree().process_frame
# 3) 교체
var packed: PackedScene = \
ResourceLoader.load_threaded_get(path)
get_tree().change_scene_to_packed(packed)
await get_tree().process_frame
# 4) 밝아지기
var tw2 := create_tween()
tw2.tween_property(fade, "color:a", 0.0, fade_time)
await tw2.finished
fade.visible = false
_loading_path = ""load() 는 다 끝날 때까지 게임 전체를 멈춥니다. 큰 씬이면 몇 초간 응답이 없습니다.
load_threaded_request() 는 다른 스레드에서 읽으면서 게임은 계속 돌게 합니다.
그동안 로딩 바를 움직이거나, 팁을 보여주거나, 애니메이션을 돌릴 수 있습니다.
주의 — 웹 빌드(26강)에서는 스레드가 없을 수 있습니다. 그때도 이 API는 동작하지만 실제로는 동기적으로 처리됩니다.
저장하고 불러오기#
어디에 저장하나#
res://— 프로젝트 파일. 배포하면 읽기 전용입니다.user://— 사용자 데이터 폴더. 여기에만 쓸 수 있습니다.
실제 경로:
| OS | 위치 |
|---|---|
| Windows | %APPDATA%\Godot\app_userdata\프로젝트명\ |
| Linux | ~/.local/share/godot/app_userdata/프로젝트명/ |
| 웹 | 브라우저의 IndexedDB |
에디터에서 프로젝트 → 사용자 데이터 폴더 열기 로 바로 볼 수 있습니다.
JSON으로 저장하기#
# res://autoload/save_system.gd
extends Node
const PATH := "user://save.json"
const VERSION := 2
func save_game() -> bool:
var data := {
"version": VERSION,
"saved_at": Time.get_unix_time_from_system(),
"score": Game.score,
"gold": Game.gold,
"stage": Game.current_stage,
"best_score": Game.best_score,
"unlocked": Game.unlocked_items,
}
var f := FileAccess.open(PATH, FileAccess.WRITE)
if f == null:
push_error("저장 실패: %d" % FileAccess.get_open_error())
return false
f.store_string(JSON.stringify(data, "\t"))
return true
func load_game() -> bool:
if not FileAccess.file_exists(PATH):
return false
var f := FileAccess.open(PATH, FileAccess.READ)
if f == null:
return false
var parsed = JSON.parse_string(f.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
push_error("세이브 파일이 손상되었습니다")
return false
var d: Dictionary = parsed
var v: int = d.get("version", 1)
if v < VERSION:
d = _migrate(d, v)
Game.score = d.get("score", 0)
Game.gold = d.get("gold", 0)
Game.current_stage = d.get("stage", 1)
Game.best_score = d.get("best_score", 0)
return true
func _migrate(d: Dictionary, from: int) -> Dictionary:
# 버전 1에는 gold가 없었다 → 기본값을 넣어준다.
if from < 2:
d["gold"] = d.get("gold", 0)
d["version"] = 2
return d게임을 업데이트하면 저장 형식이 바뀝니다. 버전 번호가 없으면 옛날 세이브를 읽다가 게임이 터집니다.
version 필드 하나로 "이 세이브는 구버전이니 변환해서 읽자"가 가능해집니다.
출시 후에 넣으려면 이미 늦습니다. 처음부터 넣으세요.
같은 이유로 모든 읽기에 d.get("키", 기본값) 를 쓰세요.
키가 없어도 안 터집니다.
저장 방식 세 가지 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| JSON | 사람이 읽고 고칠 수 있음, 디버깅 쉬움 | 용량 큼, 유저가 조작 가능 |
바이너리 (store_var) |
빠르고 작음 | 디버깅 어려움 |
| ConfigFile | 설정값에 최적, ini 형식 | 중첩 구조에 부적합 |
조작 방지가 필요하다면? 싱글플레이 게임에서 완전한 방지는 불가능합니다(파일이 유저 PC에 있으니까요). 체크섬을 넣어 "조작했다"를 탐지하는 정도가 현실적입니다. 정말 중요하면 서버에 저장해야 합니다 (P6).
설정은 ConfigFile로#
const CFG := "user://settings.cfg"
func save_settings() -> void:
var c := ConfigFile.new()
c.set_value("audio", "master", 0.8)
c.set_value("audio", "bgm", 0.6)
c.set_value("video", "fullscreen", false)
c.set_value("game", "shake_scale", 1.0)
c.save(CFG)
func load_settings() -> void:
var c := ConfigFile.new()
if c.load(CFG) != OK:
return
var master: float = c.get_value("audio", "master", 1.0)
AudioServer.set_bus_volume_db(0, linear_to_db(master))게임 흐름 만들기#
res://scenes/
├─ title.tscn 타이틀 화면
├─ stage.tscn 실제 게임
├─ result.tscn 결과 화면
└─ main_menu.tscn 설정, 도감# 타이틀에서
func _on_start_pressed() -> void:
Game.reset_run()
SceneManager.goto("res://scenes/stage.tscn")
# 게임 오버 시
func _on_player_died() -> void:
Game.best_score = maxi(Game.best_score, Game.score)
SaveSystem.save_game()
await get_tree().create_timer(1.2).timeout
SceneManager.goto("res://scenes/result.tscn")
# 결과 화면에서
func _on_retry_pressed() -> void:
Game.reset_run()
SceneManager.goto("res://scenes/stage.tscn")Game오토로드를 만들고 점수를 관리하세요.- 타이틀 → 게임 → 결과 3개 씬을 만들고 이동시키세요.
- 최고 점수를 저장하고, 게임을 완전히 껐다 켠 뒤에도 남아 있는지 확인하세요.
- 세이브 파일을 텍스트 편집기로 열어 점수를 999999로 고쳐보세요. 반영됩니까?
- 세이브 파일 내용을 일부러 망가뜨려 보세요(중괄호 하나 지우기). 게임이 터집니까, 아니면 기본값으로 시작합니까? 후자여야 합니다.
- 개발자 — 오토로드는 가능한 적게 두세요. 그리고 오토로드끼리 서로 참조하면 초기화 순서 문제가 생깁니다. 프로젝트 설정의 등록 순서가 곧 로딩 순서입니다.
- 기획자 — "무엇이 저장되어야 하는가"의 목록을 초기에 정해주세요. 나중에 추가하면 세이브 버전 마이그레이션이 필요해집니다. "진행도 / 재화 / 해금 / 설정 / 통계" 다섯 갈래로 나눠 적으면 빠짐이 적습니다.
- 디자이너 — 로딩 화면은 팁이나 일러스트를 보여줄 기회입니다. 또 페이드 시간(0.3초)이 너무 길면 답답하고 짧으면 뚝 끊깁니다. 0.2~0.4초 사이에서 직접 맞춰보세요.
"Game이 없다고 나온다" → 오토로드 등록을 안 했거나, 이름을 다르게 지었습니다. 프로젝트 설정 → 전역 탭에서 활성화 체크도 확인하세요.
"씬을 바꿨더니 오류가 쏟아진다"
→ change_scene_to_file() 은 현재 프레임이 끝난 뒤 교체합니다.
그 뒤에 이전 씬의 노드를 참조하면 터집니다. await get_tree().process_frame 을 넣으세요.
"저장이 안 된다"
→ res:// 에 쓰려고 했습니다. user:// 로 바꾸세요.
"웹에서 저장이 안 된다"
→ 브라우저 IndexedDB에 저장되므로 시크릿 모드나 사이트 데이터 삭제 시 사라집니다.
또 저장 직후 OS.get_data_dir() 동기화가 필요할 수 있습니다.
- 오토로드로 전역 상태를 관리한다
- 페이드가 있는 씬 전환을 만들었다
-
user://에 저장하고 불러온다 - 세이브에 버전 번호를 넣었다
- 손상된 세이브에도 게임이 안 터진다
P5 정리 — 이제 처음부터 끝까지 도는 완결된 게임이 있습니다.
다음 강의 — P6로 갑니다. 마지막 큰 산입니다. 둘이서 같이 노는 게임은 왜 어려운가?