系列最终目标

从这篇教程开始,我们将用 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

一、需求拆解

“第三人称控制器”其实包含四个相互依赖的模块:

  1. 场景和碰撞:角色必须站在地面上。
  2. 角色移动:把输入转换成速度,再交给物理系统处理。
  3. 相机旋转:左右转身和上下观察不能由同一个节点完成。
  4. 相机防穿透Camera3D 不参与物理,需要 SpringArm3D 检测墙壁和地面。

顺序很重要:先搭地面和角色碰撞,再写移动,最后处理相机。这样每次只引入一类问题,排错会简单很多。

本文只做键盘鼠标。手柄会在后续单独加入:左摇杆控制移动力度,右摇杆控制视角,左扳机切换潜墨形态,而不是独立的“游泳移动键”。


二、搭建测试场景

2.1 创建项目和入口场景

新建 Godot 4 项目,渲染器选择 Forward+。添加 Node3D 根节点,命名为 Main,保存为:

res://scenes/main.tscn

Node3D 本身不可见,但有位置、旋转和缩放,适合作为 3D 场景容器。给它添加 DirectionalLight3DCamera3D,按 F6 运行一次。空画面是正常的,我们只是先确认场景能运行。

已有资源可以复制到:

res://assets/Characters
res://assets/Guns
res://assets/Texture

暂时不要把模型放进场景;先用胶囊完成物理原型,后续再处理 .gltf 的比例、朝向、骨骼和动画。

2.2 地面

Main 下添加 StaticBody3D,命名为 Ground,位置设为:

X = 0
Y = -0.1
Z = 0

在它下面添加:

  • MeshInstance3DMesh 使用 BoxMesh,尺寸 (30, 0.2, 30)
  • CollisionShape3DShape 使用 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:使用 CapsuleShape3DRadius=0.4Height=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,可以直接写 -4530 度,便于阅读:

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 角色,并用 AnimationPlayerAnimationTree 加入待机、跑步和跳跃动画。