extends Node ## 저장/불러오기, 자동 저장, 오프라인 보상, 창 상태 보존. ## 저장 파일은 user:// 아래에 있다 (Windows: %APPDATA%\Godot\app_userdata\Blub Blub\). signal offline_earned(amount: float, seconds: int) signal saved const SAVE_PATH := "user://save.json" const BACKUP_PATH := "user://save.bak.json" const SAVE_VERSION := 1 const AUTOSAVE_INTERVAL := 30.0 ## 자리를 비운 동안 최대 8시간까지만, 그것도 절반 효율로 쳐준다. const OFFLINE_CAP_SECONDS := 8 * 3600 const OFFLINE_RATE := 0.5 ## 이보다 짧게 비운 건 "오프라인"으로 안 친다(창을 잠깐 껐다 켠 경우). const OFFLINE_MIN_SECONDS := 60 ## 창 위치·표시 옵션. main.gd가 읽고 쓴다. var window_state := { "x": -1, "y": -1, "always_on_top": true, "click_through": false, } ## 테스트처럼 실제 저장 파일을 건드리면 안 되는 상황에서 끈다. ## 끄지 않으면 스모크 테스트가 종료될 때 초기화된 상태를 저장해 진행도를 날린다. var persistence_enabled := true var _autosave_timer := 0.0 var _saved_on_exit := false func _ready() -> void: if persistence_enabled: load_game() func _process(delta: float) -> void: if not persistence_enabled: return _autosave_timer += delta if _autosave_timer >= AUTOSAVE_INTERVAL: _autosave_timer = 0.0 save_game() func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: if persistence_enabled and not _saved_on_exit: _saved_on_exit = true save_game() func save_game() -> void: if not persistence_enabled: return var payload := { "version": SAVE_VERSION, "saved_at": int(Time.get_unix_time_from_system()), "state": GameState.to_dict(), "window": window_state, } # 기존 저장본을 백업해두고 덮어쓴다. 쓰다가 죽어도 한 세대는 남는다. if FileAccess.file_exists(SAVE_PATH): var old := FileAccess.get_file_as_string(SAVE_PATH) if not old.is_empty(): var bak := FileAccess.open(BACKUP_PATH, FileAccess.WRITE) if bak: bak.store_string(old) bak.close() var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if f == null: push_error("저장 실패: %s" % error_string(FileAccess.get_open_error())) return f.store_string(JSON.stringify(payload, "\t")) f.close() saved.emit() func load_game() -> void: var data := _read(SAVE_PATH) if data.is_empty(): data = _read(BACKUP_PATH) if data.is_empty(): return GameState.from_dict(data.get("state", {})) var win: Dictionary = data.get("window", {}) for key in window_state: if win.has(key): window_state[key] = win[key] _grant_offline(int(data.get("saved_at", 0))) func _read(path: String) -> Dictionary: if not FileAccess.file_exists(path): return {} var text := FileAccess.get_file_as_string(path) if text.is_empty(): return {} var parsed: Variant = JSON.parse_string(text) if typeof(parsed) != TYPE_DICTIONARY: push_warning("저장 파일을 읽을 수 없습니다: %s" % path) return {} return parsed ## 마지막 저장 이후 흐른 시간만큼 생산량을 되돌려준다. func _grant_offline(saved_at: int) -> void: if saved_at <= 0 or GameState.bps <= 0.0: return var now := int(Time.get_unix_time_from_system()) var elapsed := now - saved_at if elapsed < OFFLINE_MIN_SECONDS: return var counted: int = mini(elapsed, OFFLINE_CAP_SECONDS) var amount := GameState.bps * counted * OFFLINE_RATE if amount <= 0.0: return GameState.bubbles += amount GameState.total_bubbles += amount offline_earned.emit(amount, elapsed) func wipe() -> void: for path in [SAVE_PATH, BACKUP_PATH]: if FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) GameState.reset()