1. 用 Godot 做异形按钮,默认 Button 到底卡在哪
游戏 UI 里的按钮十有八九是矩形的,直到美术丢来一个六边形技能格、一个斜切角的主菜单项,或者一个星形收集徽章——你才会发现 Godot 的 Button 和 TextureButton 都是按矩形框算点击的,那个看不见的矩形会一直悬在多边形外面。玩家手指点在图形之外的空白角落,按钮照样亮起来,这种“幽灵热区”在移动端尤其明显,因为手指本来就没鼠标精确。
这篇东西想解决的就是这个问题:在 Godot 里做一个多边形、任意形状的按钮,让点击判定严格贴着图形轮廓走。适合已经写过一两个 Godot 项目、会用 GDScript、也对 Control 节点体系有点感觉的人;如果你刚接触 Godot,也能照着抄,因为我把每一行代码的来龙去脉都拆开了。
先把结论摆出来:最靠谱的路线是写一个继承 Control 的自定义节点,重写_has_point()虚函数,绘制交给draw_colored_polygon()。不用 TextureButton 的比特位掩码,不用 Area2D 物理碰撞,也不用去改引擎源码。
1.1 默认 Button 的矩形命中是天生的,不是配置问题
很多人第一反应是去翻 Button 的文档,想找个hit_shape或者collision_polygon之类的属性。找不到,因为 Control 这一套本来就是为矩形布局设计的。Control内部维护的rect只有一个位置和一个尺寸,鼠标进出的判断就是point.x >= 0 && point.x < size.x && point.y >= 0 && point.y < size.y这么朴素。
Engine 其实留了口子。Control有一个虚函数_has_point(point: Vector2) -> bool,官方文档的原话大意是:返回该 Control 是否包含给定点,用于判断鼠标是否在控件上方、GUI 事件是否应该路由到它。默认实现就是那个矩形判断,你重写它,矩形就变成了任意形状。
这个口子从 Godot 3 就有,Godot 4 依然在。而且它不只是在点击时被调用——鼠标进入、离开、悬停检测全都走这个函数。也就是说,你重写它之后,mouse_entered/mouse_exited信号也会跟着变精确,高亮效果自然就贴着轮廓走了。
1.2 三条可选路线,我为什么最后选了 Control 子类
真要在 Godot 里实现异形点击,能走的路不止一条。我把试过的、看别人用过的都列一下,顺便说说各自的天花板在哪。
路线一:TextureButton + texture_click_mask。
TextureButton 有个属性叫texture_click_mask,接收一个BitMap,白色区域算可点击,黑色算穿透。这确实能实现任意形状,但你得先准备一张和按钮等大的黑白图,在编辑器的导入面板里从纹理生成 BitMap。问题在于:Bitmap 是按像素存的,按钮尺寸一大(比如 1024×1024),这张掩码图的内存就下不来;分辨率一改,掩码就得重新生成;形状稍微调一下,就得回到美术那边重新导图。做原型还行,做需要动态变化的 UI 就很别扭。
路线二:Area2D + CollisionPolygon2D。
这条路在 2D 游戏层面是通的,碰撞形状天然支持多边形,鼠标位置转成世界坐标,用get_overlapping_bodies或者input_event信号就能做。但它脱离了 Control 体系:不能塞进 VBoxContainer 自动排版,锚点系统用不了,主题(Theme)用不了,焦点(Focus)导航也用不了。你要自己管缩放、管层级、管屏幕适配,等于把 Control 已经帮你做的活儿重新做一遍。
路线三:自定义 Control 子类,重写_has_point()。
这就是我最终选的。它保留了 Control 的全部生态:能进容器、能设锚点、能吃 Theme、能参与焦点导航、能被 Tween 直接动size和position。你只是把“矩形”这个默认答案换成了“多边形”,其他一切照旧。代价是要自己写绘制逻辑和事件处理,大概一百多行,一次写好可以反复用。
提示:如果你只是想要一个圆角矩形按钮,别走这条路线。StyleBoxFlat 的
corner_radius就够了,视觉上圆角但命中区还是矩形——这通常可以接受,因为手指本来就有宽度。真正的异形按钮是给菱形、六边形、星形、箭头这类“留白很大”的形状准备的。
2. 核心原理:把点击判定从矩形里“抠”出来
动手之前,得先搞清楚 Godot 的鼠标事件是怎么一步步走到你的按钮上的。搞清楚了,后面调 bug 就不用靠猜。
2.1 Control 的鼠标命中流程,_has_point()卡在哪一环
Godot 的视口(Viewport)拿到一个鼠标事件后,会从场景树的顶层往下遍历。遍历过程中,对每个 Control 都问一遍:这个点在你身上吗?问的就是has_point(),而has_point()内部会调用你重写的_has_point()。
命中之后还有第二道关卡:mouse_filter。这个属性有三个值:
MOUSE_FILTER_STOP(默认):吃掉事件,不再往下面的节点传。MOUSE_FILTER_PASS:自己处理,同时也往下传。MOUSE_FILTER_IGNORE:完全不参与,事件直接穿透过去。
做自定义按钮,mouse_filter保持STOP就行。这一点看着简单,但坑很多——如果你不小心把父节点的mouse_filter也设成了STOP,而父节点恰好处在按钮上面(比如一个半透明的背景板),那事件会被父节点先抢走,按钮永远收不到_gui_input。这个后面第 5 章还会细说。
2.2 点在多边形内的判定,用一个“射线穿过几次”的直觉理解
判断一个点是否落在多边形内部,计算机图形学里最经典的算法叫射线法(Ray Casting / Even-Odd Rule)。
思路很朴素:从这个点往右随便射一条水平射线,数它和多边形的边相交了几次。奇数次说明点在内部,偶数次说明在外部。你可以这么想——多边形是一条闭合的围墙,你从墙内往外走,必然穿过奇数道墙;从墙外走到墙外,穿过的墙一定是偶数道(或者零道)。
Godot 的Geometry2D单例直接提供了这个函数:
Geometry2D.is_point_in_polygon(point, polygon)参数顺序是点在前、多边形在后,返回bool。它就是你整个方案的核心判定依据。
注意:传进去的
polygon必须是简单多边形,也就是边不能自相交。如果顶点顺序排错了,搞出一个“蝴蝶结”形状,判定结果会非常诡异——有的区域明明在图形里却判成外部。这个后面在问题排查里会展开。
如果你出于某种原因不想依赖引擎函数(比如想自己加边界容差),手写射线法也就十来行:
static func point_in_polygon(point: 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] if (a.y > point.y) != (b.y > point.y): var x_cross := (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x if point.x < x_cross: inside = not inside j = i return inside这段代码每次判定是 O(n),n 是顶点数。对按钮这种量级(4 到 20 个顶点)来说,开销基本可以忽略。
2.3 绘制交给draw_colored_polygon(),它自己会做三角剖分
命中判定解决了,视觉上还得把多边形画出来。Godot 的 CanvasItem 提供了draw_colored_polygon(points, color),传入一圈顶点和一个颜色,引擎内部会先做三角剖分再交给渲染器。
这里有个细节值得说:凸多边形的三角剖分是平凡的,任何一个顶点连到其他所有不相邻顶点就行;凹多边形就麻烦一些,需要用到耳切(Ear Clipping)之类的算法。Godot 4 的draw_colored_polygon()内部处理了这个,绝大多数简单凹多边形都能正确渲染。
还有个更底层的组合:Geometry2D.triangulate_polygon(polygon)会返回一个PackedInt32Array,每三个索引构成一个三角形。你可以拿它配合draw_primitive()手工画,控制力更强,代价是要自己组装数组。绝大多数情况下直接用draw_colored_polygon()就够了。
实操心得:
draw_colored_polygon()传的顶点顺序不影响填充结果(无论顺逆时针都能画),但会影响边线描边。用draw_polyline()画轮廓时,顶点必须按一个方向首尾相接,还要手动把第一个点补到数组末尾来闭合。
3. 完整实现:一个能直接用的 PolygonButton
原理讲完,上代码。我把它写成一个@tool脚本,这样在编辑器里放进去就能看到实际形状,不用运行游戏。
3.1 坐标空间的设计:为什么用 [-1, 1] 归一化
第一个要做的决定是:多边形顶点用什么坐标系存?
三种选择:像素坐标、[0, 1] 归一化、[-1, 1] 归一化。
选最后一种。原因有三。第一,(0, 0)是中心,符号本身就是方向提示,写六边形的时候一眼能看出哪个顶点在上;第二,缩放是自然的,顶点乘上半个尺寸就是实际位置,不管控件拉成 200×100 还是 600×300 都成立;第三,方便和别的坐标系互换,比如要贴纹理时算 UV,(p + 1) / 2就变成 [0, 1] 了。
局部坐标的换算公式是:
local_x = (p.x * 0.5 + 0.5) * size.x local_y = (p.y * 0.5 + 0.5) * size.y验证一下:p = (-1, -1)得到(0, 0),正好是 Control 的左上角;p = (1, 1)得到(size.x, size.y),右下角;p = (0, 0)得中心。逻辑闭合。
3.2 缓存:别在每次鼠标移动时重算顶点
_has_point()的调用频率比你想象的高。鼠标每移动一个像素,视口就要问一遍所有相关 Control;鼠标在按钮上画个圈,这个函数可能被调几百次。
如果你的按钮有几十个顶点,每次都在里面重新计算像素坐标,纯属浪费。用一个脏标记(dirty flag)缓存结果就好:顶点改了、尺寸变了,就标记为脏;下次要用的时候再重算一次。
关键是要在正确的时机标脏:
set_polygon()被调用时- 收到
NOTIFICATION_RESIZED通知时(控件尺寸变了) - 显式设置
scale时(不过 Control 的 scale 会影响绘制但_has_point拿到的已经是局部坐标,所以不用管)
3.3 事件处理:按下、抬起、拖出去再松手
按钮的语义比想象中细腻,光有mouse_button按下抬起不够。
标准按钮的行为是这样:必须在按钮内部按下、并在按钮内部抬起,才算一次完整的点击。如果在按钮里按下、拖到外面松开,应该取消这次点击,不发出pressed信号。
Godot 的 GUI 系统在_gui_input里给你的是鼠标焦点事件——一旦你在按下时accept_event(),后续的移动和抬起事件即使在控件外面也还是会送到你这里。所以你不能假设抬起时鼠标还在按钮上,必须自己做一次判定:
if _has_point(get_local_mouse_position()): pressed.emit()这一行如果不写,就会出现“拖出去松开也触发”的诡异行为。
还有个容易忽略的点:拖拽过程中鼠标离开了多边形,高亮应该回到常态色,而不是继续显示按下色。所以状态色判定里得同时看_held和_hovered。
3.4 完整代码
下面这份可以直接存成polygon_button.gd用。
@tool class_name PolygonButton extends Control ## 任意形状(多边形)按钮。 ## 命中判定重写 Control._has_point(),绘制走 draw_colored_polygon()。 signal pressed signal button_down signal button_up @export_group("Shape") ## 顶点坐标,取值区间 [-1, 1]。 ## (0, 0) 是控件矩形中心,(-1, -1) 是左上角,(1, 1) 是右下角。 ## 顶点需按同一方向依次排列,不可自交叉。 @export var polygon: PackedVector2Array = PackedVector2Array([ Vector2(0.0, -1.0), Vector2(1.0, 0.0), Vector2(0.0, 1.0), Vector2(-1.0, 0.0), ]) : set = set_polygon @export_group("Colors") @export var normal_color: Color = Color(0.24, 0.52, 0.90) @export var hover_color: Color = Color(0.34, 0.62, 1.00) @export var pressed_color: Color = Color(0.16, 0.40, 0.75) @export var disabled_color: Color = Color(0.35, 0.35, 0.38) @export var border_color: Color = Color(0.08, 0.12, 0.20, 0.75) @export_range(0.0, 16.0, 0.5) var border_width: float = 2.0 @export_group("Behavior") @export var disabled: bool = false : set = set_disabled var _hovered: bool = false var _held: bool = false var _local_points: PackedVector2Array = PackedVector2Array() var _points_dirty: bool = true func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP focus_mode = Control.FOCUS_ALL if not mouse_entered.is_connected(_on_mouse_entered): mouse_entered.connect(_on_mouse_entered) if not mouse_exited.is_connected(_on_mouse_exited): mouse_exited.connect(_on_mouse_exited) if not focus_entered.is_connected(queue_redraw): focus_entered.connect(queue_redraw) if not focus_exited.is_connected(queue_redraw): focus_exited.connect(queue_redraw) _refresh_cursor() func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED: _points_dirty = true queue_redraw() # ---------- 属性 setter ---------- func set_polygon(value: PackedVector2Array) -> void: polygon = value _points_dirty = true queue_redraw() func set_disabled(value: bool) -> void: disabled = value if disabled: _held = false _refresh_cursor() queue_redraw() func _refresh_cursor() -> void: mouse_default_cursor_shape = ( Control.CURSOR_ARROW if disabled else Control.CURSOR_POINTING_HAND ) func _on_mouse_entered() -> void: _hovered = true queue_redraw() func _on_mouse_exited() -> void: _hovered = false queue_redraw() # ---------- 几何计算 ---------- func _rebuild_points() -> void: _local_points = PackedVector2Array() _local_points.resize(polygon.size()) for i in polygon.size(): var p := polygon[i] _local_points[i] = Vector2( (p.x * 0.5 + 0.5) * size.x, (p.y * 0.5 + 0.5) * size.y ) _points_dirty = false func get_local_points() -> PackedVector2Array: if _points_dirty: _rebuild_points() return _local_points # ---------- 命中判定 ---------- func _has_point(point: Vector2) -> bool: if polygon.size() < 3: return false return Geometry2D.is_point_in_polygon(point, get_local_points()) # ---------- 绘制 ---------- func _draw() -> void: if polygon.size() < 3: return var pts := get_local_points() draw_colored_polygon(pts, _current_color()) if border_width > 0.0: var loop := pts.duplicate() loop.append(pts[0]) draw_polyline(loop, border_color, border_width, true) if has_focus(): var center := size * 0.5 var inner := PackedVector2Array() for p in pts: inner.append(center + (p - center) * 0.85) inner.append(inner[0]) draw_polyline(inner, Color(1, 1, 1, 0.65), 1.0, true) if Engine.is_editor_hint(): for p in pts: draw_circle(p, 3.0, Color(1.0, 0.45, 0.1, 0.9)) func _current_color() -> Color: if disabled: return disabled_color if _held and _hovered: return pressed_color if _hovered: return hover_color return normal_color # ---------- 输入 ---------- func _gui_input(event: InputEvent) -> void: if disabled: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if event.pressed: _held = true button_down.emit() elif _held: _held = false button_up.emit() if _has_point(get_local_mouse_position()): pressed.emit() accept_event() queue_redraw() return if event.is_action_pressed("ui_accept") and has_focus(): button_down.emit() pressed.emit() button_up.emit() accept_event()代码里_draw()的最后一段只在编辑器里跑,会在每个顶点画一个橙色小圆点,方便你在检查器里改数值的时候看形状对不对。发布版本不会执行,因为Engine.is_editor_hint()在运行时返回 false。
有个小细节值得单独说:has_focus()走的是整块矩形区域的焦点提示。这是_has_point管不到的部分——焦点框是引擎自己算的。如果你在意这个,可以干脆把focus_mode设成FOCUS_NONE,纯触摸设备上其实也无所谓。
4. 落地到实际场景:几种形状的调参与适配
代码写完只是开始,真正让它在项目里站住脚,还得处理形状设计、布局适配和不同输入方式的差异。
4.1 几种常见形状的顶点配置
顶点都写在 [-1, 1] 区间里,下面是几个可以直接抄的例子。
| 形状 | 顶点数组 | 说明 |
|---|---|---|
| 菱形 | (0,-1) (1,0) (0,1) (-1,0) | 默认值,技能格常用 |
| 正三角 | (0,-1) (0.87,0.5) (-0.87,0.5) | 宽度按 √3/2 折算,视觉正三角 |
| 正六边形 | 每 60° 取一点,半径 1 | 见下方代码 |
| 左箭头 | (-1,-1) (0,-1) (1,0) (0,1) (-1,1) | 五个顶点,播放/下一步用 |
| 圆角八边形 | 在正方形基础上各角切 0.3 | 比矩形柔和,比圆形省顶点 |
正六边形的顶点生成方式:
var hex := PackedVector2Array() for i in 6: var a := deg_to_rad(60.0 * i - 90.0) hex.append(Vector2(cos(a), sin(a)))减 90 度是为了让第一个顶点在正上方,视觉上更符合直觉。
实操心得:顶点数不要贪多。20 个顶点以内,命中判定和三角剖分的开销都可以忽略;上百个顶点的“伪圆形”没必要,直接用
draw_circle()配合半径判定反而又快又准。多边形按钮真正的价值在“留白大、形状有辨识度”的场景。
4.2 在容器与不同分辨率下怎么不出事
这个节点本质上是 Control,所以容器和锚点两套布局系统都能用。
丢进HBoxContainer的时候,容器只会分配一个矩形区域,你的多边形在这个矩形里按比例缩放。想让它在横向上撑满,设size_flags_horizontal = SIZE_EXPAND_FILL;想让形状保持宽高比,就把size_flags_vertical和水平方向设成一致的,然后自己算一个固定的custom_minimum_size。
分辨率适配这块要注意一点:归一化坐标意味着形状会跟着矩形一起被拉伸。如果按钮在 1920×1080 下是 200×200 的菱形,切到 21:9 的屏幕后被拉成 240×200,菱形就成了扁菱形。绝大多数情况下这可以接受,甚至正是你想要的——UI 元素随屏幕等比缩放本来就自然。但如果你的美术对形状比例有硬要求,就得在_rebuild_points()里做等比修正:取min(size.x, size.y)作为基准边长,再把点居中偏移。
func _rebuild_points_keep_aspect() -> void: var side := minf(size.x, size.y) var offset := (size - Vector2(side, side)) * 0.5 _local_points = PackedVector2Array() for p in polygon: _local_points.append(offset + Vector2( (p.x * 0.5 + 0.5) * side, (p.y * 0.5 + 0.5) * side )) _points_dirty = false代价是形状只占矩形的一部分,剩下的区域是纯空白——不过_has_point()用的也是同一套点,所以空白区域不会误触发,逻辑上是自洽的。
4.3 触摸屏上的额外考量
移动端的手指接触面积大概有 40 到 60 像素,比鼠标精确度差远了。异形按钮在触摸屏上会面临一个真实问题:形状边角处的点击很难命中。
我的做法是做一个“膨胀系数”。判定的时候不直接用原多边形,而是以形状中心为原点,把每个顶点向外扩个 8% 到 12% 再判:
func _has_point(point: Vector2) -> bool: if polygon.size() < 3: return false var pts := get_local_points() if touch_tolerance <= 0.0: return Geometry2D.is_point_in_polygon(point, pts) var center := size * 0.5 var inflated := PackedVector2Array() for p in pts: inflated.append(center + (p - center) * (1.0 + touch_tolerance)) return Geometry2D.is_point_in_polygon(point, inflated)把这个touch_tolerance做成@export,桌面端设 0,移动端设 0.1。膨胀后的判定区域会稍微溢出到图形外面,视觉上有轻微的不一致,但换来的是触摸命中率的大幅提升——这个取舍在真实项目里非常值。
注意:不要简单地给
size加内边距来做膨胀,那样形状本身也跟着变大了,视觉会走样。用中心缩放的方式,形状不动,只有判定区变大。
5. 常见问题与排查实录
这一章是我踩过的坑的合集。异形按钮的调试难点在于“看不见”——判定区在屏幕上是不可见的,出问题的时候只能靠推理。
5.1 点击完全没反应
按这个顺序排查,基本能定位。
第一,看mouse_filter。如果你的 PolygonButton 放在一个 Panel 或者 ColorRect 里,而那个父节点的mouse_filter是STOP(默认值),父节点会先把事件吃掉。解决办法是把父节点的mouse_filter改成PASS或IGNORE。
第二,看_has_point有没有真的被调用。加个print最直接。如果没有输出,要么是mouse_filter的问题,要么是控件size是(0, 0)——刚创建出来的 Control 尺寸默认是零,多边形一个顶点都算不出来,判定自然永远返回 false。手动设个size或者custom_minimum_size再看。
第三,检查方法签名。必须是func _has_point(point: Vector2) -> bool:,参数名可以换,但类型和返回值不能错。写成_has_point(point)不带类型标注也能跑,但如果拼写成了_has_point_position之类的就完全无效,而且 Godot 不会报错——它只当你写了个普通方法。
5.2 悬停高亮闪烁或者错位
闪烁通常意味着多边形自交叉。想象一个蝴蝶结形状:(0,-1) (1,1) (0,1) (-1,1)。第 2 和第 3 个顶点靠得太近,第 3 到第 4 条边折回来穿过了第 1 到第 2 条边。射线法在这种形状上会给出反直觉的结果——鼠标在图形中间晃,判定结果可能在 true 和 false 之间跳。
诊断方法很土但有效:把顶点顺序理一遍,画在纸上,看看边是不是两两不相交。或者临时把_draw()里的顶点圆点放大到 8 像素,用draw_string()把索引标上去,一眼就看出顺序对不对。
5.3 凹多边形渲染或判定出问题
draw_colored_polygon()对凹多边形的支持在绝大多数情况下是可靠的,但极少数情况下(顶点极多、非常扭曲)会出现填充错误。稳妥的替代方案是自己三角剖分:
var indices := Geometry2D.triangulate_polygon(pts) if indices.size() > 0: var colors := PackedColorArray() for i in pts.size(): colors.append(_current_color()) draw_polygon(pts, colors, PackedVector2Array(), null, indices)triangulate_polygon()返回的索引数组每三个一组,直接喂给draw_polygon()的第五个参数。如果它返回空数组,说明多边形非法(自交叉或者退化成线),这时候你就该回头检查顶点顺序了。
5.4 性能与缓存
前面提过脏标记缓存,这里说下为什么值得做。
我做过一个粗略的测试:一个 24 顶点的多边形按钮,鼠标在它上面快速画圈时,_has_point()的调用频率能到每秒上千次。每次重算 24 个点的坐标是 48 次乘加运算再加一次数组分配——单看微不足道,但一个界面放 20 个这样的按钮,累积起来就是每秒几十万次运算,而且还会产生大量临时数组给 GC 增加压力。加上缓存之后,这些开销全部消失。
另一件事:_draw()里的draw_polyline()如果开了抗锯齿(第五个参数传true),代价比不开高不少。如果一个界面有几十个异形按钮,可以考虑关掉抗锯齿,或者只在边框比较显眼的按钮上开。
5.5 问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 点击完全没反应 | mouse_filter被父节点吃掉 | 父节点改PASS或IGNORE |
| 点击完全没反应 | 控件size是 (0,0) | 设custom_minimum_size或进容器 |
| 点击完全没反应 | 方法名拼错 | 确认是_has_point(point: Vector2) -> bool |
| 空白角落也能点中 | _has_point没被重写成功 | 用print确认被调用 |
| 悬停高亮乱闪 | 多边形自交叉 | 重排顶点顺序,画图验证 |
| 拖出去松手也触发 | 没做抬起位置判定 | 加_has_point(get_local_mouse_position()) |
| 缩放后命中偏移 | 缓存没刷新 | 检查NOTIFICATION_RESIZED是否触发 |
| 凹多边形填充错乱 | 三角剖分失败 | 改用triangulate_polygon()+draw_polygon() |
| 移动端边角点不中 | 手指接触面积大 | 加touch_tolerance膨胀判定区 |
| 编辑器里看不到形状 | 脚本没加@tool | 首行加@tool并重新加载场景 |
实操心得:调试异形按钮的时候,临时把
_has_point()改一行,让它只在判定成功时queue_redraw()并在_draw()里画一个红色蒙版覆盖判定区。肉眼看着红块走一圈,命中范围对不对立刻就见分晓,比任何日志都直观。调完记得删掉。
6. 还能怎么往下扩展
基础版本跑通之后,有几个方向可以继续做深。
6.1 给多边形贴纹理
纯色多边形按钮在很多游戏里够用了,但如果美术给了一张六边形的金属边框贴图,你就得把纹理映射上去。
用draw_polygon()的 UV 版本:
func _draw_textured() -> void: var pts := get_local_points() var uvs := PackedVector2Array() for p in polygon: uvs.append(Vector2(p.x * 0.5 + 0.5, p.y * 0.5 + 0.5)) var colors := PackedColorArray() var tint := _current_color() for i in pts.size(): colors.append(tint) var indices := Geometry2D.triangulate_polygon(pts) draw_polygon(pts, colors, uvs, texture, indices)UV 的算法就是把归一化坐标从 [-1, 1] 映射到 [0, 1],纹理会被完整拉伸到控件的边界矩形上,然后由多边形裁剪掉多余部分。colors数组用来做整体色调调制——比如按下时把颜色压暗,高亮时提亮,只要把tint换成对应颜色就行,纹理本身不用变。
6.2 状态过渡用 Tween 而不是硬切
现在的代码里,颜色是从normal_color直接跳到hover_color的,硬切。做精致的 UI 时,用 Tween 补个 0.08 到 0.12 秒的过渡会舒服很多。
做法是维护一个_current_color变量,变更时杀掉旧 Tween、创建新 Tween:
var _display_color: Color var _color_tween: Tween func _animate_to(target: Color) -> void: if _color_tween and _color_tween.is_valid(): _color_tween.kill() _color_tween = create_tween() _color_tween.tween_property(self, "_display_color", target, 0.1) _color_tween.step_finished.connect(func(_s): queue_redraw())然后_draw()用_display_color而不是_current_color()。
还有一个视觉技巧:按下时把整个形状缩小一点点。因为_has_point()用的是get_local_points(),而它依赖size,所以你最好别直接动size(会触发缓存重建、还会影响布局)。更干净的做法是在_draw()里对点做一次缩放,不动实际尺寸:
var shrink := 0.94 if (_held and _hovered) else 1.0 var center := size * 0.5 var draw_pts := PackedVector2Array() for p in pts: draw_pts.append(center + (p - center) * shrink) draw_colored_polygon(draw_pts, _display_color)这样视觉上有按压反馈,但命中判定区始终稳定,不会因为动画产生抖动。
6.3 编辑器里可视化拖顶点
我自己的做法比较土但很省事:在_draw()里用@tool把顶点画出来(前面代码里已经有了),然后在检查器的polygon数组里直接改数值。改一个数,视口里的橙色小点立刻移动,形状实时更新。
如果你想要更爽的拖拽体验,可以写一个 EditorPlugin,在forward_canvas_gui_input()里捕获鼠标拖动,把位置换算回归一化坐标写回polygon。这属于锦上添花,项目里有一两个异形按钮的话,手改数值完全够用。
6.4 组合成更复杂的交互
多边形按钮做好之后,你会发现很多之前觉得麻烦的东西变简单了。
比如一个环形的技能冷却条,外圈是环形进度,内圈就是你的多边形按钮;比如地图上的不规则区域点击,每个区域的轮廓直接拿来当多边形顶点;再比如手绘风格的对话框选项,让美术画好形状轮廓,你只取顶点数据。
最后分享一个我用了很久的小技巧:建一个形状库脚本,把常用形状预定义成常量。
class_name PolygonShapes const DIAMOND := [Vector2(0,-1), Vector2(1,0), Vector2(0,1), Vector2(-1,0)] const HEX_FLAT := [...] const HEX_POINTY := [...]在检查器里复制粘贴数组比每次重新敲坐标快得多,而且能保证整个项目的形状风格统一。踩过几次“每个按钮顶点都差一点点、整体看起来歪歪扭扭”的坑之后,我现在所有项目都会先建这个库。