完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+110 -13
View File
@@ -1,29 +1,36 @@
# Quickbar (P6) —— 快捷栏:36 槽(4 页 × 9 格),数字键 1-9 施放技能 / 用物品
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 832 格)。
#
# 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 切页
# # 输入:数字 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)。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
const PlayerSkill := preload("res://player_skill.gd")
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 := 9
const SLOTS_PER_PAGE := 8
const PAGE_COUNT := 4
const SLOT_COUNT := SLOTS_PER_PAGE * PAGE_COUNT
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 _slots := [] # 当前页 UI[{btn, cd, lbl}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _page := 0
@@ -33,6 +40,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
client = m2client
table = skill_table
_player_getter = player_getter
_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)
@@ -43,7 +52,7 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 服务器 type1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id
# 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)
func restore_from_server() -> void:
if client == null or not client.has_method("get_quickslots"):
return
@@ -57,19 +66,23 @@ func restore_from_server() -> void:
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) -> void:
func assign(slot: int, kind: String, id: int, persist := true) -> bool:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
return false
if kind not in ["skill", "item", "emote"] or id <= 0:
return false
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
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
return false
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
_refresh_slot(slot)
return true
func activate(slot: int) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
@@ -91,6 +104,38 @@ func activate(slot: int) -> void:
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.
@@ -100,9 +145,42 @@ func activate(slot: int) -> void:
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)
# 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:
@@ -111,6 +189,13 @@ func set_page(page: int) -> void:
_move_from = -1
_refresh_page()
func toggle_visible() -> void:
if _root:
_root.visible = not _root.visible
func is_visible() -> bool:
return _root != null and _root.visible
func _global_slot(local_slot: int) -> int:
return _page * SLOTS_PER_PAGE + local_slot
@@ -141,7 +226,7 @@ func _swap(local_a: int, local_b: int) -> void:
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_root.position = Vector2(-9 * 23, -54)
_root.position = Vector2(-SLOTS_PER_PAGE * 23, -54)
parent.add_child(_root)
var prev := Button.new()
prev.text = ""
@@ -150,12 +235,12 @@ func _build(parent: Node) -> void:
_root.add_child(prev)
var next := Button.new()
next.text = ""
next.position = Vector2(9 * 44 + 2, 7)
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(9 * 44 + 7, 38)
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()
@@ -194,8 +279,20 @@ func _build(parent: Node) -> void:
_clear(idx))
row.add_child(slot)
_slots.append({"btn": slot, "cd": cd, "lbl": lbl})
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()