GameTech/Godot 4.7 互动教程
目录示例游戏
第三部分 · 2D 实战 · 第 10 章

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 按状态分支。最小的版本只有十几行:

snippets/fsm_minimal.gd第 5–21 行snippets/fsm_minimal.gd:三个状态的最小例子在工程中查看 ↗
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 秒":

enemies/enemy.gd第 35–38 行enemies/enemy.gd:状态列表在工程中查看 ↗
enum State { IDLE, WANDER, CHASE, ATTACK, HURT, DEAD }

var state := State.IDLE
var state_time := 0.0        # 进入当前状态多久了
enemies/enemy.gd第 66–108 行每帧:按当前状态决定速度,并检查要不要切换在工程中查看 ↗
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)

切换状态集中在一个函数里,"进入某个状态时要做的一次性准备"(随机一个游荡方向、记下扑击方向、头顶冒感叹号)都写在这里,而不是散落在各个分支里:

enemies/enemy.gd第 113–130 行change_state():重置计时器,执行进入逻辑在工程中查看 ↗
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
在示例里打开「显示状态机状态」,站在一只史莱姆附近不动:它会在 IDLE 和 WANDER 之间切换;走进黄圈,它头顶冒出"!",状态变成 CHASE;离得足够近,变成 ATTACK,先抖一下再扑过来。把「游荡间隔」拖到 0.3,史莱姆会变得很躁动。

写法二:节点即状态

玩家用的是另一种写法:每个状态是一个独立的节点,挂自己的脚本,统一继承一个基类。状态机节点负责把 _physics_process 和输入事件转发给当前状态:

player/states/state.gd第 3–22 行player/states/state.gd:状态基类在工程中查看 ↗
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:
	pass
player/states/state_machine.gd第 3–40 行player/states/state_machine.gd:子节点就是状态在工程中查看 ↗
class_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 四个节点。每个状态脚本都很短,只管自己的事:

player/states/player_attack.gd第 3–24 行player_attack.gd:挥剑 0.24 秒,结束回到 Move在工程中查看 ↗
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()
player/states/player_move.gd第 3–17 行player_move.gd:走路时才响应攻击和交互键在工程中查看 ↗
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 会怎样?

  1. 继续开着,直到 Attack 的 0.24 秒计时结束
  2. 立刻关闭,因为离开 Attack 时会调用它的 exit(),里面调了 end_swing()
  3. 永远开着,因为 Attack 的 physics_update 不会再执行

transition_to() 先调当前状态的 exit() 再进入新状态。把"收尾工作"放在 exit() 里,无论因为什么离开这个状态,都能保证执行。这正是节点状态机比在 match 分支里手写收尾更不容易出错的地方。

感知、追击和丢失目标

敌人怎么"看见"玩家?最简单的感知是距离判断:玩家离敌人的距离小于 感知半径,就算看见了:

enemies/enemy.gd第 135–142 行感知:进入感知半径就追,离开丢失半径才放弃在工程中查看 ↗
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),否则玩家永远甩不掉,躲闪就失去意义。

打开「显示感知范围」。没发现玩家的敌人画黄色实线圈(感知半径),正在追击的画红色虚线圈(丢失半径)。把「丢失半径」拖到比「感知半径」还小,比如感知 100、丢失 40,然后站在离史莱姆 60 像素左右的地方:它头顶交替出现"!"和"?",状态在 CHASE 和 IDLE 之间反复跳。
距离判断看不到墙:隔着房子也能"看见"你。要做视线判断,可以在敌人和玩家之间做一次射线检测(RayCast2D 节点,或 get_world_2d().direct_space_state.intersect_ray()),射线先碰到 world 层就说明被挡住了。追击时绕开障碍则要用 NavigationAgent2D 加 TileSet 里的导航层,这两项都超出了本章范围。

Hitbox / Hurtbox 模式

"剑打中史莱姆"能不能直接用角色的身体碰撞来判断?不行,原因有三个:

  • 身体碰撞只覆盖脚底(上一章说过),而剑要能打中身体任何部位;
  • 剑只在挥动的那一瞬间有效,身体碰撞一直都在;
  • 身体碰撞会把物体推开,而攻击判定只需要"知道重叠了",不应该产生物理推挤。

所以攻击判定用单独的 Area2D,分成两种角色:

  • Hitbox(攻击框):会造成伤害的区域。玩家的剑、敌人的身体(碰到就扣血)。它带着伤害值和击退力。
  • Hurtbox(受击框):会受到伤害的区域,一般覆盖整个身体。它负责检测,碰到对方的 Hitbox 就发出 hurt 信号。
combat/hitbox.gd第 4–19 行combat/hitbox.gd:只被看见,不检测在工程中查看 ↗
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 = active
combat/hurtbox.gd第 4–30 行combat/hurtbox.gd:检测 Hitbox,管理无敌帧在工程中查看 ↗
class_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)
			break

