525 lines
19 KiB
GDScript
525 lines
19 KiB
GDScript
# 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 接它播技能特效
|
||
# §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 _page := 0
|
||
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)
|
||
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: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 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": 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
|
||
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 == "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)):
|
||
s.cd_end = _now() + _skill_cooldown(int(s.id))
|
||
_state[global] = s
|
||
# §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)
|
||
|
||
# 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
|
||
activate(local)
|
||
return true
|
||
|
||
# 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)
|
||
_root.position = Vector2(-SLOTS_PER_PAGE * 23, -54)
|
||
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
|
||
var icon: TextureRect = _slots[slot].icon
|
||
icon.texture = tex
|
||
icon.visible = tex != null
|
||
_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:
|
||
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
|