RPG(下):战斗、状态机与存档
给俯视角 RPG 加上战斗:用两种写法实现有限状态机(enum + match 和"节点即状态")并比较优缺点,做敌人的感知、追击和丢失目标,用 Hitbox/Hurtbox 模式和规划好的碰撞层做攻击判定,加上击退和无敌帧;再做背包和 ItemData 资源,讲清共享资源的 duplicate 陷阱,最后用 JSON 存档并比较 ResourceSaver,实测 Web 上的 user://。
上一章搭好了地图、移动和对话。这一章让世界"动手":史莱姆和蝙蝠会游荡、发现你、扑过来;你挥剑把它们打退、打倒;它们掉下金币和道具,进背包;最后把进度存进 user://,关掉网页再回来还能读档。
下面的示例已经替你打开了「显示感知范围」和「显示状态机状态」。先往南走出村子,到草地上看看史莱姆头顶的状态怎么变化:
操作:J 或空格挥剑,I 背包(方向键选格子,E 使用),K 存档,L 读档。右侧的「生成一只敌人」会在你身边轮流生成史莱姆和蝙蝠。
有限状态机
先想想不用状态机会怎么写敌人。它要会发呆、游荡、追人、蓄力扑击、被打退、死掉。很自然地会冒出一堆布尔变量:is_chasing、is_attacking、is_hurt、is_dead……然后 _physics_process 里是一串 if。问题在于这些变量可以同时为真:被打中时 is_hurt 设成 true,但忘了把 is_attacking 设成 false,于是敌人一边被击退一边继续扑;死了以后 is_chasing 还是 true,尸体追着玩家跑。每加一个行为,要检查的组合就翻一倍。
有限状态机(FSM)换一个角度:敌人在任何时刻只处于一个状态,每个状态只关心两件事:
- 在这个状态里每帧做什么(发呆就减速停下,追击就朝玩家走);
- 遇到什么事件时切换到哪个状态(看见玩家 → 追击,被打中 → 受伤)。
"同时又在攻击又在受伤"这种组合根本无法表达,bug 就少了一大类。整台状态机可以画成一张图:圆圈是状态,箭头是转换,箭头上写着触发它的事件。
注意几个设计细节,它们都直接对应示例里的代码:
- DEAD 没有出去的箭头。
change_state()第一行就是"死了就不再切换",避免死亡动画播放途中被别的事件拉回来。 - HURT 可以从任何活着的状态进入。这类"全局事件"在 enum 写法里放在状态之外处理(受伤回调里直接切),不用在每个分支里重复。
- ATTACK 分成蓄力和扑出两段。蓄力的 0.35 秒是给玩家的反应时间:史莱姆停下来抖动,你可以躲开或者先砍它。
写法一:enum + match
最直接的实现:一个 enum 列出所有状态,一个变量记当前状态,_physics_process 里用 match 按状态分支。最小的版本只有十几行:
enum State { IDLE, CHASE, HURT }
var state := State.IDLE
var target: Node2D
func _physics_process(_delta: float) -> void:
match state:
State.IDLE:
velocity = Vector2.ZERO
if target and position.distance_to(target.position) < 64.0:
state = State.CHASE
State.CHASE:
velocity = position.direction_to(target.position) * 50.0
State.HURT:
velocity *= 0.9
move_and_slide()示例的敌人是同样的结构,只是状态多一些,并且记录了"进入当前状态多久了"(state_time),很多转换条件都是"在这个状态里待够 N 秒":
enum State { IDLE, WANDER, CHASE, ATTACK, HURT, DEAD }
var state := State.IDLE
var state_time := 0.0 # 进入当前状态多久了func _physics_process(delta: float) -> void:
state_time += delta
match state:
State.IDLE:
velocity = velocity.move_toward(Vector2.ZERO, 300.0 * delta)
if _sees_player():
change_state(State.CHASE)
elif state_time > wander_interval:
change_state(State.WANDER)
State.WANDER:
velocity = velocity.move_toward(_wander_dir * chase_speed * 0.45 * speed_scale, 300.0 * delta)
if _sees_player():
change_state(State.CHASE)
elif state_time > wander_interval * 0.6 or get_slide_collision_count() > 0:
change_state(State.IDLE)
State.CHASE:
var to_player := _player.global_position - global_position
velocity = velocity.move_toward(to_player.normalized() * chase_speed * speed_scale, 400.0 * delta)
if _lost_player():
change_state(State.IDLE)
_show_emote("?")
elif to_player.length() < ATTACK_RANGE and state_time > 0.3:
change_state(State.ATTACK)
State.ATTACK:
if state_time < WINDUP: # 蓄力:停下、抖动
velocity = velocity.move_toward(Vector2.ZERO, 800.0 * delta)
sprite.position.x = sin(state_time * 60.0) * 1.0
elif not _lunged: # 扑出去
_lunged = true
sprite.position.x = 0.0
velocity = _attack_dir * LUNGE_SPEED * speed_scale
else:
velocity = velocity.move_toward(Vector2.ZERO, 300.0 * delta)
if state_time > WINDUP + 0.45:
change_state(State.CHASE)
State.HURT:
velocity = velocity.move_toward(Vector2.ZERO, 500.0 * delta) # 击退速度慢慢衰减
if state_time > 0.3:
change_state(State.CHASE)
State.DEAD:
return
move_and_slide()
_update_visual(delta)切换状态集中在一个函数里,"进入某个状态时要做的一次性准备"(随机一个游荡方向、记下扑击方向、头顶冒感叹号)都写在这里,而不是散落在各个分支里:
func change_state(next: State) -> void:
if state == State.DEAD:
return # 死了就不再切换
var prev := state
state = next
state_time = 0.0
match next:
State.WANDER:
_wander_dir = Vector2.from_angle(randf() * TAU)
State.CHASE:
if prev == State.IDLE or prev == State.WANDER:
_show_emote("!")
State.ATTACK:
_attack_dir = global_position.direction_to(_player.global_position)
_lunged = false
State.HURT:
sprite.position.x = 0.0
hitbox.active = next != State.HURT and next != State.DEAD写法二:节点即状态
玩家用的是另一种写法:每个状态是一个独立的节点,挂自己的脚本,统一继承一个基类。状态机节点负责把 _physics_process 和输入事件转发给当前状态:
class_name BaseState
extends Node
var machine: StateMachine
func enter(_msg := {}) -> void:
pass
func exit() -> void:
pass
func handle_input(_event: InputEvent) -> void:
pass
func physics_update(_delta: float) -> void:
passclass_name StateMachine
extends Node
signal state_changed(from: StringName, to: StringName)
@export var initial_state: BaseState
var state: BaseState
func _ready() -> void:
for child in get_children():
if child is BaseState:
child.machine = self
await owner.ready # 等角色本身准备好再进入第一个状态
state = initial_state
state.enter()
func _unhandled_input(event: InputEvent) -> void:
if state:
state.handle_input(event)
func _physics_process(delta: float) -> void:
if state:
state.physics_update(delta)
func transition_to(target: StringName, msg := {}) -> void:
var next := get_node_or_null(NodePath(target)) as BaseState
if next == null:
push_error("没有这个状态:%s" % target)
return
var from := state.name
state.exit()
state = next
state.enter(msg)
state_changed.emit(from, target)玩家的场景树里,StateMachine 下面挂着 Move、Attack、Hurt、Dead 四个节点。每个状态脚本都很短,只管自己的事:
extends BaseState
const DURATION := 0.24
var _time := 0.0
@onready var player := owner as Player
func enter(_msg := {}) -> void:
_time = 0.0
player.start_swing()
func physics_update(delta: float) -> void:
_time += delta
player.move(delta, Vector2.ZERO) # 挥剑时滑行减速
if _time >= DURATION:
machine.transition_to(&"Move")
func exit() -> void:
player.end_swing()extends BaseState
@onready var player := owner as Player
func physics_update(delta: float) -> void:
player.move(delta, player.get_input_vector())
player.play_walk_animation()
func handle_input(event: InputEvent) -> void:
if event.is_action_pressed("attack") and player.can_attack():
machine.transition_to(&"Attack")
elif event.is_action_pressed("interact"):
player.try_interact()"挥剑时不能再挥剑""受伤时不能挥剑"这些规则不用写任何 if:只有 Move 状态处理攻击键,处于 Attack 或 Hurt 时按键根本不会被处理。
两种写法怎么选
| enum + match | 节点即状态 | |
|---|---|---|
| 代码量 | 少,一个文件看完 | 多,每个状态一个文件,加一个基类和一个状态机 |
| 状态之间共享数据 | 直接用成员变量 | 要通过 owner 或传参访问角色 |
| 状态多了以后 | 一个函数几百行,难以维护 | 每个文件都短,新增状态不改旧代码 |
| 进入 / 退出逻辑 | 手写在 change_state() 的 match 里 | 天然有 enter() / exit() |
| 复用 | 难 | 状态节点可以挂到别的角色上,可以在检查器里调状态的 @export 参数 |
| 适合 | 状态少(5 个左右)、逻辑简单的敌人、NPC、UI | 主角、Boss 这类状态多、每个状态逻辑复杂的角色 |
不必一开始就上节点状态机。先用 enum 写,等某个状态的分支长到一屏放不下、或者 change_state() 里的进入 / 退出逻辑越来越乱,再拆成节点。
- 名字冲突。这个示例最初把状态基类叫
class_name State,敌人脚本里又有enum State。结果敌人脚本里func change_state(next: State)的参数类型被解析成了全局类 State,调用时报"参数应该是 State,实际是 Enemy.State"。class_name是全局的,起名时避开常用词,示例最后改成了BaseState。 - ready 顺序。子节点的
_ready()先于父节点执行。状态机在自己的_ready()里马上调用state.enter()的话,角色脚本里那些@onready变量还是 null。所以state_machine.gd先await owner.ready再进入初始状态。
玩家在挥剑(Attack 状态)时被史莱姆撞到,转到了 Hurt。剑的 Hitbox 会怎样?
- 继续开着,直到 Attack 的 0.24 秒计时结束
- 立刻关闭,因为离开 Attack 时会调用它的
exit(),里面调了end_swing() - 永远开着,因为 Attack 的 physics_update 不会再执行
transition_to() 先调当前状态的 exit() 再进入新状态。把"收尾工作"放在 exit() 里,无论因为什么离开这个状态,都能保证执行。这正是节点状态机比在 match 分支里手写收尾更不容易出错的地方。
感知、追击和丢失目标
敌人怎么"看见"玩家?最简单的感知是距离判断:玩家离敌人的距离小于 感知半径,就算看见了:
func _sees_player() -> bool:
if _player == null or _player.is_dead():
return false
return global_position.distance_to(_player.global_position) <= sense_radius
func _lost_player() -> bool:
return _player.is_dead() or global_position.distance_to(_player.global_position) > lose_radius注意这里有两个半径。如果只用一个("在 64 以内就追,超过 64 就不追"),玩家站在 64 像素的边界附近时,敌人会在 CHASE 和 IDLE 之间每帧来回切换,原地抽搐。用一个更大的丢失半径(112)做"缓冲带":进来要到 64 以内,出去要到 112 以外。这种做法叫滞回(hysteresis),空调温控也是这样避免频繁开关的。
追击本身很朴素:每帧把速度朝"指向玩家的单位向量 × 追击速度"靠近。追击速度要比玩家慢(默认 50 对 90),否则玩家永远甩不掉,躲闪就失去意义。
RayCast2D 节点,或 get_world_2d().direct_space_state.intersect_ray()),射线先碰到 world 层就说明被挡住了。追击时绕开障碍则要用 NavigationAgent2D 加 TileSet 里的导航层,这两项都超出了本章范围。Hitbox / Hurtbox 模式
"剑打中史莱姆"能不能直接用角色的身体碰撞来判断?不行,原因有三个:
- 身体碰撞只覆盖脚底(上一章说过),而剑要能打中身体任何部位;
- 剑只在挥动的那一瞬间有效,身体碰撞一直都在;
- 身体碰撞会把物体推开,而攻击判定只需要"知道重叠了",不应该产生物理推挤。
所以攻击判定用单独的 Area2D,分成两种角色:
- Hitbox(攻击框):会造成伤害的区域。玩家的剑、敌人的身体(碰到就扣血)。它带着伤害值和击退力。
- Hurtbox(受击框):会受到伤害的区域,一般覆盖整个身体。它负责检测,碰到对方的 Hitbox 就发出
hurt信号。
class_name Hitbox
extends Area2D
@export var damage := 1
@export var knockback := 120.0 ## 击退初速度(像素/秒)
@export var active := true: ## 关掉时 Hurtbox 会忽略它(比如剑没挥出来、敌人正在受伤)
set(v):
active = v
for c in get_children():
if c is CollisionShape2D:
c.set_deferred("disabled", not v)
func _ready() -> void:
monitoring = false
active = activeclass_name Hurtbox
extends Area2D
signal hurt(hitbox: Hitbox)
var invincible := false
func _ready() -> void:
monitorable = false
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if area is Hitbox and area.active and not invincible:
hurt.emit(area)
## 开启无敌 seconds 秒。期间别人打不到,结束后重新检查一次重叠。
func start_invincibility(seconds: float) -> void:
invincible = true
await get_tree().create_timer(seconds, false).timeout
invincible = false
for area in get_overlapping_areas():
if area is Hitbox and area.active:
hurt.emit(area)
breakHitbox 的 active 用 set_deferred("disabled", ...) 开关碰撞形状。为什么要 deferred?因为开关常常发生在物理回调里(比如 area_entered 信号里),物理引擎正在遍历形状时直接改它会报错"Can't change this state while flushing queries"。延迟到这一帧结束再改就安全了。
挥剑时打开剑的 Hitbox,Attack 状态退出时关闭。剑的碰撞形状挂在一个"枢轴"节点下,枢轴旋转到玩家面朝的方向,攻击框就出现在玩家正前方:
func can_attack() -> bool:
return _cooldown <= 0.0
func start_swing() -> void:
_cooldown = attack_cooldown
sprite.flip_h = facing.x < 0
sprite.play("attack_" + _dir_name())
# 剑的枢轴朝向 facing:枢轴旋转 0 时剑尖朝上(-Y),所以要加 90°
sword_pivot.rotation = facing.angle() + PI / 2.0
move_child(sword_pivot, 0 if facing.y < 0 else -1) # 朝上挥时剑画在身体后面
sword.visible = true
sword.rotation = -1.2
create_tween().tween_property(sword, "rotation", 1.2, 0.14).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
slash.play()
sword_hitbox.active = true
func end_swing() -> void:
sword_hitbox.active = false
sword.visible = false碰撞层和遮罩的规划
每个物理对象有两组 32 位开关:
- collision_layer(层):我在哪些层上,也就是"别人能在哪些层上找到我";
- collision_mask(遮罩):我检测哪些层,也就是"我会被哪些层上的东西挡住 / 触发"。
A 能检测到 B,当且仅当 A 的 mask 和 B 的 layer 有交集。对 CharacterBody2D 来说"检测到"就是移动时被挡住;对 Area2D 来说就是触发 area_entered / body_entered(还要求 A 的 monitoring 和 B 的 monitorable 打开)。
动手写代码之前先把层规划好,在项目设置 → Layer Names → 2D Physics 里给每一层起名字,之后检查器里勾选时看到的就是名字而不是数字:
[layer_names]
2d_physics/layer_1="world"
2d_physics/layer_2="water"
2d_physics/layer_3="player"
2d_physics/layer_4="enemy"
2d_physics/layer_5="player_attack"
2d_physics/layer_6="enemy_attack"
2d_physics/layer_7="interact"
2d_physics/layer_8="pickup"| 对象 | layer(我在) | mask(我检测) | 说明 |
|---|---|---|---|
| 玩家身体 | 3 player | 1 world, 2 water | 不检测敌人身体,所以可以和敌人重叠,不会互相推 |
| 史莱姆身体 | 4 enemy | 1 world, 2 water | |
| 蝙蝠身体 | 4 enemy | 1 world | 不检测水,可以飞过池塘 |
| 剑的 Hitbox | 5 player_attack | 无 | monitoring 关,只被看见 |
| 敌人的 Hitbox | 6 enemy_attack | 无 | |
| 玩家 Hurtbox | 无 | 6 enemy_attack | 只检测敌人的攻击 |
| 敌人 Hurtbox | 无 | 5 player_attack | 只检测玩家的攻击,所以敌人不会互相伤害 |
| NPC / 宝箱交互区 | 7 interact | 无 | |
| 玩家交互探测 | 无 | 7 interact | |
| 掉落物 | 8 pickup | 3 player | 只有玩家能捡 |
在场景文件里,层和遮罩存成整数:第 n 层对应二进制第 n−1 位,值是 2n−1。所以玩家身体 collision_layer = 4(第 3 层),collision_mask = 3(第 1、2 层,1 + 2)。代码里不用自己算位,用 set_collision_mask_value(层号, 开关):
func _ready() -> void:
collision_layer = 0
set_collision_layer_value(3, true) # 我在第 3 层 player
collision_mask = 0b11 # 我会撞到第 1 层 world 和第 2 层 water
print(get_collision_mask_value(2)) # true
func become_ghost() -> void:
set_collision_mask_value(2, false) # 不再被水挡住(像蝙蝠一样)- 把 layer 和 mask 弄反:Hurtbox 放在了第 6 层而 mask 是空的,它谁也检测不到,信号永远不触发。记住"谁负责检测,谁的 mask 就要勾对方的 layer"。
- 场景文件里的
collision_mask = 3是位掩码,不是"第 3 层"。第 3 层是 4。 - Godot 3 的
set_collision_mask_bit(bit, value)在 4.x 改名为set_collision_mask_value(layer_number, value),而且参数从"位编号(从 0 开始)"变成了"层号(从 1 开始)",迁移时容易差一位。
想让史莱姆也能被别的史莱姆的攻击打伤(比如做"敌人误伤"),最少要改什么?
- 把史莱姆的 Hitbox 的 layer 改成 5 player_attack
- 把史莱姆身体的 mask 加上第 4 层 enemy
- 把敌人 Hurtbox 的 mask 加上第 6 层 enemy_attack
检测由 Hurtbox 负责。敌人的 Hurtbox 现在只检测第 5 层(玩家攻击),加上第 6 层后它就能"看见"其他敌人的 Hitbox。不过它也会看见自己身上的 Hitbox,所以 _on_area_entered 里还得排除自己。选项 1 会让玩家的 Hurtbox 再也检测不到史莱姆的攻击。
击退和无敌帧
击退
被打中时沿"攻击者 → 受击者"的方向给一个初速度 v₀,之后在 HURT 状态里每秒减速 a,直到停下:
func _on_hurt(attack: Hitbox) -> void:
if state == State.DEAD:
return
hp -= attack.damage
# 击退:沿"攻击者 → 自己"的方向给一个初速度,HURT 状态里再慢慢减到 0
velocity = attack.global_position.direction_to(global_position) * attack.knockback
_flash()
if hp <= 0:
_die()
else:
change_state(State.HURT)
hurtbox.start_invincibility(0.25) # 一次挥剑只算一下匀减速运动的滑行距离是 d = v₀² / (2a)。默认击退力 160、HURT 状态里的减速度 500,史莱姆会滑出 160² / 1000 ≈ 26 像素,大约 1.6 格。击退力加倍,距离变成 4 倍。击退让玩家和敌人之间重新拉开距离,打起来才有节奏;没有击退的话,敌人被砍时还贴在你身上,下一帧它的接触伤害就会打到你。
受击闪白用了一个很短的着色器:把每个像素的颜色朝白色混合,flash 从 1 用 Tween 降到 0。每只敌人的闪白要互不影响,所以材质勾选了 resource_local_to_scene:
[sub_resource type="ShaderMaterial" id="flash"]
resource_local_to_scene = true
shader = ExtResource("3")
shader_parameter/flash = 0.0
shader_parameter/flash_color = Color(1, 1, 1, 1)如果不勾,场景里所有史莱姆共用同一个 ShaderMaterial 对象,打中一只,全部一起闪白。这是后面要讲的"共享资源"问题的一个例子。
无敌帧
玩家被撞后如果站着不动,每一帧都和史莱姆的 Hitbox 重叠,没有保护的话一秒能掉 60 次血。无敌帧(i-frames)就是受伤后的一小段时间内忽略所有攻击,同时让角色闪烁,告诉玩家"现在打不到我":
func _on_hurt(hitbox: Hitbox) -> void:
if is_dead():
return
Game.change_hp(-hitbox.damage)
var dir := (global_position - hitbox.global_position).normalized()
if Game.hp <= 0:
machine.transition_to(&"Dead")
return
machine.transition_to(&"Hurt", {"knockback": dir * hitbox.knockback})
hurtbox.start_invincibility(invincible_time) # 无敌帧:这段时间里 Hurtbox 不再响应
_blink = invincible_time无敌结束时有一个容易漏掉的情况:area_entered 只在"开始重叠"的那一刻触发一次。如果无敌期间史莱姆一直贴着你,无敌结束后不会再有新的 area_entered,你就会站在史莱姆身上永远不掉血。Hurtbox.start_invincibility() 在结束时用 get_overlapping_areas() 主动再查一次,就是为了处理这种情况。
背包和 ItemData
物品和对话一样适合做成数据。每种物品一份 .tres,记录名字、图标、描述、堆叠上限、使用效果:
class_name ItemData
extends Resource
@export var id: StringName
@export var display_name := ""
@export_multiline var description := ""
@export var icon: Texture2D
@export_range(1, 99) var max_stack := 9
@export var heal := 0 ## 使用后回复多少血(半颗心 = 1);0 表示不能直接使用[gd_resource type="Resource" script_class="ItemData" load_steps=4 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1"]
[ext_resource type="Texture2D" path="res://art/items.png" id="2"]
[sub_resource type="AtlasTexture" id="icon"]
atlas = ExtResource("2")
region = Rect2(16, 0, 16, 16)
[resource]
script = ExtResource("1")
id = &"potion"
display_name = "红药水"
description = "回复两颗心。"
icon = SubResource("icon")
max_stack = 5
heal = 4背包有 8 个格子,每格是一个 Slot:引用一份 ItemData,加上自己的数量。放入物品时先往已有的同类格子里叠,叠满了再找空格:
class Slot:
var item: ItemData # 共享、只读
var count := 0 # 每格自己的状态
func _init(i: ItemData, n: int) -> void:
item = i
count = n## 放入物品,先叠加到同类格子,再放进空格。返回放不下的数量。
func add(item: ItemData, amount := 1) -> int:
for s in slots:
if amount > 0 and s != null and s.item.id == item.id and s.count < item.max_stack:
var n := mini(amount, item.max_stack - s.count)
s.count += n
amount -= n
for i in SIZE:
if amount > 0 and slots[i] == null:
var n := mini(amount, item.max_stack)
slots[i] = Slot.new(item, n)
amount -= n
changed.emit()
return amountduplicate 陷阱:共享资源
为什么数量不直接写在 ItemData 里?因为 Resource 是按引用共享的。preload("res://items/potion.tres") 无论调用多少次,拿到的都是同一个对象;两个节点在检查器里拖了同一个 .tres,它们的属性指向的也是同一个对象。改了其中一个,所有引用它的地方都变了。
用一个敌人数据资源来演示这个坑。把"当前血量"放进了共享的 EnemyData:
func take_damage_wrong(amount: int) -> void:
# 错误:data 是所有史莱姆共享的同一个对象,这一刀砍在了"全体史莱姆"身上
data.hp -= amount两种修法:
func _ready() -> void:
# 修法一:拿到资源后复制一份私有的。duplicate() 默认是浅复制,
# 资源里如果还嵌着别的资源(数组、子资源),要用 duplicate(true) 或 duplicate_deep()
data = data.duplicate()
data.hp = data.max_hp# 修法二(更推荐):资源只放不会变的数据,会变的状态放在节点自己身上
var hp := 0
func setup() -> void:
hp = data.max_hp
func take_damage(amount: int) -> void:
hp -= amount示例的背包用的是修法二:ItemData 当成只读的"物品说明书",数量放在 Slot 里。这样做还有两个好处:比较两件物品是不是同一种只要比 id;存档时只需要存 id 和数量,读档时用 id 从 Game.ITEMS 里找回那份共享的 ItemData。
duplicate()默认是浅复制:复制出来的新资源里,如果属性本身又是资源(比如 ItemData 的icon)或数组、字典,它们仍然和原来的共享。需要连同子资源一起复制时用duplicate(true),或 4.5 起新增的duplicate_deep()(可以更精确地控制哪些子资源要复制)。- 检查器里资源的
resource_local_to_scene只对"随场景实例化"的资源生效:每次实例化场景时自动复制一份。用代码load()的资源不受它影响。 - 在编辑器里运行游戏时修改了
load()来的资源,并调用了ResourceSaver.save(),会把改动写回 res:// 里的源文件。导出后 res:// 是只读的,所以这个 bug 只在编辑器里出现,很难排查。
使用物品时,只改格子里的数量,ItemData 本身不动:
func _use_selected() -> void:
var slot: Inventory.Slot = Game.inventory.slots[_selected]
if slot == null:
return
if slot.item.heal <= 0:
_desc.text = "%s 不能直接使用。" % slot.item.display_name
return
if Game.hp >= Game.max_hp:
_desc.text = "血是满的,先留着吧。"
return
Game.change_hp(slot.item.heal)
Game.inventory.remove_at(_selected) # 只改这一格的数量,ItemData 本身不动存档
JSON 存档
存档要回答两个问题:存什么、存在哪。存什么:能完整恢复进度的最少数据,即玩家位置、血量、金币、背包(id + 数量)、任务标记、开过的宝箱,以及场上每只敌人的种类、位置和剩余血量。敌人的状态机状态、地上的掉落物这类短暂的东西不存,读档后敌人从 IDLE 重新开始,掉落物保持原样。存在哪:user://,这是 Godot 给每个项目准备的可写目录。
## enemies 是场上每只敌人的 {"kind", "x", "y", "hp"},由主场景收集后传进来。
func save_game(player_pos: Vector2, enemies: Array = []) -> bool:
var data := {
"version": 1,
"player": {"x": player_pos.x, "y": player_pos.y, "hp": hp, "max_hp": max_hp}, # Vector2 不能直接进 JSON
"enemies": enemies,
"coins": coins,
"inventory": inventory.to_save(),
"flags": flags,
"slimes_killed": slimes_killed,
"opened_chests": opened_chests,
}
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("存档失败:%s" % error_string(FileAccess.get_open_error()))
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
if OS.has_feature("web"):
JavaScriptBridge.force_fs_sync() # Web 上 user:// 在 IndexedDB 里,立刻同步一次
return true## 读档成功返回 {"x":..,"y":..} 形式的玩家数据,另外 "enemies" 里是存档时场上的敌人;
## 没有存档或格式不对返回空字典。
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var data: Variant = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
if typeof(data) != TYPE_DICTIONARY or int(data.get("version", 0)) != 1:
push_warning("存档格式不对,忽略")
return {}
var p: Dictionary = data["player"]
max_hp = int(p["max_hp"]) # JSON 里的数字读回来都是 float,要转回 int
hp = int(p["hp"])
coins = int(data["coins"])
inventory.from_save(data["inventory"], ITEMS)
flags = data["flags"]
slimes_killed = int(data["slimes_killed"])
opened_chests.assign(data["opened_chests"])
_emit_all()
loaded.emit()
p["enemies"] = data.get("enemies", []) # 老存档没有这一项
return p敌人是场景里的节点,不归 Game 管,所以由主场景负责收集和重建:存档时遍历 enemies 分组,把每只敌人压成一个字典;读档时先删掉场上所有敌人,再按存档逐个实例化。
func _enemies_to_save() -> Array:
var out := []
for e: Enemy in get_tree().get_nodes_in_group("enemies"):
if e.hp > 0: # 正在播死亡动画的不存
out.append({"kind": str(e.kind), "x": e.global_position.x, "y": e.global_position.y, "hp": e.hp})
return out
func _restore_enemies(list: Array) -> void:
for e in get_tree().get_nodes_in_group("enemies"):
e.remove_from_group("enemies") # 先移出分组:queue_free 要到帧末才真正删除
e.queue_free()
for d: Dictionary in list:
var enemy: Enemy = (BAT if d["kind"] == "bat" else SLIME).instantiate()
enemy.position = Vector2(d["x"], d["y"])
world.add_child(enemy)
enemy.hp = int(d["hp"])queue_free() 不会立刻删除节点,而是等到这一帧结束。删除后马上用 get_nodes_in_group("enemies") 统计,旧敌人还在分组里,数量就会翻倍。所以上面的代码在 queue_free 之前,先调用 remove_from_group() 把它移出分组。
JSON 只认识字符串、数字、布尔、数组、字典和 null,所以有几处要手动转换:
- Vector2 不能直接存。
JSON.stringify(Vector2(1, 2))会得到字符串"(1, 2)",读回来是 String 而不是 Vector2。示例拆成x、y两个数字。 - 整数读回来是浮点数。JSON 不区分整数和浮点数,
JSON.parse_string()返回的数字都是 float。血量 6 读回来是 6.0,赋给var hp: int之前要int();不转的话,"%d" % hp之类的地方没问题,但字典的键、match比较、==比较类型时会出现意想不到的结果。 - 加一个 version 字段。游戏更新后存档格式会变,读档时先看版本号,旧版本的存档可以做迁移或者提示不兼容,而不是读出一堆 null。
ResourceSaver:另一种选择
Godot 也可以直接把一个 Resource 存成 .tres / .res 文件。写一个 SaveData 资源类,把要存的东西 @export 出来,ResourceSaver.save() 一行搞定:
func save_with_resource(pos: Vector2) -> void:
var data := SaveData.new()
data.player_position = pos
data.hp = Game.hp
data.coins = Game.coins
data.flags = Game.flags.duplicate()
var err := ResourceSaver.save(data, PATH)
if err != OK:
push_error("存档失败:%s" % error_string(err))
func load_with_resource() -> SaveData:
if not ResourceLoader.exists(PATH):
return null
# CACHE_MODE_IGNORE:不要拿缓存里的旧对象,每次都从文件重新读
return ResourceLoader.load(PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as SaveData| JSON + FileAccess | ResourceSaver | |
|---|---|---|
| Vector2、Color、资源引用 | 要手动拆开、转换 | 原样保存,类型不丢 |
| 文件可读性 | 标准 JSON,任何工具都能看 | .tres 是 Godot 自己的文本格式 |
| 和其他程序交换(服务器、网页) | 方便 | 不方便 |
| 安全性 | 只是数据 | .tres 里可以内嵌脚本,加载来源不可信的文件等于执行别人的代码 |
| 版本迁移 | 自己控制 | 改了类的字段名后,旧存档里对应的属性在加载时会被忽略 |
[sub_resource type="GDScript"] 并带上任意代码,load() 时就会执行。单机游戏的存档用 Resource 问题不大(玩家只能"攻击"自己的电脑),但凡涉及分享存档、下载内容,就用 JSON 这种纯数据格式。user:// 在哪里
func where_is_user_dir() -> void:
# Windows: %APPDATA%\Godot\app_userdata\<项目名>
# macOS: ~/Library/Application Support/Godot/app_userdata/<项目名>
# Linux: ~/.local/share/godot/app_userdata/<项目名>
# Web: 浏览器 IndexedDB 里的 /userfs(清除网站数据就没了)
print(OS.get_user_data_dir())
print(ProjectSettings.globalize_path("user://save.json"))Web 上没有真正的文件系统。Godot 在浏览器里用 IndexedDB 模拟出一个 /userfs,user:// 就映射到这里。写入的文件关闭后,引擎会在之后的帧里把改动异步同步到 IndexedDB;示例在存档后再调用 JavaScriptBridge.force_fs_sync() 立刻发起同步,尽量缩短"存完马上关网页"可能丢档的窗口。OS.is_userfs_persistent() 可以检查当前浏览器是否支持持久化(隐私模式、禁用了网站数据时会返回 false)。
本教程在真实的 Chrome 里测过:在示例里存档后刷新整个网页,游戏重新启动,玩家回到起点;按 L 读档,又回到了存档时的位置,背包和任务进度都还在。清除这个网站的浏览数据后,存档就没了。
存档 JSON 里写着 "hp": 5。读档时直接写 Game.hp = data["player"]["hp"](hp 声明为 var hp := 6,类型推断为 int),会发生什么?
- JSON 解析出来是 5.0(float),赋给 int 变量时 GDScript 会把它转换成 5,但最好显式写
int(),表明你知道这里有类型转换 - 报错,JSON 里不能存数字
- hp 变成字符串 "5"
JSON 的数字统一解析为 float。赋给有类型的 int 变量时会被转换,但如果目标是无类型变量、字典或数组,它就一直是 float,后面和 int 做严格比较、当字典键时就会出问题。显式 int() 是好习惯。
小结
- 有限状态机让角色任何时刻只处于一个状态。enum + match 适合状态少的敌人;节点即状态适合状态多、逻辑复杂的主角,
enter()/exit()保证收尾逻辑一定执行。 - 感知用两个半径做滞回,避免在边界上来回切换。
- 攻击判定用 Hitbox(被看见、带伤害)和 Hurtbox(负责检测)两种 Area2D,和身体碰撞分开。
- 先规划碰撞层再写代码:谁检测,谁的 mask 勾对方的 layer。
- 击退距离 d = v₀² / 2a;无敌帧结束后要主动重查一次重叠。
- Resource 按引用共享:只放不变的数据,会变的状态放节点或单独的结构里,需要时
duplicate()。 - 存档用 JSON 最稳妥,注意 Vector2 和整数的转换;ResourceSaver 方便但不要加载不可信的文件。Web 上 user:// 在 IndexedDB 里。