第 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 |
验收(你亲手做):
- 按 W 胶囊往前走,按 A/D 左右平移,按 S 后退;
- 按 Space 能跳起来,落回地面;
- 走到地板边缘掉下去(如果你把地板做小了)会被重力正常拉下去,不会飘。
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_forward | W | ControlsSettings.forward |
move_back | S | ControlsSettings.backward |
move_left | A | ControlsSettings.left |
move_right | D | ControlsSettings.right |
jump | Space | ControlsSettings.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
Intentintent.sample()读输入 → 只写 Intent,不碰状态 - 2
Movementmovement.simulate()定速度(读姿态) - 3
Looklook.simulate()定 yaw / pitch - 4
Playermove_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标志位做同样的事。
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;
}
}
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 out4.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) {)。
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)
{
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.14.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)
新增的三件事,都要看明白:
@onready var intent: Node = $Intent:@onready的意思是”等节点进场景树之后再执行这行”。因为_ready之前子节点还没准备好,直接写$Intent会拿到空值。这是新手最容易栽的一个坑。intent.sample()放在物理帧里、逻辑帧外面:读输入是每帧都做的事,而离散输入要等逻辑帧才被消费——原版是逻辑帧 12.5 Hz,50 Hz 的是tock;改状态只在_tick()里做。move_and_slide()放在累加器之后、函数末尾:它按照当前velocity移动身体并进行碰撞检测(撞墙就停住、上坡会走斜面)。它是CharacterBody3D唯一真正移动身体的方式——改position是错的做法,那样会穿墙。
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();
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。
你应该看到:
- 按 W 往前走,按 S 后退,A/D 左右平移(平移,不是转身——转身在第 3 课);
- 按 Space 跳起来,大约半米多高,落回地面;
- 松开 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 exist | Input 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 | 我们的速度是瞬间到位的(ImmediatelyResponsive 那套)。原版默认手感有轻微的加速与滑步;单机原型阶段先不要,但这是最容易被察觉手感差异的一处 |
空中操控 AirStrafing_* | PlayerMovement.cs:1284-1301Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1283-1302 | 跳起来之后完全改不了方向 |
| 起跳的体力门槛与断骨状态 | 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 | 玩家”练久了跑得快”的成长感 |
| 可走坡度 59° 与自动下滑 | PlayerMovement.cs:1187-1219Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1186-1220 | Godot 的 move_and_slide() 自带坡度处理,但阈值和原版不同 |
| 台阶/贴地吸附 | PlayerMovement.cs:1366-1401Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1365-1402 | 下楼梯会一格一格蹦 |
| 冲刺 7.0 / 蹲 2.5 / 趴 1.5 | PlayerMovement.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 课:给它加上蹲、趴,再把那台临时观察窗换成真正跟着你转的相机。