# 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 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) 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 net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源 var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1) var _player_getter: Callable var _root: Control 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 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(_refresh_page) # 物品槽引用的是背包 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) 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_id := int(qs.get("ref", 0)) restored[pos] = {"kind": "skill", "id": skill_id, "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 if type == 0 or not client.quickslot_add(global, type, id): return false _state[global] = {"kind": kind, "id": id, "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0} _refresh_slot(slot) return true # 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 = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0) xy = Vector2(p.position.x * CM, -p.position.z * CM) 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 and 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 _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 set_page(page: int) -> void: if page < 0 or page >= PAGE_COUNT or page == _page: return _page = page _move_from = -1 _refresh_page() func toggle_visible() -> void: if _root: _root.visible = not _root.visible func set_mobile_mode(enabled: bool) -> void: _mobile_mode = enabled if _root: _root.visible = not enabled 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.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) # Give the root a real extent. A zero-sized Control happened to work with # some viewport sizes, but makes anchoring and hit testing fragile on Retina # windows. The old HUD's empty row then made this look like missing skills. _root.size = Vector2(400, 56) _root.position = Vector2(-200, -62) _root.z_index = 10 parent.add_child(_root) var prev := Button.new() prev.text = "‹" prev.position = Vector2(-24, 7) prev.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT))) _root.add_child(prev) var next := Button.new() next.text = "›" next.position = Vector2(SLOTS_PER_PAGE * 44 + 2, 7) next.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT))) _root.add_child(next) var page_label := Label.new() page_label.name = "page" page_label.position = Vector2(SLOTS_PER_PAGE * 44 + 7, 38) page_label.add_theme_font_size_override("font_size", 9) _root.add_child(page_label) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 4) _root.add_child(row) for i in SLOTS_PER_PAGE: var slot := Panel.new() slot.custom_minimum_size = Vector2(40, 40) 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 num := Label.new() num.text = str(i + 1) num.position = Vector2(3, 1) num.add_theme_font_size_override("font_size", 10) num.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(num) var icon := TextureRect.new() icon.name = "icon" icon.position = Vector2(4, 4) 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) var lbl := Label.new() lbl.name = "lbl" lbl.position = Vector2(3, 15) lbl.add_theme_font_size_override("font_size", 9) lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE slot.add_child(lbl) var grade := Label.new() grade.name = "grade" grade.position = Vector2(25, 25) grade.size = Vector2(13, 13) 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_LEFT: _try_drop(idx) elif e.button_index == MOUSE_BUTTON_RIGHT: _clear(idx)) row.add_child(slot) _slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade}) if item_mouse and item_mouse.has_method("register_target"): var local_idx: int = i item_mouse.register_target(slot, func(payload: Dictionary): return _drop_mouse_item(payload, local_idx), self) _refresh_page() func _drop_mouse_item(payload: Dictionary, slot: int) -> bool: if payload.is_empty() or String(payload.get("source", "")) != "inventory": return false 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))) func _try_drop(slot: int) -> void: # 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 n.get_script() and n.has_method("set_job") and "drag_skill_id" in n: 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 := "" 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": txt = "#%d" % s.id # 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。 var dim := false if s.kind == "item" and client and client.has_method("get_item"): var it: Dictionary = client.get_item(1, s.id) if it.is_empty() or int(it.get("vnum", 0)) == 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) _slots[slot].lbl.text = txt if tex == null else "" _slots[slot].grade.text = grade_text _slots[slot].grade.visible = grade_text != "" and tex != null 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 _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 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