Files
mtgodot-poc/project/skill_test.gd
T
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00

410 lines
21 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.
# skill_test —— P6skilldesc 解析 + skill_ui 行 + quickbar 施放/冷却 headless 自检。
# godot --headless --path project --script skill_test.gd
extends SceneTree
const SkillTable = preload("res://ui/skill_table.gd")
const SkillUI = preload("res://ui/skill_ui.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const UiManager = preload("res://ui/ui_manager.gd")
# __ReserveUseSkill 的最小 NetPlay 面:射程上下文 + 预约记录。
class FakeRangeNetPlay extends Node:
var distance_cm := 100000.0
var reservations := []
func skill_context() -> Dictionary:
return {"bow_distance": 2, "target_distance": func(_vid: int) -> float: return distance_cm}
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
reservations.append([vid, slot, int(range_cm)])
func is_use_skill_reserved(slot: int) -> bool:
return not reservations.is_empty() and int(reservations[-1][1]) == slot
class FakeClient extends Node:
signal skills_changed()
signal skill_cooldown_end(skill: int)
signal quickslots_changed()
var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 2}]
var skill_group := 1
var points := {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2}
var quickslots := []
var casts := []
var skill_uses := []
var quickslot_ops := []
var saids := []
var emotes := []
var main := 1000
func get_main_vid() -> int: return main
# vid 2000 是选中目标:给个 kind=2(怪)让 §3.8 目标解析判其可攻击。
func get_entity(_v) -> Dictionary: return {"race": 0, "kind": 2}
func get_inventory() -> Array: return []
func get_skills() -> Array: return skills
func get_skill_group() -> int: return skill_group
func get_points() -> Dictionary: return points
func get_quickslots() -> Array: return quickslots
func get_target() -> Dictionary: return {"vid": 2000}
func cast_skill(mi, rot, x, y) -> bool: casts.append([mi, rot, x, y]); return true
func use_skill(id, target_vid) -> bool: skill_uses.append([id, target_vid]); return true
func quickslot_add(pos, type, ref) -> bool: quickslot_ops.append(["add", pos, type, ref]); return true
func quickslot_del(pos) -> bool: quickslot_ops.append(["del", pos]); return true
func quickslot_swap(pos, change_pos) -> bool: quickslot_ops.append(["swap", pos, change_pos]); return true
func skill_up(id) -> bool: saids.append(id); return true
func use_item(w, c) -> bool: return true
func send_emoticon(id) -> bool: emotes.append(id); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: skill_test (table + skill_ui + quickbar)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var assets := AssetRoot.path()
var st := SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
if not loaded:
print(" (skip: no skilldesc.txt)")
return
_ck(st.count > 30, "skilldesc parsed (%d skills)" % st.count)
var w := st.for_job("WARRIOR")
_ck(w.size() > 3, "WARRIOR has skills (%d)" % w.size())
var s1: Dictionary = st.entry(1)
_ck(s1.get("job", "") == "WARRIOR", "skill 1 job = WARRIOR")
_ck(String(s1.get("name", "")) != "", "skill 1 has a name (%s)" % s1.get("name"))
# 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL
_ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)")
_ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon")
var skill_icon := UiAssets.load_tex(assets, "ETC/ymir work/ui/skill/warrior/samyeon_01.sub")
_ck(skill_icon != null, "skill 1 icon loads from .sub + shared SkillWarrior.dds")
if skill_icon:
_ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32,
"skill 1 icon region is 32x32")
_ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL")
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
_ck(not st.is_ranged(1), "skill 1 melee skill is not ranged")
_ck(is_equal_approx(st.cooldown_of(1, 5), 15.0), "skill 1 .msk CoolTimeFormula = 15s")
_ck(st.is_attack(19) == false and String(st.entry(19).get("name", "")) != "",
"skill 19 'Strong Body' 非攻击")
# 辅助 / 被动树
var sup := st.for_category("SUPPORT")
_ck(sup.size() > 3, "SUPPORT 分类有技能 (%d)" % sup.size())
_ck(st.is_passive(121), "skill 121 'Leadership' is_passive")
_ck(st.can_level_up(125) == false, "skill 125 'Item Creation' CANNOT_LEVEL_UP")
_ck(st.for_category("HORSE").size() >= 3, "HORSE 分类有技能")
# --- §3.8 TSkillData 判定位解析(增量 87):对齐 PythonSkill.cpp 谓词 ---
# skill 1 Three-Way CutATTACK_SKILL|NEED_TARGET|WEAPON_LIMITATIONW=SWORD|TWO_HANDED
_ck(st.attr_bits(1) & st.ATTR["ATTACK_SKILL"] != 0, "attr_bits(1) 含 ATTACK_SKILL 位")
_ck(st.is_need_target(1), "IsNeedTarget(1)")
_ck(st.is_can_use_skill(1), "IsCanUseSkill(1)(非被动)")
_ck(st.has_weapon_limitation(1), "skill 1 WEAPON_LIMITATION")
_ck(st.can_use_weapon_type(1, st.WEAPON_SWORD), "CanUseWeaponType(1, SWORD)")
_ck(st.can_use_weapon_type(1, st.WEAPON_TWO_HANDED), "CanUseWeaponType(1, TWO_HANDED)")
_ck(not st.can_use_weapon_type(1, st.WEAPON_BOW), "CanUseWeaponType(1, BOW) == false")
# skill 121 LeadershipPASSIVE —— IsCanUseSkill 必须挡下
_ck(st.is_passive(121) and not st.is_can_use_skill(121), "IsCanUseSkill(121 被动) == false")
# skill 18 StumpATTACK_SKILL|STANDING_SKILL
_ck(st.is_standing(18) and st.is_attack(18), "IsStandingSkill(18) && IsAttackSkill(18)")
_ck(not st.is_standing(1), "skill 1 非 STANDING_SKILL")
# skill 46 Repetitive ShotBOW
_ck(st.is_need_bow(46) and st.is_ranged(46), "IsNeedBow(46)")
_ck(st.can_use_weapon_type(46, st.WEAPON_BOW), "CanUseWeaponType(46, BOW)")
_ck(not st.can_use_weapon_type(46, st.WEAPON_SWORD), "CanUseWeaponType(46, SWORD) == false")
# skill 138 Horse StumpHORSE_SKILL|SEARCH_TARGET|CHARGE_ATTACK
_ck(st.is_horse_skill(138), "IsHorseSkill(138)")
_ck(st.is_auto_search_target(138), "IsAutoSearchTarget(138)")
_ck(st.is_charge_skill(138), "IsChargeSkill(138)")
_ck(st.skill_type_of(138) == st.SKILL_TYPE_HORSE, "skill_type_of(138) == SKILL_TYPE_HORSE")
# skill 137 Horseback SlashMOVING_SKILL
_ck(st.is_moving_skill(137), "IsMovingSkill(137)")
# skill 16 Spirit Strike (W)CAN_CHANGE_DIRECTION
_ck(st.can_change_direction(16), "CanChangeDirection(16)")
_ck(not st.can_change_direction(1), "skill 1 无 CAN_CHANGE_DIRECTION")
# skill 151 Dragon EyesJOB=GUILD
_ck(st.skill_type_of(151) == st.SKILL_TYPE_GUILD and st.is_guild_skill(151),
"skill_type_of(151) == SKILL_TYPE_GUILD")
# skill 152 Blood of Dragon GodONLY_FOR_GUILD_WAR
_ck(st.is_only_for_guild_war(152), "IsOnlyForGuildWar(152)")
# skill 109 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
_ck(st.can_use_for_me(109) and st.is_only_for_alliance(109),
"CanUseForMe(109) && IsOnlyForAlliance(109)")
# skill 5 DashCHARGE_ATTACKskill 31 AmbushMELEE_ATTACK
_ck(st.is_charge_skill(5), "IsChargeSkill(5)")
_ck(st.is_melee(31), "IsMeleeSkill(31)")
# skill 63 Enchanted BladeTOGGLE|STANDING_SKILL|WEAPON_LIMITATION W=SWORD
_ck(st.is_toggle(63) and st.is_standing(63), "IsToggleSkill(63) && IsStandingSkill(63)")
_ck(st.can_use_weapon_type(63, st.WEAPON_SWORD)
and not st.can_use_weapon_type(63, st.WEAPON_DAGGER),
"skill 63 只吃 SWORD")
# 无 WEAPON_LIMITATION 的技能:CanUseWeaponType 恒真(PythonSkill.cpp:1143
_ck(st.can_use_weapon_type(106, st.WEAPON_FAN) and st.can_use_weapon_type(106, st.WEAPON_BOW),
"skill 106 无武器限制 -> CanUseWeaponType 恒真")
# _fold_bits:瓶子 / USE_HP 记号(当前语言数据里没有对应技能,直接验折叠逻辑)
_ck(SkillTable._fold_bits("NEED_EMPTY_BOTTLE|NEED_POISON_BOTTLE", st.ATTR)
== (st.ATTR["NEED_EMPTY_BOTTLE"] | st.ATTR["NEED_POISON_BOTTLE"]),
"_fold_bits 折叠 NEED_*_BOTTLE")
_ck(SkillTable._fold_bits("use_hp , circle_range foo", st.ATTR)
== (st.ATTR["USE_HP"] | st.ATTR["CIRCLE_RANGE"]),
"_fold_bits 大小写/逗号/空格宽容,未知记号忽略")
_ck(SkillTable._fold_bits("SWORD|DOUBLE_SWORD", st.NEED_WEAPON)
== (1 << st.WEAPON_SWORD | 1 << st.WEAPON_DAGGER),
"_fold_bits DOUBLE_SWORD 复用 DAGGER 位")
# --- §3.10 技能升级门(增量 90):max_level / level_limit / CanLevelUpSkill ---
# skill 1 samyeon.msk 有 MaxLevel "30"
_ck(st.max_level_of(1) == 30, "max_level_of(1) == 30samyeon.msk MaxLevel")
# skill 121 Leadership 是 SUPPORT,无 job_dir -> _load_msk 提前返回;skilldesc 无 MAX_LEVEL 列
_ck(st.max_level_of(121) == 20, "max_level_of(121) == 20SSkillData 构造默认)")
# 本 40250 shipped skilldesc.txt 只有 22 列,无 LEVEL_LIMIT 列 -> 恒 0
_ck(st.skill_level_limit(1) == 0, "skill_level_limit(1) == 0(本 build 无 LEVEL_LIMIT 数据)")
# skillCanLevelUpSkill:未满级且非 CANNOT_LEVEL_UP
_ck(st.can_level_up_skill(1, 5), "can_level_up_skill(1, 5) == true")
_ck(st.can_level_up_skill(1, 29), "can_level_up_skill(1, 29) == true29 < 30")
_ck(not st.can_level_up_skill(1, 30), "can_level_up_skill(1, 30) == false>= MaxLevel")
_ck(not st.can_level_up_skill(1, 31), "can_level_up_skill(1, 31) == false")
_ck(not st.can_level_up_skill(125, 1), "can_level_up_skill(125, 1) == falseCANNOT_LEVEL_UP")
_ck(not st.can_level_up_skill(9999, 1), "can_level_up_skill(未知 id) == false")
# --- §3.4 扇形/圆形补目标元数据(增量 91):GetTargetCount / __GetSkillTargetRange / FlyShape ---
# skill 47 'Arrow Shower' = gwangyeok.mskFAN_RANGE + TargetCountFormula + Range 2500
_ck(st.is_fan_range(47), "skill 47 IsFanRangegwangyeok FAN_RANGE")
_ck(st.fly_shape(47) == 1, "fly_shape(47) == FAN(1)")
_ck(st.target_range(47) == 2500, "target_range(47) == 2500gwangyeok.msk Range")
# "2 + floor(6 * SkillPoint)"lv1 SkillPoint=0.05 -> 2lv20(master) SkillPoint=0.5 -> 5
_ck(st.target_count(47, 1) == 2, "target_count(47, lv1) == 2")
_ck(st.target_count(47, 20) == 5, "target_count(47, lv20) == 5")
# skill 91 'Flying Talisman' = bipabu.mskFAN_RANGE + Range 1800,无 TargetCountFormula
_ck(st.is_fan_range(91), "skill 91 IsFanRangebipabu FAN_RANGE")
_ck(st.target_range(91) == 1800, "target_range(91) == 1800bipabu.msk Range")
_ck(st.target_count(91, 20) == 0, "target_count(91) == 0bipabu 无 TargetCountFormula -> 仅主目标)")
# LoadLocaleDataRegisterSkillDesc 后 RegisterSkillTablePythonApplication.cpp:1049-1055)。
# RegisterSkill(.msk) 在参考端没有调用点,dwTargetRange 只来自 SkillTable.txt 的
# TARGET_RANGE 列;GetTargetRangePythonSkill.cpp:1086)对 MELEE/CHARGE_ATTACK 固定 170。
var tbl := SkillTable.new()
for lang in ["en", "common"]:
if tbl.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
break
var table_loaded: bool = tbl.has_method("load_table") \
and bool(tbl.call("load_table", assets.path_join("locale/locale/common/skilltable.txt")))
_ck(table_loaded, "load_table(common/skilltable.txt) succeeds")
if table_loaded:
_ck(tbl.target_range(1) == 0, "SkillTable TARGET_RANGE 0 overrides samyeon.msk Range: %d" % tbl.target_range(1))
_ck(tbl.target_range(47) == 2500, "target_range(47) == 2500SkillTable")
_ck(tbl.target_range(91) == 1800, "target_range(91) == 1800SkillTable")
_ck(tbl.target_range(31) == 170, "MELEE_ATTACK 31 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(31))
_ck(tbl.target_range(5) == 170, "CHARGE_ATTACK 5 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(5))
# 预约趋近要在 Actor 碰撞(两 body 各 55cm)挡住前进入射程。
for id in tbl._by_id.keys():
var r := tbl.target_range(int(id))
_ck(r == 0 or r - 10 > 110, "skill %d range %d is reachable past actor collision" % [id, r])
# 非扇形/圆形技能:fly_shape == SINGLE(0),无 target_range
_ck(st.fly_shape(1) == 0, "fly_shape(1) == SINGLE(0)samyeon 非范围技)")
_ck(st.target_count(1, 10) == 0, "target_count(1) == 0(无 TargetCountFormula")
var fc := FakeClient.new()
get_root().add_child(fc)
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready()
# skill_ui
var sk: Node = SkillUI.new()
get_root().add_child(sk)
sk.setup(fc, st, ui)
sk.set_job("WARRIOR")
sk.open()
_ck(sk.is_open(), "skill window opened")
_ck(sk._rows.size() == w.size(), "skill window rows = WARRIOR skill count")
# 找 skill 1 的行,点加号
if sk._rows.has(1):
sk._rows[1]["up"].pressed.emit()
_ck(fc.saids == [1], " -> client.skill_up(1)")
_ck(String(sk._rows[1]["lv"].text) == "Lv 5", "skill 1 shows Lv 5 (from get_skills)")
# skill 16 master 2 -> "Lv 3 G"
if sk._rows.has(16):
_ck(String(sk._rows[16]["lv"].text) == "Lv 3 G", "skill 16 master 2 -> 'Lv 3 G'")
# --- §3.10 加号按钮门控(增量 90):主动页 ---
if sk._rows.has(1) and sk._rows[1]["up"] != null:
_ck(sk._rows[1]["up"].visible, "主动页 skill 1lv5 grade0, skill_active=5, group=1)加号可见")
if sk._rows.has(16) and sk._rows[16]["up"] != null:
_ck(not sk._rows[16]["up"].visible, "skill 16 skillGrade!=0 -> 加号隐藏")
# 技能组未选(GetMainActorSkillGroup()==0-> 整列隐藏加号
fc.skill_group = 0
sk.refresh()
if sk._rows.has(1) and sk._rows[1]["up"] != null:
_ck(not sk._rows[1]["up"].visible, "skill_group==0 -> __CanUseSkillNow 挡下,加号隐藏")
fc.skill_group = 1
# 该页技能点为 0 -> 加号隐藏
fc.points = {"level": 30, "skill_active": 0, "skill_support": 3, "skill_horse": 2}
sk.refresh()
if sk._rows.has(1) and sk._rows[1]["up"] != null:
_ck(not sk._rows[1]["up"].visible, "SKILL_ACTIVE 点数为 0 -> 加号隐藏")
fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2}
sk.refresh()
# 切到「辅助」页 -> 列 SUPPORT,被动技能无 []
sk._switch_tab(1)
_ck(sk._cat_key() == "SUPPORT", "tab 1 -> SUPPORT")
_ck(sk._rows.has(121) and sk._rows[121]["up"] == null, "被动技能 121 无 [] 按钮")
# 有能加点的辅助技能带 []
var any_up := false
for rid in sk._rows:
if sk._rows[rid]["up"] != null:
any_up = true
_ck(any_up or sup.all(func(i): return st.is_passive(i) or not st.can_level_up(i)),
"辅助页有可加点项 或 全是被动")
# §3.10HIDE_SUPPORT_SKILL_POINT -> 辅助页所有加号按钮隐藏(即便技能点足够)
var sup_any_visible := false
for rid in sk._rows:
if sk._rows[rid]["up"] != null and sk._rows[rid]["up"].visible:
sup_any_visible = true
_ck(not sup_any_visible, "辅助页 HIDE_SUPPORT_SKILL_POINT -> 加号全隐藏")
sk._switch_tab(2)
_ck(sk._cat_key() == "HORSE", "tab 2 -> HORSE")
# §3.10:马术页 —— skill_horse 点数 > 0 且 skillLevel < 20 -> 加号可见
var horse_up_id := 0
for rid in sk._rows:
if sk._rows[rid]["up"] != null:
horse_up_id = rid
break
if horse_up_id != 0:
_ck(sk._rows[horse_up_id]["up"].visible,
"马术页 skill %dlv0 < 20, skill_horse=2)加号可见" % horse_up_id)
fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 0}
sk.refresh()
_ck(not sk._rows[horse_up_id]["up"].visible, "SKILL_HORSE 点数为 0 -> 马术加号隐藏")
fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2}
sk.refresh()
sk._switch_tab(0) # 切回主动,后面的 quickbar 断言不受影响
# quickbar
var pl := Node3D.new()
get_root().add_child(pl)
var qb: Node = Quickbar.new()
get_root().add_child(qb)
qb.setup(fc, st, ui, func() -> Node: return pl)
var cast_targets: Array = []
qb.skill_cast_started.connect(func(sid: int, vid: int): cast_targets.append([sid, vid]))
qb.assign(0, "skill", 1)
_ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon")
_ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available")
_ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0")
qb.activate(0)
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
"quickbar slot 0 -> use_skill(skill 1, selected target)")
_ck(fc.casts.size() == 1, "quickbar slot 0 -> cast_skill once")
_ck(cast_targets == [[1, 2000]], "successful cast publishes resolved visual target")
if fc.casts.size() == 1:
_ck(int(fc.casts[0][0]) == st.motion_idx_of(1), "cast used skill 1's motion_idx")
# 冷却中不再施放
qb.activate(0)
_ck(fc.skill_uses.size() == 1, "on cooldown -> no second use_skill")
_ck(fc.casts.size() == 1, "on cooldown -> no second cast")
qb.assign(1, "skill", 1, false)
qb.activate(1)
_ck(fc.casts.size() == 1, "same skill in another slot shares cooldown")
fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}, {"pos": 1, "type": 2, "ref": 1}]
qb.restore_from_server()
qb._process(0.0)
_ck(qb._slots[0].cd.visible and qb._slots[1].cd.visible, "server slot refresh preserves cooldown overlays")
qb.activate(0)
_ck(fc.casts.size() == 1, "server refresh cannot reset skill cooldown")
fc.quickslots = [] # restore this fixture's original server state for later UI cases
qb._clear(1)
# GC_SKILL_COOLTIME_END 提前解锁
fc.skill_cooldown_end.emit(1)
qb.activate(0)
_ck(fc.skill_uses.size() == 2, "cooldown_end -> can use_skill again")
_ck(fc.casts.size() == 2, "cooldown_end -> can cast again")
# ClientVS22 的本地快捷栏显示 4 页 × 8 格;服务器仍保留 36 槽,
# 因此最后 4 个槽只可由协议恢复,不能从键盘本地显示/编辑。
qb.set_page(3)
qb.assign(7, "item", 9)
_ck(qb._state.size() == 36 and qb._state[31].kind == "item" and qb._state[31].id == 9,
"quickbar has 36 server slots; page 4 slot 8 = global slot 31")
_ck(fc.quickslot_ops.back() == ["add", 31, 1, 9], "page 4 assign -> CG_QUICKSLOT_ADD{31,item,9}")
qb.set_page(0)
qb._swap(0, 1) # UI 的两次点击最终调用同一个交换操作
_ck(fc.quickslot_ops.back() == ["swap", 0, 1], "quickslot move -> CG_QUICKSLOT_SWAP")
qb._clear(1)
_ck(fc.quickslot_ops.back() == ["del", 1], "quickslot clear -> CG_QUICKSLOT_DEL")
# 移动技能页:横屏大触控行 + 一键放入当前页快捷栏空位,替代 PC 拖放。
sk.close()
sk.set_mobile_mode(true)
sk.set_mobile_quickbar(qb)
sk.open()
_ck(sk.is_open() and sk._win.size == Vector2(760, 340),
"mobile skill window uses landscape base size")
_ck(sk._win.get_node_or_null("SkillScroll") is ScrollContainer,
"mobile skill list is scrollable")
if sk._rows.has(16):
sk._mobile_assign_skill(16)
_ck(qb._state[0].kind == "skill" and qb._state[0].id == 16,
"mobile skill action assigns to first free quickbar slot")
sk.close()
# 射程外(__ProcessEnemySkillTargetRange):不 cast、预约;蓄力技仅首次预约前发 use_skill(id, 0)
if st.has(5) and st.is_charge_skill(5):
var np := FakeRangeNetPlay.new()
get_root().add_child(np)
fc.skills.append({"id": 5, "level": 1, "master": 0})
var qb3: Node = Quickbar.new()
qb3.net_play = np
get_root().add_child(qb3)
qb3.setup(fc, st, ui, func() -> Node: return pl)
qb3.assign(5, "skill", 5, false)
var uses0: int = fc.skill_uses.size()
var casts0: int = fc.casts.size()
var rejected := []
qb3.skill_rejected.connect(func(sid: int, code: String): rejected.append([sid, code]))
qb3.activate(5)
var range5: int = st.target_range(5) + 200
_ck(fc.casts.size() == casts0 and fc.skill_uses.slice(uses0) == [[5, 0]],
"charge skill out of range -> only use_skill(5, 0), no cast: %s" % [fc.skill_uses.slice(uses0)])
_ck(np.reservations == [[2000, 5, range5]], "out of range -> reserve_use_skill(target, slot, TargetRange+bow*100)")
_ck(rejected.is_empty(), "reservation is silent (no OnCannotUseSkill)")
qb3.activate(5)
_ck(fc.skill_uses.size() == uses0 + 1 and np.reservations.size() == 2,
"already reserved charge skill -> no second use_skill(5, 0)")
_ck(not qb3.activate_reserved(5), "reserved fire still out of range keeps the new reservation")
np.distance_cm = 10.0
_ck(qb3.activate_reserved(5), "reserved fire in range consumes the reservation")
_ck(fc.casts.size() == casts0 + 1 and fc.skill_uses.back() == [5, 2000], "in range -> use_skill(5, target) + one cast")
fc.skills.pop_back()
qb3.free()
np.free()
# 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格
var qb2: Node = Quickbar.new()
get_root().add_child(qb2)
fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9},
{"pos": 4, "type": 3, "ref": 11}]
qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server
_ck(qb2._root.size == Vector2(400, 56) and qb2._root.z_index > 0,
"quickbar has a real bottom-anchor extent and stays above legacy HUD")
_ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16")
_ck(qb2._slots[2].icon.texture != null and qb2._slots[2].icon.visible,
"server-restored skill slot renders its icon")
_ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9")
_ck(qb2._state[4].kind == "emote" and int(qb2._state[4].id) == 11, "quickslot 恢复:格4=表情11")
qb2.activate(4)
_ck(fc.emotes == [11], "表情快捷栏 -> send_emoticon(11)")
fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}]
fc.quickslots_changed.emit()
_ck(qb2._state[0].kind == "skill" and int(qb2._state[0].id) == 1, "quickslots_changed -> 重填")