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
+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