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
273 lines
9.0 KiB
GDScript
273 lines
9.0 KiB
GDScript
# 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
|