reckfellENGINE

Снимок уровня: ID, данные и ссылки

Полный пример восстановления нескольких объектов без частичного применения ошибочного файла.

На этой странице

Что сохраняет пример

Положение, здоровье и одну ссылку между существующими объектами уровня. Снимок содержит format, level и записи id, position, health, link. Координаты мировые, позиция — массив из трёх чисел. ID door и switch назначаются в редакторе и сохраняются между запусками.

Не используйте GetInstanceId() и путь в иерархии как постоянный ID: первый меняется между процессами, второй — при перестройке уровня. Не задавайте один и тот же ID всем экземплярам префаба.

Подготовить уровень

  1. Создайте Entity3D с WorldDemo.cs. Под ней — две Entity3D с именами Door и Switch, обе со скриптом SavedActor.cs.
  2. У Door задайте SaveId = door, у Switch — SaveId = switch. Назначьте разные позиции и Health в диапазоне 0…100. Для наблюдения добавьте меши и камеру либо смотрите свойства в Remote Inspector.
  3. Добавьте WorldSnapshot.cs и FileDemo.cs в проект. Не прикрепляйте FileDemo к Entity этого уровня: здесь нужны только его статические методы.
  4. В Input Map создайте save_game на F2 и load_game на F3. Соберите проект, запустите уровень, нажмите F2. Ожидается Save world: Ok.
  5. Измените положение и здоровье объектов через Remote Inspector, затем нажмите F3. Данные восстановятся, появится Load world: Ok. Остановите игру и запустите снова: F3 должен восстановить тот же слот.

Код задаёт исходную ссылку Switch.LinkedActor → Door. При загрузке ссылка восстанавливается по ID, а не сохраняется как адрес C#-объекта.

SavedActor.cs
C#
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
C#
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
C#
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 и все ссылки должны соответствовать текущему контракту
Объект вернулся не тудаВ этом примере координаты мировые; не применяйте их как локальные
Поведение сразу перезаписывает загруженные значенияПриостановите его на время восстановления и обновите состояние системы после загрузки
Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.