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

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
+422
View File
@@ -0,0 +1,422 @@
# 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