W
AI-Wiki
CONCEPT

玩家移动脚本

定义

玩家移动脚本是本文这个 Godot 小游戏原型中的玩家控制逻辑脚本,挂在玩家场景的根节点上,用来完成三件事:

  • 把玩家节点标记进 player 分组,供其他脚本识别。
  • 读取上下左右四方向输入,计算移动速度并驱动角色位移。
  • 在每一帧物理更新后,把玩家位置钳制在屏幕范围内,避免角色飞出可视区域。

该脚本的类继承关系是 extends CharacterBody2D,说明它不是普通 Node2D,而是使用 Godot 角色物理体能力来处理移动。

在本文档中的语境

在原文的开发流程里,这个脚本属于“阶段三:写核心脚本”的一部分,承接前一阶段搭好的项目骨架。项目骨架中,玩家场景的根节点就是 CharacterBody2D,下面再挂 Sprite2DCollisionShape2D。因此玩家移动脚本并不是孤立存在的工具函数,而是整个 Godot AI 原型闭环 中最早实现的核心交互之一。

它和以下部分直接协同:

  • 与输入配置协同:依赖 move_leftmove_rightmove_upmove_down 四个动作。
  • 与敌人逻辑协同:通过 _ready() 里的 add_to_group("player"),让敌人脚本能用 body.is_in_group("player") 判断碰撞对象是否为玩家。
  • 与测试流程协同:原文专门要求运行后模拟持续按下 W 2 秒,并截图检查玩家位置是否确实移动。

脚本结构

原文给出的玩家脚本结构如下:

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,原文这套写法里的 velocitymove_and_slide() 就不成立。

2. 导出变量:speed := 350.0clamp_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 Arrow
  • move_right:D、Right Arrow
  • move_up:W、Up Arrow
  • move_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 常规的物理帧更新方式,适合角色运动和碰撞场景。

先移动,再钳制

原文代码顺序是:

  1. 读取方向。
  2. 设置 velocity
  3. 调用 move_and_slide()
  4. 再执行 global_position.clamp(...)

这意味着玩家先按输入移动,再被纠正回合法屏幕区域。这样即使角色因为连续输入或边缘移动而越界,也会在同一轮更新后被拉回边界内。

clamp_margin 不是碰撞半径计算

原文只给出了一个固定边距 40.0,并没有根据 Sprite2D 尺寸、圆形碰撞体半径或缩放比例动态计算真实包围盒。因此这里的边界限制是一个简单、足够好用的经验值方案,而不是严格按角色图形外轮廓贴边。

依赖输入映射存在

Input.get_vector("move_left", "move_right", "move_up", "move_down") 的前提是这四个动作已经在项目设置的 Input Map 中配置好。若动作名不存在、拼写不一致,脚本本身虽然能挂载,但输入不会按预期生效。

该脚本不负责动画、翻转和冲刺

原文玩家脚本只覆盖基础移动、分组和屏幕限制,没有加入:

  • 移动动画切换
  • 朝向翻转
  • 加速度或阻尼
  • 冲刺、无敌帧、攻击
  • 斜向移动单独修正逻辑以外的复杂控制