Снимок уровня: ID, данные и ссылки
Полный пример восстановления нескольких объектов без частичного применения ошибочного файла.
Что сохраняет пример
Положение, здоровье и одну ссылку между существующими объектами уровня. Снимок содержит format, level и записи id, position, health, link. Координаты мировые, позиция — массив из трёх чисел. ID door и switch назначаются в редакторе и сохраняются между запусками.
Не используйте GetInstanceId() и путь в иерархии как постоянный ID: первый меняется между процессами, второй — при перестройке уровня. Не задавайте один и тот же ID всем экземплярам префаба.
Подготовить уровень
- Создайте
Entity3DсWorldDemo.cs. Под ней — двеEntity3Dс именамиDoorиSwitch, обе со скриптомSavedActor.cs. - У
DoorзадайтеSaveId = door, уSwitch—SaveId = switch. Назначьте разные позиции иHealthв диапазоне 0…100. Для наблюдения добавьте меши и камеру либо смотрите свойства в Remote Inspector. - Добавьте
WorldSnapshot.csи FileDemo.cs в проект. Не прикрепляйтеFileDemoк Entity этого уровня: здесь нужны только его статические методы. - В Input Map создайте
save_gameна F2 иload_gameна F3. Соберите проект, запустите уровень, нажмите F2. ОжидаетсяSave world: Ok. - Измените положение и здоровье объектов через Remote Inspector, затем нажмите F3. Данные восстановятся, появится
Load world: Ok. Остановите игру и запустите снова: F3 должен восстановить тот же слот.
Код задаёт исходную ссылку Switch.LinkedActor → Door. При загрузке ссылка восстанавливается по ID, а не сохраняется как адрес C#-объекта.
SavedActor.cs
using Reckfell;
public partial class SavedActor : Entity3D
{
[Export] public string SaveId { get; set; } = "";
[Export] public int Health { get; set; } = 100;
public SavedActor? LinkedActor { get; set; }
}WorldSnapshot.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Reckfell;
public static class WorldSnapshot
{
private sealed class Snapshot
{
public required int Format { get; set; }
public required string Level { get; set; }
public required List<Record> Objects { get; set; }
}
private sealed class Record
{
public required string Id { get; set; }
public required float[] Position { get; set; }
public required int Health { get; set; }
public required string Link { get; set; }
}
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
private static bool ValidTarget(SavedActor actor) =>
ReckfellObject.IsInstanceValid(actor) && actor.IsInsideTree() &&
!actor.IsQueuedForDeletion() && !string.IsNullOrWhiteSpace(actor.SaveId) && actor.SaveId.Length <= 64;
private static bool ValidActor(SavedActor actor) => ValidTarget(actor) &&
actor.GlobalPosition.IsFinite() && actor.Health >= 0 && actor.Health <= 100;
private static int Depth(Entity entity)
{
int depth = 0;
for (Entity? parent = entity.GetParent(); parent is not null; parent = parent.GetParent())
depth++;
return depth;
}
public static Error Capture(string level, IReadOnlyList<SavedActor> actors, out string json)
{
json = "";
if (string.IsNullOrWhiteSpace(level) || level.Length > 128 || actors.Count > 1000)
return Error.InvalidData;
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (SavedActor actor in actors)
if (!ValidActor(actor) || !ids.Add(actor.SaveId))
return Error.InvalidData;
var records = new List<Record>();
foreach (SavedActor actor in actors)
{
string link = "";
if (actor.LinkedActor is not null)
{
if (!ValidActor(actor.LinkedActor) || !actors.Contains(actor.LinkedActor))
return Error.InvalidData;
link = actor.LinkedActor.SaveId;
}
Vector3 p = actor.GlobalPosition;
records.Add(new Record { Id = actor.SaveId, Position = [p.X, p.Y, p.Z], Health = actor.Health, Link = link });
}
json = JsonSerializer.Serialize(new Snapshot { Format = 1, Level = level, Objects = records }, Options);
if (json.Length > 1024 * 1024)
{
json = "";
return Error.InvalidData;
}
return Error.Ok;
}
public static Error Apply(string json, string level, IReadOnlyList<SavedActor> actors)
{
if (json.Length > 1024 * 1024 || actors.Count > 1000 || string.IsNullOrWhiteSpace(level))
return Error.InvalidData;
Snapshot? snapshot;
try
{
snapshot = JsonSerializer.Deserialize<Snapshot>(json, Options);
}
catch (JsonException)
{
return Error.InvalidData;
}
if (snapshot is null || snapshot.Format != 1 || snapshot.Level != level ||
snapshot.Objects is null || snapshot.Objects.Count != actors.Count)
return Error.InvalidData;
var current = new Dictionary<string, SavedActor>(StringComparer.Ordinal);
foreach (SavedActor actor in actors)
if (!ValidTarget(actor) || !current.TryAdd(actor.SaveId, actor))
return Error.InvalidData;
var restored = new HashSet<string>(StringComparer.Ordinal);
foreach (Record record in snapshot.Objects)
{
if (record is null || string.IsNullOrWhiteSpace(record.Id) ||
!restored.Add(record.Id) || !current.ContainsKey(record.Id) ||
record.Position is null || record.Position.Length != 3 ||
record.Position.Any(v => !float.IsFinite(v)) ||
record.Health < 0 || record.Health > 100 || record.Link is null ||
(record.Link != "" && !current.ContainsKey(record.Link)))
return Error.InvalidData;
}
foreach (Record record in snapshot.Objects.OrderBy(record => Depth(current[record.Id])))
{
SavedActor actor = current[record.Id];
actor.GlobalPosition = new Vector3(record.Position[0], record.Position[1], record.Position[2]);
actor.Health = record.Health;
actor.ResetPhysicsInterpolation();
}
foreach (Record record in snapshot.Objects)
current[record.Id].LinkedActor = record.Link == "" ? null : current[record.Link];
return Error.Ok;
}
}WorldDemo.cs
using Reckfell;
public partial class WorldDemo : Entity3D
{
private const string Path = "user://io-demo/world.json";
private const string Level = "harbor";
private SavedActor[] _actors = [];
public override void _Ready()
{
_actors = [GetEntity<SavedActor>("Door"), GetEntity<SavedActor>("Switch")];
_actors[1].LinkedActor = _actors[0];
}
public Error Save()
{
Error error = WorldSnapshot.Capture(Level, _actors, out string json);
return error == Error.Ok ? FileDemo.WriteText(Path, json) : error;
}
public Error Load()
{
Error error = FileDemo.ReadText(Path, out string json);
return error == Error.Ok ? WorldSnapshot.Apply(json, Level, _actors) : error;
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed("save_game"))
ReckfellApi.Print($"Save world: {Save()}");
else if (@event.IsActionPressed("load_game"))
ReckfellApi.Print($"Load world: {Load()}");
else
return;
GetViewport().SetInputAsHandled();
}
}Проверить до изменения мира
Apply() сначала разбирает весь JSON: версию, уровень, полный набор уникальных ID, три конечные координаты, диапазон здоровья и существование каждой ссылки. Только после этого меняются значения: сначала родители, затем вложенные объекты, чтобы мировые координаты не сдвинулись повторно. Отдельным проходом восстанавливаются ссылки. Неизвестный ID, неподдерживаемая схема и повреждённое поле возвращают InvalidData без частично изменённого уровня.
Список должен содержать готовые, не удаляемые объекты в дереве. Вызывайте методы из главного потока, пока игровая логика не меняет состав объектов. Ограничения примера: 1000 объектов, ID до 64 символов, имя уровня до 128 символов при записи, JSON до 1 048 576 символов. Файловый помощник отдельно ограничивает чтение 1 МиБ байтов UTF-8. Сериализация — System.Text.Json, поэтому названия свойств и обязательность полей заданы кодом, а не неявными преобразованиями Variant.
Что добавить для своей игры
Здесь фиксированный набор существующих объектов: пример не создаёт и не удаляет динамические сущности, не переключает уровень, не сохраняет инвентарь, физические скорости, задания и поворот. Это явные части вашей схемы. Для динамических объектов добавьте разрешённый kind из реестра префабов; создайте все недостающие экземпляры до восстановления ссылок и удалите объекты, которых нет в снимке. Не загружайте произвольный путь префаба из файла без проверки по реестру.
Для нескольких уровней сначала проверьте файл и его level, загрузите нужный уровень и дождитесь SceneChanged, затем восстановите объекты. Перенос физического персонажа также требует сброса интерполяции и согласованной обработки скорости. PackedEntity.Pack() не заменяет схему прогресса: он упаковывает сохраняемые свойства иерархии, не произвольное состояние всех игровых систем.
WorldDemo пишет файл напрямую. Для рабочего слота используйте запись с резервной копией. При ошибке чтения покажите ошибку и оставьте старый слот доступным; не вызывайте Save() автоматически с начальными данными. Изменение схемы требует отдельной миграции, а не смены версии движка.
Диагностика
| Результат | Что исправить |
|---|---|
FileNotFound | Сначала сохраните слот; сравните папку user:// |
InvalidData при сохранении | Пустой/повторный ID, неверное здоровье, неконечная позиция или ссылка за пределы списка |
InvalidData при загрузке | Версия, уровень, тип поля, набор ID и все ссылки должны соответствовать текущему контракту |
| Объект вернулся не туда | В этом примере координаты мировые; не применяйте их как локальные |
| Поведение сразу перезаписывает загруженные значения | Приостановите его на время восстановления и обновите состояние системы после загрузки |