Hitbox 的 active 用 set_deferred("disabled", ...) 开关碰撞形状。为什么要 deferred?因为开关常常发生在物理回调里(比如 area_entered 信号里),物理引擎正在遍历形状时直接改它会报错"Can't change this state while flushing queries"。延迟到这一帧结束再改就安全了。

挥剑时打开剑的 Hitbox,Attack 状态退出时关闭。剑的碰撞形状挂在一个"枢轴"节点下,枢轴旋转到玩家面朝的方向,攻击框就出现在玩家正前方:

player/player.gd第 105–125 行Player:开始挥剑 / 结束挥剑在工程中查看 ↗
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
打开「显示碰撞盒」,对着空气挥剑:玩家正前方会闪出一个红色的矩形(剑的 Hitbox,激活时是亮红色);绿色的是 Hurtbox,蓝色是身体,黄色是交互区。史莱姆身上同时有一个红框(它的接触伤害)和一个绿框。

碰撞层和遮罩的规划

每个物理对象有两组 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 里给每一层起名字,之后检查器里勾选时看到的就是名字而不是数字:

project.godot第 78–87 行project.godot:示例用到的 8 个碰撞层在工程中查看 ↗
[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 player1 world, 2 water不检测敌人身体,所以可以和敌人重叠,不会互相推
史莱姆身体4 enemy1 world, 2 water
蝙蝠身体4 enemy1 world不检测水,可以飞过池塘
剑的 Hitbox5 player_attack无monitoring 关,只被看见
敌人的 Hitbox6 enemy_attack无
玩家 Hurtbox无6 enemy_attack只检测敌人的攻击
敌人 Hurtbox无5 player_attack只检测玩家的攻击,所以敌人不会互相伤害
NPC / 宝箱交互区7 interact无
玩家交互探测无7 interact
掉落物8 pickup3 player只有玩家能捡

在场景文件里,层和遮罩存成整数:第 n 层对应二进制第 n−1 位,值是 2n−1。所以玩家身体 collision_layer = 4(第 3 层),collision_mask = 3(第 1、2 层,1 + 2)。代码里不用自己算位,用 set_collision_mask_value(层号, 开关):

snippets/layer_api.gd第 6–14 行snippets/layer_api.gd:按层号读写在工程中查看 ↗
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 开始)",迁移时容易差一位。

想让史莱姆也能被别的史莱姆的攻击打伤(比如做"敌人误伤"),最少要改什么?

  1. 把史莱姆的 Hitbox 的 layer 改成 5 player_attack
  2. 把史莱姆身体的 mask 加上第 4 层 enemy
  3. 把敌人 Hurtbox 的 mask 加上第 6 层 enemy_attack

检测由 Hurtbox 负责。敌人的 Hurtbox 现在只检测第 5 层(玩家攻击),加上第 6 层后它就能"看见"其他敌人的 Hitbox。不过它也会看见自己身上的 Hitbox,所以 _on_area_entered 里还得排除自己。选项 1 会让玩家的 Hurtbox 再也检测不到史莱姆的攻击。

击退和无敌帧

击退

被打中时沿"攻击者 → 受击者"的方向给一个初速度 v₀,之后在 HURT 状态里每秒减速 a,直到停下:

enemies/enemy.gd第 147–158 行Enemy:扣血、击退、闪白在工程中查看 ↗
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:

enemies/slime.tscn第 10–14 行slime.tscn:每个实例一份自己的材质在工程中查看 ↗
[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)就是受伤后的一小段时间内忽略所有攻击,同时让角色闪烁,告诉玩家"现在打不到我":

player/player.gd第 130–140 行Player:扣血、击退,开启无敌在工程中查看 ↗
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() 主动再查一次,就是为了处理这种情况。

把「无敌时间」拖到 0,「攻击伤害」拖到 2,然后站进史莱姆堆里:血掉得非常快。再把「无敌时间」拖回 1.5:受伤后角色闪烁一段时间,期间怎么撞都不掉血。把「击退力」拖到 400,砍中的史莱姆会被打飞好几格。

背包和 ItemData

物品和对话一样适合做成数据。每种物品一份 .tres,记录名字、图标、描述、堆叠上限、使用效果:

