第十九部分:场景切换与地图状态:SceneManager、门传送、室内外切换、读档进入指定地图

2026-05-19
4963 字约 17 分钟
...

这一部分非常关键。

前面我们已经做到了:

玩家能移动
能交互
能战斗
能对话
能接任务
能存档

但现在还有一个很大的问题:

玩家怎么从森林进入村庄?
怎么从室外进入房子?
怎么从房子出来回到门口?
怎么从读档进入指定地图?
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”变成了“多地图游戏框架”。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...