~2 мин чтения

Save / Load — сохранение прогресса

FileAccess, ConfigFile, ResourceSaver, JSON, encryption, user:// пути.

В Godot есть несколько встроенных способов сохранять прогресс. Каждый подходит под свою задачу.

Пути и user:// schema

В Godot для всех пользовательских данных есть специальный путь — user://. На разных платформах он указывает в правильное место:

  • Windows: %APPDATA%\Godot\app_userdata\[name]\
  • macOS: ~/Library/Application Support/Godot/app_userdata/[name]/
  • Linux: ~/.local/share/godot/app_userdata/[name]/
  • Android: /data/data/com.example.app/files/
  • iOS: Documents/
  • Web: IndexedDB через виртуальную ФС

Главное правило: никогда не пишите в res:// (это read-only после билда). Всё пользовательское идёт в user://.

Веб

localStorage — для primitive (как Godot ConfigFile). IndexedDB — для большого структурированного (как Godot FileAccess + JSON).

Unity

ConfigFile — для настроек

ConfigFile сохраняет в INI-формате (как .ini-файлы Windows). Идеально для player-config’а: громкость, разрешение, бинды клавиш.

extends Node

const SETTINGS_PATH := "user://settings.cfg"

func save_settings(master_volume: float, fullscreen: bool, player_name: String) -> void:
    var config := ConfigFile.new()
    config.set_value("audio", "master_volume", master_volume)
    config.set_value("video", "fullscreen", fullscreen)
    config.set_value("player", "name", player_name)
    config.save(SETTINGS_PATH)

func load_settings() -> Dictionary:
    var config := ConfigFile.new()
    var err := config.load(SETTINGS_PATH)
    if err != OK:
        return {}  # файл ещё не существует
    return {
        "master_volume": config.get_value("audio", "master_volume", 1.0),  # default 1.0
        "fullscreen": config.get_value("video", "fullscreen", false),
        "player_name": config.get_value("player", "name", "Player"),
    }

На диске получится:

[audio]
master_volume=0.8

[video]
fullscreen=true

[player]
name="Konstantin"

Это читаемо, версионируемо, легко редактируется руками — идеально для настроек, где безопасность не важна.

FileAccess — для произвольных файлов

Для структурированных сейвов (с массивами, словарями, custom data) — FileAccess + JSON:

extends Node

const SAVE_PATH := "user://save.json"

func save_game(state: Dictionary) -> Error:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        return FileAccess.get_open_error()
    file.store_string(JSON.stringify(state, "\t"))  # \t — pretty-print indent
    return OK

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}

    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_error("Failed to open save file: %s" % FileAccess.get_open_error())
        return {}

    var content := file.get_as_text()
    var parsed = JSON.parse_string(content)
    return parsed if parsed != null else {}

Использование:

var state := {
    "version": 1,
    "player": {
        "name": "Konstantin",
        "level": 5,
        "hp": 80,
        "position": [10.5, 2.0, -3.2],  # Vector3 не JSON-сериализуем напрямую
    },
    "inventory": ["sword", "potion", "potion"],
    "play_time_seconds": 3600.5,
    "active_quests": ["q_main_01", "q_side_03"],
}
save_game(state)

# Позже:
var loaded := load_game()
print(loaded.player.name)
Vector3 → Array вручную

JSON.stringify не сериализует Godot-типы (Vector3, Color, Transform3D). Конвертируйте в массивы:

var pos := player.global_position
state.player.position = [pos.x, pos.y, pos.z]

# При загрузке:
player.global_position = Vector3(loaded.player.position[0],
                                  loaded.player.position[1],
                                  loaded.player.position[2])

ResourceSaver / ResourceLoader — нативно для Godot Resource

Если ваше состояние представимо как Resource-подкласс, можно использовать встроенную сериализацию:

# save_data.gd
class_name SaveData extends Resource

@export var player_name: String = "Player"
@export var level: int = 1
@export var hp: int = 100
@export var position: Vector3 = Vector3.ZERO
@export var inventory: Array[String] = []
# save_system.gd
const SAVE_PATH := "user://save.tres"

func save_game(data: SaveData) -> void:
    ResourceSaver.save(data, SAVE_PATH)

func load_game() -> SaveData:
    if not ResourceLoader.exists(SAVE_PATH):
        return SaveData.new()
    return load(SAVE_PATH) as SaveData

Плюсы:

  • Vector3, Color, и другие Godot-типы сериализуются автоматически.
  • Файл человекочитаемый (.tres).
  • Можно открывать в Godot Editor для отладки.

