【Godot4.3】自定义简易菜单栏节点ETDMenuBar
概述
Godot中的菜单创建是一个复杂的灾难性工作,往往无从下手,我也是不止一次尝试简化菜单的创建。
从自己去年的发明“简易树形数据”用于简化Tree
控件获得灵感,于是尝试编写了用于表示菜单数据的EasyMenuData
类,以及对应的纯文本数据格式和对应的MenuBar
控件扩展ETDMenuBar
。
于是乎,你只需要在创建菜单栏时,添加一个ETDMenuBar
控件,并为其data
属性指定符合规定的简易数据,以及图标集。便可以轻松设计和获得复杂层次的菜单,以用于你创建的Godot桌面程序。
得到的效果:
原理
MenuBar
生成菜单的原理,可以参看我之前写的《【Godot4.2】菜单相关控件和节点完全解析》- 简易树形数据解析的原理,可以参看我之前写的《【Godot4.2】EasyTreeData通用解析》
EasyMenuData
- 我创建了一个名叫
EasyMenuData
的类,可以解析形如下的数据,并通过其to_menu()
方法返回一个PopUpMenu
实例
文件
新建 | 0 | -1 | Ctrl+S
打开 | 1 | 0
最近 | 1 | 1
文件1
文件2
文件3
========
关闭 | -1 | -1 | Ctrl+Q
其格式采用:
文本 | 图标索引 | 复选状态标记 | 快捷键
其中:
|
是分隔符,顺序和意义对应,可以忽略后面的设定,但顺序依然不能改变,符合要求的形式举例如下:
文本
文本 | 图标索引
文本 | 图标索引 | 复选状态标记
文本 | 图标索引 | 复选状态标记 | 快捷键
- 图标索引:为负数或超出
icons
属性提供的图标集范围时,将不显示 - 复选状态标记:为负数则不显示复选框,大于等于
0
,显示复选框,0
不选中,大于0
选中 - 快捷键:只需要指定以
+
号连接的字符串形式即可,不区分大小写
ETDMenuBar
- 我创建了一个扩展的
MenuBar
类型ETDMenuBar
,其data
属性接收如下形式的数据:
文件
新建 | 0 | -1 | Ctrl+S
打开 | 1 | 0
最近 | 1 | 1
文件1
文件2
文件3
---
关闭 | -1 | -1 | Ctrl+Q
========
编辑
撤销
重做
---
清空
也就是在多个一级菜单之间用========
进行分隔。
- 运行场景后,会自动根据
data
属性给定的数据,生成菜单栏。
EasyMenuData源码
# ========================================================
# 名称:EasyMenuData
# 类型:类
# 简介:基于ETD构造PopupMenu的类
# 作者:巽星石
# Godot版本:v4.3.stable.steam [77dcf97d8]
# 创建时间:2025年2月21日20:11:05
# 最后修改时间:2025年3月1日22:24:40
# ========================================================
class_name EasyMenuData
var _root:EasyMenuItem
# 获取生成的菜单
func to_menu() -> PopupMenu:
return _root.to_menu()
# 获取根节点信息
func get_root_data() -> String:
return _root.data if _root else ""
# ============================ 内部类 ============================
# 单项数据
class EasyMenuItem:
# ------- 图标集
var icons:Array[Texture2D]
var icon_width:float # 图标最大宽度
# ------- 菜单项信息
var label:String # 菜单项文本
var icon:Texture2D # 菜单项图标
var shortcut:Shortcut # 菜单项快捷键
var show_checkbox:bool # 是否显示复选
var checked:bool # 是否选中
# ------- 节点信息
var data:String # 原始未解析的文本
var deep:int # 节点深度
var parent:EasyMenuItem # 父节点
var children:Array[EasyMenuItem] # 子节点集合
func _init(_data:String,_deep:int,_icons:Array[Texture2D],_icon_width:float) -> void:
data = _data
deep = _deep
icons = _icons
icon_width = _icon_width
parse_data()
children = []
# 解析传入的数据
func parse_data():
if data != "" or data != "---":
var ds = data.split(" | ",false)
match ds.size():
1:
label = data
2:
label = ds[0]
var idx = int(ds[1])
icon = get_idx_icon(idx)
3:
label = ds[0]
var idx = int(ds[1])
icon = get_idx_icon(idx)
show_checkbox = is_show_box(int(ds[2]))
checked = int(ds[2])
4:
label = ds[0]
var idx = int(ds[1])
icon = get_idx_icon(idx)
show_checkbox = is_show_box(int(ds[2]))
checked = int(ds[2])
shortcut = new_shortcut(ds[3])
# 是否显示复选框
func is_show_box(tag:int) -> bool:
return true if tag >= 0 else false # 仅在大于等于0显示
# 获取对应下标的图标
func get_idx_icon(idx:int) -> Texture2D:
var icn:Texture2D
if icons:
if idx in range(icons.size()):
icn = icons[idx]
return icn
# 按给定字符串创建并返回一个快捷键
func new_shortcut(key_str:String) -> Shortcut:
var sc:Shortcut
if key_str != "":
sc = Shortcut.new()
var event := InputEventKey.new()
event.pressed = true
var keys = key_str.split("+",false)
for key in keys:
match key.to_lower(): # 小写
"ctrl":
event.ctrl_pressed = true
"alt":
event.alt_pressed = true
"shift":
event.shift_pressed = true
_:
event.keycode = OS.find_keycode_from_string(key)
sc.events.append(event)
return sc
func get_path():
var path = ""
path += label
if parent:
path = parent.get_path() + "/" + path
return path
# 转为菜单项或子菜单
func to_menu(menu:PopupMenu = null):
var root_menu:PopupMenu
if menu == null: # 根节点
menu = PopupMenu.new()
if data != "":
menu.name = label # 名称与菜单项一致
root_menu = menu
for child in children:
child.to_menu(root_menu)
return root_menu
else:
# 添加菜单项
if data == "---": # 分割线
menu.add_separator()
else:
menu.add_item(label) # 文本
var last = menu.item_count-1
# 将路径以元数据形式存储
menu.set_item_metadata(last,get_path())
# 图标
if icon:
menu.set_item_icon(last,icon)
menu.set_item_icon_max_width(last,icon_width) # 设定图标宽度
# 复选框
menu.set_item_as_checkable(last,show_checkbox)
if show_checkbox:
menu.set_item_checked(last,checked)
# 快捷键
if shortcut:
menu.set_item_shortcut(last,shortcut,true)
# 如果有子节点
if children.size()>0:
# 创建子PopupMenu
var sub_menu = PopupMenu.new()
sub_menu.name = label # 名称与菜单项一致
menu.add_child(sub_menu)
# 指定为当前项的子菜单
menu.set_item_submenu_node(menu.item_count-1,sub_menu)
for child in children:
child.to_menu(sub_menu)
# ============================ 方法 ============================
# 创建并返回一个EasyTreeItem实例
func create_item(text:String,icons:Array[Texture2D],icon_width:float,p_node:EasyMenuItem = null) -> EasyMenuItem:
var itm = EasyMenuItem.new(text,0,icons,icon_width)
if _root:
if p_node:
itm.deep = p_node.deep + 1
itm.parent = p_node
p_node.children.append(itm)
else:
itm.deep = _root.deep + 1
itm.parent = _root
_root.children.append(itm)
else:
_root = itm
return itm
# 由多行文本创建
static func new_with_etd_str(etd_str:String,icons:Array[Texture2D],icon_width:float) ->EasyMenuData:
var edt = EasyMenuData.new()
var items = etd_str.split("\n",false) # 将ETD字符串按行切分为字符串数组
var pre_itm:EasyMenuItem # 记录前一项
var p_itm:EasyMenuItem = null # 记录父节点
# 遍历每行数据
for i in range(items.size()):
if items[i].strip_edges() != "":
# 第1行直接添加为Tree控件的根节点(跳过下面if部分)
# 从第2行开始比较当前行与前一行的缩进深度(也就是\t的数目)
if i > 0:
var d_deep = deep(items[i-1]) - deep(items[i]) # 与前一行数据的缩进差值
match d_deep:
-1: # 缩进比前一项深:
p_itm = pre_itm # 将前一项作为父节点
0: # 缩进深度与前一项一样:
p_itm = pre_itm.parent # 父节点与前一项父节点一样
_:
if d_deep>0: # 缩进比前一项浅
# 通过缩进差值计算获得合适的父节点
p_itm = pre_itm
for j in range(d_deep+1):
p_itm = p_itm.parent
# 实际创建和添加TreeItem到Tree控件
var itm:EasyMenuItem = edt.create_item(items[i].replace("\t",""),icons,icon_width,p_itm)
pre_itm = itm # 将当前项记录为前一项
return edt
# 返回字符串的Tab缩进值
static func deep(sttr:String):
return sttr.rstrip(" ").count("\t")
ETDMenuBar源码
# ========================================================
# 名称:ETDMenuBar
# 类型:自定义控件(MenuBar扩展)
# 简介:基于EasyMenuData构造的MenuBar
# 作者:巽星石
# Godot版本:v4.3.stable.steam [77dcf97d8]
# 创建时间:2025年2月21日20:49:44
# 最后修改时间:2025年3月1日22:26
# ========================================================
class_name ETDMenuBar extends MenuBar
signal item_click(path:String) # 菜单项被点击
# ================================ 参数 ================================
@export_multiline var data:String ## 菜单栏简易数据
@export var icons:Array[Texture2D] ## 图标集合
@export var icon_width:float = 16.0 ## 图标最大宽度
func _ready() -> void:
reload()
# ============================ 方法 ============================
# 重新加载
func reload():
clear()
for dt in data.split("=".repeat(8),false):
var emd = EasyMenuData.new_with_etd_str(dt,icons,icon_width)
var menu = emd.to_menu()
set_connects(menu)
add_child(menu)
# 递归形式为所有层级的菜单处理菜单项点击的信号处理
func set_connects(menu:PopupMenu):
# 统一设置字号
var font_size = get("theme_override_font_sizes/font_size")
menu.set("theme_override_font_sizes/font_size",font_size)
menu.connect("index_pressed",func(index:int):
emit_signal("item_click",menu.get_item_metadata(index))
)
for sub_menu in menu.get_children():
if sub_menu is PopupMenu:
set_connects(sub_menu)
# 清空子节点
func clear():
for child in get_children():
child.queue_free()
菜单项点击的处理
只需要链接item_click
信号,就可以处理菜单项的点击了。
信号参数path
会返回菜单项的路径,类似于下面这样:
文件/最近/文件2
文件/关闭
这样通过写一个math
分支语句来匹配菜单项的路径,就可以实现具体菜单项的功能。
美化
通过外面套一个PanelContainer
,并且设定flat
,可以快速的美化菜单栏。
而且我设定所有的子菜单都保持于MenuBar
一致的字号,只要在MenuBar
上设置一次就可以了。
总结
EasyMenuData
其实对应的是单个PopupMenu
的描述和生成,可以用于普通菜单生成,也可用于弹出菜单的设计- 而
ETDMenuBar
,则是对应菜单栏生成。