feat(ui): 40250 item tooltip parity (thinboard, palette, sockets, stats)
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user