Files
mtgodot-poc/project/ui/quickbar.gd
T
shenandClaude Sonnet 5 47baf6c0c6 Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
2026-08-31 20:02:12 +09:00

273 lines
9.0 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 页 × 9 格),数字键 1-9 施放技能 / 用物品。
#
# 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 格的药水
# # 输入: game_scene 里 KEY_1..KEY_9 -> qb.activate(n)F1..F4 切页
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
const CM := 100.0
const DEFAULT_CD := 2.0
const SLOTS_PER_PAGE := 9
const PAGE_COUNT := 4
const SLOT_COUNT := SLOTS_PER_PAGE * PAGE_COUNT
var client: Node
var table: RefCounted # SkillTable
var _player_getter: Callable
var _root: Control
var _slots := [] # 当前页 UI[{btn, cd, lbl}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _page := 0
var _move_from := -1
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void:
client = m2client
table = skill_table
_player_getter = player_getter
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)
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 服务器 type:1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id。
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}
_state = restored
_refresh_page()
func assign(slot: int, kind: String, id: int, persist := true) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
if persist and client and client.has_method("quickslot_add"):
var type := 2 if kind == "skill" else 1 if kind == "item" else 0
if type == 0 or not client.quickslot_add(global, type, id):
return
_refresh_slot(slot)
func activate(slot: int) -> 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)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
# 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
skill_activated.emit(int(s.id))
elif s.kind == "item":
client.use_item(1, s.id) # WINDOW_INVENTORY
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 _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(-9 * 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(9 * 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(9 * 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)
slot.add_child(num)
var lbl := Label.new()
lbl.name = "lbl"
lbl.position = Vector2(3, 15)
lbl.add_theme_font_size_override("font_size", 9)
slot.add_child(lbl)
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
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})
_refresh_page()
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 := ""
if s.kind == "skill" and table:
txt = table.name_of(s.id).substr(0, 5)
elif s.kind == "item":
txt = "#%d" % s.id
_slots[slot].lbl.text = txt
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