第三部分:Input 输入系统、InputMap、八方向移动、动画状态切换
这一部分开始,你就会真正进入“角色能动起来”的阶段。
前面我们讲了:
Node 是对象
Scene 是节点组合
SceneTree 是运行时的总树
_process 是每帧更新
_physics_process 是物理更新
Signal 是节点通信
现在要接上游戏里最直接的东西:
玩家按键
↓
Godot 识别输入
↓
转换成移动方向
↓
更新角色速度
↓
move_and_slide() 移动
↓
根据方向播放动画
1. Input 是什么
Input 是 Godot 内置的输入单例,用来处理键盘、鼠标、手柄、触摸等输入。它可以直接检查某个按键是否按下,也可以检查某个“输入动作”是否触发。Godot 官方文档也说明,Input 负责处理键盘、鼠标、手柄等输入,而输入动作可以在 Project Settings 的 Input Map 里配置。(Godot Engine documentation)
最简单的例子:
func _process(delta):
if Input.is_key_pressed(KEY_SPACE):
print("空格被按住了")
但在实际游戏里,不推荐大量直接写 KEY_SPACE、KEY_W、KEY_A。
更推荐写成动作名:
func _process(delta):
if Input.is_action_pressed("attack"):
print("攻击键被按住了")
为什么?
因为你以后可以把 attack 绑定到:
键盘 J
鼠标左键
手柄 X
手机虚拟按钮
代码不用改。
这就是 InputMap 的价值。
2. InputMap 是什么
InputMap 可以理解成“按键配置表”。
前端类比的话,它有点像:
const keymap = {
move_left: ['A', 'ArrowLeft'],
move_right: ['D', 'ArrowRight'],
attack: ['J', 'MouseLeft']
}
Godot 里则是在编辑器中配置:
Project
→ Project Settings
→ Input Map
然后添加动作:
move_up
move_down
move_left
move_right
interact
attack
dash
open_bag
Godot 官方文档里也说,InputMap 用来管理 InputEventAction,这些动作可以在 Project Settings > Input Map 里创建和修改,也可以通过代码创建。(Godot Engine documentation)
也就是说,你的代码不应该关心玩家到底按的是 W 还是方向键上。
代码只需要关心:
玩家是否触发了 move_up
3. 为什么不要直接写具体按键
不太推荐这样:
if Input.is_key_pressed(KEY_W):
velocity.y = -speed
原因是:
以后想支持方向键,要改代码
以后想支持手柄,要改代码
以后想让玩家自定义按键,也麻烦
推荐这样:
if Input.is_action_pressed("move_up"):
velocity.y = -speed
这样你只需要在 InputMap 里给 move_up 添加多个输入:
W
方向键上
手柄左摇杆上
代码不用动。
这个思路非常重要。
做游戏时,代码最好写“行为”,不要写死“设备”。
差的写法:按 W 向上走
好的写法:触发 move_up 向上走
4. 常用输入方法
Godot 里最常用的是这几个:
Input.is_action_pressed("xxx")
Input.is_action_just_pressed("xxx")
Input.is_action_just_released("xxx")
5. is_action_pressed()
表示某个动作正在被按住。
if Input.is_action_pressed("move_right"):
print("正在向右")
适合:
移动
持续蓄力
持续奔跑
长按按钮
持续瞄准
比如角色移动:
if Input.is_action_pressed("move_right"):
direction.x += 1
只要玩家一直按着右键,这个条件就一直成立。
6. is_action_just_pressed()
表示某个动作“刚刚按下的那一瞬间”。
if Input.is_action_just_pressed("attack"):
print("攻击一次")
适合:
攻击
跳跃
打开背包
确认对话
拾取物品
交互
比如:
func _process(delta):
if Input.is_action_just_pressed("open_bag"):
open_bag()
为什么打开背包不能用 is_action_pressed()?
因为如果你按住一小会儿,它可能会连续触发很多次。
比如:
if Input.is_action_pressed("open_bag"):
open_bag()
这就可能一秒打开关闭几十次,场面非常鬼畜。
所以这种“一次性行为”用:
is_action_just_pressed()
7. is_action_just_released()
表示某个动作“刚刚松开”。
if Input.is_action_just_released("charge"):
release_charge_attack()
适合:
松开蓄力攻击
松开鼠标释放技能
松开弓箭射击
比如:
if Input.is_action_pressed("charge"):
charge_time += delta
if Input.is_action_just_released("charge"):
shoot_arrow(charge_time)
charge_time = 0
8. 给 RPG 角色配置 InputMap
对于你的 RPG 游戏,建议先配置这些:
move_up
move_down
move_left
move_right
interact
attack
open_bag
dash
先不要贪多。
第一阶段只做:
移动
交互
攻击
打开背包
对应按键可以这样:
move_up:W、方向键上
move_down:S、方向键下
move_left:A、方向键左
move_right:D、方向键右
interact:E、Enter
attack:J、鼠标左键
open_bag:B、I
dash:Shift
现在你还不需要真的做攻击和背包,只要先把动作名设计好。
这样后面加功能时会舒服很多。
9. 八方向移动的基础写法
最基础的移动代码是这样:
extends CharacterBody2D
@export var speed: float = 120.0
func _physics_process(delta):
var direction = Vector2.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_down"):
direction.y += 1
if Input.is_action_pressed("move_up"):
direction.y -= 1
direction = direction.normalized()
velocity = direction * speed
move_and_slide()
这段代码是 RPG 移动的最小骨架。
重点是:
var direction = Vector2.ZERO
表示当前没有方向。
然后根据按键修改方向:
direction.x += 1
direction.x -= 1
direction.y += 1
direction.y -= 1
最后:
velocity = direction * speed
move_and_slide()
让角色移动。
Godot 官方文档提醒,移动 CharacterBody2D 时通常不应该直接改 position,而是使用 move_and_collide() 或 move_and_slide() 这类方法,并且物理身体的移动逻辑应该放在 _physics_process() 中处理。(Godot Engine documentation)
10. Vector2 是什么
Vector2 就是二维向量。
你可以先简单理解成:
Vector2(x, y)
比如:
Vector2(1, 0)
表示向右。
Vector2(-1, 0)
表示向左。
Vector2(0, -1)
表示向上。
Vector2(0, 1)
表示向下。
Godot 的 Vector2 是一个包含两个数值的结构,常用来表示 2D 坐标、方向、速度等二维数据。(Godot Engine documentation)
对于 2D 游戏来说,Vector2 基本天天见。
position = Vector2(100, 200)
velocity = Vector2(120, 0)
direction = Vector2(-1, 0)
分别可以理解成:
位置
速度
方向
11. 为什么要 normalized()
如果玩家只按右:
direction = Vector2(1, 0)
长度是 1。
如果玩家同时按右和下:
direction = Vector2(1, 1)
这个向量的长度大约是 1.414。
也就是说,如果不处理,角色斜着走会比横着走更快。
所以要:
direction = direction.normalized()
它会把方向向量变成长度为 1 的单位向量。
这样不管是上下左右,还是斜方向,最终速度都一致。
你可以把它理解成:
我只要方向,不要让方向本身偷偷加速
12. 更简洁的写法:Input.get_vector()
Godot 提供了一个非常适合移动的写法:
var direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
完整写法:
extends CharacterBody2D
@export var speed: float = 120.0
func _physics_process(delta):
var direction = Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
velocity = direction * speed
move_and_slide()
这个比手动判断四个方向更干净。
Godot 的 2D movement 文档中也使用了 Input.get_vector() 来读取上下左右四个动作,然后把得到的方向向量乘以速度,最后调用 MoveAndSlide() / move_and_slide()。(Godot Engine documentation)
注意参数顺序:
Input.get_vector(
negative_x,
positive_x,
negative_y,
positive_y
)
也就是:
Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
别写反。
13. 推荐你现在就用 get_vector()
你现在做 RPG 项目,推荐直接用这个:
func get_input_direction() -> Vector2:
return Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
然后移动逻辑写成:
func _physics_process(delta):
var direction = get_input_direction()
velocity = direction * speed
move_and_slide()
这样代码结构更清楚。
14. velocity 是什么
对于 CharacterBody2D 来说,velocity 是它的移动速度。
velocity = Vector2(100, 0)
可以理解成:
每秒向右移动 100 像素
velocity = Vector2(0, -100)
表示:
每秒向上移动 100 像素
所以:
velocity = direction * speed
意思是:
用方向乘以速度,得到最终移动速度
例如:
direction = Vector2(1, 0)
speed = 120
velocity = Vector2(120, 0)
角色向右移动。
15. 不要手动修改 position 做角色移动
初学者很容易写:
position += direction * speed * delta
这个写法对普通 Node2D 可以,但对需要碰撞的角色不适合。
因为你绕过了 CharacterBody2D 的移动和碰撞逻辑。
如果角色需要撞墙、挡住、滑动、检测碰撞,就应该用:
velocity = direction * speed
move_and_slide()
简单记:
只是飘着动的东西:可以改 position
有碰撞的角色:用 CharacterBody2D + velocity + move_and_slide()
16. 角色节点应该怎么搭
建议你的玩家场景先这样:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D
其中:
CharacterBody2D:负责移动和碰撞
AnimatedSprite2D:负责角色动画
CollisionShape2D:负责碰撞形状
Camera2D:让镜头跟随角色
AnimatedSprite2D 类似 Sprite2D,但它可以用 SpriteFrames 资源管理多帧动画,所以很适合角色 idle、walk、attack 等帧动画。(Godot Engine documentation)
如果你暂时没有角色动画素材,也可以先用:
Player CharacterBody2D
├── Sprite2D
└── CollisionShape2D
Sprite2D 就是一个通用 2D 图片显示节点,可以显示一张纹理,也可以显示图集中的一块区域。(Godot Engine documentation)
17. AnimatedSprite2D 的动画命名建议
对于 RPG 俯视角角色,建议动画名一开始就设计清楚。
最简单版本:
idle_down
idle_up
idle_left
idle_right
walk_down
walk_up
walk_left
walk_right
如果你有四方向动画,这样最清楚。
如果你只有左右动画,也可以先这样:
idle
walk
然后用 flip_h 翻转左右。
但对于中世纪 RPG,后面你大概率会需要上下左右动画,所以我更建议你从一开始就按四方向规划。
18. 保存角色朝向
角色停止移动后,应该保持最后朝向。
比如玩家向下走,松开键后,应该播放:
idle_down
玩家向左走,松开键后,应该播放:
idle_left
所以我们需要一个变量保存最后方向:
var last_direction: Vector2 = Vector2.DOWN
默认面朝下。
当玩家移动时,更新它:
if direction != Vector2.ZERO:
last_direction = direction
不过这里有个小问题。
如果玩家斜着走:
Vector2(1, 1)
那最后方向到底算右,还是下?
RPG 里通常会选择一个主方向。
比如:
func get_main_direction(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
return "right"
else:
return "left"
else:
if direction.y > 0:
return "down"
else:
return "up"
意思是:
横向分量更大 → 算左右
纵向分量更大 → 算上下
19. 播放移动动画
假设你的 AnimatedSprite2D 叫:
AnimatedSprite2D
脚本里先拿到它:
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
播放动画:
animated_sprite.play("walk_down")
停止时播放 idle:
animated_sprite.play("idle_down")
完整动画函数可以这样写:
func update_animation(direction: Vector2):
if direction == Vector2.ZERO:
var idle_animation = "idle_" + get_direction_name(last_direction)
animated_sprite.play(idle_animation)
return
last_direction = direction
var walk_animation = "walk_" + get_direction_name(direction)
animated_sprite.play(walk_animation)
不过这里的 get_direction_name() 要写得稍微严谨一点。
20. 方向转动画名
可以这样写:
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
return "right"
else:
return "left"
else:
if direction.y > 0:
return "down"
else:
return "up"
然后:
"walk_" + get_direction_name(direction)
就会得到:
walk_right
walk_left
walk_down
walk_up
比如:
direction = Vector2(1, 0)
结果是:
walk_right
direction = Vector2(0, -1)
结果是:
walk_up
21. 完整 Player.gd 示例
这是目前最推荐你保存下来的版本。
extends CharacterBody2D
@export var speed: float = 120.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var last_direction: Vector2 = Vector2.DOWN
func _physics_process(delta):
var direction = get_input_direction()
velocity = direction * speed
move_and_slide()
update_animation(direction)
func get_input_direction() -> Vector2:
return Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
func update_animation(direction: Vector2):
if direction == Vector2.ZERO:
var idle_animation = "idle_" + get_direction_name(last_direction)
animated_sprite.play(idle_animation)
return
last_direction = direction
var walk_animation = "walk_" + get_direction_name(direction)
animated_sprite.play(walk_animation)
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
return "right"
else:
return "left"
else:
if direction.y > 0:
return "down"
else:
return "up"
这个脚本已经包含:
输入读取
八方向移动
速度控制
碰撞移动
方向判断
站立动画
行走动画
你后面加攻击、交互、奔跑,都是在这个基础上扩展。
22. 如果暂时没有四方向动画怎么办
可以先用一个简单版本。
假设你只有:
idle
walk
那代码可以简化成:
extends CharacterBody2D
@export var speed: float = 120.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
func _physics_process(delta):
var direction = Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
velocity = direction * speed
move_and_slide()
if direction == Vector2.ZERO:
animated_sprite.play("idle")
else:
animated_sprite.play("walk")
if direction.x != 0:
animated_sprite.flip_h = direction.x < 0
这适合临时测试。
但它只能比较好地表现左右移动。
上下移动就没那么自然。
23. AnimationPlayer 和 AnimatedSprite2D 的区别
这里你会经常迷糊,我直接讲清楚。
AnimatedSprite2D 主要适合播放一组帧图。
比如:
walk_down_0.png
walk_down_1.png
walk_down_2.png
walk_down_3.png
它就是不断切换图片,形成动画。
AnimationPlayer 更强,它可以动画化很多属性,比如位置、透明度、颜色、缩放、旋转、声音、调用方法等。Godot 官方文档也说明,AnimationPlayer 可以创建从简单到复杂的动画,并且可以给节点属性打关键帧。(Godot Engine documentation)
简单记:
AnimatedSprite2D:适合角色帧动画
AnimationPlayer:适合复杂属性动画
比如:
角色走路 → AnimatedSprite2D
门慢慢打开 → AnimationPlayer
宝箱弹一下 → AnimationPlayer
UI 淡入淡出 → AnimationPlayer
火把火焰帧动画 → AnimatedSprite2D
技能特效 → 两者都可能用
24. 移动逻辑和动画逻辑最好分开
不要把所有东西都挤在 _physics_process() 里。
不推荐一直写成这样:
func _physics_process(delta):
var direction = Input.get_vector(...)
velocity = direction * speed
move_and_slide()
if direction == Vector2.ZERO:
...
else:
...
短期能用,但后面代码会膨胀。
更推荐:
func _physics_process(delta):
handle_movement()
update_animation()
或者:
func _physics_process(delta):
var direction = get_input_direction()
move_player(direction)
update_animation(direction)
这样以后你加功能会更舒服:
func _physics_process(delta):
var direction = get_input_direction()
if can_move:
move_player(direction)
update_animation(direction)
代码结构清楚,后面不容易变成一锅粥。
25. 加一个奔跑功能
InputMap 里添加:
dash
绑定:
Shift
然后代码:
@export var walk_speed: float = 120.0
@export var run_speed: float = 180.0
func get_current_speed() -> float:
if Input.is_action_pressed("dash"):
return run_speed
return walk_speed
移动时:
velocity = direction * get_current_speed()
完整一点:
extends CharacterBody2D
@export var walk_speed: float = 120.0
@export var run_speed: float = 180.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var last_direction: Vector2 = Vector2.DOWN
func _physics_process(delta):
var direction = get_input_direction()
var speed = get_current_speed()
velocity = direction * speed
move_and_slide()
update_animation(direction)
func get_input_direction() -> Vector2:
return Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
func get_current_speed() -> float:
if Input.is_action_pressed("dash"):
return run_speed
return walk_speed
func update_animation(direction: Vector2):
if direction == Vector2.ZERO:
animated_sprite.play("idle_" + get_direction_name(last_direction))
return
last_direction = direction
animated_sprite.play("walk_" + get_direction_name(direction))
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
return "right" if direction.x > 0 else "left"
return "down" if direction.y > 0 else "up"
这已经是一个很像样的 RPG 玩家移动脚本了。
26. 加一个交互键
InputMap 里添加:
interact
绑定:
E
Enter
先写最简单的测试:
func _process(delta):
if Input.is_action_just_pressed("interact"):
print("尝试交互")
为什么这里用 _process() 也可以?
因为交互不是物理移动,不需要固定物理帧。
不过如果你的交互强依赖碰撞检测,也可以放在 _physics_process() 里。初学阶段不用纠结,先能跑起来。
27. _input、_unhandled_input、_process 的区别
Godot 输入处理有好几种方式,初学者很容易懵。
先记这个:
持续移动:_physics_process + Input.get_vector()
一次性操作:_input 或 _unhandled_input 或 _process + just_pressed
UI 按钮:用按钮自己的 pressed 信号
Input 是全局输入状态,可以在 _process() 或 _physics_process() 里检查。
而 _input(event) 是事件进来时调用。
例如:
func _input(event):
if event.is_action_pressed("interact"):
print("交互")
_unhandled_input(event) 通常用于“没有被 UI 消费掉的输入”。
比如打开菜单时,UI 按钮可能先处理了回车键。
如果你不想 UI 操作同时触发角色攻击,就会用到 _unhandled_input()。
现在你可以先这样记:
角色移动:_physics_process
角色交互:_unhandled_input 或 _process + just_pressed
UI 点击:pressed 信号
28. 常见错误 1:InputMap 没配置
代码没错,但角色不动,最常见原因就是 InputMap 没配置。
比如代码里写:
Input.get_vector("move_left", "move_right", "move_up", "move_down")
但是项目设置里没有这些动作。
那方向永远是:
Vector2.ZERO
解决:
Project
→ Project Settings
→ Input Map
→ 添加 move_left / move_right / move_up / move_down
→ 给它们绑定按键
29. 常见错误 2:节点类型用错
如果你写了:
extends CharacterBody2D
那脚本应该挂在 CharacterBody2D 节点上。
不要挂到 Node2D、Sprite2D、AnimatedSprite2D 上。
正确结构:
Player CharacterBody2D
├── AnimatedSprite2D
└── CollisionShape2D
脚本挂在:
Player
不是挂在:
AnimatedSprite2D
这个坑很常见。
30. 常见错误 3:AnimatedSprite2D 名字不匹配
代码写:
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
那节点名字必须真的叫:
AnimatedSprite2D
如果你改名成了:
PlayerSprite
那代码也要改:
@onready var animated_sprite: AnimatedSprite2D = $PlayerSprite
Godot 的 $节点名 是按节点名字找的。
名字不一致就找不到。
31. 常见错误 4:动画名不匹配
代码里播放:
animated_sprite.play("walk_down")
那 AnimatedSprite2D 的 SpriteFrames 里必须真的有:
walk_down
如果你动画叫:
walk_front
那代码就播放不了。
所以建议动画名一开始统一成:
idle_down
idle_up
idle_left
idle_right
walk_down
walk_up
walk_left
walk_right
别一会儿 front,一会儿 down,一会儿 bottom。
命名混乱,后面自己会被自己创飞。
32. 常见错误 5:CollisionShape2D 没有形状
角色不碰撞,可能是:
CollisionShape2D 节点有了
但是 Shape 没设置
要选中 CollisionShape2D,在 Inspector 里设置 Shape。
比如:
RectangleShape2D
CapsuleShape2D
CircleShape2D
RPG 角色常用:
CapsuleShape2D
RectangleShape2D
如果是像素风俯视角,碰撞范围不要覆盖整个角色图片。
更建议只覆盖脚底附近。
比如角色图片 32x48,碰撞可以只放下半部分:
不是整个身体都碰撞
而是脚底区域碰撞
这样角色靠近墙、桌子、树的时候会自然很多。
33. 这一部分你要记住的核心
这一部分最重要的东西是:
InputMap 是按键映射表
Input 是读取输入的入口
is_action_pressed 适合持续按住
is_action_just_pressed 适合单次触发
Input.get_vector 很适合角色移动
CharacterBody2D 移动用 velocity + move_and_slide()
角色移动逻辑放 _physics_process()
动画播放根据 direction 判断
last_direction 用来保存最后朝向
你现在要优先吃透这条链路:
InputMap
↓
Input.get_vector()
↓
direction
↓
velocity
↓
move_and_slide()
↓
update_animation()
这就是 RPG 角色移动的基本闭环。
34. 建议你现在做的小练习
创建:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
└── Camera2D
配置 InputMap:
move_up
move_down
move_left
move_right
dash
interact
实现:
WASD 移动
Shift 奔跑
E 打印“交互”
移动时播放 walk 动画
停止时播放 idle 动画
角色停止后保持最后朝向
这一关过了之后,你的 RPG 项目就可以进入下一步:
第四部分预告:TileMapLayer、地图碰撞、角色和地图的关系
下一部分会讲:
TileMapLayer 是什么
TileSet 是什么
地形图块怎么理解
地图碰撞怎么设置
角色为什么能被墙挡住
Y-sort 是什么
角色走到树后面为什么应该被遮住
地图层级应该怎么拆如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录