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

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

第 2 课 · 输入、意图与移动(单元一)

第 1 课你搭好了骨架,但那个胶囊只会自己转。这一课给它接上键盘,让它真的跑起来、跳起来。

  • 系统说明书(本课对应的源码解剖):docs/systems/01-player-controller.md。建议先扫一眼它的”系统地图”和”数值速查表”两节,不用全读。
  • 原版源码方向:Unturned/Player/PlayerInput.cs(输入采样)、Unturned/Player/PlayerMovement.cs(速度与重力)。

0. 本课要拿到什么

目标:WASD 走(4.5 m/s)、Space 跳(约 0.83 m)、重力会把你拉回地面、撞到东西会被挡住。

本课要动的文件(先看清楚,别漏改):

文件动作
project.godot 的 Input Map新增 5 个输入动作
scripts/player_intent.gd新建
scripts/player_movement.gd新建
scripts/player.gd整份替换成新版本(上一次那份里临时转速表要删掉)
scenes/player.tscn新增两个子节点 Intent、Movement

验收(你亲手做):

  1. 按 W 胶囊往前走,按 A/D 左右平移,按 S 后退;
  2. 按 Space 能跳起来,落回地面;
  3. 走到地板边缘掉下去(如果你把地板做小了)会被重力正常拉下去,不会飘。

1. 两个概念

1.1 为什么要有”意图层”

你现在完全可以这么写:if Input.is_action_pressed("move_forward"): position.z -= 1。能跑。但最后一整个单元做联机的时候,这句话会要你的命:联网时”玩家想干什么”必须能被送到服务器去,而不是本机直接改位置。所以从一开始就把两件事分开:

输入(键盘鼠标)  →  意图(想做什么)  →  权威状态(真的在哪、什么姿态)  →  表现(画在哪)
   Input Map           Intent                  Movement / Stance              Visual / Camera
  • 输入:Input 类的读取,只发生在物理帧开头的 sample() 里;
  • 意图:一个只有数据的节点,描述”想往前、想跳”,不管物理;
  • 权威状态:速度、位置、姿态,只有逻辑帧里的 simulate() 能改;
  • 表现:模型和摄像机,最后照着状态摆。

这套分层在第 1 课已经埋了:_physics_process 只记账、_tick() 才改状态。这一课把”意图”这层正式加进去。原版也是这个形状——PlayerInput 收输入,PlayerMovement.simulate() 改速度,两个文件两件事。

1.2 Input Map:别在代码里写按键

Input.is_key_pressed(KEY_W) 这种写法,等你以后想加”右键绑定""手柄支持""自定义按键”时会全部重写。Godot 的做法是给按键起个名字,代码里只用名字:

Project → Project Settings → Input Map。名字打错不会报错,只会”按键没反应”,所以名字要一字不差。

2. 配置 Input Map(5 个动作)

在 Add New Action 里依次加这 5 个(deadzone 保持默认 0.2),每加一个就点右边 ➕ 按一下对应键:

动作名按键对应原版
move_forwardWControlsSettings.forward
move_backSControlsSettings.backward
move_leftAControlsSettings.left
move_rightDControlsSettings.right
jumpSpaceControlsSettings.jump

(冲刺、蹲趴、鼠标是后面两课的内容,本课不配。)

官方文档:Input examples。

3. 在节点树里加两个节点

打开 scenes/player.tscn,选中 Player,右键 → Add Child Node,加两个 Node(就是最普通的空节点):

Player (CharacterBody3D)
├─ Body (CollisionShape3D)
├─ Intent (Node)        ← 新增,挂 scripts/player_intent.gd
├─ Movement (Node)      ← 新增,挂 scripts/player_movement.gd
├─ Visual (Node3D)
│   └─ Mesh (MeshInstance3D)
└─ Look (Node3D)
    └─ PitchPivot (Node3D)
        └─ SpringArm3D
            └─ Camera3D

顺序其实不影响运行,但建议照这个顺序摆:它和后面的执行顺序一一对应,你以后看节点树就能想起代码在干什么。

节点名必须恰好是 Intent 和 Movement——外壳脚本用 $Intent、$Movement 找它们,名字对不上会报”找不到子节点”。

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

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

  1. 1Intentintent.sample()读输入 → 只写 Intent,不碰状态
  2. 2Movementmovement.simulate()定速度(读姿态)
  3. 3Looklook.simulate()定 yaw / pitch
  4. 4Playermove_and_slide()唯一真的动身体的地方

真实场景里还有讲义这棵树没画的节点:Stance(player_stance.gd)——它们是后面几课才会讲的,所以现在不画进你照抄的那棵树里。

讲义那一棵树取自 docs/tutorials/lesson-02-input-and-movement.md;脚本挂载取自真实场景 reference/01-player-controller/player.tscn,两侧不一致会照实列出来。

4. 写代码

4.1 scripts/player_intent.gd(新建,挂给 Intent 节点)

scripts/player_intent.gd

extends Node
# 意图层:只记录"玩家想做什么",绝不改动任何物理状态。
# 对应原版 PlayerInput 里"采样输入"的那一半(Unturned/Player/PlayerInput.cs:1524-1642)。
# 第 3 课会加冲刺、蹲趴与鼠标。

var move := Vector2.ZERO   # x:左右(-1/0/1),y:前后(-1/0/1,正为前)

var _jump_latch := false   # 跳跃"上锁":按下就记住,等逻辑帧来取

func sample() -> void:
	move = Input.get_vector("move_left", "move_right", "move_back", "move_forward")
	# 原版的移动输入是整数(PlayerMovement.cs:1570-1594),归一化是在算速度那一步做的
	# (`move.normalized`,PlayerMovement.cs:1222)——所以斜着走不会更快。
	# 这里只取符号,速度由速度表统一给。
	move = Vector2(signf(move.x), signf(move.y))
	if Input.is_action_just_pressed("jump"):
		_jump_latch = true

func take_jump() -> bool:
	var out := _jump_latch
	_jump_latch = false
	return out

