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

2026-05-19
496317 分钟
...

这一部分非常关键。

前面我们已经做到了:

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

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

玩家怎么从森林进入村庄
怎么从室外进入房子
怎么从房子出来回到门口
怎么从读档进入指定地图
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 不用手动拖 QuestManagerDialogueBox,加载地图后会自动注入。

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切地图时保留长期状态
地图负责 TileMapNPC怪物出生点
Door 只需要 target_map_id target_spawn_id
SpawnPoints Marker2D 管理不同入口
SceneManager 适合做 Autoload
读档时要先切到存档地图再恢复玩家位置
切地图时要清理 Player 当前交互对象
切地图后要更新 Camera2D 边界

52. 你现在的小练习

按这个顺序做:

1. 创建 SceneManager.gd
2. 设置为 AutoloadSceneManager
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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...