class_name UiBuilder extends RefCounted # UI 构建工具。 # # 这个类只负责“怎么画一个通用控件”,不持有任何业务状态。 # 目的: # - 把重复的 TextureRect/NinePatch/Label/Button 组合从各面板里抽掉。 # - 保持所有弹窗、Tab、物品格、按钮动效的一致性。 # - 避免每个面板都手写 mouse_filter、字体、outline、press scale 这些细节。 const NumberFormatterScript := preload("res://scripts/core/number_formatter.gd") const I18nScript := preload("res://scripts/core/i18n.gd") const UiMotionScript := preload("res://scripts/ui/common/ui_motion.gd") const PANEL_SHELL_PATH := "res://assets/replacement/panels/panel_shell.png" const PANEL_CONTENT_PATH := "res://assets/replacement/panels/panel_content.png" const PANEL_SHELL_MARGINS := Vector4(96.0, 184.0, 84.0, 124.0) const PANEL_CONTENT_MARGINS := Vector4(56.0, 64.0, 56.0, 58.0) const NON_TITLE_GLYPH_SPACING := -1 const NON_TITLE_SPACE_SPACING := -2 # _owner 用于创建 Tween 和播放按钮按压动效;真正的 UI 节点仍由调用方持有。 var _owner: Control var _panel_screen_size := Vector2(640.0, 960.0) var _font: Font var _title_font: Font var _button_font: Font var _body_font: Font var _texture_provider # 关闭弹窗、物品图标解析都由 GameScene 注入,UiBuilder 不直接知道业务对象。 var _close_modal_callback: Callable var _get_item_icon_texture: Callable var _get_item_icon_id: Callable func setup(options: Dictionary) -> void: _owner = options.get("owner") as Control _panel_screen_size = options.get("panel_screen_size", _panel_screen_size) var font_options = options.get("fonts", {}) if font_options is Dictionary: _title_font = font_options.get("title") as Font _button_font = font_options.get("button") as Font _body_font = font_options.get("body") as Font var fallback_font := options.get("font") as Font _body_font = _body_font if _body_font != null else fallback_font _button_font = _button_font if _button_font != null else _body_font _title_font = _title_font if _title_font != null else _button_font _title_font = _font_variation(_title_font, 0, 0) _button_font = _font_variation(_button_font, NON_TITLE_GLYPH_SPACING, NON_TITLE_SPACE_SPACING) _body_font = _font_variation(_body_font, NON_TITLE_GLYPH_SPACING, NON_TITLE_SPACE_SPACING) _font = _body_font _texture_provider = options.get("texture_provider") _close_modal_callback = options.get("close_modal_callback", Callable()) _get_item_icon_texture = options.get("get_item_icon_texture", Callable()) _get_item_icon_id = options.get("get_item_icon_id", Callable()) func create_common_panel_shell(panel_name: String, panel_size: Vector2, icon_key: String, icon_atlas: String, title_key: String, title_atlas: String) -> Control: # 旧 Egret 面板大量用图集里的 icon/title。这里封装区域裁剪,再交给通用 shell。 return create_panel_shell( panel_name, panel_size, _texture_provider.atlas_texture(icon_atlas, _texture_provider.region_for(icon_atlas, icon_key)), _texture_provider.atlas_texture(title_atlas, _texture_provider.region_for(title_atlas, title_key)) ) func create_panel_shell(panel_name: String, panel_size: Vector2, icon: Texture2D, title: Texture2D) -> Control: # 弹窗根节点固定为 640x960 设计稿坐标,内部面板居中。 # 子面板只需要传自己的视觉尺寸和标题素材,避免重复处理遮罩、背景、关闭按钮。 var root := create_modal_root(panel_name) var bg_position := Vector2((_panel_screen_size.x - panel_size.x) * 0.5, (_panel_screen_size.y - panel_size.y) * 0.5) # Egret scale9Grid 对应 Godot NinePatchRect,必须保留边角不被拉伸。 add_panel_shell_background(root, "PanelBg", bg_position, panel_size) # 标题和图标沿用旧版绝对位置,不按 panel_size 居中,否则和原 UI 会有肉眼偏差。 var title_pos := Vector2(331.0 - title.get_width() * 0.5, bg_position.y - 18.0) add_texture(root, "PanelTitle", title, title_pos, title.get_size()) var icon_pos := Vector2(142.5 - icon.get_width() * 0.5, bg_position.y + 66.0 - icon.get_height()) add_texture(root, "PanelIcon", icon, icon_pos, icon.get_size()) add_texture_button(root, "PanelClose", _texture_provider.common("c_btn1"), Vector2(220.0, bg_position.y + panel_size.y - 63.0), Vector2(200.0, 70.0), "离开", _close_modal_callback, 36) return root func create_modal_root(panel_name: String) -> Control: var root := Control.new() root.name = panel_name root.size = _panel_screen_size root.mouse_filter = Control.MOUSE_FILTER_STOP var mask := Button.new() # 使用 Button 作为遮罩可以吞掉底层点击;样式置空后视觉上不可见。 mask.name = "ModalMask" mask.set_anchors_preset(Control.PRESET_FULL_RECT) mask.text = "" mask.focus_mode = Control.FOCUS_NONE UiMotionScript.apply_empty_button_style(mask) root.add_child(mask) return root func create_legacy_panel_shell(panel_name: String, bg_position: Vector2, bg_size: Vector2, icon: Texture2D, title: Texture2D, icon_position: Vector2, title_position: Vector2, close_position: Vector2, close_size := Vector2(200.0, 70.0), close_font_size := 36) -> Control: var root := create_modal_root(panel_name) add_scale9_patch(root, "PanelBg", _texture_provider.common("c_panel"), bg_position, bg_size, Rect2(122.0, 179.0, 5.0, 4.0)) if title != null: add_texture(root, "PanelTitle", title, title_position, title.get_size()) if icon != null: add_texture(root, "PanelIcon", icon, icon_position, icon.get_size()) add_texture_button(root, "PanelClose", _texture_provider.common("c_btn1"), close_position, close_size, "离开", _close_modal_callback, close_font_size) return root func add_panel_shell_background(parent: Control, name: String, position: Vector2, size: Vector2) -> NinePatchRect: return add_nine_patch(parent, name, _texture_provider.standalone(PANEL_SHELL_PATH, _texture_provider.common("c_panel")), position, size, PANEL_SHELL_MARGINS) func add_content_panel_background(parent: Control, name: String, position: Vector2, size: Vector2) -> NinePatchRect: return add_nine_patch(parent, name, _texture_provider.standalone(PANEL_CONTENT_PATH, _texture_provider.common("inner_page")), position, size, PANEL_CONTENT_MARGINS) func create_tab_bar(parent: Control, name: String, labels: Array, position: Vector2, selected_index: int, callback: Callable, base_height := 40.0, max_width := 460.0) -> Control: # TabBar 是动态宽度:中文标签、英文替换和选中态高度都可能不同。 # 子 Tab 通过 meta 保存 index/text,update_tab_bar 只根据 meta 重排,不依赖外部数组。 var group := Control.new() group.name = name group.position = position group.size = Vector2(max_width, base_height + 7.0) group.set_meta("base_height", base_height) parent.add_child(group) for index in range(labels.size()): var source_text := str(labels[index]) var text := I18nScript.t(source_text) var item := Control.new() item.name = "Tab%s" % index item.set_meta("tab_index", index) item.set_meta("tab_source_text", source_text) item.mouse_filter = Control.MOUSE_FILTER_STOP group.add_child(item) var bg := NinePatchRect.new() # Tab 背景同样使用九宫格,长文本时不会拉坏圆角和边框。 bg.name = "Bg" bg.patch_margin_left = 40 bg.patch_margin_top = 20 bg.patch_margin_right = 2 bg.patch_margin_bottom = 2 bg.mouse_filter = Control.MOUSE_FILTER_IGNORE item.add_child(bg) var label := Label.new() label.name = "Label" label.text = text label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER item.add_child(label) var hit := Button.new() # hit 覆盖整个 tab;视觉节点 mouse_filter_ignore,保证点击命中稳定。 hit.name = "Hit" hit.text = "" hit.focus_mode = Control.FOCUS_NONE UiMotionScript.apply_empty_button_style(hit) hit.pressed.connect(callback.bind(index)) item.add_child(hit) update_tab_bar(group, selected_index) return group func configure_vertical_scroll(scroll: ScrollContainer) -> void: # 面板列表统一关闭横向滚动,避免手机端误拖出横向偏移。 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO func update_tab_bar(group: Control, selected_index: int) -> void: # 每次切 tab 都重新计算宽高,保证选中态抬高但不挤压后续控件。 if group == null: return var base_height := float(group.get_meta("base_height", 40.0)) var gap := 5.0 var tabs: Array[Dictionary] = [] var desired_total := 0.0 var min_total := 0.0 for child in group.get_children(): if not child is Control or not child.has_meta("tab_index"): continue var item := child as Control var index := int(item.get_meta("tab_index", 0)) var text := I18nScript.t(str(item.get_meta("tab_source_text", ""))) var selected := index == selected_index var min_width := 105.0 if selected else 88.0 var desired_width := maxf(min_width, _measure_text_width(text, 26, _button_font) + 44.0) tabs.append({ "item": item, "index": index, "text": text, "selected": selected, "min_width": min_width, "width": desired_width, }) desired_total += desired_width min_total += min_width if tabs.is_empty(): return var gaps_total := gap * maxf(float(tabs.size() - 1), 0.0) var available_width := maxf(group.size.x - gaps_total, 0.0) if desired_total > available_width: var shrink_capacity := maxf(desired_total - min_total, 0.0) if available_width < min_total and available_width > 0.0: var even_width := available_width / float(tabs.size()) for tab in tabs: tab["width"] = even_width elif shrink_capacity > 0.0: var target_total := available_width var shrink_needed := desired_total - target_total for tab in tabs: var width := float(tab["width"]) var min_width := float(tab["min_width"]) var capacity := maxf(width - min_width, 0.0) tab["width"] = width - shrink_needed * (capacity / shrink_capacity) elif available_width > 0.0: var even_width := available_width / float(tabs.size()) for tab in tabs: tab["width"] = even_width var current_x := 0.0 for tab in tabs: var item := tab["item"] as Control var text := str(tab["text"]) var selected := bool(tab["selected"]) var width := float(tab["width"]) var height := base_height + (7.0 if selected else 0.0) item.position = Vector2(current_x, -4.0 if selected else -2.0) item.size = Vector2(width, height) var bg := item.get_node_or_null("Bg") as NinePatchRect if bg != null: bg.texture = _texture_provider.common("tab_selected" if selected else "tab_unselecte") bg.size = item.size var label := item.get_node_or_null("Label") as Label if label != null: label.text = text label.size = item.size style_button_label(label, 26, Color.WHITE if selected else Color.html("#764d24"), 3 if selected else 0, Color.html("#6b4c26")) var hit := item.get_node_or_null("Hit") as Button if hit != null: hit.size = item.size current_x += width + gap func add_texture(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, stretch_mode := TextureRect.STRETCH_SCALE) -> TextureRect: # 所有 TextureRect 默认不参与鼠标命中,交互统一交给显式 Button hit 区。 var node := TextureRect.new() node.name = name node.expand_mode = TextureRect.EXPAND_IGNORE_SIZE node.stretch_mode = stretch_mode node.texture = texture node.position = position node.size = size node.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(node) return node func add_nine_patch(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, margins: Vector4) -> NinePatchRect: # margins 使用 Vector4(left, top, right, bottom),直接映射 Egret scale9Grid 的边距语义。 var node := NinePatchRect.new() node.name = name node.texture = texture node.position = position node.size = size node.patch_margin_left = int(margins.x) node.patch_margin_top = int(margins.y) node.patch_margin_right = int(margins.z) node.patch_margin_bottom = int(margins.w) node.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(node) return node func add_scale9_patch(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, scale9_grid: Rect2) -> NinePatchRect: if texture == null: return add_nine_patch(parent, name, texture, position, size, Vector4.ZERO) var right := maxf(float(texture.get_width()) - scale9_grid.position.x - scale9_grid.size.x, 0.0) var bottom := maxf(float(texture.get_height()) - scale9_grid.position.y - scale9_grid.size.y, 0.0) return add_nine_patch(parent, name, texture, position, size, Vector4(scale9_grid.position.x, scale9_grid.position.y, right, bottom)) func add_panel_label(parent: Control, name: String, text: String, position: Vector2, size: Vector2, font_size: int, color: Color, outline: int, outline_color: Color, align := HORIZONTAL_ALIGNMENT_CENTER) -> Label: # 面板内文本默认居中垂直对齐并裁剪,防止后端长字符串覆盖相邻按钮。 var label := Label.new() label.name = name label.text = I18nScript.t(text) label.position = position label.size = size label.horizontal_alignment = align label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.clip_text = true if _should_use_title_font(name, font_size): style_title_label(label, font_size, color, outline, outline_color) else: style_label(label, font_size, color, outline, outline_color) parent.add_child(label) return label func add_texture_button(parent: Control, name: String, texture: Texture2D, position: Vector2, size: Vector2, text: String, callback: Callable, font_size: int) -> Control: # 不是 Godot 原生 TextureButton:旧 UI 需要“贴图 + 文本 + 透明命中层 + 按压缩放”的组合。 # 返回 holder,调用方可用 set_texture_button_enabled 同时控制贴图和 hit.disabled。 var holder := Control.new() holder.name = name holder.position = position holder.size = size holder.pivot_offset = size * 0.5 holder.mouse_filter = Control.MOUSE_FILTER_STOP parent.add_child(holder) add_texture(holder, "Art", texture, Vector2.ZERO, size) if not text.is_empty(): var label := add_panel_label(holder, "Label", text, Vector2.ZERO, size, font_size, Color.html("#59120c"), 0, Color.TRANSPARENT) style_button_label(label, font_size, Color.html("#59120c"), 0, Color.TRANSPARENT) var hit := Button.new() # Button 放最后,确保它覆盖 Art/Label;Art/Label 忽略鼠标,命中不受层级影响。 hit.name = "Hit" hit.position = Vector2.ZERO hit.size = size hit.text = "" hit.focus_mode = Control.FOCUS_NONE UiMotionScript.apply_empty_button_style(hit) hit.pressed.connect(callback) hit.button_down.connect(func() -> void: UiMotionScript.play_press_scale(_owner, holder)) hit.button_up.connect(func() -> void: UiMotionScript.play_release_scale(_owner, holder)) holder.add_child(hit) return holder func add_item_slot(parent: Control, name: String, item_id: int, count: int, position: Vector2, size := Vector2(76.0, 76.0)) -> Control: # 物品格统一解析 item_id -> icon_id -> Texture2D。 # 有些面板传入的是 icon_id,有些传入 item_id,因此保留 get_item_icon_id 回调做兼容。 var holder := Control.new() holder.name = name holder.position = position holder.size = size holder.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(holder) add_texture(holder, "Bg", _texture_provider.common("item_bg"), Vector2.ZERO, size) var icon_id := item_id if _get_item_icon_id.is_valid(): icon_id = int(_get_item_icon_id.call(item_id)) var icon_texture: Texture2D = _texture_provider.common("item_bg") if _texture_provider != null and _texture_provider.has_method("item_icon"): icon_texture = _texture_provider.item_icon(icon_id) elif _get_item_icon_texture.is_valid(): icon_texture = _get_item_icon_texture.call(icon_id) as Texture2D add_texture(holder, "Icon", icon_texture, Vector2(7.0, 6.0), size - Vector2(14.0, 18.0), TextureRect.STRETCH_KEEP_ASPECT_CENTERED) add_panel_label(holder, "Num", "x%s" % NumberFormatterScript.compact(count), Vector2(4.0, size.y - 25.0), Vector2(size.x - 8.0, 24.0), 18, Color.html("#b36011"), 1, Color.html("#ffe59d"), HORIZONTAL_ALIGNMENT_RIGHT) return holder func style_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void: # 字体和描边必须统一,否则中文标签和原版按钮文字会出现明显视觉差异。 _style_label_with_font(label, _body_font, size, color, outline, outline_color) func style_title_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void: _style_label_with_font(label, _title_font, size, color, outline, outline_color) func style_button_label(label: Label, size: int, color: Color, outline: int, outline_color: Color) -> void: _style_label_with_font(label, _button_font, size, color, outline, outline_color) func _style_label_with_font(label: Label, font: Font, size: int, color: Color, outline: int, outline_color: Color) -> void: if label == null: return var target_font := font if font != null else _font if target_font != null: label.add_theme_font_override("font", target_font) label.add_theme_font_size_override("font_size", size) label.add_theme_color_override("font_color", color) label.add_theme_constant_override("outline_size", outline) label.add_theme_color_override("font_outline_color", outline_color) func _measure_text_width(text: String, font_size: int, font: Font = null) -> float: var target_font := font if font != null else _body_font if target_font == null: return float(text.length() * font_size) * 0.55 return target_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x func _font_variation(base_font: Font, glyph_spacing: int, space_spacing: int) -> Font: if base_font == null: return null var variation := FontVariation.new() variation.base_font = base_font variation.spacing_glyph = glyph_spacing variation.spacing_space = space_spacing return variation func _should_use_title_font(label_name: String, font_size: int) -> bool: if font_size < 24: return false var lowered := label_name.to_lower() return lowered.contains("title") func apply_empty_button_style(button: Button) -> void: UiMotionScript.apply_empty_button_style(button)