items/item_data.gd第 5–13 行items/item_data.gd在工程中查看 ↗
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 表示不能直接使用
items/potion.tres第 1–17 行items/potion.tres:图标是从 items.png 里切出来的 AtlasTexture在工程中查看 ↗
[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,加上自己的数量。放入物品时先往已有的同类格子里叠,叠满了再找空格:

items/inventory.gd第 10–16 行Slot:共享的 ItemData + 每格自己的数量在工程中查看 ↗
class Slot:
	var item: ItemData   # 共享、只读
	var count := 0       # 每格自己的状态

	func _init(i: ItemData, n: int) -> void:
		item = i
		count = n
items/inventory.gd第 32–45 行Inventory.add():先叠加,再放空格在工程中查看 ↗
## 放入物品,先叠加到同类格子,再放进空格。返回放不下的数量。
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 amount

duplicate 陷阱:共享资源

为什么数量不直接写在 ItemData 里?因为 Resource 是按引用共享的。preload("res://items/potion.tres") 无论调用多少次,拿到的都是同一个对象;两个节点在检查器里拖了同一个 .tres,它们的属性指向的也是同一个对象。改了其中一个,所有引用它的地方都变了。

用一个敌人数据资源来演示这个坑。把"当前血量"放进了共享的 EnemyData:

snippets/duplicate_trap.gd第 8–10 行反例:砍一只,全体掉血在工程中查看 ↗
func take_damage_wrong(amount: int) -> void:
	# 错误:data 是所有史莱姆共享的同一个对象,这一刀砍在了"全体史莱姆"身上
	data.hp -= amount

两种修法:

snippets/duplicate_trap.gd第 15–19 行修法一:拿到后复制一份私有的在工程中查看 ↗
func _ready() -> void:
	# 修法一:拿到资源后复制一份私有的。duplicate() 默认是浅复制,
	# 资源里如果还嵌着别的资源(数组、子资源),要用 duplicate(true) 或 duplicate_deep()
	data = data.duplicate()
	data.hp = data.max_hp
snippets/duplicate_trap.gd第 24–33 行修法二:资源只放不变的数据,状态放节点上在工程中查看 ↗
# 修法二(更推荐):资源只放不会变的数据,会变的状态放在节点自己身上
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 的几个细节:
  • duplicate() 默认是浅复制:复制出来的新资源里,如果属性本身又是资源(比如 ItemData 的 icon)或数组、字典,它们仍然和原来的共享。需要连同子资源一起复制时用 duplicate(true),或 4.5 起新增的 duplicate_deep()(可以更精确地控制哪些子资源要复制)。
  • 检查器里资源的 resource_local_to_scene 只对"随场景实例化"的资源生效:每次实例化场景时自动复制一份。用代码 load() 的资源不受它影响。
  • 在编辑器里运行游戏时修改了 load() 来的资源,并调用了 ResourceSaver.save(),会把改动写回 res:// 里的源文件。导出后 res:// 是只读的,所以这个 bug 只在编辑器里出现,很难排查。

使用物品时,只改格子里的数量,ItemData 本身不动:

ui/inventory_ui.gd第 78–89 行背包界面:使用选中的物品在工程中查看 ↗
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 给每个项目准备的可写目录。

autoload/game.gd第 99–119 行Game.save_game():拼字典 → JSON 文本 → 写文件在工程中查看 ↗
## 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
autoload/game.gd第 124–144 行Game.load_game():读文件 → 解析 → 逐项恢复在工程中查看 ↗
## 读档成功返回 {"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 分组,把每只敌人压成一个字典;读档时先删掉场上所有敌人,再按存档逐个实例化。

main.gd第 57–73 行main.gd:敌人的存与读在工程中查看 ↗
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() 一行搞定:

snippets/save_resource.gd第 8–23 行snippets/save_resource.gd:用 Resource 存档在工程中查看 ↗
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 + FileAccessResourceSaver
Vector2、Color、资源引用要手动拆开、转换原样保存,类型不丢
文件可读性标准 JSON,任何工具都能看.tres 是 Godot 自己的文本格式
和其他程序交换(服务器、网页)方便不方便
安全性只是数据.tres 里可以内嵌脚本,加载来源不可信的文件等于执行别人的代码
版本迁移自己控制改了类的字段名后,旧存档里对应的属性在加载时会被忽略
不要用 ResourceLoader 加载玩家可以替换的文件。存档、Mod、从网上下载的关卡,玩家都可以手动编辑。一个 .tres 文件里可以写 [sub_resource type="GDScript"] 并带上任意代码,load() 时就会执行。单机游戏的存档用 Resource 问题不大(玩家只能"攻击"自己的电脑),但凡涉及分享存档、下载内容,就用 JSON 这种纯数据格式。

user:// 在哪里

snippets/save_resource.gd第 28–34 行各平台 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 读档,又回到了存档时的位置,背包和任务进度都还在。清除这个网站的浏览数据后,存档就没了。

在上面的示例里打开宝箱、捡几样东西,按 K 存档(右侧"有存档"变成"是");然后刷新这个网页,重新运行示例,按 L 读档。也可以先点「重新开始」再读档,对比两者的区别:重新开始会清空 Game 里的全局状态,读档再把它们填回来。

存档 JSON 里写着 "hp": 5。读档时直接写 Game.hp = data["player"]["hp"](hp 声明为 var hp := 6,类型推断为 int),会发生什么?

  1. JSON 解析出来是 5.0(float),赋给 int 变量时 GDScript 会把它转换成 5,但最好显式写 int(),表明你知道这里有类型转换
  2. 报错,JSON 里不能存数字
  3. 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 里。