系列最终目标
从这篇教程开始,我们将用 Godot 4 制作一个受《Splatoon》核心循环启发的单机 3D 墨水占地射击 Demo。重点是通过完整项目学习 Godot 4 的 3D 工作流,而不是复制原作的角色、美术、地图或联网功能。
最终可玩的版本会包含:
- 键盘鼠标和手柄都能操作的第三人称角色;
- 墨水枪、墨量消耗和命中地面后的涂色;
- 己方墨水中的潜行、加速与回墨,以及敌方墨水减速;
- 一张小型对称地图和会涂地、追击、回墨的机器人;
- 倒计时、双方覆盖率、结算和重开。
本系列会按依赖关系逐步完成:角色与相机、模型与动画、手柄、武器、涂地、潜墨、地图、AI 和对局流程。本文是第一步,只搭好后续所有玩法都要依赖的第三人称角色控制器。
最终效果
这篇先不实现墨水枪、潜墨和 AI,只把后续功能都依赖的第三人称控制器搭好:
- WASD 按角色当前朝向移动;
- 空格跳跃,离地后受重力影响;
- 鼠标控制左右转身和上下观察;
- 相机靠近地面或墙壁时自动拉近,不穿过障碍物;
- 移动代码不写死具体按键,为以后接入手柄左摇杆保留输入力度。
最终节点树:
Main (Node3D)
DirectionalLight3D
Ground (StaticBody3D)
GroundMesh (MeshInstance3D)
GroundCollision (CollisionShape3D)
Player (CharacterBody3D)
PlayerCollision (CollisionShape3D)
PlayerMesh (MeshInstance3D)
CameraPivot (Node3D)
SpringArm3D
Camera3D
一、需求拆解
“第三人称控制器”其实包含四个相互依赖的模块:
- 场景和碰撞:角色必须站在地面上。
- 角色移动:把输入转换成速度,再交给物理系统处理。
- 相机旋转:左右转身和上下观察不能由同一个节点完成。
- 相机防穿透:
Camera3D不参与物理,需要SpringArm3D检测墙壁和地面。
顺序很重要:先搭地面和角色碰撞,再写移动,最后处理相机。这样每次只引入一类问题,排错会简单很多。
本文只做键盘鼠标。手柄会在后续单独加入:左摇杆控制移动力度,右摇杆控制视角,左扳机切换潜墨形态,而不是独立的“游泳移动键”。
二、搭建测试场景
2.1 创建项目和入口场景
新建 Godot 4 项目,渲染器选择 Forward+。添加 Node3D 根节点,命名为 Main,保存为:
res://scenes/main.tscn
Node3D 本身不可见,但有位置、旋转和缩放,适合作为 3D 场景容器。给它添加 DirectionalLight3D 和 Camera3D,按 F6 运行一次。空画面是正常的,我们只是先确认场景能运行。
已有资源可以复制到:
res://assets/Characters
res://assets/Guns
res://assets/Texture
暂时不要把模型放进场景;先用胶囊完成物理原型,后续再处理 .gltf 的比例、朝向、骨骼和动画。
2.2 地面
在 Main 下添加 StaticBody3D,命名为 Ground,位置设为:
X = 0
Y = -0.1
Z = 0
在它下面添加:
MeshInstance3D:Mesh使用BoxMesh,尺寸(30, 0.2, 30);CollisionShape3D:Shape使用BoxShape3D,尺寸同样为(30, 0.2, 30)。
地面中心放在 Y=-0.1,是因为厚度为 0.2,上表面正好位于 Y=0。
MeshInstance3D 只负责显示,CollisionShape3D 只负责碰撞。少了前者看不见,少了后者角色会穿过去。
2.3 玩家原型
在 Main 下添加 CharacterBody3D,命名为 Player,位置:
X = 0
Y = 1
Z = 0
添加两个子节点:
CollisionShape3D:使用CapsuleShape3D,Radius=0.4、Height=1.8;MeshInstance3D:使用CapsuleMesh,保持相同尺寸。
CharacterBody3D 适合由脚本控制的玩家;StaticBody3D 适合不会移动的地面。胶囊体是物理原型,确认移动正确后再换成正式角色模型。
三、第三人称相机
直接把相机放在玩家后方,靠墙或低头时会穿进墙和地面。原因是 Camera3D 不参与物理,给它添加碰撞体也没有用。
Godot 的解决方案是 SpringArm3D:它从支点向相机方向检测碰撞,遇到障碍物时自动缩短距离。
将节点整理为:
Player
CameraPivot
SpringArm3D
Camera3D
设置:
CameraPivot Position = (0, 1.4, 0)
SpringArm3D Position = (0, 0, 0)
SpringArm3D Rotation Degrees = (0, 0, 0)
SpringArm3D Spring Length = 5.0
Camera3D Position = (0, 0, 0)
Camera3D Rotation Degrees = (0, 0, 0)
Camera3D Current = On
距离由 Spring Length 决定,所以 Camera3D 的 Z 必须是 0,不是 5。相机默认看向局部 -Z,位于玩家后方时 Rotation.Y 应为 0,不是 180。
如果相机仍穿透,检查地面是否有 CollisionShape3D,并确认 Ground 的 Collision Layer 与 SpringArm 的 Collision Mask 包含同一层。
四、输入映射
打开“项目 -> 项目设置 -> 输入映射”,创建并绑定:
| 动作 | 键盘 |
|---|---|
move_forward |
W |
move_back |
S |
move_left |
A |
move_right |
D |
jump |
空格 |
pause |
Esc |
代码应该询问“前进动作是否发生”,不应该询问“W 是否被按下”。将来给同一个动作增加手柄事件,不需要重写移动逻辑。
五、完整代码
创建 res://scripts/player.gd 并附加到 Player:
extends CharacterBody3D
@export var move_speed := 5.0
@export var jump_velocity := 5.0
@export var mouse_sensitivity := 0.003
@onready var camera_pivot: Node3D = $CameraPivot
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("pause"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if event is InputEventMouseButton and event.pressed:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * mouse_sensitivity)
camera_pivot.rotate_x(-event.relative.y * mouse_sensitivity)
camera_pivot.rotation_degrees.x = clamp(
camera_pivot.rotation_degrees.x,
-60.0,
45.0
)
func _physics_process(delta: float) -> void:
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_direction := Input.get_vector(
"move_left",
"move_right",
"move_forward",
"move_back"
)
var move_direction := transform.basis * Vector3(
input_direction.x,
0.0,
input_direction.y
)
velocity.x = move_direction.x * move_speed
velocity.z = move_direction.z * move_speed
move_and_slide()
六、代码解析
@export 会把速度、跳跃力度和鼠标灵敏度显示在检查器中,方便调手感。@onready var camera_pivot = $CameraPivot 会在节点进入场景树后获取相机支点;节点改名后必须同步修改路径。
运行时捕获鼠标:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
Esc 释放鼠标:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
rotate_y(-event.relative.x * mouse_sensitivity)
camera_pivot.rotate_x(-event.relative.y * mouse_sensitivity)
左右移动让 Player 绕 Y 轴转身,因此 W 始终代表角色正前方;上下移动只让 CameraPivot 绕 X 轴转,避免角色碰撞体跟着低头、抬头或翻倒。
rotate_x() 与 rotate_y() 使用弧度参数,所以灵敏度是弧度比例。但上下限使用 rotation_degrees,可以直接写 -45 到 30 度,便于阅读:
camera_pivot.rotation_degrees.x = clamp(
camera_pivot.rotation_degrees.x,
-45.0,
30.0
)
_physics_process(delta)用于物理逻辑。角色离地时降低velocity.y,站在地面且刚按下跳跃时赋予向上速度,最后调用 move_and_slide()` 执行真正的移动和碰撞。
if not is_on_floor():
velocity.y -= gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
Input.get_vector()` 把四个动作合成二维输入。键盘通常是停或满速;未来左摇杆半推时,向量长度会小于 1,因此移动速度也应该变小。
var move_direction := transform.basis * Vector3(
input_direction.x,
0.0,
input_direction.y
)
这里不能追加 .normalized()。归一化会抹掉摇杆力度,让半推和满推变成同样速度。
八、下一步
现在已经有了可扩展的 3D 基础:场景树分工明确,角色物理与视觉分离,输入不绑定具体设备,SpringArm 处理相机碰撞。下一篇将替换胶囊为 .gltf 角色,并用 AnimationPlayer 或 AnimationTree 加入待机、跑步和跳跃动画。

评论区