# PlayerSkill (§3.8 修改 1) —— 逐行移植 CPythonPlayer 的技能三层合法性校验。 # # 参考 REF/UserInterface/PythonPlayerSkill.cpp: # (a) ClickSkillSlot(dwSlotIndex) :106 —— 槽位 / 技能数据 / 公会技路由 / 被动挡下 / # 站立技 + 开关技 toggle-off 分支 # (b) __CanUseSkill() :439 —— 主角存在 / 观战 / 骑乘缺骑乘术 / actor 可行动 # (c) __CheckSkillUsable(dwSlotIndex) :166 —— 骑乘⇄骑乘术、安全区攻击技、瓶子、钓鱼、 # 未学、武器种类、箭矢、冷却、HP/SP # (d) __UseSkill(dwSlotIndex) :460 —— 编排:私人商店 / __CanUseSkill / __CheckSpecialSkill / # toggle-active 早退 / __CheckSkillUsable / IsUsingSkill / # 目标解析(联盟 / 安全区 / 可攻击 / 尸体 / 需要目标) # # POC 没有 C++ 技能 proto,TSkillData 判定位全部落在 `ui/skill_table.gd`(增量 87)。 # 本类是纯校验器:不发包、不播动作,返回 { ok:bool, code:String, target_vid:int }。 # 施放路径(quickbar.activate() 的 skill 分支)在调用 M2Client.use_skill() / # cast_skill() 之前先过 use_skill(),ok=false 时按 code 弹 OnCannotUseSkill 文案。 # # 运行期上下文(网络下发 / net_play 本地占位)由调用方每次校验前刷新到公开字段, # 对齐原客户端从 CInstanceBase / m_sysIs* / GetStatus() 读到的那些值。 extends RefCounted # --- §3.8 修改 1 要求的命名常量 -------------------------------------------- const SKILL_MAX_NUM := 255 # Packet.h:2041 const SKILL_TYPE_GUILD := 3 # CPythonSkill::SKILL_TYPE_GUILD const RIDING_SKILL_INDEX := 109 # __CanUseSkill 里硬编码的骑乘术判定 vnum const RIDING_SKILL_MASTER_LEVEL := 20 # GetSkillLevel(109) < 20 的下限 const ITEM_EMPTY_BOTTLE := 27995 # __CheckSkillUsable / __HasItem const ITEM_POISON_BOTTLE := 27996 const SKILL_INDEX_COMBO := 122 # PythonPlayerModule.cpp:6 const SKILL_INDEX_FISHING := 123 # PythonPlayerModule.cpp:7 const SKILL_INDEX_SUMMON := 131 # PythonPlayerModule.cpp:15(__CheckShortMana 豁免) # code 里这几类是「原客户端也不弹文案 / 已在别处处理」,调用方不该弹 OnCannotUseSkill: const SILENT_CODES := [ "", "OK", "PASSIVE", "BAD_SLOT", "NO_SKILL_DATA", "GUILD_ROUTED", "TOGGLE_OFF", "SPECIAL_SKILL", "PRIVATE_SHOP", "RESERVED", "NO_RIDING_SKILL", "CANNOT_ACT", "ALREADY_CASTING", ] var client: Object # M2Client var table: RefCounted # SkillTable(增量 87 谓词) # --- 运行期上下文(调用方刷新;默认取「未知 / 放行」)----------------------- var observer_mode := false # IsObserverMode() var mounting := false # CInstanceBase::IsMountingHorse() var in_safe := false # CInstanceBase::IsInSafe() var is_fishing := false # CInstanceBase::IsFishingMode() var is_using_skill := false # CInstanceBase::IsUsingSkill() var can_act := true # CanProcessNetworkStatePacket(死亡/晕眩/击退 → false) var private_shop_open := false # IsOpenPrivateShop() var level_limit := true # m_sysIsLevelLimit var cooltime_check := true # m_sysIsCoolTime var dash_active := false # __CheckDashAffect var weapon_sub_type := -1 # CInstanceBase::GetWeaponType();-1 = 未知(跳过匹配) var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,0 = EMPTY_ARROW var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000) var cur_sp := -1 # GetStatus(POINT_SP);-1 = 未知(不判 NOT_ENOUGH_SP) var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不判 NOT_ENOUGH_HP) var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成 ## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE −200 修正);<0 = 未知。未注入则不判射程。 var target_distance := Callable() func setup(m2client: Object, skill_table: RefCounted) -> void: client = m2client table = skill_table # --- 小工具 -------------------------------------------------------------- func _now() -> float: return Time.get_ticks_msec() / 1000.0 func _ok(target_vid := 0) -> Dictionary: return {"ok": true, "code": "OK", "target_vid": target_vid} # __GetSkillTargetRange —— PythonPlayerSkill.cpp:390 func _skill_target_range(skill_id: int) -> int: return int(table.target_range(skill_id)) + bow_distance * 100 # 射程外:不施法,交给调用方 __ReserveUseSkill(走近后再 __UseSkill)。 # charge = 参考端「蓄力技且未预约时先 __SendUseSkill(slot, 0)」。 func _out_of_range(skill_id: int, tvid: int) -> Dictionary: var range_cm := _skill_target_range(skill_id) if range_cm <= 0 or not target_distance.is_valid(): return {} var distance := float(target_distance.call(tvid)) if distance < 0.0 or distance < float(range_cm): return {} var r := _no("RESERVED", tvid) r["range_cm"] = range_cm r["charge"] = bool(table.is_charge_skill(skill_id)) return r func _no(code: String, target_vid := 0) -> Dictionary: return {"ok": false, "code": code, "target_vid": target_vid} static func is_silent_code(code: String) -> bool: return SILENT_CODES.has(code) func _main_vid() -> int: if client and client.has_method("get_main_vid"): return int(client.get_main_vid()) return 0 func _main_entity() -> Dictionary: var v := _main_vid() if client and client.has_method("get_entity") and v != 0: var e: Variant = client.get_entity(v) if e is Dictionary: return e return {} # GetSkillLevel(idx) / GetSkillGrade(idx) 等价:查 M2Client.get_skills()(按技能 vnum)。 func _skill_level(skill_id: int) -> int: if client == null or not client.has_method("get_skills"): return 0 for s in client.get_skills(): if int(s.get("id", 0)) == skill_id: return int(s.get("level", 0)) return 0 func _skill_grade(skill_id: int) -> int: if client == null or not client.has_method("get_skills"): return 0 for s in client.get_skills(): if int(s.get("id", 0)) == skill_id: return int(s.get("master", 0)) # 0..3,master 即 grade return 0 # __HasItem(dwItemID):扫背包(POC 无独立龙魂背包,省略那段)。 func _has_item(vnum: int) -> bool: if client == null or not client.has_method("get_inventory"): return false for it in client.get_inventory(): if int(it.get("vnum", 0)) == vnum and int(it.get("count", 0)) > 0: return true return false # __HasEnoughArrow():原版查装备箭袋 subtype==WEAPON_ARROW;POC 用 net_play 的 # _arrow_count 占位(真值待服务端 / 后续增量)。-1 视为无限。 func _has_enough_arrow() -> bool: return arrow_count != 0 # __CheckRestSkillCoolTime(slot):m_sysIsCoolTime 关 → 不在冷却;否则比本地剩余。 func _check_rest_skill_cooltime() -> bool: if not cooltime_check: return false return _now() < slot_cd_end # §3.8 修改 3:NeedSP 公式经 skill_table.need_sp()(.msk NeedSPFormula)解析。 # # __CheckShortLife(PythonPlayerSkill.cpp:354): # !IsUseHPSkill() → false # dwNeedHP = GetNeedSP(eff%) (HP 消耗也取 NeedSP,参考端怪癖) # dwNeedHP <= GetStatus(POINT_HP) → false 否则 true func _check_short_life(skill_id: int) -> bool: if table == null or not table.has_method("need_hp"): return false if not table.is_use_hp(skill_id): return false if cur_hp < 0: # HP 未知 → 不误挡 return false var need_hp := int(table.need_hp(skill_id, _skill_level(skill_id))) if need_hp < 0: # 公式解析不出 → 不挡 return false return need_hp > cur_hp # __CheckShortMana(PythonPlayerSkill.cpp:320): # idx == c_iSkillIndex_Summon(131) → false # iNeedSP = GetNeedSP(eff%) ; icurSP = GetStatus(POINT_SP) # !IsToggleSkill() && iNeedSP == 0 → true("strange game data") # CanUseIfNotEnough(): icurSP <= 0 → true # else: iNeedSP != -1 && iNeedSP > icurSP → true # 否则 false func _check_short_mana(skill_id: int) -> bool: if table == null or not table.has_method("need_sp"): return false if skill_id == SKILL_INDEX_SUMMON: return false if cur_sp < 0: # SP 未知 → 不误挡 return false var need_sp := int(table.need_sp(skill_id, _skill_level(skill_id))) # 参考端:非开关技且 iNeedSP == 0 视为坏数据并拦截。POC 约定 need_sp() 用 -1 # 表示「.msk 没有 NeedSPFormula」(解析不出),此时不套这条以免误挡;只有确有 # 公式且算出 0 才按参考拦。 if not table.is_toggle(skill_id) and need_sp == 0: return true if table.can_use_if_not_enough(skill_id): return cur_sp <= 0 return need_sp != -1 and need_sp > cur_sp func _entity_kind(e: Dictionary) -> int: return int(e.get("kind", int(e.get("type", -1)))) # IsAttackableInstance 的近似:PC(0) / MONSTER(2) 且未死可打。 # 帝国 / 结盟 / PK / 决斗规则归 §5.4,这里不判。 func _is_attackable(e: Dictionary) -> bool: if e.is_empty() or bool(e.get("dead", false)): return false return _entity_kind(e) in [0, 2] # ======================================================================== # (a) ClickSkillSlot —— PythonPlayerSkill.cpp:106 # ======================================================================== # is_active:该槽当前是否已激活(IsSkillActive(slot)),仅开关技有意义。 func click_skill_slot(skill_id: int, is_active := false) -> Dictionary: # if (dwSlotIndex >= SKILL_MAX_NUM) return; if skill_id < 0 or skill_id >= SKILL_MAX_NUM: return _no("BAD_SLOT") # if (!GetSkillData(rkSkillInst.dwIndex, &pSkillData)) return; if table == null or not table.has(skill_id): return _no("NO_SKILL_DATA") # if (SKILL_TYPE_GUILD == pSkillData->byType) { UseGuildSkill(...); return; } if table.skill_type_of(skill_id) == SKILL_TYPE_GUILD: return _no("GUILD_ROUTED") # if (!pSkillData->IsCanUseSkill()) return; —— 被动技,静默 if not table.is_can_use_skill(skill_id): return _no("PASSIVE") # if (pSkillData->IsStandingSkill()) if table.is_standing(skill_id): # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return; } if table.is_toggle(skill_id) and is_active: if not can_act: # pkInstMain->IsUsingSkill() 之前的取指针失败 return _no("CANNOT_ACT") if is_using_skill: return _no("ALREADY_CASTING") return _no("TOGGLE_OFF") # toggle-off:调用方直接发 use_skill 关掉 # __UseSkill(dwSlotIndex); return use_skill(skill_id, is_active) # 非站立技:原版还有 m_dwcurSkillSlotIndex / __IsRightButtonSkillMode 的两段式选中, # POC 快捷栏是一键即放,等价于 !__IsRightButtonSkillMode() 分支 -> __UseSkill。 return use_skill(skill_id, is_active) # ======================================================================== # (b) __CanUseSkill —— PythonPlayerSkill.cpp:439 # ======================================================================== func can_use_skill() -> Dictionary: # if (!NEW_GetMainActorPtr()) return false; if _main_vid() == 0: return _no("CANNOT_ACT") # if (IsObserverMode()) return false; if observer_mode: return _no("OBSERVER") # if (IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20) return false; if mounting and _skill_grade(RIDING_SKILL_INDEX) < 1 \ and _skill_level(RIDING_SKILL_INDEX) < RIDING_SKILL_MASTER_LEVEL: return _no("NO_RIDING_SKILL") # return pkInstMain->CanUseSkill(); —— 死亡 / 晕眩 / 击退 / 施法中 if not can_act: return _no("CANNOT_ACT") return _ok() # ======================================================================== # (c) __CheckSkillUsable —— PythonPlayerSkill.cpp:166 # ======================================================================== func check_skill_usable(skill_id: int) -> Dictionary: # if (!NEW_GetMainActorPtr()) return false; if _main_vid() == 0: return _no("CANNOT_ACT") # if (dwSlotIndex >= SKILL_MAX_NUM) return false; if skill_id < 0 or skill_id >= SKILL_MAX_NUM: return _no("BAD_SLOT") # if (!GetSkillData(...)) return false; if table == null or not table.has(skill_id): return _no("NO_SKILL_DATA") # if (IsMountingHorse() && !IsHorseSkill()) -> "NOT_HORSE_SKILL" if mounting and not table.is_horse_skill(skill_id): return _no("NOT_HORSE_SKILL") # if (IsHorseSkill() && !IsMountingHorse()) -> "HAVE_TO_RIDE" if table.is_horse_skill(skill_id) and not mounting: return _no("HAVE_TO_RIDE") # if (IsAttackSkill() && IsInSafe()) -> "IN_SAFE" if table.is_attack(skill_id) and in_safe: return _no("IN_SAFE") # if (!IsCanUseSkill()) return false; —— 被动技 if not table.is_can_use_skill(skill_id): return _no("PASSIVE") # if (IsNeedEmptyBottle() && !__HasItem(27995)) -> "NEED_EMPTY_BOTTLE" if table.is_need_empty_bottle(skill_id) and not _has_item(ITEM_EMPTY_BOTTLE): return _no("NEED_EMPTY_BOTTLE") # if (IsNeedPoisonBottle() && !__HasItem(27996)) -> "NEED_POISON_BOTTLE" if table.is_need_poison_bottle(skill_id) and not _has_item(ITEM_POISON_BOTTLE): return _no("NEED_POISON_BOTTLE") # if (IsFishingMode()) -> "REMOVE_FISHING_ROD" if is_fishing: return _no("REMOVE_FISHING_ROD") # if (m_sysIsLevelLimit && rkSkillInst.iLevel <= 0) -> "NOT_YET_LEARN" if level_limit and _skill_level(skill_id) <= 0: return _no("NOT_YET_LEARN") # if (!CanUseWeaponType(GetWeaponType())) -> "NOT_MATCHABLE_WEAPON" # weapon_sub_type < 0(未知)时跳过,避免误挡。 if weapon_sub_type >= 0 and not table.can_use_weapon_type(skill_id, weapon_sub_type): return _no("NOT_MATCHABLE_WEAPON") # if (!IsHorseSkill()) { __CheckShortArrow; if (IsNeedBow() && !__HasEnoughArrow()) ... } if not table.is_horse_skill(skill_id): if table.is_need_bow(skill_id) and not _has_enough_arrow(): return _no("EMPTY_ARROW") # __CheckDashAffect 分支:冲刺 affect 下、非蓄力技才查冷却;否则查冷却 + HP + SP if dash_active: if not table.is_charge_skill(skill_id): if _check_rest_skill_cooltime(): return _no("WAIT_COOLTIME") else: if _check_rest_skill_cooltime(): return _no("WAIT_COOLTIME") if _check_short_life(skill_id): return _no("NOT_ENOUGH_HP") if _check_short_mana(skill_id): return _no("NOT_ENOUGH_SP") return _ok() # ======================================================================== # (d) __UseSkill —— PythonPlayerSkill.cpp:460 # ======================================================================== func use_skill(skill_id: int, is_active := false) -> Dictionary: # if (IsOpenPrivateShop()) return true; —— 静默成功但什么都不做 if private_shop_open: return _no("PRIVATE_SHOP") # if (!__CanUseSkill()) return false; var b := can_use_skill() if not b.ok: return b # if (dwSlotIndex >= SKILL_MAX_NUM) return false; if skill_id < 0 or skill_id >= SKILL_MAX_NUM: return _no("BAD_SLOT") # if (__CheckSpecialSkill(rkSkillInst.dwIndex)) return true; if _check_special_skill(skill_id): return _no("SPECIAL_SKILL") # if (!GetSkillData(...)) return false; if table == null or not table.has(skill_id): return _no("NO_SKILL_DATA") # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return false; } if table.is_toggle(skill_id) and is_active: return _no("TOGGLE_OFF") # if (!__CheckSkillUsable(dwSlotIndex)) return false; var c := check_skill_usable(skill_id) if not c.ok: return c # if (pkInstMain->IsUsingSkill()) return false; if is_using_skill: return _no("ALREADY_CASTING") # 目标解析(IsNeedTarget || CanChangeDirection || IsAutoSearchTarget) var need_target: bool = table.is_need_target(skill_id) \ or table.can_change_direction(skill_id) \ or table.is_auto_search_target(skill_id) if not need_target: return _ok(0) return _resolve_target(skill_id) # __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。 # 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge), # 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9。 func _resolve_target(skill_id: int) -> Dictionary: var main_vid := _main_vid() var tvid := 0 if client and client.has_method("get_target"): tvid = int(client.get_target().get("vid", 0)) var need_corpse: bool = table.is_need_corpse(skill_id) var te: Dictionary = {} if tvid != 0 and client and client.has_method("get_entity"): var e: Variant = client.get_entity(tvid) if e is Dictionary: te = e # --- 有目标 --- if not te.is_empty(): var is_self := tvid == main_vid var target_dead := bool(te.get("dead", false)) # 需要尸体的技能:目标必须已死 if need_corpse and not target_dead: return _no("ONLY_FOR_CORPSE") if table.is_only_for_alliance(skill_id): if is_self: if not table.can_use_for_me(skill_id): return _no("CANNOT_USE_SELF") return _ok(main_vid) # 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法 if _entity_kind(te) == 0 and not _is_attackable(te): # PythonPlayerSkill.cpp:560:友方 PC 在射程外 -> __ReserveUseSkill(无蓄力预发)。 var far := _out_of_range(skill_id, tvid) if not far.is_empty(): far["charge"] = false return far return _ok(tvid) if table.can_use_for_me(skill_id): return _ok(main_vid) # 目标非法 -> 回落自身 return _no("ONLY_FOR_ALLIANCE") else: # 攻击类:目标在安全区不可打 if bool(te.get("in_safe", false)): return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") if _is_attackable(te): # __ProcessEnemySkillTargetRange(PythonPlayerSkill.cpp:395) var far := _out_of_range(skill_id, tvid) if not far.is_empty(): return far return _ok(tvid) return _no("CANNOT_ATTACK") # --- 无目标 --- if table.is_auto_search_target(skill_id): # NEW_GetFrontInstance 在 POC 侧没有等价物;无目标即失败。 if not table.can_use_for_me(skill_id) and not need_corpse: return _no("NEED_TARGET") if table.can_use_for_me(skill_id): return _ok(main_vid) if need_corpse: return _no("ONLY_FOR_CORPSE") return _no("NEED_TARGET") # __CheckSpecialSkill —— PythonPlayerSkill.cpp:950(钓鱼 123 / 连击 122)。 func _check_special_skill(skill_id: int) -> bool: if skill_id == SKILL_INDEX_FISHING: return true if skill_id == SKILL_INDEX_COMBO: return true return false