# 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..3,F1..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 个快捷栏槽位。 # 参考 ESlotType:1 道具 / 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