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编辑器的调试功能,结合自己绘制的调试图形,把那些看不见的“格子”和“路径”变成看得见的参考线,这会极大提升你的开发效率和代码质量。