3. FPS 角色控制器
2026/4/14大约 10 分钟
FPS 角色控制器
FPS 角色控制器是整个游戏的地基——如果移动手感不对,后面再多的功能也白搭。这一章我们要实现:第一人称视角、WASD 移动、急停、蹲下、跳跃,以及 CS 特有的移动手感。
核心概念:CS 的移动为什么不一样?
大多数 FPS 游戏的移动很简单——按下 W 角色就匀速前进,松开就立刻停下。但 CS 不是这样:
- 有加速度和减速度:按下方向键后,角色会从静止逐渐加速到最大速度;松开后也会逐渐减速
- 急停机制:快速点按反方向键可以让角色瞬间停下——这是 CS 高手的基本功
- 空中无法变向:在空中按方向键几乎无效(只有极小的空气控制力)
这套系统让 CS 的移动有了"重量感"和"惯性",也为急停射击创造了技术门槛。
角色场景结构
先搭建角色的节点树:
Player (CharacterBody3D) ← 物理运动的主角
├── CollisionShape3D ← 碰撞形状(胶囊体)
│ └── Shape: CapsuleShape3D ← 高 1.8m,半径 0.4m
│
├── Head (Node3D) ← 头部节点,摄像机挂在这
│ ├── Camera3D ← 第一人称摄像机
│ └── RayCast3D ← 射线检测(用于射击)
│
├── BodyMesh (MeshInstance3D) ← 第三人称可见的身体模型
│ └── Mesh: CapsuleMesh ← 简单的胶囊体模型
│
├── WeaponHolder (Node3D) ← 武器挂载点
│ └── Weapon (Node3D) ← 当前装备的武器
│
├── GroundCheck (RayCast3D) ← 检测是否站在地上
├── AudioStreamPlayer3D ← 脚步声音效播放器
└── Hitbox (Area3D) ← 受击判定区域
├── HeadHitbox (CollisionShape3D) ← 头部碰撞(爆头判定)
├── BodyHitbox (CollisionShape3D) ← 身体碰撞
└── LegHitbox (CollisionShape3D) ← 腿部碰撞为什么要分头/身体/腿部三个碰撞体?
CS 有"部位伤害"系统——打头伤害最高(通常是 4 倍),打腿伤害最低。所以我们需要三个独立的碰撞区域来检测子弹击中了哪个部位。
第一人称摄像机
FPS 的摄像机需要做到两件事:
- 鼠标控制视角:左右移动鼠标让角色左右转头,上下移动鼠标让视角上下看
- 锁定鼠标:游戏时鼠标隐藏且锁定在屏幕中央
C#
// Scripts/Characters/FPSCamera.cs
using Godot;
/// <summary>
/// 第一人称摄像机控制器。
///
/// 做两件事:
/// 1. 鼠标左右移动 → 角色左右转头(旋转 Y 轴)
/// 2. 鼠标上下移动 → 视角上下看(旋转 X 轴)
///
/// 关键:Y 轴旋转放在角色上,X 轴旋转放在 Head 节点上。
/// 这样角色的身体会跟着转向,但头部只负责上下看。
/// </summary>
public partial class FPSCamera : Node3D
{
[Export] public float MouseSensitivity { get; set; } = 0.003f;
[Export] public float MinVerticalAngle { get; set; } = -89f;
[Export] public float MaxVerticalAngle { get; set; } = 89f;
private CharacterBody3D _player;
private Camera3D _camera;
// 当前垂直角度(上下看的角度)
private float _verticalRotation;
public override void _Ready()
{
_player = GetParent<CharacterBody3D>();
_camera = GetNode<Camera3D>("Camera3D");
// 锁定鼠标:隐藏光标并锁定在屏幕中央
Input.MouseMode = Input.MouseModeEnum.Captured;
}
public override void _UnhandledInput(InputEvent @event)
{
// 鼠标移动事件
if (@event is InputEventMouseMotion mouseMotion)
{
// 水平旋转(角色转身)
_player.RotateY(-mouseMotion.Relative.X * MouseSensitivity);
// 垂直旋转(抬头/低头)
_verticalRotation -= mouseMotion.Relative.Y * MouseSensitivity;
_verticalRotation = Mathf.Clamp(
_verticalRotation,
Mathf.DegToRad(MinVerticalAngle),
Mathf.DegToRad(MaxVerticalAngle)
);
// 将垂直角度应用到 Head 节点(也就是 this)
Rotation = new Vector3(_verticalRotation, 0, 0);
}
// 按 Esc 解锁鼠标(方便调试)
if (@event.IsActionPressed("ui_cancel"))
{
if (Input.MouseMode == Input.MouseModeEnum.Captured)
Input.MouseMode = Input.MouseModeEnum.Visible;
else
Input.MouseMode = Input.MouseModeEnum.Captured;
}
}
/// <summary>
/// 添加后坐力抖动(射击时调用)
/// </summary>
public void AddRecoil(float verticalRecoil, float horizontalRecoil)
{
_verticalRotation -= Mathf.DegToRad(verticalRecoil);
_verticalRotation = Mathf.Clamp(
_verticalRotation,
Mathf.DegToRad(MinVerticalAngle),
Mathf.DegToRad(MaxVerticalAngle)
);
_player.RotateY(Mathf.DegToRad(horizontalRecoil));
}
/// <summary>
/// 获取摄像机朝向(用于射线检测)
/// </summary>
public Vector3 GetForwardDirection()
{
return -_camera.GlobalTransform.Basis.Z;
}
}GDScript
# Scripts/Characters/FPSCamera.gd
extends Node3D
## 第一人称摄像机控制器。
##
## 做两件事:
## 1. 鼠标左右移动 → 角色左右转头(旋转 Y 轴)
## 2. 鼠标上下移动 → 视角上下看(旋转 X 轴)
##
## 关键:Y 轴旋转放在角色上,X 轴旋转放在 Head 节点上。
## 这样角色的身体会跟着转向,但头部只负责上下看。
@export var mouse_sensitivity: float = 0.003
@export var min_vertical_angle: float = -89.0
@export var max_vertical_angle: float = 89.0
var player: CharacterBody3D
var camera: Camera3D
# 当前垂直角度(上下看的角度)
var _vertical_rotation: float = 0.0
func _ready():
player = get_parent() as CharacterBody3D
camera = $Camera3D
# 锁定鼠标:隐藏光标并锁定在屏幕中央
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent):
# 鼠标移动事件
if event is InputEventMouseMotion:
var mouse_motion = event as InputEventMouseMotion
# 水平旋转(角色转身)
player.rotate_y(-mouse_motion.relative.x * mouse_sensitivity)
# 垂直旋转(抬头/低头)
_vertical_rotation -= mouse_motion.relative.y * mouse_sensitivity
_vertical_rotation = clampf(
_vertical_rotation,
deg_to_rad(min_vertical_angle),
deg_to_rad(max_vertical_angle)
)
# 将垂直角度应用到 Head 节点(也就是 self)
rotation = Vector3(_vertical_rotation, 0, 0)
# 按 Esc 解锁鼠标(方便调试)
if event.is_action_pressed("ui_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 add_recoil(vertical_recoil: float, horizontal_recoil: float):
_vertical_rotation -= deg_to_rad(vertical_recoil)
_vertical_rotation = clampf(
_vertical_rotation,
deg_to_rad(min_vertical_angle),
deg_to_rad(max_vertical_angle)
)
player.rotate_y(deg_to_rad(horizontal_recoil))
## 获取摄像机朝向(用于射线检测)
func get_forward_direction() -> Vector3:
return -camera.global_transform.basis.z角色移动控制
下面是最关键的移动控制。CS 的移动有几个关键参数:
| 参数 | 值 | 说明 |
|---|---|---|
| 最大移动速度 | 250 单位/秒 | CS 的标准移动速度 |
| 蹲下速度 | 150 单位/秒 | 蹲下时速度降低 |
| 持枪速度倍率 | 0.85-0.95 | 不同武器影响移动速度 |
| 加速度 | 10 | 按下方向键后的加速力度 |
| 减速度 | 10 | 松开方向键后的减速力度 |
| 空气控制力 | 0.3 | 空中改变方向的能力(很小) |
| 重力 | 9.8 | 标准重力 |
| 跳跃力 | 6.5 | 跳跃初速度 |
C#
// Scripts/Characters/PlayerController.cs
using Godot;
public partial class PlayerController : CharacterBody3D
{
[Export] public float MaxSpeed { get; set; } = 8.0f; // 最大移动速度
[Export] public float CrouchSpeed { get; set; } = 4.8f; // 蹲下速度
[Export] public float WalkSpeed { get; set; } = 4.0f; // 静步速度
[Export] public float Acceleration { get; set; } = 10.0f; // 加速度
[Export] public float Deceleration { get; set; } = 10.0f; // 减速度(急停的关键)
[Export] public float AirControl { get; set; } = 0.3f; // 空中控制力
[Export] public float JumpVelocity { get; set; } = 6.5f; // 跳跃力
[Export] public float Gravity { get; set; } = 9.8f; // 重力
[Export] public float StandHeight { get; set; } = 1.8f; // 站立高度
[Export] public float CrouchHeight { get; set; } = 1.2f; // 蹲下高度
private float _currentSpeed;
private bool _isCrouching;
private float _weaponSpeedMultiplier = 1.0f;
private Vector3 _velocity;
public bool IsMoving => _velocity.Horizontal().Length() > 0.5f;
public bool IsSprinting => Input.IsActionPressed("sprint");
public bool IsCrouching => _isCrouching;
public float SpeedFactor => _velocity.Horizontal().Length() / MaxSpeed;
public override void _PhysicsProcess(double delta)
{
var dt = (float)delta;
HandleGravity(dt);
HandleJump();
HandleCrouch(dt);
HandleMovement(dt);
Velocity = _velocity;
MoveAndSlide();
}
private void HandleGravity(float delta)
{
if (!IsOnFloor())
{
_velocity.Y -= Gravity * delta;
}
else if (_velocity.Y < 0)
{
_velocity.Y = 0;
}
}
private void HandleJump()
{
if (Input.IsActionJustPressed("jump") && IsOnFloor())
{
_velocity.Y = JumpVelocity;
}
}
private void HandleCrouch(float delta)
{
var wantCrouch = Input.IsActionPressed("crouch");
if (wantCrouch && !_isCrouching)
{
_isCrouching = true;
// 缩小碰撞体
var shape = GetNode<CollisionShape3D>("CollisionShape3D");
shape.Scale = new Vector3(1, CrouchHeight / StandHeight, 1);
shape.Position = new Vector3(0, CrouchHeight / 2, 0);
}
else if (!wantCrouch && _isCrouching)
{
// 检查站起来会不会碰到东西
if (!WouldCollideOnStand())
{
_isCrouching = false;
var shape = GetNode<CollisionShape3D>("CollisionShape3D");
shape.Scale = Vector3.One;
shape.Position = new Vector3(0, StandHeight / 2, 0);
}
}
}
private void HandleMovement(float delta)
{
// 获取输入方向(WASD)
var inputDir = Input.GetVector(
"move_left", "move_right",
"move_forward", "move_backward"
);
// 将输入转为 3D 方向(相对于角色朝向)
var direction = new Vector3(inputDir.X, 0, inputDir.Y)
.Rotated(Vector3.Up, Rotation.Y)
.Normalized();
// 确定当前最大速度
_currentSpeed = MaxSpeed * _weaponSpeedMultiplier;
if (_isCrouching)
_currentSpeed = CrouchSpeed * _weaponSpeedMultiplier;
else if (IsSprinting)
_currentSpeed = WalkSpeed * _weaponSpeedMultiplier;
// 水平速度
var horizontalVel = _velocity.Horizontal();
if (IsOnFloor())
{
if (direction.LengthSquared() > 0.01f)
{
// 有输入:加速
horizontalVel = horizontalVel.MoveToward(
direction * _currentSpeed,
Acceleration * _currentSpeed * delta
);
}
else
{
// 无输入:急停(高减速度 = 快速停下)
horizontalVel = horizontalVel.MoveToward(
Vector3.Zero,
Deceleration * _currentSpeed * delta
);
}
}
else
{
// 空中:极小的控制力
if (direction.LengthSquared() > 0.01f)
{
horizontalVel = horizontalVel.MoveToward(
horizontalVel + direction * AirControl,
AirControl * delta
);
}
}
_velocity = new Vector3(horizontalVel.X, _velocity.Y, horizontalVel.Z);
}
private bool WouldCollideOnStand()
{
// 用射线检测站起来后头顶有没有障碍物
var spaceState = GetWorld3D().DirectSpaceState;
var query = PhysicsRayQueryParameters3D.Create(
GlobalPosition + Vector3.Up * CrouchHeight,
GlobalPosition + Vector3.Up * StandHeight
);
return spaceState.IntersectRay(query).Count > 0;
}
/// <summary>
/// 武器切换时调用,更新移动速度倍率
/// </summary>
public void SetWeaponSpeedMultiplier(float multiplier)
{
_weaponSpeedMultiplier = multiplier;
}
}GDScript
# Scripts/Characters/PlayerController.gd
extends CharacterBody3D
@export var max_speed: float = 8.0 ## 最大移动速度
@export var crouch_speed: float = 4.8 ## 蹲下速度
@export var walk_speed: float = 4.0 ## 静步速度
@export var acceleration: float = 10.0 ## 加速度
@export var deceleration: float = 10.0 ## 减速度(急停的关键)
@export var air_control: float = 0.3 ## 空中控制力
@export var jump_velocity: float = 6.5 ## 跳跃力
@export var gravity: float = 9.8 ## 重力
@export var stand_height: float = 1.8 ## 站立高度
@export var crouch_height: float = 1.2 ## 蹲下高度
var _current_speed: float
var _is_crouching: bool = false
var _weapon_speed_multiplier: float = 1.0
var _velocity: Vector3
var is_moving: bool:
get: return _velocity.horizontal().length() > 0.5
var is_sprinting: bool:
get: return Input.is_action_pressed("sprint")
var speed_factor: float:
get: return _velocity.horizontal().length() / max_speed
func _physics_process(delta: float):
_handle_gravity(delta)
_handle_jump()
_handle_crouch(delta)
_handle_movement(delta)
velocity = _velocity
move_and_slide()
func _handle_gravity(delta: float):
if not is_on_floor():
_velocity.y -= gravity * delta
elif _velocity.y < 0:
_velocity.y = 0
func _handle_jump():
if Input.is_action_just_pressed("jump") and is_on_floor():
_velocity.y = jump_velocity
func _handle_crouch(delta: float):
var want_crouch = Input.is_action_pressed("crouch")
if want_crouch and not _is_crouching:
_is_crouching = true
# 缩小碰撞体
var shape = $CollisionShape3D as CollisionShape3D
shape.scale = Vector3(1, crouch_height / stand_height, 1)
shape.position = Vector3(0, crouch_height / 2, 0)
elif not want_crouch and _is_crouching:
# 检查站起来会不会碰到东西
if not _would_collide_on_stand():
_is_crouching = false
var shape = $CollisionShape3D as CollisionShape3D
shape.scale = Vector3.ONE
shape.position = Vector3(0, stand_height / 2, 0)
func _handle_movement(delta: float):
# 获取输入方向(WASD)
var input_dir = Input.get_vector(
"move_left", "move_right",
"move_forward", "move_backward"
)
# 将输入转为 3D 方向(相对于角色朝向)
var direction = Vector3(input_dir.x, 0, input_dir.y)
direction = direction.rotated(Vector3.UP, rotation.y).normalized()
# 确定当前最大速度
_current_speed = max_speed * _weapon_speed_multiplier
if _is_crouching:
_current_speed = crouch_speed * _weapon_speed_multiplier
elif is_sprinting:
_current_speed = walk_speed * _weapon_speed_multiplier
# 水平速度
var horizontal_vel = _velocity.horizontal()
if is_on_floor():
if direction.length_squared() > 0.01:
# 有输入:加速
horizontal_vel = horizontal_vel.move_toward(
direction * _current_speed,
acceleration * _current_speed * delta
)
else:
# 无输入:急停(高减速度 = 快速停下)
horizontal_vel = horizontal_vel.move_toward(
Vector3.ZERO,
deceleration * _current_speed * delta
)
else:
# 空中:极小的控制力
if direction.length_squared() > 0.01:
horizontal_vel = horizontal_vel.move_toward(
horizontal_vel + direction * air_control,
air_control * delta
)
_velocity = Vector3(horizontal_vel.x, _velocity.y, horizontal_vel.z)
func _would_collide_on_stand() -> bool:
# 用射线检测站起来后头顶有没有障碍物
var space_state = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(
global_position + Vector3.UP * crouch_height,
global_position + Vector3.UP * stand_height
)
return space_state.intersect_ray(query).size() > 0
## 武器切换时调用,更新移动速度倍率
func set_weapon_speed_multiplier(multiplier: float):
_weapon_speed_multiplier = multiplier急停原理解析
上面代码中最关键的是 MoveToward 这行。让我们拆解一下急停的工作原理:
角色正在向右移动(速度 = 8.0)
│
松开 D 键
│
direction = (0, 0, 0)
│
MoveToward(当前速度, 零, 减速度 × 最大速度 × delta)
│
┌─────────────────────────────────────┐
│ 减速度 = 10,max_speed = 8, │
│ delta ≈ 0.016(60fps) │
│ │
│ 每帧减速 = 10 × 8 × 0.016 = 1.28 │
│ 当前速度 8.0 → 6.72 → 5.44 → ... │
│ 大约 6-7 帧(约 0.1 秒)完全停下 │
└─────────────────────────────────────┘CS 高手的"急停"技巧就是在松开前进方向后快速点按一下反方向键,利用加速度让速度归零得更快。这在代码中自然就支持了——反方向输入会让 MoveToward 的目标变成反方向,加速减速叠加后停得更快。
脚步声系统
脚步声是 CS 中至关重要的信息来源——听到脚步声就知道附近有敌人。我们需要根据移动状态播放不同的声音。
C#
// 添加到 PlayerController.cs 中的脚步声逻辑
private AudioStreamPlayer3D _footstepPlayer;
private float _footstepTimer;
private float _footstepInterval;
public override void _Ready()
{
_footstepPlayer = GetNode<AudioStreamPlayer3D>("FootstepPlayer");
}
// 在 _PhysicsProcess 末尾添加:
private void UpdateFootsteps(float delta)
{
if (!IsMoving || !IsOnFloor()) return;
// 静步时不发声
if (IsSprinting) return;
// 根据移动速度调整脚步间隔
_footstepInterval = _isCrouching ? 0.7f : 0.45f;
_footstepTimer += delta;
if (_footstepTimer >= _footstepInterval)
{
_footstepTimer = 0;
PlayFootstep();
}
}
private void PlayFootstep()
{
// 随机选择脚步声音调,避免重复感
_footstepPlayer.PitchScale = (float)GD.RandRange(0.9, 1.1);
_footstepPlayer.Play();
}GDScript
## 添加到 PlayerController.gd 中的脚步声逻辑
@onready var _footstep_player: AudioStreamPlayer3D = $FootstepPlayer
var _footstep_timer: float = 0.0
var _footstep_interval: float = 0.45
# 在 _physics_process 末尾添加:
func _update_footsteps(delta: float):
if not is_moving or not is_on_floor():
return
# 静步时不发声
if is_sprinting:
return
# 根据移动速度调整脚步间隔
_footstep_interval = 0.7 if _is_crouching else 0.45
_footstep_timer += delta
if _footstep_timer >= _footstep_interval:
_footstep_timer = 0.0
_play_footstep()
func _play_footstep():
# 随机选择脚步声音调,避免重复感
_footstep_player.pitch_scale = randf_range(0.9, 1.1)
_footstep_player.play()小结
这一章实现了 FPS 角色控制器的核心部分:
- 第一人称摄像机:鼠标控制视角,锁定光标,支持后坐力抖动
- 角色移动:有加速度/减速度的物理移动,支持急停
- 蹲下:缩小碰撞体高度,降低移动速度
- 跳跃:标准跳跃,空中控制力极小
- 脚步声:根据移动状态播放,静步时不发声
下一章我们实现武器系统——这是 CS 的"灵魂",包括射击、后坐力、弹道散布和换弹。
