Godot 4.2 从零构建可交互2D网格系统:动态绘制与鼠标交互
1. 项目概述为什么我们需要一个可交互的2D网格系统在游戏开发、工具软件或者数据可视化项目中一个灵活、可交互的2D网格系统往往是底层交互逻辑的基石。无论是策略游戏的战场地图、模拟经营游戏的建造系统还是图像编辑软件的画布其背后都离不开一个能够精确响应鼠标点击、拖拽并能根据需求动态调整大小、样式和逻辑的网格。很多开发者尤其是刚接触Godot引擎的朋友可能会选择使用TileMap节点来快速搭建网格。TileMap确实方便但它本质上是一个用于拼接静态或动态图块的渲染工具其网格逻辑是固定的与瓦片资源强绑定难以实现复杂的、参数驱动的交互逻辑比如动态改变网格颜色、实时调整单元格大小、或者在运行时根据算法生成和修改网格数据。这正是“从零构建”的意义所在。我们不依赖任何预设的、功能固化的节点而是使用Godot 4.2提供的GDScript 2.0和强大的2D渲染API亲手打造一个完全受我们代码控制的网格系统。这个系统的核心目标是“参数化”与“动态化”。参数化意味着网格的所有视觉和逻辑属性——如行数、列数、单元格尺寸、线条颜色、填充色——都可以通过暴露的参数轻松调整甚至能在运行时通过代码或编辑器Inspector动态修改。动态化则意味着网格的绘制不是一张静态贴图而是每一帧或在属性变化时根据当前参数实时计算并渲染出来的这为实现高亮选中区域、显示路径规划、动态改变地形等高级交互提供了无限可能。本文将带你深入Godot 4.2的2D渲染管线从创建一个自定义的Node2D节点开始逐步实现网格的数学计算、动态绘制、鼠标交互包括像素坐标到网格坐标的转换以及参数化配置。无论你是想为你的回合制战棋游戏制作一个可点击的战场还是为你的关卡编辑器打造一个灵活的网格参考系这套方法论都将为你提供一个坚实、可扩展的起点。我们将避开TileMap的局限性直接与CanvasItem的绘图API对话获得对网格的完全控制权。2. 核心设计思路解构一个可交互网格的组成部分在动手写代码之前理清架构至关重要。一个功能完整的可交互2D网格系统可以分解为以下几个核心模块它们各自独立又相互协作。2.1 数据层网格的抽象模型网格首先是一个数据模型与渲染无关。我们需要一个类通常是一个Resource或普通GDScript类来存储网格的逻辑状态。这个模型至少包含维度 (columns,rows)网格有多少列和行。单元格尺寸 (cell_size)每个网格单元的宽度和高度通常是一个Vector2允许非正方形单元格。原点偏移 (grid_offset)网格左上角或中心根据设计在世界坐标系或局部坐标系中的位置。这决定了网格被“画”在屏幕的哪里。这个模型是网格的“灵魂”。所有后续的绘制和交互逻辑都基于这个模型进行计算。将其设计为独立的资源有利于实现数据与表现的分离方便未来实现网格数据的序列化保存/加载或在多个节点间共享同一套网格定义。2.2 渲染层动态绘制的实现这是将数据模型可视化的部分。我们通常会创建一个继承自Node2D或Control如果用于UI的自定义节点。该节点的核心职责是在_draw()虚函数中利用Godot的CanvasItem绘图API根据数据模型实时绘制出网格线。动态绘制的关键在于效率和灵活性。我们不会预生成一张网格纹理而是每次需要重绘时例如网格参数改变、节点进入场景树用代码循环计算出所有线条的起点和终点然后调用draw_line或draw_multiline进行绘制。Godot的绘图API在内部做了优化只要不是每帧绘制极其复杂的图形性能是完全可控的。这种方式的灵活性极高我们可以轻易地根据不同的条件如单元格是否被占据、是否可通行绘制不同颜色或粗细的线条甚至为特定单元格填充颜色。2.3 交互层从屏幕到网格的坐标映射交互是让网格“活”起来的关键。用户用鼠标点击屏幕上的某个像素点我们需要将这个屏幕坐标event.position转换为我们定义的网格模型中的行列索引(column, row)。这个过程涉及几个步骤坐标转换将全局的屏幕坐标通过get_global_mouse_position()和to_local()函数转换到网格绘制节点自身的局部坐标系中。逆向计算根据局部坐标(local_x, local_y)、网格原点偏移(grid_offset.x, grid_offset.y)和单元格尺寸(cell_width, cell_height)通过数学计算得到行列号。基本公式column floor((local_x - grid_offset.x) / cell_width)同理row floor((local_y - grid_offset.y) / cell_height)边界检查计算出的column和row必须在[0, columns-1]和[0, rows-1]范围内才是有效的点击。这步检查能防止用户点击网格外部区域产生无效索引。我们将这个坐标映射逻辑封装成一个函数如world_position_to_cell(world_pos: Vector2) - Vector2i它接收一个世界坐标返回一个包含行列索引的Vector2iGodot 4中的整数向量。这个函数是几乎所有网格交互点击、悬停、框选的基础。2.4 参数化接口暴露给编辑器和外部脚本的控制面板为了让我们的网格系统易于使用和调试我们需要将关键属性暴露出来。在GDScript中使用export注解可以将变量显示在编辑器的Inspector面板中。我们需要暴露的典型属性包括export var columns: int 10网格列数。export var rows: int 10网格行数。export var cell_size: Vector2 Vector2(64, 64)单元格大小。export var line_color: Color Color(0.5, 0.5, 0.5, 0.8)网格线颜色。export var line_width: float 2.0网格线宽度。当这些export变量在编辑器中发生改变时我们需要触发网格的重新绘制。这可以通过Godot的属性通知机制export配合setget或者直接使用signal和_ready、_process中的检查来实现但更优雅的方式是使用setget关键字在属性被设置时自动调用重绘函数。3. 从零开始构建网格数据模型与绘制节点现在让我们将设计思路转化为具体的代码。我们将创建两个主要的脚本文件。3.1 创建网格数据资源 (GridModel.gd)首先创建一个独立的资源脚本用于存储网格数据。这虽然不是必须的但是一种良好的架构实践。# GridModel.gd class_name GridModel extends Resource export var columns: int 10: set(value): columns max(1, value) # 确保至少有一列 emit_changed() # 资源改变时发出信号可供监听 export var rows: int 10: set(value): rows max(1, value) emit_changed() export var cell_size: Vector2 Vector2(64, 64): set(value): # 确保单元格尺寸不为零或负数 cell_size Vector2(max(1, value.x), max(1, value.y)) emit_changed() export var grid_offset: Vector2 Vector2.ZERO # 一个辅助函数根据行列号计算该单元格的中心点世界坐标相对于grid_offset func get_cell_center_position(cell_col: int, cell_row: int) - Vector2: return grid_offset Vector2(cell_col 0.5, cell_row 0.5) * cell_size # 另一个辅助函数计算单元格的矩形区域用于绘制填充色或碰撞检测 func get_cell_rect(cell_col: int, cell_row: int) - Rect2: var position grid_offset Vector2(cell_col, cell_row) * cell_size return Rect2(position, cell_size)注意这里我们使用了setget在变量定义后加:和set函数来拦截属性的赋值操作。这样做的好处是无论属性是通过代码grid.columns 20还是在编辑器中滑动滑块被修改我们都能在set函数中执行边界检查如确保值大于0并发出emit_changed()信号。监听此信号的节点如我们的绘制节点就可以知道网格数据已更新需要重绘。3.2 创建动态网格绘制节点 (InteractiveGrid.gd)接下来创建核心的绘制与交互节点。我们将其设为Node2D的子类。# InteractiveGrid.gd extends Node2D class_name InteractiveGrid # 引用我们创建的数据模型 export var grid_data: GridModel # 绘制样式参数 export var line_color: Color Color.GRAY export var line_width: float 2.0 export var highlight_color: Color Color(1, 1, 0, 0.3) # 高亮填充色 # 内部状态当前鼠标悬停或选中的单元格 var hovered_cell: Vector2i Vector2i(-1, -1) var selected_cells: Array[Vector2i] [] # 初始化如果没有提供grid_data则创建一个默认的 func _ready() - void: if not grid_data: grid_data GridModel.new() # 监听网格数据的变化以便重绘 if grid_data: grid_data.changed.connect(_on_grid_data_changed) queue_redraw() # 初始绘制 func _on_grid_data_changed() - void: queue_redraw() # 当网格数据改变时请求重绘 # 核心绘制函数 func _draw() - void: if not grid_data: return var cols grid_data.columns var rows grid_data.rows var cell_size grid_data.cell_size var offset grid_data.grid_offset # 1. 绘制垂直线 for i in range(cols 1): # 需要画 cols1 条垂直线 var start_x offset.x i * cell_size.x var start Vector2(start_x, offset.y) var end Vector2(start_x, offset.y rows * cell_size.y) draw_line(start, end, line_color, line_width) # 2. 绘制水平线 for j in range(rows 1): # 需要画 rows1 条水平线 var start_y offset.y j * cell_size.y var start Vector2(offset.x, start_y) var end Vector2(offset.x cols * cell_size.x, start_y) draw_line(start, end, line_color, line_width) # 3. 绘制高亮选中的单元格如果有的话 for cell in selected_cells: if _is_cell_valid(cell.x, cell.y): var rect grid_data.get_cell_rect(cell.x, cell.y) draw_rect(rect, highlight_color, true) # 第五个参数为true表示填充 # 4. 绘制鼠标悬停的单元格边框 if _is_cell_valid(hovered_cell.x, hovered_cell.y): var rect grid_data.get_cell_rect(hovered_cell.x, hovered_cell.y) draw_rect(rect, highlight_color, false, line_width * 2) # 只画边框加粗 # 辅助函数检查行列索引是否在有效范围内 func _is_cell_valid(col: int, row: int) - bool: if not grid_data: return false return col 0 and col grid_data.columns and row 0 and row grid_data.rows将这段脚本附加到一个Node2D节点上然后在Inspector面板中为grid_data创建一个新的GridModel资源并调整其参数和line_color等属性你应该就能在场景中看到一个静态的网格了。但是它还不能交互。4. 实现鼠标交互点击、悬停与坐标转换交互的核心是处理输入事件和坐标转换。我们需要让节点能够接收输入并重写_input(event)或_unhandled_input(event)函数。4.1 实现坐标映射函数在InteractiveGrid.gd中添加一个关键函数用于将任意位置的世界坐标转换为网格坐标。# 在 InteractiveGrid.gd 中添加 # 将世界坐标转换为网格单元格坐标Vector2i func world_to_grid(world_position: Vector2) - Vector2i: if not grid_data: return Vector2i(-1, -1) # 将世界坐标转换到本节点的局部坐标 var local_pos to_local(world_position) # 计算相对于网格原点的偏移量 var relative_pos local_pos - grid_data.grid_offset # 计算行列索引使用floor向下取整确保点击单元格任意位置都映射到该单元格 var col floori(relative_pos.x / grid_data.cell_size.x) var row floori(relative_pos.y / grid_data.cell_size.y) return Vector2i(col, row)4.2 处理鼠标输入事件现在我们来处理鼠标移动和点击事件更新悬停和选中状态。# 在 InteractiveGrid.gd 中添加 func _input(event: InputEvent) - void: if not grid_data: return # 鼠标移动事件更新悬停单元格 if event is InputEventMouseMotion: var mouse_grid_pos world_to_grid(get_global_mouse_position()) # 只有当悬停的单元格发生变化且有效时才更新并重绘 if mouse_grid_pos ! hovered_cell and _is_cell_valid(mouse_grid_pos.x, mouse_grid_pos.y): hovered_cell mouse_grid_pos queue_redraw() # 请求重绘以更新悬停高亮 # 如果鼠标移出网格清除悬停状态 elif not _is_cell_valid(mouse_grid_pos.x, mouse_grid_pos.y) and hovered_cell ! Vector2i(-1, -1): hovered_cell Vector2i(-1, -1) queue_redraw() # 鼠标点击事件左键选中/取消选中单元格 if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_LEFT: if event.pressed: # 按下事件 var clicked_cell world_to_grid(get_global_mouse_position()) if _is_cell_valid(clicked_cell.x, clicked_cell.y): # 简单的切换选中逻辑如果已选中则移除否则添加 if clicked_cell in selected_cells: selected_cells.erase(clicked_cell) else: selected_cells.append(clicked_cell) queue_redraw() # 选中状态改变需要重绘 # 可以在这里发射一个自定义信号通知其他节点哪个单元格被点击了 # cell_selected.emit(clicked_cell)现在运行场景。你应该可以看到当鼠标在网格上移动时当前所在的单元格会被一个加粗的边框高亮点击鼠标左键单元格会被半透明的颜色填充选中。基础的交互已经实现了4.3 添加自定义信号与更复杂的交互为了让这个网格系统能真正融入游戏逻辑我们还需要让它能与外界通信。Godot的信号系统是完美的选择。# 在 InteractiveGrid.gd 的顶部export 变量之后添加信号定义 signal cell_hovered(cell_position: Vector2i) signal cell_unhovered() signal cell_clicked(cell_position: Vector2i, mouse_button_index: int) # 然后修改 _input 函数中的相关部分 func _input(event: InputEvent) - void: if not grid_data: return if event is InputEventMouseMotion: var mouse_grid_pos world_to_grid(get_global_mouse_position()) var was_valid_hover _is_cell_valid(hovered_cell.x, hovered_cell.y) var is_valid_hover _is_cell_valid(mouse_grid_pos.x, mouse_grid_pos.y) if mouse_grid_pos ! hovered_cell: # 如果之前悬停在一个有效单元格上现在离开了发出 unhovered 信号 if was_valid_hover and (not is_valid_hover or mouse_grid_pos ! hovered_cell): cell_unhovered.emit() hovered_cell mouse_grid_pos queue_redraw() # 如果现在悬停在一个有效单元格上发出 hovered 信号 if is_valid_hover: cell_hovered.emit(hovered_cell) if event is InputEventMouseButton: var clicked_cell world_to_grid(get_global_mouse_position()) if _is_cell_valid(clicked_cell.x, clicked_cell.y) and event.pressed: cell_clicked.emit(clicked_cell, event.button_index) # ... 原有的选中逻辑可以保留也可以移到外部通过信号处理这样其他节点就可以连接到这个InteractiveGrid节点的cell_clicked等信号上在单元格被点击时执行自己的游戏逻辑比如移动角色、放置建筑或查询信息实现了完美的解耦。5. 高级功能扩展与性能优化一个基础的交互网格已经完成但要让它在实际项目中发挥作用我们还需要考虑一些高级功能和优化。5.1 动态参数更新与实时编辑我们的系统已经是参数化的通过修改grid_data的属性或在Inspector中调整line_color网格会通过changed信号自动重绘。但有时我们可能需要通过代码在运行时大规模修改网格比如根据屏幕大小自动调整网格密度。# 在 InteractiveGrid.gd 中添加一个函数示例 func resize_grid_to_fit_area(area_width: float, area_height: float, min_cell_size: Vector2) - void: if not grid_data: return # 计算在给定区域内不小于最小单元格尺寸的前提下能容纳的最大网格数 var max_cols floori(area_width / min_cell_size.x) var max_rows floori(area_height / min_cell_size.y) # 更新数据模型setter会自动触发changed信号和重绘 grid_data.columns max(1, max_cols) grid_data.rows max(1, max_rows) grid_data.cell_size Vector2(area_width / grid_data.columns, area_height / grid_data.rows)5.2 绘制优化避免每帧重绘目前任何视觉变化如悬停都会调用queue_redraw()这会触发_draw()在下一帧执行。对于简单的网格这没有问题。但如果网格非常巨大如1000x1000或者_draw()内有更复杂的绘制逻辑频繁重绘可能影响性能。优化策略是按需重绘。我们可以将网格的绘制分为静态部分和动态部分。静态部分网格基线。只有在网格参数大小、偏移改变时才需要重绘。动态部分高亮、选中状态。这部分需要频繁更新。一种实现方式是使用两个Node2D节点一个父节点GridStatic只负责绘制不变的网格线一个子节点GridDynamic负责绘制所有动态元素高亮、选中块、路径等。当只有动态状态变化时只需调用GridDynamic.queue_redraw()避免了重绘整个静态网格的开销。5.3 添加网格坐标调试显示在开发阶段能够实时看到鼠标所在网格的坐标非常有用。我们可以在_draw()函数的最后添加一段文本绘制代码。# 在 _draw() 函数末尾添加 func _draw() - void: # ... 之前的绘制代码 ... # 调试信息在屏幕一角绘制当前悬停的网格坐标 if _is_cell_valid(hovered_cell.x, hovered_cell.y): var debug_text Grid: (%d, %d) % [hovered_cell.x, hovered_cell.y] var font ThemeDB.fallback_font var font_size ThemeDB.fallback_font_size var text_pos Vector2(20, 20) # 屏幕左上角偏移 draw_string(font, text_pos, debug_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, line_color)5.4 实现框选Drag Selection功能框选是策略游戏中常见的操作。实现思路是在鼠标左键按下时记录起始网格坐标在拖动过程中记录当前网格坐标并在_draw()中绘制一个半透明的矩形来表示选择区域释放鼠标时确定最终框选的范围。# 在 InteractiveGrid.gd 中添加状态变量 var drag_start_cell: Vector2i Vector2i(-1, -1) var drag_current_cell: Vector2i Vector2i(-1, -1) var is_dragging: bool false # 修改 _input 函数处理鼠标拖拽 func _input(event: InputEvent) - void: # ... 原有的 motion 和 button 处理 ... if event is InputEventMouseButton and event.button_index MOUSE_BUTTON_LEFT: var grid_pos world_to_grid(get_global_mouse_position()) if event.pressed and _is_cell_valid(grid_pos.x, grid_pos.y): drag_start_cell grid_pos drag_current_cell grid_pos is_dragging true elif not event.pressed and is_dragging: # 鼠标释放 is_dragging false # 计算框选区域内的所有单元格 var start_col min(drag_start_cell.x, drag_current_cell.x) var end_col max(drag_start_cell.x, drag_current_cell.x) var start_row min(drag_start_cell.y, drag_current_cell.y) var end_row max(drag_start_cell.y, drag_current_cell.y) selected_cells.clear() for col in range(start_col, end_col 1): for row in range(start_row, end_row 1): if _is_cell_valid(col, row): selected_cells.append(Vector2i(col, row)) drag_start_cell Vector2i(-1, -1) drag_current_cell Vector2i(-1, -1) queue_redraw() if event is InputEventMouseMotion and is_dragging: var grid_pos world_to_grid(get_global_mouse_position()) if _is_cell_valid(grid_pos.x, grid_pos.y) and grid_pos ! drag_current_cell: drag_current_cell grid_pos queue_redraw() # 拖拽过程中实时更新选择框的绘制 # 在 _draw() 函数中添加框选区域的绘制 func _draw() - void: # ... 之前的绘制代码 ... # 绘制拖拽选择框 if is_dragging and _is_cell_valid(drag_start_cell.x, drag_start_cell.y) and _is_cell_valid(drag_current_cell.x, drag_current_cell.y): var start_pos grid_data.get_cell_rect(drag_start_cell.x, drag_start_cell.y).position var end_pos grid_data.get_cell_rect(drag_current_cell.x, drag_current_cell.y).position var rect_pos Vector2(min(start_pos.x, end_pos.x), min(start_pos.y, end_pos.y)) var rect_size Vector2(abs(end_pos.x - start_pos.x) grid_data.cell_size.x, abs(end_pos.y - start_pos.y) grid_data.cell_size.y) var selection_rect Rect2(rect_pos, rect_size) draw_rect(selection_rect, Color(0.2, 0.6, 1.0, 0.2), true) # 半透明蓝色填充 draw_rect(selection_rect, Color(0.1, 0.3, 1.0, 0.8), false, line_width) # 蓝色边框6. 常见问题与调试技巧实录在实际构建和使用这个网格系统的过程中你可能会遇到一些典型问题。以下是我在多次项目中总结出来的排查清单和经验。6.1 网格绘制不出来或位置不对检查1_draw()是否被调用确保在属性改变后或初始化时调用了queue_redraw()。在_ready()函数中调用一次是个好习惯。检查2坐标计算是否正确这是最常见的问题。重点检查world_to_grid函数to_local()是否正确将全局坐标转换到了当前节点的坐标系确保你的InteractiveGrid节点在场景树中有正确的位置和缩放。计算公式(local_pos - offset) / cell_size中的减法和除法顺序是否正确offset是网格的起始绘制点。使用print()或print_debug()在运行时输出local_pos、offset和计算出的col、row与你的预期进行对比。检查3线条颜色Alpha值是否为0如果line_color.a透明度为0线条会完全透明。确保它大于0。检查4节点是否在可视范围内检查节点的position和CanvasLayer设置确保它没有被父节点裁剪或移出屏幕。6.2 鼠标交互不灵敏或错位问题点击位置和网格高亮对不上。原因A节点缩放。如果InteractiveGrid节点或其父节点有缩放scale不为Vector2(1,1)to_local()的转换会出错。一个简单的解决方案是在计算局部坐标时考虑节点的全局变换使用get_global_transform().affine_inverse() * world_position来代替to_local(world_position)这样能更精确地处理缩放和旋转。原因Bfloor与ceil或round的误用。我们使用floori意味着一个单元格覆盖的范围是[offset n*size, offset (n1)*size)。如果你希望点击单元格右/下边缘时归属到下一个单元格可能需要调整逻辑。理解并统一你的“坐标归属”规则至关重要。调试技巧在_draw()中在被点击的单元格中心画一个小点看看这个点是否出现在你鼠标点击的位置。这能快速帮你定位是绘制问题还是坐标转换问题。6.3 性能问题网格很大时感到卡顿优化绘制如5.2节所述将静态和动态绘制分离。对于超大型网格可以考虑只绘制视口Camera范围内的部分这需要结合相机的视口矩形进行计算。减少重绘频率确保queue_redraw()只在必要时调用。例如鼠标移动事件InputEventMouseMotion触发非常频繁可以添加一个阈值判断只有当鼠标移动超过一定像素或进入新的单元格时才触发重绘。简化绘制命令对于纯色网格线使用draw_multiline一次性绘制所有线段比在循环中多次调用draw_line可能更高效取决于Godot版本和底层渲染优化。可以预先计算好所有线段的点数组。6.4 在编辑器Inspector中修改属性不实时更新确保使用了setget或信号。如我们在GridModel.gd中所做在export变量的setter中调用emit_changed()。在InteractiveGrid.gd中我们需要监听这个信号并连接queue_redraw()。在InteractiveGrid.gd自身的export变量如line_color上也需要使用setget来触发重绘。export var line_color: Color Color.GRAY: set(value): line_color value queue_redraw()编辑器插件对于更复杂的即时预览可能需要编写一个简单的编辑器插件但这超出了基础网格系统的范畴。对于大多数情况上述方法已足够。构建这样一个系统最关键的不仅是让它运行起来更是理解每一行代码背后的意图。从数据模型与渲染分离的设计到精确的坐标映射数学再到按需重绘的性能考量每一步都体现了对问题本质的思考。当你亲手实现并调试通过后你会发现它不仅是一个工具更是一套理解2D空间、交互和实时渲染的思维模型。你可以在此基础上轻松扩展出六边形网格、等距网格或者集成寻路算法、战争迷雾等高级功能真正成为你项目坚实的交互基础。