PAYATHON 2026

Godot 4 跨场景保留数据:持久节点与加密存档该如何选择?

技术老齐

建议:用 Autoload 保存本局状态

倒计时和背包数据如果只需在场景之间保留,建议增加一个 Autoload(自动加载)节点,将本局状态保存在内存中。现有的场景切换方式可以继续使用,通常不必重做整个游戏,也不需要读写加密存档。

保留完整玩家节点也能解决问题。不过,你目前只需要保留这两类数据,单独管理数据更容易接入。等需要支持退出游戏后继续游玩时,再考虑磁盘存档。

为什么 Autoload 适合这个需求

调用 get_tree().change_scene_to_file() 切换场景时,原来的当前场景会被移除并释放,其中的玩家、背包节点和 Timer 也会随之销毁。保存在这些节点上的数据也就无法延续到下一个场景。

Autoload 节点位于当前场景之外,不会随普通场景切换而销毁。场景树可以采用这样的结构:

/root
├── GameState          # Autoload,保存本局数据
└── 当前场景            # 切换时替换
    ├── Player
    └── UI

新场景重新创建玩家和界面后,从 GameState 读取当前状态即可。背包通常只需保留物品 ID、数量等数据,不必让旧场景中的物品节点一直存活。

方案保留什么适合的需求
Autoload 保存数据剩余时间、背包等本局状态当前需求,通常改动较少
持久玩家节点,只替换关卡玩家节点及其内部状态需要保留完整角色实例、复杂运行状态
写入磁盘存档可供后续读取的数据退出后继续、崩溃恢复等需求

最小改造:把时间和背包移到 GameState

1. 创建并注册 Autoload

创建 res://game_state.gd,在项目设置的 Autoload 配置中将它注册为 GameState。不同 Godot 4 小版本的设置界面位置可能略有差别。

由 GameState 统一管理倒计时,背包则用 Dictionary 保存,键是物品 ID,值是数量:

# res://game_state.gd
extends Node

signal time_changed(seconds_left: float)
signal inventory_changed
signal time_expired

const RUN_DURATION: float = 90.0

var remaining_time: float = RUN_DURATION
var inventory: Dictionary = {}
var running: bool = false


func start_new_run() -> void:
    remaining_time = RUN_DURATION
    inventory.clear()
    running = true

    time_changed.emit(remaining_time)
    inventory_changed.emit()


func _process(delta: float) -> void:
    if not running:
        return

    remaining_time = maxf(remaining_time - delta, 0.0)
    time_changed.emit(remaining_time)

    if remaining_time <= 0.0:
        running = false
        time_expired.emit()


func add_item(item_id: String, count: int = 1) -> void:
    if count <= 0:
        return

    inventory[item_id] = int(inventory.get(item_id, 0)) + count
    inventory_changed.emit()

只在开始新一局时调用:

GameState.start_new_run()

不要将这句放进每个关卡的 _ready(),否则每次切换场景都会把倒计时重置为 90 秒,并清空背包。

2. 让场景读取状态

假设场景中有一个名为 TimeLabel 的 Label,可以在界面脚本中这样显示剩余时间:

extends Control

@onready var time_label: Label = $TimeLabel


func _ready() -> void:
    GameState.time_changed.connect(_update_time)
    _update_time(GameState.remaining_time)


func _update_time(seconds_left: float) -> void:
    var seconds: int = int(ceil(seconds_left))
    time_label.text = "%02d:%02d" % [
        seconds / 60,
        seconds % 60
    ]

信号只会通知之后发生的变化,所以新界面需要在 _ready() 中读取当前值,主动刷新一次。

捡到食物时,更新共享背包:

func collect_food() -> void:
    GameState.add_item("expired_food")

读取数量时:

var food_count: int = int(
    GameState.inventory.get("expired_food", 0)
)

背包界面也可以连接 inventory_changed 信号,在物品变化时刷新。

3. 保留现有场景切换方式

将状态移到 GameState 后,仍可按原来的方式切换当前场景:

func enter_kitchen() -> void:
    var error := get_tree().change_scene_to_file(
        "res://levels/kitchen.tscn"
    )
    if error != OK:
        push_error("Failed to change scene: %s" % error)

将示例中的场景路径替换为实际路径即可。切换前不需要保存文件,新场景也不需要加载文件。

迁移时,要移除原来场景中扣减时间的逻辑,避免 Autoload 和旧 Timer 同时计时。背包数据也应统一读写 GameState.inventory,以免各场景分别维护的数据不一致。

倒计时需要先确定哪些时间算在内

这段代码通过 _process(delta) 累计游戏处理时间。只要场景树没有暂停、主循环正常推进,倒计时就会继续运行。

暂停和场景过渡期间是否扣时间,要按游戏规则决定:

  • 如果暂停游戏时也要暂停倒计时,可以让 GameState 遵循场景树的暂停状态。
  • 如果切场景的淡入淡出期间不应扣时间,可以暂时将 running 设为 false,过渡完成后恢复。

如果同步加载、窗口卡顿等期间也需要严格按现实经过的时间扣除,可以考虑使用 Time.get_ticks_msec()。开局时先记录截止时刻:

var deadline_msec: int = Time.get_ticks_msec() + 90_000

之后按时间差计算剩余时间:

var seconds_left: float = maxf(
    float(deadline_msec - Time.get_ticks_msec()) / 1000.0,
    0.0
)

采用这种计时方式时,需要将它接入统一的状态管理逻辑。如果支持暂停,还要在恢复时补偿暂停时长,否则暂停期间也会消耗时间。主线程阻塞时,界面刷新和超时处理仍需等主循环恢复后才能执行。

什么时候值得保留完整玩家节点

如果玩家节点内部有大量需要延续的运行状态,例如复杂的技能效果或组件状态,保留玩家实例可能更省事。可以让游戏主场景长期存在,只替换它下面的关卡节点。

如果只需保存剩余时间和背包数量,搬动整个玩家节点会增加额外工作,包括重新关联摄像机、出生点,以及新关卡中的节点引用。

remove_child() 只会解除父子关系,不会释放节点。节点离开场景树后,也不会继续正常接收 _process() 等回调。如果倒计时位于临时移出场景树的玩家子树中,过渡期间就可能停止计时。因此,以后即使保留玩家实例,将全局倒计时单独放在 GameState 中也更容易管理。

如何权衡性能和改造成本

在内存中读写状态,省去了序列化、加密、文件写入和重新读取的步骤。具体能节省多少时间,需要实际测量;少量存档数据也未必会造成可见卡顿。

对当前需求来说,维护成本更值得考虑。使用文件存档,需要处理文件不存在、读取失败、数据格式变化,以及误读上一局数据等情况。如果数据只在当前进程内使用,这些处理并没有带来必要的功能。

增加一个保存数据的 Autoload,通常只需迁移状态变量、修改读写位置,并调整界面刷新逻辑。具体改动量取决于现有代码的耦合程度,通常无需全面调整关卡结构。

自定义 Resource 也可以保存背包等数据,但仍需由 Autoload 或其他长期存活的对象持有同一个实例。extends Resource 本身不会自动保留跨场景状态,也不会自动写入磁盘。

以后如果增加退出后继续游玩的功能,可以沿用这套内存状态,在明确的保存时机将其序列化到磁盘。是否加密,应根据存档保护需求决定;它与跨场景保留数据是两个独立问题。

备注:内容仅供参考。