第五部分:Camera2D、地图边界、玩家出生点、场景组织
这一部分会让你的 RPG 场景从“角色能在地图上走”变成“像一个真正的游戏画面”。
现在我们要解决这些问题:
角色走远了,镜头怎么跟着走?
镜头会不会看到地图外面的黑边?
玩家出生点应该怎么放?
Main、World、UI 应该怎么组织?
以后切换地图时,结构怎么预留?
1. Camera2D 是什么
Camera2D 就是 2D 游戏里的相机。
它决定玩家在屏幕上看到哪一块世界。
如果没有相机,Godot 也会显示默认视图,但你没法很好地控制:
镜头跟随玩家
镜头缩放
镜头平滑移动
镜头边界
镜头震动
镜头偏移
RPG 里最常见的做法是:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D
把 Camera2D 作为 Player 的子节点。
这样 Player 移动时,Camera2D 会跟着 Player 一起移动。
这就是最简单的镜头跟随。
2. Camera2D 为什么放在 Player 下面
因为子节点会跟随父节点的位置变化。
所以:
Player 移动
↓
Camera2D 跟着移动
↓
画面跟着玩家移动
这很适合初学阶段。
你现在先别搞太复杂的独立 CameraController。
先用最稳的:
Camera2D 放在 Player 下面
结构:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D
选中 Camera2D,在 Inspector 里确认它是当前相机。
Godot 的 Camera2D 有很多用于控制镜头的属性,比如位置平滑、缩放、偏移、边界限制等;其中 position_smoothing_enabled 可以让镜头平滑靠近目标位置,zoom 可以控制视图缩放。(Godot Engine documentation)
3. Camera2D 的 Current / Enabled
一个场景里可以有多个 Camera2D。
但是同一时间真正工作的,一般只有一个。
你可以理解成:
场景里可以摆很多摄像机
但当前屏幕只能主要看一个摄像机
初学阶段,你只需要:
选中 Camera2D
Inspector
启用当前相机相关选项
或者在代码里:
func _ready():
$Camera2D.make_current()
如果你运行后画面没有跟随玩家,第一件事就检查 Camera2D 有没有成为当前相机。
4. Camera2D 的 Zoom
zoom 控制镜头缩放。
默认:
zoom = Vector2(1, 1)
如果你想看得更近:
zoom = Vector2(2, 2)
如果你想看得更远:
zoom = Vector2(0.75, 0.75)
这里有点反直觉:
数值越大,看起来越近
数值越小,看起来越远
像素 RPG 初期可以先用:
Vector2(2, 2)
这样图块和角色会更大,看起来更像复古 RPG。
但如果你的 tile 是 32x32,可能 Vector2(1, 1) 就已经够了。
5. 相机平滑 Position Smoothing
如果 Camera2D 直接跟随 Player,画面会非常“硬”。
角色一动,镜头立刻贴着动。
可以开启平滑:
Camera2D
Inspector
Position Smoothing
Enabled = true
Speed = 5 或 8
代码也可以写:
@onready var camera: Camera2D = $Camera2D
func _ready():
camera.position_smoothing_enabled = true
camera.position_smoothing_speed = 8.0
position_smoothing_enabled 开启后,相机会平滑移动到目标位置,position_smoothing_speed 控制平滑速度。(Godot Engine documentation)
简单理解:
Speed 小:镜头更慢,更柔
Speed 大:镜头更快,更紧
RPG 里不建议太慢。
否则玩家走路时会有拖影感,容易晕。
推荐先试:
5.0
8.0
10.0
6. 地图边界是什么
现在你会遇到一个问题:
玩家走到地图边缘
Camera2D 继续跟着玩家移动
屏幕外露出黑色空白
这就需要相机边界。
Camera2D 有四个边界属性:
limit_left
limit_top
limit_right
limit_bottom
它们表示相机能滚动到的最左、最上、最右、最下位置。官方文档里说明,limit_left、limit_right、limit_top 这些属性是以像素为单位的滚动边界;相机到达这些值后会停止继续滚动。(Godot Engine documentation)
比如你的地图大小是:
宽 960 像素
高 640 像素
那可以设置:
camera.limit_left = 0
camera.limit_top = 0
camera.limit_right = 960
camera.limit_bottom = 640
这样相机就不会无限看到地图外面。
7. 手动设置相机边界
最简单方法是手动填。
选中 Camera2D:
Inspector
Limit
Left = 0
Top = 0
Right = 地图宽度
Bottom = 地图高度
例如你的地图是:
30 格宽
20 格高
每格 32 像素
那地图像素大小是:
宽:30 * 32 = 960
高:20 * 32 = 640
相机边界:
Left = 0
Top = 0
Right = 960
Bottom = 640
如果你的 tile 是 16x16:
30 * 16 = 480
20 * 16 = 320
所以你一定要知道你的 tile 尺寸。
8. 用代码根据 TileMapLayer 自动设置边界
后面地图多了,手动填会麻烦。
可以写一个简单方法,根据 Ground 的使用范围自动设置相机边界。
假设结构是:
Main
└── World
├── Ground TileMapLayer
└── Player
└── Camera2D
在 Player 脚本里导出一个 TileMapLayer:
@export var ground_layer: TileMapLayer
@onready var camera: Camera2D = $Camera2D
func _ready():
setup_camera_limits()
func setup_camera_limits():
if ground_layer == null:
return
var used_rect: Rect2i = ground_layer.get_used_rect()
var tile_size: Vector2i = ground_layer.tile_set.tile_size
camera.limit_left = used_rect.position.x * tile_size.x
camera.limit_top = used_rect.position.y * tile_size.y
camera.limit_right = used_rect.end.x * tile_size.x
camera.limit_bottom = used_rect.end.y * tile_size.y
然后在 Inspector 里,把 Ground 拖到 Player 的 ground_layer 上。
这段的核心思想是:
Ground 画了多大
↓
相机边界就设置多大
注意:这个版本适合 Ground 从原点附近开始、不做复杂缩放的情况。以后你地图节点有偏移、缩放、多层嵌套时,需要进一步把坐标转换成 global 坐标。初学阶段先不折腾那个,先跑通。
9. 相机边界常见问题
如果你设置了边界,但没生效,检查这些:
Camera2D 是不是当前相机
Limit Enabled 是否开启
limit_right 是否大于 limit_left
limit_bottom 是否大于 limit_top
地图尺寸是不是算错了
Player 是否已经跑到边界外
Camera2D 的 offset 是否让画面越界
Camera2D 文档里提到,关闭 limits 后,四个 limit_* 属性不会起作用;另外,offset 可以把视图推到限制范围之外。(Godot Engine documentation)
所以如果你后面做镜头震动,发现边界附近会露黑边,可能就是 offset 或 shake 效果把相机画面推出去了。
10. Marker2D 是什么
Marker2D 是一个非常适合当“位置标记”的节点。
它自己不会显示到游戏运行画面里,但在编辑器里会显示一个十字标记。官方文档也说,Marker2D 是一个通用的 2D 位置提示节点,类似普通 Node2D,但在 2D 编辑器里始终显示为十字。(Godot Engine documentation)
它很适合做:
玩家出生点
怪物刷新点
NPC 站位点
传送目标点
剧情触发位置
镜头参考点
比如:
World
├── Ground
├── Obstacles
├── SpawnPoints Node2D
│ ├── PlayerSpawn Marker2D
│ ├── SlimeSpawn01 Marker2D
│ └── SlimeSpawn02 Marker2D
└── Player
11. 玩家出生点怎么做
不要把玩家位置写死在代码里:
position = Vector2(100, 100)
短期能用,但后面地图多了会很烦。
更好的做法是放一个 Marker2D:
World
├── SpawnPoints Node2D
│ └── PlayerSpawn Marker2D
└── Player CharacterBody2D
然后让 Player 出现在这个点:
@onready var player: CharacterBody2D = $Player
@onready var player_spawn: Marker2D = $SpawnPoints/PlayerSpawn
func _ready():
player.global_position = player_spawn.global_position
这段代码可以放在 World.gd 里。
12. 为什么出生点不要放在 Player 里面
有些人会把 Marker2D 放在 Player 下面。
不推荐。
因为出生点是地图的一部分,不是玩家的一部分。
Player 是要被放到出生点
不是出生点跟着 Player 走
正确思路是:
地图决定玩家出生在哪里
Player 只是被放过去
所以出生点应该放在 World 或具体地图场景里。
例如:
ForestMap
├── Ground
├── Obstacles
├── SpawnPoints
│ └── PlayerSpawn
└── Foreground
Player 进入 ForestMap 时,被放到 PlayerSpawn 的位置。
13. Main、World、UI 的推荐结构
你的主场景建议这样:
Main Node
├── World Node2D
└── UI CanvasLayer
这是一个很经典的结构。
Godot 官方的场景组织建议里也提到,游戏应该有一个明确入口点;在 Godot 里通常是 Main 节点,World 可以作为 Main 的子节点,GUI 也作为主界面部分存在。(Godot Engine documentation)
你可以这样理解:
Main:游戏总入口,负责组织大结构
World:游戏世界,地图、玩家、NPC、怪物都在这里
UI:屏幕界面,血条、背包、对话框都在这里
前端类比:
App
├── GameWorld
└── HUD
Godot:
Main
├── World
└── UI
14. 为什么 UI 要放 CanvasLayer
UI 不应该跟着地图一起移动。
比如:
血条
背包按钮
任务提示
金币数量
对话框
这些应该固定在屏幕上。
如果 UI 放在 World 里面,Camera2D 移动时,UI 可能跟着地图跑。
CanvasLayer 正是为这种情况准备的:它会创建独立的 2D 渲染层,层级数字越大越靠上,并且它有自己的 transform,不依赖其他层的 transform,因此可以让 UI 固定在屏幕空间里。(Godot Engine documentation)
推荐:
Main
├── World Node2D
└── UI CanvasLayer
├── HPBar
├── BagButton
└── DialoguePanel
这样玩家走到哪里,UI 都稳稳待在屏幕上。
15. 第一阶段 Main 场景结构
你现在可以按这个来:
Main Node
├── World Node2D
│ ├── Ground TileMapLayer
│ ├── Obstacles TileMapLayer
│ ├── SpawnPoints Node2D
│ │ └── PlayerSpawn Marker2D
│ ├── Player CharacterBody2D
│ │ ├── AnimatedSprite2D
│ │ ├── CollisionShape2D
│ │ └── Camera2D
│ └── Foreground TileMapLayer
└── UI CanvasLayer
└── DebugLabel Label
这个结构足够你做第一张可玩的地图。
先实现:
玩家出生
玩家移动
相机跟随
相机边界
地图碰撞
UI 固定
不要再往里塞系统了。
天气、四季、卡牌、背包、任务,都后面慢慢加。
16. World.gd 应该负责什么
World.gd 适合负责“这个地图场景启动时要做什么”。
比如:
把玩家放到出生点
设置相机边界
生成当前地图的怪物
初始化地图环境
连接地图内信号
一个简单的 World.gd:
extends Node2D
@onready var player: CharacterBody2D = $Player
@onready var player_spawn: Marker2D = $SpawnPoints/PlayerSpawn
@onready var ground: TileMapLayer = $Ground
@onready var camera: Camera2D = $Player/Camera2D
func _ready():
setup_player_spawn()
setup_camera_limits()
func setup_player_spawn():
player.global_position = player_spawn.global_position
func setup_camera_limits():
var used_rect: Rect2i = ground.get_used_rect()
var tile_size: Vector2i = ground.tile_set.tile_size
camera.limit_left = used_rect.position.x * tile_size.x
camera.limit_top = used_rect.position.y * tile_size.y
camera.limit_right = used_rect.end.x * tile_size.x
camera.limit_bottom = used_rect.end.y * tile_size.y
这个脚本非常适合现在用。
17. Player.gd 应该负责什么
Player.gd 不要什么都管。
它主要管自己:
输入
移动
动画
血量
攻击
交互检测
不要让 Player 去管:
地图怎么加载
UI 怎么创建
天气怎么变化
场景怎么切换
怪物怎么生成
也就是说,不要写成:
func _ready():
create_map()
create_ui()
spawn_all_enemies()
setup_weather()
这会让 Player 变成“上帝对象”,后面必炸。
Player 现在保持简单:
extends CharacterBody2D
@export var speed: float = 120.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var last_direction: Vector2 = Vector2.DOWN
func _physics_process(delta):
var direction = Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
velocity = direction * speed
move_and_slide()
update_animation(direction)
func update_animation(direction: Vector2):
if direction == Vector2.ZERO:
animated_sprite.play("idle_" + get_direction_name(last_direction))
return
last_direction = direction
animated_sprite.play("walk_" + get_direction_name(direction))
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
return "right" if direction.x > 0 else "left"
return "down" if direction.y > 0 else "up"
18. Main.gd 应该负责什么
Main.gd 适合做更高层的组织。
比如:
当前加载哪个 World
切换地图
暂停游戏
连接 UI 和 World
管理游戏整体状态
第一阶段你甚至可以不写 Main.gd。
让 World.gd 先工作就行。
但你要知道后面会变成:
Main
├── CurrentWorld
└── UI
以后切换地图时,你可以把旧 World 移除,再实例化新 World。
19. 为以后切换地图预留结构
现在先不要直接用很多个完整主场景来回切。
比如:
ForestMain.tscn
TownMain.tscn
HouseMain.tscn
不太推荐这样开局。
更推荐:
Main.tscn
├── WorldContainer Node2D
└── UI CanvasLayer
然后不同地图是独立场景:
ForestMap.tscn
TownMap.tscn
HouseInterior.tscn
运行时把地图实例放进 WorldContainer:
Main
├── WorldContainer
│ └── ForestMap
└── UI
切换到室内:
Main
├── WorldContainer
│ └── HouseInterior
└── UI
这样 UI 不会被删掉,整体结构也更稳。
Godot 的最佳实践文档也提到,切换关卡时可以替换 World 节点的子节点;如果直接切换整个主场景,GUI 也可能会随场景一起被删除,所以 GUI 需要作为单例、World 的临时部分,或手动作为 root 的直接子节点来处理。(Godot Engine documentation)
20. 简单场景切换方式
Godot 里最简单的切换场景是:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
SceneTree 负责管理节点层级和场景本身,可以加载、切换和重载场景;官方也提到,基础场景切换可以使用 SceneTree.change_scene_to_file()。(Godot Engine documentation)
但是对 RPG 来说,后面你大概率会想保留:
UI
玩家数据
背包
任务状态
时间系统
天气系统
所以不一定每次都切整个主场景。
更好的思路是:
Main 不换
UI 不换
只换 WorldContainer 里的地图
这对你后面做室内/室外切换很重要。
21. 地图切换的简化版代码
先看结构:
Main Node
├── WorldContainer Node2D
└── UI CanvasLayer
Main.gd:
extends Node
@onready var world_container: Node2D = $WorldContainer
var current_world: Node = null
func change_world(scene_path: String):
if current_world != null:
current_world.queue_free()
var world_scene: PackedScene = load(scene_path)
current_world = world_scene.instantiate()
world_container.add_child(current_world)
调用:
change_world("res://scenes/maps/forest_map.tscn")
切室内:
change_world("res://scenes/maps/house_interior.tscn")
这只是基础版。
以后还要处理:
从哪个门进入
出生在哪个 Marker2D
是否播放转场动画
是否保存旧地图状态
但别急,现在知道结构就行。
22. 传送点怎么设计
比如你从室外进入房子。
可以做一个 Area2D:
DoorToHouse Area2D
├── CollisionShape2D
└── Sprite2D 可选
玩家进入区域并按 E:
触发切换地图
门节点可以导出目标地图路径:
extends Area2D
@export var target_scene: String
@export var target_spawn_name: String = "PlayerSpawn"
以后进入门时:
切到 target_scene
把玩家放到 target_spawn_name 对应的 Marker2D
但这是后面“交互系统”和“地图切换系统”的内容。
现在你只要知道:
门不是 tile
门应该是有逻辑的 Area2D / Scene
因为门需要触发事件。
23. 第一张地图要不要拆成独立场景
建议拆。
不要把所有东西都堆在 Main.tscn。
推荐:
Main.tscn
ForestMap.tscn
Player.tscn
UI.tscn
结构大概是:
Main.tscn
├── WorldContainer
│ └── ForestMap 实例
└── UI 实例
ForestMap.tscn:
ForestMap Node2D
├── Ground TileMapLayer
├── Obstacles TileMapLayer
├── SpawnPoints
│ └── PlayerSpawn Marker2D
├── Player 实例
└── Foreground TileMapLayer
Player.tscn:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D
这样你之后复用 Player、替换地图都会方便。
24. 但玩家到底放在地图里,还是放在 Main 里?
这问题很关键。
初学阶段,建议:
Player 先放在地图里
也就是:
ForestMap
├── Ground
├── Obstacles
├── Player
└── Foreground
这样最直观,最好调试。
后面项目复杂了,可以改成:
Main
├── Player
├── WorldContainer
│ └── CurrentMap
└── UI
或者切地图时保留 Player,只换地图。
但现在不要一上来搞“完美架构”。
你现在最重要的是:
地图能跑
玩家能走
相机能跟
碰撞能挡
先活下来,再讲优雅。
25. DebugLabel 很有用
建议你在 UI 里放一个 DebugLabel。
结构:
UI CanvasLayer
└── DebugLabel Label
显示玩家坐标:
extends CanvasLayer
@export var player: CharacterBody2D
@onready var debug_label: Label = $DebugLabel
func _process(delta):
if player == null:
return
debug_label.text = "Player: %s" % player.global_position
这对调试出生点、相机边界、地图坐标很有帮助。
你会很直观地看到:
玩家当前在哪里
坐标是不是负数
地图边界大概是多少
小项目可以先简单粗暴点,别嫌丑。调试 UI 是游戏开发里的扳手,不是艺术品。
26. Camera2D 和 DebugLabel 的常见坑
如果 DebugLabel 跟着地图移动了,说明它没放在 CanvasLayer 里。
正确:
Main
├── World
│ └── Player
│ └── Camera2D
└── UI CanvasLayer
└── DebugLabel
错误:
World
├── Player
│ └── Camera2D
└── DebugLabel
因为错误结构里,Label 在 World 里,会受相机影响。
27. 地图边界和玩家边界不是一回事
相机边界只限制镜头。
它不会阻止玩家走出地图。
比如:
camera.limit_right = 960
只是相机不会继续往右显示。
但玩家如果没有墙或边界碰撞,仍然可以走出地图。
所以你还需要地图边缘有碰撞:
边界墙
树墙
河流
悬崖
InvisibleWall
最简单做法:
用 Obstacles TileMapLayer 在地图边缘围一圈不可通行 tile
后面可以做一个 InvisibleWall:
StaticBody2D
└── CollisionShape2D
但第一阶段,用 tile 围边就够了。
28. 第一张地图的推荐尺寸
建议第一张测试地图不要太大。
用:
30 x 20
或者:
40 x 25
如果 tile 是 32x32:
30 x 20 = 960 x 640 像素
40 x 25 = 1280 x 800 像素
够你测试:
移动
碰撞
相机跟随
边界
遮挡
出生点
地图太大只会让你调试变慢。
29. 当前阶段推荐的完整结构
我建议你现在最终搭成这样:
Main Node
├── World Node2D
│ ├── Ground TileMapLayer
│ ├── Obstacles TileMapLayer
│ ├── SpawnPoints Node2D
│ │ └── PlayerSpawn Marker2D
│ ├── Player CharacterBody2D
│ │ ├── AnimatedSprite2D
│ │ ├── CollisionShape2D
│ │ └── Camera2D
│ └── Foreground TileMapLayer
└── UI CanvasLayer
└── DebugLabel Label
脚本分配:
World.gd:出生点、相机边界
Player.gd:移动、动画
UI.gd:调试显示、以后血条
这就是第一阶段的“干净骨架”。
30. 本部分最重要的记忆点
Camera2D 控制玩家看到哪里
Camera2D 放 Player 下面最简单
Position Smoothing 可以让镜头更柔
Limit 可以限制相机不要看到地图外
Marker2D 很适合做玩家出生点
出生点属于地图,不属于玩家
Main 是游戏入口
World 是游戏世界
UI 应该放 CanvasLayer
相机边界不会阻止玩家出地图
地图边缘还需要碰撞
31. 你现在的小练习
按这个目标做:
1. 给 Player 添加 Camera2D
2. 让 Camera2D 成为当前相机
3. 开启 Position Smoothing
4. 设置 Camera2D 的 Limit
5. 在 World 里添加 SpawnPoints/PlayerSpawn
6. 运行时把 Player 放到 PlayerSpawn
7. UI 里放 DebugLabel 显示玩家坐标
8. 用 Obstacles 在地图边缘围一圈碰撞
做完这一部分,你的项目就已经有“基础 RPG 场景”的样子了。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录