423 lines
17 KiB
GDScript
423 lines
17 KiB
GDScript
# 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)
|
||
|
||
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}
|
||
|
||
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)。
|
||
# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9,
|
||
# 这里只判目标「类型」是否合法并回传解析到的 vid。
|
||
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):
|
||
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):
|
||
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
|