Files
mtgodot-poc/project/ui/quickbar.gd
T
2026-09-07 14:53:36 +08:00

525 lines
19 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。
#
# var qb := preload("res://ui/quickbar.gd").new()
# add_child(qb)
# qb.setup(m2client, skill_table, canvas_parent, player_getter)
# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1
# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水
# # 输入:数字 1..4 -> 本页 0..3F1..F4 -> 本页 4..7。
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(默认朝向 = 玩家 yaw)。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
const PlayerSkill := preload("res://player_skill.gd")
const 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 个快捷栏槽位。
# 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。
func restore_from_server() -> void:
if client == null or not client.has_method("get_quickslots"):
return
var restored := []
for _i in SLOT_COUNT:
restored.append({"kind": "", "id": 0, "cd_end": 0.0})
for qs in client.get_quickslots():
var pos := int(qs.get("pos", -1))
if pos < 0 or pos >= SLOT_COUNT:
continue
match int(qs.get("type", 0)):
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
2: 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