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 本身不会自动保留跨场景状态,也不会自动写入磁盘。
以后如果增加退出后继续游玩的功能,可以沿用这套内存状态,在明确的保存时机将其序列化到磁盘。是否加密,应根据存档保护需求决定;它与跨场景保留数据是两个独立问题。
备注:内容仅供参考。
