玩家移动脚本
定义
玩家移动脚本是本文这个 Godot 小游戏原型中的玩家控制逻辑脚本,挂在玩家场景的根节点上,用来完成三件事:
- 把玩家节点标记进
player分组,供其他脚本识别。 - 读取上下左右四方向输入,计算移动速度并驱动角色位移。
- 在每一帧物理更新后,把玩家位置钳制在屏幕范围内,避免角色飞出可视区域。
该脚本的类继承关系是 extends CharacterBody2D,说明它不是普通 Node2D,而是使用 Godot 角色物理体能力来处理移动。
在本文档中的语境
在原文的开发流程里,这个脚本属于“阶段三:写核心脚本”的一部分,承接前一阶段搭好的项目骨架。项目骨架中,玩家场景的根节点就是 CharacterBody2D,下面再挂 Sprite2D 和 CollisionShape2D。因此玩家移动脚本并不是孤立存在的工具函数,而是整个 Godot AI 原型闭环 中最早实现的核心交互之一。
它和以下部分直接协同:
- 与输入配置协同:依赖
move_left、move_right、move_up、move_down四个动作。 - 与敌人逻辑协同:通过
_ready()里的add_to_group("player"),让敌人脚本能用body.is_in_group("player")判断碰撞对象是否为玩家。 - 与测试流程协同:原文专门要求运行后模拟持续按下
W2 秒,并截图检查玩家位置是否确实移动。
脚本结构
原文给出的玩家脚本结构如下:
extends CharacterBody2D
@export var speed := 350.0
@export var clamp_margin := 40.0
func _ready() -> void:
add_to_group("player")
func _physics_process(_delta: float) -> void:
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = direction * speed
move_and_slide()
# 限制在屏幕内
var viewport_size := get_viewport_rect().size
global_position = global_position.clamp(
Vector2(clamp_margin, clamp_margin),
Vector2(viewport_size.x - clamp_margin, viewport_size.y - clamp_margin)
)
关键机制
1. 继承 CharacterBody2D
脚本开头是 extends CharacterBody2D。这意味着玩家对象使用的是 Godot 面向角色移动的 2D 物理节点,而不是单纯直接改坐标的普通节点。
这样做的直接好处是:
- 可以使用
velocity作为标准速度变量。 - 可以调用
move_and_slide()完成移动。 - 和玩家场景的碰撞体结构更匹配,适合后续与敌人发生碰撞。
如果根节点不是 CharacterBody2D,原文这套写法里的 velocity 和 move_and_slide() 就不成立。
2. 导出变量:speed := 350.0 与 clamp_margin := 40.0
脚本定义了两个导出变量:
@export var speed := 350.0@export var clamp_margin := 40.0
这两个数字都不是可有可无的占位值,而是原文明确给出的默认参数。
其中:
speed表示玩家移动速度系数。输入方向向量会和这个值相乘,得到最终velocity。默认值是350.0。clamp_margin表示屏幕边界内缩的安全距离。玩家不会被限制到(0, 0)和屏幕右下角的极限边缘,而是保留 40 像素的边距。默认值是40.0。
使用 @export 的意义在于,这两个值可以直接在编辑器里调整,而不用每次回脚本改代码。
3. 在 _ready() 中加入 player 分组
_ready() 中执行:
add_to_group("player")
这是这个脚本和敌人脚本联动的关键点。原文的敌人逻辑在碰撞回调里会判断:
if body.is_in_group("player"):
因此,玩家移动脚本不只是负责“移动”,还承担了玩家身份注册的职责。没有这一步,即使玩家和敌人发生了物理接触,敌人脚本里的分组判断也可能失败,从而无法触发 game over 场景切换。
4. 用 Input.get_vector 获取四方向输入
在 _physics_process(_delta: float) 中,脚本这样读取输入:
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
这一步把四个动作映射整合成一个二维方向向量,而不是分别手写 if 判断。原文对应的输入配置是:
move_left:A、Left Arrowmove_right:D、Right Arrowmove_up:W、Up Arrowmove_down:S、Down Arrow
也就是说,玩家既可以用 WASD,也可以用方向键控制。
随后脚本把方向和速度相乘:
velocity = direction * speed
再调用:
move_and_slide()
这样就把输入转换成实际移动。
5. 用 global_position.clamp 限制在屏幕内
移动之后,脚本通过当前视口大小限制玩家位置:
var viewport_size := get_viewport_rect().size
global_position = global_position.clamp(
Vector2(clamp_margin, clamp_margin),
Vector2(viewport_size.x - clamp_margin, viewport_size.y - clamp_margin)
)
这段逻辑的含义是:
- 先读取当前屏幕或视口的尺寸。
- 把玩家的全局坐标限制在一个矩形范围内。
- 左上边界不是
(0, 0),而是(clamp_margin, clamp_margin)。 - 右下边界不是
(viewport_size.x, viewport_size.y),而是分别减去clamp_margin。
当 clamp_margin = 40.0 时,玩家会始终被限制在距离四周边缘至少 40 像素的位置内。
原文把这一步明确标为“限制在屏幕内”,说明这不是附带优化,而是玩家脚本必须完成的职责。
细节与边界
物理更新中处理移动
脚本把输入读取和移动放在 _physics_process() 中,而不是 _process()。这表明原文采用的是 Godot 常规的物理帧更新方式,适合角色运动和碰撞场景。
先移动,再钳制
原文代码顺序是:
- 读取方向。
- 设置
velocity。 - 调用
move_and_slide()。 - 再执行
global_position.clamp(...)。
这意味着玩家先按输入移动,再被纠正回合法屏幕区域。这样即使角色因为连续输入或边缘移动而越界,也会在同一轮更新后被拉回边界内。
clamp_margin 不是碰撞半径计算
原文只给出了一个固定边距 40.0,并没有根据 Sprite2D 尺寸、圆形碰撞体半径或缩放比例动态计算真实包围盒。因此这里的边界限制是一个简单、足够好用的经验值方案,而不是严格按角色图形外轮廓贴边。
依赖输入映射存在
Input.get_vector("move_left", "move_right", "move_up", "move_down") 的前提是这四个动作已经在项目设置的 Input Map 中配置好。若动作名不存在、拼写不一致,脚本本身虽然能挂载,但输入不会按预期生效。
该脚本不负责动画、翻转和冲刺
原文玩家脚本只覆盖基础移动、分组和屏幕限制,没有加入:
- 移动动画切换
- 朝向翻转
- 加速度或阻尼
- 冲刺、无敌帧、攻击
- 斜向移动单独修正逻辑以外的复杂控制