# 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 extends RefCounted var _by_id := {} var count := 0 const JOB_DIR := { "WARRIOR": "warrior", "ASSASSIN": "assassin", "SURA": "sura", "SHAMAN": "shaman", } 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 entry := { "id": id, "job": t[1].strip_edges(), "name": t[2].strip_edges(), "name2": t[3].strip_edges(), "name3": t[4].strip_edges(), "attrs": t[10].strip_edges() if t.size() > 10 else "", "weapon": t[11].strip_edges() if t.size() > 11 else "", "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, } _load_cooltime(entry) _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", "")) func is_attack(id: int) -> bool: return _attrs(id).contains("ATTACK_SKILL") # 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来, # 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。 func is_ranged(id: int) -> bool: return String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW") func is_passive(id: int) -> bool: return _attrs(id).contains("PASSIVE") func is_toggle(id: int) -> bool: return _attrs(id).contains("TOGGLE") func can_level_up(id: int) -> bool: return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP") # 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式 # 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END # 仍是服务器的提前解锁信号。 func _load_cooltime(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("\""))) 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 var max_level: int = max(1, int(data.get("max_level", 20))) var skill_point: float = clampf(float(level) / float(max_level), 0.0, 1.0) var expression := Expression.new() if expression.parse(formula, ["SkillPoint"]) != OK: return 0.0 var value: Variant = expression.execute([skill_point]) 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