Godot 4 多边形按钮:重写 _has_point 实现任意形状点击
发布时间:2026/10/1 5:00:03来源:尧图网络
做过游戏 UI 的人大概都碰过这种局面美术给了一张不规则的贴图——一块岩石底座、一片树叶、一张撕裂的纸片、一块地图上的岛屿——你把它丢进 Button 里视觉上挺漂亮可鼠标只要挨到那圈透明的矩形边框按钮就亮了。玩家点在一片空白上游戏却跳进了下一关。这类问题在 Godot 里尤其常见因为它的 Control 体系从骨子里就是矩形的Button、TextureButton、CheckBox全部继承自Control而Control的命中测试默认就是Rect2(Vector2.ZERO, size).has_point(point)——一个规规矩矩的矩形。多边形按钮、任意形状按钮这个需求说穿了就两件事点击判定要跟着形状走视觉绘制也要跟着形状走。前者是逻辑后者是表现两者脱节就会出现点得到但看不见或者看得见却点不到的诡异体验。我下面要聊的就是怎么在 Godot 4 里用最少的代码把这两件事对齐包括三种技术路线的取舍、射线法自己的坑、顶点数据怎么攒、凹多边形挖洞踩过的雷以及在物理节点和 UI 节点之间怎么选。适合的读者用过 Godot 基本控件、知道signal和_ready怎么回事、想做一个不规则热区交互的人。不需要数学基础点内判定我会把原理拆开讲。1. Godot 的按钮为什么天生是矩形1.1 Control 的命中测试到底发生在哪一步想改形状先得知道 Godot 是怎么判断鼠标落在哪个控件上的。整个流程的核心在Viewport的 GUI 拾取逻辑里鼠标移动或按下时Viewport 会从场景树里可见的 Control 中按绘制顺序从后往前遍历后绘制的认为在上层对每个控件依次调用has_point()做判定第一个返回true且mouse_filter为STOP的控件拿走这次事件。而Control::has_point()的实现非常朴素它先尝试调用脚本重写的虚函数_has_point()如果脚本没重写就退回默认实现——拿控件的局部矩形去测。这就是按钮天生是方的的根源。它不是什么硬编码限制只是一个默认值而 Godot 恰好把这个默认值开放成了一个可以重写的虚函数。还有一个细节容易被忽略_has_point()接收的point参数是控件局部坐标系下的点原点是控件左上角不考虑父节点的位置。也就是说如果你把一个 200x100 的按钮放在 (300, 400)鼠标在屏幕 (350, 450) 处传进_has_point的是(50, 50)不是(350, 450)。这个语义决定了我们的多边形顶点数据也应该用局部坐标存储省掉一次坐标转换。另外mouse_entered和mouse_exited这两个信号同样依赖has_point。你重写了_has_point悬停范围会自动跟着变形不用额外处理。这是个便宜占很值得。1.2 三条路线摆在一起看代价差得很远在 Godot 里实现任意形状热区能走的路其实就三条我按改动量 / 精确度 / 适用场景列一下路线核心做法精确度改动量适合谁重写_has_point脚本里判断点是否在多边形内顶点级可做到亚像素极小一个脚本绝大多数 UI 按钮场景TextureButton的texture_click_mask从贴图 alpha 生成BitMap当遮罩像素级但受贴图分辨率限制很小按钮本来就是一张带透明通道的图Area2DCollisionPolygon2D物理拾取靠碰撞形状判定顶点级中等要处理图层冲突游戏世界里可点击的场景物件texture_click_mask看着最省事但它有硬伤遮罩是按贴图原始像素格判定的控件缩放、拉伸之后命中范围和视觉会错位。你如果用了stretch_mode KEEP_ASPECT_CENTERED之类的设置形状和热区之间就会漂移越放大越明显。做原型够用做正式 UI 我不推荐。Area2D那条路适合游戏世界里的可点击物体——地图上的一座城、场景里的一只箱子。但它跟 Control 体系是两套事件流混用会打架后面第 6 节单独讲。绝大多数多边形按钮需求答案就是第一条继承 Button重写_has_point。它保留了 Button 的所有现成能力——pressed、button_down、button_up、toggled、disabled、toggle_mode、快捷键、主题系统、无障碍名称——你只需要把命中判定这一个函数换掉其余全都白捡。2. 用_has_point换掉命中判定2.1 点内判定为什么选射线法而不是凸包拆分要在多边形内判断一个点常见算法有两种凸包拆分和射线法。凸包拆分的思路是把任意多边形拆成若干个三角形或凸多边形然后逐个测试点是否落在其中任何一个里。它的好处是单个测试极快凸多边形用叉积符号判断几次乘加就完事坏处是拆分本身有代价而且形状一变就得重新拆。物理引擎里的CollisionPolygon2D走的就是这条路——Godot 内部会把凹多边形分解成若干凸块来建碰撞形状这也是它能高效处理凹形的原因。射线法的思路更像小孩子数数从待测点向任意方向习惯上取 x 方向发一条射线数它穿过多边形边界的次数。奇数次说明点在内部偶数次说明在外部。这个规则叫奇偶规则even-odd rule它的美妙之处在于不需要任何预处理顶点数组拿来就能用而且天然支持凹多边形和自相交形状。代价是每次判定都要遍历所有边。但这里有个量级概念一个按钮撑死几十个顶点一次判定几百次浮点运算现代 CPU 上一个循环几十纳秒。鼠标移动时一帧最多调用几百到一两千次总开销不到 0.1 毫秒。在 UI 场景里这个开销完全可以忽略追求凸包拆分那种微优化是自找麻烦。提示Godot 其实内置了Geometry2D.is_point_in_polygon(point, polygon)一行就能用语义同样是 even-odd。原型阶段直接用没问题。但它在边界附近的判定行为不完全可控如果你需要挖洞、需要自定义容差自己写一份 float 版本的射线法会更好掌握。2.2 一个能直接抄进项目的 PolygonButton下面这份脚本我在实际项目里用了不止一次去掉注释大概四十行。核心结构是export暴露顶点数组_has_point里先做 AABB 快速剔除再跑射线法_draw负责把形状画出来。tool class_name PolygonButton extends Button # 顶点用局部坐标原点在控件左上角 export var polygon: PackedVector2Array PackedVector2Array([ Vector2(64, 0), Vector2(128, 46), Vector2(102, 118), Vector2(26, 118), Vector2(0, 46) ]): set(value): polygon value _aabb_dirty true queue_redraw() # 内环用来在按钮上挖洞比如环形按钮 export var holes: Array[PackedVector2Array] []: set(value): holes value _aabb_dirty true queue_redraw() var _aabb : Rect2() var _aabb_dirty : true func _ready() - void: # 把默认的矩形背景全部清掉否则会在多边形外面露出一圈方框 for state in [normal, hover, pressed, focus, disabled]: add_theme_stylebox_override(state, StyleBoxEmpty.new()) func _has_point(point: Vector2) - bool: # 顶点不够退回默认矩形行为避免整个控件点不到 if polygon.size() 3: return Rect2(Vector2.ZERO, size).has_point(point) # AABB 先剔除绝大多数鼠标位置在这里就被挡掉了 if not _get_aabb().has_point(point): return false if not point_in_polygon(point, polygon): return false # 命中外环后再检查内环落在洞里算没点中 for hole in holes: if hole.size() 3 and point_in_polygon(point, hole): return false return true func _get_aabb() - Rect2: if _aabb_dirty: _aabb _build_aabb() _aabb_dirty false return _aabb func _build_aabb() - Rect2: if polygon.is_empty(): return Rect2(Vector2.ZERO, size) var mn : polygon[0] var mx : polygon[0] for p in polygon: mn.x minf(mn.x, p.x) mn.y minf(mn.y, p.y) mx.x maxf(mx.x, p.x) mx.y maxf(mx.y, p.y) return Rect2(mn, mx - mn) # 奇偶规则射线法静态方法方便别处复用 static func point_in_polygon(p: Vector2, poly: PackedVector2Array) - bool: var inside : false var n : poly.size() var j : n - 1 for i in n: var a : poly[i] var b : poly[j] # 判断这条边的两端是否横跨待测点的 y if (a.y p.y) ! (b.y p.y): var t : (p.y - a.y) / (b.y - a.y) var x_at_y : a.x (b.x - a.x) * t if p.x x_at_y: inside not inside j i return inside func _draw() - void: if polygon.size() 3: return var fill : _state_color() draw_colored_polygon(polygon, fill) # 编辑器里把顶点和边画出来方便对着调 if Engine.is_editor_hint(): var outline : polygon.duplicate() outline.append(polygon[0]) draw_polyline(outline, Color(1.0, 0.55, 0.1), 2.0) for p in polygon: draw_circle(p, 3.0, Color.ORANGE) func _state_color() - Color: if disabled: return Color(0.35, 0.35, 0.35, 0.6) if button_pressed and toggle_mode: return Color(0.2, 0.5, 0.9) if is_hovered(): return Color(0.28, 0.6, 0.95) return Color(0.2, 0.45, 0.75) func _notification(what: int) - void: # 悬停、按下状态变化时重绘把颜色反馈跟上 if what in [NOTIFICATION_MOUSE_ENTER, NOTIFICATION_MOUSE_EXIT, NOTIFICATION_FOCUS_ENTER, NOTIFICATION_FOCUS_EXIT]: queue_redraw()有几个地方我想重点说一下因为它们都是踩过的第一for state in [...]那段覆盖 StyleBox 是必须的。很多人只重写_has_point就以为完事了结果按钮外面那圈矩形背景还在——因为 Button 自己会画normal/hover/pressed三个样式的背景框那是个实打实的矩形。视觉和热区一脱节玩家就会觉得这按钮很怪。第二polygon.size() 3时退回矩形不是偷懒。编辑器里刚创建节点、顶点还没配好的时候如果_has_point直接返回false你连选中它、拖它都做不到会以为节点坏了。这个兜底能省掉不少困惑。第三_state_color()里我用了is_hovered()这是 Godot 4 里 Control 自带的比自己去连mouse_entered信号更省事悬停状态会自动跟着重写后的_has_point走。第四_aabb_dirty这个标记不能省。如果每帧都遍历顶点算包围盒那和直接跑射线法没区别白优化了。3. 射线法自己写起来会遇到的边界问题3.1 顶点、水平边和浮点误差射线法看着简单但边界情况一堆我第一次写的时候就在顶点上翻过车。问题出在(a.y p.y) ! (b.y p.y)这个判断上。它本质上是在问这条边的两个端点是不是分居待测点两侧。因为用的是严格大于当端点 y 恰好等于p.y时这条边会被算作不跨越于是射线刚好擦过顶点的情况会被漏掉或重复计数。不过神奇的是只要所有边都用同一套规则都用而不是这个漏掉是成对发生的射线穿过一个顶点等于同时穿过两条边两条边都判定为不跨越计数不变或者都判为跨越计数加二。加二不影响奇偶性不跨越也不影响。所以标准写法反而是稳的——别自作聪明把某个比较改成那样才会真的错。真正需要处理的是点在边上的情况。射线法对点恰好落在多边形边上的返回值是不确定的取决于边相对射线的方向。对按钮来说这不是大事——鼠标正好压在边缘那一个像素上判定成内还是外玩家感觉不出来。但如果你在做拖拽多边形顶点这类需要精确判断的编辑器工具就得额外加一步先算点到每条线段的距离小于阈值就直接返回true。浮点误差方面(p.y - a.y) / (b.y - a.y)这一步在边接近水平时没问题因为b.y ! a.y由前面的判断保证了但如果你自己改过比较逻辑可能出现除零。这也是我坚持用标准写法的原因之一。3.2 局部坐标和变换叠加的坑_has_point拿到的是局部坐标所以顶点用局部坐标存比较的时候直接比这是最简单的模型。但如果你给这个 Button 加了rotation或者scale事情会变复杂。Godot 在处理带变换的 Control 时不同版本的坐标转换细节不完全一致。我在 Godot 4 上实测下来rotation旋过的按钮_has_point收到的点是被反向旋转回控件本地空间的也就是说按你本地坐标存的多边形依然能对上。但这属于依赖实现细节我一般宁可绕开。更稳的做法是不要给多边形按钮本体加缩放和旋转。需要旋转效果时把旋转做在父节点上或者直接在顶点数据里预先旋转好。需要缩放时用一套固定的设计尺寸做判定然后用Control的size与实际顶点做一个归一化映射。具体就是在_has_point开头加一步func _has_point(point: Vector2) - bool: var design_size : Vector2(128, 128) var local_p : Vector2( point.x / size.x * design_size.x, point.y / size.y * design_size.y ) return point_in_polygon(local_p, polygon)这样你只需按 128x128 的设计尺寸画一次顶点之后按钮拖多大、怎么拉伸热区都会等比跟上不用重算顶点。这个技巧在制作九宫格式的可变尺寸多边形按钮时特别有用。3.3 挖洞为什么直接拼接顶点数组是错的做环形按钮、挖空按钮的时候直觉上会觉得把内环的顶点接到外环数组后面射线法不是按奇偶算的吗自然就挖空了。我一开始就是这么想的结果整个按钮的反了——环的实体部分判成了外部中间的空洞判成了内部。原因不难想从环内实体部分的某点向 x 发射线它会先穿过内环计数 1再穿过外环计数 2总共两次偶数判定为外部。刚好反了。正确做法是分别判定再组合if not point_in_polygon(point, polygon): return false for hole in holes: if point_in_polygon(point, hole): return false return true逻辑非常直白先必须落在主体轮廓里然后只要落在任何一个洞里就否决。这个写法还有个额外好处——holes数组是独立的你想加几个洞加几个不用重新组织顶点顺序也不会因为环与环之间的隐式连线把形状搞乱。注意内环的顶点绕向顺时针还是逆时针在这种分别判定的方案里完全不影响结果因为每个环都是独立跑一遍 even-odd。这一点和某些图形库要求内外环绕向相反的规定不一样可以省心。4. 顶点数据从哪来4.1export数组加tool预览最省事的顶点来源就是手填。上面那段脚本里export var polygon: PackedVector2Array会让这个数组出现在检查器里点开能一个一个改 x、y也能增删元素。配合tool让脚本在编辑器里也跑_draw你改一个数字视口里的形状和顶点标记点立刻跟着动所见即所得。这个组合对付十几个顶点的形状完全够用。但顶点一多手填坐标的效率就崩了——你得先在纸上量再一个个敲改起来更痛苦。4.2 拿 Polygon2D 当顶点编辑器顶点多的时候我会用这个土招在场景里临时放一个Polygon2D节点用它的顶点编辑功能把形状摆好——这部分编辑器交互很成熟鼠标直接拖点加删点也顺手。摆完之后把Polygon2D.polygon里的数据复制到按钮的polygon属性上然后删掉那个临时节点。在 Godot 4 的检查器里PackedVector2Array类型的属性可以直接把Polygon2D的数据拖过去或者右键复制粘贴。我一般更暴力一点写个一次性脚本tool extends EditorScript func _run() - void: var src: Polygon2D get_editor_interface().get_selection().get_selected_nodes()[0] var btn : ... # 目标节点引用 btn.polygon src.polygon btn.queue_redraw()这个脚本丢进EditorScript里跑一次顶点就搬过去了。比手动复制粘贴靠谱尤其是形状复杂、需要反复调整的时候。4.3 运行时采点这个土办法反而最好用还有一种情况形状不是设计好的而是从游戏内容里长出来的。比如地图上一块不规则的可选区域它的轮廓本来就是关卡数据里的一段多边形这时候顶点根本不用你画直接从数据里读就行。但如果数据也没有比如你就是想照着这张不规则贴图做个按钮那我推荐一个最土但最有效的做法运行时采点。在按钮上挂一段临时脚本鼠标点击时把局部坐标打印出来func _gui_input(event: InputEvent) - void: if event is InputEventMouseButton and event.pressed \ and event.button_index MOUSE_BUTTON_LEFT: print(Vector2(%f, %f), % [event.position.x, event.position.y]) accept_event()对着贴图把轮廓的关键点挨个点一遍控制台里吐出的就是一串可以直接粘进数组的Vector2。注意这里用的是event.position_gui_input里的事件坐标已经是局部坐标了不用再转换。我做过一个 30 多个顶点的形状几分钟就采完了比用外部工具量坐标快得多。采完点记得把这段临时脚本删掉否则正式版本里会一直吞鼠标事件。5. 让手感不塌的几件事5.1 边界抖动和悬停闪烁形状越不规则边缘越容易出现一种讨厌的现象鼠标停在凹口的尖端附近悬停状态噼里啪啦地闪。根源是_has_point在相邻两帧给出了不同结果mouse_entered和mouse_exited交替触发视觉上就是背景色在抖。因为两个信号之间没
网站建设高端定制企业官网