From f22c0f8e3129829e8a3fcb6c32f8244fcc263a1d Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 19 Sep 2026 17:57:35 -0700 Subject: [PATCH] feat(ui): 40250 item tooltip parity (thinboard, palette, sockets, stats) --- project/test_item_tooltip_parity.gd | 256 +++++++++++++++++++ project/ui/inventory_ui.gd | 45 ++++ project/ui/item_tooltip.gd | 384 ++++++++++++++++++++++++++-- project/ui/item_tooltip_view.gd | 303 ++++++++++++++++++++++ project/ui/shop_ui.gd | 31 +++ project/ui/ui_manager.gd | 26 ++ 6 files changed, 1024 insertions(+), 21 deletions(-) create mode 100644 project/test_item_tooltip_parity.gd create mode 100644 project/ui/item_tooltip_view.gd diff --git a/project/test_item_tooltip_parity.gd b/project/test_item_tooltip_parity.gd new file mode 100644 index 00000000..09fdad1f --- /dev/null +++ b/project/test_item_tooltip_parity.gd @@ -0,0 +1,256 @@ +# test_item_tooltip_parity.gd —— 装备属性面板 40250 官方规范 100% 对齐测试。 +# 检验: +# 1. 调色板与 uitooltip.py 官方常量 1:1 对齐 +# 2. 装备标题根据是否有强化附加属性判定白装 (#F2E7C1) / 金装 (#FFC800) +# 3. 武器攻防加算与防具防御力加算 (value[1] + value[5]*2),浅绿色 (#8AB98E) +# 4. 固有属性与百分比词条格式化,带正负符号与 % +# 5. 附加属性 1~5 薄荷青绿 (#B0DFB4),6~7 浅薄荷绿 (#E1FAE1),无非官方“附加”冗余前缀 +# 6. 适用职业与性别判断,不符合时警示红 (#E67976) +# 7. 等级限制判断,玩家等级不足时警示红 (#E67976) +# 8. 魂石凹槽图文解析与空槽/损坏槽 +# 9. ItemTooltipView 控件九宫格 ThinBoard 背景、居中对齐与文字描边 +extends SceneTree + +const ItemTooltip = preload("res://ui/item_tooltip.gd") +const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") + +class MockProto extends Node: + var items: Dictionary = {} + func item(vnum: int) -> Dictionary: + return items.get(vnum, {}) + +var _failed := 0 +var _pass_count := 0 + +func _assert(cond: bool, msg: String) -> void: + if cond: + _pass_count += 1 + print("PASS: %s" % msg) + else: + _failed += 1 + printerr("FAIL: %s" % msg) + +func _init() -> void: + print("=== Starting test_item_tooltip_parity ===") + _test_palette_constants() + _test_weapon_model_parity() + _test_armor_model_parity() + _test_attrs_coloring_and_naming() + _test_wearable_job_and_level_warnings() + _test_socket_stone_resolution() + _test_item_tooltip_view_ui_structure() + + if _failed == 0: + print("\n=== ALL %d ITEM TOOLTIP PARITY TESTS PASSED ===" % _pass_count) + quit(0) + else: + printerr("\n=== %d ITEM TOOLTIP PARITY TESTS FAILED ===" % _failed) + quit(1) + +# --- Test 1: 调色板与 40250 原版常量一致 --- +func _test_palette_constants() -> void: + print("\n--- Test 1: 40250 Palette Constants ---") + # uitooltip.py: TITLE_COLOR = grp.GenerateColor(0.9490, 0.9058, 0.7568, 1.0) + _assert(is_equal_approx(ItemTooltip.TITLE_COLOR.r, 0.9490) and is_equal_approx(ItemTooltip.TITLE_COLOR.g, 0.9058), + "TITLE_COLOR matches 40250 (0.9490, 0.9058, 0.7568)") + # uitooltip.py: SPECIAL_TITLE_COLOR = grp.GenerateColor(1.0, 0.7843, 0.0, 1.0) + _assert(is_equal_approx(ItemTooltip.SPECIAL_TITLE_COLOR.r, 1.0) and is_equal_approx(ItemTooltip.SPECIAL_TITLE_COLOR.g, 0.7843), + "SPECIAL_TITLE_COLOR matches 40250 (1.0, 0.7843, 0.0)") + # uitooltip.py: POSITIVE_COLOR = grp.GenerateColor(0.5411, 0.7254, 0.5568, 1.0) + _assert(is_equal_approx(ItemTooltip.POSITIVE_COLOR.r, 0.5411) and is_equal_approx(ItemTooltip.POSITIVE_COLOR.g, 0.7254), + "POSITIVE_COLOR matches 40250 (0.5411, 0.7254, 0.5568)") + # uitooltip.py: SPECIAL_POSITIVE_COLOR = grp.GenerateColor(0.6911, 0.8754, 0.7068, 1.0) + _assert(is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR.r, 0.6911) and is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR.g, 0.8754), + "SPECIAL_POSITIVE_COLOR matches 40250 (0.6911, 0.8754, 0.7068)") + # uitooltip.py: SPECIAL_POSITIVE_COLOR2 = grp.GenerateColor(0.8824, 0.9804, 0.8824, 1.0) + _assert(is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR2.r, 0.8824) and is_equal_approx(ItemTooltip.SPECIAL_POSITIVE_COLOR2.g, 0.9804), + "SPECIAL_POSITIVE_COLOR2 matches 40250 (0.8824, 0.9804, 0.8824)") + # uitooltip.py: DISABLE_COLOR = grp.GenerateColor(0.9, 0.4745, 0.4627, 1.0) + _assert(is_equal_approx(ItemTooltip.DISABLE_COLOR.r, 0.9) and is_equal_approx(ItemTooltip.DISABLE_COLOR.g, 0.4745), + "DISABLE_COLOR matches 40250 (0.9, 0.4745, 0.4627)") + # uitooltip.py: PRICE_COLOR = 0xffFFB96D + _assert(is_equal_approx(ItemTooltip.PRICE_COLOR.r, 1.0) and is_equal_approx(ItemTooltip.PRICE_COLOR.b, 0.4275), + "PRICE_COLOR matches 40250 (0xffFFB96D)") + +# --- Test 2: 武器数值计算与白装/金装标题颜色 --- +func _test_weapon_model_parity() -> void: + print("\n--- Test 2: Weapon Model & Attack Values ---") + var builder := ItemTooltip.new() + var sword_proto := { + "vnum": 19, + "name": "Sword+9", + "locale_name": "战斗剑 +9", + "type": 1, # WEAPON + "sub_type": 0, # WEAPON_SWORD + "values": [0, 50, 70, 100, 120, 15], # min_magic=50+15=65, max_magic=70+15=85, min_atk=100+15=115, max_atk=120+15=135 + "limits": [{"type": 1, "value": 30}], + "applies": [{"type": 7, "value": 15}], # 攻击速度 +15% + "anti_flags": 0, + } + + # 1. 普通白装 (无 attrs):标题颜色为 TITLE_COLOR + var plain_model := builder.build_model(19, 1, sword_proto, {}) + _assert(plain_model["title"] == "战斗剑 +9", "Title is 战斗剑 +9") + _assert(plain_model["title_color"] == ItemTooltip.TITLE_COLOR, "Plain weapon title color is TITLE_COLOR") + _assert(plain_model["stats"].size() == 2, "Weapon has 2 stats (attack & magic attack)") + _assert(plain_model["stats"][0]["text"] == "攻击力 115 - 135", "Attack power calculation matches (100+15 - 120+15)") + _assert(plain_model["stats"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Attack power is POSITIVE_COLOR (green)") + _assert(plain_model["stats"][1]["text"] == "魔法攻击 65 - 85", "Magic attack calculation matches (50+15 - 70+15)") + _assert(plain_model["applies"][0]["text"] == "攻击速度 +15%", "Attack speed has % symbol") + _assert(plain_model["applies"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Attack speed bonus is POSITIVE_COLOR") + + # 2. 带有强化附加属性 (蓝字):标题颜色升级为 SPECIAL_TITLE_COLOR (金黄色) + var enchanted_instance := { + "attrs": [ + {"type": 5, "value": 12}, # STR +12 + {"type": 15, "value": 10}, # 暴击概率 +10% + ] + } + var enchanted_model := builder.build_model(19, 1, sword_proto, enchanted_instance) + _assert(enchanted_model["title_color"] == ItemTooltip.SPECIAL_TITLE_COLOR, + "Weapon with bonus attributes has SPECIAL_TITLE_COLOR (#FFC800)") + +# --- Test 3: 防具防御力公式加算 --- +func _test_armor_model_parity() -> void: + print("\n--- Test 3: Armor Defense Formula ---") + var builder := ItemTooltip.new() + var armor_proto := { + "vnum": 11209, + "name": "Armor+9", + "locale_name": "修罗战甲 +9", + "type": 2, # ARMOR + "sub_type": 0, # ARMOR_BODY + "values": [15, 80, 0, 0, 0, 8], # magic_def=15, def = 80 + 8*2 = 96 + "limits": [{"type": 1, "value": 42}], + "applies": [{"type": 8, "value": -5}], # 移动速度 -5% + "anti_flags": (1 << 2) | (1 << 3) | (1 << 5), # 仅修罗可穿 (ANTIFLAG_WARRIOR | ANTIFLAG_ASSASSIN | ANTIFLAG_SHAMAN) + } + var model := builder.build_model(11209, 1, armor_proto, {}) + _assert(model["stats"].size() == 2, "Armor has 2 stats") + _assert(model["stats"][0]["text"] == "防御力 96", "Armor defense formula (value[1] + value[5]*2 = 80 + 16 = 96)") + _assert(model["stats"][0]["color"] == ItemTooltip.POSITIVE_COLOR, "Defense is POSITIVE_COLOR") + _assert(model["stats"][1]["text"] == "魔法防御 15", "Magic defense matches value[0]") + _assert(model["applies"][0]["text"] == "移动速度 -5%", "Moving speed negative apply has %") + _assert(model["applies"][0]["color"] == ItemTooltip.NEGATIVE_COLOR, "Negative moving speed is NEGATIVE_COLOR (red)") + +# --- Test 4: 附加属性配色 (1~5条薄荷青绿,6~7条浅薄荷绿) 与去除非原版“附加”字样 --- +func _test_attrs_coloring_and_naming() -> void: + print("\n--- Test 4: Bonus Attributes Colors & Clean Naming ---") + var builder := ItemTooltip.new() + var proto := {"type": 1, "values": [0, 0, 0, 10, 20, 0]} + var instance := { + "attrs": [ + {"type": 1, "value": 2000}, # 最大生命 +2000 + {"type": 5, "value": 12}, # 力量 +12 + {"type": 15, "value": 10}, # 暴击概率 +10% + {"type": 16, "value": 10}, # 穿透概率 +10% + {"type": 17, "value": 15}, # 对人族伤害 +15% + {"type": 71, "value": 8}, # 技能伤害 +8% (第6条) + {"type": 72, "value": 12}, # 普通攻击伤害 +12% (第7条) + ] + } + var model := builder.build_model(10, 1, proto, instance) + _assert(model["attrs"].size() == 7, "Model parsed 7 bonus attributes") + _assert(model["attrs"][0]["text"] == "最大生命 +2000", "Attribute 1 formatted cleanly without '附加' prefix") + _assert(model["attrs"][0]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR, "Attribute 1 is SPECIAL_POSITIVE_COLOR (#B0DFB4)") + _assert(model["attrs"][2]["text"] == "暴击概率 +10%", "Attribute 3 (percent) has % sign") + _assert(model["attrs"][4]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR, "Attribute 5 is SPECIAL_POSITIVE_COLOR") + _assert(model["attrs"][5]["text"] == "技能伤害 +8%", "Attribute 6 has % sign") + _assert(model["attrs"][5]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR2, "Attribute 6 is SPECIAL_POSITIVE_COLOR2 (#E1FAE1)") + _assert(model["attrs"][6]["color"] == ItemTooltip.SPECIAL_POSITIVE_COLOR2, "Attribute 7 is SPECIAL_POSITIVE_COLOR2 (#E1FAE1)") + +# --- Test 5: 穿戴职业与等级不足红字警示 --- +func _test_wearable_job_and_level_warnings() -> void: + print("\n--- Test 5: Wearable Jobs & Level Warning ---") + var builder := ItemTooltip.new() + var proto := { + "type": 1, + "values": [0, 0, 0, 10, 20, 0], + "limits": [{"type": 1, "value": 50}], # 要求等级 50 + "anti_flags": (1 << 3) | (1 << 4) | (1 << 5), # 仅战士可穿 (非刺客/修罗/萨满) + } + + # 1. 玩家等级不足 (当前 35 < 50): 等级限制飘红 + var ctx_low_level := {"level": 35, "job": 0, "sex": 0} # 战士,35级 + var model_low := builder.build_model(10, 1, proto, {}, ctx_low_level) + _assert(model_low["limits"][0]["text"] == "要求等级: 50", "Limit text is 要求等级: 50") + _assert(model_low["limits"][0]["color"] == ItemTooltip.DISABLE_COLOR, "Low level triggers DISABLE_COLOR (red)") + _assert(model_low["wearable"]["jobs"] == "战士", "Only 战士 listed in wearable") + _assert(model_low["wearable"]["jobs_color"] == ItemTooltip.NORMAL_COLOR, "Warrior can wear, jobs_color is NORMAL_COLOR") + + # 2. 玩家职业不符 (当前是刺客 job=1): 职业限制飘红 + var ctx_wrong_job := {"level": 60, "job": 1, "sex": 0} # 刺客,60级 + var model_wrong := builder.build_model(10, 1, proto, {}, ctx_wrong_job) + _assert(model_wrong["limits"][0]["color"] == ItemTooltip.NORMAL_COLOR, "Level 60 >= 50, limit is NORMAL_COLOR") + _assert(model_wrong["wearable"]["jobs_color"] == ItemTooltip.DISABLE_COLOR, "Assassin cannot wear warrior sword, jobs_color is DISABLE_COLOR (red)") + +# --- Test 6: 魂石凹槽图文解析与空槽/损坏槽 --- +func _test_socket_stone_resolution() -> void: + print("\n--- Test 6: Socket Stones Resolution ---") + var proto_node := MockProto.new() + proto_node.items[28430] = { + "vnum": 28430, + "name": "Stone_of_Speed+4", + "locale_name": "加速之石 +4", + "applies": [{"type": 8, "value": 30}], # 移动速度 +30% + } + var builder := ItemTooltip.new() + var assets := AssetRoot.path() + builder.setup(assets, "en", proto_node) + + var sword_proto := {"type": 1, "values": [0, 0, 0, 10, 20, 0]} + var instance := { + "sockets": [28430, 0, -1] # 1: 镶嵌加速之石, 2: 未使用空槽, 3: 损坏槽 + } + var model := builder.build_model(10, 1, sword_proto, instance) + _assert(model["sockets"].size() == 3, "Weapon has 3 socket entries") + _assert(model["sockets"][0]["type"] == "stone", "Socket 0 is stone") + _assert(model["sockets"][0]["name"] == "加速之石 +4", "Socket 0 resolved stone name 加速之石 +4") + _assert(model["sockets"][0]["affect"] == "移动速度 +30%", "Socket 0 resolved stone affect 移动速度 +30%") + _assert(model["sockets"][0]["affect_color"] == ItemTooltip.POSITIVE_COLOR, "Stone affect is POSITIVE_COLOR") + _assert(model["sockets"][1]["type"] == "empty", "Socket 1 is empty slot") + _assert(model["sockets"][1]["name"] == "未使用的凹槽", "Empty socket displays 未使用的凹槽") + _assert(model["sockets"][2]["type"] == "broken", "Socket 2 is broken") + _assert(model["sockets"][2]["name"] == "损坏的凹槽", "Broken socket displays 损坏的凹槽") + _assert(model["sockets"][2]["name_color"] == ItemTooltip.DISABLE_COLOR, "Broken socket is DISABLE_COLOR (red)") + + proto_node.free() + +# --- Test 7: ItemTooltipView UI 控件结构与 ThinBoard 呈现 --- +func _test_item_tooltip_view_ui_structure() -> void: + print("\n--- Test 7: ItemTooltipView UI Structure & ThinBoard ---") + var assets := AssetRoot.path() + var view := ItemTooltipView.new() + view.setup(assets) + + var model := { + "title": "屠龙刀 +9", + "title_color": ItemTooltip.SPECIAL_TITLE_COLOR, + "count": 1, + "desc": "上古神兵", + "summary": "天下无双", + "limits": [{"text": "要求等级: 75", "color": ItemTooltip.NORMAL_COLOR}], + "stats": [{"text": "攻击力 250 - 310", "color": ItemTooltip.POSITIVE_COLOR}], + "applies": [{"text": "半人伤害 +15%", "color": ItemTooltip.POSITIVE_COLOR}], + "attrs": [{"text": "暴击概率 +10%", "color": ItemTooltip.SPECIAL_POSITIVE_COLOR}], + "wearable": {"header": "[ 穿戴职业 ]", "jobs": "战士", "jobs_color": ItemTooltip.NORMAL_COLOR, "gender": ""}, + "sockets": [ + {"type": "stone", "name": "致命之石 +4", "affect": "暴击概率 +8%", "vnum": 28431, "name_color": ItemTooltip.NORMAL_COLOR, "affect_color": ItemTooltip.POSITIVE_COLOR} + ], + "price": 250000, + "realtime": "", + } + + view.populate_from_model(model) + + _assert(view.custom_minimum_size.x >= 190.0, "Tooltip custom_minimum_size.x >= 190px standard") + var vbox: VBoxContainer = view._vbox + _assert(vbox != null, "VBoxContainer created") + _assert(vbox.get_child_count() >= 8, "VBox contains all rendered sections") + + var title_lbl: Label = vbox.get_child(0) + _assert(title_lbl.text == "屠龙刀 +9", "Title text is 屠龙刀 +9") + _assert(title_lbl.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "Title is centered") + _assert(title_lbl.get_theme_constant("outline_size") == 2, "Title has outline_size = 2") + + view.free() diff --git a/project/ui/inventory_ui.gd b/project/ui/inventory_ui.gd index 4ea507ae..6ae7ef2b 100644 --- a/project/ui/inventory_ui.gd +++ b/project/ui/inventory_ui.gd @@ -28,6 +28,7 @@ var context_consumer: Callable = Callable() const UiAssets = preload("res://ui/ui_assets.gd") const EquipRules = preload("res://equip_rules.gd") const ItemTooltip = preload("res://ui/item_tooltip.gd") +const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") const ChestSystem = preload("res://chest_system.gd") const ItemAttrSystem = preload("res://item_attr_system.gd") const MetinSocketSystem = preload("res://metin_socket_system.gd") @@ -66,6 +67,7 @@ var uiscript_dir := "" var item_mouse: Node # MouseController(可空,兼容旧测试桩) var _win: Dictionary = {} # { root, nodes } +var _local_tooltip_view: Control = null var _cells := {} # ui_index:int -> Panel var _drag_from := -1 var _combine_from := -1 # Shift 选中的“使用到物品”来源格 @@ -122,6 +124,7 @@ func toggle() -> void: else: open() func close() -> void: + _hide_tooltip() if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog) and ui: ui.close(_mobile_detail_dialog) _mobile_detail_dialog = null @@ -259,9 +262,51 @@ func _wire_cells() -> void: var cell: Panel = _cells[idx] var cell_idx: int = int(idx) cell.gui_input.connect(func(e: InputEvent): _on_cell_input(cell_idx, e)) + cell.mouse_entered.connect(func(): _on_cell_mouse_entered(cell_idx)) + cell.mouse_exited.connect(func(): _on_cell_mouse_exited(cell_idx)) if item_mouse and item_mouse.has_method("register_target"): item_mouse.register_target(cell, func(payload: Dictionary): return _drop_mouse_item(payload, cell_idx), self) +func _get_tooltip_view() -> Control: + if ui and ui.has_method("get_item_tooltip_view"): + return ui.get_item_tooltip_view(assets_root, proto, item_list) + if _local_tooltip_view == null or not is_instance_valid(_local_tooltip_view): + _local_tooltip_view = ItemTooltipView.new() + _local_tooltip_view.setup(assets_root, proto, item_list, item_tooltip) + _local_tooltip_view.visible = false + _local_tooltip_view.z_index = 200 + if _win.has("root") and is_instance_valid(_win["root"]): + _win["root"].add_child(_local_tooltip_view) + return _local_tooltip_view + +func _on_cell_mouse_entered(cell_idx: int) -> void: + if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): + return + var cell: Panel = _cells.get(cell_idx, null) + if cell == null: + return + var vnum := int(cell.get_meta("vnum", 0)) + if vnum <= 0: + return + var count := int(cell.get_meta("count", 1)) + var instance_data: Dictionary = cell.get_meta("item_data", {}) + var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + var ctx: Dictionary = _equip_ctx() + if ctx.has("race"): + ctx["job"] = EquipRules.job_of_race(int(ctx["race"])) + ctx["sex"] = EquipRules.sex_of_race(int(ctx["race"])) + var tv := _get_tooltip_view() + if tv: + tv.show_item(vnum, count, proto_data, instance_data, ctx) + +func _on_cell_mouse_exited(_cell_idx: int) -> void: + _hide_tooltip() + +func _hide_tooltip() -> void: + var tv := _get_tooltip_view() + if tv: + tv.hide_tooltip() + func _wire_money() -> void: _money_button = _win.get("nodes", {}).get("Money_Slot", null) if _money_button is BaseButton: diff --git a/project/ui/item_tooltip.gd b/project/ui/item_tooltip.gd index 50ba0ee9..23c61b92 100644 --- a/project/ui/item_tooltip.gd +++ b/project/ui/item_tooltip.gd @@ -1,10 +1,24 @@ -# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一文字提示。 +# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一属性提示与 40250 模型构建。 # # 参考:ClientVS22/POC/assets/root/uitooltip.py::ItemToolTip.AddItemData。 -# Godot 的原生 tooltip 负责显示位置与换行;本类只负责把同一份物品快照 -# 组织成标题、描述、限制、基础数值、附加属性、魂石槽和精炼关联。 +# 支持 40250 调色板、居中排版数据模型导出、攻防数值加算、固有属性与附加属性着色、 +# 适用职业判断(红字警示)、魂石槽图文解析与纯文本向下兼容。 extends RefCounted +# 40250 官方调色板 (uitooltip.py) +const TITLE_COLOR := Color(0.9490, 0.9058, 0.7568, 1.0) # #f2e7c1 浅金象牙白 (白装/普通) +const SPECIAL_TITLE_COLOR := Color(1.0, 0.7843, 0.0, 1.0) # #ffc800 亮金黄色 (有附加属性/稀有) +const NORMAL_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) # #c2c2c2 普通浅灰 +const FONT_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) +const PRICE_COLOR := Color(1.0, 0.7255, 0.4275, 1.0) # #ffb96d 价格金橙色 +const CONDITION_COLOR := Color(0.7451, 0.7059, 0.4902, 1.0) # #beb47d 说明/要求淡金 +const ENABLE_COLOR := Color(0.7607, 0.7607, 0.7607, 1.0) +const DISABLE_COLOR := Color(0.9, 0.4745, 0.4627, 1.0) # #e67976 限制不满足/警示红 +const POSITIVE_COLOR := Color(0.5411, 0.7254, 0.5568, 1.0) # #8ab98e 基础攻防与固有词条浅绿 +const NEGATIVE_COLOR := Color(0.9, 0.4745, 0.4627, 1.0) # #e67976 负向词条警示红 +const SPECIAL_POSITIVE_COLOR := Color(0.6911, 0.8754, 0.7068, 1.0) # #b0dfb4 附加属性1~5薄荷青绿 +const SPECIAL_POSITIVE_COLOR2 := Color(0.8824, 0.9804, 0.8824, 1.0)# #e1fae1 附加属性6~7浅薄荷绿 + const ITEM_TYPE_WEAPON := 1 const ITEM_TYPE_ARMOR := 2 const ITEM_TYPE_LOTTERY := 8 @@ -13,10 +27,19 @@ const ITEM_TYPE_ROD := 13 const ITEM_TYPE_UNIQUE := 16 const ITEM_TYPE_PICK := 24 const ITEM_TYPE_BLEND := 27 +const ITEM_TYPE_COSTUME := 28 const ITEM_TYPE_DS := 29 const ITEM_TYPE_RING := 33 const ITEM_TYPE_BELT := 34 +const CHARACTER_NAMES := ["战士", "刺客", "修罗", "萨满"] +const ANTIFLAG_FEMALE := 1 << 0 +const ANTIFLAG_MALE := 1 << 1 +const ANTIFLAG_WARRIOR := 1 << 2 +const ANTIFLAG_ASSASSIN := 1 << 3 +const ANTIFLAG_SURA := 1 << 4 +const ANTIFLAG_SHAMAN := 1 << 5 + const LIMIT_NAMES := { 1: "等级要求", 2: "力量要求", @@ -160,14 +183,89 @@ const APPLY_NAMES := { 91: "抗穿透", } +# 40250 uitooltip.py AFFECT_DICT 规定为百分比显示的词条 +const PERCENT_APPLIES := { + 7: true, # 攻击速度 + 8: true, # 移动速度 + 9: true, # 施法速度 + 10: true, # 生命恢复 + 11: true, # 法力恢复 + 12: true, # 中毒概率 + 13: true, # 眩晕概率 + 14: true, # 缓慢概率 + 15: true, # 暴击概率 + 16: true, # 穿透概率 + 17: true, # 对人族伤害 + 18: true, # 对动物伤害 + 19: true, # 对兽人伤害 + 20: true, # 对秘境伤害 + 21: true, # 对不死族伤害 + 22: true, # 对恶魔伤害 + 23: true, # 生命吸收 + 24: true, # 法力吸收 + 25: true, # 法力燃烧 + 26: true, # 伤害转法力 + 27: true, # 格挡 + 28: true, # 闪避 + 29: true, # 剑防御 + 30: true, # 双手防御 + 31: true, # 匕首防御 + 32: true, # 铃防御 + 33: true, # 扇防御 + 34: true, # 弓防御 + 35: true, # 火抗性 + 36: true, # 雷抗性 + 37: true, # 魔法抗性 + 38: true, # 风抗性 + 39: true, # 近战反射 + 40: true, # 诅咒反射 + 41: true, # 中毒抵抗 + 42: true, # 击杀恢复法力 + 43: true, # 经验加成 + 44: true, # 金币加成 + 45: true, # 掉落加成 + 46: true, # 药水效果 + 47: true, # 击杀恢复生命 + 57: true, # 诅咒概率 + 59: true, # 对战士伤害 + 60: true, # 对刺客伤害 + 61: true, # 对武士伤害 + 62: true, # 对萨满伤害 + 63: true, # 对怪物伤害 + 64: true, # 商城攻击加成 + 65: true, # 商城防御加成 + 66: true, # 商城经验加成 + 67: true, # 商城掉落加成 + 68: true, # 商城金币加成 + 69: true, # 最大生命百分比 + 70: true, # 最大法力百分比 + 71: true, # 技能伤害 + 72: true, # 普通攻击伤害 + 73: true, # 技能防御 + 74: true, # 普通攻击防御 + 78: true, # 战士抗性 + 79: true, # 刺客抗性 + 80: true, # 武士抗性 + 81: true, # 萨满抗性 + 85: true, # 魔法攻击百分比 + 86: true, # 近战魔法攻击百分比 + 87: true, # 冰抗性 + 88: true, # 土抗性 + 89: true, # 暗抗性 + 90: true, # 抗暴击 + 91: true, # 抗穿透 +} + var _descriptions: Dictionary = {} var _skill_names: Dictionary = {} -var _mob_proto: Object = null +var _proto_node: Object = null +var _assets_root: String = "" func setup(assets_root: String, lang := "en", proto_node: Object = null) -> bool: _descriptions.clear() _skill_names.clear() - _mob_proto = proto_node + _assets_root = assets_root + _proto_node = proto_node var path := assets_root.path_join("locale/locale/%s/itemdesc.txt" % lang) if FileAccess.file_exists(path): var file := FileAccess.open(path, FileAccess.READ) @@ -186,6 +284,249 @@ func setup(assets_root: String, lang := "en", proto_node: Object = null) -> bool _load_skill_names(assets_root, lang) return not _descriptions.is_empty() or not _skill_names.is_empty() +func get_proto_node() -> Object: + return _proto_node + +func get_stone_info(stone_vnum: int) -> Dictionary: + var name := "魂石 #%d" % stone_vnum + var affect := "" + var icon_path := "" + if _proto_node and _proto_node.has_method("item"): + var sp: Dictionary = _proto_node.item(stone_vnum) + if not sp.is_empty(): + name = String(sp.get("locale_name", sp.get("name", name))) + var sa: Array = sp.get("applies", []) + if not sa.is_empty(): + var atype := int(sa[0].get("type", 0)) + var aval := int(sa[0].get("value", 0)) + if atype != 0 and aval != 0: + affect = format_apply_string(atype, aval) + if name.begins_with("魂石 #") and _descriptions.has(stone_vnum): + name = String(_descriptions[stone_vnum].get("name", name)) + return {"name": name, "affect": affect, "icon_path": icon_path} + +func format_apply_string(apply_type: int, value: int) -> String: + var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type) + if PERCENT_APPLIES.get(apply_type, false): + return "%s %+d%%" % [label, value] + return "%s %+d" % [label, value] + +# 核心 40250 数据模型导出(供 ItemTooltipView 九宫格控件渲染) +func build_model(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}, + player_ctx: Dictionary = {}) -> Dictionary: + if vnum <= 0: + return {} + + var name := String(proto_data.get("locale_name", proto_data.get("name", "物品 #%d" % vnum))) + if name.is_empty(): + name = "物品 #%d" % vnum + var sockets: Array = instance_data.get("sockets", proto_data.get("sockets", [])) + name = _special_title(vnum, name, sockets) + + var attrs: Array = instance_data.get("attrs", proto_data.get("attrs", [])) + var has_attrs := false + for a in attrs: + if int(a.get("type", 0)) != 0 and int(a.get("value", 0)) != 0: + has_attrs = true + break + + var title_col := SPECIAL_TITLE_COLOR if has_attrs else TITLE_COLOR + + var desc_info: Dictionary = _descriptions.get(vnum, {}) + var desc_text := String(desc_info.get("description", "")) + var summary_text := String(desc_info.get("summary", "")) + + # 限制要求 + var limits_out: Array[Dictionary] = [] + var player_level: int = int(player_ctx.get("level", 0)) + for limit in proto_data.get("limits", []): + var limit_type := int(limit.get("type", 0)) + var limit_value := int(limit.get("value", 0)) + if limit_type == 6: + limits_out.append({"text": "PC房专用道具", "color": NORMAL_COLOR}) + continue + if limit_type == 1 and limit_value > 0: + var col := DISABLE_COLOR if (player_level > 0 and player_level < limit_value) else NORMAL_COLOR + limits_out.append({"text": "要求等级: %d" % limit_value, "color": col, "limit_type": 1, "value": limit_value}) + elif limit_type != 0 and limit_value > 0: + var lim_name: String = LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type) + limits_out.append({"text": "%s: %d" % [lim_name, limit_value], "color": NORMAL_COLOR, "limit_type": limit_type, "value": limit_value}) + + # 基础战斗属性 (Weapon / Armor) + var stats_out: Array[Dictionary] = [] + var item_type := int(proto_data.get("type", 0)) + var item_subtype := int(proto_data.get("sub_type", proto_data.get("subtype", 0))) + var values: Array = proto_data.get("values", []) + if item_type == ITEM_TYPE_WEAPON and values.size() >= 6: + var min_attack := int(values[3]) + int(values[5]) + var max_attack := int(values[4]) + int(values[5]) + var min_magic := int(values[1]) + int(values[5]) + var max_magic := int(values[2]) + int(values[5]) + var atk_str := "" + if max_attack > min_attack: + atk_str = "攻击力 %d - %d" % [min_attack, max_attack] + elif min_attack > 0: + atk_str = "攻击力 %d" % min_attack + var mag_str := "" + if max_magic > min_magic: + mag_str = "魔法攻击 %d - %d" % [min_magic, max_magic] + elif min_magic > 0: + mag_str = "魔法攻击 %d" % min_magic + + if item_subtype == 4: # WEAPON_FAN 扇子先魔法后物理 + if not mag_str.is_empty(): + stats_out.append({"text": mag_str, "color": POSITIVE_COLOR}) + if not atk_str.is_empty(): + stats_out.append({"text": atk_str, "color": POSITIVE_COLOR}) + else: + if not atk_str.is_empty(): + stats_out.append({"text": atk_str, "color": POSITIVE_COLOR}) + if not mag_str.is_empty(): + stats_out.append({"text": mag_str, "color": POSITIVE_COLOR}) + elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6: + var def_val := int(values[1]) + int(values[5]) * 2 + if def_val > 0: + stats_out.append({"text": "防御力 %d" % def_val, "color": POSITIVE_COLOR}) + if int(values[0]) > 0: + stats_out.append({"text": "魔法防御 %d" % int(values[0]), "color": POSITIVE_COLOR}) + + # 固有属性 (Proto Applies) + var applies_out: Array[Dictionary] = [] + for apply in proto_data.get("applies", []): + var atype := int(apply.get("type", 0)) + var aval := int(apply.get("value", 0)) + if atype == 0 or aval == 0: + continue + var astr := format_apply_string(atype, aval) + var acol := POSITIVE_COLOR if aval > 0 else NEGATIVE_COLOR + applies_out.append({"text": astr, "color": acol, "type": atype, "value": aval}) + + # 附加属性 (Instance Attrs,蓝字/强化属性) + var attrs_out: Array[Dictionary] = [] + for i in attrs.size(): + var attr: Dictionary = attrs[i] + var atype := int(attr.get("type", 0)) + var aval := int(attr.get("value", 0)) + if atype == 0 or aval == 0: + continue + var astr := format_apply_string(atype, aval) + var acol := SPECIAL_POSITIVE_COLOR if i < 5 else SPECIAL_POSITIVE_COLOR2 + if aval < 0: + acol = NEGATIVE_COLOR + attrs_out.append({"text": astr, "color": acol, "type": atype, "value": aval, "index": i}) + + # 穿戴职业 (Wearable Info) + var wearable_out: Dictionary = {} + if item_type == ITEM_TYPE_WEAPON or item_type == ITEM_TYPE_ARMOR or item_type == ITEM_TYPE_COSTUME: + var anti: int = int(proto_data.get("anti_flags", 0)) + var jobs: Array[String] = [] + var player_job: int = int(player_ctx.get("job", -1)) + var player_job_can := true + for j in 4: + if (anti & (1 << (2 + j))) == 0: + jobs.append(CHARACTER_NAMES[j]) + else: + if player_job == j: + player_job_can = false + var jobs_str := " ".join(jobs) + var jobs_col := DISABLE_COLOR if (player_job >= 0 and not player_job_can) else NORMAL_COLOR + var gender_str := "" + var gender_col := NORMAL_COLOR + var player_sex: int = int(player_ctx.get("sex", -1)) + if (anti & ANTIFLAG_FEMALE) != 0: + gender_str = "男性专用" + if player_sex == 1: + gender_col = DISABLE_COLOR + elif (anti & ANTIFLAG_MALE) != 0: + gender_str = "女性专用" + if player_sex == 0: + gender_col = DISABLE_COLOR + wearable_out = { + "header": "[ 穿戴职业 ]", + "jobs": jobs_str, + "jobs_color": jobs_col, + "gender": gender_str, + "gender_color": gender_col, + } + + # 魂石凹槽 + var sockets_out: Array[Dictionary] = [] + var has_sockets := false + for s in sockets: + if int(s) != 0: + has_sockets = true + break + if has_sockets or item_type == ITEM_TYPE_WEAPON or item_type == ITEM_TYPE_ARMOR: + for i in sockets.size(): + var sval := int(sockets[i]) + if sval == 0: + sockets_out.append({ + "index": i, + "type": "empty", + "name": "未使用的凹槽", + "name_color": NORMAL_COLOR, + "affect": "", + "affect_color": POSITIVE_COLOR, + "vnum": 0, + "icon_path": "", + }) + elif sval < 0: + sockets_out.append({ + "index": i, + "type": "broken", + "name": "损坏的凹槽", + "name_color": DISABLE_COLOR, + "affect": "", + "affect_color": POSITIVE_COLOR, + "vnum": sval, + "icon_path": "", + }) + elif sval == 1: + sockets_out.append({ + "index": i, + "type": "empty", + "name": "未使用的凹槽", + "name_color": NORMAL_COLOR, + "affect": "", + "affect_color": POSITIVE_COLOR, + "vnum": 1, + "icon_path": "", + }) + else: + var sinfo := get_stone_info(sval) + sockets_out.append({ + "index": i, + "type": "stone", + "name": sinfo.get("name", "魂石 #%d" % sval), + "name_color": NORMAL_COLOR, + "affect": sinfo.get("affect", ""), + "affect_color": POSITIVE_COLOR, + "vnum": sval, + "icon_path": sinfo.get("icon_path", ""), + }) + + # 价格与限时 + var price_val := int(instance_data.get("price", 0)) + var realtime_str := _get_realtime_text(proto_data, sockets, instance_data) + + return { + "vnum": vnum, + "title": name, + "title_color": title_col, + "count": count, + "desc": desc_text, + "summary": summary_text, + "limits": limits_out, + "stats": stats_out, + "applies": applies_out, + "attrs": attrs_out, + "wearable": wearable_out, + "sockets": sockets_out, + "price": price_val, + "realtime": realtime_str, + "item_type": item_type, + } + func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}) -> String: if vnum <= 0: return "" @@ -224,7 +565,6 @@ func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictio var limit_type := int(limit.get("type", 0)) var limit_value := int(limit.get("value", 0)) if limit_type == 6: - # LIMIT_PCBANG 只是“网吧专用”标记,不是装备门禁(服务器 item.cpp:1899)。 lines.append("PC房专用道具") continue if limit_type != 0 and limit_value > 0: @@ -275,16 +615,15 @@ func _special_title(vnum: int, item_name: String, sockets: Array) -> String: if vnum == 50300 or vnum == 70037 or vnum == 70055: var skill_name := String(_skill_names.get(int(sockets[0]), "")) return skill_name + " " + item_name if skill_name != "" else item_name - if vnum >= 70103 and vnum <= 70106 and _mob_proto and _mob_proto.has_method("mob"): - var mob: Dictionary = _mob_proto.mob(int(sockets[0])) + if vnum >= 70103 and vnum <= 70106 and _proto_node and _proto_node.has_method("mob"): + var mob: Dictionary = _proto_node.mob(int(sockets[0])) var mob_name := String(mob.get("locale_name", mob.get("name", ""))) return mob_name + " " + item_name if mob_name != "" else item_name return item_name -func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, sockets: Array, - instance_data: Dictionary) -> void: +func _get_realtime_text(proto_data: Dictionary, sockets: Array, instance_data: Dictionary) -> String: if bool(instance_data.get("realtime_rendered", false)) or sockets.is_empty(): - return + return "" var limit_type := 0 for limit in proto_data.get("limits", []): var candidate := int(limit.get("type", 0)) @@ -292,27 +631,30 @@ func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, socket limit_type = candidate break if limit_type == 0: - return + return "" var value := int(sockets[0]) var remaining := 0 if limit_type == 8: - # REAL_TIME_START_FIRST_USE:socket1 = 使用计数。未启用(0)时 socket0 是 - # 时长秒数而非到期时间戳;启用后服务器把 socket0 换成到期 unix ts - #(char_item.cpp:6100 尾部 cLimitRealTimeFirstUseIndex 分支)。 var used := int(sockets[1]) if sockets.size() > 1 else 0 if used == 0: - lines.append("首次使用后计时:%s" % _duration_text(value)) - return + return "首次使用后计时:%s" % _duration_text(value) remaining = maxi(0, value - int(Time.get_unix_time_from_system())) elif limit_type == 7: - # REAL_TIME:socket0 = 到期 unix ts(item_length.h:328-347)。 remaining = maxi(0, value - int(Time.get_unix_time_from_system())) else: remaining = maxi(0, value) if remaining <= 0: - lines.append("剩余时间:已超时") - return - lines.append("剩余时间:%s" % _duration_text(remaining)) + return "已超时" + return _duration_text(remaining) + +func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, sockets: Array, + instance_data: Dictionary) -> void: + var t := _get_realtime_text(proto_data, sockets, instance_data) + if not t.is_empty(): + if t.begins_with("首次使用"): + lines.append(t) + else: + lines.append("剩余时间:%s" % t) func _duration_text(seconds: int) -> String: var minutes := seconds / 60 diff --git a/project/ui/item_tooltip_view.gd b/project/ui/item_tooltip_view.gd new file mode 100644 index 00000000..c372d3e4 --- /dev/null +++ b/project/ui/item_tooltip_view.gd @@ -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) diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd index cb4cd447..df858b07 100644 --- a/project/ui/shop_ui.gd +++ b/project/ui/shop_ui.gd @@ -14,6 +14,7 @@ extends Node const CursorManager = preload("res://ui/cursor_manager.gd") const ItemTooltip = preload("res://ui/item_tooltip.gd") +const ItemTooltipView = preload("res://ui/item_tooltip_view.gd") const UiAssets = preload("res://ui/ui_assets.gd") const UiKit = preload("res://ui_kit.gd") const UiBuild = preload("res://ui/ui_build.gd") @@ -28,6 +29,7 @@ var proto: Node var item_list # ItemListDB (RefCounted) var _assets_root := "" var _tooltip_builder: RefCounted +var _tooltip_view: Control = null var _root: Control var _tabbar: HBoxContainer var _grid: GridContainer @@ -134,6 +136,7 @@ func open() -> void: refresh() func _close() -> void: + _hide_slot_tooltip() _has_open_pos = false if item_mouse and item_mouse.has_method("unregister_owner"): item_mouse.unregister_owner(self) @@ -419,6 +422,9 @@ func _slot(it: Dictionary, pos: int) -> Button: slot.tooltip_text = _tooltip_builder.format(vnum, maxi(1, count), pd, it) slot.tooltip_text += "\n价格:%d" % int(it.get("price", 0)) + slot.mouse_entered.connect(func(): _show_slot_tooltip(vnum, maxi(1, count), it)) + slot.mouse_exited.connect(func(): _hide_slot_tooltip()) + slot.pressed.connect(func() -> void: _buy_slot(pos, it)) slot.gui_input.connect(func(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: @@ -428,6 +434,31 @@ func _slot(it: Dictionary, pos: int) -> Button: slot.disabled = true return slot +func _get_tooltip_view() -> Control: + if _root and _root.get_parent() and _root.get_parent().has_method("get_item_tooltip_view"): + return _root.get_parent().get_item_tooltip_view(_assets_root, proto, item_list) + if _tooltip_view == null or not is_instance_valid(_tooltip_view): + _tooltip_view = ItemTooltipView.new() + _tooltip_view.setup(_assets_root, proto, item_list, _tooltip_builder) + _tooltip_view.visible = false + _tooltip_view.z_index = 200 + if _root and is_instance_valid(_root): + _root.add_child(_tooltip_view) + return _tooltip_view + +func _show_slot_tooltip(vnum: int, count: int, item_data: Dictionary) -> void: + if vnum <= 0: + return + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + var tv := _get_tooltip_view() + if tv: + tv.show_item(vnum, count, pd, item_data) + +func _hide_slot_tooltip() -> void: + var tv := _get_tooltip_view() + if tv: + tv.hide_tooltip() + func _direct_buy(pos: int, item: Dictionary) -> void: if _mode != 1 or item.is_empty() or pos < 0: return diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd index 8a110c93..eb851f63 100644 --- a/project/ui/ui_manager.gd +++ b/project/ui/ui_manager.gd @@ -451,3 +451,29 @@ func _on_handle_input(win: Control, ev: InputEvent) -> void: _drag_win = null elif ev is InputEventMouseMotion and _drag_win == win: win.position = win.get_global_mouse_position() - _drag_from + +# --- 40250 ItemToolTip 统一悬浮窗 --- +var _item_tooltip_view: Control = null + +func get_item_tooltip_view(assets_root := "", proto: Node = null, ilist: RefCounted = null) -> Control: + if _item_tooltip_view == null or not is_instance_valid(_item_tooltip_view): + var view_cls = load("res://ui/item_tooltip_view.gd") + _item_tooltip_view = view_cls.new() + _item_tooltip_view.z_index = 200 + _item_tooltip_view.visible = false + _ensure_root() + _root.add_child(_item_tooltip_view) + if not assets_root.is_empty(): + _item_tooltip_view.setup(assets_root, proto, ilist) + return _item_tooltip_view + +func show_item_tooltip(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}, + player_ctx: Dictionary = {}, assets_root := "", proto: Node = null, ilist: RefCounted = null) -> void: + var view = get_item_tooltip_view(assets_root, proto, ilist) + if view: + view.show_item(vnum, count, proto_data, instance_data, player_ctx) + +func hide_item_tooltip() -> void: + if _item_tooltip_view and is_instance_valid(_item_tooltip_view): + _item_tooltip_view.hide_tooltip() +