第十九部分:场景切换与地图状态:SceneManager、门传送、室内外切换、读档进入指定地图
这一部分非常关键。
前面我们已经做到了:
玩家能移动
能交互
能战斗
能对话
能接任务
能存档
但现在还有一个很大的问题:
玩家怎么从森林进入村庄?
怎么从室外进入房子?
怎么从房子出来回到门口?
怎么从读档进入指定地图?
UI、任务、背包、金币这些东西切地图时会不会丢?
这就是场景切换系统要解决的事情。
1. 为什么不能简单到处用 change_scene_to_file()
Godot 确实可以直接切场景:
get_tree().change_scene_to_file("res://scenes/maps/forest_map.tscn")
SceneTree 本身就负责管理节点树和当前场景,也支持加载、切换、重载场景。官方文档里也说明 SceneTree 管理节点层级和场景本身,可以添加、获取、删除节点,并可以切换场景。(Godot Engine documentation)
但是,RPG 项目里不建议你所有地方都直接切整个主场景。
原因是:
如果你直接切 Main.tscn
UI 可能会被重建
QuestManager 可能会丢
Player 可能会重新创建
背包、金币、任务、装备状态都要重新处理
当然你可以把所有东西都做成 Autoload。
但一开始全局化太多,也容易乱。
更推荐的结构是:
Main 不切
UI 不切
QuestManager 不切
Player 可以保留或重新挂载
只替换当前地图
也就是:
Main
├── WorldContainer
│ └── 当前地图
└── UI
切地图时,只换:
WorldContainer 里的地图场景
2. 推荐的主场景结构
现在建议你的主场景结构升级成这样:
Main Node
├── QuestManager Node
├── WorldContainer Node2D
│ └── 当前地图场景
└── UI CanvasLayer
├── PlayerHUD
├── InventoryPanel
├── EquipmentPanel
├── QuestPanel
├── ShopPanel
├── DialogueBox
└── DeathPanel
地图场景单独存在:
res://scenes/maps/forest_map.tscn
res://scenes/maps/village_map.tscn
res://scenes/maps/house_interior.tscn
res://scenes/maps/dungeon_01.tscn
Player 可以有两种放法。
方案 A:
Player 放在每张地图里
简单,但切地图时 Player 会重建,背包/等级/装备要迁移。
方案 B:
Player 放在 Main 下,地图只负责提供出生点
更适合 RPG 长期项目。
我建议从现在开始,逐渐改成方案 B:
Main
├── QuestManager
├── WorldContainer
│ └── 当前地图
├── Player
└── UI
也就是:
Player 不属于某一张地图
Player 属于整个游戏
3. 为什么 Player 更适合放 Main 下
因为 Player 身上有大量长期状态:
等级
经验
金币
背包
装备
血量
任务交互状态
相机
如果 Player 放在每张地图里面,你每次切地图都要考虑:
旧 Player 的数据怎么转到新 Player?
UI 绑定的是旧 Player 还是新 Player?
Camera2D 跟谁?
Inventory 是不是丢了?
而如果 Player 放 Main 下:
切地图时 Player 不删
只是移动到新地图的出生点
这样简单很多。
新的推荐结构:
Main Node
├── QuestManager Node
├── WorldContainer Node2D
│ └── CurrentMap Node2D
├── Player CharacterBody2D
│ ├── Inventory
│ ├── AnimatedSprite2D
│ ├── Camera2D
│ ├── InteractArea
│ ├── AttackArea
│ └── Hurtbox
└── UI CanvasLayer
4. 地图场景应该包含什么
地图场景不应该包含 Player 的长期数据。
地图应该包含:
TileMapLayer
地图碰撞
NavigationRegion2D
出生点 Marker2D
门 Door
NPC
怪物
宝箱
掉落物
环境节点
比如 ForestMap:
ForestMap Node2D
├── Ground TileMapLayer
├── Obstacles TileMapLayer
├── NavigationRegion2D
├── SpawnPoints Node2D
│ ├── DefaultSpawn Marker2D
│ ├── FromVillage Marker2D
│ └── FromHouse Marker2D
├── Doors Node2D
│ └── DoorToVillage Area2D
├── Interactables Node2D
│ ├── QuestNPC
│ └── Chest
├── Enemies Node2D
│ └── Slime
└── Foreground TileMapLayer
VillageMap:
VillageMap Node2D
├── Ground
├── Obstacles
├── NavigationRegion2D
├── SpawnPoints
│ ├── DefaultSpawn
│ ├── FromForest
│ └── FromHouse
├── Doors
│ ├── DoorToForest
│ └── DoorToHouse
├── Interactables
│ ├── ShopNPC
│ └── QuestNPC
└── Foreground
HouseInterior:
HouseInterior Node2D
├── Floor TileMapLayer
├── Walls TileMapLayer
├── SpawnPoints
│ ├── DefaultSpawn
│ └── FromVillage
├── Doors
│ └── DoorToVillage
└── Interactables
5. PackedScene 是什么
切地图时我们会加载 .tscn 文件,然后实例化。
比如:
var map_scene: PackedScene = load("res://scenes/maps/forest_map.tscn")
var map = map_scene.instantiate()
PackedScene 是 Godot 用来表示已保存场景文件的资源,可以实例化成节点树。官方文档里对 PackedScene 的描述就是“场景文件的简化接口”,并且可以加载一个保存的场景再实例化。(Godot Engine documentation)
你可以把它理解成前端里的“组件定义”:
PackedScene ≈ 组件模板
instantiate() ≈ 创建这个组件的一个实例
比如:
forest_map.tscn 是模板
instantiate 后得到一张真正运行中的地图节点
6. SceneManager 是什么
SceneManager 负责管理地图切换。
它应该处理:
当前地图是什么
加载新地图
删除旧地图
把新地图加到 WorldContainer
把 Player 移动到指定出生点
通知 UI 或其他系统地图变化
读档时进入指定地图
建议做成 Autoload。
因为场景切换是全局行为,Godot 官方 Autoload 文档也提到,Autoload 节点/脚本始终加载,可以存储全局变量,也可以处理场景切换和场景间过渡。(Godot Engine documentation)
创建:
res://scripts/scene/scene_manager.gd
设置为 Autoload:
Project Settings
Globals
Autoload
添加 scene_manager.gd
命名 SceneManager
7. SceneManager 要保存哪些引用
SceneManager 需要知道:
Main
WorldContainer
Player
当前地图
当前地图 ID
但 Autoload 启动时,它不一定马上知道 Main 里的节点。
所以推荐给它写一个 setup()。
SceneManager.gd:
extends Node
signal map_changed(map_id: String)
var main: Node
var world_container: Node2D
var player: Node2D
var current_map: Node = null
var current_map_id: String = ""
setup:
func setup(p_main: Node, p_world_container: Node2D, p_player: Node2D):
main = p_main
world_container = p_world_container
player = p_player
Main.gd 里调用:
func _ready():
SceneManager.setup(self, $WorldContainer, $Player)
8. 地图 ID 和路径映射
不要到处写地图路径。
不推荐每个门直接写:
res://scenes/maps/forest_map.tscn
虽然能用,但后面路径改了会很烦。
更推荐用 map_id:
forest
village
house_interior
dungeon_01
SceneManager 内部维护路径表:
var map_paths := {
"forest": "res://scenes/maps/forest_map.tscn",
"village": "res://scenes/maps/village_map.tscn",
"house_interior": "res://scenes/maps/house_interior.tscn",
"dungeon_01": "res://scenes/maps/dungeon_01.tscn"
}
以后门只需要写:
target_map_id = village
target_spawn_id = FromForest
而不是直接写文件路径。
这和前端路由有点像:
routeName: 'village'
而不是全项目到处硬写 URL。
9. SceneManager.change_map()
核心函数:
func change_map(map_id: String, spawn_id: String = "DefaultSpawn") -> bool:
if world_container == null:
push_error("SceneManager 没有 world_container")
return false
if player == null:
push_error("SceneManager 没有 player")
return false
if not map_paths.has(map_id):
push_error("未知地图 ID:" + map_id)
return false
var scene_path: String = map_paths[map_id]
var packed_scene: PackedScene = load(scene_path)
if packed_scene == null:
push_error("无法加载地图:" + scene_path)
return false
unload_current_map()
current_map = packed_scene.instantiate()
world_container.add_child(current_map)
current_map_id = map_id
move_player_to_spawn(spawn_id)
map_changed.emit(map_id)
return true
这个函数做了:
检查引用
根据 map_id 找路径
加载 PackedScene
删除旧地图
实例化新地图
加入 WorldContainer
更新 current_map_id
移动 Player 到 spawn_id
发出 map_changed
10. 卸载旧地图
func unload_current_map():
if current_map == null:
return
current_map.queue_free()
current_map = null
queue_free() 会把节点排队到当前帧结束时删除,并且删除时它的子节点也会一起删除;官方 Node 文档也是这样说明的。(Godot Engine documentation)
为什么不用 free()?
queue_free() 更安全
不会在当前逻辑中立刻把节点删掉
更适合游戏运行时删除节点
第一版记住:
运行时删节点,大多数情况用 queue_free()
11. 移动 Player 到出生点
地图里有:
SpawnPoints
├── DefaultSpawn
├── FromForest
├── FromVillage
└── FromHouse
SceneManager:
func move_player_to_spawn(spawn_id: String):
if current_map == null:
return
var spawn_points = current_map.get_node_or_null("SpawnPoints")
if spawn_points == null:
push_warning("当前地图没有 SpawnPoints")
return
var spawn = spawn_points.get_node_or_null(spawn_id)
if spawn == null:
push_warning("找不到出生点:" + spawn_id)
spawn = spawn_points.get_node_or_null("DefaultSpawn")
if spawn == null:
push_warning("没有可用出生点")
return
player.global_position = spawn.global_position
这样门可以指定:
去 village 地图的 FromForest 出生点
12. 为什么出生点要用名字
比如从森林进村庄,玩家应该出现在村庄靠森林出口的位置。
从房子出来,玩家应该出现在村庄房子门口。
所以一个地图会有多个出生点:
DefaultSpawn
FromForest
FromHouse
FromDungeon
FromVillageNorth
FromVillageSouth
门只需要告诉 SceneManager:
目标地图是谁
目标地图里的哪个出生点
这比直接保存坐标更灵活。
因为地图后面改了,你只要移动 Marker2D,不用改代码坐标。
13. Door 传送门节点
门应该是一个可交互对象。
结构:
Door Area2D
└── CollisionShape2D
或者有图像:
Door Area2D
├── Sprite2D
└── CollisionShape2D
Door 加入 group:
interactable
Door.gd:
extends Area2D
@export var prompt_text: String = "按 E 进入"
@export var target_map_id: String = ""
@export var target_spawn_id: String = "DefaultSpawn"
func interact(player: Node):
if target_map_id == "":
print("Door 没有设置 target_map_id")
return
SceneManager.change_map(target_map_id, target_spawn_id)
现在玩家按 E,就能切地图。
14. 自动触发门:进入 Area 就切图
有些门你可能不想按 E,而是玩家走进去就切图。
比如地图边缘出口:
玩家走到森林右边边界
自动进入村庄
可以写:
extends Area2D
@export var auto_trigger: bool = false
@export var target_map_id: String = ""
@export var target_spawn_id: String = "DefaultSpawn"
func _ready():
body_entered.connect(_on_body_entered)
func _on_body_entered(body):
if not auto_trigger:
return
if not body.is_in_group("player"):
return
SceneManager.change_map(target_map_id, target_spawn_id)
也可以同时支持按 E 和自动触发。
第一版建议:
房门按 E
地图边缘自动
15. 门节点完整版本
extends Area2D
@export var prompt_text: String = "按 E 进入"
@export var target_map_id: String = ""
@export var target_spawn_id: String = "DefaultSpawn"
@export var auto_trigger: bool = false
var is_changing_map: bool = false
func _ready():
body_entered.connect(_on_body_entered)
func interact(player: Node):
if auto_trigger:
return
change_map()
func _on_body_entered(body):
if not auto_trigger:
return
if not body.is_in_group("player"):
return
change_map()
func change_map():
if is_changing_map:
return
if target_map_id == "":
print("Door 没有设置 target_map_id")
return
is_changing_map = true
SceneManager.change_map(target_map_id, target_spawn_id)
is_changing_map 防止玩家站在门里连续触发多次。
16. 切地图时要清理 Player 当前交互对象
切地图时,旧地图的 NPC、门、宝箱会被删。
但 Player 的 nearby_interactables 数组里可能还存着旧对象。
所以 Player 需要一个方法:
func clear_interactables():
nearby_interactables.clear()
current_interactable = null
interactable_changed.emit(null)
SceneManager.change_map() 里,在卸载旧地图前或后调用都可以:
if player.has_method("clear_interactables"):
player.clear_interactables()
否则可能出现:
切到新地图后
按 E
Player 还试图和旧地图已删除的 Door/NPC 交互
这个 bug 很烦,所以早点处理。
17. 切地图时要隐藏 UI 面板
比如你打开背包、商店、对话框时切地图,UI 可能还开着。
切地图前建议通知 UI:
关闭 DialogueBox
关闭 ShopPanel
关闭 InventoryPanel
隐藏 InteractLabel
可以让 UI.gd 提供:
func close_all_panels():
if inventory_panel.visible:
inventory_panel.visible = false
if equipment_panel.visible:
equipment_panel.visible = false
if shop_panel.visible:
shop_panel.close_shop()
if dialogue_box.visible:
dialogue_box.end_dialogue()
interact_label.visible = false
SceneManager 不一定要直接管 UI。
可以让 Main 接收 SceneManager.map_changed 信号,然后调用 UI。
Main.gd:
func _ready():
SceneManager.setup(self, $WorldContainer, $Player)
SceneManager.map_changed.connect(_on_map_changed)
func _on_map_changed(_map_id: String):
$UI.close_all_panels()
第一版你也可以先不做,但建议加。
18. 地图需要初始化依赖
地图里的 NPC、门、怪物,可能需要引用:
QuestManager
DialogueBox
ShopPanel
Player
如果地图场景是动态加载的,不能再靠 Inspector 手动拖 Main 里的 UI 节点,因为这个引用跨场景会比较脆。
推荐地图加载后,SceneManager 或 Main 调用地图的初始化方法。
比如每张地图根节点脚本 MapBase.gd:
extends Node2D
func setup_map(context: Dictionary):
pass
SceneManager 加:
func setup_current_map():
if current_map == null:
return
if current_map.has_method("setup_map"):
current_map.setup_map({
"player": player,
"quest_manager": main.get_node("QuestManager"),
"ui": main.get_node("UI")
})
change_map 里:
current_map = packed_scene.instantiate()
world_container.add_child(current_map)
setup_current_map()
move_player_to_spawn(spawn_id)
这样地图可以自己给 NPC/门/怪物设置引用。
19. MapBase.gd
创建:
res://scripts/maps/map_base.gd
class_name MapBase
extends Node2D
var player: Node
var quest_manager: QuestManager
var ui: CanvasLayer
func setup_map(context: Dictionary):
player = context.get("player")
quest_manager = context.get("quest_manager")
ui = context.get("ui")
setup_interactables()
setup_enemies()
func setup_interactables():
var interactables = get_node_or_null("Interactables")
if interactables == null:
return
for node in interactables.get_children():
setup_interactable(node)
func setup_interactable(node: Node):
if "quest_manager" in node:
node.quest_manager = quest_manager
if "dialogue_box" in node and ui != null:
node.dialogue_box = ui.get_node_or_null("DialogueBox")
if "shop_panel" in node and ui != null:
node.shop_panel = ui.get_node_or_null("ShopPanel")
func setup_enemies():
pass
这样地图里的 QuestNPC 不用手动拖 QuestManager 和 DialogueBox,加载地图后会自动注入。
20. 注意:GDScript 的 “property” in object
你前面已经用过:
if "prompt_text" in interactable:
这里也类似:
if "quest_manager" in node:
node.quest_manager = quest_manager
这适合第一版。
更严格的做法是:
让 NPC 提供 setup_npc(context)
比如:
if node.has_method("setup_npc"):
node.setup_npc(context)
这会更稳。
第一版两种都可以。
我更推荐以后逐渐改成方法式:
func setup_npc(context: Dictionary):
...
21. Door 不需要 Player 引用
Door 只需要:
target_map_id
target_spawn_id
SceneManager
因为 SceneManager 是 Autoload,所以 Door 不需要被注入。
这很方便。
22. ShopNPC 需要 shop_panel
ShopNPC.gd 之前是:
@export var shop_panel: Control
如果地图动态加载,建议让 MapBase 自动设置:
if "shop_panel" in node and ui != null:
node.shop_panel = ui.get_node_or_null("ShopPanel")
这样每张地图里的商人 NPC 都能用同一个 UI ShopPanel。
23. QuestNPC 需要 quest_manager 和 dialogue_box
同理:
if "quest_manager" in node:
node.quest_manager = quest_manager
if "dialogue_box" in node:
node.dialogue_box = ui.get_node_or_null("DialogueBox")
这样地图切换后,新地图的 NPC 也能正常接任务和对话。
24. Enemy 是否需要 Player 引用
你前面的 Enemy 可能通过 DetectionArea 发现 Player,不一定需要提前引用 Player。
如果它有寻路、仇恨、经验奖励,都可以运行时检测。
但如果 Enemy 需要知道 QuestManager 或全局难度,也可以在 MapBase.setup_enemies() 里注入。
第一版可以不管。
25. Camera2D 放在哪里
如果 Player 保留在 Main 下,那么 Camera2D 继续放 Player 下面是最简单的。
Player
└── Camera2D
切地图时 Camera 不会丢。
但地图边界要更新。
因为每张地图大小不同。
前面我们讲过 Camera2D 有 limit_left、limit_right、limit_top、limit_bottom 等边界属性,用来限制相机滚动范围。Godot Camera2D 文档也说明这些 limit 属性是相机滚动边界。这个概念前面已经用过,这里继续沿用。
每次切地图后,要根据新地图设置 Camera 限制。
26. MapBase 提供相机边界
地图可以提供一个方法:
func get_camera_limits() -> Rect2:
return Rect2(Vector2.ZERO, Vector2(1000, 800))
更方便是放一个 CameraBounds 节点。
结构:
ForestMap
├── CameraBounds Marker2D 或 Node2D
但最简单还是根据 Ground TileMapLayer 自动计算。
MapBase.gd:
func get_camera_limits() -> Rect2:
var ground = get_node_or_null("Ground")
if ground == null:
return Rect2(Vector2.ZERO, Vector2(1000, 800))
var used_rect: Rect2i = ground.get_used_rect()
var tile_size: Vector2i = ground.tile_set.tile_size
var position = Vector2(
used_rect.position.x * tile_size.x,
used_rect.position.y * tile_size.y
)
var size = Vector2(
used_rect.size.x * tile_size.x,
used_rect.size.y * tile_size.y
)
return Rect2(position, size)
SceneManager 切地图后:
setup_camera_limits()
27. SceneManager 设置相机边界
func setup_camera_limits():
if player == null:
return
var camera: Camera2D = player.get_node_or_null("Camera2D")
if camera == null:
return
if current_map == null:
return
if not current_map.has_method("get_camera_limits"):
return
var rect: Rect2 = current_map.get_camera_limits()
camera.limit_left = int(rect.position.x)
camera.limit_top = int(rect.position.y)
camera.limit_right = int(rect.position.x + rect.size.x)
camera.limit_bottom = int(rect.position.y + rect.size.y)
change_map 里顺序可以是:
setup_current_map()
move_player_to_spawn(spawn_id)
setup_camera_limits()
28. SceneManager 完整基础版
extends Node
signal map_changed(map_id: String)
var main: Node
var world_container: Node2D
var player: Node2D
var current_map: Node = null
var current_map_id: String = ""
var map_paths := {
"forest": "res://scenes/maps/forest_map.tscn",
"village": "res://scenes/maps/village_map.tscn",
"house_interior": "res://scenes/maps/house_interior.tscn",
"dungeon_01": "res://scenes/maps/dungeon_01.tscn"
}
func setup(p_main: Node, p_world_container: Node2D, p_player: Node2D):
main = p_main
world_container = p_world_container
player = p_player
func change_map(map_id: String, spawn_id: String = "DefaultSpawn") -> bool:
if world_container == null:
push_error("SceneManager 没有 world_container")
return false
if player == null:
push_error("SceneManager 没有 player")
return false
if not map_paths.has(map_id):
push_error("未知地图 ID:" + map_id)
return false
var scene_path: String = map_paths[map_id]
var packed_scene: PackedScene = load(scene_path)
if packed_scene == null:
push_error("无法加载地图:" + scene_path)
return false
if player.has_method("clear_interactables"):
player.clear_interactables()
unload_current_map()
current_map = packed_scene.instantiate()
world_container.add_child(current_map)
current_map_id = map_id
setup_current_map()
move_player_to_spawn(spawn_id)
setup_camera_limits()
map_changed.emit(map_id)
return true
func unload_current_map():
if current_map == null:
return
current_map.queue_free()
current_map = null
func setup_current_map():
if current_map == null:
return
if current_map.has_method("setup_map"):
current_map.setup_map({
"player": player,
"quest_manager": main.get_node_or_null("QuestManager"),
"ui": main.get_node_or_null("UI")
})
func move_player_to_spawn(spawn_id: String):
if current_map == null:
return
var spawn_points = current_map.get_node_or_null("SpawnPoints")
if spawn_points == null:
push_warning("当前地图没有 SpawnPoints")
return
var spawn = spawn_points.get_node_or_null(spawn_id)
if spawn == null:
push_warning("找不到出生点:" + spawn_id)
spawn = spawn_points.get_node_or_null("DefaultSpawn")
if spawn == null:
push_warning("没有可用出生点")
return
player.global_position = spawn.global_position
func setup_camera_limits():
if player == null:
return
var camera: Camera2D = player.get_node_or_null("Camera2D")
if camera == null:
return
if current_map == null:
return
if not current_map.has_method("get_camera_limits"):
return
var rect: Rect2 = current_map.get_camera_limits()
camera.limit_left = int(rect.position.x)
camera.limit_top = int(rect.position.y)
camera.limit_right = int(rect.position.x + rect.size.x)
camera.limit_bottom = int(rect.position.y + rect.size.y)
29. Main.gd 基础版
extends Node
@onready var world_container: Node2D = $WorldContainer
@onready var player: Node2D = $Player
@onready var quest_manager: QuestManager = $QuestManager
@onready var ui: CanvasLayer = $UI
@export var start_map_id: String = "forest"
@export var start_spawn_id: String = "DefaultSpawn"
func _ready():
SceneManager.setup(self, world_container, player)
SceneManager.map_changed.connect(_on_map_changed)
SceneManager.change_map(start_map_id, start_spawn_id)
func _on_map_changed(_map_id: String):
if ui.has_method("close_all_panels"):
ui.close_all_panels()
这样游戏启动时自动加载 forest 地图。
30. 读档进入指定地图
前面 SaveManager 保存了:
"current_map_id": current_map_id
现在读档时需要:
读取存档
拿到 current_map_id
SceneManager.change_map(current_map_id, 某个 spawn)
恢复 player 位置
恢复数据
注意:如果存档保存了精确 position,就不需要 spawn。
读档逻辑可以是:
先切到存档地图
再恢复玩家 position
SaveManager.load_game 当前直接 apply 数据。
我们要稍微调整。
31. SaveManager 提供 get_save_data()
先让 SaveManager 能只读取数据:
func read_save_data() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return {}
var save_data = file.get_var()
file.close()
if typeof(save_data) != TYPE_DICTIONARY:
return {}
return save_data
Main.gd 或 SceneManager 可以先读:
var save_data = SaveManager.read_save_data()
var map_id = save_data.get("current_map_id", "forest")
32. Main.gd 加载存档版
func load_from_save():
var save_data = SaveManager.read_save_data()
if save_data.is_empty():
return
var map_id: String = save_data.get("current_map_id", start_map_id)
SceneManager.change_map(map_id, "DefaultSpawn")
SaveManager.apply_save_data(save_data, player, quest_manager)
读档快捷键:
func _unhandled_input(event):
if event.is_action_pressed("save_game"):
SaveManager.save_game(player, quest_manager, SceneManager.current_map_id)
get_viewport().set_input_as_handled()
if event.is_action_pressed("load_game"):
load_from_save()
get_viewport().set_input_as_handled()
这样:
F9
↓
读存档数据
↓
切到存档地图
↓
恢复玩家位置、背包、任务等
33. 为什么读档时不能先恢复 position 再切地图
如果你先:
player.global_position = saved_position
然后:
SceneManager.change_map(map_id)
change_map 里面会调用:
move_player_to_spawn(spawn_id)
把你刚恢复的位置覆盖掉。
所以读档顺序应该是:
先切地图
再恢复玩家 position
这个特别容易踩坑。
34. 切地图和读档的区别
普通门传送:
change_map(target_map_id, target_spawn_id)
目标位置来自 SpawnPoints/target_spawn_id。
读档:
change_map(saved_map_id, "DefaultSpawn")
apply_save_data()
目标位置来自存档里的 player.position。
所以读档时 spawn 只是临时点,最后会被 position 覆盖。
35. 保存 current_map_id
Main.gd 保存时不要再用手写字段:
SaveManager.save_game(player, quest_manager, "forest")
应该用:
SaveManager.save_game(player, quest_manager, SceneManager.current_map_id)
否则你在 village 存档,结果存进去还是 forest,那读档直接穿越回森林,挺魔幻,但不太适合调试。
36. 地图状态保存先不要急
现在我们保存了当前地图 ID 和玩家位置。
但没有保存:
当前地图哪些怪死了
哪些门开了
哪些宝箱开了
哪些掉落物还在
第一版可以接受。
因为很多 RPG 普通小怪和掉落在切地图后刷新是正常的。
但是宝箱、Boss、机关以后一定要保存。
这属于:
MapState
WorldState
后面可以单独讲。
37. 地图对象唯一 ID
以后要保存地图状态,所有重要对象都需要稳定 ID。
比如宝箱:
@export var persistent_id: String = "forest_chest_001"
门机关:
@export var persistent_id: String = "village_gate_switch"
Boss:
@export var persistent_id: String = "forest_boss"
存档里保存:
opened_chests = ["forest_chest_001"]
defeated_bosses = ["forest_boss"]
activated_switches = ["village_gate_switch"]
记住:
需要跨存档记住状态的地图对象,都应该有稳定 ID
38. 传送门命名建议
门本身也可以有 ID,但第一版门不需要保存状态。
建议命名:
DoorToVillage
DoorToForest
DoorToHouse
DoorToDungeon
ExitToVillage
导出字段:
target_map_id = village
target_spawn_id = FromForest
对应目标地图:
VillageMap
└── SpawnPoints
└── FromForest
这样你一看就知道门通向哪里。
39. 出生点命名建议
统一用:
DefaultSpawn
FromForest
FromVillage
FromHouse
FromDungeon
FromNorthGate
FromSouthGate
不要乱写:
spawn1
spawn2
pointA
aaa
现在偷懒,后面会被自己追杀。
40. 过渡动画
切地图时最好有淡入淡出。
比如:
黑屏淡出
切地图
移动玩家
黑屏淡入
UI 下可以加:
FadeLayer ColorRect
全屏黑色,默认透明。
以后 SceneManager.change_map 可以:
await fade_out()
切地图
await fade_in()
第一版先不做。
但你要知道真正游戏体验里,直接瞬切会有点硬。
41. 异步加载地图
如果地图很大,加载可能卡顿。
Godot 可以用更高级的资源加载方式做异步加载。
但第一版地图很小,直接:
load(scene_path)
instantiate()
完全够。
不要现在就做 Loading Screen。
你还没大到需要电梯广告时间,先开心地瞬切。
42. 当前推荐完整结构
现在你的项目结构可以升级成:
Autoload
├── SceneManager
├── SaveManager
├── ItemDatabase
└── QuestDatabase
Main Node
├── QuestManager Node
├── WorldContainer Node2D
│ └── CurrentMap
├── Player CharacterBody2D
│ ├── Inventory
│ ├── AnimatedSprite2D
│ ├── CollisionShape2D
│ ├── Camera2D
│ ├── InteractArea
│ ├── AttackArea
│ └── Hurtbox
└── UI CanvasLayer
├── PlayerHUD
├── InventoryPanel
├── EquipmentPanel
├── QuestPanel
├── ShopPanel
├── DialogueBox
└── DeathPanel
地图:
ForestMap Node2D
├── Ground
├── Obstacles
├── NavigationRegion2D
├── SpawnPoints
├── Doors
├── Interactables
├── Enemies
└── Foreground
43. 常见错误 1:切地图后 Player 不见了
排查:
Player 是否还在 Main 下
是不是旧地图 queue_free 时把 Player 一起删了
Player 有没有被放到旧地图节点下面
SceneManager.player 引用是否正确
move_player_to_spawn 是否找到了 Spawn
如果 Player 是旧地图的子节点,旧地图 queue_free() 时 Player 也会一起被删。
所以 Player 要放在 Main 下,不要放在 CurrentMap 里面。
44. 常见错误 2:切地图后按 E 报错
大概率是:
Player.current_interactable 还指向旧地图对象
解决:
player.clear_interactables()
切地图前调用。
45. 常见错误 3:切地图后 NPC 对话打不开
排查:
新地图 NPC 是否加入 interactable 组
MapBase.setup_map 是否被调用
QuestNPC.dialogue_box 是否被设置
QuestNPC.quest_manager 是否被设置
UI 里 DialogueBox 路径是否正确
如果你从手动拖引用改成动态注入,最常见就是 setup_map() 没执行。
46. 常见错误 4:切地图后商店打不开
排查:
ShopNPC.shop_panel 是否设置
MapBase.setup_interactable 是否处理 shop_panel
UI 里 ShopPanel 路径是否正确
ShopNPC.shop_data 是否配置
47. 常见错误 5:读档后回到错误地图
排查:
SaveManager 是否保存 SceneManager.current_map_id
save_data["current_map_id"] 是否正确
load_from_save 是否先 SceneManager.change_map(map_id)
有没有手写 start_map_id 覆盖
保存时一定用:
SceneManager.current_map_id
48. 常见错误 6:读档位置被出生点覆盖
原因:
先恢复 position
后 change_map
正确:
先 change_map
后 apply_save_data 恢复 position
49. 常见错误 7:相机边界还是旧地图的
排查:
SceneManager.change_map 后是否调用 setup_camera_limits()
current_map 是否有 get_camera_limits()
Ground TileMapLayer 路径是否正确
Camera2D 是否在 Player 下
如果新地图小,旧地图相机边界大,玩家可能看到地图外空白。
50. 常见错误 8:新地图怪物不会寻路
排查:
新地图是否有 NavigationRegion2D 或 TileMap 导航
怪物是否在导航区域内
玩家出生点是否在导航区域内
NavigationAgent2D 是否正常
切地图系统本身不负责导航数据。
每张地图都要自己配置导航。
51. 这一部分最重要的记忆点
RPG 不建议到处直接 change_scene_to_file 切整个主场景
更推荐 Main 不变,只替换 WorldContainer 里的地图
Player 最好放 Main 下,切地图时保留长期状态
地图负责 TileMap、NPC、门、怪物、出生点
Door 只需要 target_map_id 和 target_spawn_id
SpawnPoints 用 Marker2D 管理不同入口
SceneManager 适合做 Autoload
读档时要先切到存档地图,再恢复玩家位置
切地图时要清理 Player 当前交互对象
切地图后要更新 Camera2D 边界
52. 你现在的小练习
按这个顺序做:
1. 创建 SceneManager.gd
2. 设置为 Autoload:SceneManager
3. Main 下创建 WorldContainer
4. 把 Player 从地图里移到 Main 下
5. ForestMap 单独做成地图场景
6. VillageMap 单独做成地图场景
7. 每张地图添加 SpawnPoints
8. ForestMap 添加 DoorToVillage
9. VillageMap 添加 DoorToForest
10. DoorToVillage 设置 target_map_id = village
11. DoorToVillage 设置 target_spawn_id = FromForest
12. DoorToForest 设置 target_map_id = forest
13. DoorToForest 设置 target_spawn_id = FromVillage
14. Main._ready() 调用 SceneManager.setup()
15. Main._ready() 调用 SceneManager.change_map("forest")
16. 玩家靠近门按 E 切到 village
17. 玩家从 village 的 FromForest 出生点出现
18. 再从村庄门回 forest
19. 检查 Player 背包、金币、等级没有丢
20. 检查 NPC 对话、商店是否还能打开
21. 保存 current_map_id
22. 读档时先 change_map,再 apply_save_data
23. 测试在 village 存档,重启后读档仍在 village
完成这部分之后,你的 RPG 就真正从“一张地图 Demo”变成了“多地图游戏框架”。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录