完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+324 -22
View File
@@ -11,6 +11,12 @@
# st.for_job("WARRIOR") # 主动职业技能 [id, …]
# st.for_category("SUPPORT") # 辅助 / 被动树
# st.is_passive(121) # true
#
# §3.8 TSkillData 判定位:第 11 列 attributes / 第 12 列 weapon_limit 是 `|` 分隔的
# 记号串,原客户端 RegisterSkillDescPythonSkill.cpp:280)把它们经
# m_SkillAttributeIndexMap / m_SkillNeedWeaponIndexMap 折成 dwSkillAttribute /
# dwNeedWeapon 两个位掩码,CPythonSkill 的一众 IsXxx() 谓词就是对这两个掩码取位。
# 这里在 load_file 时做同样的折叠,谓词逐条对齐 PythonSkill.cpp。
extends RefCounted
var _by_id := {}
@@ -23,6 +29,87 @@ const JOB_DIR := {
"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::EWeaponSubTypesGameLib/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_SkillNeedWeaponIndexMapDOUBLE_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::ESkillTypePythonSkill.h+ m_SkillTypeIndexMapPythonSkill.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
@@ -43,19 +130,34 @@ func load_file(path: String) -> bool:
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": t[1].strip_edges(),
"job": job,
"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 "",
"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_cooltime(entry)
_load_msk(entry)
# RegisterSkillDescPythonSkill.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
@@ -75,27 +177,171 @@ func motion_idx_of(id: int) -> int:
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")
# §3.8:折叠后的判定位掩码。取位即原版一众 IsXxx() 谓词(PythonSkill.cpp)。
func attr_bits(id: int) -> int:
return int(_by_id.get(id, {}).get("attr_bits", 0))
# 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来,
# 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。
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)))
# __GetSkillTargetRangePythonPlayerSkill.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.FlyShapeSINGLE/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 String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW")
return (need_weapon_bits(id) & (int(NEED_WEAPON["BOW"]) | int(NEED_WEAPON["ARROW"]))) != 0
func is_passive(id: int) -> bool:
return _attrs(id).contains("PASSIVE")
# CPythonSkill::ESkillType3=SKILL_TYPE_GUILD4=SKILL_TYPE_HORSE1=主动,2=辅助。
func skill_type_of(id: int) -> int:
return int(_by_id.get(id, {}).get("skill_type", SKILL_TYPE_NONE))
func is_toggle(id: int) -> bool:
return _attrs(id).contains("TOGGLE")
func is_guild_skill(id: int) -> bool:
return skill_type_of(id) == SKILL_TYPE_GUILD
func can_level_up(id: int) -> bool:
return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP")
func can_level_up(id: int) -> bool: # !CANNOT_LEVEL_UP
return _by_id.has(id) and not _has_attr(id, "CANNOT_LEVEL_UP")
# 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式
# 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END
# 仍是服务器的提前解锁信号
func _load_cooltime(entry: Dictionary) -> void:
# 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))
# skillGetSkillLevelLimitPythonSkill.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))
# skillCanLevelUpSkillPythonSkill.cpp:1833)。本 build 里 isRequirement /
# strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充
# (构造默认 isRequirement=FALSE、空 vectorPythonSkill.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
# (参考 GetNeedSPPythonSkill.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 == "":
@@ -115,6 +361,62 @@ func _load_cooltime(entry: Dictionary) -> void:
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_GetSkillPowerLocale.cpp:126INTERNATIONAL 表)——
# 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的
# SkillPoint / k 变量取这个值(ProcessFormulaPythonSkill.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)
# __CheckShortLifePythonPlayerSkill.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()
@@ -142,12 +444,12 @@ func cooldown_of(id: int, level: int) -> float:
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)
# §3.8 修改 3SkillPoint 统一走 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])
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))