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
+256
View File
@@ -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()
+45
View File
@@ -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:
+363 -21
View File
@@ -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_USEsocket1 = 使用计数。未启用(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_TIMEsocket0 = 到期 unix tsitem_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
+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)
+31
View File
@@ -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
+26
View File
@@ -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()