2. 项目搭建
项目搭建
本章我们从零开始搭建大逃杀项目的基础框架。包括创建项目、配置大地图、设置输入映射、以及搭建视角切换系统。
本章你将学到
- 创建 Godot 4.6 大逃杀项目
- 大地图配置与性能设置
- 完整的输入映射配置
- 第一/三人称视角切换系统
- 角色场景结构设计
项目结构概览
先看看我们的项目最终会长什么样:
创建项目
步骤1:新建项目
打开 Godot 4.6,点击"新建项目":
- 项目名称:
BattleRoyale - 渲染器:选择 Forward+(桌面端最佳画质)
- 项目路径:选择你的工作目录
为什么选 Forward+?
Forward+ 渲染器是 Godot 4.6 中画质最好的选择,支持更多光源和后处理效果。如果你的目标平台是手机,可以用 Mobile 渲染器;如果追求最广兼容性,用 Compatibility 渲染器。
步骤2:创建目录结构
在 Godot 的文件系统面板(底部"文件系统"标签页)中,右键 res:// 创建以下文件夹:
res://
├── scenes/
│ ├── main/ # 主场景和大厅
│ ├── player/ # 玩家相关场景
│ ├── weapons/ # 武器场景
│ ├── vehicles/ # 载具场景
│ ├── ui/ # UI 场景
│ ├── environment/ # 地图和环境
│ └── effects/ # 特效场景
├── scripts/
│ ├── player/ # 玩家脚本
│ ├── weapons/ # 武器脚本
│ ├── systems/ # 游戏系统脚本
│ ├── ui/ # UI 脚本
│ ├── network/ # 网络脚本
│ └── utils/ # 工具类
├── assets/
│ ├── models/ # 3D 模型
│ ├── textures/ # 贴图
│ ├── sounds/ # 音效
│ ├── fonts/ # 字体
│ └── shaders/ # 着色器
└── project.godot大地图配置
大逃杀的地图通常是 8km x 8km 的规模。在 Godot 中,我们需要对项目做一些特殊配置来支持这么大的地图。
WorldBoundaries 设置
在 project.godot 中,我们可以设置世界的边界。但更灵活的做法是在代码中动态设置:
// scripts/systems/GameWorld.cs
using Godot;
public partial class GameWorld : Node3D
{
// 地图大小:8000 x 8000 米
[Export] public float MapSize { get; set; } = 8000f;
// 地图中心点
[Export] public Vector3 MapCenter { get; set; } = new Vector3(4000f, 0f, 4000f);
public override void _Ready()
{
// 设置物理世界的边界(防止物体飞出地图)
// Godot 4.6 中通过 PhysicsServer3D 设置
var worldBounds = new AABB(
new Vector3(0f, -100f, 0f),
new Vector3(MapSize, 1000f, MapSize)
);
GD.Print($"游戏世界初始化完成,地图大小: {MapSize}m x {MapSize}m");
GD.Print($"地图中心: {MapCenter}");
}
// 检查一个位置是否在地图范围内
public bool IsInBounds(Vector3 position)
{
return position.X >= 0 && position.X <= MapSize
&& position.Z >= 0 && position.Z <= MapSize;
}
// 把超出边界的坐标限制回地图内
public Vector3 ClampToMap(Vector3 position)
{
return new Vector3(
Mathf.Clamp(position.X, 0f, MapSize),
position.Y,
Mathf.Clamp(position.Z, 0f, MapSize)
);
}
}# scripts/systems/game_world.gd
extends Node3D
class_name GameWorld
# 地图大小:8000 x 8000 米
@export var map_size: float = 8000.0
# 地图中心点
@export var map_center: Vector3 = Vector3(4000.0, 0.0, 4000.0)
func _ready():
# 设置物理世界的边界(防止物体飞出地图)
var world_bounds = AABB(
Vector3(0.0, -100.0, 0.0),
Vector3(map_size, 1000.0, map_size)
)
print("游戏世界初始化完成,地图大小: ", map_size, "m x ", map_size, "m")
print("地图中心: ", map_center)
## 检查一个位置是否在地图范围内
func is_in_bounds(position: Vector3) -> bool:
return position.x >= 0 and position.x <= map_size \
and position.z >= 0 and position.z <= map_size
## 把超出边界的坐标限制回地图内
func clamp_to_map(position: Vector3) -> Vector3:
return Vector3(
clampf(position.x, 0.0, map_size),
position.y,
clampf(position.z, 0.0, map_size)
)地图区域划分
大地图通常划分成若干个有名字的区域,方便玩家交流和定位:
// scripts/systems/MapZone.cs
using Godot;
using System.Collections.Generic;
public partial class MapZone : Node3D
{
[Export] public string ZoneName { get; set; } = "未命名区域";
[Export] public float ZoneRadius { get; set; } = 500f;
[Export] public ZoneType Type { get; set; } = ZoneType.Town;
// 区域内物资密度(越高物资越多)
[Export] public float LootDensity { get; set; } = 1.0f;
public enum ZoneType
{
Town, // 城镇 - 密集建筑,物资丰富
Industrial, // 工业区 - 大型建筑,稀有物资
Rural, // 乡村 - 稀疏建筑,物资少
Military, // 军事区 - 高级武器
Water // 水域 - 无法进入
}
public override void _Ready()
{
GD.Print($"区域 [{ZoneName}] 类型: {Type} 物资密度: {LootDensity}");
}
// 判断一个位置是否在此区域内
public bool IsInZone(Vector3 worldPosition)
{
var distance = new Vector2(
worldPosition.X - GlobalPosition.X,
worldPosition.Z - GlobalPosition.Z
).Length();
return distance <= ZoneRadius;
}
}# scripts/systems/map_zone.gd
extends Node3D
class_name MapZone
## 区域名称
@export var zone_name: String = "未命名区域"
## 区域半径
@export var zone_radius: float = 500.0
## 区域类型
@export var type: ZoneType = ZoneType.TOWN
## 物资密度(越高物资越多)
@export var loot_density: float = 1.0
enum ZoneType {
TOWN, # 城镇 - 密集建筑,物资丰富
INDUSTRIAL, # 工业区 - 大型建筑,稀有物资
RURAL, # 乡村 - 稀疏建筑,物资少
MILITARY, # 军事区 - 高级武器
WATER # 水域 - 无法进入
}
func _ready():
print("区域 [", zone_name, "] 类型: ", ZoneType.keys()[type], " 物资密度: ", loot_density)
## 判断一个位置是否在此区域内
func is_in_zone(world_position: Vector3) -> bool:
var distance = Vector2(
world_position.x - global_position.x,
world_position.z - global_position.z
).length()
return distance <= zone_radius性能配置
大地图需要特别注意性能。在 项目 → 项目设置 中做以下调整:
| 设置项 | 路径 | 推荐值 | 说明 |
|---|---|---|---|
| 渲染距离 | Rendering → Limits → Spatial Indexer | 2000 | 超出距离的物体不渲染 |
| 阴影距离 | Rendering → Quality → Shadow | 100 | 远处不开阴影 |
| 最大光源数 | Rendering → Limits → Cluster | 32 | 限制同时渲染的光源 |
| LOD 阈值 | Rendering → LOD | 启用 | 距离远的模型自动简化 |
| 网格合并 | Rendering → Misc | 启用 | 静态物体合并减少 Draw Call |
输入映射完整设置
输入映射就是告诉 Godot "按哪个键对应什么动作"。我们的大逃杀游戏需要大量的按键绑定。
在 project.godot 中配置
打开 项目 → 项目设置 → 输入映射 选项卡,添加以下动作:
| 动作名称 | 默认按键 | 用途 |
|---|---|---|
move_forward | W | 向前走 |
move_backward | S | 向后退 |
move_left | A | 向左走 |
move_right | D | 向右走 |
jump | Space | 跳跃 |
sprint | Shift | 冲刺 |
crouch | C | 蹲下 |
prone | Z | 趴下 |
shoot | 鼠标左键 | 射击 |
aim | 鼠标右键 | 瞄准(开镜) |
reload | R | 换弹 |
switch_weapon_1 | 1 | 切换到武器栏位1 |
switch_weapon_2 | 2 | 切换到武器栏位2 |
switch_weapon_3 | 3 | 切换到武器栏位3 |
toggle_inventory | Tab | 打开/关闭背包 |
interact | F | 交互(拾取、开门等) |
toggle_view | V | 切换第一/三人称 |
use_health_kit | 5 | 使用医疗包 |
use_boost | 6 | 使用能量饮料 |
map | M | 打开大地图 |
throw_grenade | G | 投掷手雷 |
cancel | Escape | 取消/暂停 |
用代码配置输入映射(备选方案)
如果你更喜欢用代码来管理,也可以在脚本中动态添加:
// scripts/utils/InputSetup.cs
using Godot;
public static class InputSetup
{
public static void SetupInputs()
{
// 移动相关
AddAction("move_forward", Key.W);
AddAction("move_backward", Key.S);
AddAction("move_left", Key.A);
AddAction("move_right", Key.D);
// 动作相关
AddAction("jump", Key.Space);
AddAction("sprint", Key.Shift);
AddAction("crouch", Key.C);
AddAction("prone", Key.Z);
// 战斗相关
AddMouseAction("shoot", MouseButton.Left);
AddMouseAction("aim", MouseButton.Right);
AddAction("reload", Key.R);
// 武器切换
AddAction("switch_weapon_1", Key.Key1);
AddAction("switch_weapon_2", Key.Key2);
AddAction("switch_weapon_3", Key.Key3);
// 系统相关
AddAction("toggle_inventory", Key.Tab);
AddAction("interact", Key.F);
AddAction("toggle_view", Key.V);
}
private static void AddAction(string actionName, Key key)
{
if (!InputMap.HasAction(actionName))
{
InputMap.AddAction(actionName);
}
var event = new InputEventKey
{
Keycode = key
};
InputMap.ActionAddEvent(actionName, event);
}
private static void AddMouseAction(string actionName, MouseButton button)
{
if (!InputMap.HasAction(actionName))
{
InputMap.AddAction(actionName);
}
var event = new InputEventMouseButton
{
ButtonIndex = button
};
InputMap.ActionAddEvent(actionName, event);
}
}# scripts/utils/input_setup.gd
class_name InputSetup
static func setup_inputs():
# 移动相关
_add_action("move_forward", KEY_W)
_add_action("move_backward", KEY_S)
_add_action("move_left", KEY_A)
_add_action("move_right", KEY_D)
# 动作相关
_add_action("jump", KEY_SPACE)
_add_action("sprint", KEY_SHIFT)
_add_action("crouch", KEY_C)
_add_action("prone", KEY_Z)
# 战斗相关
_add_mouse_action("shoot", MOUSE_BUTTON_LEFT)
_add_mouse_action("aim", MOUSE_BUTTON_RIGHT)
_add_action("reload", KEY_R)
# 武器切换
_add_action("switch_weapon_1", KEY_1)
_add_action("switch_weapon_2", KEY_2)
_add_action("switch_weapon_3", KEY_3)
# 系统相关
_add_action("toggle_inventory", KEY_TAB)
_add_action("interact", KEY_F)
_add_action("toggle_view", KEY_V)
static func _add_action(action_name: String, key_code: int):
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
var event = InputEventKey.new()
event.keycode = key_code
InputMap.action_add_event(action_name, event)
static func _add_mouse_action(action_name: String, button_index: int):
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
var event = InputEventMouseButton.new()
event.button_index = button_index
InputMap.action_add_event(action_name, event)角色场景结构
玩家角色是我们游戏的核心场景。它的结构如下:
创建角色场景
- 新建场景,根节点选择 CharacterBody3D,命名为
Player - 按照上面的结构添加子节点
- 保存到
scenes/player/Player.tscn
// scripts/player/Player.cs
using Godot;
public partial class Player : CharacterBody3D
{
// 移动参数
[Export] public float WalkSpeed { get; set; } = 5.0f;
[Export] public float SprintSpeed { get; set; } = 8.0f;
[Export] public float CrouchSpeed { get; set; } = 2.5f;
[Export] public float ProneSpeed { get; set; } = 1.0f;
[Export] public float JumpVelocity { get; set; } = 4.5f;
// 视角参数
[Export] public float MouseSensitivity { get; set; } = 0.003f;
[Export] public float MinLookAngle { get; set; } = -80f;
[Export] public float MaxLookAngle { get; set; } = 80f;
// 视角模式
public enum ViewMode { FirstPerson, ThirdPerson }
private ViewMode _currentView = ViewMode.ThirdPerson;
// 节点引用
private Node3D _cameraPivot;
private SpringArm3D _springArm;
private Camera3D _camera;
private Node3D _weaponPivot;
// 角色状态
private float _gravity = ProjectSettings.GetSetting("physics/3d/default_gravity").AsSingle();
private Vector2 _lookRotation = Vector2.Zero;
public override void _Ready()
{
// 获取节点引用
_cameraPivot = GetNode<Node3D>("CameraPivot");
_springArm = GetNode<SpringArm3D>("CameraPivot/SpringArm3D");
_camera = GetNode<Camera3D>("CameraPivot/SpringArm3D/Camera3D");
_weaponPivot = GetNode<Node3D>("WeaponPivot");
// 捕获鼠标
Input.MouseMode = Input.MouseModeEnum.Captured;
GD.Print("玩家角色初始化完成");
}
public override void _UnhandledInput(InputEvent @event)
{
// 鼠标移动控制视角
if (@event is InputEventMouseMotion mouseMotion)
{
_lookRotation.Y -= mouseMotion.Relative.X * MouseSensitivity;
_lookRotation.X -= mouseMotion.Relative.Y * MouseSensitivity;
_lookRotation.X = Mathf.Clamp(_lookRotation.X,
Mathf.DegToRad(MinLookAngle),
Mathf.DegToRad(MaxLookAngle));
}
// 切换视角
if (Input.IsActionJustPressed("toggle_view"))
{
ToggleViewMode();
}
// 释放鼠标(调试用)
if (Input.IsActionJustPressed("cancel"))
{
if (Input.MouseMode == Input.MouseModeEnum.Captured)
Input.MouseMode = Input.MouseModeEnum.Visible;
else
Input.MouseMode = Input.MouseModeEnum.Captured;
}
}
public override void _PhysicsProcess(double delta)
{
// 应用重力
if (!IsOnFloor())
Velocity += new Vector3(0, -_gravity * (float)delta, 0);
// 跳跃
if (Input.IsActionJustPressed("jump") && IsOnFloor())
Velocity = new Vector3(Velocity.X, JumpVelocity, Velocity.Z);
// 计算移动方向
Vector2 inputDir = Input.GetVector(
"move_left", "move_right", "move_forward", "move_backward");
Vector3 direction = new Transform3D(
Basis.Identity.Rotated(Vector3.Up, _lookRotation.Y),
Vector3.Zero
).Orthonormalized() * new Vector3(inputDir.X, 0, inputDir.Y);
// 根据状态选择速度
float speed = WalkSpeed;
if (Input.IsActionPressed("sprint")) speed = SprintSpeed;
if (direction != Vector3.Zero)
{
Velocity = new Vector3(
direction.X * speed,
Velocity.Y,
direction.Z * speed
);
}
else
{
Velocity = new Vector3(0, Velocity.Y, 0);
}
// 旋转角色朝向移动方向
if (direction != Vector3.Zero)
{
var targetRotation = Mathf.Atan2(direction.X, direction.Z);
Rotation = new Vector3(0, targetRotation, 0);
}
// 更新摄像机
_cameraPivot.Rotation = new Vector3(_lookRotation.X, 0, 0);
Rotation = new Vector3(0, _lookRotation.Y, 0);
MoveAndSlide();
}
private void ToggleViewMode()
{
if (_currentView == ViewMode.ThirdPerson)
{
_currentView = ViewMode.FirstPerson;
_springArm.SpringLength = 0f;
GD.Print("切换到第一人称视角");
}
else
{
_currentView = ViewMode.ThirdPerson;
_springArm.SpringLength = 2.5f;
GD.Print("切换到第三人称视角");
}
}
}# scripts/player/player.gd
extends CharacterBody3D
class_name Player
# 移动参数
@export var walk_speed: float = 5.0
@export var sprint_speed: float = 8.0
@export var crouch_speed: float = 2.5
@export var prone_speed: float = 1.0
@export var jump_velocity: float = 4.5
# 视角参数
@export var mouse_sensitivity: float = 0.003
@export var min_look_angle: float = -80.0
@export var max_look_angle: float = 80.0
# 视角模式
enum ViewMode { FIRST_PERSON, THIRD_PERSON }
var _current_view: ViewMode = ViewMode.THIRD_PERSON
# 节点引用
@onready var _camera_pivot: Node3D = $CameraPivot
@onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D
@onready var _weapon_pivot: Node3D = $WeaponPivot
# 角色状态
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var _look_rotation: Vector2 = Vector2.ZERO
func _ready():
# 捕获鼠标
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
print("玩家角色初始化完成")
func _unhandled_input(event: InputEvent):
# 鼠标移动控制视角
if event is InputEventMouseMotion:
_look_rotation.y -= event.relative.x * mouse_sensitivity
_look_rotation.x -= event.relative.y * mouse_sensitivity
_look_rotation.x = clampf(_look_rotation.x,
deg_to_rad(min_look_angle),
deg_to_rad(max_look_angle))
# 切换视角
if Input.is_action_just_pressed("toggle_view"):
toggle_view_mode()
# 释放鼠标(调试用)
if Input.is_action_just_pressed("cancel"):
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _physics_process(delta: float):
# 应用重力
if not is_on_floor():
velocity.y -= _gravity * delta
# 跳跃
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
# 计算移动方向
var input_dir := Input.get_vector(
"move_left", "move_right", "move_forward", "move_backward")
var direction := Transform3D(
Basis.IDENTITY.rotated(Vector3.UP, _look_rotation.y),
Vector3.ZERO
).orthonormalized() * Vector3(input_dir.x, 0, input_dir.y)
# 根据状态选择速度
var speed := walk_speed
if Input.is_action_pressed("sprint"):
speed = sprint_speed
if direction != Vector3.ZERO:
velocity.x = direction.x * speed
velocity.z = direction.z * speed
else:
velocity.x = 0.0
velocity.z = 0.0
# 更新摄像机
_camera_pivot.rotation = Vector3(_look_rotation.x, 0, 0)
rotation = Vector3(0, _look_rotation.y, 0)
move_and_slide()
func toggle_view_mode():
if _current_view == ViewMode.THIRD_PERSON:
_current_view = ViewMode.FIRST_PERSON
_spring_arm.spring_length = 0.0
print("切换到第一人称视角")
else:
_current_view = ViewMode.THIRD_PERSON
_spring_arm.spring_length = 2.5
print("切换到第三人称视角")SpringArm3D 相机遮挡处理
在第三人称视角中,摄像机可能会穿到墙壁里。SpringArm3D 就是用来解决这个问题的——它像一根弹簧,遇到障碍物会自动缩短,把摄像机拉回来。
SpringArm3D 原理示意:
正常状态:
角色 ----[弹簧长度 2.5m]---- 摄像机
碰到墙壁:
角色 --[被压缩到 0.8m]-- 摄像机
|墙|设置 SpringArm3D 的关键参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Spring Length | 2.5 | 默认弹簧长度(摄像机离角色的距离) |
| Shape | SphereShape3D | 碰撞检测用的形状 |
| Collision Mask | 1 (Environment) | 只检测环境物体 |
| Margin | 0.2 | 碰撞边距,防止摄像机紧贴墙壁 |
常见问题
Q: 为什么我的角色会穿墙?
检查 CollisionShape3D 是否正确设置了形状。常见的错误是忘记给角色添加碰撞体,或者碰撞体太小。角色碰撞体一般用 CapsuleShape3D,高度设为 1.8m,半径设为 0.3m。
Q: 鼠标灵敏度怎么调?
MouseSensitivity 的值越小,鼠标移动同样的距离,视角转得越少。FPS玩家一般喜欢 0.001-0.003 的范围。在游戏设置里给玩家一个可调节的滑块是最好的。
Q: 大地图加载很慢怎么办?
大地图不要一次性全部加载。可以把地图分成多个区块(Chunk),只加载玩家附近的区块。这在游戏开发中叫做"流式加载"(Streaming)。Godot 4.6 目前没有内置的流式加载系统,需要自己实现。
下一步
项目搭好后,开始 玩家控制器。
