fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+122 -20
View File
@@ -7,8 +7,10 @@
# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水
# # 输入:数字 1..4 -> 本页 0..3F1..F4 -> 本页 4..7。
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(默认朝向 = 玩家 yaw
# 技能施放顺序对齐 ClientVS22/PythonPlayerSkill.cpp:先由本地动作生成
# FUNC_SKILL|motion + arg,再发送 CG_USE_SKILL(skill, target)
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm, arg)(默认朝向 = 玩家 yaw);
# arg 对齐 CActorInstance::__OnUseSkill:低 4 位是动作循环次数,第 4 位是移动技能。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
@@ -185,6 +187,10 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
server_ref = id
if type == 0 or not client.quickslot_add(global, type, server_ref):
return false
# The reference client does not mutate its local quickslot here. The
# server's GC_QUICKSLOT_ADD packet is consumed by EntityStore and emits
# quickslots_changed, which calls restore_from_server().
return true
_state[global] = {"kind": kind, "id": id,
"ref": _skill_slot_for_id(id) if kind == "skill" else id,
"cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
@@ -272,7 +278,7 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
if _player_dead():
return
if s.kind == "skill":
var mi: int = table.motion_idx_of(s.id) if table else 0
var mi: int = _motion_idx_for_skill(int(s.id))
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
@@ -296,6 +302,9 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id))
if not bool(gate.get("ok", false)):
var code := String(gate.get("code", ""))
if code == "GUILD_ROUTED":
_activate_guild_skill(int(s.id))
return
# PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端
# 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。
if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \
@@ -327,16 +336,21 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
var resolved := int(gate.get("target_vid", 0))
if resolved != 0:
target_vid = resolved
# use_skill is the authoritative server intent. Keep the CG_MOVE skill
# state packet as a separate visual/action-state sync, just like the
# original client did.
# PythonPlayerEventHandler::OnUseSkill 先上行 FUNC_SKILL|motion + arg
# 然后 __SendUseSkill 才发送 CG_USE_SKILL。动作包失败时不产生后续技能意图。
var motion_sent := true
if client.has_method("cast_skill"):
var motion_arg: int = 0
if table and table.has_method("motion_state_arg"):
motion_arg = int(table.call("motion_state_arg", int(s.id), _skill_level(int(s.id))))
motion_sent = bool(client.cast_skill(mi, yaw, int(xy.x), int(xy.y), motion_arg))
if not motion_sent:
return
skill_cast_started.emit(int(s.id), target_vid)
var intent_sent := true
if client.has_method("use_skill"):
intent_sent = client.use_skill(int(s.id), target_vid)
if intent_sent:
if client.has_method("cast_skill"):
client.cast_skill(mi, yaw, int(xy.x), int(xy.y))
skill_cast_started.emit(int(s.id), target_vid)
s.cd_end = _now() + _skill_cooldown(int(s.id))
_state[global] = s
_skill_cooldowns[int(s.id)] = s.cd_end
@@ -357,6 +371,33 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
elif s.kind == "emote" and client.has_method("send_emoticon"):
client.send_emoticon(s.id)
func _activate_guild_skill(skill_id: int) -> void:
if _skill_gate == null or not _skill_gate.has_method("use_guild_skill"):
return
_skill_gate.slot_cd_end = float(_skill_cooldowns.get(skill_id, 0.0))
var gate: Dictionary = _skill_gate.use_guild_skill(skill_id)
if not bool(gate.get("ok", false)):
var code := String(gate.get("code", ""))
if not PlayerSkill.is_silent_code(code):
skill_rejected.emit(skill_id, code)
return
var motion_idx: int = int(table.motion_idx_of(skill_id)) if table else 0
var motion_sent := true
if client.has_method("cast_skill"):
# UseGuildSkill calls NEW_UseSkill(..., loop=1, moving=false).
motion_sent = bool(client.cast_skill(motion_idx, 0.0, 0, 0, 1))
if not motion_sent:
return
skill_cast_started.emit(skill_id, 0)
if not client.has_method("use_guild_skill") or not client.use_guild_skill(skill_id, 0):
return
var cd_end := _now() + _skill_cooldown(skill_id)
_skill_cooldowns[skill_id] = cd_end
for peer in _state:
if peer.kind == "skill" and peer.id == skill_id:
peer.cd_end = cd_end
skill_activated.emit(skill_id)
## 从技能窗口右键直接激活技能
func activate_skill_direct(skill_id: int) -> void:
if skill_id <= 0 or _player_dead():
@@ -364,7 +405,7 @@ func activate_skill_direct(skill_id: int) -> void:
var s := {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
if _now() < s.cd_end:
return
var mi: int = table.motion_idx_of(skill_id) if table else 0
var mi: int = _motion_idx_for_skill(skill_id)
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
@@ -383,6 +424,9 @@ func activate_skill_direct(skill_id: int) -> void:
var gate: Dictionary = _skill_gate.click_skill_slot(skill_id)
if not bool(gate.get("ok", false)):
var code := String(gate.get("code", ""))
if code == "GUILD_ROUTED":
_activate_guild_skill(skill_id)
return
if code == "TOGGLE_OFF":
if client.has_method("use_skill"):
client.use_skill(skill_id, 0)
@@ -393,13 +437,20 @@ func activate_skill_direct(skill_id: int) -> void:
var resolved := int(gate.get("target_vid", 0))
if resolved != 0:
target_vid = resolved
# Direct skill-window activation follows the same OnUseSkill -> SendUseSkill order.
var motion_sent := true
if client.has_method("cast_skill"):
var motion_arg: int = 0
if table and table.has_method("motion_state_arg"):
motion_arg = int(table.call("motion_state_arg", skill_id, _skill_level(skill_id)))
motion_sent = bool(client.cast_skill(mi, yaw, int(xy.x), int(xy.y), motion_arg))
if not motion_sent:
return
skill_cast_started.emit(skill_id, target_vid)
var intent_sent := true
if client.has_method("use_skill"):
intent_sent = client.use_skill(skill_id, target_vid)
if intent_sent:
if client.has_method("cast_skill"):
client.cast_skill(mi, yaw, int(xy.x), int(xy.y))
skill_cast_started.emit(skill_id, target_vid)
var cd_end := _now() + _skill_cooldown(skill_id)
_skill_cooldowns[skill_id] = cd_end
for peer in _state:
@@ -410,7 +461,7 @@ func activate_skill_direct(skill_id: int) -> void:
and (table.is_fan_range(skill_id) or table.is_circle_range(skill_id)):
var lvl := _skill_level(skill_id)
net_play.send_fly_targeting(target_vid, float(table.target_range(skill_id)),
table.target_count(skill_id, lvl), table.fly_shape(skill_id))
table.target_count(skill_id, lvl), table.fly_shape(skill_id))
skill_activated.emit(skill_id)
func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float:
@@ -479,13 +530,27 @@ func _skill_level(sid: int) -> int:
return int(sk.get("level", 0))
return 0
# CPythonSkill::GetSkillMotionIndex(iGrade);保留旧 SkillTable 的单参数回退,
# 让最小测试桩和第三方表仍可工作。
func _motion_idx_for_skill(skill_id: int) -> int:
if table == null:
return 0
var grade := _skill_grade(skill_id)
if table.has_method("motion_idx_for_grade"):
return int(table.call("motion_idx_for_grade", skill_id, grade))
return int(table.motion_idx_of(skill_id))
func get_page() -> int:
return _page
func set_page(page: int) -> void:
if page < 0 or page >= PAGE_COUNT or page == _page:
# CPythonPlayer::SetQuickPage wraps the requested page by
# QUICKSLOT_MAX_LINE. Keyboard/game code and recovery paths can call this
# entry point directly with -1 or PAGE_COUNT.
var normalized := posmod(page, PAGE_COUNT)
if normalized == _page:
return
_page = page
_page = normalized
_move_from = -1
_refresh_page()
@@ -568,15 +633,28 @@ func _clear(slot: int) -> void:
var global := _global_slot(slot)
if _state[global].kind == "":
return
if client and client.has_method("quickslot_del") and not client.quickslot_del(global):
if client and client.has_method("quickslot_del"):
if not client.quickslot_del(global):
return
# 40250 changes the authoritative local slot only from
# GC_QUICKSLOT_DEL. Do not clear the Godot mirror optimistically: a
# rejected, delayed, or out-of-order request must remain recoverable from
# the last server snapshot.
return
_state[global] = {"kind": "", "id": 0, "cd_end": 0.0}
_refresh_slot(slot)
func _swap(local_a: int, local_b: int) -> void:
if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE:
return
if local_a == local_b:
return
var a := _global_slot(local_a)
var b := _global_slot(local_b)
if client and client.has_method("quickslot_swap") and not client.quickslot_swap(a, b):
if client and client.has_method("quickslot_swap"):
if not client.quickslot_swap(a, b):
return
# GC_QUICKSLOT_SWAP is the only local state transition.
return
var tmp: Dictionary = _state[a]
_state[a] = _state[b]
@@ -1533,6 +1611,7 @@ func _process(dt: float) -> void:
_st_gauge_img.texture = _st_frames[_gauge_idx % _st_frames.size()]
func _skill_cooldown(skill_id: int) -> float:
var base := DEFAULT_CD
if table and table.has_method("cooldown_of"):
var level := 0
if client and client.has_method("get_skills"):
@@ -1542,8 +1621,31 @@ func _skill_cooldown(skill_id: int) -> float:
break
var configured := float(table.cooldown_of(skill_id, level))
if configured > 0.0:
return configured
return DEFAULT_CD
base = configured
# CPythonPlayer::__RunCoolTime (PythonPlayerSkill.cpp:796) applies the
# exact POINT_CASTING_SPEED piecewise scale after GetSkillCoolTime().
# M2Client exposes the named field; the points array fallback keeps this
# compatible with headless/test clients that only provide raw points.
# POINT_CASTING_SPEED is a percentage-like point whose neutral/default
# value is 100 in the reference client. Keep that neutral value while a
# headless fixture has not supplied a points snapshot yet.
var casting_speed := 100
if client and client.has_method("get_points"):
var points: Dictionary = client.get_points()
if points.has("casting_speed"):
casting_speed = int(points.get("casting_speed", 0))
else:
var values: Array = points.get("points", [])
if values.size() > 21:
casting_speed = int(values[21])
var speed_scale := 100
var speed_delta := 100 - casting_speed
if speed_delta > 0:
speed_scale = 100 + speed_delta
elif speed_delta < 0:
speed_scale = 10000 / (100 - speed_delta)
return base * float(speed_scale) / 100.0
func _now() -> float:
return Time.get_ticks_msec() / 1000.0