Godot 3 2D游戏网格移动实现:从基础原理到AStar寻路实战

发布时间:2026/7/9 21:46:30
Godot 3 2D游戏网格移动实现:从基础原理到AStar寻路实战 1. 项目概述为什么2D游戏需要网格移动如果你做过一些2D游戏尤其是像战棋、策略、RPG或者需要精确对齐的像素风游戏肯定遇到过这样的问题角色移动时位置总是对不齐要么差几个像素要么在斜坡上滑动得不够自然。直接用position velocity * delta这种连续移动虽然简单但在很多2D游戏里会显得“飘”缺乏那种扎实的、一格一格的感觉。这就是网格移动Grid-based Movement要解决的问题。它不是什么高深的技术而是一种设计模式核心思想是将连续的游戏世界离散化。想象一下国际象棋的棋盘棋子只能从一个格子跳到另一个格子不能停在格子中间。在Godot里我们就是把屏幕划分成一个个看不见的“格子”让角色、敌人、可交互物体都沿着这些格子的中心点移动。我刚开始用Godot做2D项目时也试过用物理引擎或者简单的向量移动来处理角色控制结果在做一个类《火焰纹章》的战棋Demo时单位移动后总是对不齐地图格子视觉效果非常糟糕。后来花了几天时间研究Godot 3的Demos源码和官方文档才把网格移动这套逻辑彻底搞明白。今天我就把这块硬骨头拆开揉碎了讲给你听从核心原理到代码实现再到那些官方Demo里没写的“坑”保证你读完就能在自己的项目里用起来。2. 核心思路拆解离散化、寻路与插值网格移动听起来简单不就是让角色从一个点跳到另一个点吗但真要做得流畅、自然背后有三层逻辑需要理清。2.1 离散化的世界坐标首先我们要定义什么是“格子”。在Godot的2D坐标系里一个格子通常用一个Vector2来表示比如(0, 0)、(1, 0)、(0, 1)。但这个Vector2是网格坐标不是屏幕像素坐标。我们需要一个映射关系世界坐标 网格坐标 * 格子尺寸。假设我们的格子是64x64像素那么网格坐标(1, 2)对应的世界坐标就是(64, 128)。这个计算会在代码里反复出现是网格移动的基石。实操心得格子尺寸最好定义成一个全局常量或者导出变量export var cell_size: Vector2 Vector2(64, 64)。这样你后期想调整角色大小、地图比例只需要改这一个地方所有依赖网格计算的逻辑都会自动更新避免散弹式修改。2.2 路径寻找Pathfinding角色要从A格子移动到B格子中间可能隔着墙、水等地形。我们不可能让玩家手动点击每一个格子这就需要自动寻路。Godot 3自带了强大的AStar和Navigation2D系统。AStar 这是一个通用的图寻路算法类。你需要手动构建一个“图”把每个可通行的格子作为节点Node把相邻格子之间的连接作为边Edge。它的优点是极度灵活你可以自定义每个格子的移动代价例如沼泽地移动代价是2平原是1非常适合策略游戏。Navigation2D 更高级更“傻瓜式”。你只需要在场景里摆放Navigation2D节点和NavigationPolygonInstance来定义可行走区域它会在后台自动生成导航网格。角色移动时调用get_simple_path()就能拿到一串世界坐标点。这对于RPG、ARPG这种需要复杂地形寻路的游戏非常方便。在Godot 3的官方Demos里网格移动的示例更多是基于AStar因为它更贴近“网格”的本质概念。但我的建议是如果你的游戏地形复杂且有大量不规则障碍物用Navigation2D如果你的游戏就是标准的棋盘格子或者需要高度自定义移动规则如不同职业移动力不同用AStar。2.3 平滑移动与插值寻路算法返回的是一系列格子坐标点。如果让角色直接从当前世界坐标“瞬移”到下一个目标世界坐标会非常生硬。我们需要插值Interpolation让移动看起来是平滑的。最常用的插值方法是线性插值Lerp和缓动函数Easing Functions。lerp(a, b, weight) 在a和b之间按权重weight进行线性插值。weight为0时返回a为1时返回b。tween.interpolate_property() Godot的Tween节点可以帮你处理更复杂的插值包括缓动如EASE_IN_OUT、重复等。但这里有个关键细节插值应该作用于世界坐标而不是网格坐标。我们的逻辑流程应该是输入目标网格坐标target_grid_pos。通过target_grid_pos * cell_size计算出目标世界坐标target_world_pos。使用Tween或自定义插值函数将角色的position从当前位置平滑过渡到target_world_pos。在移动过程中角色的逻辑位置用于碰撞检测、触发事件应该已经是target_grid_pos而视觉位置则在追赶这个逻辑位置。这种“逻辑位置先行视觉位置跟随”的做法是避免各种奇怪bug比如穿墙、触发两次事件的关键。3. 从零实现一个基础的网格移动控制器理论说再多不如一行代码。我们直接动手创建一个最基础、可复用的网格移动脚本。我会用KinematicBody2D作为例子因为它兼顾了碰撞检测和灵活控制。3.1 节点结构与属性定义首先创建一个新场景根节点为KinematicBody2D命名为GridMover。给它添加一个Sprite子节点用来显示再加一个CollisionShape2D用于碰撞。然后为GridMover节点添加脚本我们开始定义核心属性extends KinematicBody2D # 网格属性 export var cell_size: Vector2 Vector2(64, 64) # 每个格子的大小 export var move_speed: float 300.0 # 像素/秒视觉移动速度 # 内部状态 var grid_position: Vector2 Vector2.ZERO # 当前所在的网格坐标 var target_world_position: Vector2 Vector2.ZERO # 当前要移动到的世界坐标 var is_moving: bool false # 是否正在移动中 # 移动方向常量对应键盘输入 const DIRECTIONS { ui_right: Vector2.RIGHT, ui_left: Vector2.LEFT, ui_down: Vector2.DOWN, ui_up: Vector2.UP }export关键字使得cell_size和move_speed在编辑器的检查器面板中可见并可调整这非常方便进行调试和平衡。3.2 初始化与坐标同步当角色被放入场景时我们需要根据它的初始世界位置反推出它所在的网格坐标。func _ready(): # 初始化根据当前的世界位置计算网格位置 sync_grid_position_from_world() # 将角色瞬间对齐到网格中心确保起始位置准确 snap_to_grid() func sync_grid_position_from_world(): # 将世界坐标转换为网格坐标 # 使用 round() 确保取整避免浮点数误差 grid_position.x round(position.x / cell_size.x) grid_position.y round(position.y / cell_size.y) func snap_to_grid(): # 将角色的世界位置对齐到当前网格坐标的中心 position grid_position * cell_size target_world_position position_ready()中的sync_grid_position_from_world()和snap_to_grid()是黄金搭档。前者从视觉位置推导逻辑位置后者用逻辑位置修正视觉位置。很多新手会忘记这一步导致角色初始位置就不在格子上后续移动全部错位。3.3 输入处理与移动请求我们不直接在_process或_physics_process里更新位置而是先处理输入生成一个“移动请求”。func _unhandled_input(event): # 如果正在移动忽略新的输入实现“指令队列”或“回合制”的感觉 if is_moving: return # 检查按下的方向键 for dir_input in DIRECTIONS.keys(): if event.is_action_pressed(dir_input): # 根据输入方向计算目标网格位置 var direction: Vector2 DIRECTIONS[dir_input] var target_grid_pos grid_position direction # 在真正移动前进行碰撞检测 if can_move_to(target_grid_pos): # 移动请求合法开始移动流程 move_to(target_grid_pos) break # 一次只处理一个方向的输入这里的关键是can_move_to(target_grid_pos)函数。它决定了这个格子是否可通行。在基础版本里我们可以用RayCast2D或者move_and_collide的零距离测试来提前探测。func can_move_to(target_grid_pos: Vector2) - bool: # 方法1使用 RayCast2D需在场景中预先添加一个RayCast2D子节点并指向正前方 # $RayCast2D.cast_to (target_grid_pos - grid_position) * cell_size # $RayCast2D.force_raycast_update() # return not $RayCast2D.is_colliding() # 方法2使用 KinematicBody2D 自身的碰撞检测更推荐 # 临时计算目标世界位置 var target_world_pos target_grid_pos * cell_size # 计算从当前位置到目标位置的向量 var motion (target_world_pos - position).normalized() * 1.0 # 一个很小的探测距离 # 使用 move_and_collide 进行探测如果返回非空说明有碰撞 var collision move_and_collide(motion, true, true, true) # 最后一个参数 test_only 为 true只测试不移动 if collision: return false return true避坑指南move_and_collide的test_only参数在Godot 3.5里是第三个参数safe_margin之后文档有时没说清楚。如果测试失败记得检查函数签名。最稳妥的方法是在_physics_process之外做碰撞测试时使用test_move方法KinematicBody2D的成员函数它专为这种场景设计。3.4 核心移动逻辑与插值当移动请求通过检测后我们调用move_to函数。func move_to(target_grid_pos: Vector2): # 更新逻辑位置 grid_position target_grid_pos # 计算目标世界位置 target_world_position grid_position * cell_size # 设置移动状态 is_moving true func _physics_process(delta): if is_moving: # 计算当前位置到目标位置的向量 var move_vector target_world_position - position # 如果已经非常接近目标点小于1像素则认为移动完成 if move_vector.length() 1.0: position target_world_position # 精确对齐 is_moving false else: # 计算本帧应移动的距离基于速度 var move_distance move_speed * delta # 如果本帧移动距离大于剩余距离则直接移动到终点 if move_distance move_vector.length(): position target_world_position is_moving false else: # 否则向目标位置移动固定距离 # normalize() 获取方向乘以速度得到本帧位移 var velocity move_vector.normalized() * move_speed # 使用 move_and_collide 处理移动中的碰撞 # 注意这里我们期望路径是畅通的所以碰撞可能意味着逻辑错误或动态障碍物 var collision move_and_collide(velocity * delta) if collision: # 如果移动过程中撞到东西比如动态出现的障碍停止移动并回退逻辑位置 print(移动中发生碰撞, collision.collider.name) is_moving false # 可选将grid_position和position回退到上一帧的状态 # 这需要你记录上一次有效的grid_position这就是最核心的移动循环。在_physics_process中每一帧我们都让角色的视觉位置 (position) 向目标世界位置 (target_world_position) 靠近一点直到抵达。3.5 使用Tween实现更平滑的移动上面的_physics_process实现是线性的移动速度恒定。如果你想实现“快入慢出”等效果或者想让代码更简洁Godot的Tween节点是绝佳选择。首先在场景中为GridMover添加一个Tween子节点命名为MoveTween。然后修改move_to函数func move_to(target_grid_pos: Vector2): grid_position target_grid_pos target_world_position grid_position * cell_size is_moving true # 计算移动所需时间距离/速度 var distance position.distance_to(target_world_position) var duration distance / move_speed # 使用Tween进行插值 $MoveTween.interpolate_property(self, position, position, target_world_position, duration, Tween.TRANS_LINEAR, Tween.EASE_IN_OUT) # 连接Tween完成信号 if not $MoveTween.is_connected(tween_completed, self, _on_Tween_completed): $MoveTween.connect(tween_completed, self, _on_Tween_completed) $MoveTween.start() func _on_Tween_completed(object, key): # 当position属性插值完成时调用 if key :position: is_moving false # 确保位置完全精确避免浮点误差 position target_world_position使用Tween的好处非常明显代码简洁移动逻辑浓缩在几行内。效果丰富通过更改TRANS_*和EASE_*参数可以轻松实现弹性、反弹等数十种缓动效果无需自己写数学公式。自动管理Tween会自己处理帧率变化保证动画平滑。重要提示使用Tween时一定要在移动完成后将position精确设置为target_world_position。因为插值计算可能有极微小的浮点误差累积起来会导致角色慢慢偏离网格。同时记得在_physics_process中移除手动的移动代码否则会和Tween冲突。4. 进阶集成AStar实现自动寻路基础移动实现了“走一步”但战棋或RPG里经常需要“走一路”。我们把AStar集成进来。4.1 构建AStar网格图假设我们有一个TileMap节点来绘制关卡其中0号图块是地面可通行1号图块是墙壁不可通行。我们创建一个单独的GridController节点可以是Node2D来管理全局的AStar网格。# GridController.gd extends Node2D export var cell_size: Vector2 Vector2(64, 64) export(Vector2) var grid_size Vector2(20, 15) # 网格的宽度和高度格子数 var astar AStar2D.new() # Godot 3 使用 AStar2D func _ready(): generate_astar_grid() func generate_astar_grid(): astar.clear() # 1. 添加所有点节点 for y in range(grid_size.y): for x in range(grid_size.x): var id calculate_point_id(x, y) astar.add_point(id, Vector2(x, y)) # 2. 连接可通行的点 for y in range(grid_size.y): for x in range(grid_size.x): var current_id calculate_point_id(x, y) # 检查当前点是否可通行这里需要你根据TileMap信息判断 if not is_cell_walkable(x, y): # 不可通行的点不连接任何边 continue # 检查四个方向的邻居上、下、左、右 var neighbors [ Vector2(x 1, y), # 右 Vector2(x - 1, y), # 左 Vector2(x, y 1), # 下 Vector2(x, y - 1) # 上 ] for neighbor in neighbors: # 确保邻居在网格范围内 if neighbor.x 0 and neighbor.x grid_size.x and neighbor.y 0 and neighbor.y grid_size.y: var neighbor_id calculate_point_id(neighbor.x, neighbor.y) # 如果邻居点也存在且可通行则连接两点 if astar.has_point(neighbor_id) and is_cell_walkable(neighbor.x, neighbor.y): # 计算移动代价这里简单设为1你可以根据地形修改 var weight 1.0 # 检查两点之间是否已经连接避免重复 if not astar.are_points_connected(current_id, neighbor_id): astar.connect_points(current_id, neighbor_id, true) # 双向连接 # 根据坐标计算唯一的点ID func calculate_point_id(x: int, y: int) - int: return y * int(grid_size.x) x # 根据网格坐标获取点ID func get_point_id(grid_pos: Vector2) - int: return calculate_point_id(int(grid_pos.x), int(grid_pos.y)) # 示例根据TileMap判断格子是否可通行 func is_cell_walkable(x: int, y: int) - bool: var tilemap $TileMap # 假设TileMap是子节点 if tilemap: # 获取该位置图块的索引-1表示没有图块 var tile_id tilemap.get_cell(x, y) # 假设0号图块地面可通行1号墙壁不可通行 return tile_id 0 # 如果没有TileMap默认所有格子可通行 return true # 核心函数获取从A到B的路径返回一系列网格坐标 func get_path(start_grid_pos: Vector2, end_grid_pos: Vector2) - Array: var start_id get_point_id(start_grid_pos) var end_id get_point_id(end_grid_pos) if not astar.has_point(start_id) or not astar.has_point(end_id): return [] # 起点或终点无效 # 获取路径返回的是世界坐标数组Vector2元素 var path_world_positions astar.get_point_path(start_id, end_id) # 注意AStar返回的路径坐标是我们在add_point时传入的Vector2即网格坐标。 # 所以这里path_world_positions实际上就是网格坐标数组。 return path_world_positions4.2 在移动控制器中使用路径修改GridMover.gd让它能接收并跟随一条路径。# GridMover.gd 新增部分 var current_path: Array [] # 存储路径网格坐标数组 var path_index: int 0 # 当前路径点的索引 # 设置移动路径 func set_path(new_path: Array): if new_path.empty(): return current_path new_path.duplicate() # 复制数组避免修改原数据 path_index 0 # 立即开始向路径的第一个点移动跳过当前位置 # 通常路径的第一个点就是起点所以从第二个点开始 if current_path.size() 1: path_index 1 move_to(current_path[path_index]) # 修改 move_to 完成后的回调使其能自动走完路径 func _on_Tween_completed(object, key): if key :position: is_moving false position target_world_position # 如果还有路径点继续移动到下一个 if not current_path.empty() and path_index current_path.size() - 1: path_index 1 move_to(current_path[path_index]) else: # 路径走完了清空路径 current_path.clear() path_index 0 emit_signal(path_finished) # 可以发射一个信号通知其他系统现在你可以在游戏逻辑中比如玩家点击地面调用GridController.get_path()获取路径然后传给GridMover.set_path()角色就会自动一格一格地走过去了。4.3 动态更新障碍物游戏中的障碍物可能会变化比如门打开/关闭。AStar需要动态更新。# GridController.gd 新增函数 # 设置某个格子为不可通行例如放置了一个箱子 func set_cell_solid(grid_pos: Vector2, solid: bool): var point_id get_point_id(grid_pos) if not astar.has_point(point_id): return if solid: # 使该点不可通行断开与所有邻居的连接 var connected_points astar.get_point_connections(point_id) for connected_id in connected_points: astar.disconnect_points(point_id, connected_id) # 也可以选择移除这个点但这样它就不能再被加回来了 // astar.remove_point(point_id) else: # 使该点恢复通行重新连接可通行的邻居 var x int(grid_pos.x) var y int(grid_pos.y) var current_id point_id var neighbors [ Vector2(x 1, y), Vector2(x - 1, y), Vector2(x, y 1), Vector2(x, y - 1) ] for neighbor in neighbors: if neighbor.x 0 and neighbor.x grid_size.x and neighbor.y 0 and neighbor.y grid_size.y: var neighbor_id get_point_id(neighbor) if astar.has_point(neighbor_id) and is_cell_walkable(neighbor.x, neighbor.y): if not astar.are_points_connected(current_id, neighbor_id): astar.connect_points(current_id, neighbor_id, true)5. 实战技巧与常见问题排查掌握了基本实现我们来看看那些容易踩坑的地方和提升体验的技巧。5.1 处理斜坡与非整数格移动有时游戏需要角色在斜坡上移动或者网格不是严格的方形比如等距视角。这时简单的round()取整可能不够。解决方案使用自定义的网格对齐函数。例如对于等距网格转换公式会更复杂。或者你可以存储一个“偏移量”offset允许角色在格子内有微小的位置偏差只在逻辑判断时使用网格坐标。# 更健壮的网格坐标转换处理子像素偏移 func world_to_grid(world_pos: Vector2) - Vector2: # 除以格子大小后加上0.5再向下取整效果类似于四舍五入到最近的格子 var grid_x floor((world_pos.x / cell_size.x) 0.5) var grid_y floor((world_pos.y / cell_size.y) 0.5) return Vector2(grid_x, grid_y) func grid_to_world(grid_pos: Vector2) - Vector2: return grid_pos * cell_size5.2 移动动画与状态管理当角色移动时你可能需要播放行走动画。不要在_physics_process里直接控制动画而是根据is_moving状态来管理。# GridMover.gd onready var animation_player $AnimationPlayer onready var sprite $Sprite func _process(delta): # 根据移动状态和方向更新动画 if is_moving: var move_dir (target_world_position - position).normalized() # 根据方向决定播放哪个动画 if abs(move_dir.x) abs(move_dir.y): # 主要水平移动 animation_player.play(walk_right if move_dir.x 0 else walk_left) else: # 主要垂直移动 animation_player.play(walk_down if move_dir.y 0 else walk_up) else: animation_player.play(idle)5.3 常见问题速查表下面这个表格是我在项目中遇到过的典型问题及其解决方案希望能帮你节省大量调试时间。问题现象可能原因解决方案角色移动后与格子中心有1-2像素偏差浮点数精度误差或插值结束时未精确对齐在移动完成回调中强制设置position target_world_position角色可以斜向穿墙碰撞检测只检查了目标格子没检查移动路径经过的格子使用RayCast2D沿移动方向检测或使用AStar确保整条路径可通行移动动画卡顿、不流畅在_physics_process中使用了lerp但没考虑帧率波动或Tween的duration计算有误确保移动速度乘以delta使用Tween并检查缓动函数是否合适点击移动后角色有时不动can_move_to检测失败可能是碰撞形状偏移或图层/遮罩设置不对调试can_move_to函数打印target_grid_pos和碰撞信息检查碰撞层寻路出来的路径很奇怪绕远路AStar中连接点的权重设置不正确或者某些点被错误地标记为不可通行检查generate_astar_grid中is_cell_walkable的逻辑可视化调试AStar网格多个单位同时移动时相互卡住移动逻辑没有考虑其他动态单位占据的格子在can_move_to中增加对“其他单位占用”的检查或使用更复杂的导航避免系统5.4 可视化调试绘制网格与路径调试网格移动时能“看到”网格和路径会事半功倍。我们可以用CanvasItem的_draw()函数来绘制调试图形。# 在GridController.gd中添加 func _draw(): if not Engine.editor_hint: # 只在运行时绘制 # 绘制网格线 var grid_color Color(1, 1, 1, 0.2) # 半透明白色 for x in range(grid_size.x 1): var start Vector2(x * cell_size.x, 0) var end Vector2(x * cell_size.x, grid_size.y * cell_size.y) draw_line(start, end, grid_color, 1.0) for y in range(grid_size.y 1): var start Vector2(0, y * cell_size.y) var end Vector2(grid_size.x * cell_size.x, y * cell_size.y) draw_line(start, end, grid_color, 1.0) # 绘制不可通行区域红色 var blocked_color Color(1, 0, 0, 0.3) for y in range(grid_size.y): for x in range(grid_size.x): if not is_cell_walkable(x, y): var rect Rect2(Vector2(x, y) * cell_size, cell_size) draw_rect(rect, blocked_color) # 记得在 _ready 或需要更新时调用 update() func update_grid_debug(): update()在GridMover中也可以绘制当前路径# GridMover.gd func _draw(): if not current_path.empty(): var path_color Color(0, 1, 0, 0.8) # 绿色路径 for i in range(current_path.size() - 1): var start_world current_path[i] * cell_size var end_world current_path[i 1] * cell_size draw_line(start_world - global_position, end_world - global_position, path_color, 2.0)记得在修改路径后调用update()来触发重绘。网格移动是2D游戏开发中构建精确、可控游戏体验的基石。从简单的键盘控制四方向移动到结合AStar的复杂自动寻路其核心思想始终是将连续空间离散化用确定的格子坐标来驱动逻辑用平滑的插值来优化表现。Godot 3提供的AStar、Tween、KinematicBody2D等节点和类让实现这一切变得非常模块化。我个人的经验是在项目初期就确定好网格的尺度cell_size并围绕它来设计角色、地图元素和UI。将网格移动控制器做成一个可复用的场景或脚本通过信号如move_started、move_completed、path_finished与其他系统如战斗系统、对话系统解耦。这样当你从一个小Demo扩展到完整游戏时这套移动框架依然能坚实可靠地运行。最后别忘了多利用Godot编辑器的调试功能结合自己绘制的调试图形把那些看不见的“格子”和“路径”变成看得见的参考线这会极大提升你的开发效率和代码质量。