# skill_test —— P6:skilldesc 解析 + 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 UiManager = preload("res://ui/ui_manager.gd") 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") _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 Cut:ATTACK_SKILL|NEED_TARGET|WEAPON_LIMITATION,W=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 Leadership:PASSIVE —— IsCanUseSkill 必须挡下 _ck(st.is_passive(121) and not st.is_can_use_skill(121), "IsCanUseSkill(121 被动) == false") # skill 18 Stump:ATTACK_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 Shot:BOW _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 Stump:HORSE_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 Slash:MOVING_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 Eyes:JOB=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 God:ONLY_FOR_GUILD_WAR _ck(st.is_only_for_guild_war(152), "IsOnlyForGuildWar(152)") # skill 109 Cure:CAN_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 Dash:CHARGE_ATTACK;skill 31 Ambush:MELEE_ATTACK _ck(st.is_charge_skill(5), "IsChargeSkill(5)") _ck(st.is_melee(31), "IsMeleeSkill(31)") # skill 63 Enchanted Blade:TOGGLE|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) == 30(samyeon.msk MaxLevel)") # skill 121 Leadership 是 SUPPORT,无 job_dir -> _load_msk 提前返回;skilldesc 无 MAX_LEVEL 列 _ck(st.max_level_of(121) == 20, "max_level_of(121) == 20(SSkillData 构造默认)") # 本 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) == true(29 < 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) == false(CANNOT_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.msk:FAN_RANGE + TargetCountFormula + Range 2500 _ck(st.is_fan_range(47), "skill 47 IsFanRange(gwangyeok FAN_RANGE)") _ck(st.fly_shape(47) == 1, "fly_shape(47) == FAN(1)") _ck(st.target_range(47) == 2500, "target_range(47) == 2500(gwangyeok.msk Range)") # "2 + floor(6 * SkillPoint)":lv1 SkillPoint=0.05 -> 2;lv20(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.msk:FAN_RANGE + Range 1800,无 TargetCountFormula _ck(st.is_fan_range(91), "skill 91 IsFanRange(bipabu FAN_RANGE)") _ck(st.target_range(91) == 1800, "target_range(91) == 1800(bipabu.msk Range)") _ck(st.target_count(91, 20) == 0, "target_count(91) == 0(bipabu 无 TargetCountFormula -> 仅主目标)") # 非扇形/圆形技能: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 1(lv5 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.10:HIDE_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 %d(lv0 < 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) qb.assign(0, "skill", 1) _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") 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") # 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() # 服务器 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._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16") _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 -> 重填")