307 lines
16 KiB
GDScript
307 lines
16 KiB
GDScript
# 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")
|
||
|
||
# 服务器 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 -> 重填")
|