feat(ui): 40250 item tooltip parity (thinboard, palette, sockets, stats)

This commit is contained in:
shen
2026-09-19 17:57:35 -07:00
parent a834af873f
commit f22c0f8e31
6 changed files with 1024 additions and 21 deletions
+303
View File
@@ -0,0 +1,303 @@
# ItemTooltipView —— 40250 标准 ThinBoard 悬浮窗控件。
#
# 严格遵循 40250 uitooltip.py 规范:
# 1. 继承 ui.ThinBoard 视觉:九宫格古铜金属边框、半透明黑底、固定初始宽度 190px。
# 2. 所有文本水平居中(SetHorizontalAlignCenter),带黑色文字描边。
# 3. 严格的 40250 配色体系:
# - 普通装备浅金象牙白,蓝字/强化装备亮金色
# - 攻击力、防御力加算公式及绿色高亮
# - 固有词条绿色/负值红色,附加属性 1~5 薄荷青绿,6~7 浅薄荷绿
# - 适用职业([ 穿戴职业 ] 及职业列表,不符合角色职业时警示红)
# - 等级限制(不满足角色等级时警示红)
# 4. 魂石凹槽图文解析:银色凹槽贴图 + 32x32 魂石图标 + 魂石名称与第一条属性描述。
# 5. 浮动跟随鼠标或在格子旁边即时展现(0 延迟)。
extends MarginContainer
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const UiKit = preload("res://ui_kit.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
var _vbox: VBoxContainer
var _bg: NinePatchRect
var _assets_root := ""
var _proto_node: Node = null
var _item_list: RefCounted = null
var _tooltip_builder: RefCounted
var follow_mouse := true
func _init() -> void:
name = "ItemTooltipView"
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(190, 0)
add_theme_constant_override("margin_left", 8)
add_theme_constant_override("margin_right", 8)
add_theme_constant_override("margin_top", 10)
add_theme_constant_override("margin_bottom", 10)
func setup(assets_root: String, proto: Node = null, ilist: RefCounted = null, builder: RefCounted = null) -> void:
_assets_root = assets_root
_proto_node = proto
_item_list = ilist
_tooltip_builder = builder if builder != null else ItemTooltip.new()
if builder == null and not _assets_root.is_empty():
_tooltip_builder.setup(_assets_root, "en", _proto_node)
_rebuild_background()
func _rebuild_background() -> void:
if _bg != null and is_instance_valid(_bg):
_bg.queue_free()
_bg = null
if not _assets_root.is_empty():
_bg = UiKit.board(_assets_root, "thinboard")
if _bg:
_bg.name = "ThinBoardBg"
_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
_bg.show_behind_parent = true
_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_bg)
move_child(_bg, 0)
func show_item(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {},
player_ctx: Dictionary = {}) -> void:
if _tooltip_builder == null:
_tooltip_builder = ItemTooltip.new()
if not _assets_root.is_empty():
_tooltip_builder.setup(_assets_root, "en", _proto_node)
var model: Dictionary = _tooltip_builder.build_model(vnum, count, proto_data, instance_data, player_ctx)
populate_from_model(model)
visible = true
if is_inside_tree():
update_position()
func hide_tooltip() -> void:
visible = false
func populate_from_model(model: Dictionary) -> void:
if _vbox == null:
_vbox = VBoxContainer.new()
_vbox.name = "ContentVBox"
_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
_vbox.add_theme_constant_override("separation", 2)
add_child(_vbox)
for c in _vbox.get_children():
c.queue_free()
if model.is_empty():
return
# 1. 标题 (Item Title)
var title_text: String = model.get("title", "")
var title_col: Color = model.get("title_color", ItemTooltip.TITLE_COLOR)
_vbox.add_child(_make_label(title_text, title_col, 12, true))
# 2. 数量 (Count > 1)
var count: int = int(model.get("count", 1))
if count > 1:
_vbox.add_child(_make_label("数量:%d" % count, ItemTooltip.NORMAL_COLOR, 11))
# 3. 描述 (Description)
var desc: String = model.get("desc", "")
if not desc.is_empty():
_vbox.add_child(_make_label(desc, ItemTooltip.NORMAL_COLOR, 10, false, true))
# 4. 概要/条件 (Summary)
var summary: String = model.get("summary", "")
if not summary.is_empty():
_vbox.add_child(_make_label(summary, ItemTooltip.CONDITION_COLOR, 10, false, true))
# 5. 限制要求 (Limits)
var limits: Array = model.get("limits", [])
if not limits.is_empty():
_vbox.add_child(_make_spacer(3))
for lim in limits:
_vbox.add_child(_make_label(lim.get("text", ""), lim.get("color", ItemTooltip.NORMAL_COLOR), 11))
# 6. 基础战斗数值 (Stats: Attack Power, Defense)
var stats: Array = model.get("stats", [])
if not stats.is_empty():
_vbox.add_child(_make_spacer(3))
for s in stats:
_vbox.add_child(_make_label(s.get("text", ""), s.get("color", ItemTooltip.POSITIVE_COLOR), 11))
# 7. 固有属性 (Proto Applies)
var applies: Array = model.get("applies", [])
for a in applies:
_vbox.add_child(_make_label(a.get("text", ""), a.get("color", ItemTooltip.POSITIVE_COLOR), 11))
# 8. 附加属性 (Instance Attrs)
var attrs: Array = model.get("attrs", [])
for a in attrs:
_vbox.add_child(_make_label(a.get("text", ""), a.get("color", ItemTooltip.SPECIAL_POSITIVE_COLOR), 11))
# 9. 穿戴职业 (Wearable Info)
var wearable: Dictionary = model.get("wearable", {})
if not wearable.is_empty():
_vbox.add_child(_make_spacer(3))
var hdr: String = wearable.get("header", "[ 穿戴职业 ]")
_vbox.add_child(_make_label(hdr, ItemTooltip.NORMAL_COLOR, 11))
var jobs_text: String = wearable.get("jobs", "")
if not jobs_text.is_empty():
var j_col: Color = wearable.get("jobs_color", ItemTooltip.NORMAL_COLOR)
_vbox.add_child(_make_label(jobs_text, j_col, 11))
var gender_text: String = wearable.get("gender", "")
if not gender_text.is_empty():
var g_col: Color = wearable.get("gender_color", ItemTooltip.NORMAL_COLOR)
_vbox.add_child(_make_label(gender_text, g_col, 11))
# 10. 魂石凹槽 (Sockets)
var sockets: Array = model.get("sockets", [])
if not sockets.is_empty():
_vbox.add_child(_make_spacer(3))
for sock in sockets:
_vbox.add_child(_make_socket_row(sock))
# 11. 价格 (Price)
var price: int = int(model.get("price", 0))
if price > 0:
_vbox.add_child(_make_spacer(3))
_vbox.add_child(_make_label("价格:%s 金币" % _format_price(price), ItemTooltip.PRICE_COLOR, 11))
# 12. 限时剩余时间 (Realtime)
var realtime: String = model.get("realtime", "")
if not realtime.is_empty():
_vbox.add_child(_make_spacer(2))
_vbox.add_child(_make_label("剩余时间:%s" % realtime, ItemTooltip.NORMAL_COLOR, 11))
func _make_label(text: String, color: Color, font_size := 11, bold := false, autowrap := false) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", font_size)
lbl.add_theme_color_override("font_color", color)
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95))
lbl.add_theme_constant_override("outline_size", 2)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
if autowrap:
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lbl.custom_minimum_size.x = 174
return lbl
func _make_spacer(height: int) -> Control:
var sp := Control.new()
sp.custom_minimum_size = Vector2(0, height)
sp.mouse_filter = Control.MOUSE_FILTER_IGNORE
return sp
func _make_socket_row(sock: Dictionary) -> Control:
var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 6)
row.alignment = BoxContainer.ALIGNMENT_CENTER
var icon_box := Control.new()
icon_box.custom_minimum_size = Vector2(36, 36)
icon_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
var slot_tex: Texture2D = null
if not _assets_root.is_empty():
slot_tex = UiAssets.load_tex(_assets_root, "d:/ymir work/ui/game/windows/metin_slot_silver.sub")
if slot_tex:
var slot_rect := TextureRect.new()
slot_rect.texture = slot_tex
slot_rect.custom_minimum_size = Vector2(36, 36)
slot_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
icon_box.add_child(slot_rect)
var stone_vnum: int = int(sock.get("vnum", 0))
if stone_vnum > 1:
var icon_tex := _resolve_item_icon(stone_vnum)
if icon_tex:
var icon_rect := TextureRect.new()
icon_rect.texture = icon_tex
icon_rect.position = Vector2(2, 2)
icon_rect.size = Vector2(32, 32)
icon_rect.custom_minimum_size = Vector2(32, 32)
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_rect.stretch_mode = TextureRect.STRETCH_SCALE
icon_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
icon_box.add_child(icon_rect)
row.add_child(icon_box)
var text_box := VBoxContainer.new()
text_box.alignment = BoxContainer.ALIGNMENT_CENTER
text_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
text_box.add_theme_constant_override("separation", 1)
var name_lbl := Label.new()
name_lbl.text = String(sock.get("name", ""))
name_lbl.add_theme_font_size_override("font_size", 10)
name_lbl.add_theme_color_override("font_color", sock.get("name_color", ItemTooltip.NORMAL_COLOR))
name_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95))
name_lbl.add_theme_constant_override("outline_size", 2)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
text_box.add_child(name_lbl)
var affect_str: String = String(sock.get("affect", ""))
if not affect_str.is_empty():
var aff_lbl := Label.new()
aff_lbl.text = affect_str
aff_lbl.add_theme_font_size_override("font_size", 10)
aff_lbl.add_theme_color_override("font_color", sock.get("affect_color", ItemTooltip.POSITIVE_COLOR))
aff_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95))
aff_lbl.add_theme_constant_override("outline_size", 2)
aff_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
text_box.add_child(aff_lbl)
row.add_child(text_box)
return row
func _resolve_item_icon(vnum: int) -> Texture2D:
if _item_list and _item_list.has_method("icon"):
var p: String = _item_list.icon(vnum)
if not p.is_empty() and not _assets_root.is_empty():
var tex := UiAssets.load_tex(_assets_root, p)
if tex:
return tex
if not _assets_root.is_empty():
var rel := "icon/item/%d.tga" % vnum
var cand := UiAssets.load_tex(_assets_root, rel)
if cand:
return cand
return null
func _format_price(val: int) -> String:
var s := str(val)
var res := ""
var cnt := 0
for i in range(s.length() - 1, -1, -1):
res = s[i] + res
cnt += 1
if cnt % 3 == 0 and i > 0:
res = "," + res
return res
func _process(_delta: float) -> void:
if visible and follow_mouse:
update_position()
func update_position(screen_size: Vector2 = Vector2.ZERO) -> void:
if not visible:
return
var mouse := get_global_mouse_position()
var w := size.x if size.x > 0 else custom_minimum_size.x
var h := size.y
if screen_size == Vector2.ZERO:
var vp := get_viewport()
if vp:
screen_size = vp.get_visible_rect().size
else:
screen_size = Vector2(1920, 1080)
var x := mouse.x - w / 2.0
var y := 0.0
if mouse.y < screen_size.y - 320.0:
y = mouse.y + 24.0
else:
y = mouse.y - h - 16.0
x = clampf(x, 8.0, maxf(8.0, screen_size.x - w - 8.0))
y = clampf(y, 8.0, maxf(8.0, screen_size.y - h - 8.0))
global_position = Vector2(x, y)