1. 项目概述从零开始的 Godot 之旅 — EP5控制角色移动如果你刚刚接触 Godot并且已经跟着教程走完了创建场景、添加节点、设置碰撞这些基础步骤那么恭喜你你已经搭建好了一个角色的“躯壳”。现在是时候赋予这个躯壳“灵魂”了——也就是让它在你的游戏世界里动起来。控制角色移动这几乎是所有游戏开发旅程中第一个真正让人兴奋的里程碑。它不仅仅是让一个精灵在屏幕上滑动更是你与游戏世界建立交互的开始。在 Godot 中这个过程既直观又强大尤其是当你开始理解CharacterBody3D节点和_physics_process函数背后的逻辑时。这篇文章就是为你准备的“角色移动”实操指南。我们将不依赖任何预设的脚本模板从零开始一行一行地构建一个在 3D 空间中响应键盘输入、平滑移动并受重力影响的角色控制器。无论你是想制作一个跑酷游戏、一个 RPG还是一个简单的平台跳跃游戏这里面的核心逻辑都是相通的。我会带你深入理解velocity速度向量、move_and_slide()方法以及如何处理输入和重力让你不仅知道怎么做更明白为什么这么做。准备好了吗让我们开始这段让角色“活”起来的旅程。2. 核心思路与架构设计为什么是 CharacterBody3D 和物理帧在开始敲代码之前我们先花点时间理清思路。Godot 提供了多种处理移动和碰撞的方式比如RigidBody3D刚体物理模拟驱动和Area3D区域用于检测但不处理碰撞。但对于一个需要玩家精确控制的角色比如马里奥、塞尔达CharacterBody3D是官方推荐且最合适的节点类型。2.1 为什么选择 CharacterBody3DCharacterBody3D是一个专门为需要自定义移动逻辑的角色设计的节点。它与物理引擎紧密集成但把移动控制的“方向盘”完全交给了你。这意味着完全可控你可以精确计算每一帧角色应该移动到哪里而不是像刚体那样被物理规则如动量、摩擦力完全支配。内置碰撞响应它提供了move_and_collide()和move_and_slide()等方法能自动处理与场景中其他PhysicsBody的碰撞并返回碰撞信息让你可以据此做出反应比如碰到墙就停止碰到地板就站立。地面检测它内置了is_on_floor()、is_on_wall()、is_on_ceiling()等方法这对于实现跳跃、攀爬等平台游戏核心机制至关重要。简单来说CharacterBody3D是“物理驱动的可控体”它为你处理了复杂的碰撞检测和响应让你能专注于编写“如何移动”的逻辑。2.2 物理帧 (_physics_process) 与普通帧 (_process) 的区别这是 Godot 开发中一个关键概念理解错了可能会导致移动卡顿或不稳定。_process(delta)每渲染一帧调用一次。调用频率取决于显示器的刷新率如 60Hz 就是每秒60次。它适合处理与画面渲染紧密相关、但对时间精度要求不高的逻辑比如 UI 更新、非物理的动画播放。_physics_process(delta)在固定的时间间隔调用默认每秒60次与渲染帧率脱钩。这个间隔就是delta参数通常是固定的如 1/60 秒。所有与物理、碰撞、移动相关的代码都应该放在这里。为什么因为物理引擎如 Godot 内置的 Godot Physics 或 Jolt需要在稳定的时间步长下进行模拟才能保证碰撞检测和物体运动的准确性。如果放在_process里当游戏帧率波动时角色的移动速度也会时快时慢碰撞检测可能出错。_physics_process保证了物理模拟的稳定性。所以我们的移动代码将全部写在_physics_process(delta)函数中。2.3 移动逻辑的蓝图我们的移动控制器将围绕一个核心向量展开velocity速度。在每一帧的_physics_process中我们会读取输入检查玩家按下了哪些方向键WASD 或方向键。计算水平方向速度根据输入计算出一个表示水平移动方向和速度的向量。应用重力如果角色在空中则根据重力加速度持续修改垂直方向Y轴的速度使其下落。组合最终速度将水平速度和垂直速度合并到velocity向量中。执行移动调用move_and_slide()让CharacterBody3D根据velocity移动角色并自动处理碰撞。更新状态移动后根据碰撞结果如是否着地更新角色状态为下一帧计算做准备。这个流程构成了一个清晰的循环是绝大多数 3D 角色控制器的基石。3. 核心细节解析与实操要点理解了整体框架我们来拆解每一个环节看看具体怎么做以及有哪些需要注意的“坑”。3.1 输入处理从按键到方向向量Godot 的输入系统非常灵活我们使用Input单例来检测动作Action。首先确保你已经在项目设置的“输入映射”中定义了move_left,move_right,move_forward,move_back这些动作并关联了相应的按键如 A/D/W/S。在代码中我们这样获取输入var direction Vector3.ZERO # 初始化一个零向量 if Input.is_action_pressed(move_right): direction.x 1 if Input.is_action_pressed(move_left): direction.x - 1 if Input.is_action_pressed(move_back): # 注意在3D中向前是-Z向后是Z direction.z 1 if Input.is_action_pressed(move_forward): direction.z - 1这里有几个要点is_action_pressed在按键被按住的每一帧都返回true适合持续移动。方向轴在 Godot 的 3D 坐标系中默认是Y轴向上X轴向右Z轴向内屏幕深处。所以move_forward对应direction.z - 1是向 Z 轴负方向移动。向量叠加如果同时按下move_right(x1) 和move_forward(z-1)direction会变成(1, 0, -1)。这个向量的长度模是 √2 ≈ 1.414。这意味着对角移动会比单轴移动快 1.414 倍这通常不是我们想要的。注意必须对方向向量进行归一化Normalize。归一化就是将向量缩放到长度为 1只保留方向信息。这样无论朝哪个方向移动基础速度都是一致的。if direction ! Vector3.ZERO: direction direction.normalized()3.2 速度向量的构成水平移动与垂直重力CharacterBody3D有一个velocity属性它是一个Vector3。我们的任务就是在每一帧正确计算它。# 1. 计算水平地面速度 var horizontal_velocity direction * speed # speed 是你定义的移动速度标量如 10 # 2. 处理垂直方向的重力 var vertical_velocity velocity.y # 继承上一帧的垂直速度 if not is_on_floor(): vertical_velocity - fall_acceleration * delta # fall_acceleration 是重力加速度如 75 # 注意这里用减号因为Y轴向上为正重力是向下的力。 # 3. 组合速度向量 target_velocity.x horizontal_velocity.x target_velocity.z horizontal_velocity.z target_velocity.y vertical_velocity # 4. 赋值给角色的 velocity velocity target_velocity关键解释delta这是_physics_process函数的参数代表自上一帧物理更新以来经过的时间通常是固定的如 1/60 秒。任何基于时间的连续变化如速度、位移都必须乘以delta这样才能保证在不同帧率下行为一致。例如vertical_velocity - fall_acceleration * delta意味着“这一帧垂直速度减少重力加速度 × 帧时间”。is_on_floor()这是CharacterBody3D提供的神奇方法。只有在调用move_and_slide()或move_and_collide()之后它才能根据碰撞结果准确判断角色是否站在地面上。因此我们在计算重力时检查它只有不在地面上时才应用重力。分离计算将水平速度和垂直速度分开计算是个好习惯。这样逻辑更清晰也便于后续单独修改比如添加跳跃时只需修改垂直速度。3.3 执行移动move_and_slide()的魔力计算好velocity后最后一步就是调用move_and_slide()。move_and_slide()这个方法做了很多事情内部移动它根据当前velocity向量尝试移动角色。碰撞检测与响应如果移动过程中撞到其他PhysicsBody如墙壁、地板它会根据碰撞面的法线自动调整velocity防止穿透。例如撞到水平的墙水平速度会被抵消撞到地板垂直速度会被设置为0。更新地面状态移动完成后它会更新内部碰撞状态使得is_on_floor()等方法能返回正确结果。滑动处理如果角色沿着斜坡或墙角移动move_and_slide()会尝试让角色“滑过”表面而不是卡住。这是它比move_and_collide()更常用于角色移动的原因。一个非常重要的细节move_and_slide()可能会在内部修改velocity属性例如撞墙后水平速度被设为0。因此不要在调用move_and_slide()之后在同一帧内再次使用velocity进行其他物理计算除非你明确知道自己在做什么。通常的模式是计算新的velocity- 调用move_and_slide()- 等待下一帧。4. 完整实现与代码逐行解读现在我们把所有部分组合起来形成一个完整的、可用的角色移动脚本。我将创建一个名为player.gd的脚本附加到CharacterBody3D节点上。4.1 脚本属性定义首先在脚本顶部定义一些可配置的属性。使用export关键字可以让它们在编辑器的检查器中显示方便随时调整无需修改代码。extends CharacterBody3D # 玩家在地面上的移动速度米/秒 export var speed: float 10.0 # 空中的下落加速度米/秒²。地球重力约为 9.8游戏中可以调大些让下落更快。 export var fall_acceleration: float 30.0 # 这是一个临时变量用于存储我们计算出的目标速度最后赋值给 velocity。 # 将其与 velocity 分开可以避免直接修改 velocity 可能带来的混淆。 var target_velocity Vector3.ZERO为什么用export调试和平衡游戏参数时你可以在运行游戏的同时在编辑器中实时拖动滑块修改speed和fall_acceleration立即看到效果这比反复修改代码、重启游戏高效得多。4.2 物理处理函数_physics_process这是脚本的核心。func _physics_process(delta: float) - void: # 1. 初始化输入方向向量 var input_direction Vector3.ZERO # 2. 根据按键输入更新方向向量 # 注意Godot 的 3D 坐标系Z轴负方向是“向前”看向屏幕里。 if Input.is_action_pressed(move_right): input_direction.x 1.0 if Input.is_action_pressed(move_left): input_direction.x - 1.0 if Input.is_action_pressed(move_back): input_direction.z 1.0 if Input.is_action_pressed(move_forward): input_direction.z - 1.0 # 3. 处理方向归一化并让角色面向移动方向 if input_direction ! Vector3.ZERO: # 归一化确保对角线移动速度不会更快 input_direction input_direction.normalized() # 可选让角色的视觉模型例如一个子节点 MeshInstance3D面向移动方向 # 这里假设你有一个叫 “Pivot” 或 “Model” 的子节点来控制视觉旋转 # $Pivot.look_at(global_position input_direction, Vector3.UP) # 更简单的做法直接设置 Basis。Basis.looking_at() 创建一个朝向目标方向的旋转。 $Pivot.basis Basis.looking_at(input_direction) # 4. 计算地面水平速度 target_velocity.x input_direction.x * speed target_velocity.z input_direction.z * speed # 5. 计算垂直速度应用重力 if not is_on_floor(): # 如果不在空中则应用向下的加速度 # 注意Y轴向上为正所以重力是负加速度 target_velocity.y - fall_acceleration * delta else: # 如果在地面上确保垂直速度不被重力持续影响而变成负数除非你想让角色沉入地面。 # 通常在地面时我们把垂直速度设为0或一个很小的值防止抖动。 # move_and_slide() 在碰到地面时会自动将垂直速度设为0但这里显式处理更安全。 target_velocity.y 0.0 # 6. 将计算好的目标速度赋给角色的 velocity velocity target_velocity # 7. 执行移动这是最关键的一步。 move_and_slide()逐行解读与技巧第2步输入处理这里使用了四个独立的if语句而不是elif。这是为了允许组合按键如同时按右和前产生对角线输入。第3步面向方向$Pivot.basis Basis.looking_at(input_direction)这行代码非常有用。它让一个名为Pivot的子节点通常用来挂载角色模型立即面向移动方向。Basis.looking_at(target_direction, up_vector)会计算出一个旋转矩阵让节点的 -Z 轴前向指向target_direction。第二个参数Vector3.UP定义了哪个方向是“上”确保旋转不会倒置。第5步重力处理if not is_on_floor():这个判断是平台游戏逻辑的基石。只有角色悬空时我们才应用重力。一旦move_and_slide()检测到角色站在了地面上is_on_floor()就会返回true重力计算停止角色稳稳站住。第5步地面垂直速度将地面上的target_velocity.y设为0是一个好习惯。虽然move_and_slide()在碰撞到地面时通常会处理但显式设置可以避免一些极端情况下的微小下沉或抖动。第7步move_and_slide()调用这个方法后角色的位置会被更新碰撞状态is_on_floor等也会被刷新供下一帧使用。4.3 基础跳跃功能的实现一个不能跳跃的角色是不完整的。让我们在现有基础上增加跳跃功能。首先在export变量区域添加一个跳跃速度export var jump_impulse: float 15.0 # 跳跃的初始向上速度然后在_physics_process函数中重力计算部分之前添加跳跃输入检测func _physics_process(delta: float) - void: # ... [之前的输入和方向计算代码不变] ... # 4. 处理跳跃输入必须在地面上才能跳 if Input.is_action_just_pressed(jump) and is_on_floor(): target_velocity.y jump_impulse # 赋予一个向上的初速度 # 5. 计算地面速度原第4步 target_velocity.x input_direction.x * speed target_velocity.z input_direction.z * speed # 6. 计算垂直速度应用重力原第5步但逻辑需调整 # 注意跳跃后is_on_floor() 会立即变为 false所以重力会开始作用。 if not is_on_floor(): target_velocity.y - fall_acceleration * delta # 注意这里不再有 else target_velocity.y 0因为跳跃已经设置了y速度。 # 但是如果因为某种原因如从边缘滑落y速度是负的而我们又在地面应该重置。 # 更健壮的写法 # if is_on_floor() and target_velocity.y 0: # target_velocity.y 0 # elif not is_on_floor(): # target_velocity.y - fall_acceleration * delta # 7. 赋值并移动原第6、7步 velocity target_velocity move_and_slide()跳跃逻辑的关键is_action_just_pressed(“jump”)这个函数只在按键被按下的那一帧返回true。如果用is_action_pressed玩家按住空格键会导致角色不断向上“喷射”这不是我们想要的单次跳跃。and is_on_floor()这是一个重要的限制条件防止“二段跳”或空中无限跳除非你的游戏设计允许。只有在地面上时按下跳跃键才有效。跳跃的物理跳跃本质上就是给角色一个向上的初始速度 (jump_impulse)。之后重力 (fall_acceleration) 会持续将这个速度减小直到变为负值角色开始下落。5. 常见问题、调试技巧与性能优化即使代码看起来正确在实际运行中你可能会遇到各种奇怪的问题。这里我总结了一些常见坑点和解决方案。5.1 角色移动“滑冰”或停不下来问题描述松开按键后角色还会缓慢滑动一段距离才停止。原因你的水平速度计算逻辑可能没有在无输入时正确归零。检查input_direction是否为Vector3.ZERO以及是否正确地将其乘以了speed。在我们的代码中当没有输入时input_direction是(0,0,0)那么target_velocity.x和.z就会被设为0角色应该立刻停止。如果还在滑可能是碰撞形状CollisionShape太圆滑或与地面摩擦力设置有关但CharacterBody3D默认不模拟摩擦力。你错误地修改了velocity而没有通过target_velocity。确保每一帧都重新计算target_velocity.x和.z而不是累加。5.2 角色卡在斜坡或轻微不平的地面问题描述角色在斜坡上走不上去或者在小凸起上被卡住。解决方案move_and_slide()有一个非常实用的参数叫floor_max_angle默认约 45 度。它定义了多大角度的斜面可以被视为“地板”。如果你的斜坡角度超过了这个值is_on_floor()会返回false角色会滑下来。你可以根据需要调大这个值比如到 60 度或者在调用时传入move_and_slide() # 或者更明确地 # move_and_slide(velocity, Vector3.UP, false, 4, deg_to_rad(60.0))参数依次是速度、上方向向量、是否在斜坡上停止、最大滑移次数、地板最大角度。通常使用默认值即可但了解它们有助于调试。5.3 角色抖动或穿模问题描述角色在移动尤其是碰撞时出现高频抖动或者偶尔会穿过薄墙。原因与解决Delta Time确保所有速度、位移的计算都乘以了delta重力计算我们做了。忘记乘delta会导致帧率越高移动越快物理不稳定。碰撞形状确保CollisionShape3D的大小和位置与视觉模型Mesh基本匹配。一个过大或过小的碰撞盒会导致奇怪的碰撞反馈。对于人形角色一个胶囊体CapsuleShape3D通常比长方体更合适。速度过大如果一帧内移动的距离velocity * delta大于角色碰撞体的尺寸可能会发生“隧道效应”直接穿过薄物体。CharacterBody3D的move_and_slide()内部有碰撞检测但如果速度极快比如子弹可能需要使用move_and_collide()并手动处理射线投射。物理帧率确保移动逻辑在_physics_process中而不是_process。5.4 性能优化小贴士避免在_physics_process中做繁重操作这里的代码每物理帧默认每秒60次都会执行。复杂的数学计算、大量的节点查找get_node或资源加载应尽量避免。如果必须做考虑缓存结果。使用onready缓存节点引用如果你需要频繁访问子节点比如上面代码中的$Pivot可以在脚本顶部用onready缓存它避免每次_physics_process都进行路径查找。extends CharacterBody3D onready var pivot_node $Pivot # 游戏启动时缓存一次 func _physics_process(delta): # ... 之后使用 pivot_node 而不是 $Pivot ... if input_direction ! Vector3.ZERO: pivot_node.basis Basis.looking_at(input_direction)简化碰撞形状在保证游戏体验的前提下使用尽可能简单的碰撞形状球体、胶囊体、长方体。复杂的凸包或三角网格碰撞体计算开销大。5.5 调试显示让速度可视化在开发过程中将角色的速度向量打印出来或可视化能极大帮助调试。func _physics_process(delta): # ... 所有移动逻辑之后 ... # 在输出面板打印速度调试用发布时移除 # print(Velocity: , velocity, | On Floor: , is_on_floor()) # 或者使用 Debug 覆盖层如果启用了 # DebugDraw3D.draw_line(global_position, global_position velocity, Color.RED)Godot 4 有内置的DebugDraw3D插件需在项目设置中启用可以非常方便地在3D视图中绘制线条、文字用于调试位置、速度向量等。6. 扩展与进阶让移动更丰富基础移动实现后你可以在此基础上添加更多功能让角色操控感更上一层楼。6.1 冲刺与疾跑增加一个冲刺功能通常是在移动时按住某个键如 Shift来获得更高的速度。export var sprint_speed_multiplier: float 1.8 export var sprint_acceleration: float 20.0 # 加速到冲刺速度的力度 var current_speed: float 0.0 # 当前实际速度用于平滑加速 func _physics_process(delta): var input_dir Vector3.ZERO # ... 获取输入方向 ... var target_speed speed if Input.is_action_pressed(sprint): target_speed * sprint_speed_multiplier # 平滑加速/减速线性插值 current_speed lerp(current_speed, target_speed, sprint_acceleration * delta) if input_dir ! Vector3.ZERO: input_dir input_dir.normalized() $Pivot.look_at(global_position input_dir, Vector3.UP) # 使用平滑后的速度 target_velocity.x input_dir.x * current_speed target_velocity.z input_dir.z * current_speed # ... 重力、跳跃、移动 ...这里引入了lerp线性插值函数来实现速度的平滑过渡让起步和停止不那么生硬。6.2 摄像机跟随与第三人称控制基础的移动是让角色自己转。在第三人称游戏中通常摄像机是独立的角色应朝向摄像机的前方移动。思路获取摄像机或一个摄像机支架节点的全局变换global_transform。将输入方向基于键盘的“前、后、左、右”从这个摄像机的视角空间转换到世界空间。角色朝这个“世界空间方向”移动和旋转。onready var camera_pivot get_node(../CameraPivot) # 假设摄像机支架是兄弟节点 func _physics_process(delta): var input_dir Vector3.ZERO if Input.is_action_pressed(move_forward): input_dir.z - 1 if Input.is_action_pressed(move_back): input_dir.z 1 if Input.is_action_pressed(move_left): input_dir.x - 1 if Input.is_action_pressed(move_right): input_dir.x 1 # 关键转换将局部输入方向转换为相对于摄像机的世界方向 if input_dir ! Vector3.ZERO: input_dir input_dir.normalized() # 获取摄像机朝向但忽略其俯仰pitch旋转只保留水平Y轴旋转 var camera_basis camera_pivot.global_transform.basis var camera_forward -camera_basis.z # 摄像机的前向是 -Z camera_forward.y 0 camera_forward camera_forward.normalized() var camera_right camera_basis.x camera_right.y 0 camera_right camera_right.normalized() # 计算最终的世界移动方向 var world_dir camera_forward * input_dir.z camera_right * input_dir.x world_dir world_dir.normalized() # 让角色面向移动方向 $Pivot.look_at(global_position world_dir, Vector3.UP) target_velocity.x world_dir.x * speed target_velocity.z world_dir.z * speed else: target_velocity.x 0 target_velocity.z 0 # ... 重力、跳跃、移动 ...这个逻辑稍微复杂但它是第三人称动作游戏如《黑暗之魂》、《战神》控制的基础。核心思想是玩家的“前”不再是角色的正前方而是摄像机的水平前向。6.3 动画状态机集成移动逻辑完成后下一步就是连接动画。通常你会有一个AnimationPlayer或AnimationTree节点来控制角色的行走、奔跑、跳跃、空闲等动画。在_physics_process中根据当前状态速度大小、是否在地面等触发动画onready var animation_player $AnimationPlayer func _physics_process(delta): # ... 所有移动逻辑 ... move_and_slide() # 移动后根据状态播放动画 var is_moving input_direction.length_squared() 0.01 and is_on_floor() var is_in_air not is_on_floor() if is_in_air: if velocity.y 0: animation_player.play(jump_up) else: animation_player.play(jump_down) elif is_moving: if Input.is_action_pressed(sprint): animation_player.play(run) else: animation_player.play(walk) else: animation_player.play(idle)对于更复杂的动画混合如从走到跑的平滑过渡建议使用AnimationTree节点配合状态机AnimationNodeStateMachine这提供了更强大和灵活的控制。7. 总结与个人心得控制角色移动是游戏编程中最基础也最有趣的部分之一。从按下键盘到角色在屏幕上响应这中间涉及了输入处理、向量数学、物理集成和状态管理。Godot 的CharacterBody3D和move_and_slide()将这些复杂性封装得很好让你能专注于游戏性的逻辑。在我自己的项目实践中有几点深刻体会参数化一切像speed、jump_impulse、fall_acceleration这样的数值一定要做成export变量。在游戏测试阶段微调这些参数是家常便饭能在编辑器里实时调整会节省大量时间。理解向量游戏开发本质上是向量和矩阵的运算。花点时间理解Vector3的加减、缩放、点乘、叉乘和归一化这些知识在处理移动、朝向、视线、碰撞检测时无处不在。调试是朋友不要害怕使用print()输出变量的值或者用DebugDraw3D画线。亲眼看到速度向量的方向和长度比在脑子里想象要直观一百倍。从简单开始先实现最基础的、无重力、无旋转的八方向移动。确保它工作正常后再一步步添加重力、跳跃、摄像机相对移动、动画等。每加一个功能就测试一下能帮你快速定位问题。最后别忘了享受这个过程。当你第一次按下按键看到自己创建的角色在亲手搭建的世界里奔跑、跳跃时那种成就感是无与伦比的。这仅仅是开始有了这个可操控的角色你已经打开了通往更复杂游戏机制战斗、对话、解谜、载具的大门。祝你开发愉快
Godot 3D角色移动控制:从CharacterBody3D到物理帧的完整实现
1. 项目概述从零开始的 Godot 之旅 — EP5控制角色移动如果你刚刚接触 Godot并且已经跟着教程走完了创建场景、添加节点、设置碰撞这些基础步骤那么恭喜你你已经搭建好了一个角色的“躯壳”。现在是时候赋予这个躯壳“灵魂”了——也就是让它在你的游戏世界里动起来。控制角色移动这几乎是所有游戏开发旅程中第一个真正让人兴奋的里程碑。它不仅仅是让一个精灵在屏幕上滑动更是你与游戏世界建立交互的开始。在 Godot 中这个过程既直观又强大尤其是当你开始理解CharacterBody3D节点和_physics_process函数背后的逻辑时。这篇文章就是为你准备的“角色移动”实操指南。我们将不依赖任何预设的脚本模板从零开始一行一行地构建一个在 3D 空间中响应键盘输入、平滑移动并受重力影响的角色控制器。无论你是想制作一个跑酷游戏、一个 RPG还是一个简单的平台跳跃游戏这里面的核心逻辑都是相通的。我会带你深入理解velocity速度向量、move_and_slide()方法以及如何处理输入和重力让你不仅知道怎么做更明白为什么这么做。准备好了吗让我们开始这段让角色“活”起来的旅程。2. 核心思路与架构设计为什么是 CharacterBody3D 和物理帧在开始敲代码之前我们先花点时间理清思路。Godot 提供了多种处理移动和碰撞的方式比如RigidBody3D刚体物理模拟驱动和Area3D区域用于检测但不处理碰撞。但对于一个需要玩家精确控制的角色比如马里奥、塞尔达CharacterBody3D是官方推荐且最合适的节点类型。2.1 为什么选择 CharacterBody3DCharacterBody3D是一个专门为需要自定义移动逻辑的角色设计的节点。它与物理引擎紧密集成但把移动控制的“方向盘”完全交给了你。这意味着完全可控你可以精确计算每一帧角色应该移动到哪里而不是像刚体那样被物理规则如动量、摩擦力完全支配。内置碰撞响应它提供了move_and_collide()和move_and_slide()等方法能自动处理与场景中其他PhysicsBody的碰撞并返回碰撞信息让你可以据此做出反应比如碰到墙就停止碰到地板就站立。地面检测它内置了is_on_floor()、is_on_wall()、is_on_ceiling()等方法这对于实现跳跃、攀爬等平台游戏核心机制至关重要。简单来说CharacterBody3D是“物理驱动的可控体”它为你处理了复杂的碰撞检测和响应让你能专注于编写“如何移动”的逻辑。2.2 物理帧 (_physics_process) 与普通帧 (_process) 的区别这是 Godot 开发中一个关键概念理解错了可能会导致移动卡顿或不稳定。_process(delta)每渲染一帧调用一次。调用频率取决于显示器的刷新率如 60Hz 就是每秒60次。它适合处理与画面渲染紧密相关、但对时间精度要求不高的逻辑比如 UI 更新、非物理的动画播放。_physics_process(delta)在固定的时间间隔调用默认每秒60次与渲染帧率脱钩。这个间隔就是delta参数通常是固定的如 1/60 秒。所有与物理、碰撞、移动相关的代码都应该放在这里。为什么因为物理引擎如 Godot 内置的 Godot Physics 或 Jolt需要在稳定的时间步长下进行模拟才能保证碰撞检测和物体运动的准确性。如果放在_process里当游戏帧率波动时角色的移动速度也会时快时慢碰撞检测可能出错。_physics_process保证了物理模拟的稳定性。所以我们的移动代码将全部写在_physics_process(delta)函数中。2.3 移动逻辑的蓝图我们的移动控制器将围绕一个核心向量展开velocity速度。在每一帧的_physics_process中我们会读取输入检查玩家按下了哪些方向键WASD 或方向键。计算水平方向速度根据输入计算出一个表示水平移动方向和速度的向量。应用重力如果角色在空中则根据重力加速度持续修改垂直方向Y轴的速度使其下落。组合最终速度将水平速度和垂直速度合并到velocity向量中。执行移动调用move_and_slide()让CharacterBody3D根据velocity移动角色并自动处理碰撞。更新状态移动后根据碰撞结果如是否着地更新角色状态为下一帧计算做准备。这个流程构成了一个清晰的循环是绝大多数 3D 角色控制器的基石。3. 核心细节解析与实操要点理解了整体框架我们来拆解每一个环节看看具体怎么做以及有哪些需要注意的“坑”。3.1 输入处理从按键到方向向量Godot 的输入系统非常灵活我们使用Input单例来检测动作Action。首先确保你已经在项目设置的“输入映射”中定义了move_left,move_right,move_forward,move_back这些动作并关联了相应的按键如 A/D/W/S。在代码中我们这样获取输入var direction Vector3.ZERO # 初始化一个零向量 if Input.is_action_pressed(move_right): direction.x 1 if Input.is_action_pressed(move_left): direction.x - 1 if Input.is_action_pressed(move_back): # 注意在3D中向前是-Z向后是Z direction.z 1 if Input.is_action_pressed(move_forward): direction.z - 1这里有几个要点is_action_pressed在按键被按住的每一帧都返回true适合持续移动。方向轴在 Godot 的 3D 坐标系中默认是Y轴向上X轴向右Z轴向内屏幕深处。所以move_forward对应direction.z - 1是向 Z 轴负方向移动。向量叠加如果同时按下move_right(x1) 和move_forward(z-1)direction会变成(1, 0, -1)。这个向量的长度模是 √2 ≈ 1.414。这意味着对角移动会比单轴移动快 1.414 倍这通常不是我们想要的。注意必须对方向向量进行归一化Normalize。归一化就是将向量缩放到长度为 1只保留方向信息。这样无论朝哪个方向移动基础速度都是一致的。if direction ! Vector3.ZERO: direction direction.normalized()3.2 速度向量的构成水平移动与垂直重力CharacterBody3D有一个velocity属性它是一个Vector3。我们的任务就是在每一帧正确计算它。# 1. 计算水平地面速度 var horizontal_velocity direction * speed # speed 是你定义的移动速度标量如 10 # 2. 处理垂直方向的重力 var vertical_velocity velocity.y # 继承上一帧的垂直速度 if not is_on_floor(): vertical_velocity - fall_acceleration * delta # fall_acceleration 是重力加速度如 75 # 注意这里用减号因为Y轴向上为正重力是向下的力。 # 3. 组合速度向量 target_velocity.x horizontal_velocity.x target_velocity.z horizontal_velocity.z target_velocity.y vertical_velocity # 4. 赋值给角色的 velocity velocity target_velocity关键解释delta这是_physics_process函数的参数代表自上一帧物理更新以来经过的时间通常是固定的如 1/60 秒。任何基于时间的连续变化如速度、位移都必须乘以delta这样才能保证在不同帧率下行为一致。例如vertical_velocity - fall_acceleration * delta意味着“这一帧垂直速度减少重力加速度 × 帧时间”。is_on_floor()这是CharacterBody3D提供的神奇方法。只有在调用move_and_slide()或move_and_collide()之后它才能根据碰撞结果准确判断角色是否站在地面上。因此我们在计算重力时检查它只有不在地面上时才应用重力。分离计算将水平速度和垂直速度分开计算是个好习惯。这样逻辑更清晰也便于后续单独修改比如添加跳跃时只需修改垂直速度。3.3 执行移动move_and_slide()的魔力计算好velocity后最后一步就是调用move_and_slide()。move_and_slide()这个方法做了很多事情内部移动它根据当前velocity向量尝试移动角色。碰撞检测与响应如果移动过程中撞到其他PhysicsBody如墙壁、地板它会根据碰撞面的法线自动调整velocity防止穿透。例如撞到水平的墙水平速度会被抵消撞到地板垂直速度会被设置为0。更新地面状态移动完成后它会更新内部碰撞状态使得is_on_floor()等方法能返回正确结果。滑动处理如果角色沿着斜坡或墙角移动move_and_slide()会尝试让角色“滑过”表面而不是卡住。这是它比move_and_collide()更常用于角色移动的原因。一个非常重要的细节move_and_slide()可能会在内部修改velocity属性例如撞墙后水平速度被设为0。因此不要在调用move_and_slide()之后在同一帧内再次使用velocity进行其他物理计算除非你明确知道自己在做什么。通常的模式是计算新的velocity- 调用move_and_slide()- 等待下一帧。4. 完整实现与代码逐行解读现在我们把所有部分组合起来形成一个完整的、可用的角色移动脚本。我将创建一个名为player.gd的脚本附加到CharacterBody3D节点上。4.1 脚本属性定义首先在脚本顶部定义一些可配置的属性。使用export关键字可以让它们在编辑器的检查器中显示方便随时调整无需修改代码。extends CharacterBody3D # 玩家在地面上的移动速度米/秒 export var speed: float 10.0 # 空中的下落加速度米/秒²。地球重力约为 9.8游戏中可以调大些让下落更快。 export var fall_acceleration: float 30.0 # 这是一个临时变量用于存储我们计算出的目标速度最后赋值给 velocity。 # 将其与 velocity 分开可以避免直接修改 velocity 可能带来的混淆。 var target_velocity Vector3.ZERO为什么用export调试和平衡游戏参数时你可以在运行游戏的同时在编辑器中实时拖动滑块修改speed和fall_acceleration立即看到效果这比反复修改代码、重启游戏高效得多。4.2 物理处理函数_physics_process这是脚本的核心。func _physics_process(delta: float) - void: # 1. 初始化输入方向向量 var input_direction Vector3.ZERO # 2. 根据按键输入更新方向向量 # 注意Godot 的 3D 坐标系Z轴负方向是“向前”看向屏幕里。 if Input.is_action_pressed(move_right): input_direction.x 1.0 if Input.is_action_pressed(move_left): input_direction.x - 1.0 if Input.is_action_pressed(move_back): input_direction.z 1.0 if Input.is_action_pressed(move_forward): input_direction.z - 1.0 # 3. 处理方向归一化并让角色面向移动方向 if input_direction ! Vector3.ZERO: # 归一化确保对角线移动速度不会更快 input_direction input_direction.normalized() # 可选让角色的视觉模型例如一个子节点 MeshInstance3D面向移动方向 # 这里假设你有一个叫 “Pivot” 或 “Model” 的子节点来控制视觉旋转 # $Pivot.look_at(global_position input_direction, Vector3.UP) # 更简单的做法直接设置 Basis。Basis.looking_at() 创建一个朝向目标方向的旋转。 $Pivot.basis Basis.looking_at(input_direction) # 4. 计算地面水平速度 target_velocity.x input_direction.x * speed target_velocity.z input_direction.z * speed # 5. 计算垂直速度应用重力 if not is_on_floor(): # 如果不在空中则应用向下的加速度 # 注意Y轴向上为正所以重力是负加速度 target_velocity.y - fall_acceleration * delta else: # 如果在地面上确保垂直速度不被重力持续影响而变成负数除非你想让角色沉入地面。 # 通常在地面时我们把垂直速度设为0或一个很小的值防止抖动。 # move_and_slide() 在碰到地面时会自动将垂直速度设为0但这里显式处理更安全。 target_velocity.y 0.0 # 6. 将计算好的目标速度赋给角色的 velocity velocity target_velocity # 7. 执行移动这是最关键的一步。 move_and_slide()逐行解读与技巧第2步输入处理这里使用了四个独立的if语句而不是elif。这是为了允许组合按键如同时按右和前产生对角线输入。第3步面向方向$Pivot.basis Basis.looking_at(input_direction)这行代码非常有用。它让一个名为Pivot的子节点通常用来挂载角色模型立即面向移动方向。Basis.looking_at(target_direction, up_vector)会计算出一个旋转矩阵让节点的 -Z 轴前向指向target_direction。第二个参数Vector3.UP定义了哪个方向是“上”确保旋转不会倒置。第5步重力处理if not is_on_floor():这个判断是平台游戏逻辑的基石。只有角色悬空时我们才应用重力。一旦move_and_slide()检测到角色站在了地面上is_on_floor()就会返回true重力计算停止角色稳稳站住。第5步地面垂直速度将地面上的target_velocity.y设为0是一个好习惯。虽然move_and_slide()在碰撞到地面时通常会处理但显式设置可以避免一些极端情况下的微小下沉或抖动。第7步move_and_slide()调用这个方法后角色的位置会被更新碰撞状态is_on_floor等也会被刷新供下一帧使用。4.3 基础跳跃功能的实现一个不能跳跃的角色是不完整的。让我们在现有基础上增加跳跃功能。首先在export变量区域添加一个跳跃速度export var jump_impulse: float 15.0 # 跳跃的初始向上速度然后在_physics_process函数中重力计算部分之前添加跳跃输入检测func _physics_process(delta: float) - void: # ... [之前的输入和方向计算代码不变] ... # 4. 处理跳跃输入必须在地面上才能跳 if Input.is_action_just_pressed(jump) and is_on_floor(): target_velocity.y jump_impulse # 赋予一个向上的初速度 # 5. 计算地面速度原第4步 target_velocity.x input_direction.x * speed target_velocity.z input_direction.z * speed # 6. 计算垂直速度应用重力原第5步但逻辑需调整 # 注意跳跃后is_on_floor() 会立即变为 false所以重力会开始作用。 if not is_on_floor(): target_velocity.y - fall_acceleration * delta # 注意这里不再有 else target_velocity.y 0因为跳跃已经设置了y速度。 # 但是如果因为某种原因如从边缘滑落y速度是负的而我们又在地面应该重置。 # 更健壮的写法 # if is_on_floor() and target_velocity.y 0: # target_velocity.y 0 # elif not is_on_floor(): # target_velocity.y - fall_acceleration * delta # 7. 赋值并移动原第6、7步 velocity target_velocity move_and_slide()跳跃逻辑的关键is_action_just_pressed(“jump”)这个函数只在按键被按下的那一帧返回true。如果用is_action_pressed玩家按住空格键会导致角色不断向上“喷射”这不是我们想要的单次跳跃。and is_on_floor()这是一个重要的限制条件防止“二段跳”或空中无限跳除非你的游戏设计允许。只有在地面上时按下跳跃键才有效。跳跃的物理跳跃本质上就是给角色一个向上的初始速度 (jump_impulse)。之后重力 (fall_acceleration) 会持续将这个速度减小直到变为负值角色开始下落。5. 常见问题、调试技巧与性能优化即使代码看起来正确在实际运行中你可能会遇到各种奇怪的问题。这里我总结了一些常见坑点和解决方案。5.1 角色移动“滑冰”或停不下来问题描述松开按键后角色还会缓慢滑动一段距离才停止。原因你的水平速度计算逻辑可能没有在无输入时正确归零。检查input_direction是否为Vector3.ZERO以及是否正确地将其乘以了speed。在我们的代码中当没有输入时input_direction是(0,0,0)那么target_velocity.x和.z就会被设为0角色应该立刻停止。如果还在滑可能是碰撞形状CollisionShape太圆滑或与地面摩擦力设置有关但CharacterBody3D默认不模拟摩擦力。你错误地修改了velocity而没有通过target_velocity。确保每一帧都重新计算target_velocity.x和.z而不是累加。5.2 角色卡在斜坡或轻微不平的地面问题描述角色在斜坡上走不上去或者在小凸起上被卡住。解决方案move_and_slide()有一个非常实用的参数叫floor_max_angle默认约 45 度。它定义了多大角度的斜面可以被视为“地板”。如果你的斜坡角度超过了这个值is_on_floor()会返回false角色会滑下来。你可以根据需要调大这个值比如到 60 度或者在调用时传入move_and_slide() # 或者更明确地 # move_and_slide(velocity, Vector3.UP, false, 4, deg_to_rad(60.0))参数依次是速度、上方向向量、是否在斜坡上停止、最大滑移次数、地板最大角度。通常使用默认值即可但了解它们有助于调试。5.3 角色抖动或穿模问题描述角色在移动尤其是碰撞时出现高频抖动或者偶尔会穿过薄墙。原因与解决Delta Time确保所有速度、位移的计算都乘以了delta重力计算我们做了。忘记乘delta会导致帧率越高移动越快物理不稳定。碰撞形状确保CollisionShape3D的大小和位置与视觉模型Mesh基本匹配。一个过大或过小的碰撞盒会导致奇怪的碰撞反馈。对于人形角色一个胶囊体CapsuleShape3D通常比长方体更合适。速度过大如果一帧内移动的距离velocity * delta大于角色碰撞体的尺寸可能会发生“隧道效应”直接穿过薄物体。CharacterBody3D的move_and_slide()内部有碰撞检测但如果速度极快比如子弹可能需要使用move_and_collide()并手动处理射线投射。物理帧率确保移动逻辑在_physics_process中而不是_process。5.4 性能优化小贴士避免在_physics_process中做繁重操作这里的代码每物理帧默认每秒60次都会执行。复杂的数学计算、大量的节点查找get_node或资源加载应尽量避免。如果必须做考虑缓存结果。使用onready缓存节点引用如果你需要频繁访问子节点比如上面代码中的$Pivot可以在脚本顶部用onready缓存它避免每次_physics_process都进行路径查找。extends CharacterBody3D onready var pivot_node $Pivot # 游戏启动时缓存一次 func _physics_process(delta): # ... 之后使用 pivot_node 而不是 $Pivot ... if input_direction ! Vector3.ZERO: pivot_node.basis Basis.looking_at(input_direction)简化碰撞形状在保证游戏体验的前提下使用尽可能简单的碰撞形状球体、胶囊体、长方体。复杂的凸包或三角网格碰撞体计算开销大。5.5 调试显示让速度可视化在开发过程中将角色的速度向量打印出来或可视化能极大帮助调试。func _physics_process(delta): # ... 所有移动逻辑之后 ... # 在输出面板打印速度调试用发布时移除 # print(Velocity: , velocity, | On Floor: , is_on_floor()) # 或者使用 Debug 覆盖层如果启用了 # DebugDraw3D.draw_line(global_position, global_position velocity, Color.RED)Godot 4 有内置的DebugDraw3D插件需在项目设置中启用可以非常方便地在3D视图中绘制线条、文字用于调试位置、速度向量等。6. 扩展与进阶让移动更丰富基础移动实现后你可以在此基础上添加更多功能让角色操控感更上一层楼。6.1 冲刺与疾跑增加一个冲刺功能通常是在移动时按住某个键如 Shift来获得更高的速度。export var sprint_speed_multiplier: float 1.8 export var sprint_acceleration: float 20.0 # 加速到冲刺速度的力度 var current_speed: float 0.0 # 当前实际速度用于平滑加速 func _physics_process(delta): var input_dir Vector3.ZERO # ... 获取输入方向 ... var target_speed speed if Input.is_action_pressed(sprint): target_speed * sprint_speed_multiplier # 平滑加速/减速线性插值 current_speed lerp(current_speed, target_speed, sprint_acceleration * delta) if input_dir ! Vector3.ZERO: input_dir input_dir.normalized() $Pivot.look_at(global_position input_dir, Vector3.UP) # 使用平滑后的速度 target_velocity.x input_dir.x * current_speed target_velocity.z input_dir.z * current_speed # ... 重力、跳跃、移动 ...这里引入了lerp线性插值函数来实现速度的平滑过渡让起步和停止不那么生硬。6.2 摄像机跟随与第三人称控制基础的移动是让角色自己转。在第三人称游戏中通常摄像机是独立的角色应朝向摄像机的前方移动。思路获取摄像机或一个摄像机支架节点的全局变换global_transform。将输入方向基于键盘的“前、后、左、右”从这个摄像机的视角空间转换到世界空间。角色朝这个“世界空间方向”移动和旋转。onready var camera_pivot get_node(../CameraPivot) # 假设摄像机支架是兄弟节点 func _physics_process(delta): var input_dir Vector3.ZERO if Input.is_action_pressed(move_forward): input_dir.z - 1 if Input.is_action_pressed(move_back): input_dir.z 1 if Input.is_action_pressed(move_left): input_dir.x - 1 if Input.is_action_pressed(move_right): input_dir.x 1 # 关键转换将局部输入方向转换为相对于摄像机的世界方向 if input_dir ! Vector3.ZERO: input_dir input_dir.normalized() # 获取摄像机朝向但忽略其俯仰pitch旋转只保留水平Y轴旋转 var camera_basis camera_pivot.global_transform.basis var camera_forward -camera_basis.z # 摄像机的前向是 -Z camera_forward.y 0 camera_forward camera_forward.normalized() var camera_right camera_basis.x camera_right.y 0 camera_right camera_right.normalized() # 计算最终的世界移动方向 var world_dir camera_forward * input_dir.z camera_right * input_dir.x world_dir world_dir.normalized() # 让角色面向移动方向 $Pivot.look_at(global_position world_dir, Vector3.UP) target_velocity.x world_dir.x * speed target_velocity.z world_dir.z * speed else: target_velocity.x 0 target_velocity.z 0 # ... 重力、跳跃、移动 ...这个逻辑稍微复杂但它是第三人称动作游戏如《黑暗之魂》、《战神》控制的基础。核心思想是玩家的“前”不再是角色的正前方而是摄像机的水平前向。6.3 动画状态机集成移动逻辑完成后下一步就是连接动画。通常你会有一个AnimationPlayer或AnimationTree节点来控制角色的行走、奔跑、跳跃、空闲等动画。在_physics_process中根据当前状态速度大小、是否在地面等触发动画onready var animation_player $AnimationPlayer func _physics_process(delta): # ... 所有移动逻辑 ... move_and_slide() # 移动后根据状态播放动画 var is_moving input_direction.length_squared() 0.01 and is_on_floor() var is_in_air not is_on_floor() if is_in_air: if velocity.y 0: animation_player.play(jump_up) else: animation_player.play(jump_down) elif is_moving: if Input.is_action_pressed(sprint): animation_player.play(run) else: animation_player.play(walk) else: animation_player.play(idle)对于更复杂的动画混合如从走到跑的平滑过渡建议使用AnimationTree节点配合状态机AnimationNodeStateMachine这提供了更强大和灵活的控制。7. 总结与个人心得控制角色移动是游戏编程中最基础也最有趣的部分之一。从按下键盘到角色在屏幕上响应这中间涉及了输入处理、向量数学、物理集成和状态管理。Godot 的CharacterBody3D和move_and_slide()将这些复杂性封装得很好让你能专注于游戏性的逻辑。在我自己的项目实践中有几点深刻体会参数化一切像speed、jump_impulse、fall_acceleration这样的数值一定要做成export变量。在游戏测试阶段微调这些参数是家常便饭能在编辑器里实时调整会节省大量时间。理解向量游戏开发本质上是向量和矩阵的运算。花点时间理解Vector3的加减、缩放、点乘、叉乘和归一化这些知识在处理移动、朝向、视线、碰撞检测时无处不在。调试是朋友不要害怕使用print()输出变量的值或者用DebugDraw3D画线。亲眼看到速度向量的方向和长度比在脑子里想象要直观一百倍。从简单开始先实现最基础的、无重力、无旋转的八方向移动。确保它工作正常后再一步步添加重力、跳跃、摄像机相对移动、动画等。每加一个功能就测试一下能帮你快速定位问题。最后别忘了享受这个过程。当你第一次按下按键看到自己创建的角色在亲手搭建的世界里奔跑、跳跃时那种成就感是无与伦比的。这仅仅是开始有了这个可操控的角色你已经打开了通往更复杂游戏机制战斗、对话、解谜、载具的大门。祝你开发愉快