Files
mtgodot-poc/project/player_skill.gd
T

423 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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++ 技能 protoTSkillData 判定位全部落在 `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..3master 即 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_ARROWPOC 用 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 修改 3NeedSP 公式经 skill_table.need_sp().msk NeedSPFormula)解析。
#
# __CheckShortLifePythonPlayerSkill.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
# __CheckShortManaPythonPlayerSkill.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