417 lines
18 KiB
GDScript
417 lines
18 KiB
GDScript
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)
|