第 1 课 · 工程骨架与运行循环(单元一)
欢迎。这一课不写玩法,只做一件事:让”游戏在跑”这件事变得可见、可解释、可验证。
- 参照源码:
/home/jay/gameprojects/U3-SDK/(Unturned 的 Unity 工程)。本课不涉及具体系统,但后面每一课都会对着它讲。 - 系统说明书(越查越厚的参考资料):
docs/systems/01-player-controller.md——本课先不用读,第 2 课开始用。 - 环境:Godot 4.7.2.stable,可执行文件
/home/jay/.local/bin/godot。
0. 本课要拿到什么
目标:项目建起来、节点树搭出来、调参表就位、一个固定步长的”逻辑帧”循环真的在跑。
验收(你亲手做,必须看见):
- 按 F5,屏幕上出现一个灰白色胶囊,摄像机在它身后;
- 那个胶囊自己在慢慢转,一秒正好转一圈;
- 控制台(Output 面板)每秒打印一行:
tick=60 累计逻辑时间=1.00 秒、tick=120 …、tick=180 …。
不要小看第 2、3 条。它们是这门课后面所有内容的地基:这个循环就是以后放移动、姿态、僵尸 AI、昼夜计时的地方,也是最后一个单元做联机时唯一要换掉的东西。
1. 三个概念,讲完就动手
1.1 场景与节点树
Godot 里你搭的一切都是节点,节点挂在别的节点下面,形成一棵树(叫场景)。规则只有一条,但它是整个引擎的地基:
父节点动,子节点跟着动。
所以你后面会看到:身体转左右、摄像机跟着转;身体变矮(蹲下)、眼睛跟着降。这不是代码里一行行写出来的,是节点树的结构天然给的。
这一帧的数据流(3 步,只有最后一步真的动身体)
- 1
Intentintent.sample()读输入 → 只写 Intent,不碰状态 - 2
Looklook.simulate()定 yaw / pitch - 3
Playermove_and_slide()唯一真的动身体的地方
真实场景里还有讲义这棵树没画的节点:Intent(player_intent.gd)、Stance(player_stance.gd)、Movement(player_movement.gd)——它们是后面几课才会讲的,所以现在不画进你照抄的那棵树里。
讲义那一棵树取自 docs/tutorials/lesson-01-engine-skeleton.md;脚本挂载取自真实场景 reference/01-player-controller/player.tscn,两侧不一致会照实列出来。
1.2 两个时钟:_process 和 _physics_process
每个挂脚本的节点都有两个会被反复调用的函数。这是初学者最容易混的地方,我们一次分清:
_process(delta) | _physics_process(delta) | |
|---|---|---|
| 什么时候被叫 | 每个渲染帧(跟着你的显示器和显卡跑,144Hz 屏就是每秒 144 次) | 每个物理帧(默认固定每秒 60 次) |
参数 delta | 上一帧过了多久(会抖,0.006 到 0.02 都有可能) | 固定的约 0.01667 秒 |
| 该放什么 | 画面、界面、平滑插值 | 物理、移动、碰撞、游戏逻辑 |
我们这套要严格照这个分。官方文档:Idle and Physics Processing。
1.3 固定步长的”逻辑帧”(本课的核心)
物理帧是固定的 60 Hz,那为什么不直接在里面写游戏逻辑?因为最后一个单元要联网:联网时,游戏逻辑跑得太快没有意义(子弹、移动、僵尸的判定必须在全网统一的节拍上)。原版 Unturned 就是每 4 个物理帧才跑一次逻辑,逻辑步长写死 0.08 秒(Unturned/Player/PlayerInput.cs:878-879Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:877-880 {
public static readonly uint SAMPLES = 4;
public static readonly float RATE = 0.08f;,SAMPLES=4、RATE=0.08f)。
这里有个容易记错的点:物理帧每帧都在读输入,但离散输入(跳、姿态、攻击、移动键)要等逻辑帧才被消费——它们全都写在 count % SAMPLES == 0 这道门禁里面(PlayerInput.cs:1542Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1541-1543 {
if (count % SAMPLES == 0)
{),所以消费的节拍与逻辑帧相同,都是 12.5 Hz,而不是每秒采 50 次。真正跑在 50 Hz 的是 tock(PlayerInput.cs:1652Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1651-1653
player.equipment.tock(clock);,在门禁外面);而鼠标视角是按渲染帧率算的(PlayerLook.cs:1532Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1531-1533 {
_yaw += ControlsSettings.mouseAimSensitivity * zoomSensitivity * Input.GetAxis("mouse_x") * yawInputMultiplier;
},写在 Update() 里)。
这三个频率分开,正好解释了原版那种手感:转身立刻跟手(渲染帧率),走位却有一拍延迟(12.5 Hz)。我们的复刻把它简化成一档 60 Hz,两者就都跟手了。
我们第一单元用 60 Hz(单机没必要吃那最多 80 毫秒的延迟),但形状照抄:物理帧只负责”往时间账户里存钱”,攒够一个固定步长就”取出来花掉一次”——花掉的那一次,就是逻辑帧。写出来是这样:
物理帧(每秒 60 次) _accum += delta
while _accum >= LOGIC_STEP:
_accum -= LOGIC_STEP
跑一次逻辑 ← 移动、姿态、AI 都在这里
这个 _accum 叫累加器。它的全部价值在于:**逻辑跑几次只取决于过了多少秒,不取决于你这台机器每秒能画多少帧。**最后那个单元改联网时,只改 LOGIC_HZ 这一个常数,逻辑代码一行不动。
这一帧的调用顺序
累加器
渲染帧(_process)另算:只做眼高平滑,不参与权威状态。数值来自讲义正文与 tuning.gd,本图不另存一份。
2. 建项目骨架
- 打开 Godot → New Project → 项目名
unturned-godot,路径选/home/jay/gameprojects/unturned-godot/,渲染器保持默认 Forward+,点 Create & Edit。 (这个目录里已有的docs/、reference/、site/是课程资料,reference/和site/各自带了一个.gdignore,Godot 会整个跳过它们,不会打架。) - 在 FileSystem 面板右键
res://→ New Folder,建两个目录:scripts、scenes。
做完你的工程长这样:
unturned-godot/
├─ project.godot
├─ scripts/ ← 你写的脚本
├─ scenes/ ← 你搭的场景
├─ docs/ ← 课程讲义(资料,不是工程的一部分)
├─ reference/ ← 参考实现(兜底用,带 .gdignore)
└─ site/ ← 课程站(带 .gdignore)
小提醒:如果你发现
docs/tutorials/images/里冒出了.import文件,那是 Godot 把讲义的截图当资源导入了。我在自己那棵树下放了docs/tutorials/.gdignore挡掉它;万一你在别处看到同类文件,可以照着加一个。
3. 搭节点树
新建场景:根节点选 Other Node → CharacterBody3D,改名 Player,存成 scenes/player.tscn。
CharacterBody3D 是 Godot 里专门给”有身体、会撞墙的角色”用的节点(它自带 move_and_slide() 这个方法,第 2 课就用上)。这一课它只是站着不动,先认识它的名字。
然后照下面这棵树建(每个节点的类型、位置、数值都对得上,别自己改):
Player (CharacterBody3D) ← 第 5 节会给它挂脚本
├─ Body (CollisionShape3D) ← 形状 CapsuleShape3D:半径 0.4、高 2.0;位置 (0, 1, 0)
├─ Visual (Node3D)
│ └─ Mesh (MeshInstance3D) ← CapsuleMesh:半径 0.4、高 2.0;位置 (0, 1, 0);给它上个灰白色的材质
└─ Look (Node3D) ← 现在只是相机的挂架,第 3 课才给它挂脚本
└─ PitchPivot (Node3D) ← 位置 (0, 1.75, 0)
└─ SpringArm3D ← spring_length = 4.0;margin = 0.01;shape = SphereShape3D 半径 0.39
└─ Camera3D ← 其他不用改
三个必须解释的地方:
- 为什么
Body要抬高到(0, 1, 0)? Godot 的胶囊形状是以自身中心为原点的,而我们要求Player的原点落在脚底——因为整套角色的眼睛高度、身高变化都是从脚往上量的。这条原版有直接证据:PlayerMovement.cs:283-284Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:282-285 controller.enabled = false; controller.height = size; controller.center = new Vector3(0, size * 0.5f, 0); controller.enabled = wasControllerEnabled;把碰撞体中心放在半身高处(:283设高度controller.height = size;,:284设中心controller.center = new Vector3(0, size * 0.5f, 0);)——胶囊中心在半身高,说明坐标原点在脚底;再配合PlayerLook.cs:579Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:578-580 MainCamera.instance.transform.localPosition = Vector3.up * eyes; }用localPosition(局部坐标 = 相对 transform 原点)定眼睛位置,两条合起来才算证明。(只看PlayerLook.cs:17-20Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:16-21 { private static readonly float HEIGHT_LOOK_SIT = 1.6f; private static readonly float HEIGHT_LOOK_STAND = 1.75f; private static readonly float HEIGHT_LOOK_CROUCH = 1.2f; private static readonly float HEIGHT_LOOK_PRONE = 0.35f;是不够的,那里只有眼高的数值,证明不了”从哪儿量”。)胶囊高 2.0,抬高半个身高就是 1.0,于是脚底正好在y = 0。 - 为什么
Visual和Body分开?Body管碰撞(参与物理计算),Visual管长相(给人看的)。这两件事以后会经常不同步——比如蹲下时碰撞胶囊变矮,但模型可能还要走一段动画。分开是给自己留后路。 - 相机这一套(
Look/PitchPivot/SpringArm3D)现在先照抄搭好,不要问为什么。 它现在是”一扇能看见自己的观察窗”,第 3 课会专门讲它为什么这么搭(SpringArm3D为什么会防穿墙、0.39这个半径哪来的)。现在搭它的理由很实在:没有一个在身后的相机,你根本看不见胶囊在转。
数值的出处(不是随手写的,都对着原版量过):
| 数值 | 出处 |
|---|---|
| 胶囊半径 0.4 | Unturned/Player/PlayerStance.cs:19Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:18-20
public static readonly float RADIUS = 0.4f;(RADIUS) |
| 胶囊高 2.0 / 1.2 / 0.8 | Unturned/Player/PlayerMovement.cs:49-51Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:48-52 {
public static readonly float HEIGHT_STAND = 2;
public static readonly float HEIGHT_CROUCH = 1.2f;
public static readonly float HEIGHT_PRONE = 0.8f;(站着 / 蹲着 / 趴着) |
| 眼睛高度 1.75 / 1.2 / 0.35 | Unturned/Player/PlayerLook.cs:17-20Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:16-21 {
private static readonly float HEIGHT_LOOK_SIT = 1.6f;
private static readonly float HEIGHT_LOOK_STAND = 1.75f;
private static readonly float HEIGHT_LOOK_CROUCH = 1.2f;
private static readonly float HEIGHT_LOOK_PRONE = 0.35f; |
| 相机球扫半径 0.39 | Unturned/Player/PlayerLook.cs:1091Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1090-1092 // otherwise wide aspect ratios with wide field of view easily clips through walls.
private const float NEAR_CLIP_SWEEP_RADIUS = 0.39f; // PlayerStance.RADIUS(NEAR_CLIP_SWEEP_RADIUS) |
4. 写第一个脚本:scripts/tuning.gd(调参表)
这个文件不挂给任何节点,它只是一张表。以后所有数值——走多快、跳多高、重力多强——都只写在这里,改玩法只改这一处,不会散落在十几个文件里。
FileSystem 面板右键 scripts → New → Script…,路径填 scripts/tuning.gd,然后整份照抄:
scripts/tuning.gd
extends RefCounted
# 单元一唯一的调参处。数值全部来自 U3-SDK,注释里标出处。
# 原版路径前缀是 Assets/Runtime/Assembly-CSharp/
# ── 逻辑帧率 ──────────────────────────────────────────────
# 原版:每 SAMPLES=4 个物理帧跑 1 次逻辑帧,步长 RATE=0.08s
# Unturned/Player/PlayerInput.cs:878-879 → 逻辑帧 12.5 Hz
# 物理帧每帧都读输入,离散输入等逻辑帧才被消费(12.5 Hz,写在 :1542 的
# count % SAMPLES == 0 门禁里);50 Hz 的是 tock(:1652);鼠标视角是渲染帧率(PlayerLook.cs:1532)。
# 本课程改成 60 Hz(= Godot 物理帧率),理由:单机不需要吃那最多 80ms 延迟。
# 单元七要联机时,只改这两个数。
const LOGIC_HZ := 60.0
const LOGIC_STEP := 1.0 / LOGIC_HZ
# ── 速度表(米/秒)Unturned/Player/PlayerMovement.cs:91-104 ──
const SPEED_STAND := 4.5
const SPEED_SPRINT := 7.0
const SPEED_CROUCH := 2.5
const SPEED_PRONE := 1.5
const JUMP := 7.0
# ── 重力 Unturned/Player/PlayerMovement.cs:1277 ──
# 原版是 Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3。
# 那个 3 是硬编码的,别丢;totalGravityMultiplier 正常恒为 1(滑翔类装备会改它,单元四之后是雷)。
# 9.81 取自工程实测:ProjectSettings/DynamicsManager.asset 的 m_Gravity y = -9.81。
const GRAVITY := 9.81
const GRAVITY_TIMESCALE := 3.0
const TERMINAL_VELOCITY := -100.0 # 原版 :1280-1281
# ── 胶囊尺寸 Unturned/Player/PlayerMovement.cs:49-51、PlayerStance.cs:19 ──
const HEIGHT_STAND := 2.0
const HEIGHT_CROUCH := 1.2
const HEIGHT_PRONE := 0.8
const RADIUS := 0.4
# ── 眼睛高度 Unturned/Player/PlayerLook.cs:17-20 ──
const EYE_STAND := 1.75
const EYE_CROUCH := 1.2
const EYE_PRONE := 0.35
const EYE_LERP_SPEED := 4.0 # 原版 :1235 的 4 * Time.deltaTime
# ── 俯仰夹取(0=垂直向上,90=水平,180=垂直向下)PlayerLook.cs:77-88、:147-149 ──
const PITCH_MIN_STAND := 0.0
const PITCH_MAX_STAND := 180.0
const PITCH_MIN_CROUCH := 20.0
const PITCH_MAX_CROUCH := 160.0
const PITCH_MIN_PRONE := 60.0
const PITCH_MAX_PRONE := 120.0
# 原版走 ControlsSettings.mouseAimSensitivity(玩家设置),本课程先用常量
const MOUSE_SENSITIVITY := 0.12 # 度 / 像素
# ── 姿态 ──────────────────────────────────────────────────
# 原版 EPlayerStance 是独立文件(8 个状态)。单元一只有 4 个状态,
# 而且速度表、俯仰表、眼高表都按它索引,所以先塞在这里,以后状态多了再拆出去。
enum Stance { STAND, CROUCH, PRONE, SPRINT }
const HOLD_TO_PRONE := 0.33 # Unturned/Player/PlayerStance.cs:735
static func speed_for(s: Stance) -> float:
match s:
Stance.SPRINT: return SPEED_SPRINT
Stance.CROUCH: return SPEED_CROUCH
Stance.PRONE: return SPEED_PRONE
_: return SPEED_STAND
static func height_for(s: Stance) -> float:
match s:
Stance.CROUCH: return HEIGHT_CROUCH
Stance.PRONE: return HEIGHT_PRONE
_: return HEIGHT_STAND
static func eye_for(s: Stance) -> float:
match s:
Stance.CROUCH: return EYE_CROUCH
Stance.PRONE: return EYE_PRONE
_: return EYE_STAND
static func pitch_min_for(s: Stance) -> float:
match s:
Stance.CROUCH: return PITCH_MIN_CROUCH
Stance.PRONE: return PITCH_MIN_PRONE
_: return PITCH_MIN_STAND
static func pitch_max_for(s: Stance) -> float:
match s:
Stance.CROUCH: return PITCH_MAX_CROUCH
Stance.PRONE: return PITCH_MAX_PRONE
_: return PITCH_MAX_STAND
关于 extends RefCounted:这个文件不需要出现在场景里,RefCounted 是”可以随手创建、随手丢掉的普通对象”的基类。后面别的脚本用 preload 把它拉进来当表查,就这样用:
const Tuning = preload("res://scripts/tuning.gd")
var speed = Tuning.SPEED_STAND
本课只会用到 LOGIC_HZ 和 LOGIC_STEP,其余的先放着——它们是整单元的表,不是让你现在读懂。
Unturned/Player/PlayerInput.cs
{
public static readonly uint SAMPLES = 4;
public static readonly float RATE = 0.08f; {
if (count % SAMPLES == 0)
{
player.equipment.tock(clock);
Unturned/Player/PlayerLook.cs
{
private static readonly float HEIGHT_LOOK_SIT = 1.6f;
private static readonly float HEIGHT_LOOK_STAND = 1.75f;
private static readonly float HEIGHT_LOOK_CROUCH = 1.2f;
private static readonly float HEIGHT_LOOK_PRONE = 0.35f;
private static readonly float MIN_ANGLE_SIT = 60;
private static readonly float MAX_ANGLE_SIT = 120;
private static readonly float MIN_ANGLE_CLIMB = 45;
private static readonly float MAX_ANGLE_CLIMB = 100;
private static readonly float MIN_ANGLE_SWIM = 45;
private static readonly float MAX_ANGLE_SWIM = 135;
private static readonly float MIN_ANGLE_STAND = 0;
private static readonly float MAX_ANGLE_STAND = 180;
private static readonly float MIN_ANGLE_CROUCH = 20;
private static readonly float MAX_ANGLE_CROUCH = 160;
private static readonly float MIN_ANGLE_PRONE = 60;
private static readonly float MAX_ANGLE_PRONE = 120;
private float _pitch = 90.0f;
/// <summary>
/// Unintuitively (to say the least), a pitch of 0 is up, 90 is forward, and 180 is down.
/// </summary> float targetEyeHeight = heightLook;
eyes = Mathf.Lerp(eyes, targetEyeHeight, 4 * Time.deltaTime);
if (player.movement.controller != null) {
_yaw += ControlsSettings.mouseAimSensitivity * zoomSensitivity * Input.GetAxis("mouse_x") * yawInputMultiplier;
}
Unturned/Player/PlayerMovement.cs
{
public static readonly float HEIGHT_STAND = 2;
public static readonly float HEIGHT_CROUCH = 1.2f;
public static readonly float HEIGHT_PRONE = 0.8f;
private static readonly float SPEED_CLIMB = 4.5f;
private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f;
/// <summary>
/// Jump speed = sqrt(2 * jump height * gravity)
/// Jump height = (jump speed ^ 2) / (2 * gravity)
/// With 7 speed and 9.81 * 3 gravity = apex height of 1.66496772
/// </summary>
private static readonly float JUMP = 7.0f;
private static readonly float SWIM = 3; {
velocity.y += Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3;
// Clamp free-fall / terminal velocity
float minVerticalVelocity = totalGravityMultiplier < 0.99f ? Physics.gravity.y * 2.0f * totalGravityMultiplier : -100.0f;
velocity.y = Mathf.Max(minVerticalVelocity, velocity.y);
Unturned/Player/PlayerStance.cs
public static readonly float RADIUS = 0.4f; {
if (Time.realtimeSinceStartup - lastHold > 0.33f)
{
extends RefCounted
# 单元一唯一的调参处。数值全部来自 U3-SDK,注释里标出处。
# 原版路径前缀是 Assets/Runtime/Assembly-CSharp/
# ── 逻辑帧率 ──────────────────────────────────────────────
# 原版:每 SAMPLES=4 个物理帧跑 1 次逻辑帧,步长 RATE=0.08s
# Unturned/Player/PlayerInput.cs:878-879 → 逻辑帧 12.5 Hz
# 物理帧每帧都读输入,离散输入等逻辑帧才被消费(12.5 Hz,写在 :1542 的
# count % SAMPLES == 0 门禁里);50 Hz 的是 tock(:1652);鼠标视角是渲染帧率(PlayerLook.cs:1532)。
# 本课程改成 60 Hz(= Godot 物理帧率),理由:单机不需要吃那最多 80ms 延迟。
# 单元七要联机时,只改这两个数。
const LOGIC_HZ := 60.0
const LOGIC_STEP := 1.0 / LOGIC_HZ
# ── 速度表(米/秒)Unturned/Player/PlayerMovement.cs:91-104 ──
const SPEED_STAND := 4.5
const SPEED_SPRINT := 7.0
const SPEED_CROUCH := 2.5
const SPEED_PRONE := 1.5
const JUMP := 7.0
# ── 重力 Unturned/Player/PlayerMovement.cs:1277 ──
# 原版是 Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3。
# 那个 3 是硬编码的,别丢;totalGravityMultiplier 正常恒为 1(滑翔类装备会改它,单元四之后是雷)。
# 9.81 取自工程实测:ProjectSettings/DynamicsManager.asset 的 m_Gravity y = -9.81。
const GRAVITY := 9.81
const GRAVITY_TIMESCALE := 3.0
const TERMINAL_VELOCITY := -100.0 # 原版 :1280-1281
# ── 胶囊尺寸 Unturned/Player/PlayerMovement.cs:49-51、PlayerStance.cs:19 ──
const HEIGHT_STAND := 2.0
const HEIGHT_CROUCH := 1.2
const HEIGHT_PRONE := 0.8
const RADIUS := 0.4
# ── 眼睛高度 Unturned/Player/PlayerLook.cs:17-20 ──
const EYE_STAND := 1.75
const EYE_CROUCH := 1.2
const EYE_PRONE := 0.35
const EYE_LERP_SPEED := 4.0 # 原版 :1235 的 4 * Time.deltaTime
# ── 俯仰夹取(0=垂直向上,90=水平,180=垂直向下)PlayerLook.cs:77-88、:147-149 ──
const PITCH_MIN_STAND := 0.0
const PITCH_MAX_STAND := 180.0
const PITCH_MIN_CROUCH := 20.0
const PITCH_MAX_CROUCH := 160.0
const PITCH_MIN_PRONE := 60.0
const PITCH_MAX_PRONE := 120.0
# 原版走 ControlsSettings.mouseAimSensitivity(玩家设置),本课程先用常量
const MOUSE_SENSITIVITY := 0.12 # 度 / 像素
# ── 姿态 ──────────────────────────────────────────────────
# 原版 EPlayerStance 是独立文件(8 个状态)。单元一只有 4 个状态,
# 而且速度表、俯仰表、眼高表都按它索引,所以先塞在这里,以后状态多了再拆出去。
enum Stance { STAND, CROUCH, PRONE, SPRINT }
const HOLD_TO_PRONE := 0.33 # Unturned/Player/PlayerStance.cs:735
static func speed_for(s: Stance) -> float:
match s:
Stance.SPRINT: return SPEED_SPRINT
Stance.CROUCH: return SPEED_CROUCH
Stance.PRONE: return SPEED_PRONE
_: return SPEED_STAND
static func height_for(s: Stance) -> float:
match s:
Stance.CROUCH: return HEIGHT_CROUCH
Stance.PRONE: return HEIGHT_PRONE
_: return HEIGHT_STAND
static func eye_for(s: Stance) -> float:
match s:
Stance.CROUCH: return EYE_CROUCH
Stance.PRONE: return EYE_PRONE
_: return EYE_STAND
static func pitch_min_for(s: Stance) -> float:
match s:
Stance.CROUCH: return PITCH_MIN_CROUCH
Stance.PRONE: return PITCH_MIN_PRONE
_: return PITCH_MIN_STAND
static func pitch_max_for(s: Stance) -> float:
match s:
Stance.CROUCH: return PITCH_MAX_CROUCH
Stance.PRONE: return PITCH_MAX_PRONE
_: return PITCH_MAX_STAND5. 写第二个脚本:scripts/player.gd(外壳与时钟)
同样方式新建 scripts/player.gd,然后把它挂到 Player 节点上:在场景里选中 Player,Inspector 最上面有一栏 Script,把文件拖进去。
scripts/player.gd
extends CharacterBody3D
# 外壳与时钟。第 1 课:先把"固定步的逻辑帧"跑起来。
# 这个文件在后面几课会长大:第 2 课加移动,第 3 课加姿态与视角。
const Tuning = preload("res://scripts/tuning.gd")
var _accum := 0.0 # 已经攒了多少秒
var _ticks := 0 # 已经跑过多少个逻辑帧
func _physics_process(delta: float) -> void:
# 物理帧(默认每秒 60 次)负责往"时间账户"里存钱
_accum += delta
# 攒够一个逻辑步长,就取出来花掉一次
while _accum >= Tuning.LOGIC_STEP:
_accum -= Tuning.LOGIC_STEP
_ticks += 1
_tick(Tuning.LOGIC_STEP)
func _tick(step: float) -> void:
# ── 临时转速表:每逻辑帧转 6 度 ──
# 60 Hz × 6 度 = 每秒 360 度,正好一秒一圈。它不是玩法,是让你"看得见"逻辑帧在跑。
# 第 2 课会删掉这几行。
$Visual.rotate_y(deg_to_rad(6.0))
if _ticks % int(Tuning.LOGIC_HZ) == 0:
print("tick=", _ticks, " 累计逻辑时间=%.2f 秒" % (_ticks * step))
逐段说明(这是本课真正要学的东西):
_physics_process(delta):物理帧的入口。注意它不直接改任何状态,只在_accum上记账。这个”记账”和”改状态”分开的写法,就是以后拆输入层 / 逻辑层的第一步。while _accum >= Tuning.LOGIC_STEP:用while不是if。如果某一帧卡了一下(渲染掉帧、切窗口回来),delta会偏大,攒下的时间够跑两次逻辑,while就会补跑两次——这样游戏里的”时间”才不会因为卡顿而变慢。这是累加器比”每帧跑一次”强的地方。_tick(step):未来的逻辑帧全部住在这里——移动、姿态、AI、计时器都会往这儿搬。现在它只干两件小事。$Visual.rotate_y(...):$是”取子节点”的简写,$Visual就是那棵树里的Visual节点。转 6 度是临时仪表。if _ticks % int(Tuning.LOGIC_HZ) == 0:每攒够 60 个逻辑帧(也就是 1 秒)打印一行。%是取余数,int(...)是因为LOGIC_HZ是小数、取余要整数。
6. 跑起来
场景里还得有地板,不然你可能看不出来自己在哪。新建一个场景 scenes/world.tscn:根节点 Node3D,加一个 StaticBody3D 当地板(它下面挂一个 CollisionShape3D,形状用 BoxShape3D,尺寸 40 × 1 × 40,位置 (0, -0.5, 0)),再把 scenes/player.tscn 拖进来,位置 (0, 0, 0)。
把主场景设成它:Project → Project Settings → Application → Run → Main Scene,然后按 F5。
你应该看到:
- 画面中央偏下一个灰白胶囊,镜头在它身后约 4 米、略高一点;
- 胶囊在慢慢转,不卡、不抖,一秒一圈;
- 下方 Output 面板每秒出现一行:
tick=60 累计逻辑时间=1.00 秒
tick=120 累计逻辑时间=2.00 秒
tick=180 累计逻辑时间=3.00 秒
第 3 条是这一课的灵魂:打印出来的 tick 数和真实秒数严格对应(每秒 60 行的一半——即每秒 60 个 tick)。你切到别的窗口再切回来,回来后它照样对得上,因为攒下的时间会补跑。
想看它”不是跟着帧率跑”的,做一个 10 秒钟的小实验(理论派的正式练习在第 2 批课,这里只是先让你亲手摸一下):把 tuning.gd 里的 LOGIC_HZ 从 60.0 改成 12.5,再按 F5。
先猜再跑:你猜胶囊转速会变成多少?打印的行还会是每秒一行吗?
跑完你会发现两件事,都值得记住:转速慢到大约五分之一(因为逻辑帧少了,而”每逻辑帧转 6 度”没变);打印变成大约每 4.8 秒一行。这就是”固定步”的含义——逻辑代码不认秒,只认自己跑了多少次。 看完记得改回 60.0。
7. 我替你跑过的验收
这是我在设计期跑过的验收抄本。这一课的完整工程就在仓库里(samples/lesson-01/),你可以在仓库根目录直接跑它复现:
/home/jay/.local/bin/godot --headless --path samples/lesson-01 world.tscn
你按讲义搭出来的工程里,会看到下面中间那三行 tick=——那才是本课的验收点(按 F5 后在 Output 面板里)。带 [OK ] 的行来自这个样例里附带的一份检查脚本(lesson01_check.gd),不是你需要在第 1 课里写的东西。
[OK ] 调参表就位:站立速度 SPEED_STAND=4.5
[OK ] 调参表就位:逻辑步长 0.01667
tick=60 累计逻辑时间=1.00 秒
tick=120 累计逻辑时间=2.00 秒
tick=180 累计逻辑时间=3.00 秒
[OK ] 逻辑帧与物理帧同频(60 Hz) 181 次等待 → 180 个逻辑帧
[OK ] 转速表:每逻辑帧 6 度 rotation.y=-0.00° / 期望 0.00°(tick=180)
[OK ] 胶囊站在地板上(y=0 是脚底) y=0.0000
[OK ] 相机在身后 4 m cam z=4.000
──── 失败项:0 ────
两个数字解释一下,免得你以为自己写错了:
- 180 个 tick 而不是 181:测试脚本等了 181 个”物理帧信号”,第一个信号在当帧处理之前就发出了,所以差一帧。你自己玩的时候不用管这个——看 Output 里每秒一行就够。
rotation.y = -0.00是”正好转满一圈”:180 个逻辑帧 × 6 度 = 1080 度,对 360 取余是 0。它是浮点数,所以显示-0.00。
8. 常见报错与修法
| 现象 / 报错 | 原因 | 修法 |
|---|---|---|
Parse Error: Identifier "Tuning" not found | 忘了在文件开头 const Tuning = preload("res://scripts/tuning.gd");每个用到这张表的脚本都要自己写一次 | 补上那一行,路径必须从 res:// 开始 |
Invalid access to property or key 'Visual' on a base object of type 'CharacterBody3D' | 节点名对不上(Visual 拼错、或者层级不对,比如它被挂到了 Body 下面) | 场景里核对节点树;$Visual 只找直接子节点,隔层要用 $A/B |
| 按 F5 一片黑 / 看不到胶囊 | 没设主场景,或者相机被别的东西挡住了 | Project Settings → Application → Run → Main Scene 指向 world.tscn;把 spring_length 临时设成 0 试试能不能看见 |
| 胶囊不转 | player.gd 没挂到 Player 上(Inspector 的 Script 槽是空的),或者 _tick 里的代码没保存 | 看 Inspector 的 Script 栏;看 Output 有没有那行 tick=60 |
| Output 里一行都没有 | 打印条件写错,或者你没跑够 1 秒 | 等两秒;确认写的是 int(Tuning.LOGIC_HZ) 而不是 Tuning.LOGIC_HZ(小数取余会报错) |
| 控制台刷屏,每秒几百行 | 把 if _ticks % ... 那行的条件删了或写反了 | 恢复成讲义里的写法 |
9. 这一课故意没做什么(先知道,别急着补)
- 没有重力和移动:胶囊现在只是”站”在那儿。重力和
move_and_slide()是第 2 课的机制。 - 没有输入:键盘鼠标现在完全没接。Input Map 也是第 2 课。
- 相机这一套现在只是搭好、没解释:第 3 课讲。它现在不跟随鼠标、也不会转。
tuning.gd里一大半常量暂时用不到:它们是整个单元的表。一次写全,是为了让你以后改数值时永远只来这一个文件。
10. 打勾清单
- 项目建在
/home/jay/gameprojects/unturned-godot/,有scripts/和scenes/两个目录 -
scenes/player.tscn的节点树和讲义一致(含Body抬高到y = 1) -
scripts/tuning.gd落盘,能preload到 -
scripts/player.gd挂到Player上 - F5 能看见胶囊,且它在转,一秒一圈
- Output 每秒一行
tick=60 / 120 / 180 …
全打勾就可以进第 2 课:给这个循环接上键盘,让胶囊真的跑起来。