Files
mtgodot-poc/project/ui/quickbar.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

1550 lines
56 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。
#
# var qb := preload("res://ui/quickbar.gd").new()
# add_child(qb)
# qb.setup(m2client, skill_table, canvas_parent, player_getter)
# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1
# 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)。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
const PlayerSkill := preload("res://player_skill.gd")
const SkillSlotMap := preload("res://skill_slot_map.gd")
const UiAssets := preload("res://ui/ui_assets.gd")
const JOB_DIR := {
"WARRIOR": "warrior",
"ASSASSIN": "assassin",
"SURA": "sura",
"SHAMAN": "shaman",
}
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not later selection
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
signal skill_rejected(skill_id: int, code: String)
# 底部任务栏功能按钮信号(40250 uitaskbar.py 对齐)
signal character_requested
signal inventory_requested
signal messenger_requested
signal safebox_requested
signal system_requested
signal chat_requested
const CM := 100.0
const DEFAULT_CD := 2.0
const SLOTS_PER_PAGE := 8
const PAGE_COUNT := 4
const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显示页
var client: Node
var table: RefCounted # SkillTable
var proto: Node:
set(v):
proto = v
_refresh_page()
var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源
var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1
var _player_getter: Callable
var _root: Control
var _dock: Control
var _buttons := {}
var item_mouse: Node
var _assets_root := ""
var _slots := [] # 当前页 UI[{btn, cd, lbl, icon, grade}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
var _page := 0
var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
var _move_from := -1
var _mobile_mode := false
var _left_mouse_mode := 0 # 0: 移动与攻击, 1: 自动攻击, 2: 镜头视角
var _right_mouse_mode := 2 # 0: 移动与攻击, 1: 镜头视角, 2: 技能施放
var _right_mouse_skill_id := 0
# 40250 TaskBar 控件引用
var _hp_board: Control
var _hp_recovery_bar: ColorRect
var _hp_fill: Control
var _hp_gauge_img: TextureRect
var _hp_frames: Array[Texture2D] = []
var _sp_board: Control
var _sp_recovery_bar: ColorRect
var _sp_fill: Control
var _sp_gauge_img: TextureRect
var _sp_frames: Array[Texture2D] = []
var _st_board: Control
var _st_fill: Control
var _st_gauge_img: TextureRect
var _st_frames: Array[Texture2D] = []
var _gauge_timer := 0.0
var _gauge_idx := 0
var _exp_board: Control
var _exp_points: Array[TextureRect] = []
var _exp_clips: Array[Control] = []
var _rampage_gauge: TextureRect
var _rampage_frames: Array[Texture2D] = []
var _rampage_idx := 0
var _rampage_timer := 0.0
var _quick_page_num_img: TextureRect
var _cur_hp := 0
var _max_hp := 0
var _cur_sp := 0
var _max_sp := 0
var _cur_st := 0
var _max_st := 0
var _cur_exp := 0
var _next_exp := 0
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable,
assets_root_override := "") -> void:
client = m2client
table = skill_table
_player_getter = player_getter
_assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path()
_skill_gate = PlayerSkill.new()
_skill_gate.setup(client, table)
for _i in SLOT_COUNT:
_state.append({"kind": "", "id": 0, "cd_end": 0.0})
_build(parent)
if client.has_signal("skill_cooldown_end"):
client.skill_cooldown_end.connect(_on_cd_end)
if client.has_signal("quickslots_changed"):
client.quickslots_changed.connect(restore_from_server)
if client.has_signal("skills_changed"):
client.skills_changed.connect(func():
restore_from_server()
_refresh_page())
if client.has_signal("skill_group_changed"):
client.skill_group_changed.connect(func(_group: int): restore_from_server())
if client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(_vid: int): restore_from_server())
# 物品槽引用的是背包 cell:装备/销毁/丢弃后服务器 SyncQuickslot 会推
# quickslots_changed;这里再挂 inventory_changed 兜住时序窗口(置灰失效引用)。
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): _refresh_page())
# 死亡/复活:死亡中槽位全部置灰(对齐原版 POS_DEAD 全链路禁用)。
if client.has_signal("entity_dead"):
client.entity_dead.connect(_on_entity_dead)
# 变身(AFFECT_POLYMORPH=220):技能槽整体遮罩,结束(re-broadcast)后解除。
if client.has_signal("affect_added"):
client.affect_added.connect(_on_affect_added)
if client.has_signal("affect_removed"):
client.affect_removed.connect(_on_affect_removed)
if client.has_signal("points_changed"):
client.points_changed.connect(_on_points_changed)
if client.has_method("get_points"):
_on_points_changed(client.get_points())
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。
func restore_from_server() -> void:
if client == null or not client.has_method("get_quickslots"):
return
var restored := []
for _i in SLOT_COUNT:
restored.append({"kind": "", "id": 0, "cd_end": 0.0})
for qs in client.get_quickslots():
var pos := int(qs.get("pos", -1))
if pos < 0 or pos >= SLOT_COUNT:
continue
match int(qs.get("type", 0)):
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
2:
var skill_slot := int(qs.get("ref", 0))
var skill_id := _skill_id_from_slot(skill_slot)
# Unknown custom slots remain usable as a compatibility fallback; all
# stock 40250 slots are mapped above and therefore show the correct
# skill tree icon (e.g. warrior group 2 slot 2 -> skill 17, not 2).
if skill_id == 0:
skill_id = skill_slot
restored[pos] = {"kind": "skill", "id": skill_id, "ref": skill_slot,
"cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
_state = restored
_refresh_page()
func assign(slot: int, kind: String, id: int, persist := true) -> bool:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return false
if kind not in ["skill", "item", "emote"] or id <= 0:
return false
var global := _global_slot(slot)
if persist and client and client.has_method("quickslot_add"):
var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0
var server_ref := _skill_slot_for_id(id) if kind == "skill" else id
if server_ref == 0:
server_ref = id
if type == 0 or not client.quickslot_add(global, type, server_ref):
return false
_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}
_refresh_slot(slot)
return true
func _main_race() -> int:
if client and client.has_method("get_main_vid") and client.has_method("get_entity"):
var vid := int(client.get_main_vid())
if vid != 0:
return int(client.get_entity(vid).get("race", 0))
return 0
func _skill_group() -> int:
var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0
if grp > 0:
return grp
grp = _infer_skill_group()
if grp > 0:
return grp
return 1
func _infer_skill_group() -> int:
if client == null or not client.has_method("get_skills"):
return 0
var race := _main_race()
for sk in client.get_skills():
var sid := int(sk.get("id", 0))
var lvl := int(sk.get("level", 0))
if lvl <= 0 and int(sk.get("master", 0)) == 0:
continue
for g in [1, 2]:
var slot := SkillSlotMap.skill_slot_for_id(race, g, sid)
if slot >= 1 and slot <= 8:
return g
return 0
func _skill_id_from_slot(slot: int) -> int:
return SkillSlotMap.skill_id_for_slot(_main_race(), _skill_group(), slot)
func _skill_slot_for_id(skill_id: int) -> int:
return SkillSlotMap.skill_slot_for_id(_main_race(), _skill_group(), skill_id)
# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar.
# Put a selected skill/item/emote into the first free slot on the current page
# while keeping the same server-backed CG_QUICKSLOT_ADD path.
func assign_mobile(kind: String, id: int) -> bool:
for slot in SLOTS_PER_PAGE:
if _state[_global_slot(slot)].kind == "":
return assign(slot, kind, id)
return false
func mobile_slot_state(slot: int) -> Dictionary:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return {}
return _state[_global_slot(slot)].duplicate(true)
func mobile_swap(local_a: int, local_b: int) -> bool:
if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE:
return false
if local_a == local_b:
return true
_swap(local_a, local_b)
return true
func activate(slot: int) -> void:
_activate_slot(slot, Vector2.ZERO)
## Mobile long-press skill release. `screen_direction` uses screen space:
## right is +X and up is -Y. A zero vector intentionally falls back to the
## player's current facing, so a long press without a drag is still usable.
func activate_aimed(slot: int, screen_direction: Vector2) -> void:
_activate_slot(slot, screen_direction)
func _activate_slot(slot: int, screen_direction: Vector2) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
var s: Dictionary = _state[global]
if s.kind == "" or s.id == 0:
return
if _now() < s.cd_end:
return
# 死亡中不可使用(服务器 POS_DEAD 拦截一切使用;这里提前本地拦截)。
if _player_dead():
return
if s.kind == "skill":
var mi: int = table.motion_idx_of(s.id) if table else 0
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
if p:
yaw = MapCoord.yaw_to_heading(p.rotation.y)
xy = MapCoord.to_server_cm(p.position)
if screen_direction.length() > 0.01:
yaw = _aim_heading(p, screen_direction, yaw)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
# §3.8 修改 1:三层合法性校验(ClickSkillSlot -> __CanUseSkill /
# __CheckSkillUsable / __UseSkill)。挡下就发 skill_rejected(静默码除外),
# TOGGLE_OFF 只发 use_skill 关掉开关技、不施法。
if _skill_gate:
if net_play and net_play.has_method("skill_context"):
var ctx: Dictionary = net_play.skill_context()
for k in ctx:
_skill_gate.set(k, ctx[k])
_skill_gate.slot_cd_end = s.cd_end
var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id))
if not bool(gate.get("ok", false)):
var code := String(gate.get("code", ""))
# PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端
# 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。
if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \
and net_play and net_play.has_method("activate_fishing"):
var fishing_result := String(net_play.activate_fishing())
if fishing_result == "OK":
skill_activated.emit(int(s.id))
elif not PlayerSkill.is_silent_code(fishing_result):
skill_rejected.emit(int(s.id), fishing_result)
return
if code == "RESERVED":
# __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先
# __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。
_last_activation_reserved = true
if net_play and net_play.has_method("reserve_use_skill"):
var already: bool = net_play.has_method("is_use_skill_reserved") \
and bool(net_play.is_use_skill_reserved(global))
if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0)))
return
if code == "TOGGLE_OFF":
if client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
skill_activated.emit(int(s.id))
elif not PlayerSkill.is_silent_code(code):
skill_rejected.emit(int(s.id), code)
return
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.
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
for peer in _state:
if peer.kind == "skill" and peer.id == s.id:
peer.cd_end = s.cd_end
# §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过;
# PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。
if target_vid != 0 and net_play and table \
and net_play.has_method("send_fly_targeting") \
and (table.is_fan_range(int(s.id)) or table.is_circle_range(int(s.id))):
var lvl := _skill_level(int(s.id))
net_play.send_fly_targeting(target_vid, float(table.target_range(int(s.id))),
table.target_count(int(s.id), lvl), table.fly_shape(int(s.id)))
skill_activated.emit(int(s.id))
elif s.kind == "item":
client.use_item(1, s.id) # WINDOW_INVENTORY
elif s.kind == "emote" and client.has_method("send_emoticon"):
client.send_emoticon(s.id)
## 从技能窗口右键直接激活技能
func activate_skill_direct(skill_id: int) -> void:
if skill_id <= 0 or _player_dead():
return
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 p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
if p:
yaw = MapCoord.yaw_to_heading(p.rotation.y)
xy = MapCoord.to_server_cm(p.position)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
if _skill_gate:
if net_play and net_play.has_method("skill_context"):
var ctx: Dictionary = net_play.skill_context()
for k in ctx:
_skill_gate.set(k, ctx[k])
_skill_gate.slot_cd_end = s.cd_end
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 == "TOGGLE_OFF":
if client.has_method("use_skill"):
client.use_skill(skill_id, 0)
skill_activated.emit(skill_id)
elif not PlayerSkill.is_silent_code(code):
skill_rejected.emit(skill_id, code)
return
var resolved := int(gate.get("target_vid", 0))
if resolved != 0:
target_vid = resolved
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:
if peer.kind == "skill" and peer.id == skill_id:
peer.cd_end = cd_end
if target_vid != 0 and net_play and table \
and net_play.has_method("send_fly_targeting") \
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))
skill_activated.emit(skill_id)
func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float:
# Keep the historical POC heading convention as the base and add the
# camera-relative drag angle. This makes an upward drag equal to the
# existing one-tap cast, while right/left drags rotate it predictably.
var angle_from_up := rad_to_deg(atan2(screen_direction.x, -screen_direction.y))
var camera_heading := player_node.rotation.y
if net_play and "camera" in net_play and net_play.camera \
and net_play.camera.has_method("heading"):
camera_heading = float(net_play.camera.heading())
var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y)
return fposmod(fallback + camera_delta + angle_from_up, 360.0)
# 服务器权威的死亡态(GC add chr state DEAD);复活后 entity_info 会再触发刷新。
func _player_dead() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and bool(me.get("dead", false))
# 变身态(POINT_POLYMORPH / affect 220):技能槽遮罩的数据源。
func _polymorphed() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and (int(me.get("polymorph", 0)) != 0 or int(me.get("poly_vnum", 0)) != 0)
func _on_entity_dead(vid: int) -> void:
if client and client.has_method("get_main_vid") and vid == client.get_main_vid():
_refresh_page()
# 只关心 AFFECT_POLYMORPH(220) 的增减,避免每次 buff 变化都整页重刷。
const AFFECT_POLYMORPH := 220
func _on_affect_added(affect: Dictionary) -> void:
if int(affect.get("type", 0)) == AFFECT_POLYMORPH:
_refresh_page()
func _on_affect_removed(type: int) -> void:
if type == AFFECT_POLYMORPH:
_refresh_page()
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
func activate_reserved(global_slot: int) -> bool:
if global_slot < 0 or global_slot >= SLOT_COUNT:
return false
var local := global_slot - _page * SLOTS_PER_PAGE
if local < 0 or local >= SLOTS_PER_PAGE:
return false
var s: Dictionary = _state[global_slot]
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
return false
_last_activation_reserved = false
activate(local)
# 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。
return not _last_activation_reserved
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
func _skill_level(sid: int) -> int:
if client == null or not client.has_method("get_skills"):
return 0
for sk in client.get_skills():
if int(sk.get("id", 0)) == sid:
return int(sk.get("level", 0))
return 0
func get_page() -> int:
return _page
func set_page(page: int) -> void:
if page < 0 or page >= PAGE_COUNT or page == _page:
return
_page = page
_move_from = -1
_refresh_page()
func get_left_mouse_mode() -> int:
return _left_mouse_mode
func set_left_mouse_mode(mode: int) -> void:
_left_mouse_mode = posmod(mode, 3)
_update_mouse_mode_buttons()
func get_right_mouse_mode() -> int:
return _right_mouse_mode
func set_right_mouse_mode(mode: int) -> void:
_right_mouse_mode = posmod(mode, 3)
_update_mouse_mode_buttons()
func set_right_mouse_skill(skill_id: int) -> void:
_right_mouse_skill_id = skill_id
_right_mouse_mode = 2
_update_mouse_mode_buttons()
func get_right_mouse_skill() -> int:
return _right_mouse_skill_id
func _update_mouse_mode_buttons() -> void:
var btn_l: BaseButton = _buttons.get("mouse_left", null)
if btn_l:
match _left_mouse_mode:
0: btn_l.tooltip_text = "攻击 / 移动 (鼠标左键)"
1: btn_l.tooltip_text = "自动攻击 (鼠标左键)"
2: btn_l.tooltip_text = "镜头旋转 (鼠标左键)"
var btn_r: BaseButton = _buttons.get("mouse_right", null)
if btn_r:
match _right_mouse_mode:
0: btn_r.tooltip_text = "攻击 / 移动 (鼠标右键)"
1: btn_r.tooltip_text = "镜头旋转 (鼠标右键)"
2:
if _right_mouse_skill_id > 0 and table and table.has_method("skill_name"):
btn_r.tooltip_text = "技能快捷施放: %s (鼠标右键)" % table.skill_name(_right_mouse_skill_id)
else:
btn_r.tooltip_text = "技能快捷施放 (鼠标右键)"
func toggle_visible() -> void:
if _root:
_root.visible = not _root.visible
if _dock:
_dock.visible = not _dock.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_root.visible = not enabled
if _dock:
_dock.visible = not enabled
func get_dock() -> Control:
return _dock
func get_button(btn_name: String) -> Control:
return _buttons.get(btn_name, null)
func _exit_tree() -> void:
if _dock and is_instance_valid(_dock):
_dock.queue_free()
_dock = null
func is_mobile_mode() -> bool:
return _mobile_mode
func is_visible() -> bool:
return _root != null and _root.visible
func _global_slot(local_slot: int) -> int:
return _page * SLOTS_PER_PAGE + local_slot
func _clear(slot: int) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
if _state[global].kind == "":
return
if client and client.has_method("quickslot_del") and not client.quickslot_del(global):
return
_state[global] = {"kind": "", "id": 0, "cd_end": 0.0}
_refresh_slot(slot)
func _swap(local_a: int, local_b: int) -> void:
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):
return
var tmp: Dictionary = _state[a]
_state[a] = _state[b]
_state[b] = tmp
_refresh_slot(local_a)
_refresh_slot(local_b)
# --- UI ---------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.name = "TaskBar"
_root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_root.offset_top = -37
_root.offset_bottom = 0
_root.offset_left = 0
_root.offset_right = 0
_root.custom_minimum_size = Vector2(0, 37)
_root.z_index = 10
_root.mouse_filter = Control.MOUSE_FILTER_PASS
parent.add_child(_root)
# 1. Base_Board_01: TaskBar_Base.tga 全屏平铺底纹
var base_bg := TextureRect.new()
base_bg.name = "Base_Board_01"
base_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
base_bg.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/taskbar_base.tga")
base_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
base_bg.stretch_mode = TextureRect.STRETCH_TILE
base_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(base_bg)
# 2. Gauge_Board: gauge.sub 位于 (0, -10)
var gauge_board := TextureRect.new()
gauge_board.name = "Gauge_Board"
gauge_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/gauge.sub")
gauge_board.position = Vector2(0, -10)
gauge_board.size = Vector2(158, 47)
gauge_board.mouse_filter = Control.MOUSE_FILTER_PASS
_root.add_child(gauge_board)
# Rampage 暴气红球(17帧循环)
_rampage_gauge = TextureRect.new()
_rampage_gauge.name = "RampageGauge"
_rampage_gauge.position = Vector2(8, 4)
_rampage_gauge.size = Vector2(40, 40)
_rampage_gauge.mouse_filter = Control.MOUSE_FILTER_IGNORE
gauge_board.add_child(_rampage_gauge)
_rampage_frames.clear()
for i in range(17):
var frame_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/rampage_01/%02d.sub" % i)
if frame_tex:
_rampage_frames.append(frame_tex)
if not _rampage_frames.is_empty():
_rampage_gauge.texture = _rampage_frames[0]
# HPGauge_Board: (59, 14, 95, 11)
_hp_board = Control.new()
_hp_board.name = "HPGauge_Board"
_hp_board.position = Vector2(59, 14)
_hp_board.size = Vector2(95, 11)
_hp_board.mouse_filter = Control.MOUSE_FILTER_STOP
_hp_board.tooltip_text = "HP : 0 / 0"
gauge_board.add_child(_hp_board)
_hp_recovery_bar = ColorRect.new()
_hp_recovery_bar.name = "HPRecoveryGaugeBar"
_hp_recovery_bar.color = Color(1.0, 0.0, 0.0, 0.33)
_hp_recovery_bar.position = Vector2(0, 0)
_hp_recovery_bar.size = Vector2(0, 11)
_hp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_recovery_bar.visible = false
_hp_board.add_child(_hp_recovery_bar)
_hp_fill = Control.new()
_hp_fill.name = "HPFill"
_hp_fill.clip_contents = true
_hp_fill.size = Vector2(95, 11)
_hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_board.add_child(_hp_fill)
_hp_gauge_img = TextureRect.new()
_hp_gauge_img.name = "HPGauge"
_hp_gauge_img.size = Vector2(95, 11)
_hp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_fill.add_child(_hp_gauge_img)
_hp_frames.clear()
for i in range(1, 8):
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/hpgauge/%02d.tga" % i)
if tex:
_hp_frames.append(tex)
if not _hp_frames.is_empty():
_hp_gauge_img.texture = _hp_frames[0]
# SPGauge_Board: (59, 24, 95, 11)
_sp_board = Control.new()
_sp_board.name = "SPGauge_Board"
_sp_board.position = Vector2(59, 24)
_sp_board.size = Vector2(95, 11)
_sp_board.mouse_filter = Control.MOUSE_FILTER_STOP
_sp_board.tooltip_text = "SP : 0 / 0"
gauge_board.add_child(_sp_board)
_sp_recovery_bar = ColorRect.new()
_sp_recovery_bar.name = "SPRecoveryGaugeBar"
_sp_recovery_bar.color = Color(0.0, 0.0, 1.0, 0.33)
_sp_recovery_bar.position = Vector2(0, 0)
_sp_recovery_bar.size = Vector2(0, 11)
_sp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_sp_recovery_bar.visible = false
_sp_board.add_child(_sp_recovery_bar)
_sp_fill = Control.new()
_sp_fill.name = "SPFill"
_sp_fill.clip_contents = true
_sp_fill.size = Vector2(95, 11)
_sp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_sp_board.add_child(_sp_fill)
_sp_gauge_img = TextureRect.new()
_sp_gauge_img.name = "SPGauge"
_sp_gauge_img.size = Vector2(95, 11)
_sp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
_sp_fill.add_child(_sp_gauge_img)
_sp_frames.clear()
for i in range(1, 8):
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/spgauge/%02d.tga" % i)
if tex:
_sp_frames.append(tex)
if not _sp_frames.is_empty():
_sp_gauge_img.texture = _sp_frames[0]
# STGauge_Board: (59, 38, 95, 6)
_st_board = Control.new()
_st_board.name = "STGauge_Board"
_st_board.position = Vector2(59, 38)
_st_board.size = Vector2(95, 6)
_st_board.mouse_filter = Control.MOUSE_FILTER_STOP
_st_board.tooltip_text = "ST : 0 / 0"
gauge_board.add_child(_st_board)
_st_fill = Control.new()
_st_fill.name = "STFill"
_st_fill.clip_contents = true
_st_fill.size = Vector2(95, 6)
_st_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_st_board.add_child(_st_fill)
_st_gauge_img = TextureRect.new()
_st_gauge_img.name = "STGauge"
_st_gauge_img.size = Vector2(95, 6)
_st_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
_st_fill.add_child(_st_gauge_img)
_st_frames.clear()
for i in range(1, 8):
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/stgauge/%02d.tga" % i)
if tex:
_st_frames.append(tex)
if not _st_frames.is_empty():
_st_gauge_img.texture = _st_frames[0]
# 3. EXP_Gauge_Board: (158, 0, 105, 37)
_exp_board = TextureRect.new()
_exp_board.name = "EXP_Gauge_Board"
_exp_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge.sub")
_exp_board.position = Vector2(158, 0)
_exp_board.size = Vector2(105, 37)
_exp_board.mouse_filter = Control.MOUSE_FILTER_STOP
_exp_board.tooltip_text = "EXP : 0.00%"
_root.add_child(_exp_board)
_exp_points.clear()
_exp_clips.clear()
for i in 4:
var clip_box := Control.new()
clip_box.name = "EXPGauge_Clip_%02d" % (i + 1)
clip_box.position = Vector2(5 + i * 25, 9)
clip_box.size = Vector2(19, 19)
clip_box.clip_contents = true
clip_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
clip_box.visible = false
_exp_board.add_child(clip_box)
_exp_clips.append(clip_box)
var pt := TextureRect.new()
pt.name = "EXPGauge_%02d" % (i + 1)
pt.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge_point.sub")
pt.position = Vector2(0, 0)
pt.size = Vector2(19, 19)
pt.mouse_filter = Control.MOUSE_FILTER_IGNORE
clip_box.add_child(pt)
_exp_points.append(pt)
# 4. 中央控制栏 (CenterBar): 水平居中 (SCREEN_WIDTH/2 - 128 到 SCREEN_WIDTH/2 + 237)
var center_bar := Control.new()
center_bar.name = "CenterBar"
center_bar.anchor_left = 0.5
center_bar.anchor_right = 0.5
center_bar.anchor_top = 0.0
center_bar.anchor_bottom = 0.0
center_bar.offset_left = -128
center_bar.offset_right = 237
center_bar.offset_top = 0
center_bar.offset_bottom = 37
center_bar.size = Vector2(365, 37)
center_bar.mouse_filter = Control.MOUSE_FILTER_PASS
_root.add_child(center_bar)
# LeftMouseButton: (0, 3) -> SCREEN_WIDTH/2 - 128
var mouse_left := _create_taskbar_btn("LeftMouseButton",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
"攻击 / 移动 (鼠标左键)", func(): set_left_mouse_mode(_left_mouse_mode + 1))
mouse_left.position = Vector2(0, 3)
center_bar.add_child(mouse_left)
_buttons["mouse_left"] = mouse_left
# quickslot_board: (42, 0, 283, 37) -> SCREEN_WIDTH/2 - 86
var qb_board := Control.new()
qb_board.name = "quickslot_board"
qb_board.position = Vector2(42, 0)
qb_board.size = Vector2(283, 37)
qb_board.mouse_filter = Control.MOUSE_FILTER_PASS
center_bar.add_child(qb_board)
if item_mouse and item_mouse.has_method("register_target"):
item_mouse.register_target(qb_board, func(payload: Dictionary, pos: Vector2 = Vector2.ZERO):
return _drop_on_quickslot_board(payload, pos), self)
qb_board.set_drag_forwarding(
Callable(),
func(_at_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
func(at_pos: Vector2, data: Variant) -> void:
if data is Dictionary:
var slot_idx := -1
if at_pos.x < 135.0:
slot_idx = clampi(int(floor(at_pos.x / 32.0)), 0, 3)
elif at_pos.x >= 135.0:
slot_idx = clampi(int(floor((at_pos.x - 142.0) / 32.0)), 0, 3) + 4
if slot_idx >= 0 and slot_idx < 8:
_apply_drag_data_to_slot(data, slot_idx)
)
_slots.clear()
var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/slot_base.sub")
# quick_slot_1: 4 格 (0..3)
var qs1 := Control.new()
qs1.name = "quick_slot_1"
qs1.position = Vector2(0, 3)
qs1.size = Vector2(128, 32)
qb_board.add_child(qs1)
qs1.set_drag_forwarding(
Callable(),
func(_at_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
func(at_pos: Vector2, data: Variant) -> void:
if data is Dictionary:
var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3)
_apply_drag_data_to_slot(data, slot_idx)
)
for i in 4:
var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/%d.sub" % (i + 1))
slot_ctrl.position = Vector2(i * 32, 0)
qs1.add_child(slot_ctrl)
# ChatButton / ExpandButton: (128, 1)
var chat_btn := _create_chat_btn()
chat_btn.position = Vector2(128, 1)
qb_board.add_child(chat_btn)
_buttons["chat"] = chat_btn
# quick_slot_2: 4 格 (4..7)
var qs2 := Control.new()
qs2.name = "quick_slot_2"
qs2.position = Vector2(142, 3)
qs2.size = Vector2(128, 32)
qb_board.add_child(qs2)
qs2.set_drag_forwarding(
Callable(),
func(_at_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
func(at_pos: Vector2, data: Variant) -> void:
if data is Dictionary:
var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3) + 4
_apply_drag_data_to_slot(data, slot_idx)
)
for i in range(4, 8):
var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/f%d.sub" % (i - 3))
slot_ctrl.position = Vector2((i - 4) * 32, 0)
qs2.add_child(slot_ctrl)
# QuickSlotBoard (翻页板): (272, 0, 11, 37)
var qsb := Control.new()
qsb.name = "QuickSlotBoard"
qsb.position = Vector2(272, 0)
qsb.size = Vector2(11, 37)
qb_board.add_child(qsb)
var num_box := TextureRect.new()
num_box.name = "QuickSlotNumberBox"
num_box.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_button_board.sub")
num_box.position = Vector2(1, 15)
num_box.size = Vector2(11, 11)
num_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
qsb.add_child(num_box)
var page_up := TextureButton.new()
page_up.name = "QuickPageUpButton"
page_up.position = Vector2(1, 9)
page_up.size = Vector2(11, 6)
page_up.tooltip_text = "上一页 (Shift+1)"
page_up.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_01.sub")
page_up.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_02.sub")
page_up.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_03.sub")
page_up.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
qsb.add_child(page_up)
_quick_page_num_img = TextureRect.new()
_quick_page_num_img.name = "QuickPageNumber"
_quick_page_num_img.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/1.sub")
_quick_page_num_img.position = Vector2(3, 15)
_quick_page_num_img.size = Vector2(7, 9)
_quick_page_num_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
qsb.add_child(_quick_page_num_img)
var page_down := TextureButton.new()
page_down.name = "QuickPageDownButton"
page_down.position = Vector2(1, 24)
page_down.size = Vector2(11, 6)
page_down.tooltip_text = "下一页 (Shift+2)"
page_down.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_01.sub")
page_down.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_02.sub")
page_down.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_03.sub")
page_down.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
qsb.add_child(page_down)
# RightMouseButton: (333, 3) -> SCREEN_WIDTH/2 + 205
var mouse_right := _create_taskbar_btn("RightMouseButton",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
"ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
"技能快捷施放 (鼠标右键)", func(): set_right_mouse_mode(_right_mouse_mode + 1))
mouse_right.position = Vector2(333, 3)
center_bar.add_child(mouse_right)
_buttons["mouse_right"] = mouse_right
_update_mouse_mode_buttons()
_refresh_page()
_build_dock(_root)
func _create_quickslot_widget(i: int, slot_base_tex: Texture2D, corner_sub: String) -> Control:
var slot := Panel.new()
slot.name = "slot_%d" % i
slot.custom_minimum_size = Vector2(32, 32)
slot.size = Vector2(32, 32)
if slot_base_tex:
var sbt := StyleBoxTexture.new()
sbt.texture = slot_base_tex
slot.add_theme_stylebox_override("panel", sbt)
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.1, 0.11, 0.14, 0.9)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", sb)
var icon := TextureRect.new()
icon.name = "icon"
icon.position = Vector2(0, 0)
icon.size = Vector2(32, 32)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(icon)
# 40250 原版角标 (1..4 / F1..F4) 在左上角 (3, 3) 处显示
if corner_sub != "":
var corner_tex := UiAssets.load_tex(_assets_root, corner_sub)
if corner_tex:
var corner := TextureRect.new()
corner.name = "Corner"
corner.texture = corner_tex
corner.position = Vector2(3, 3)
corner.size = corner_tex.get_size()
corner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
corner.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(corner)
var lbl := Label.new()
lbl.name = "lbl"
lbl.position = Vector2(2, 16)
lbl.size = Vector2(28, 14)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.add_theme_font_size_override("font_size", 9)
lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
lbl.add_theme_constant_override("shadow_offset_x", 1)
lbl.add_theme_constant_override("shadow_offset_y", 1)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(lbl)
var grade := Label.new()
grade.name = "grade"
grade.position = Vector2(16, 16)
grade.size = Vector2(14, 14)
grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
grade.add_theme_font_size_override("font_size", 9)
grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45))
grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
grade.add_theme_constant_override("shadow_offset_x", 1)
grade.add_theme_constant_override("shadow_offset_y", 1)
grade.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(grade)
var cd := ColorRect.new()
cd.name = "cd"
cd.color = Color(0, 0, 0, 0.55)
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
cd.visible = false
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(cd)
var idx := i
slot.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.pressed:
if e.button_index == MOUSE_BUTTON_RIGHT:
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
if item_mouse.has_method("cancel"):
item_mouse.cancel()
_move_from = -1
elif e.shift_pressed:
_clear(idx)
else:
activate(idx)
elif e.button_index == MOUSE_BUTTON_LEFT:
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
_try_drop(idx)
else:
var sk_ui := _find_skill_ui()
if sk_ui and sk_ui.drag_skill_id != 0:
_try_drop(idx)
elif _move_from >= 0:
_try_drop(idx)
else:
activate(idx))
_slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
slot.set_drag_forwarding(
Callable(),
func(_at_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
func(_at_pos: Vector2, data: Variant) -> void:
if data is Dictionary:
_apply_drag_data_to_slot(data, idx)
)
if item_mouse and item_mouse.has_method("register_target"):
var local_idx: int = i
item_mouse.register_target(slot,
func(payload: Dictionary, _pos: Vector2 = Vector2.ZERO): return _drop_mouse_item(payload, local_idx), self)
return slot
func _build_dock(parent: Node) -> void:
if _dock and is_instance_valid(_dock):
_dock.queue_free()
_dock = Control.new()
_dock.name = "TaskBarDock"
_dock.anchor_left = 1.0
_dock.anchor_top = 0.0
_dock.anchor_right = 1.0
_dock.anchor_bottom = 0.0
_dock.offset_left = -178
_dock.offset_top = 0
_dock.offset_right = 0
_dock.offset_bottom = 37
_dock.size = Vector2(178, 37)
_dock.mouse_filter = Control.MOUSE_FILTER_PASS
parent.add_child(_dock)
# 1. 仓库 (Safebox / Mall) -> SCREEN_WIDTH - 178
var btn_safe := _create_taskbar_btn(
"SafeboxButton",
"ETC/ymir work/ui/game/taskbar/mall_button_01.tga",
"ETC/ymir work/ui/game/taskbar/mall_button_02.tga",
"ETC/ymir work/ui/game/taskbar/mall_button_03.tga",
"仓库",
func(): safebox_requested.emit()
)
btn_safe.position = Vector2(0, 3)
_buttons["safebox"] = btn_safe
_dock.add_child(btn_safe)
# 2. 角色 (C) -> SCREEN_WIDTH - 144
var btn_char := _create_taskbar_btn(
"CharacterButton",
"ETC/ymir work/ui/game/taskbar/character_button_01.sub",
"ETC/ymir work/ui/game/taskbar/character_button_02.sub",
"ETC/ymir work/ui/game/taskbar/character_button_03.sub",
"角色 (C)",
func(): character_requested.emit()
)
btn_char.position = Vector2(34, 3)
_buttons["character"] = btn_char
_dock.add_child(btn_char)
# 3. 道具 (I) -> SCREEN_WIDTH - 110
var btn_inv := _create_taskbar_btn(
"InventoryButton",
"ETC/ymir work/ui/game/taskbar/inventory_button_01.sub",
"ETC/ymir work/ui/game/taskbar/inventory_button_02.sub",
"ETC/ymir work/ui/game/taskbar/inventory_button_03.sub",
"道具 (I)",
func(): inventory_requested.emit()
)
btn_inv.position = Vector2(68, 3)
_buttons["inventory"] = btn_inv
_dock.add_child(btn_inv)
# 4. 好友 / 社群 (Alt+M) -> SCREEN_WIDTH - 76
var btn_msg := _create_taskbar_btn(
"MessengerButton",
"ETC/ymir work/ui/game/taskbar/community_button_01.sub",
"ETC/ymir work/ui/game/taskbar/community_button_02.sub",
"ETC/ymir work/ui/game/taskbar/community_button_03.sub",
"社群 (Alt+M)",
func(): messenger_requested.emit()
)
btn_msg.position = Vector2(102, 3)
_buttons["messenger"] = btn_msg
_dock.add_child(btn_msg)
# 5. 系统设置 (ESC) -> SCREEN_WIDTH - 42
var btn_sys := _create_taskbar_btn(
"SystemButton",
"ETC/ymir work/ui/game/taskbar/system_button_01.sub",
"ETC/ymir work/ui/game/taskbar/system_button_02.sub",
"ETC/ymir work/ui/game/taskbar/system_button_03.sub",
"系统 (ESC)",
func(): system_requested.emit()
)
btn_sys.position = Vector2(136, 3)
_buttons["system"] = btn_sys
_dock.add_child(btn_sys)
func _on_points_changed(pd: Dictionary) -> void:
if pd.is_empty():
return
var pts: Array = pd.get("points", [])
var get_pt := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0
var hp := int(pd.get("hp", get_pt.call(5)))
var max_hp := int(pd.get("max_hp", get_pt.call(6)))
var sp := int(pd.get("sp", get_pt.call(7)))
var max_sp := int(pd.get("max_sp", get_pt.call(8)))
var st: int = int(get_pt.call(9))
var max_st: int = int(get_pt.call(10))
if max_st <= 0:
max_st = 100
st = 100
var exp_val := int(pd.get("exp", get_pt.call(3)))
var next_exp_val := int(pd.get("next_exp", get_pt.call(4)))
set_hp(hp, max_hp)
set_sp(sp, max_sp)
set_st(st, max_st)
set_exp(exp_val, next_exp_val)
func set_hp(cur: int, max_val: int) -> void:
_cur_hp = cur
_max_hp = max_val
if _hp_fill:
var pct := clampf(float(_cur_hp) / maxf(1.0, float(_max_hp)), 0.0, 1.0)
_hp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
if _hp_board:
_hp_board.tooltip_text = "HP : %d / %d" % [_cur_hp, _max_hp]
func set_sp(cur: int, max_val: int) -> void:
_cur_sp = cur
_max_sp = max_val
if _sp_fill:
var pct := clampf(float(_cur_sp) / maxf(1.0, float(_max_sp)), 0.0, 1.0)
_sp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
if _sp_board:
_sp_board.tooltip_text = "SP : %d / %d" % [_cur_sp, _max_sp]
func set_st(cur: int, max_val: int) -> void:
_cur_st = cur
_max_st = max_val
if _st_fill:
var pct := clampf(float(_cur_st) / maxf(1.0, float(_max_st)), 0.0, 1.0)
_st_fill.size = Vector2(int(roundf(95.0 * pct)), 6)
if _st_board:
_st_board.tooltip_text = "ST : %d / %d" % [_cur_st, _max_st]
func set_exp(cur: int, next_val: int) -> void:
_cur_exp = cur
_next_exp = next_val
var quarter_pt := _next_exp / 4
var full_count := 0
if quarter_pt > 0:
full_count = mini(4, _cur_exp / quarter_pt)
for i in 4:
if i < _exp_clips.size() and _exp_clips[i]:
var clip: Control = _exp_clips[i]
var pt: TextureRect = _exp_points[i] if i < _exp_points.size() else null
var base_x := 5 + i * 25
var base_y := 9
if _next_exp <= 0 or _cur_exp <= 0:
clip.visible = false
elif i < full_count:
clip.position = Vector2(base_x, base_y)
clip.size = Vector2(19, 19)
if pt:
pt.position = Vector2(0, 0)
clip.visible = true
elif i == full_count and full_count < 4 and quarter_pt > 0:
var frac := float(_cur_exp % quarter_pt) / float(quarter_pt)
var fill_h := int(roundf(19.0 * frac))
if fill_h > 0:
clip.position = Vector2(base_x, base_y + 19 - fill_h)
clip.size = Vector2(19, fill_h)
if pt:
pt.position = Vector2(0, fill_h - 19)
clip.visible = true
else:
clip.visible = false
else:
clip.visible = false
if _exp_board:
var pct := float(_cur_exp) / maxf(1.0, float(_next_exp)) * 100.0
_exp_board.tooltip_text = "EXP : %.2f%%" % pct
func _create_taskbar_btn(btn_name: String, rel_nrm: String, rel_ovr: String, rel_dwn: String, tooltip: String, on_press: Callable) -> Control:
var btn := TextureButton.new()
btn.name = btn_name
btn.custom_minimum_size = Vector2(32, 32)
btn.tooltip_text = tooltip
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
if _assets_root != "":
btn.texture_normal = UiAssets.load_tex(_assets_root, rel_nrm)
btn.texture_hover = UiAssets.load_tex(_assets_root, rel_ovr)
btn.texture_pressed = UiAssets.load_tex(_assets_root, rel_dwn)
if btn.texture_normal == null:
var fallback := Button.new()
fallback.name = btn_name
fallback.text = tooltip.substr(0, 2)
fallback.custom_minimum_size = Vector2(32, 32)
fallback.tooltip_text = tooltip
fallback.pressed.connect(on_press)
return fallback
btn.pressed.connect(on_press)
return btn
func _create_chat_btn() -> Control:
var btn := TextureButton.new()
btn.name = "ChatButton"
btn.custom_minimum_size = Vector2(14, 35)
btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
btn.tooltip_text = "聊天 (L)"
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
if _assets_root != "":
btn.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_01.sub")
btn.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_02.sub")
btn.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_03.sub")
if btn.texture_normal == null:
var fallback := Button.new()
fallback.name = "ChatButton"
fallback.text = "C"
fallback.custom_minimum_size = Vector2(14, 35)
fallback.size_flags_vertical = Control.SIZE_SHRINK_CENTER
fallback.tooltip_text = "聊天 (L)"
fallback.pressed.connect(func(): chat_requested.emit())
return fallback
btn.pressed.connect(func(): chat_requested.emit())
return btn
func _drop_mouse_payload(payload: Dictionary, slot: int) -> bool:
if payload.is_empty():
return false
var src := String(payload.get("source", ""))
if src == "inventory":
if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0:
return false
# QuickSlot inventory entries store the source inventory cell, not the vnum.
return assign(slot, "item", int(payload.get("cell", -1)))
elif src == "skill":
var sid := int(payload.get("skill_id", 0))
if sid > 0:
return assign(slot, "skill", sid)
elif src == "emotion":
var eid := int(payload.get("emote_id", 0))
if eid > 0:
return assign(slot, "emote", eid)
return false
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
return _drop_mouse_payload(payload, slot)
func _apply_drag_data_to_slot(data: Dictionary, slot_idx: int) -> bool:
if data.is_empty() or slot_idx < 0 or slot_idx >= SLOTS_PER_PAGE:
return false
var k := String(data.get("kind", data.get("type", "")))
if k == "skill":
var sid := int(data.get("id", data.get("skill_id", 0)))
if sid > 0:
return assign(slot_idx, "skill", sid)
elif k in ["emotion", "emote"]:
var eid := int(data.get("id", data.get("emote_id", 0)))
if eid > 0:
return assign(slot_idx, "emote", eid)
elif k == "item":
var cell := int(data.get("cell", -1))
if cell >= 0:
return assign(slot_idx, "item", cell)
return false
func _drop_on_quickslot_board(payload: Dictionary, global_pos: Vector2) -> bool:
if _root == null or not is_instance_valid(_root):
return false
var qb_board: Control = _root.find_child("quickslot_board", true, false)
if qb_board == null:
return false
var local_pos := qb_board.get_global_transform().affine_inverse() * global_pos
var slot_idx := -1
if local_pos.x < 135.0:
slot_idx = clampi(int(floor(local_pos.x / 32.0)), 0, 3)
elif local_pos.x >= 135.0:
slot_idx = clampi(int(floor((local_pos.x - 142.0) / 32.0)), 0, 3) + 4
if slot_idx >= 0 and slot_idx < 8:
return _drop_mouse_payload(payload, slot_idx)
return false
func _try_drop(slot: int) -> void:
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
var payload: Dictionary = item_mouse.attached() if item_mouse.has_method("attached") else {}
if not payload.is_empty() and _drop_mouse_payload(payload, slot):
if item_mouse.has_method("cancel"):
item_mouse.cancel()
_move_from = -1
return
# skill_ui 把待拖技能放在 drag_skill_id;这里落点接
var sk_ui := _find_skill_ui()
if sk_ui and sk_ui.drag_skill_id != 0:
assign(slot, "skill", sk_ui.drag_skill_id)
sk_ui.drag_skill_id = 0
_move_from = -1
return
if _move_from >= 0 and _move_from != slot:
_swap(_move_from, slot)
_move_from = -1
return
if _state[_global_slot(slot)].kind != "":
_move_from = slot
func _find_skill_ui() -> Node:
var tree := get_tree()
if tree == null or tree.root == null:
return null
for n in tree.root.find_children("*", "Node", true, false):
if "drag_skill_id" in n and int(n.drag_skill_id) != 0:
return n
return null
func _refresh_slot(slot: int) -> void:
if slot < 0 or slot >= _slots.size():
return
var s: Dictionary = _state[_global_slot(slot)]
var txt := ""
var tex: Texture2D = null
var grade_text := ""
var vnum := 0
var count := 0
var item_name := ""
if s.kind == "skill" and table:
txt = table.name_of(s.id).substr(0, 5)
tex = _skill_icon(int(s.id))
grade_text = _skill_grade_text(int(s.id))
elif s.kind == "item":
if client and client.has_method("get_item"):
var it: Dictionary = client.get_item(1, s.id)
vnum = int(it.get("vnum", 0))
count = int(it.get("count", 1))
if vnum > 0:
tex = _item_icon(vnum)
txt = str(count) if count > 1 else ""
if proto and proto.has_method("item"):
var idata: Dictionary = proto.item(vnum)
item_name = String(idata.get("locale_name", idata.get("name", "")))
if item_name == "":
item_name = "item %d" % vnum
else:
txt = "#%d" % s.id
elif s.kind == "emote":
txt = "E%d" % s.id
# 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。
var dim := false
if s.kind == "item":
if vnum == 0:
dim = true
txt = "×%d" % s.id
# 变身中技能槽整体遮罩(服务器只放行箭矢类物品);死亡中全部置灰。
if (s.kind == "skill" and _polymorphed()) or _player_dead():
dim = true
var icon: TextureRect = _slots[slot].icon
icon.texture = tex
icon.visible = tex != null
_slots[slot].btn.modulate = Color(0.42, 0.42, 0.42) if dim else Color(1, 1, 1)
var lbl: Label = _slots[slot].lbl
if tex != null and s.kind == "item":
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.position = Vector2(2, 16)
lbl.size = Vector2(28, 14)
lbl.text = txt
elif tex == null:
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.position = Vector2(2, 12)
lbl.size = Vector2(28, 14)
lbl.text = txt
else:
lbl.text = ""
_slots[slot].grade.text = grade_text
_slots[slot].grade.visible = grade_text != "" and tex != null
# Tooltip
if s.kind == "skill" and table:
_slots[slot].btn.tooltip_text = table.name_of(s.id)
elif s.kind == "item" and vnum > 0:
_slots[slot].btn.tooltip_text = "%s\n#%d%s" % [item_name, vnum, (" x%d" % count) if count > 1 else ""]
else:
_slots[slot].btn.tooltip_text = ""
func _item_icon(vnum: int) -> Texture2D:
if _assets_root == "" or vnum <= 0:
return null
var rel := "icon/item/%05d.tga" % vnum
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
if tex == null:
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
tex = UiAssets.load_tex(_assets_root, rel)
return tex
func _skill_grade(skill_id: int) -> int:
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
return clampi(int(skill.get("master", 0)), 0, 3)
return 0
func _skill_grade_text(skill_id: int) -> String:
match _skill_grade(skill_id):
1: return "M"
2: return "G"
3: return "P"
_: return ""
func _skill_icon(skill_id: int) -> Texture2D:
if _assets_root == "" or table == null or not table.has_method("entry"):
return null
var data: Dictionary = table.entry(skill_id)
var job := String(data.get("job", "")).to_upper()
var motion := String(data.get("motion", "")).strip_edges()
if motion == "":
return null
var candidates: Array[String] = []
if JOB_DIR.has(job):
var dir := String(JOB_DIR[job])
for suffix in _skill_icon_suffixes(skill_id):
candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, suffix])
elif job == "SUPPORT":
for suffix in _skill_icon_suffixes(skill_id):
candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, suffix])
candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion)
elif job == "HORSE":
candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion)
elif job == "GUILD":
candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion)
for rel in candidates:
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
if tex != null:
return tex
return null
func _skill_icon_suffixes(skill_id: int) -> Array[String]:
var grade := _skill_grade(skill_id)
var first := "_01"
if grade == 1:
first = "_02"
elif grade >= 2:
first = "_03"
var out: Array[String] = [first]
for suffix in ["_03", "_02", "_01"]:
if not out.has(suffix):
out.append(suffix)
return out
func _refresh_page() -> void:
if _quick_page_num_img:
var page_sub := "ETC/ymir work/ui/game/taskbar/%d.sub" % (_page + 1)
_quick_page_num_img.texture = UiAssets.load_tex(_assets_root, page_sub)
if _root and _root.has_node("page"):
_root.get_node("page").text = "F%d" % (_page + 1)
for slot in _slots.size():
_refresh_slot(slot)
func _on_cd_end(skill_id: int) -> void:
_skill_cooldowns.erase(skill_id)
for i in _state.size():
var s: Dictionary = _state[i]
if s.kind == "skill" and s.id == skill_id:
s.cd_end = 0.0
_state[i] = s
func _process(dt: float) -> void:
var t := _now()
for slot in _slots.size():
var s: Dictionary = _state[_global_slot(slot)]
var on_cd: bool = t < s.cd_end
_slots[slot].cd.visible = on_cd
if on_cd:
var duration := _skill_cooldown(int(s.id))
var frac: float = clampf((s.cd_end - t) / duration, 0.0, 1.0)
_slots[slot].cd.anchor_top = 1.0 - frac
# 暴气红球旋转动画 (delay 6 帧即约 0.08s 步进)
_rampage_timer += dt
if _rampage_timer >= 0.08:
_rampage_timer = 0.0
if not _rampage_frames.is_empty() and _rampage_gauge:
_rampage_idx = (_rampage_idx + 1) % _rampage_frames.size()
_rampage_gauge.texture = _rampage_frames[_rampage_idx]
# HP/SP/ST 水流动波纹动画 (40250 delay 6 帧 = 0.08s 步进,循环 7 帧)
_gauge_timer += dt
if _gauge_timer >= 0.08:
_gauge_timer = 0.0
_gauge_idx = (_gauge_idx + 1) % 7
if not _hp_frames.is_empty() and _hp_gauge_img:
_hp_gauge_img.texture = _hp_frames[_gauge_idx % _hp_frames.size()]
if not _sp_frames.is_empty() and _sp_gauge_img:
_sp_gauge_img.texture = _sp_frames[_gauge_idx % _sp_frames.size()]
if not _st_frames.is_empty() and _st_gauge_img:
_st_gauge_img.texture = _st_frames[_gauge_idx % _st_frames.size()]
func _skill_cooldown(skill_id: int) -> float:
if table and table.has_method("cooldown_of"):
var level := 0
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
level = int(skill.get("level", 0))
break
var configured := float(table.cooldown_of(skill_id, level))
if configured > 0.0:
return configured
return DEFAULT_CD
func _now() -> float:
return Time.get_ticks_msec() / 1000.0