# SkillTable (P6) —— 读 `locale/locale//skilldesc.txt`:skill id → 名/职业/动作。 # # 列(TSV,1-indexed,PythonSkill.cpp __LoadSkillDesc): # 1 id · 2 JOB(WARRIOR/ASSASSIN/SURA/SHAMAN/SUPPORT/HORSE/GUILD) · 3-5 name/name2/name3 # 6-9 desc1-4 · 10 (空) · 11 attributes(ATTACK_SKILL|PASSIVE|TOGGLE|STANDING_SKILL|…) # 12 weapon_limit(SWORD|BOW|…) · 13 motion_name · 14 motion_idx · 15 grade_count · 16+ 公式 # # var st := preload("res://ui/skill_table.gd").new() # st.load_file("/locale/locale/en/skilldesc.txt") # st.entry(1) # { id, job, name, …, attrs, motion, motion_idx, grades, cooltime } # st.for_job("WARRIOR") # 主动职业技能 [id, …] # st.for_category("SUPPORT") # 辅助 / 被动树 # st.is_passive(121) # true # # §3.8 TSkillData 判定位:第 11 列 attributes / 第 12 列 weapon_limit 是 `|` 分隔的 # 记号串,原客户端 RegisterSkillDesc(PythonSkill.cpp:280)把它们经 # m_SkillAttributeIndexMap / m_SkillNeedWeaponIndexMap 折成 dwSkillAttribute / # dwNeedWeapon 两个位掩码,CPythonSkill 的一众 IsXxx() 谓词就是对这两个掩码取位。 # 这里在 load_file 时做同样的折叠,谓词逐条对齐 PythonSkill.cpp。 extends RefCounted var _by_id := {} var count := 0 const JOB_DIR := { "WARRIOR": "warrior", "ASSASSIN": "assassin", "SURA": "sura", "SHAMAN": "shaman", } # --- SKILL_ATTRIBUTE_*(PythonSkill.h:92-115,记号名同 m_SkillAttributeIndexMap)--- const ATTR := { "NEED_TARGET": 1 << 0, "TOGGLE": 1 << 1, "WEAPON_LIMITATION": 1 << 2, "MELEE_ATTACK": 1 << 3, "USE_HP": 1 << 4, "CAN_CHANGE_DIRECTION": 1 << 5, "STANDING_SKILL": 1 << 6, "ONLY_FOR_ALLIANCE": 1 << 7, "CAN_USE_FOR_ME": 1 << 8, "NEED_CORPSE": 1 << 9, "FAN_RANGE": 1 << 10, "CAN_USE_IF_NOT_ENOUGH": 1 << 11, "NEED_EMPTY_BOTTLE": 1 << 12, "NEED_POISON_BOTTLE": 1 << 13, "ATTACK_SKILL": 1 << 14, "TIME_INCREASE_SKILL": 1 << 15, "CHARGE_ATTACK": 1 << 16, "PASSIVE": 1 << 17, "CANNOT_LEVEL_UP": 1 << 18, "ONLY_FOR_GUILD_WAR": 1 << 19, "MOVING_SKILL": 1 << 20, "HORSE_SKILL": 1 << 21, "CIRCLE_RANGE": 1 << 22, "SEARCH_TARGET": 1 << 23, } # --- CItemData::EWeaponSubTypes(GameLib/ItemData.h:63-75)--- const WEAPON_SWORD := 0 const WEAPON_DAGGER := 1 const WEAPON_BOW := 2 const WEAPON_TWO_HANDED := 3 const WEAPON_BELL := 4 const WEAPON_FAN := 5 const WEAPON_ARROW := 6 const WEAPON_NONE := 8 # WEAPON_NUM_TYPES(7) + 1 # --- SKILL_NEED_WEAPON_*(PythonSkill.h:120-128,位 = 1 << CItemData::WEAPON_*)--- # 记号名同 m_SkillNeedWeaponIndexMap;DOUBLE_SWORD 复用 DAGGER 位。 const NEED_WEAPON := { "SWORD": 1 << WEAPON_SWORD, "DAGGER": 1 << WEAPON_DAGGER, "BOW": 1 << WEAPON_BOW, "TWO_HANDED": 1 << WEAPON_TWO_HANDED, "DOUBLE_SWORD": 1 << WEAPON_DAGGER, "BELL": 1 << WEAPON_BELL, "FAN": 1 << WEAPON_FAN, "ARROW": 1 << WEAPON_ARROW, "EMPTY_HAND": 1 << WEAPON_NONE, } # --- CPythonSkill::ESkillType(PythonSkill.h)+ m_SkillTypeIndexMap(PythonSkill.cpp:909-918)--- const SKILL_TYPE_NONE := 0 const SKILL_TYPE_ACTIVE := 1 const SKILL_TYPE_SUPPORT := 2 const SKILL_TYPE_GUILD := 3 const SKILL_TYPE_HORSE := 4 const _SKILL_TYPE_MAP := { "none": SKILL_TYPE_NONE, "active": SKILL_TYPE_ACTIVE, "support": SKILL_TYPE_SUPPORT, "guild": SKILL_TYPE_GUILD, "horse": SKILL_TYPE_HORSE, "warrior": SKILL_TYPE_ACTIVE, "assassin": SKILL_TYPE_ACTIVE, "sura": SKILL_TYPE_ACTIVE, "shaman": SKILL_TYPE_ACTIVE, "passive": SKILL_TYPE_ACTIVE, } # 把 `|`(或旧数据里的空格 / 逗号)分隔的记号串折成位掩码。未知记号忽略, # 对齐原版“map 里查不到就跳过该记号”的行为(PythonSkill.cpp:374-378 / 393-397)。 static func _fold_bits(tokens: String, name_to_bit: Dictionary) -> int: var bits := 0 for raw in tokens.replace(",", "|").replace(" ", "|").split("|", false): var key := raw.strip_edges().to_upper() if name_to_bit.has(key): bits |= int(name_to_bit[key]) return bits func load_file(path: String) -> bool: _by_id.clear() count = 0 if not FileAccess.file_exists(path): return false var f := FileAccess.open(path, FileAccess.READ) if f == null: return false while not f.eof_reached(): var line := f.get_line() if line.strip_edges() == "": continue var t := line.split("\t") if t.size() < 14: continue var id := int(t[0]) if id == 0: continue # 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11 # motion_name=12 motion_idx=13 grades=14 var job := t[1].strip_edges() var attrs := t[10].strip_edges() if t.size() > 10 else "" var weapon := t[11].strip_edges() if t.size() > 11 else "" var entry := { "id": id, "job": job, "name": t[2].strip_edges(), "name2": t[3].strip_edges(), "name3": t[4].strip_edges(), "attrs": attrs, "weapon": weapon, "motion": t[12].strip_edges() if t.size() > 12 else "", "motion_idx": int(t[13]) if t.size() > 13 else 0, "grades": int(t[14]) if t.size() > 14 else 1, # §3.8:预折叠的 TSkillData 判定位(对齐 dwSkillAttribute / dwNeedWeapon) "attr_bits": _fold_bits(attrs, ATTR), "need_weapon_bits": _fold_bits(weapon, NEED_WEAPON), "skill_type": int(_SKILL_TYPE_MAP.get(job.to_lower(), SKILL_TYPE_NONE)), } _load_msk(entry) # RegisterSkillDesc(PythonSkill.cpp:280):DESC_TOKEN_TYPE LEVEL_LIMIT=26 / # MAX_LEVEL=27 只在 TSV 列数够时解析。本 40250 shipped skilldesc.txt 只有 22 列, # 这两列都缺,逻辑保留以对齐参考端、并兼容带这两列的第三方数据。 # .msk 的 maxlevel(_load_msk 已处理)优先于 skilldesc MAX_LEVEL。 if not entry.has("max_level") and t.size() > 27 and int(t[27]) > 0: entry["max_level"] = max(1, int(t[27])) if t.size() > 26 and int(t[26]) > 0: entry["level_limit"] = int(t[26]) _by_id[id] = entry count = _by_id.size() return count > 0 func has(id: int) -> bool: return _by_id.has(id) func entry(id: int) -> Dictionary: return _by_id.get(id, {}) func name_of(id: int) -> String: return String(_by_id.get(id, {}).get("name", "skill %d" % id)) func motion_idx_of(id: int) -> int: return int(_by_id.get(id, {}).get("motion_idx", 0)) func _attrs(id: int) -> String: return String(_by_id.get(id, {}).get("attrs", "")) # §3.8:折叠后的判定位掩码。取位即原版一众 IsXxx() 谓词(PythonSkill.cpp)。 func attr_bits(id: int) -> int: return int(_by_id.get(id, {}).get("attr_bits", 0)) func need_weapon_bits(id: int) -> int: return int(_by_id.get(id, {}).get("need_weapon_bits", 0)) func _has_attr(id: int, name: String) -> bool: return (attr_bits(id) & int(ATTR[name])) != 0 # --- 逐条对齐 CPythonSkill 谓词(PythonSkill.cpp)--- func is_attack(id: int) -> bool: # IsAttackSkill return _has_attr(id, "ATTACK_SKILL") func is_passive(id: int) -> bool: # SKILL_ATTRIBUTE_PASSIVE return _has_attr(id, "PASSIVE") func is_toggle(id: int) -> bool: # IsToggleSkill return _has_attr(id, "TOGGLE") func is_standing(id: int) -> bool: # IsStandingSkill return _has_attr(id, "STANDING_SKILL") func is_need_target(id: int) -> bool: # IsNeedTarget return _has_attr(id, "NEED_TARGET") func is_need_corpse(id: int) -> bool: # IsNeedCorpse return _has_attr(id, "NEED_CORPSE") func is_auto_search_target(id: int) -> bool: # IsAutoSearchTarget return _has_attr(id, "SEARCH_TARGET") func can_change_direction(id: int) -> bool: # CanChangeDirection return _has_attr(id, "CAN_CHANGE_DIRECTION") func is_horse_skill(id: int) -> bool: # IsHorseSkill return _has_attr(id, "HORSE_SKILL") func is_moving_skill(id: int) -> bool: # IsMovingSkill return _has_attr(id, "MOVING_SKILL") func is_melee(id: int) -> bool: # IsMeleeSkill return _has_attr(id, "MELEE_ATTACK") func is_charge_skill(id: int) -> bool: # IsChargeSkill return _has_attr(id, "CHARGE_ATTACK") func is_use_hp(id: int) -> bool: # IsUseHPSkill return _has_attr(id, "USE_HP") func is_time_increase(id: int) -> bool: # IsTimeIncreaseSkill return _has_attr(id, "TIME_INCREASE_SKILL") func is_need_empty_bottle(id: int) -> bool: # IsNeedEmptyBottle return _has_attr(id, "NEED_EMPTY_BOTTLE") func is_need_poison_bottle(id: int) -> bool: # IsNeedPoisonBottle return _has_attr(id, "NEED_POISON_BOTTLE") func can_use_for_me(id: int) -> bool: # CanUseForMe return _has_attr(id, "CAN_USE_FOR_ME") func is_only_for_alliance(id: int) -> bool: # IsOnlyForAlliance return _has_attr(id, "ONLY_FOR_ALLIANCE") func can_use_if_not_enough(id: int) -> bool: # CanUseIfNotEnough return _has_attr(id, "CAN_USE_IF_NOT_ENOUGH") func is_only_for_guild_war(id: int) -> bool: # IsOnlyForGuildWar return _has_attr(id, "ONLY_FOR_GUILD_WAR") func is_fan_range(id: int) -> bool: # IsFanRange return _has_attr(id, "FAN_RANGE") func is_circle_range(id: int) -> bool: # IsCircleRange return _has_attr(id, "CIRCLE_RANGE") # GetTargetCount(fSkillPoint)(PythonSkill.cpp:1361):strTargetCountFormula 为空 → 0, # 否则把 SkillPoint(= LocaleService_GetSkillPower/100)代入公式后向下取整。 # 扇形/圆形补目标(PythonPlayerSkill.cpp:684 `if (dwTargetMaxCount>0 …)`)就靠这个 > 0。 func target_count(id: int, level: int) -> int: var formula := String(_by_id.get(id, {}).get("target_count_formula", "")) if formula == "": return 0 var expression := Expression.new() if expression.parse(formula, ["SkillPoint"]) != OK: return 0 var value: Variant = expression.execute([_skill_point(level)]) if expression.has_execute_failed() or not (value is float or value is int): return 0 return int(floor(float(value))) # __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange() # + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成 # 由 net_play(有玩家状态)叠加。 func target_range(id: int) -> int: return int(_by_id.get(id, {}).get("target_range", 0)) # skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShape(SINGLE/FAN/CIRCLE)。 func fly_shape(id: int) -> int: if is_fan_range(id): return 1 if is_circle_range(id): return 2 return 0 func has_weapon_limitation(id: int) -> bool: # SKILL_ATTRIBUTE_WEAPON_LIMITATION return _has_attr(id, "WEAPON_LIMITATION") # IsCanUseSkill()(PythonSkill.cpp:1407):被动技一律不可主动施放,其余可。 func is_can_use_skill(id: int) -> bool: return not _has_attr(id, "PASSIVE") # CanUseWeaponType(dwWeaponType)(PythonSkill.cpp:1143):无武器限制恒真, # 否则 dwNeedWeapon & (1 << dwWeaponType)。 func can_use_weapon_type(id: int, weapon_type: int) -> bool: if not has_weapon_limitation(id): return true return (need_weapon_bits(id) & (1 << weapon_type)) != 0 # IsNeedBow():dwNeedWeapon & SKILL_NEED_WEAPON_BOW。 func is_need_bow(id: int) -> bool: return (need_weapon_bits(id) & int(NEED_WEAPON["BOW"])) != 0 # 保留旧名:原本靠 weapon 列字符串判远程,现改为查 need-weapon 位(等价,含 ARROW)。 func is_ranged(id: int) -> bool: return (need_weapon_bits(id) & (int(NEED_WEAPON["BOW"]) | int(NEED_WEAPON["ARROW"]))) != 0 # CPythonSkill::ESkillType:3=SKILL_TYPE_GUILD,4=SKILL_TYPE_HORSE,1=主动,2=辅助。 func skill_type_of(id: int) -> int: return int(_by_id.get(id, {}).get("skill_type", SKILL_TYPE_NONE)) func is_guild_skill(id: int) -> bool: return skill_type_of(id) == SKILL_TYPE_GUILD func can_level_up(id: int) -> bool: # !CANNOT_LEVEL_UP return _by_id.has(id) and not _has_attr(id, "CANNOT_LEVEL_UP") # SSkillData::GetMaxLevel()(PythonSkill.cpp:1392)= byMaxLevel。优先 .msk 的 # maxlevel(_load_msk),其次 skilldesc MAX_LEVEL 列,都没有则构造默认 20 # (SSkillData 构造,PythonSkill.cpp:1462)。 func max_level_of(id: int) -> int: return int(_by_id.get(id, {}).get("max_level", 20)) # skillGetSkillLevelLimit(PythonSkill.cpp:1741)= byLevelLimit。本 40250 数据里 # skilldesc 无 LEVEL_LIMIT 列、.msk 也无 levellimit 记号,恒 0;参考端马术页门 # `GetStatus(LEVEL) >= GetSkillLevelLimit()` 因而恒真。 func skill_level_limit(id: int) -> int: return int(_by_id.get(id, {}).get("level_limit", 0)) # skillCanLevelUpSkill(PythonSkill.cpp:1833)。本 build 里 isRequirement / # strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充 # (构造默认 isRequirement=FALSE、空 vector,PythonSkill.cpp:1471), # .msk / skilldesc 也没有任何前置技能 / 前置属性字段,故 1:1 化简为: # 未满级(level < GetMaxLevel())且 非 SKILL_ATTRIBUTE_CANNOT_LEVEL_UP。 func can_level_up_skill(id: int, level: int) -> bool: return _by_id.has(id) and level < max_level_of(id) and not _has_attr(id, "CANNOT_LEVEL_UP") # 读取对应 .msk 中的 CoolTimeFormula / MaxLevel / NeedSPFormula。原客户端用同一份公式 # 按技能效率百分比计算本地冷却与消耗;这里在发送前 / 后做同样的本地预测, # GC_SKILL_COOLTIME_END 仍是服务器的提前解锁信号。 # NeedSPFormula 供 §3.8 修改 3 的 __CheckShortMana / __CheckShortLife # (参考 GetNeedSP,PythonSkill.cpp:1426;__CheckShortLife 同样取 GetNeedSP, # PythonPlayerSkill.cpp:359)。 func _load_msk(entry: Dictionary) -> void: var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), "")) var motion := String(entry.get("motion", "")) if job_dir == "" or motion == "": return var path := AssetRoot.path().path_join("PC/ymir work/pc/%s/skill/%s.msk" % [job_dir, motion]) if not FileAccess.file_exists(path): return var file := FileAccess.open(path, FileAccess.READ) if file == null: return # .msk 常含 CP949/CP1252 本地化文本;只扫描 ASCII 配置行,不能把整份 # 文件当 UTF-8 逐行读,否则 Godot 会为无关文案输出解码警告。 var bytes := file.get_buffer(file.get_length()) var formula := _ascii_field(bytes, "CoolTimeFormula") if formula != "": entry["cooltime_formula"] = formula.trim_prefix("\"").trim_suffix("\"") var max_level := _ascii_field(bytes, "MaxLevel") if max_level != "": entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\""))) var need_sp := _ascii_field(bytes, "NeedSPFormula") if need_sp != "": entry["need_sp_formula"] = need_sp.trim_prefix("\"").trim_suffix("\"") # GetTargetCount / __GetSkillTargetRange 的数据源(.msk 的 TargetCountFormula / Range, # CMotionSkillData 里对应 strTargetCountFormula / dwTargetRange)。 var tcf := _ascii_field(bytes, "TargetCountFormula") if tcf != "": entry["target_count_formula"] = tcf.trim_prefix("\"").trim_suffix("\"") var trange := _ascii_field(bytes, "Range") if trange != "": entry["target_range"] = max(0, int(trange.trim_prefix("\"").trim_suffix("\""))) # LocaleService_GetSkillPower(Locale.cpp:126,INTERNATIONAL 表)—— # 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的 # SkillPoint / k 变量取这个值(ProcessFormula,PythonSkill.cpp:1258)。 const _SKILL_POWER := [ 0, 5, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 50, # 20 = master 52, 54, 56, 58, 60, 63, 66, 69, 72, 82, # 30 = grand master 85, 88, 91, 94, 98, 102, 106, 110, 115, 125, # 40 = perfect master 125, ] # fSkillPoint(= fcurEfficientPercentage):level 越界(>= 50,参考 SKILL_POWER_NUM)→ 0。 func _skill_point(level: int) -> float: if level < 0 or level >= 50: return 0.0 var idx: int = min(level, _SKILL_POWER.size() - 1) return float(_SKILL_POWER[idx]) / 100.0 # GetNeedSP(fSkillPoint)(PythonSkill.cpp:1426):无公式 → 返回 -1(POC 约定: # 「未知」,调用方按参考里的 `if (-1 != iNeedSP)` 分支不拦截;参考端此处返回 0, # 但 POC 的 skilldesc/.msk 里不少主动技确实没有 NeedSPFormula,硬套 0 会误挡, # 故这里显式区分「解析不出」= -1)。公式引用了非 SkillPoint 的状态变量时同样退化 -1。 func need_sp(id: int, level: int) -> int: var data := entry(id) var formula := String(data.get("need_sp_formula", "")) if formula == "": return -1 var expression := Expression.new() if expression.parse(formula, ["SkillPoint"]) != OK: return -1 var value: Variant = expression.execute([_skill_point(level)]) if expression.has_execute_failed() or not (value is float or value is int): return -1 return int(value) # __CheckShortLife(PythonPlayerSkill.cpp:359)里 dwNeedHP 也是取 GetNeedSP, # 不是单独的 HP 公式——照抄这个怪癖。 func need_hp(id: int, level: int) -> int: return need_sp(id, level) func _ascii_field(bytes: PackedByteArray, field: String) -> String: var needle := field.to_ascii_buffer() if needle.is_empty(): return "" for start in range(bytes.size() - needle.size() + 1): var found := true for offset in needle.size(): if bytes[start + offset] != needle[offset]: found = false break if not found: continue var out := PackedByteArray() var i := start + needle.size() while i < bytes.size() and bytes[i] != 10 and bytes[i] != 13: if bytes[i] >= 32 and bytes[i] <= 126: out.append(bytes[i]) i += 1 return out.get_string_from_ascii().strip_edges() return "" func cooldown_of(id: int, level: int) -> float: var data := entry(id) var formula := String(data.get("cooltime_formula", "")) if formula == "": return 0.0 # §3.8 修改 3:SkillPoint 统一走 LocaleService_GetSkillPower(与 GetNeedSP 同一份 # fcurEfficientPercentage),不再用 level/max_level 近似。 var expression := Expression.new() if expression.parse(formula, ["SkillPoint"]) != OK: return 0.0 var value: Variant = expression.execute([_skill_point(level)]) if expression.has_execute_failed() or not (value is float or value is int): return 0.0 return maxf(0.0, float(value)) # JOB 列的分类:主动树是 WARRIOR/ASSASSIN/SURA/SHAMAN;辅助/被动树是 SUPPORT;还有 HORSE / GUILD func category_of(id: int) -> String: return String(_by_id.get(id, {}).get("job", "")) func for_job(job: String) -> Array: return for_category(job) func for_category(cat: String) -> Array: var out := [] for id in _by_id: if _by_id[id].get("job", "") == cat: out.append(id) out.sort() return out