Минусы:

  • Жёсткая привязка к structure — миграция между версиями требует осторожности.
  • Безопасность нулевая (файл легко правится).

Бинарный формат — .res

Если хотите компактный и быстрый формат:

ResourceSaver.save(data, "user://save.res", ResourceSaver.FLAG_COMPRESS)

.res — бинарный (быстрее парсится), плюс FLAG_COMPRESS — компрессия zstd. Это в десятки раз компактнее JSON для больших сейвов.

Шифрование

Простой XOR

const KEY := 0x5A

func encrypt_xor(text: String) -> PackedByteArray:
    var bytes := text.to_utf8_buffer()
    for i in bytes.size():
        bytes[i] ^= KEY
    return bytes

func decrypt_xor(bytes: PackedByteArray) -> String:
    for i in bytes.size():
        bytes[i] ^= KEY
    return bytes.get_string_from_utf8()

AES через FileAccess (встроенный в Godot)

Godot имеет встроенный FileAccess.open_encrypted_with_pass:

func save_encrypted(state: Dictionary, password: String) -> void:
    var file := FileAccess.open_encrypted_with_pass(
        "user://save.dat", FileAccess.WRITE, password)
    file.store_string(JSON.stringify(state))

func load_encrypted(password: String) -> Dictionary:
    if not FileAccess.file_exists("user://save.dat"):
        return {}
    var file := FileAccess.open_encrypted_with_pass(
        "user://save.dat", FileAccess.READ, password)
    if file == null:
        push_warning("Wrong password or corrupted file")
        return {}
    var content := file.get_as_text()
    return JSON.parse_string(content)

⚠️ Как и в Unity: пароль в коде → reverse-engineering найдёт. Это защита от казуальных читеров, не более.

Версионирование сейвов

const CURRENT_VERSION := 3

func load_game() -> Dictionary:
    var data := _read_save()
    if data.is_empty():
        return _default_state()

    var version: int = data.get("version", 1)
    if version < CURRENT_VERSION:
        data = _migrate(data, version)

    return data

func _migrate(data: Dictionary, from_version: int) -> Dictionary:
    if from_version == 1:
        # v1 → v2: добавили inventory
        data.inventory = []
        from_version = 2

    if from_version == 2:
        # v2 → v3: переименовали поле
        data.player_level = data.get("level", 1)
        data.erase("level")
        from_version = 3

    data.version = CURRENT_VERSION
    return data

Версионируйте с самого начала. Добавить поле version после релиза — больно.

Async-сохранение через WorkerThreadPool

Если сейв большой и стопорит main thread:

extends Node

var _save_task_id: int = -1

func save_async(state: Dictionary) -> void:
    var json := JSON.stringify(state)
    _save_task_id = WorkerThreadPool.add_task(_write_to_disk.bind(json))

func _write_to_disk(json: String) -> void:
    var file := FileAccess.open("user://save.json", FileAccess.WRITE)
    file.store_string(json)

func _process(_delta: float) -> void:
    if _save_task_id != -1 and WorkerThreadPool.is_task_completed(_save_task_id):
        WorkerThreadPool.wait_for_task_completion(_save_task_id)
        _save_task_id = -1
        print("Save complete")

См. главу про Threading для деталей.

Auto-save паттерн

extends Node

@export var interval_seconds: float = 60.0
@onready var _timer := Timer.new()

func _ready() -> void:
    _timer.wait_time = interval_seconds
    _timer.timeout.connect(_on_autosave)
    add_child(_timer)
    _timer.start()

func _on_autosave() -> void:
    var state := GameState.capture_state()
    SaveSystem.save_game(state)
    # show toast "💾 Сохранено"

func _notification(what: int) -> void:
    if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_APPLICATION_PAUSED:
        _on_autosave()
        get_tree().quit()

NOTIFICATION_APPLICATION_PAUSED срабатывает на mobile при сворачивании — гарантированно успеваем сохранить.

Cloud Saves

Godot core не имеет встроенного Cloud Save. Решения:

  • Steam Cloud — через GodotSteam модуль (community).
  • Google Play Games Services — через AssetLib плагин.
  • Custom backend — пишете API → HTTPRequest узел общается с сервером.

Подводные камни

  1. res:// — read-only в билде. Только user://.
  2. Vector3 и др. Godot-типы — НЕ JSON. Конвертируйте в массивы или используйте Resource.
  3. JSON.parse_string возвращает null при ошибке. Всегда проверяйте.
  4. Тестируйте миграцию — добавили поле → проверьте старый сейв.
  5. WebGL — IndexedDB лимиты. Браузер может ограничить ваш storage (~50MB+). Большие сохранения дробите.