- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排, 新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS), 帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案 - 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、 物品槽失配置灰(服务器回收/换武器后刷新) - 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、 被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1) - 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/ near/far/divorce 命令事件→在线/在身边状态与离婚隐藏 - 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示; 仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close; 仓库金钱经核实 40250 无 wire 协议,UI 定界只读 - app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫 - 相机 look_at 零向量守卫(首帧/传送落点) - 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为 HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路) - docs/CLIENT-GAP.md 同步增量 129 Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
386 lines
13 KiB
GDScript
386 lines
13 KiB
GDScript
# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一文字提示。
|
||
#
|
||
# 参考:ClientVS22/POC/assets/root/uitooltip.py::ItemToolTip.AddItemData。
|
||
# Godot 的原生 tooltip 负责显示位置与换行;本类只负责把同一份物品快照
|
||
# 组织成标题、描述、限制、基础数值、附加属性、魂石槽和精炼关联。
|
||
extends RefCounted
|
||
|
||
const ITEM_TYPE_WEAPON := 1
|
||
const ITEM_TYPE_ARMOR := 2
|
||
const ITEM_TYPE_LOTTERY := 8
|
||
const ITEM_TYPE_FISH := 12
|
||
const ITEM_TYPE_ROD := 13
|
||
const ITEM_TYPE_UNIQUE := 16
|
||
const ITEM_TYPE_PICK := 24
|
||
const ITEM_TYPE_BLEND := 27
|
||
const ITEM_TYPE_DS := 29
|
||
const ITEM_TYPE_RING := 33
|
||
const ITEM_TYPE_BELT := 34
|
||
|
||
const LIMIT_NAMES := {
|
||
1: "等级要求",
|
||
2: "力量要求",
|
||
3: "敏捷要求",
|
||
4: "智力要求",
|
||
5: "体力要求",
|
||
6: "网吧要求",
|
||
7: "实时期限",
|
||
8: "首次使用期限",
|
||
9: "穿戴计时",
|
||
}
|
||
|
||
const TYPE_NAMES := {
|
||
1: "武器",
|
||
2: "防具",
|
||
3: "使用品",
|
||
4: "自动使用品",
|
||
5: "材料",
|
||
6: "特殊物品",
|
||
7: "工具",
|
||
8: "抽奖品",
|
||
9: "金币",
|
||
10: "魔石",
|
||
11: "容器",
|
||
12: "鱼",
|
||
13: "鱼竿",
|
||
14: "资源",
|
||
16: "独特物品",
|
||
17: "技能书",
|
||
18: "任务物品",
|
||
19: "变身物品",
|
||
20: "宝箱",
|
||
21: "宝箱钥匙",
|
||
22: "遗忘书",
|
||
23: "礼盒",
|
||
24: "镐",
|
||
25: "发型",
|
||
26: "图腾",
|
||
27: "混合物",
|
||
28: "时装",
|
||
29: "龙魂石",
|
||
30: "特殊龙魂石",
|
||
31: "提取物",
|
||
32: "副货币",
|
||
33: "戒指",
|
||
34: "腰带",
|
||
}
|
||
|
||
# APPLY_* 编号来自 ClientVS22/source/GameLib/ItemData.h::EApplyTypes。
|
||
const APPLY_NAMES := {
|
||
1: "最大生命",
|
||
2: "最大法力",
|
||
3: "体质",
|
||
4: "智力",
|
||
5: "力量",
|
||
6: "敏捷",
|
||
7: "攻击速度",
|
||
8: "移动速度",
|
||
9: "施法速度",
|
||
10: "生命恢复",
|
||
11: "法力恢复",
|
||
12: "中毒概率",
|
||
13: "眩晕概率",
|
||
14: "缓慢概率",
|
||
15: "暴击概率",
|
||
16: "穿透概率",
|
||
17: "对人族伤害",
|
||
18: "对动物伤害",
|
||
19: "对兽人伤害",
|
||
20: "对秘境伤害",
|
||
21: "对不死族伤害",
|
||
22: "对恶魔伤害",
|
||
23: "生命吸收",
|
||
24: "法力吸收",
|
||
25: "法力燃烧",
|
||
26: "伤害转法力",
|
||
27: "格挡",
|
||
28: "闪避",
|
||
29: "剑防御",
|
||
30: "双手防御",
|
||
31: "匕首防御",
|
||
32: "铃防御",
|
||
33: "扇防御",
|
||
34: "弓防御",
|
||
35: "火抗性",
|
||
36: "雷抗性",
|
||
37: "魔法抗性",
|
||
38: "风抗性",
|
||
39: "近战反射",
|
||
40: "诅咒反射",
|
||
41: "中毒抵抗",
|
||
42: "击杀恢复法力",
|
||
43: "经验加成",
|
||
44: "金币加成",
|
||
45: "掉落加成",
|
||
46: "药水效果",
|
||
47: "击杀恢复生命",
|
||
48: "免疫眩晕",
|
||
49: "免疫缓慢",
|
||
50: "免疫跌落",
|
||
51: "技能",
|
||
52: "弓箭距离",
|
||
53: "攻击等级",
|
||
54: "防御等级",
|
||
55: "魔法攻击等级",
|
||
56: "魔法防御等级",
|
||
57: "诅咒概率",
|
||
58: "最大耐力",
|
||
59: "对战士伤害",
|
||
60: "对刺客伤害",
|
||
61: "对武士伤害",
|
||
62: "对萨满伤害",
|
||
63: "对怪物伤害",
|
||
64: "商城攻击加成",
|
||
65: "商城防御加成",
|
||
66: "商城经验加成",
|
||
67: "商城掉落加成",
|
||
68: "商城金币加成",
|
||
69: "最大生命百分比",
|
||
70: "最大法力百分比",
|
||
71: "技能伤害",
|
||
72: "普通攻击伤害",
|
||
73: "技能防御",
|
||
74: "普通攻击防御",
|
||
75: "生命提取",
|
||
76: "网吧经验加成",
|
||
77: "网吧掉落加成",
|
||
78: "战士抗性",
|
||
79: "刺客抗性",
|
||
80: "武士抗性",
|
||
81: "萨满抗性",
|
||
82: "能量",
|
||
83: "防御",
|
||
84: "时装属性加成",
|
||
85: "魔法攻击百分比",
|
||
86: "近战魔法攻击百分比",
|
||
87: "冰抗性",
|
||
88: "土抗性",
|
||
89: "暗抗性",
|
||
90: "抗暴击",
|
||
91: "抗穿透",
|
||
}
|
||
|
||
var _descriptions: Dictionary = {}
|
||
var _skill_names: Dictionary = {}
|
||
var _mob_proto: Object = null
|
||
|
||
func setup(assets_root: String, lang := "en", proto_node: Object = null) -> bool:
|
||
_descriptions.clear()
|
||
_skill_names.clear()
|
||
_mob_proto = 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)
|
||
if file:
|
||
while not file.eof_reached():
|
||
var parts := file.get_line().split("\t", true)
|
||
if parts.size() < 2:
|
||
continue
|
||
var vnum := int(parts[0].strip_edges())
|
||
if vnum <= 0:
|
||
continue
|
||
_descriptions[vnum] = {
|
||
"name": parts[1].strip_edges(),
|
||
"description": parts[2].strip_edges() if parts.size() > 2 else "",
|
||
}
|
||
_load_skill_names(assets_root, lang)
|
||
return not _descriptions.is_empty() or not _skill_names.is_empty()
|
||
|
||
func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}) -> String:
|
||
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 lines: Array[String] = [name]
|
||
if count > 1:
|
||
lines.append("数量:%d" % count)
|
||
var desc: Dictionary = _descriptions.get(vnum, {})
|
||
if not String(desc.get("description", "")).is_empty():
|
||
lines.append(String(desc["description"]))
|
||
|
||
var item_type := int(proto_data.get("type", 0))
|
||
var type_name: String = TYPE_NAMES.get(item_type, "")
|
||
if not type_name.is_empty():
|
||
lines.append("类型:" + type_name)
|
||
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])
|
||
if min_attack > 0 or max_attack > 0:
|
||
lines.append("攻击力:%d - %d" % [min_attack, max_attack])
|
||
if int(values[1]) > 0 or int(values[2]) > 0:
|
||
lines.append("魔法攻击:%d - %d" % [int(values[1]), int(values[2])])
|
||
elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6:
|
||
if int(values[1]) > 0:
|
||
lines.append("防御力:%d" % (int(values[1]) + int(values[5]) * 2))
|
||
if int(values[0]) > 0:
|
||
lines.append("魔法防御:%d" % int(values[0]))
|
||
_append_special_item_data(lines, item_type, vnum, values, proto_data, instance_data)
|
||
|
||
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:
|
||
# LIMIT_PCBANG 只是“网吧专用”标记,不是装备门禁(服务器 item.cpp:1899)。
|
||
lines.append("PC房专用道具")
|
||
continue
|
||
if limit_type != 0 and limit_value > 0:
|
||
lines.append("%s:%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value])
|
||
for apply in proto_data.get("applies", []):
|
||
_append_bonus(lines, int(apply.get("type", 0)), int(apply.get("value", 0)))
|
||
for attr in instance_data.get("attrs", proto_data.get("attrs", [])):
|
||
_append_bonus(lines, int(attr.get("type", 0)), int(attr.get("value", 0)), "附加")
|
||
|
||
var has_socket := false
|
||
for socket in sockets:
|
||
if int(socket) != 0:
|
||
has_socket = true
|
||
break
|
||
if has_socket:
|
||
for i in sockets.size():
|
||
var socket_value := int(sockets[i])
|
||
lines.append("孔 %d:%s" % [i + 1, "空" if socket_value == 0 else ("损坏" if socket_value < 0 else "#%d" % socket_value)])
|
||
|
||
var refined_vnum := int(proto_data.get("refined_vnum", 0))
|
||
if refined_vnum > 0:
|
||
lines.append("精炼后:#%d" % refined_vnum)
|
||
var refine_set := int(proto_data.get("refine_set", 0))
|
||
if refine_set > 0:
|
||
lines.append("精炼套件:%d" % refine_set)
|
||
_append_realtime_limit(lines, proto_data, sockets, instance_data)
|
||
return "\n".join(lines)
|
||
|
||
func _load_skill_names(assets_root: String, lang: String) -> void:
|
||
var path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)
|
||
if not FileAccess.file_exists(path):
|
||
return
|
||
var file := FileAccess.open(path, FileAccess.READ)
|
||
if file == null:
|
||
return
|
||
while not file.eof_reached():
|
||
var parts := file.get_line().split("\t", false)
|
||
if parts.size() < 3:
|
||
continue
|
||
var skill_id := int(parts[0])
|
||
var skill_name := parts[2].strip_edges()
|
||
if skill_id > 0 and skill_name != "":
|
||
_skill_names[skill_id] = skill_name
|
||
|
||
func _special_title(vnum: int, item_name: String, sockets: Array) -> String:
|
||
if sockets.is_empty() or int(sockets[0]) <= 0:
|
||
return item_name
|
||
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]))
|
||
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:
|
||
if bool(instance_data.get("realtime_rendered", false)) or sockets.is_empty():
|
||
return
|
||
var limit_type := 0
|
||
for limit in proto_data.get("limits", []):
|
||
var candidate := int(limit.get("type", 0))
|
||
if candidate == 7 or candidate == 8 or candidate == 9:
|
||
limit_type = candidate
|
||
break
|
||
if limit_type == 0:
|
||
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
|
||
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))
|
||
|
||
func _duration_text(seconds: int) -> String:
|
||
var minutes := seconds / 60
|
||
var rest := seconds % 60
|
||
var text := "%d秒" % rest
|
||
if minutes > 0:
|
||
text = "%d分%s" % [minutes, text] if rest > 0 else "%d分" % minutes
|
||
return text
|
||
|
||
func _append_special_item_data(lines: Array[String], item_type: int, vnum: int,
|
||
values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void:
|
||
var sockets: Array = instance_data.get("sockets", [])
|
||
if item_type == ITEM_TYPE_ROD or item_type == ITEM_TYPE_PICK:
|
||
if values.size() >= 3 and not sockets.is_empty():
|
||
var level := int(values[0]) / 10
|
||
var current_exp := int(sockets[0])
|
||
var max_exp := int(values[2])
|
||
lines.append("等级:%d" % level)
|
||
lines.append("经验:%d / %d" % [current_exp, max_exp])
|
||
if max_exp > 0 and current_exp >= max_exp:
|
||
lines.append("经验已满,可升级")
|
||
elif item_type == ITEM_TYPE_FISH:
|
||
if not sockets.is_empty() and int(sockets[0]) > 0:
|
||
lines.append("长度:%.2f cm" % (float(sockets[0]) / 100.0))
|
||
elif item_type == ITEM_TYPE_LOTTERY:
|
||
if sockets.size() >= 2 and (int(sockets[0]) != 0 or int(sockets[1]) != 0):
|
||
lines.append("彩票号码:%d" % int(sockets[0]))
|
||
lines.append("彩票步数:%d" % int(sockets[1]))
|
||
elif item_type == ITEM_TYPE_BLEND:
|
||
if sockets.size() >= 3 and int(sockets[0]) != 0:
|
||
var affect_type := int(sockets[0])
|
||
var affect_value := int(sockets[1])
|
||
_append_bonus(lines, affect_type, affect_value, "效果:")
|
||
var duration := int(sockets[2])
|
||
lines.append("持续时间:%d 秒" % duration if duration > 0 else "无持续时间")
|
||
elif item_type == ITEM_TYPE_UNIQUE:
|
||
_append_item_remaining(lines, proto_data, sockets, instance_data)
|
||
elif item_type == ITEM_TYPE_DS:
|
||
var step := (vnum / 100) % 10
|
||
var refine := (vnum / 10) % 10
|
||
if step >= 0 and step <= 4:
|
||
lines.append("龙魂石阶段:%d" % (step + 1))
|
||
lines.append("龙魂石强化:%d" % refine)
|
||
|
||
func _append_item_remaining(lines: Array[String], proto_data: Dictionary, sockets: Array,
|
||
instance_data: Dictionary) -> void:
|
||
var remaining := int(instance_data.get("remaining_seconds", instance_data.get("duration", 0)))
|
||
if remaining <= 0 and not sockets.is_empty():
|
||
var limit_type := int(instance_data.get("limit_type", 0))
|
||
if limit_type == 0:
|
||
for limit in proto_data.get("limits", []):
|
||
if int(limit.get("type", 0)) != 0:
|
||
limit_type = int(limit.get("type", 0))
|
||
break
|
||
if limit_type == 7 or limit_type == 8:
|
||
remaining = maxi(0, int(sockets[0]) - int(Time.get_unix_time_from_system()))
|
||
elif limit_type == 9:
|
||
remaining = int(sockets[0])
|
||
elif sockets.size() >= 3:
|
||
remaining = int(sockets[2]) * 60
|
||
if remaining > 0:
|
||
lines.append("剩余时间:%d 秒" % remaining)
|
||
|
||
func _append_bonus(lines: Array[String], apply_type: int, value: int, prefix := "") -> void:
|
||
if apply_type == 0 or value == 0:
|
||
return
|
||
var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type)
|
||
if not prefix.is_empty():
|
||
label = prefix + label
|
||
lines.append("%s:%+d" % [label, value])
|