三段要解释:

  • Input.get_vector(左, 右, 后, 前):Godot 的现成工具,一次读四个方向、返回一个 Vector2。注意参数顺序是”左 右 后 前”,返回值的 y 正方向是”前”——容易记反,记住 get_vector 拿到的是”屏幕上的上下”。
  • Vector2(signf(...), signf(...)):把斜着走得到的 (0.707, 0.707) 变成 (1, 1)。原版的移动输入就是整数(PlayerMovement.cs:1570-1594Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1569-1595

    …还有 11 行没显示(共 27 行)

    { if (InputEx.GetKey(ControlsSettings.left)) { input_x = -1; } else if (InputEx.GetKey(ControlsSettings.right)) { input_x = 1; } else { input_x = 0; } if (InputEx.GetKey(ControlsSettings.up)) { input_y = 1; } else if (InputEx.GetKey(ControlsSettings.down)) { input_y = -1; } else { input_y = 0; } }
    ),归一化发生在后面算速度那一步(move.normalized,PlayerMovement.cs:1222Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1221-1223 { Vector3 desiredWalkVelocity = transform.rotation * move.normalized * speed;)。这么分的好处是:“速度上限”永远由速度表说了算,不会出现”斜着走快一倍”。
  • _jump_latch(跳跃上锁):Input.is_action_just_pressed("jump") 只在按下的那一帧为真,可是逻辑帧未必正好落在那一个物理帧上(第 1 课你已经看到两个时钟)。所以按下时先”锁上”,等逻辑帧来取——取走就解锁。这叫输入缓冲,原版用 inputJump 标志位做同样的事。
源码对照 scripts/player_intent.gd 悬停/点击任一行:看它抄的是原版哪几行
Unturned(Unity C#)· 行号是真实源码行号

Unturned/Player/PlayerInput.cs


		private void FixedUpdate()
		{
			if (isDismissed)
			{
				return;
			}

			if (Provider.isServer)
			{
				// Nelson 2025-06-17: adding here rather than the end is admittedly unintuitive,
				// but the body of this function has some early returns on the server and I don't
				// want to create hassle for plugin devs injecting code. The most up-to-date
				// character capsule is always included in hit validation regardless.
				serverBoundsHistory.AddCharacterControllerBounds(player.movement.controller);
			}

			if (channel.IsLocalPlayer)
			{
				if (count % SAMPLES == 0)
				{
					_tick = Time.realtimeSinceStartup;

					if (clientHasPendingResimulation)
					{
						clientHasPendingResimulation = false;
						ClientResimulate();
					}

					keys[0] = player.movement.jump;
					keys[1] = false; // Was primary attack, now available for something else.
					keys[2] = false; // Was secondary attack, now available for something else.
					keys[3] = player.stance.crouch;
					keys[4] = player.stance.prone;
					keys[5] = player.stance.sprint;
					keys[6] = player.animator.leanLeft;
					keys[7] = player.animator.leanRight;
					keys[8] = false; // Gun tactical input.
					keys[9] = player.stance.localWantsToSteadyAim;

					// Ignore input while rebinding a key.
					bool enablePluginKeys = !MenuConfigurationControlsUI.ShouldGameIgnoreInput;
					for (int pluginKeyIndex = 0; pluginKeyIndex < ControlsSettings.NUM_PLUGIN_KEYS; pluginKeyIndex++)
					{
						int packetKeyIndex = keys.Length - ControlsSettings.NUM_PLUGIN_KEYS + pluginKeyIndex;
						keys[packetKeyIndex] = enablePluginKeys && InputEx.GetKey(ControlsSettings.getPluginKeyCode(pluginKeyIndex));
					}

					player.equipment.CaptureAttackInputs(out pendingPrimaryAttackInput, out pendingSecondaryAttackInput);

					UnityEngine.Profiling.Profiler.BeginSample("Life");
					player.life.simulate(simulation);
					UnityEngine.Profiling.Profiler.EndSample();

					bool inputCrouch = player.stance.crouch;
					bool inputProne = player.stance.prone;
					bool inputSprint = player.stance.sprint;

					UnityEngine.Profiling.Profiler.BeginSample("Stance");
					player.stance.simulate(simulation, inputCrouch, inputProne, inputSprint);
					UnityEngine.Profiling.Profiler.EndSample();

					int input_x = player.movement.horizontal - 1;
					int input_y = player.movement.vertical - 1;
					bool inputJump = player.movement.jump;

					UnityEngine.Profiling.Profiler.BeginSample("Move");
					player.movement.simulate(simulation, 0, input_x, input_y, player.look.look_x, player.look.look_y, inputJump, inputSprint, PlayerInput.RATE);
					UnityEngine.Profiling.Profiler.EndSample();

					if (Provider.isServer)
					{
						inputs.Clear();
					}
					else
					{
						if (player.stance.stance == EPlayerStance.DRIVING)
						{
							clientPendingInput = new DrivingPlayerInputPacket(player.movement.getVehicle());
						}
						else
						{
							WalkingPlayerInputPacket clientPendingWalkingInput = new WalkingPlayerInputPacket();
							clientPendingWalkingInput.analog = (byte) ((player.movement.horizontal << 4) | player.movement.vertical);
							clientPendingWalkingInput.clientPosition = transform.position;

							clientPendingInput = clientPendingWalkingInput;

							ClientMovementInput historyInput = new ClientMovementInput();
							historyInput.frameNumber = simulation;
							historyInput.crouch = inputCrouch;
							historyInput.prone = inputProne;
							historyInput.input_x = input_x;
							historyInput.input_y = input_y;
							historyInput.jump = inputJump;
							historyInput.sprint = inputSprint;
							historyInput.rotation = transform.rotation;
							historyInput.aimRotation = player.look.aim.rotation;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
							historyInput.debugPosition = transform.position;
							historyInput.debugVelocity = player.movement.velocity;
							historyInput.debugIsGrounded = player.movement.isGrounded;
#endif // UNITY_EDITOR || DEVELOPMENT_BUILD
							clientInputHistory.Add(historyInput);
						}

						clientPendingInput.clientSimulationFrameNumber = simulation;
						clientPendingInput.recov = recov;
						clientPendingInput.pitch = player.look.pitch;
						clientPendingInput.yaw = player.look.yaw;
					}

					UnityEngine.Profiling.Profiler.BeginSample("Equipment");
					player.equipment.simulate(simulation, pendingPrimaryAttackInput, pendingSecondaryAttackInput, player.stance.localWantsToSteadyAim);
					UnityEngine.Profiling.Profiler.EndSample();

					UnityEngine.Profiling.Profiler.BeginSample("Animator");
					player.animator.simulate(simulation, player.animator.leanLeft, player.animator.leanRight);
					UnityEngine.Profiling.Profiler.EndSample();

					buffer += SAMPLES;

Unturned/Player/PlayerMovement.cs

				{
					Vector3 desiredWalkVelocity = transform.rotation * move.normalized * speed;
					{
						if (InputEx.GetKey(ControlsSettings.left))
						{
							input_x = -1;
						}
						else if (InputEx.GetKey(ControlsSettings.right))
						{
							input_x = 1;
						}
						else
						{
							input_x = 0;
						}

						if (InputEx.GetKey(ControlsSettings.up))
						{
							input_y = 1;
						}
						else if (InputEx.GetKey(ControlsSettings.down))
						{
							input_y = -1;
						}
						else
						{
							input_y = 0;
						}
					}
本项目(Godot)· scripts/player_intent.gd
extends Node
# 意图层:只记录"玩家想做什么",绝不改动任何物理状态。
# 对应原版 PlayerInput 里"采样输入"的那一半(Unturned/Player/PlayerInput.cs:1524-1642)。
# 第 3 课会加冲刺、蹲趴与鼠标。

var move := Vector2.ZERO   # x:左右(-1/0/1),y:前后(-1/0/1,正为前)

var _jump_latch := false   # 跳跃"上锁":按下就记住,等逻辑帧来取

func sample() -> void:
	move = Input.get_vector("move_left", "move_right", "move_back", "move_forward")
	# 原版的移动输入是整数(PlayerMovement.cs:1570-1594),归一化是在算速度那一步做的
	# (`move.normalized`,PlayerMovement.cs:1222)——所以斜着走不会更快。
	# 这里只取符号,速度由速度表统一给。
	move = Vector2(signf(move.x), signf(move.y))
	if Input.is_action_just_pressed("jump"):
		_jump_latch = true

func take_jump() -> bool:
	var out := _jump_latch
	_jump_latch = false
	return out

4.2 scripts/player_movement.gd(新建,挂给 Movement 节点)

scripts/player_movement.gd

extends Node
# 移动。对应原版 PlayerMovement 的 simulate(Unturned/Player/PlayerMovement.cs:1051)。
# 只算速度,不执行位移 —— 位移由外壳的 move_and_slide() 干。
# 第 3 课才会按姿态改速度与身高。

const Tuning = preload("res://scripts/tuning.gd")

var is_moving := false

func simulate(body: CharacterBody3D, step: float, intent: Node) -> void:
	var speed := Tuning.SPEED_STAND

	# 移动方向相对身体朝向。Godot 里前方是 -Z,所以 move.y 取负。
	var facing := Basis(Vector3.UP, body.rotation.y)
	var wish := facing * Vector3(intent.move.x, 0.0, -intent.move.y)
	wish.y = 0.0
	if wish.length() > 1.0:
		wish = wish.normalized()

	var want_jump: bool = intent.take_jump()

	if body.is_on_floor():
		# 原版两套摩擦模型(PlayerMovement.cs:1226-1273),
		# 本实现直接用 ImmediatelyResponsive 那套:速度直接赋值。
		body.velocity.x = wish.x * speed
		body.velocity.z = wish.z * speed
		# 起跳条件 PlayerMovement.cs:1308-1315:着地 && 姿态是站/冲刺 && 体力够 && 倍率非零。
		# 单元一把体力与熟练度都砍了,只剩"着地"这一条。
		if want_jump:
			body.velocity.y = Tuning.JUMP
		else:
			body.velocity.y = -0.1   # 一点向下的速度,帮 move_and_slide 贴住地面
	else:
		# 空中:重力 ×3(PlayerMovement.cs:1277 那个硬编码的 3)
		body.velocity.y -= Tuning.GRAVITY * Tuning.GRAVITY_TIMESCALE * step
		body.velocity.y = maxf(body.velocity.y, Tuning.TERMINAL_VELOCITY)
		# 空中不改水平速度:原版的 AirStrafing_* 配置(:1284-1301)本单元砍掉

	is_moving = Vector2(body.velocity.x, body.velocity.z).length() > 0.1

要点:

  • 它不算位移,只写 velocity。 真位移在外壳里由 move_and_slide() 执行。这一点跟原版不一样(原版 PlayerMovement 自己持有 CharacterController 并调它),是 Godot 的结构决定的:move_and_slide() 是 CharacterBody3D 自己的方法,子节点调不到。
  • Godot 里”前方”是 -Z,所以 move.y(正为前)要取负号才能变成世界方向。这一行写错的表现是”按 W 往后跑”。
  • facing 乘上去:移动方向相对身体朝向。现在没人转身体(视角是第 3 课),但先把结构写对,第 3 课接上鼠标时这里一行都不用改。
  • 重力那个 × 3 是原版硬编码的(PlayerMovement.cs:1277Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1276-1278 { velocity.y += Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3;)。别觉得它多余,去掉之后跳跃会变得像在月球上——你可以自己试:把 tuning.gd 里的 GRAVITY_TIMESCALE 从 3.0 改成 1.0,跳一次验收就明白。
  • is_moving 这一行看起来没用,其实第 3 课立刻要用:原版”能不能冲刺”的条件之一就是它在移动(PlayerStance.cs:703Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:702-704 } if (inputSprint && !player.life.isBroken && player.life.stamina > 0 && doesEquipmentAllowSprinting && player.movement.isMoving) {)。
源码对照 scripts/player_intent.gd 悬停/点击任一行:看它抄的是原版哪几行
Unturned(Unity C#)· 行号是真实源码行号

Unturned/Player/PlayerInput.cs


		private void FixedUpdate()
		{
			if (isDismissed)
			{
				return;
			}

			if (Provider.isServer)
			{
				// Nelson 2025-06-17: adding here rather than the end is admittedly unintuitive,
				// but the body of this function has some early returns on the server and I don't
				// want to create hassle for plugin devs injecting code. The most up-to-date
				// character capsule is always included in hit validation regardless.
				serverBoundsHistory.AddCharacterControllerBounds(player.movement.controller);
			}

			if (channel.IsLocalPlayer)
			{
				if (count % SAMPLES == 0)
				{
					_tick = Time.realtimeSinceStartup;

					if (clientHasPendingResimulation)
					{
						clientHasPendingResimulation = false;
						ClientResimulate();
					}

					keys[0] = player.movement.jump;
					keys[1] = false; // Was primary attack, now available for something else.
					keys[2] = false; // Was secondary attack, now available for something else.
					keys[3] = player.stance.crouch;
					keys[4] = player.stance.prone;
					keys[5] = player.stance.sprint;
					keys[6] = player.animator.leanLeft;
					keys[7] = player.animator.leanRight;
					keys[8] = false; // Gun tactical input.
					keys[9] = player.stance.localWantsToSteadyAim;

					// Ignore input while rebinding a key.
					bool enablePluginKeys = !MenuConfigurationControlsUI.ShouldGameIgnoreInput;
					for (int pluginKeyIndex = 0; pluginKeyIndex < ControlsSettings.NUM_PLUGIN_KEYS; pluginKeyIndex++)
					{
						int packetKeyIndex = keys.Length - ControlsSettings.NUM_PLUGIN_KEYS + pluginKeyIndex;
						keys[packetKeyIndex] = enablePluginKeys && InputEx.GetKey(ControlsSettings.getPluginKeyCode(pluginKeyIndex));
					}

					player.equipment.CaptureAttackInputs(out pendingPrimaryAttackInput, out pendingSecondaryAttackInput);

					UnityEngine.Profiling.Profiler.BeginSample("Life");
					player.life.simulate(simulation);
					UnityEngine.Profiling.Profiler.EndSample();

					bool inputCrouch = player.stance.crouch;
					bool inputProne = player.stance.prone;
					bool inputSprint = player.stance.sprint;

					UnityEngine.Profiling.Profiler.BeginSample("Stance");
					player.stance.simulate(simulation, inputCrouch, inputProne, inputSprint);
					UnityEngine.Profiling.Profiler.EndSample();

					int input_x = player.movement.horizontal - 1;
					int input_y = player.movement.vertical - 1;
					bool inputJump = player.movement.jump;

					UnityEngine.Profiling.Profiler.BeginSample("Move");
					player.movement.simulate(simulation, 0, input_x, input_y, player.look.look_x, player.look.look_y, inputJump, inputSprint, PlayerInput.RATE);
					UnityEngine.Profiling.Profiler.EndSample();

					if (Provider.isServer)
					{
						inputs.Clear();
					}
					else
					{
						if (player.stance.stance == EPlayerStance.DRIVING)
						{
							clientPendingInput = new DrivingPlayerInputPacket(player.movement.getVehicle());
						}
						else
						{
							WalkingPlayerInputPacket clientPendingWalkingInput = new WalkingPlayerInputPacket();
							clientPendingWalkingInput.analog = (byte) ((player.movement.horizontal << 4) | player.movement.vertical);
							clientPendingWalkingInput.clientPosition = transform.position;

							clientPendingInput = clientPendingWalkingInput;

							ClientMovementInput historyInput = new ClientMovementInput();
							historyInput.frameNumber = simulation;
							historyInput.crouch = inputCrouch;
							historyInput.prone = inputProne;
							historyInput.input_x = input_x;
							historyInput.input_y = input_y;
							historyInput.jump = inputJump;
							historyInput.sprint = inputSprint;
							historyInput.rotation = transform.rotation;
							historyInput.aimRotation = player.look.aim.rotation;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
							historyInput.debugPosition = transform.position;
							historyInput.debugVelocity = player.movement.velocity;
							historyInput.debugIsGrounded = player.movement.isGrounded;
#endif // UNITY_EDITOR || DEVELOPMENT_BUILD
							clientInputHistory.Add(historyInput);
						}

						clientPendingInput.clientSimulationFrameNumber = simulation;
						clientPendingInput.recov = recov;
						clientPendingInput.pitch = player.look.pitch;
						clientPendingInput.yaw = player.look.yaw;
					}

					UnityEngine.Profiling.Profiler.BeginSample("Equipment");
					player.equipment.simulate(simulation, pendingPrimaryAttackInput, pendingSecondaryAttackInput, player.stance.localWantsToSteadyAim);
					UnityEngine.Profiling.Profiler.EndSample();

					UnityEngine.Profiling.Profiler.BeginSample("Animator");
					player.animator.simulate(simulation, player.animator.leanLeft, player.animator.leanRight);
					UnityEngine.Profiling.Profiler.EndSample();

					buffer += SAMPLES;

Unturned/Player/PlayerMovement.cs

		/// </summary>
		public void simulate(uint simulation, int recov, int input_x, int input_y, float look_x, float look_y, bool inputJump, bool inputSprint, float deltaTime)
		{
				{
					Vector3 desiredWalkVelocity = transform.rotation * move.normalized * speed;
					{
						PhysicsMaterialCharacterFrictionProperties frictionProperties = PhysicMaterialCustomData.GetCharacterFrictionProperties(materialName);
						if (frictionProperties.mode == EPhysicsMaterialCharacterFrictionMode.ImmediatelyResponsive)
						{
							// Rather than adding gravity while grounded to smoothly walk down slopes, we adjust the
							// downward velocity to align with the floor plane. We do not allow an upward velocity here
							// because it would bounce us over the top of the ramp while walking up a slope.
							Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized;
							Vector3 desiredWalkForwardDirection = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized;
							desiredWalkVelocity = desiredWalkForwardDirection * speed;
							desiredWalkVelocity.y = Mathf.Min(desiredWalkVelocity.y, 0.0f);

							// Immediately responsive
							velocity = desiredWalkVelocity;
						}
						else
						{
							Vector3 currentVelocityAlongFloor = Vector3.ProjectOnPlane(velocity, ground.normal);
							float currentSpeedAlongFloor = currentVelocityAlongFloor.magnitude;

							Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized;
							Vector3 desiredWalkDirectionAlongFloor = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized;
							Vector3 desiredWalkVelocityAlongFloor = desiredWalkDirectionAlongFloor * speed;
							// note we do not clamp Y component here so that we can slide off jumps
							desiredWalkVelocityAlongFloor *= frictionProperties.maxSpeedMultiplier;
							float desiredSpeed = desiredWalkVelocityAlongFloor.magnitude;

							float maxSpeed;
							if (currentSpeedAlongFloor > desiredSpeed)
							{
								// Base deceleration is 2.0 m/s²
								float deceleration = -2.0f * frictionProperties.decelerationMultiplier;
								maxSpeed = Mathf.Max(desiredSpeed, currentSpeedAlongFloor + (deceleration * deltaTime));
							}
							else
							{
								maxSpeed = desiredSpeed;
							}

							// Questionable units-wise, but pretend base acceleration is proportional to desired speed.
							// For example if walk speed is 4.5 m/s then acceleration is 4.5 m/s².
							Vector3 acceleration = desiredWalkVelocityAlongFloor * frictionProperties.accelerationMultiplier;

							Vector3 newVelocity = currentVelocityAlongFloor + (acceleration * deltaTime);
							velocity = newVelocity.ClampMagnitude(maxSpeed);

							// Prevent sticking to walls at high velocity.
							updateVelocityAfterMove = true;
						}
					}
					{
						velocity.y += Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3;
						// Midair, maybe with already high rocket-jump speed
						float desiredWalkSpeed = desiredWalkVelocity.GetHorizontalMagnitude();
						Vector3 currentHorizontalVelocity = velocity.GetHorizontal();
						float currentHorizontalSpeed = velocity.GetHorizontalMagnitude();
						float maxHorizontalSpeed;
						if (currentHorizontalSpeed > desiredWalkSpeed)
						{
							float deceleration = 2.0f * Provider.modeConfigData.Gameplay.AirStrafing_Deceleration_Multiplier;
							maxHorizontalSpeed = Mathf.Max(desiredWalkSpeed, currentHorizontalSpeed - (deceleration * deltaTime));
						}
						else
						{
							maxHorizontalSpeed = desiredWalkSpeed;
						}
						Vector3 accel = desiredWalkVelocity * (8.0f * Provider.modeConfigData.Gameplay.AirStrafing_Acceleration_Multiplier);
						Vector3 newHorizontalVelocity = currentHorizontalVelocity + (accel * deltaTime);
						newHorizontalVelocity = newHorizontalVelocity.ClampHorizontalMagnitude(maxHorizontalSpeed);
						velocity.x = newHorizontalVelocity.x;
						velocity.z = newHorizontalVelocity.z;

				if (inputJump)
				{
					if (isGrounded && !player.life.isBroken && player.life.stamina >= 10 * (1f - (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.5f)) && (player.stance.stance == EPlayerStance.STAND || player.stance.stance == EPlayerStance.SPRINT) && !MathfEx.IsNearlyZero(pluginJumpMultiplier, 0.001f))
					{
						velocity.y = JUMP * (1f + (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.25f)) * pluginJumpMultiplier;
						player.life.askTire((byte) (10 * (1f - (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.5f))));
					}
				}
					{
						if (InputEx.GetKey(ControlsSettings.left))
						{
							input_x = -1;
						}
						else if (InputEx.GetKey(ControlsSettings.right))
						{
							input_x = 1;
						}
						else
						{
							input_x = 0;
						}

						if (InputEx.GetKey(ControlsSettings.up))
						{
							input_y = 1;
						}
						else if (InputEx.GetKey(ControlsSettings.down))
						{
							input_y = -1;
						}
						else
						{
							input_y = 0;
						}
					}

Unturned/Player/PlayerStance.cs

				}
				if (inputSprint && !player.life.isBroken && player.life.stamina > 0 && doesEquipmentAllowSprinting && player.movement.isMoving)
				{
本项目(Godot)· scripts/player_intent.gd
extends Node
# 移动。对应原版 PlayerMovement 的 simulate(Unturned/Player/PlayerMovement.cs:1051)。
# 只算速度,不执行位移 —— 位移由外壳的 move_and_slide() 干。
# 第 3 课才会按姿态改速度与身高。

const Tuning = preload("res://scripts/tuning.gd")

var is_moving := false

func simulate(body: CharacterBody3D, step: float, intent: Node) -> void:
	var speed := Tuning.SPEED_STAND

	# 移动方向相对身体朝向。Godot 里前方是 -Z,所以 move.y 取负。
	var facing := Basis(Vector3.UP, body.rotation.y)
	var wish := facing * Vector3(intent.move.x, 0.0, -intent.move.y)
	wish.y = 0.0
	if wish.length() > 1.0:
		wish = wish.normalized()

	var want_jump: bool = intent.take_jump()

	if body.is_on_floor():
		# 原版两套摩擦模型(PlayerMovement.cs:1226-1273),
		# 本实现直接用 ImmediatelyResponsive 那套:速度直接赋值。
		body.velocity.x = wish.x * speed
		body.velocity.z = wish.z * speed
		# 起跳条件 PlayerMovement.cs:1308-1315:着地 && 姿态是站/冲刺 && 体力够 && 倍率非零。
		# 单元一把体力与熟练度都砍了,只剩"着地"这一条。
		if want_jump:
			body.velocity.y = Tuning.JUMP
		else:
			body.velocity.y = -0.1   # 一点向下的速度,帮 move_and_slide 贴住地面
	else:
		# 空中:重力 ×3(PlayerMovement.cs:1277 那个硬编码的 3)
		body.velocity.y -= Tuning.GRAVITY * Tuning.GRAVITY_TIMESCALE * step
		body.velocity.y = maxf(body.velocity.y, Tuning.TERMINAL_VELOCITY)
		# 空中不改水平速度:原版的 AirStrafing_* 配置(:1284-1301)本单元砍掉

	is_moving = Vector2(body.velocity.x, body.velocity.z).length() > 0.1

4.3 scripts/player.gd(整份替换)

选中 Player 节点,把脚本内容整份换成下面这版。注意第 1 课那个”转速表”($Visual.rotate_y)这一课要删掉——它已经完成使命了。

scripts/player.gd

extends CharacterBody3D
# 外壳与时钟。第 2 课:接上输入,让胶囊真的跑起来。
# 这个文件在后面还会长大:第 3 课加姿态与视角。

const Tuning = preload("res://scripts/tuning.gd")

@onready var intent: Node = $Intent
@onready var movement: Node = $Movement

var _accum := 0.0   # 已经攒了多少秒
var _ticks := 0     # 已经跑过多少个逻辑帧

func _physics_process(delta: float) -> void:
	intent.sample()          # 输入层只记账
	_accum += delta
	while _accum >= Tuning.LOGIC_STEP:
		_accum -= Tuning.LOGIC_STEP
		_ticks += 1
		_tick(Tuning.LOGIC_STEP)
	move_and_slide()         # 唯一真正移动身体的地方

func _tick(step: float) -> void:
	# 顺序照着原版的驱动链写(PlayerInput.cs:1574-1640):
	# 先由意图定"想做什么",再让移动把它变成速度。
	movement.simulate(self, step, intent)

新增的三件事,都要看明白:

  1. @onready var intent: Node = $Intent:@onready 的意思是”等节点进场景树之后再执行这行”。因为 _ready 之前子节点还没准备好,直接写 $Intent 会拿到空值。这是新手最容易栽的一个坑。
  2. intent.sample() 放在物理帧里、逻辑帧外面:读输入是每帧都做的事,而离散输入要等逻辑帧才被消费——原版是逻辑帧 12.5 Hz,50 Hz 的是 tock;改状态只在 _tick() 里做。
  3. move_and_slide() 放在累加器之后、函数末尾:它按照当前 velocity 移动身体并进行碰撞检测(撞墙就停住、上坡会走斜面)。它是 CharacterBody3D 唯一真正移动身体的方式——改 position 是错的做法,那样会穿墙。
源码对照 scripts/player.gd 悬停/点击任一行:看它抄的是原版哪几行
Unturned(Unity C#)· 行号是真实源码行号

Unturned/Player/PlayerInput.cs

					UnityEngine.Profiling.Profiler.BeginSample("Life");
					player.life.simulate(simulation);
					UnityEngine.Profiling.Profiler.EndSample();

					bool inputCrouch = player.stance.crouch;
					bool inputProne = player.stance.prone;
					bool inputSprint = player.stance.sprint;

					UnityEngine.Profiling.Profiler.BeginSample("Stance");
					player.stance.simulate(simulation, inputCrouch, inputProne, inputSprint);
					UnityEngine.Profiling.Profiler.EndSample();

					int input_x = player.movement.horizontal - 1;
					int input_y = player.movement.vertical - 1;
					bool inputJump = player.movement.jump;

					UnityEngine.Profiling.Profiler.BeginSample("Move");
					player.movement.simulate(simulation, 0, input_x, input_y, player.look.look_x, player.look.look_y, inputJump, inputSprint, PlayerInput.RATE);
					UnityEngine.Profiling.Profiler.EndSample();

					if (Provider.isServer)
					{
						inputs.Clear();
					}
					else
					{
						if (player.stance.stance == EPlayerStance.DRIVING)
						{
							clientPendingInput = new DrivingPlayerInputPacket(player.movement.getVehicle());
						}
						else
						{
							WalkingPlayerInputPacket clientPendingWalkingInput = new WalkingPlayerInputPacket();
							clientPendingWalkingInput.analog = (byte) ((player.movement.horizontal << 4) | player.movement.vertical);
							clientPendingWalkingInput.clientPosition = transform.position;

							clientPendingInput = clientPendingWalkingInput;

							ClientMovementInput historyInput = new ClientMovementInput();
							historyInput.frameNumber = simulation;
							historyInput.crouch = inputCrouch;
							historyInput.prone = inputProne;
							historyInput.input_x = input_x;
							historyInput.input_y = input_y;
							historyInput.jump = inputJump;
							historyInput.sprint = inputSprint;
							historyInput.rotation = transform.rotation;
							historyInput.aimRotation = player.look.aim.rotation;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
							historyInput.debugPosition = transform.position;
							historyInput.debugVelocity = player.movement.velocity;
							historyInput.debugIsGrounded = player.movement.isGrounded;
#endif // UNITY_EDITOR || DEVELOPMENT_BUILD
							clientInputHistory.Add(historyInput);
						}

						clientPendingInput.clientSimulationFrameNumber = simulation;
						clientPendingInput.recov = recov;
						clientPendingInput.pitch = player.look.pitch;
						clientPendingInput.yaw = player.look.yaw;
					}

					UnityEngine.Profiling.Profiler.BeginSample("Equipment");
					player.equipment.simulate(simulation, pendingPrimaryAttackInput, pendingSecondaryAttackInput, player.stance.localWantsToSteadyAim);
					UnityEngine.Profiling.Profiler.EndSample();

					UnityEngine.Profiling.Profiler.BeginSample("Animator");
					player.animator.simulate(simulation, player.animator.leanLeft, player.animator.leanRight);
					UnityEngine.Profiling.Profiler.EndSample();
本项目(Godot)· scripts/player.gd
extends CharacterBody3D
# 外壳与时钟。第 2 课:接上输入,让胶囊真的跑起来。
# 这个文件在后面还会长大:第 3 课加姿态与视角。

const Tuning = preload("res://scripts/tuning.gd")

@onready var intent: Node = $Intent
@onready var movement: Node = $Movement

var _accum := 0.0   # 已经攒了多少秒
var _ticks := 0     # 已经跑过多少个逻辑帧

func _physics_process(delta: float) -> void:
	intent.sample()          # 输入层只记账
	_accum += delta
	while _accum >= Tuning.LOGIC_STEP:
		_accum -= Tuning.LOGIC_STEP
		_ticks += 1
		_tick(Tuning.LOGIC_STEP)
	move_and_slide()         # 唯一真正移动身体的地方

func _tick(step: float) -> void:
	# 顺序照着原版的驱动链写(PlayerInput.cs:1574-1640):
	# 先由意图定"想做什么",再让移动把它变成速度。
	movement.simulate(self, step, intent)

5. 跑起来

按 F5。

你应该看到:

  1. 按 W 往前走,按 S 后退,A/D 左右平移(平移,不是转身——转身在第 3 课);
  2. 按 Space 跳起来,大约半米多高,落回地面;
  3. 松开 W 立刻停住,没有惯性滑行(我们这版速度是直接赋值的,见第 8 节)。

如果你想要一个数字证据:让胶囊从地板一头走到另一头,默数大概几秒——4.5 m/s 意味着 4.5 秒能走 20 米出头。想更精确,就在 _tick() 里临时加一行:

	if _ticks % 60 == 0:
		print("每秒走过 %.2f 米" % (velocity.length()))

跑起来应该显示 4.50 左右(站着不动时是 0.00)。

6. 我替你跑过的验收

这是我在设计期跑过的验收抄本。这一课的完整工程就在仓库里(samples/lesson-02/),你可以在仓库根目录直接跑它复现:

/home/jay/.local/bin/godot --headless --path samples/lesson-02 world.tscn

带 [OK ] 的行来自这个样例里附带的一份检查脚本(lesson02_check.gd),不是你需要在第 2 课里写的东西。你要自己确认的是第 5 节那三条行为;下面的数字可以拿去对照(位移都是 4.5 / 2.25 / 4.5 米)。

[OK ] 落地  y=0.000
[OK ] 前进 4.5 m/s  60 帧走了 4.500 m
[OK ] is_moving 标志  停住后应为 false
[OK ] 停手后 is_moving = false  is_moving=false
[OK ] 后退也是 4.5 m/s  30 帧走了 2.250 m
[OK ] 横走 4.5 m/s(左 = -X)  30 帧走了 2.250 m
[OK ] 斜走不加速(原版 move.normalized)  60 帧走了 4.500 m
[OK ] 跳高约 0.83 m(跳 7.0 / 重力 9.81×3,容差 0.10)  实测 0.892 m
[OK ] 落回地面  y=0.000
──── 失败项:0 ────

三个数字值得说清:

  • 横着走也是 4.500 m:这个游戏的”快”只由速度表决定,方向不影响速度。
  • 斜着走 60 帧还是 4.500 m,不是 6.36 m:这就是第 4.1 节那个 signf + 归一化的作用。如果哪天你发现斜着走变快了,一定是 wish.normalize() 那段被改坏了。
  • 跳高 0.893 m 而不是算出来的 0.833 m:起跳那一帧还在”着地”分支里,所以少吃到一帧重力。这是固定步长离散积分的必然误差,不是 bug(连续解是 v²/2g = 7²/(2×29.4) = 0.833)。

7. 常见报错与修法

现象 / 报错原因修法
The InputMap action "move_forward" doesn't existInput Map 里名字打错或漏加回到 Project Settings → Input Map 逐个对;名字大小写和空格都要一致
按键没反应,但也没报错同上;或者 Input Map 里那个动作没绑定按键点开动作看右边有没有键;也可以看第 8 节最后那个打印当前输入的小技巧
Invalid access to property or key 'sample' on a base object of type 'Node'Intent 节点没挂脚本,或者 $Intent 指的节点不存在场景里选中 Intent,看 Inspector 的 Script 槽是不是空的
Invalid get index 'Intent' (on base: 'CharacterBody3D')节点名不是 Intent(比如写成了 intent)节点名严格一致
按 W 往后跑Vector3(intent.move.x, 0.0, -intent.move.y) 里的负号丢了补上 -;Godot 的前方是 -Z
胶囊穿墙 / 穿地板用了 position += ... 而不是 move_and_slide()位移只能交给 move_and_slide();velocity 只写不读
一跳就飘很远,像在月球GRAVITY_TIMESCALE 被改成 1.0 了改回 3.0
按 Space 有时跳不起来跳跃没做”上锁”(直接在 _tick 里读 is_action_just_pressed)用讲义里的 _jump_latch 写法,逻辑帧取走才解锁

8. 与原版的落差(这一课砍了什么)

砍掉的源码位置丢了什么
两套地面摩擦模型(起步/停下有加速减速)PlayerMovement.cs:1226-1273Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1225-1274

…还有 34 行没显示(共 50 行)

{ PhysicsMaterialCharacterFrictionProperties frictionProperties = PhysicMaterialCustomData.GetCharacterFrictionProperties(materialName); if (frictionProperties.mode == EPhysicsMaterialCharacterFrictionMode.ImmediatelyResponsive) { // Rather than adding gravity while grounded to smoothly walk down slopes, we adjust the // downward velocity to align with the floor plane. We do not allow an upward velocity here // because it would bounce us over the top of the ramp while walking up a slope. Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized; Vector3 desiredWalkForwardDirection = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized; desiredWalkVelocity = desiredWalkForwardDirection * speed; desiredWalkVelocity.y = Mathf.Min(desiredWalkVelocity.y, 0.0f); // Immediately responsive velocity = desiredWalkVelocity; } else { Vector3 currentVelocityAlongFloor = Vector3.ProjectOnPlane(velocity, ground.normal); float currentSpeedAlongFloor = currentVelocityAlongFloor.magnitude; Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized; Vector3 desiredWalkDirectionAlongFloor = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized; Vector3 desiredWalkVelocityAlongFloor = desiredWalkDirectionAlongFloor * speed; // note we do not clamp Y component here so that we can slide off jumps desiredWalkVelocityAlongFloor *= frictionProperties.maxSpeedMultiplier; float desiredSpeed = desiredWalkVelocityAlongFloor.magnitude; float maxSpeed; if (currentSpeedAlongFloor > desiredSpeed) { // Base deceleration is 2.0 m/s² float deceleration = -2.0f * frictionProperties.decelerationMultiplier; maxSpeed = Mathf.Max(desiredSpeed, currentSpeedAlongFloor + (deceleration * deltaTime)); } else { maxSpeed = desiredSpeed; } // Questionable units-wise, but pretend base acceleration is proportional to desired speed. // For example if walk speed is 4.5 m/s then acceleration is 4.5 m/s². Vector3 acceleration = desiredWalkVelocityAlongFloor * frictionProperties.accelerationMultiplier; Vector3 newVelocity = currentVelocityAlongFloor + (acceleration * deltaTime); velocity = newVelocity.ClampMagnitude(maxSpeed); // Prevent sticking to walls at high velocity. updateVelocityAfterMove = true; } }
我们的速度是瞬间到位的(ImmediatelyResponsive 那套)。原版默认手感有轻微的加速与滑步;单机原型阶段先不要,但这是最容易被察觉手感差异的一处
空中操控 AirStrafing_*PlayerMovement.cs:1284-1301Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1283-1302

…还有 4 行没显示(共 20 行)

// Midair, maybe with already high rocket-jump speed float desiredWalkSpeed = desiredWalkVelocity.GetHorizontalMagnitude(); Vector3 currentHorizontalVelocity = velocity.GetHorizontal(); float currentHorizontalSpeed = velocity.GetHorizontalMagnitude(); float maxHorizontalSpeed; if (currentHorizontalSpeed > desiredWalkSpeed) { float deceleration = 2.0f * Provider.modeConfigData.Gameplay.AirStrafing_Deceleration_Multiplier; maxHorizontalSpeed = Mathf.Max(desiredWalkSpeed, currentHorizontalSpeed - (deceleration * deltaTime)); } else { maxHorizontalSpeed = desiredWalkSpeed; } Vector3 accel = desiredWalkVelocity * (8.0f * Provider.modeConfigData.Gameplay.AirStrafing_Acceleration_Multiplier); Vector3 newHorizontalVelocity = currentHorizontalVelocity + (accel * deltaTime); newHorizontalVelocity = newHorizontalVelocity.ClampHorizontalMagnitude(maxHorizontalSpeed); velocity.x = newHorizontalVelocity.x; velocity.z = newHorizontalVelocity.z;
跳起来之后完全改不了方向
起跳的体力门槛与断骨状态PlayerMovement.cs:1310-1313Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1309-1314 { if (isGrounded && !player.life.isBroken && player.life.stamina >= 10 * (1f - (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.5f)) && (player.stance.stance == EPlayerStance.STAND || player.stance.stance == EPlayerStance.SPRINT) && !MathfEx.IsNearlyZero(pluginJumpMultiplier, 0.001f)) { velocity.y = JUMP * (1f + (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.25f)) * pluginJumpMultiplier; player.life.askTire((byte) (10 * (1f - (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.5f)))); }原版跳一次扣体力,体力不够跳不动;我们单元五才接回来
熟练度乘区(exercise、totalSpeedMultiplier)PlayerMovement.cs:291-330Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:290-331

…还有 26 行没显示(共 42 行)

private bool _isMoving; public bool isMoving => _isMoving; public float speed { get { if (player.stance.stance == EPlayerStance.SWIM) { return SPEED_SWIM * (1f + (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.DIVING) * 0.25f)) * totalSpeedMultiplier; } else { float exercise = 1f + (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.EXERCISE) * 0.25f); if (player.stance.stance == EPlayerStance.CLIMB) { return SPEED_CLIMB * exercise * totalSpeedMultiplier; } else if (player.stance.stance == EPlayerStance.SPRINT) { return SPEED_SPRINT * exercise * totalSpeedMultiplier; } else if (player.stance.stance == EPlayerStance.STAND) { return SPEED_STAND * exercise * totalSpeedMultiplier; } else if (player.stance.stance == EPlayerStance.CROUCH) { return SPEED_CROUCH * exercise * totalSpeedMultiplier; } else if (player.stance.stance == EPlayerStance.PRONE) { return SPEED_PRONE * exercise * totalSpeedMultiplier; } } return 0; } } private Vector3 _move;
玩家”练久了跑得快”的成长感
可走坡度 59° 与自动下滑PlayerMovement.cs:1187-1219Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1186-1220

…还有 19 行没显示(共 35 行)

float maxWalkableSlope = 59; if (Level.info != null && Level.info.configData != null && Level.info.configData.Max_Walkable_Slope > -0.5f) { maxWalkableSlope = Level.info.configData.Max_Walkable_Slope; } sliding = angle > maxWalkableSlope; slidingGroundNormal = ground.normal; } if (!sliding) { if (mostRecentControllerColliderHit != null && mostRecentControllerColliderHit.collider != null // public issue #3726 && mostRecentControllerColliderHit.gameObject != null && mostRecentControllerColliderHit.normal.y > 0.0f && mostRecentControllerColliderHit.gameObject.CompareTag("Agent")) { // Prevent standing on zombie head. sliding = true; slidingGroundNormal = mostRecentControllerColliderHit.normal; } } if (sliding) { Vector3 perp = Vector3.Cross(Vector3.up, slidingGroundNormal).normalized; Vector3 slide = Vector3.Cross(perp, slidingGroundNormal).normalized; velocity += slide * 16.0f * deltaTime; // Prevent building up huge velocity by sliding against a wall. updateVelocityAfterMove = true; } else
Godot 的 move_and_slide() 自带坡度处理,但阈值和原版不同
台阶/贴地吸附PlayerMovement.cs:1366-1401Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1365-1402

…还有 22 行没显示(共 38 行)

{ // Player *was* grounded, so let's see if we should slightly snap the player to the ground. // Don't snap if velocity was upward to prevent sticking to ground when e.g. jumping. if (velocity.y < 0.01f) { int mask = RayMasks.BLOCK_COLLISION; float CAST_RADIUS = PlayerStance.RADIUS - 0.001f; float snapLength = controller.stepOffset + SKIN_WIDTH; Ray snapRay = new Ray(transform.position + new Vector3(0.0f, CAST_RADIUS, 0.0f), Vector3.down); bool snapRayHit = Physics.SphereCast(snapRay, CAST_RADIUS, out RaycastHit snapHit, snapLength, mask, QueryTriggerInteraction.Ignore); #if WITH_FLOOR_SNAPPING_GIZMOS RuntimeGizmos.Get().Spherecast(snapRay, CAST_RADIUS, snapLength, snapHit, Color.green, Color.red, 0.1f); #endif if (snapRayHit) { float snapDelta = (snapHit.distance - SKIN_WIDTH); if (snapDelta > Mathf.Epsilon) { Vector3 snapFloorNormal = snapHit.normal; float maxWalkableSlope = 59; if (Level.info != null && Level.info.configData != null && Level.info.configData.Max_Walkable_Slope > -0.5f) { maxWalkableSlope = Level.info.configData.Max_Walkable_Slope; } // Nelson 2024-08-19: Snapping to *any* surface was quickly exploited to climb trees! bool canSnap = Vector3.Angle(Vector3.up, snapFloorNormal) < maxWalkableSlope; #if WITH_FLOOR_SNAPPING_GIZMOS RuntimeGizmos.Get().Arrow(snapHit.point, snapHit.normal, 0.4f, canSnap ? Color.green : Color.red, 0.1f, EGizmoLayer.Foreground); #endif if (canSnap) { transform.position += new Vector3(0.0f, -snapDelta, 0.0f); } } } } }
下楼梯会一格一格蹦
冲刺 7.0 / 蹲 2.5 / 趴 1.5PlayerMovement.cs:91-104Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-105 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;下一课的内容:那三个速度由姿态决定,得先有姿态状态机

顺带一个调试技巧,以后遇到”按键没反应”很好用——在 _tick() 里临时加:

	if intent.move != Vector2.ZERO:
		print("当前意图:", intent.move)

有输出说明输入这一层通了,问题在后面的层;没输出就是 Input Map 或节点名的问题。

9. 打勾清单

  • Input Map 里有 5 个动作,名字一字不差
  • 节点树里多了 Intent 和 Movement 两个节点,各自挂着脚本
  • scripts/player.gd 换成了新版本,转速表那段已经删掉
  • W/S/A/D 能动,Space 能跳,松开就停
  • 斜着走没有变快
  • Output 里没有 InputMap action ... doesn't exist 之类的红字

全打勾,第一批(第 1-2 课)就学完了。这批的核心成果是:一个能跑能跳、分层干净的角色,和一个可以随时改数值的调参表。 接下来是第 3-4 课:给它加上蹲、趴,再把那台临时观察窗换成真正跟着你转的相机。