2. 项目搭建
项目搭建
核心玩法设计好了,现在要把蓝图变成真正的代码工程。这章我们来搭建一个开放世界游戏的 Godot 项目。开放世界和普通游戏最大的区别是——地图特别大。如果一次性把整张地图加载到内存里,电脑直接就卡死了。所以我们得用"区块加载"的方式:只加载玩家附近的区域,远处的区域先卸载掉。
本章你将学到
- 开放世界项目怎么组织文件夹
- 大地图性能策略:区块加载和卸载
- 流式加载系统的设计思路
- 第三人称角色控制器的完整代码
项目结构设计
一个好的项目结构就像一个整理有序的工具箱——你总能快速找到需要的工具。
推荐的文件夹结构
project_root/
├── scenes/ # 场景文件
│ ├── player/ # 玩家相关场景
│ │ ├── player.tscn # 玩家主场景
│ │ └── pal_ball.tscn # 帕鲁球场景
│ ├── pals/ # 帕鲁相关场景
│ │ ├── base_pal.tscn # 帕鲁基类场景
│ │ └── pal_001.tscn # 具体帕鲁场景
│ ├── world/ # 世界相关场景
│ │ ├── chunk.tscn # 区块场景
│ │ └── biome_grassland.tscn
│ ├── buildings/ # 建筑相关场景
│ └── ui/ # UI 场景
├── scripts/ # 脚本文件
│ ├── core/ # 核心系统
│ │ ├── game_manager.gd
│ │ ├── chunk_loader.gd
│ │ └── save_system.gd
│ ├── pals/ # 帕鲁相关脚本
│ ├── world/ # 世界相关脚本
│ └── player/ # 玩家相关脚本
├── assets/ # 资源文件
│ ├── models/ # 3D 模型
│ ├── textures/ # 贴图
│ ├── audio/ # 音频
│ └── fonts/ # 字体
├── data/ # 数据文件(JSON/Resource)
│ ├── pal_data/ # 帕鲁数据
│ ├── recipes/ # 合成配方
│ └── biomes/ # 群系配置
└── project.godot # 项目配置大地图性能策略
什么问题?
假设你的游戏地图是 4km x 4km(4000 米 x 4000 米)。如果整张地图的每个物体都加载到内存里,可能需要几十 GB 的内存。普通电脑根本扛不住。
解决方案:区块(Chunk)
就像你拼拼图一样——你不会一次性把所有拼图都铺在桌上,而是先拼眼前的那几块。
核心思路:
- 把大地图切成一个一个的小方块,每个方块叫一个"区块"(Chunk)
- 只加载玩家周围的区块(比如周围 3x3 = 9 个区块)
- 玩家走远了,就把远的区块卸载掉,加载新的近处区块
区块大小怎么选?
| 区块大小 | 优点 | 缺点 |
|---|---|---|
| 64 米 x 64 米 | 加载快、内存省 | 切换太频繁、远处物体突然出现 |
| 128 米 x 128 米 | 平衡性能和体验 | 推荐新手使用 |
| 256 米 x 256 米 | 切换少、视野远 | 每个区块内容多、加载慢 |
推荐新手用 128 米的区块,加载半径 3 格(也就是玩家周围 7x7 = 49 个区块)。
流式加载系统
流式加载就是"一边走一边加载"。下面我们来实现一个简单但完整的区块加载系统。
区块加载器
// ChunkLoader.cs
// 区块加载器——负责管理地图区块的加载和卸载
using Godot;
using System.Collections.Generic;
public partial class ChunkLoader : Node3D
{
// 区块大小(米)
[Export] public int ChunkSize = 128;
// 加载半径(区块数量)
[Export] public int LoadRadius = 3;
// 区块场景
[Export] public PackedScene ChunkScene { get; set; }
// 当前已加载的区块 key -> Node3D
private Dictionary<string, Node3D> _loadedChunks = new();
// 玩家引用
private Player _player;
// 玩家当前所在的区块坐标
private Vector2I _currentChunkCoord;
public override void _Ready()
{
// 找到玩家节点
_player = GetNode<Player>("%Player");
}
public override void _Process(double delta)
{
if (_player == null) return;
// 计算玩家当前在哪个区块
Vector2I newCoord = WorldToChunkCoord(_player.GlobalPosition);
// 如果区块没变,不用重新加载
if (newCoord == _currentChunkCoord) return;
_currentChunkCoord = newCoord;
UpdateChunks();
}
// 把世界坐标转换为区块坐标
private Vector2I WorldToChunkCoord(Vector3 worldPos)
{
int x = (int)Mathf.Floor(worldPos.X / ChunkSize);
int z = (int)Mathf.Floor(worldPos.Z / ChunkSize);
return new Vector2I(x, z);
}
// 更新所有区块:卸载远的、加载近的
private void UpdateChunks()
{
var chunksToKeep = new HashSet<string>();
// 遍历加载半径内的所有区块
for (int dx = -LoadRadius; dx <= LoadRadius; dx++)
{
for (int dz = -LoadRadius; dz <= LoadRadius; dz++)
{
Vector2I coord = new Vector2I(
_currentChunkCoord.X + dx,
_currentChunkCoord.Y + dz
);
string key = $"{coord.X},{coord.Y}";
chunksToKeep.Add(key);
// 如果还没加载,就加载它
if (!_loadedChunks.ContainsKey(key))
{
LoadChunk(coord, key);
}
}
}
// 卸载不在范围内的区块
var chunksToRemove = new List<string>();
foreach (var kvp in _loadedChunks)
{
if (!chunksToKeep.Contains(kvp.Key))
{
chunksToRemove.Add(kvp.Key);
}
}
foreach (string key in chunksToRemove)
{
UnloadChunk(key);
}
}
// 加载一个区块
private void LoadChunk(Vector2I coord, string key)
{
if (ChunkScene == null) return;
Node3D chunk = ChunkScene.Instantiate<Node3D>();
chunk.Position = new Vector3(
coord.X * ChunkSize,
0,
coord.Y * ChunkSize
);
chunk.Name = $"Chunk_{key}";
AddChild(chunk);
_loadedChunks[key] = chunk;
GD.Print($"加载区块: {key}");
}
// 卸载一个区块
private void UnloadChunk(string key)
{
if (_loadedChunks.TryGetValue(key, out Node3D chunk))
{
chunk.QueueFree();
_loadedChunks.Remove(key);
GD.Print($"卸载区块: {key}");
}
}
}# chunk_loader.gd
# 区块加载器——负责管理地图区块的加载和卸载
extends Node3D
## 区块大小(米)
@export var chunk_size: int = 128
## 加载半径(区块数量)
@export var load_radius: int = 3
## 区块场景
@export var chunk_scene: PackedScene
# 当前已加载的区块 key -> Node3D
var _loaded_chunks: Dictionary = {}
# 玩家引用
var _player: CharacterBody3D
# 玩家当前所在的区块坐标
var _current_chunk_coord: Vector2i = Vector2i.ZERO
func _ready() -> void:
# 找到玩家节点
_player = get_node("%Player")
func _process(_delta: float) -> void:
if _player == null:
return
# 计算玩家当前在哪个区块
var new_coord := world_to_chunk_coord(_player.global_position)
# 如果区块没变,不用重新加载
if new_coord == _current_chunk_coord:
return
_current_chunk_coord = new_coord
update_chunks()
## 把世界坐标转换为区块坐标
func world_to_chunk_coord(world_pos: Vector3) -> Vector2i:
var x := int(floorf(world_pos.x / chunk_size))
var z := int(floorf(world_pos.z / chunk_size))
return Vector2i(x, z)
## 更新所有区块:卸载远的、加载近的
func update_chunks() -> void:
var chunks_to_keep: Dictionary = {}
# 遍历加载半径内的所有区块
for dx in range(-load_radius, load_radius + 1):
for dz in range(-load_radius, load_radius + 1):
var coord := Vector2i(
_current_chunk_coord.x + dx,
_current_chunk_coord.y + dz
)
var key := "%d,%d" % [coord.x, coord.y]
chunks_to_keep[key] = true
# 如果还没加载,就加载它
if not _loaded_chunks.has(key):
load_chunk(coord, key)
# 卸载不在范围内的区块
var chunks_to_remove: Array = []
for key in _loaded_chunks:
if not chunks_to_keep.has(key):
chunks_to_remove.append(key)
for key in chunks_to_remove:
unload_chunk(key)
## 加载一个区块
func load_chunk(coord: Vector2i, key: String) -> void:
if chunk_scene == null:
return
var chunk := chunk_scene.instantiate() as Node3D
chunk.position = Vector3(
coord.x * chunk_size,
0,
coord.y * chunk_size
)
chunk.name = "Chunk_%s" % key
add_child(chunk)
_loaded_chunks[key] = chunk
print("加载区块: %s" % key)
## 卸载一个区块
func unload_chunk(key: String) -> void:
if _loaded_chunks.has(key):
var chunk: Node3D = _loaded_chunks[key]
chunk.queue_free()
_loaded_chunks.erase(key)
print("卸载区块: %s" % key)代码说明
上面的代码做了这几件事:
- 每帧检查玩家位置:
_Process里算出玩家在哪个区块 - 判断是否需要更新:如果玩家还在同一个区块内,就什么都不做(省性能)
- 加载近处区块:把加载半径内的区块都加载进来
- 卸载远处区块:把不在范围内的区块删掉,释放内存
第三人称角色控制器
开放世界游戏通常使用第三人称视角——摄像机跟在角色身后,你能看到自己的角色。下面是一个完整的第三人称角色控制器。
场景结构
Player (CharacterBody3D)
├── Mesh (Node3D) # 角色的3D模型
│ └── Skin (Skeleton3D) # 骨骼和蒙皮
├── CollisionShape3D # 碰撞形状
├── SpringArm3D # 摄像机摇臂(控制距离和旋转)
│ └── Camera3D # 摄像机
└── RayCast3D # 地面检测射线角色控制脚本
// Player.cs
// 第三人称角色控制器
using Godot;
public partial class Player : CharacterBody3D
{
// 移动速度
[Export] public float WalkSpeed = 5.0f;
[Export] public float SprintSpeed = 8.0f;
[Export] public float JumpVelocity = 4.5f;
// 鼠标灵敏度
[Export] public float MouseSensitivity = 0.003f;
// 摄像机摇臂
private SpringArm3D _springArm;
// 当前速度
private float _currentSpeed;
// 重力
private float _gravity = (float)ProjectSettings.GetSetting("physics/3d/default_gravity");
public override void _Ready()
{
_springArm = GetNode<SpringArm3D>("SpringArm3D");
_currentSpeed = WalkSpeed;
// 捕获鼠标(用于旋转视角)
Input.MouseMode = Input.MouseModeEnum.Captured;
}
public override void _UnhandledInput(InputEvent @event)
{
// 鼠标移动 = 旋转视角
if (@event is InputEventMouseMotion mouseMotion)
{
// 水平旋转:角色左右转
RotateY(-mouseMotion.Relative.X * MouseSensitivity);
// 垂直旋转:摄像机上下看
_springArm.RotateX(-mouseMotion.Relative.Y * MouseSensitivity);
// 限制摄像机不能转到角色下方
_springArm.RotationDegrees = new Vector3(
Mathf.Clamp(_springArm.RotationDegrees.X, -70, 30),
_springArm.RotationDegrees.Y,
_springArm.RotationDegrees.Z
);
}
// ESC 键释放鼠标
if (@event.IsActionPressed("ui_cancel"))
{
Input.MouseMode = Input.MouseModeEnum.Visible;
}
}
public override void _PhysicsProcess(double delta)
{
Vector3 velocity = Velocity;
// 跑步切换
_currentSpeed = Input.IsActionPressed("sprint") ? SprintSpeed : WalkSpeed;
// 重力
if (!IsOnFloor())
{
velocity.Y -= _gravity * (float)delta;
}
// 跳跃
if (Input.IsActionJustPressed("jump") && IsOnFloor())
{
velocity.Y = JumpVelocity;
}
// 获取移动输入(WASD)
Vector2 inputDir = Input.GetVector("move_left", "move_right", "move_forward", "move_back");
// 把2D输入方向转换为3D移动方向(相对于角色朝向)
Vector3 direction = (Transform.Basis * new Vector3(inputDir.X, 0, inputDir.Y)).Normalized();
if (direction != Vector3.Zero)
{
velocity.X = direction.X * _currentSpeed;
velocity.Z = direction.Z * _currentSpeed;
}
else
{
// 没有输入时减速停下
velocity.X = Mathf.MoveToward(velocity.X, 0, _currentSpeed);
velocity.Z = Mathf.MoveToward(velocity.Z, 0, _currentSpeed);
}
Velocity = velocity;
MoveAndSlide();
}
}# player.gd
# 第三人称角色控制器
extends CharacterBody3D
## 移动速度
@export var walk_speed: float = 5.0
@export var sprint_speed: float = 8.0
@export var jump_velocity: float = 4.5
## 鼠标灵敏度
@export var mouse_sensitivity: float = 0.003
# 摄像机摇臂
@onready var spring_arm: SpringArm3D = $SpringArm3D
# 当前速度
var _current_speed: float
# 重力
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
func _ready() -> void:
_current_speed = walk_speed
# 捕获鼠标(用于旋转视角)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
# 鼠标移动 = 旋转视角
if event is InputEventMouseMotion:
var mouse_motion: InputEventMouseMotion = event
# 水平旋转:角色左右转
rotate_y(-mouse_motion.relative.x * mouse_sensitivity)
# 垂直旋转:摄像机上下看
spring_arm.rotate_x(-mouse_motion.relative.y * mouse_sensitivity)
# 限制摄像机不能转到角色下方
spring_arm.rotation_degrees = Vector3(
clampf(spring_arm.rotation_degrees.x, -70, 30),
spring_arm.rotation_degrees.y,
spring_arm.rotation_degrees.z
)
# ESC 键释放鼠标
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _physics_process(delta: float) -> void:
var velocity := velocity
# 跑步切换
_current_speed = sprint_speed if Input.is_action_pressed("sprint") else walk_speed
# 重力
if not is_on_floor():
velocity.y -= _gravity * delta
# 跳跃
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
# 获取移动输入(WASD)
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
# 把2D输入方向转换为3D移动方向(相对于角色朝向)
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
if direction != Vector3.ZERO:
velocity.x = direction.x * _current_speed
velocity.z = direction.z * _current_speed
else:
# 没有输入时减速停下
velocity.x = move_toward(velocity.x, 0, _current_speed)
velocity.z = move_toward(velocity.z, 0, _current_speed)
velocity = velocity
move_and_slide()输入映射配置
在 Godot 的 项目设置 → 输入映射 中添加以下动作:
| 动作名 | 按键 |
|---|---|
| move_forward | W, Up |
| move_back | S, Down |
| move_left | A, Left |
| move_right | D, Right |
| jump | Space |
| sprint | Shift |
| interact | E |
| open_inventory | Tab |
常见问题
Q:区块加载时会不会卡顿?
会!第一次加载一个区块时需要生成地形和放置物体,这个过程可能需要几十毫秒。解决办法是用多线程——在后台线程生成区块内容,生成完了再显示出来。Godot 4 的 Thread 类可以帮你做到这一点。
Q:摄像机穿墙怎么办?
SpringArm3D 自带碰撞检测。只要场景里有碰撞体,摄像机就不会穿墙。你可以在 SpringArm3D 的属性里设置 Shape(碰撞形状)和 Margin(碰撞边距)来微调。
Q:第三人称角色面朝移动方向怎么实现?
在 _PhysicsProcess 里,当角色在移动时,用 LookAt 让角色朝向移动方向。但要注意用 Mathf.Lerp 平滑过渡,不然转向太突兀。
Q:区块里怎么保存玩家修改过的内容(比如建了房子)?
玩家离开区块时,把区块里所有建筑的数据保存到一个字典里。下次加载同一个区块时,先加载原始地形,再把保存的建筑数据还原上去。具体实现在后面的存档系统章节会详细讲。
下一步
项目搭好了,接下来实现 帕鲁生物系统——让你的游戏有可以互动的生物。
