U3 → Godot 复刻课程 交互式课程站 · 本地学习件

单元一 · 起步与角色 / 第 1 课

第 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. 本课要拿到什么

目标:项目建起来、节点树搭出来、调参表就位、一个固定步长的”逻辑帧”循环真的在跑。

验收(你亲手做,必须看见):

  1. 按 F5,屏幕上出现一个灰白色胶囊,摄像机在它身后;
  2. 那个胶囊自己在慢慢转,一秒正好转一圈;
  3. 控制台(Output 面板)每秒打印一行:tick=60 累计逻辑时间=1.00 秒、tick=120 …、tick=180 …。

不要小看第 2、3 条。它们是这门课后面所有内容的地基:这个循环就是以后放移动、姿态、僵尸 AI、昼夜计时的地方,也是最后一个单元做联机时唯一要换掉的东西。

1. 三个概念,讲完就动手

1.1 场景与节点树

Godot 里你搭的一切都是节点,节点挂在别的节点下面,形成一棵树(叫场景)。规则只有一条,但它是整个引擎的地基:

父节点动,子节点跟着动。

所以你后面会看到:身体转左右、摄像机跟着转;身体变矮(蹲下)、眼睛跟着降。这不是代码里一行行写出来的,是节点树的结构天然给的。

节点树 点节点看它挂什么脚本,下方是这一帧的数据流

这一帧的数据流(3 步,只有最后一步真的动身体)

  1. 1Intentintent.sample()读输入 → 只写 Intent,不碰状态
  2. 2Looklook.simulate()定 yaw / pitch
  3. 3Playermove_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. 建项目骨架

      1. 打开 Godot → New Project → 项目名 unturned-godot,路径选 /home/jay/gameprojects/unturned-godot/,渲染器保持默认 Forward+,点 Create & Edit。 (这个目录里已有的 docs/、reference/、site/ 是课程资料,reference/ 和 site/ 各自带了一个 .gdignore,Godot 会整个跳过它们,不会打架。)
      2. 在 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             ← 其他不用改

      三个必须解释的地方:

      1. 为什么 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。
      2. 为什么 Visual 和 Body 分开? Body 管碰撞(参与物理计算),Visual 管长相(给人看的)。这两件事以后会经常不同步——比如蹲下时碰撞胶囊变矮,但模型可能还要走一段动画。分开是给自己留后路。
      3. 相机这一套(Look / PitchPivot / SpringArm3D)现在先照抄搭好,不要问为什么。 它现在是”一扇能看见自己的观察窗”,第 3 课会专门讲它为什么这么搭(SpringArm3D 为什么会防穿墙、0.39 这个半径哪来的)。现在搭它的理由很实在:没有一个在身后的相机,你根本看不见胶囊在转。

      数值的出处(不是随手写的,都对着原版量过):

      数值出处
      胶囊半径 0.4Unturned/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.8Unturned/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.35Unturned/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.39Unturned/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,其余的先放着——它们是整单元的表,不是让你现在读懂。

      源码对照 scripts/tuning.gd 悬停/点击任一行:看它抄的是原版哪几行
      Unturned(Unity C#)· 行号是真实源码行号

      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)
      							{
      本项目(Godot)· 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

      5. 写第二个脚本: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。

      你应该看到:

      1. 画面中央偏下一个灰白胶囊,镜头在它身后约 4 米、略高一点;
      2. 胶囊在慢慢转,不卡、不抖,一秒一圈;
      3. 下方 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 课:给这个循环接上键盘,让胶囊真的跑起来。