Files
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00

1894 lines
82 KiB
GDScript
Raw Permalink 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.
# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。
#
# 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s
# 停下 -> M2Client.move(FUNC_WAIT, …)
# 点实体 -> M2Client.set_target(vid);按住 SPACE 才在攻击距离内按攻速 attack()
# 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp
# points_changed / vitals_changed / target_info -> HUD
#
# 需要:M2Client、PlayerController`player_controller.gd`)、NetWorld`net_world.gd`)。
# 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。
#
# 用法:
# var np := preload("res://net_play.gd").new()
# add_child(np)
# np.setup(m2client, player_ctl, net_world, hud)
extends Node
signal target_changed(vid: int)
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。
signal whisper_requested(name: String)
# OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调),
# 供 HUD 弹 localeInfo 文案。§3.4
signal cannot_act(code: String)
# PythonPlayerInput::OnFishingWrongPlace 的独立反馈通道。当前场景若没有
# 水面查询器不会误报,等 Metin2World 接上水面采样后由这里统一上抛。
signal fishing_feedback(code: String)
# 弓动作起手:本次挥击的 uSkill(普攻 0)。对齐
# CNormalBowAttack_FlyEventHandler_AutoClear —— OnSetFlyTarget() 已在 _emit_swing 里
# 直接发 CG_FLY_TARGETING,剩下的 OnShoot(uSkill) 要等 `.msa` MOTION_EVENT_TYPE_FLY 帧,
# 由 game_scene 收到本信号后入队、在 _on_local_motion_event(6) 里 pop 出来发 CG_SHOOT。§3.6
signal bow_shot_fired(skill: int)
# CPythonPlayer 的预约动作模式(PythonPlayerInput.cpp:820-856)。每帧在
# _refresh_reserved()(≈ NEW_RefreshMouseWalkingDirection)里按模式分派。
enum ReservedMode { NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL }
# 扇形 / 圆形多目标 fly-targeting 的范围形状(skilldesc / .msk 的
# SKILL_ATTRIBUTE_FLAG_FAN_RANGE / _CIRCLE_RANGEPythonSkill.cpp:1103/1111)。
enum FlyShape { SINGLE, FAN, CIRCLE }
# NEW_GetInstanceVectorInFanRangeInstanceBaseBattle.cpp:192)的扇形半角随距离收窄:
# fHalfFanRot = (MAX-MIN) - RPM * min(dist, MIN_DIST) + MIN, RPM = (MAX-MIN)/MIN_DIST
const FAN_HALF_ROT_MIN := 20.0
const FAN_HALF_ROT_MAX := 40.0
const FAN_HALF_ROT_MIN_DISTANCE := 1000.0
# NEW_GetRandomPositionInFanRange:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。
const FAN_RANDOM_JITTER_DEG := 10.0
const FAN_RANDOM_REACH_CM := 8000.0
# MODE_CLICK_ITEM 的到手阈值(PythonPlayerInputMouse.cpp:297 `< 20.0f`,单位 cm)。
const CLICK_ITEM_PICKUP_CM := 20.0
# MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。
const HUGE_RACE_DIST_FIX_CM := 200.0
# _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。
const HUGE_RACE_SIZE := 3 # MOBSIZE_HUGE
const HUGE_RACE_RANK := 4 # MOB_RANK_BOSS
# __ReserveUseSkillPythonPlayerInput.cpp:854):range > 100 时先减 10 再存。
const RESERVE_SKILL_RANGE_TRIM_CM := 10.0
const FUNC_WAIT := 0
const FUNC_MOVE := 1
const FUNC_COMBO := 3
const FUNC_SKILL := 0x80
const CM := 100.0 # 米 -> 厘米
# §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。
const EntityRules = preload("res://entity_rules.gd")
# §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP1:1。
const TargetBoard = preload("res://target_board.gd")
# §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调):
# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口
# OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE
# OnWaiting—— 站立中周期上行:100 ms 节流且相对上次上行位移 ≥ 1 cm 才发 FUNC_WAIT
# OnStop —— 停下收尾:不节流,发 FUNC_WAIT
# OnAttack —— 不节流,发 FUNC_COMBO + motion index(见 _do_attack_swing
# OnUseSkill—— 不节流,发 FUNC_SKILL|motion + arg(§3.8 施法路径)
const SEND_MOVING_INTERVAL := 0.3 # OnMoving 最小间隔(秒)
const SEND_WAITING_INTERVAL := 0.1 # OnWaiting 最小间隔(秒)
const SEND_WAITING_MIN_DISP_CM := 1.0 # OnWaiting 触发的最小位移(服务器 cm)
# 可点击 / 攻击距离(厘米),照 `InstanceBaseBattle.cpp:294`
# `NEW_IsClickableDistanceDestInstance`:默认 150NPC 500,采集物 100,弓箭走 __GetBowRange。
const CLICK_DIST_DEFAULT_CM := 150.0
const CLICK_DIST_NPC_CM := 500.0
const CLICK_DIST_RESOURCE_CM := 100.0
# CInstanceBase::__GetBowRangeInstanceBase.cpp:694):基础 2500-100 cm
# 主角再加 GetStatus(POINT_BOW_DISTANCE)(参考端直接按 cm 相加,不 ×100)。
const BOW_RANGE_BASE_CM := 2500.0 - 100.0
const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移
const CORRECTION_LERP := 0.25 # 否则每次校正插值比例
# §3.5 修改 4 —— 命中判定(对齐 `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`
# + `InstanceBaseBattle.cpp:413 AttackProcess` + `PythonPlayerEventHandler.cpp:135 OnHit` /
# `:194 FlushVictimList`)。`NRaceData::EMotionType`GameType.h:52):
const MOTION_TYPE_NONE := 0
const MOTION_TYPE_NORMAL := 1
const MOTION_TYPE_COMBO := 2
const MOTION_TYPE_SKILL := 3
const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadiusActorInstanceCollisionDetection.cpp:355
const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT
# NRaceData::EHitTypeGameType.h
const HIT_TYPE_NONE := 0
const HIT_TYPE_GREAT := 1
const HIT_TYPE_GOOD := 2
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcessfDistance >= 300² -> FALSE
const HIT_DISTANCE_HUGE_CM := 500.0 # IS_HUGE_RACE(victim)>= 500² -> FALSE
const COMBO_HIT_LIMIT := 16 # MOTION_TYPE_COMBO / NORMAL 每个命中窗最多 16 个目标
const PUSH_OWNER_TIME := 3.0 # __CanPushDestActor__GetOwnerTime() > 3.0f -> 不推
const COMBO_SKILL_ID := 122 # SetComboSkillFlag 的 c_iSkillIndex_Combo
# m_dwBattleHitEffectIDplayersettingmodule.py RegisterCacheEffect(EFFECT_HIT, ...)
const EFFECT_HIT := "d:/ymir work/effect/hit/blow_1/blow_1_low.mse"
# 占位胶囊(没有 .msm 防御球)退化用的一个防御球(seam)
const HIT_FALLBACK_SPHERE := {"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}
const HitCollision = preload("res://hit_collision.gd")
var client: Node # M2Client
var pc: Node # PlayerController
var net_world: Node # NetWorld
var hud: Node # 可空
# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime`
# `CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
const DEFAULT_ATTACK_PERIOD := 0.6
var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取
var _atk_speed_factor := 1.0 # m_fAtkSpd = bAttackSpeed / 100>1100 -> 0),动作 fSpeedRatio
var camera: Node # GameCamera(可空,用于受击抖屏)
# PlayerView(可空)。motion_bound(state) = __SetMotion 尾(清命中表 / 连击段号)。
var player_view: Node:
set(v):
if is_instance_valid(player_view) and player_view.has_signal("motion_bound") \
and player_view.motion_bound.is_connected(_on_motion_bound):
player_view.motion_bound.disconnect(_on_motion_bound)
player_view = v
if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound):
v.motion_bound.connect(_on_motion_bound)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
# --- §3.5 连击段(对齐 CActorInstanceActorInstanceBattle.cpp:157-330---
# chr.MOTION_MODE_*CRaceMotionData::EModeRaceMotionData.h:30)——完整枚举
const MOTION_MODE_RESERVED := 0
const MOTION_MODE_GENERAL := 1
const MOTION_MODE_ONEHAND_SWORD := 2
const MOTION_MODE_TWOHAND_SWORD := 3
const MOTION_MODE_DUALHAND_SWORD := 4
const MOTION_MODE_BOW := 5
const MOTION_MODE_FAN := 6
const MOTION_MODE_BELL := 7
const MOTION_MODE_FISHING := 8
const MOTION_MODE_HORSE := 9
const MOTION_MODE_HORSE_ONEHAND_SWORD := 10
const MOTION_MODE_HORSE_TWOHAND_SWORD := 11
const MOTION_MODE_HORSE_DUALHAND_SWORD := 12
const MOTION_MODE_HORSE_BOW := 13
const MOTION_MODE_HORSE_FAN := 14
const MOTION_MODE_HORSE_BELL := 15
const MOTION_MODE_WEDDING_DRESS := 16
const MOTION_MODE_HORSE_FIRST := 9 # HORSE..HORSE_BELL = 9..15
const MOTION_MODE_HORSE_LAST := 15
# CItemData::EItemTypeItemData.h:22)子集 —— 只用到武器 / 钓竿 / 镐
const ITEM_TYPE_WEAPON := 1
const ITEM_TYPE_ROD := 13
const ITEM_TYPE_PICK := 24
# CItemData::EWeaponSubTypesItemData.h:63)—— GetWeaponType() 返回的 bSubType
const WEAPON_SUB_SWORD := 0
const WEAPON_SUB_DAGGER := 1
const WEAPON_SUB_BOW := 2
const WEAPON_SUB_TWO_HANDED := 3
const WEAPON_SUB_BELL := 4
const WEAPON_SUB_FAN := 5
const WEAPON_SUB_ARROW := 6
# 装备槽 / parts[] 下标(对齐 EquipModel
const WEAR_WEAPON := 4
const PART_WEAPON := 1
# 与 Packet.h 的 FISHING_SUBHEADER_GC_* 一致。
const FISHING_START := 0
const FISHING_STOP := 1
const FISHING_REACT := 2
const FISHING_SUCCESS := 3
const FISHING_FAIL := 4
const FISHING_FISH := 5
const FISHING_CANCEL_COOLDOWN := 0.5
# 段号 = CRaceMotionData::NAME_* ComboIndexVector 里存的就是这些
const NAME_NORMAL_ATTACK := 13
const NAME_COMBO_ATTACK_1 := 14
var _target_vid := 0
var _main_vid := 0
# game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。
# 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。
var _view_other_empire_target := true
# game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。poc 侧由 game_scene
# 的 KEY_CTRL 按下 / 松开喂 set_lcontrol_down(),点 PC 时轮询(对齐参考端 IsPressed 语义)。
var _lcontrol_down := false
var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标
var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒)
var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒)
var _was_moving := false
var _attack_cd := 0.0
var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing
var _using_skill := false # 施法中(§3.8 由 player_skill 驱动)
# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo /
# __OnEndCombo / __ClearCombo
var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shamanrace & 3
var combo_motion_mode := MOTION_MODE_GENERAL # m_wcurMotionMode(§3.5 修改 6:按装备武器种类 + 骑乘态)
var _combo_type := 0 # m_wcurComboTypeSetComboType;连击技能激活时 = MIN(lv,2)
var _combo_index := 0 # m_dwcurComboIndex0 = 不在连击中)
var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发)
var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击)
var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } }
# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsedInsertDelay 冻结(命中硬直)。
var _local_time := 0.0 # GetLocalTime()
var _delay := 0.0 # m_fDelay
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
var _hit_windows := [] # get_motion_data().hit_windowsTHitDataContainer
var _hit_motion_type := 0 # get_motion_data().motion_typeNRaceData::EMotionType
var _hit_limit_count := 0 # get_motion_data().hit_limit_countSKILL 类的每窗命中上限)
var _hit_invisible_time := 0.0 # get_motion_data().invisible_timefInvisibleTime
var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_typeiHittingType
var _hit_stiffen := 0.0 # get_motion_data().stiffen_timefStiffenTime
var _hit_external_force := 0.0 # get_motion_data().external_forcefExternalForce
var _hit_dedup := {} # m_HitDataMap{ window_idx:int -> { victim_vid:int -> 本地时间 + fInvisibleTime } }
var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}]
var _victim_invisible_until := {} # 受击方 m_fInvisibleTime{ vid -> 秒(_now()}
var _def_last := {} # 防御球 v3LastPosition{ vid -> {frame, pos: [Vector3 cm]} }
var _atk_frame := 0 # _attack_process 帧号(判断 _def_last 是否是上一帧)
var _owner_seen := {} # m_fOwnerBaseTime 的客户端观测:{ vid -> [owner_vid, 秒] }
var _observer_mode := false
var _attack_key_down := false
var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角
var _last_fishing_cancel_t := -1e9 # NEW_CancelFishing 的 500ms 节流
# --- §3.4 预约动作状态机(对齐 PythonPlayerInput.cpp:820-865 / :877 __ReserveProcess_ClickActor
# + PythonPlayer.cpp:207 __Update_AutoAttack---
var _reserved_mode: int = ReservedMode.NONE
var _vid_reserved := 0 # m_dwVIDReserved
var _iid_reserved := 0 # m_dwIIDReserved(拾取物,§3.4 后续)
var _ppos_reserved := Vector3.ZERO
var _skill_slot_reserved := -1
var _skill_range_reserved := 0.0
var _reserved_delay := 0.0 # 拾取 / 到点前的等待(CLICK_ITEM / CLICK_POSITION
var _auto_attack_vid := 0 # m_dwAutoAttackTargetVID
var _last_cannot := "" # 最近一次 OnCannot* 码(供测试 / HUD
# 服务端尚未下发的字段的本地占位(与 knock_down 同套做法,真值待后续增量 / 真服):
var _arrow_count := -1 # -1 = 未知/无限;0 = EMPTY_ARROW
var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBowRange() 加成
var _dash_active := false # __CheckDashAffect
var _charge_skill := false # 预约技能 IsChargeSkill()
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
client = m2client
pc = player_ctl
net_world = nw
hud = hud_node
pc.target_selected.connect(_on_pick)
pc.moved.connect(_on_local_moved)
pc.anim_state.connect(_on_anim_state)
client.entity_main_set.connect(_on_main_set)
client.entity_moved.connect(_on_net_moved)
if client.has_signal("entity_info"):
client.entity_info.connect(_on_entity_info)
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed_mode) # §3.5 修改 6:换武器 → motion mode
if client.has_signal("mount_changed"):
client.mount_changed.connect(func(v):
if v == _main_vid: _refresh_motion_mode()) # §3.5 修改 6:上/下马 → HORSE_*
client.points_changed.connect(_on_points)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(_on_entity_gone)
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
if client.has_signal("combo_changed"):
client.combo_changed.connect(_on_combo_changed)
if client.has_signal("observer_mode_changed"):
client.observer_mode_changed.connect(_on_observer_mode)
if client.has_method("is_observer_mode"):
_observer_mode = bool(client.is_observer_mode())
if pc and "skip_actor_collision" in pc:
pc.skip_actor_collision = _observer_mode
func _on_observer_mode(enabled: bool) -> void:
_observer_mode = enabled
# 观战 = 自由镜头穿行 → CanSkipCollision 语义:本地移动整段跳过 Actor 碰撞。
if pc and "skip_actor_collision" in pc:
pc.skip_actor_collision = enabled
if enabled:
_target_vid = 0
_attack_cd = 0.0
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
# PythonPlayerInputKeyboard::SetAttackKeyState keeps m_isAtkKey; fishing mode
# diverts the press to NEW_Fishing and must not start ordinary combat.
func set_attack_key(down: bool) -> void:
if down and combo_motion_mode == MOTION_MODE_FISHING:
_new_fishing()
return
_attack_key_down = down
if not down:
_attack_cd = 0.0
# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。
# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。
func activate_fishing() -> String:
if combo_motion_mode != MOTION_MODE_FISHING:
return "EQUIP_FISHING_ROD"
return _new_fishing()
func is_fishing() -> bool:
return _fishing_active
# 地面点击前的动作清理顺序:__OnPressGround 先清预约 / 自动攻击,再取消钓鱼。
func on_ground_click() -> bool:
_clear_reserved()
_auto_attack_vid = 0
return cancel_fishing()
# NEW_CancelFishing:只在 IsFishing() 时发 CG_FISHING(0),并按参考端节流 500ms。
func cancel_fishing() -> bool:
if not _fishing_active or client == null or not client.has_method("fishing"):
return false
var now := _now()
if now < _last_fishing_cancel_t + FISHING_CANCEL_COOLDOWN:
return false
if not bool(client.fishing(0.0)):
return false
_last_fishing_cancel_t = now
return true
func _fishing_rotation_deg() -> float:
if pc == null or pc.player == null:
return 0.0
# GetFishingRot 返回客户端原生方向;MapCoord 与普通 CG_MOVE 使用同一
# world-yaw -> server-heading 换算,M2Client 再按 5 度量化为 BYTE。
return _heading_deg(pc.player.rotation.y)
func _new_fishing() -> String:
if client == null or not client.has_method("fishing"):
return "FISHING_UNAVAILABLE"
if _fishing_active:
return "OK" if bool(client.fishing(0.0)) else "FISHING_SEND_FAILED"
if not _can_process_network_state() or _using_skill:
return "CANNOT_ACT"
if pc == null or pc.player == null:
return "CANNOT_ACT"
# InstanceBaseMotion::GetFishingRot:优先取水面扫描选出的 heading;旧的
# can_fishing_position 只返回 bool,供尚未升级的 world provider 兼容。
var fishing_dir := _fishing_rotation_deg()
if net_world and net_world.has_method("get_fishing_rotation"):
var resolved := float(net_world.call("get_fishing_rotation",
pc.player.position.x, pc.player.position.z, fishing_dir))
if is_equal_approx(resolved, -2.0):
pass # NetWorld has no map provider (headless / protocol-only client).
elif resolved < 0.0:
fishing_feedback.emit("FISHING_WRONG_PLACE")
return "FISHING_WRONG_PLACE"
else:
fishing_dir = resolved
elif net_world and net_world.has_method("can_fishing_position"):
if not bool(net_world.call("can_fishing_position",
pc.player.position.x, pc.player.position.z, fishing_dir)):
fishing_feedback.emit("FISHING_WRONG_PLACE")
return "FISHING_WRONG_PLACE"
if bool(client.fishing(fishing_dir)):
return "OK"
return "FISHING_SEND_FAILED"
# §3.5:从 assets/root/playersettingmodule.py 解析 PC 连击段表(不在 .msm 里,
# 而是 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData)。
func set_asset_root(root: String) -> void:
if not ClassDB.class_exists("Metin2AnimPlayer"):
return
var p := root.trim_suffix("/") + "/root/playersettingmodule.py"
var t: Variant = Metin2AnimPlayer.parse_combo_tables(p)
if t is Dictionary:
_combo_tables = t
# PythonPlayerSkill.cpp:940 —— 连击技能激活时 SetComboType(MIN(skillLv, 2)),否则 0。
func set_combo_type(t: int) -> void:
_combo_type = clampi(t, 0, 2)
# CPythonPlayer::SetComboSkillFlagPythonPlayerSkill.cpp:916):GC 连击开关到达时查连击技能
# 122 的槽位 / 等级,缺槽或等级 <= 0 直接 return(不改 combo type),否则
# SetComboType(bFlag ? MIN(iLevel, 2) : 0)。
func _on_combo_changed(enabled: bool) -> void:
if client == null or not client.has_method("get_skills"):
return
var level := -1
for skill in client.get_skills():
if int(skill.get("id", 0)) == COMBO_SKILL_ID:
level = int(skill.get("level", 0))
break
if level <= 0:
return
_combo_type = mini(level, 2) if enabled else 0
# CActorInstance::__SetMotion 尾(ActorInstanceMotion.cpp):绑定带 MotionAttackData 的动作 ->
# m_HitDataMap.clear();连击中换到没有 ComboInputData 的动作 -> m_dwcurComboIndex = 0。
func _on_motion_bound(_state: String) -> void:
var md := _motion_data()
if bool(md.get("has_attacking_data", false)):
_hit_dedup.clear()
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
_combo_index = 0
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
_clear_combo()
# §3.5 修改 6 —— CInstanceBase::RefreshStateInstanceBase.cpp:2851)里 SetMotionMode 的分支:
# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE /
# 否则 FISHING;骑乘时按武器 subtype 走 HORSE_* ;否则按 subtype 走 ONEHAND / TWOHAND /
# DUALHAND / BOW / FAN / BELLARROW 及其余 → GENERAL。
static func motion_mode_for(item_type: int, weapon_sub: int, mounted: bool,
poly: bool, dress: bool) -> int:
if poly:
return MOTION_MODE_GENERAL
if dress:
return MOTION_MODE_WEDDING_DRESS
if item_type == ITEM_TYPE_PICK:
return MOTION_MODE_HORSE if mounted else MOTION_MODE_GENERAL
if item_type == ITEM_TYPE_ROD:
return MOTION_MODE_HORSE if mounted else MOTION_MODE_FISHING
if mounted:
match weapon_sub:
WEAPON_SUB_SWORD: return MOTION_MODE_HORSE_ONEHAND_SWORD
WEAPON_SUB_TWO_HANDED: return MOTION_MODE_HORSE_TWOHAND_SWORD
WEAPON_SUB_DAGGER: return MOTION_MODE_HORSE_DUALHAND_SWORD
WEAPON_SUB_FAN: return MOTION_MODE_HORSE_FAN
WEAPON_SUB_BELL: return MOTION_MODE_HORSE_BELL
WEAPON_SUB_BOW: return MOTION_MODE_HORSE_BOW
_: return MOTION_MODE_HORSE
match weapon_sub:
WEAPON_SUB_SWORD: return MOTION_MODE_ONEHAND_SWORD
WEAPON_SUB_TWO_HANDED: return MOTION_MODE_TWOHAND_SWORD
WEAPON_SUB_DAGGER: return MOTION_MODE_DUALHAND_SWORD
WEAPON_SUB_BOW: return MOTION_MODE_BOW
WEAPON_SUB_FAN: return MOTION_MODE_FAN
WEAPON_SUB_BELL: return MOTION_MODE_BELL
_: return MOTION_MODE_GENERAL
# 当前装备武器 vnum:主角 parts[WEAPON] 优先(时装 / GC_CHARACTER_UPDATE 写这里),
# 为 0 回退 EQUIPMENT 窗 WEAR_WEAPON 槽(对齐 EquipModel._eff)。
func _equipped_weapon_vnum() -> int:
var e := _main_entity()
var parts: Variant = e.get("parts", [])
if parts is Array and parts.size() > PART_WEAPON and int(parts[PART_WEAPON]) != 0:
return int(parts[PART_WEAPON])
if client and client.has_method("get_equipment"):
var eq: Variant = client.get_equipment()
if eq is Array and eq.size() > WEAR_WEAPON:
return int(eq[WEAR_WEAPON].get("vnum", 0))
return 0
func _is_main_mounted() -> bool:
var main := _main_entity()
return int(main.get("mount_vnum", 0)) != 0 or bool(main.get("mounting", false))
static func _is_new_mount(mount: int) -> bool:
return (mount >= 20205 and mount <= 20212) or mount in [20214, 20215, 20217, 20218, 20220]
func _horse_skill_unlocked(min_level: int) -> bool:
if client == null or not client.has_method("get_skills"):
return false
for skill in client.get_skills():
if int(skill.get("id", 0)) == 109:
return int(skill.get("master", 0)) >= 1 or int(skill.get("level", 0)) >= min_level
return false
# 读装备 / 骑乘 / 变身状态 -> motion_mode_for -> set_combo_motion_mode(换武器 / 上下马时调用)。
func _refresh_motion_mode() -> void:
var e := _main_entity()
var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false))
var poly := int(e.get("polymorph", 0)) != 0 or int(e.get("poly_vnum", 0)) != 0
var dress := bool(e.get("wedding_dress", false))
var it := 0
var sub := 0
var vnum := _equipped_weapon_vnum()
if vnum > 0 and proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
it = int(d.get("type", 0))
sub = int(d.get("sub_type", 0))
set_combo_motion_mode(motion_mode_for(it, sub, mounted, poly, dress))
# CInstanceBase::MountHorse → SetRotationSpeed(c_fDefaultHorseRotationSpeed 300)
# 下马恢复 c_fDefaultRotationSpeed 1200InstanceBase.cpp:954 / 3020)。
if pc and "rotation_speed_deg" in pc:
pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG
func _on_inv_changed_mode(window: int, _cell: int) -> void:
if window == 2: # mtnet::WINDOW_EQUIPMENT
_refresh_motion_mode()
func _is_bow_mode() -> bool:
return combo_motion_mode == MOTION_MODE_BOW or combo_motion_mode == MOTION_MODE_HORSE_BOW
# __CanShot 弓箭合法性 —— 40250 源码树没有成品 `__CanShot` / `CanShoot` 定义
# `grep -rn "CanShot|CanShoot"` 全树无匹配),按能门控射箭的三处反推:
# ① CNormalBowAttack_FlyEventHandler_AutoClearREF/UserInterface/PythonPlayerEventHandler.cpp:235
# 的 OnSetFlyTarget / OnShoot 之外有 OnNoTarget() / OnNoArrow() 两个失败回调(REF/GameLib/FlyHandler.h);
# ② NEW_AttackREF/GameLib/InstanceBaseBattle.cpp:355)的 IsDead / IsStun / IsKnockDown / IsUsingSkill 门;
# ③ 弓普攻可点击 / 攻击距离走 __GetBowRangeInstanceBase.cpp:694),空箭走 EMPTY_ARROW 文案。
# → 弓模式 && 有箭 && 有可攻击目标 && 目标在弓程内。返回 "" = 可射;否则返回 cannot 文案码。
func _can_shot(target_vid := -1) -> String:
if not _is_bow_mode():
return "NOT_BOW_MODE"
if _arrow_count == 0: # IFlyEventHandler::OnNoArrow —— -1=未跟踪/无限
return "EMPTY_ARROW"
var tv := target_vid if target_vid >= 0 else _target_vid
if tv <= 0:
tv = _vid_reserved
if tv <= 0: # IFlyEventHandler::OnNoTarget
return "NO_TARGET"
var te: Dictionary = client.get_entity(tv) if client else {}
# 实体数据缺失(headless / 未进 store)时不据此否决——沿用 _send_fly_target 的「有节点即可」,
# 只在确知目标不可攻击 / 已死时否决。
if not te.is_empty() and not _is_attackable(te):
return "NO_TARGET"
var tp = _entity_world_pos(tv)
var mp = _entity_world_pos(_main_vid)
if tp is Vector3 and mp is Vector3:
if (tp as Vector3).distance_to(mp) * CM > _bow_range_cm():
return "OUT_OF_RANGE"
return ""
# CActorInstance::__GetCurrentComboTypeActorInstance.cpp:164)——弓 / 徒手 / 骑乘模式恒 0。
func _current_combo_type() -> int:
if _is_bow_mode() or combo_motion_mode == MOTION_MODE_GENERAL \
or (combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST):
return 0
return _combo_type
# GetComboDataPointer(m_wcurMotionMode, wComboType) 的等价:命中返回段号数组,否则空。
func _combo_vec() -> PackedInt32Array:
var cls: Dictionary = _combo_tables.get(combo_class, {})
var key := (combo_motion_mode << 16) | _current_combo_type()
return cls.get(key, PackedInt32Array())
func _motion_data() -> Dictionary:
var pv_anim: Object = player_view.get("anim") if player_view else null
if pv_anim and pv_anim.has_method("get_motion_data"):
return pv_anim.get_motion_data()
return {}
# --- helpers --------------------------------------------------------------
func _vid_of(node: Object) -> int:
if node and node.has_meta("vid"):
return int(node.get_meta("vid"))
return 0
# 玩家节点在 Metin2World 本地帧 -> 服务器全局 cm(含 BasePosition
func _server_xy(world_pos: Vector3) -> Vector2:
return MapCoord.to_server_cm(world_pos)
func _heading_deg(yaw_rad: float) -> float:
return MapCoord.yaw_to_heading(yaw_rad)
func _player_yaw() -> float:
return pc.player.rotation.y if pc.player else 0.0
# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP / 5 BUILDING /
# 6 WOODEN_DOOR。门类型既可由实体 fixture 的 building / kind 给出,也可由
# mob_proto 的 bType=4 + 13000 / 30111..30119 race 识别。
# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。
func _entity_kind(e: Dictionary) -> int:
if bool(e.get("building", false)):
return EntityRules.KIND_BUILDING
var explicit_kind := int(e.get("kind", -1))
if explicit_kind >= EntityRules.KIND_BUILDING:
return explicit_kind
var ct := int(e.get("ch_type", 0))
if ct != 0:
return ct
var race := int(e.get("race", 0))
if EntityRules._is_wooden_door(e):
return EntityRules.KIND_WOODEN_DOOR
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
# mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
match int(m.get("type", 0)):
0: return 2
1: return 1
2: return 3
3: return 4
4: return EntityRules.KIND_WOODEN_DOOR \
if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING
return 0
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
func _entity_name(e: Dictionary) -> String:
var nm := String(e.get("name", ""))
if nm != "":
return nm
var race := int(e.get("race", 0))
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
return String(m.get("locale_name", m.get("name", "")))
return ""
# --- movement ----------------------------------------------------------------
# `pc.moved` 在平移的每一帧发。第一帧 = OnMove,之后各帧 = OnMoving。
func _on_local_moved(pos: Vector3) -> void:
if _observer_mode or client == null or not client.is_in_game():
return
var now := _now()
if not _was_moving:
# OnMove:立即上行,并把 moving/waiting 两个节流窗口都推到「刚发过」,
# 让随后的 OnMoving / OnWaiting 各自等一个完整间隔(PythonPlayerEventHandler.cpp:76)。
_send_state(FUNC_MOVE, 0, pos)
_was_moving = true
_last_moving_sent_t = now
_last_waiting_sent_t = now
return
# OnMoving300 ms 节流(:62)。
if now - _last_moving_sent_t < SEND_MOVING_INTERVAL:
return
_send_state(FUNC_MOVE, 0, pos)
_last_moving_sent_t = now
func _on_anim_state(state: String) -> void:
if _observer_mode:
return
# OnStop:停下时不节流,落一包 FUNC_WAIT 收尾(PythonPlayerEventHandler.cpp:88)。
if state == "wait" and _was_moving:
_was_moving = false
if client and client.is_in_game() and pc.player:
_send_state(FUNC_WAIT, 0, pc.player.position)
# 统一上行出口 —— 对齐 `SendCharacterStatePacket` / `TPacketCGMove`
# PythonNetworkStreamPhaseGame.cpp:1111):bFunc / bArg / bRot / lX / lY / dwTime。
# rot 的 /5.0 与坐标的 __LocalPositionToGlobalPosition 由 C++ 侧 client.move() 负责。
func _send_state(func_id: int, arg: int, world_pos: Vector3) -> void:
if client == null or not client.is_in_game():
return
var xy := _server_xy(world_pos)
client.move(func_id, arg, _heading_deg(_player_yaw()), int(xy.x), int(xy.y))
_last_sent_pos = world_pos
# OnWaiting —— 站立时周期性把当前位置补发上行,防止本地被击退 / 碰撞推挤后与
# 服务器不一致(PythonPlayerEventHandler.cpp:38):100 ms 节流且相对上次上行位移
# ≥ SEND_WAITING_MIN_DISP_CM 才发。
func _tick_on_waiting() -> void:
if _was_moving or _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null:
return
var now := _now()
if now - _last_waiting_sent_t < SEND_WAITING_INTERVAL:
return
var disp_cm := _server_xy(pc.player.position).distance_to(_server_xy(_last_sent_pos))
if disp_cm < SEND_WAITING_MIN_DISP_CM:
return
_send_state(FUNC_WAIT, 0, pc.player.position)
_last_waiting_sent_t = now
# OnUseSkill —— 施法瞬间的状态上行:FUNC_SKILL|uMotSkill + uArg(不节流,
# PythonPlayerEventHandler.cpp:120)。调用点(技能合法性 / CD / charge)属 §3.8 的
# `player_skill.gd`,这里只留 1:1 的上行出口,做法同 §3.4 的预留桩。
func on_use_skill(motion_skill: int, arg: int) -> void:
if _observer_mode or pc == null or pc.player == null:
return
_was_moving = false
_send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pc.player.position)
func _on_main_set(vid: int) -> void:
_main_vid = vid
if client and client.has_method("get_entity"):
_on_entity_info(vid, client.get_entity(vid))
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(vid)
func _on_entity_info(vid: int, entity: Dictionary) -> void:
if entity.has("owner_vid"):
_note_owner(vid, int(entity.get("owner_vid", 0)))
if vid != _main_vid:
return
if pc and pc.has_method("set_server_speed"):
var speed := int(entity.get("moving_speed", 0))
if speed > 0:
pc.set_server_speed(speed)
_apply_attack_speed(int(entity.get("attack_speed", 0)))
if entity.has("race"):
combo_class = int(entity.get("race", 0)) & 3 # §3.5 连击段表按职业分块
_refresh_motion_mode() # §3.5 修改 6parts[WEAPON] / 骑乘 / 变身变化 → combo_motion_mode
func _apply_attack_speed(speed: int) -> void:
# `CInstanceBase::SetAttackSpeed`bAttackSpeed / 100 只缩放动作播放速率,
# 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。
# 出货代码:uAtkSpd > 1100 -> 0,否则 uAtkSpd / 100,没有上下限。speed 0 = 数据未到,保留旧值。
if speed > 0:
_atk_speed_factor = 0.0 if speed > 1100 else float(speed) / 100.0
# 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。
func _current_attack_period() -> float:
var base := DEFAULT_ATTACK_PERIOD
var nc := float(_motion_data().get("next_combo", 0.0))
if nc > 0.0:
base = nc
if _atk_speed_factor <= 0.0:
return base # fSpeedRatio 0 = 动作冻结;节奏只作 HUD / 冷却兜底,不除零
return base / _atk_speed_factor
# CGraphicThingInstance::UpdateTimem_fDelay 先吃掉本帧 elapsed,余下才推进本地时间。
func _advance_local_time(dt: float) -> void:
var elapsed := dt
if _delay > elapsed:
_delay -= elapsed
elapsed = 0.0
else:
elapsed -= _delay
_delay = 0.0
_local_time += elapsed
# CActorInstance::GetAttackingElapsedTime(GetLocalTime() - fStartTime) * fSpeedRatio
func _attacking_elapsed() -> float:
return (_local_time - _motion_start_t) * _atk_speed_factor
# CInstanceBase::__GetBowRangeInstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。
func _bow_range_cm() -> float:
return BOW_RANGE_BASE_CM + _bow_distance_bonus
# 可点击 / 攻击距离(米),逐分支对齐 NEW_IsClickableDistanceDestInstance
# InstanceBaseBattle.cpp:294):默认 150 → 弓箭 __GetBowRange() → NPC 500 → 采集物 100
# 后面的 if 覆盖前面的(NPC / 采集物优先于弓箭距离)。
func _clickable_distance(te: Dictionary) -> float:
var cm := CLICK_DIST_DEFAULT_CM
if _is_bow_mode():
cm = _bow_range_cm()
match _entity_kind(te):
1: cm = CLICK_DIST_NPC_CM # NPC
3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物
return cm / CM
func _on_points(p: Dictionary) -> void:
_apply_attack_speed(int(p.get("attack_speed", 0)))
_bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange()
if hud == null:
return
if hud.has_method("set_vitals"):
hud.set_vitals(int(p.get("hp", 0)), int(p.get("max_hp", 0)),
int(p.get("sp", 0)), int(p.get("max_sp", 0)))
if hud.has_method("set_stamina"):
hud.set_stamina(int(p.get("stamina", 0)), int(p.get("max_stamina", 0)))
if hud.has_method("set_exp"):
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
if hud.has_method("set_level"):
hud.set_level(int(p.get("level", 0)))
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
func _on_net_moved(vid: int) -> void:
if client == null or vid != _main_vid or pc.player == null:
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
var d: float = pc.player.position.distance_to(sp)
if d > CORRECTION_SNAP:
pc.player.position = sp
elif d > 0.3:
pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
# --- targeting / attack -----------------------------------------------------
func _on_pick(node: Node3D) -> void:
if _observer_mode:
return
var vid := _vid_of(node)
if vid == 0 or vid == _main_vid:
return
# __OnPressActor 特例(PythonPlayerInput.cpp:324):预约技能 + 冲锋 affect 生效时,
# 只改预约目标、不立即选中 / 施法。
if _reserved_mode == ReservedMode.USE_SKILL and _dash_active:
_vid_reserved = vid
return
# NPC / 石头 / warp -> 交互,不是攻击目标
var e: Dictionary = client.get_entity(vid)
if _entity_kind(e) in [1, 3, 4]:
client.click_npc(vid)
return
_attack_cd = 0.0
_change_target_to_picked_instance(vid)
# game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL
# 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building
# :814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC
# 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别,
# 不满足 PC 分支时不会误触发悄悄话。
if _entity_kind(e) == EntityRules.KIND_PC:
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
var nm := _entity_name(e)
if nm != "":
whisper_requested.emit(nm)
# poc 版 RecvTargetPacketPythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」
# 目标 -> SetHPTargetBoardNPC / warp 等不可看血目标 -> 关框。经 target_board.gd 纯静态镜像。
func _on_target_info(vid: int, hp_pct: int) -> void:
var e: Dictionary = client.get_entity(vid) if vid != 0 else {}
var exists := vid != 0 and not e.is_empty()
var dead := exists and bool(e.get("dead", false))
var kind := _entity_kind(e) if exists else -1
var is_pc := kind == EntityRules.KIND_PC
var is_building := EntityRules._is_building(e)
# uitarget.py:189-193 前置过滤:game-option 关「看他国目标框」时异阵营目标不开框。
# empire 0 的怪/NPC/石头在 _is_same_empire 里算同阵营 → 不受影响,只挡真·他国 PC。
if exists:
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire):
_clear_target()
return
var can_view := TargetBoard.can_view_target_hp(
kind == EntityRules.KIND_STONE, EntityRules._is_wooden_door(e),
kind == EntityRules.KIND_MONSTER)
match TargetBoard.classify(exists, dead, is_pc, is_building, can_view):
TargetBoard.Action.CLOSE:
_clear_target()
TargetBoard.Action.CLOSE_IF_DIFFERENT:
if TargetBoard.should_close_if_different(vid, _target_vid):
_clear_target()
TargetBoard.Action.SET_HP:
if TargetBoard.needs_reset(vid, _target_vid):
_target_vid = vid
target_changed.emit(vid)
if hud and hud.has_method("set_target"):
var nm := _entity_name(e)
hud.set_target(nm if nm != "" else "目标",
TargetBoard.clamp_hp_pct(hp_pct))
TargetBoard.Action.NONE:
pass
# game_option_ui 的「看他国玩家目标框」radiodisplay_option_changed "target_board")。
# uigameoption.py → constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)。
func set_view_other_empire_target(on: bool) -> void:
_view_other_empire_target = on
# game_scene 的 KEY_CTRL 按下 / 松开 → app.IsPressed(app.DIK_LCONTROL) 语义(game.py:807)。
func set_lcontrol_down(down: bool) -> void:
_lcontrol_down = down
func _clear_target() -> void:
if _target_vid == 0:
return
_target_vid = 0
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
# 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。
# 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING)
# 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid != _main_vid:
return
var crit := (flag & DAMAGE_CRITICAL) != 0
if camera and camera.has_method("shake"):
camera.shake(0.08 if crit else 0.04, 10.0)
if (flag & DAMAGE_DODGE) != 0:
return
_knock_down = true
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("damage")
# GC_FISHING motion events for the local player. `info` is the fisher VID for
# START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only.
func _on_fishing_event(subheader: int, info: int, _dir: int) -> void:
if info != _main_vid:
return
# CActorInstance::IsFishing() 仅在 FISHING_WAIT / FISHING_REACT 为真;
# START 到达时先记录意图,随后 STOP/SUCCESS/FAIL 收尾清掉。
match subheader:
FISHING_START, FISHING_REACT:
_fishing_active = true
FISHING_STOP, FISHING_SUCCESS, FISHING_FAIL, FISHING_FISH:
_fishing_active = false
if player_view == null or not player_view.has_method("set_anim_state"):
return
if subheader == FISHING_FISH:
return
var state := "fishing"
match subheader:
FISHING_STOP: state = "wait"
FISHING_REACT: state = "fishing_react"
FISHING_SUCCESS: state = "fishing_catch"
FISHING_FAIL: state = "fishing_fail"
player_view.set_anim_state(state)
# --- 行动闸门(对齐 InstanceBase.cpp:1247 / InstanceBaseBattle.cpp:355 / :310---
func _main_entity() -> Dictionary:
if client and client.has_method("get_entity") and _main_vid != 0:
return client.get_entity(_main_vid)
return {}
# CanProcessNetworkStatePacket:死亡 / 晕眩 / 击退中都不接受状态输入(≈ __IsSyncing)。
func _can_process_network_state() -> bool:
if _knock_down:
return false
var e := _main_entity()
return not (bool(e.get("dead", false)) or bool(e.get("stunned", false)) \
or bool(e.get("knock_down", false)))
# __CanAttack:可处理状态 + 不在施法中。
func _can_attack() -> bool:
if not _can_process_network_state() or _using_skill:
return false
# CPythonPlayer::__CanAttack() 的新坐骑等级 11 门(:759),
# 只作用于 IsNewMount;普通坐骑仍由 CanAttackHorseLevel() 的 level<=1 门处理。
var mount := int(_main_entity().get("mount_vnum", 0))
if _is_new_mount(mount) and not _horse_skill_unlocked(11):
return false
return true
# __CanUseSkill:同上(技能合法性 / CD 归 §3.8)。
func _can_use_skill() -> bool:
return _can_process_network_state() and not _using_skill
# 兼容旧调用:真正语义 = 「不能处理网络状态包 / 不能行动」。
func is_stunned() -> bool:
return not _can_process_network_state()
# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。
# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* /
# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。
func skill_context() -> Dictionary:
var e := _main_entity()
var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false))
var wsub := -1
var vnum := _equipped_weapon_vnum()
if vnum > 0 and proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
if int(d.get("type", -1)) == ITEM_TYPE_WEAPON:
wsub = int(d.get("sub_type", -1))
# §3.8 修改 3GetStatus(POINT_HP/SP) —— __CheckShortLife / __CheckShortMana 的即时值。
var cur_hp := -1
var cur_sp := -1
if client and client.has_method("get_points"):
var pts: Dictionary = client.get_points()
if pts.has("hp"):
cur_hp = int(pts.get("hp", -1))
if pts.has("sp"):
cur_sp = int(pts.get("sp", -1))
return {
"observer_mode": _observer_mode,
"mounting": mounted,
"in_safe": bool(e.get("in_safe", false)),
"is_fishing": combo_motion_mode == MOTION_MODE_FISHING,
"is_using_skill": _using_skill,
"can_act": _can_process_network_state(),
"level_limit": true,
"cooltime_check": true,
"dash_active": _dash_active,
"weapon_sub_type": wsub,
"arrow_count": _arrow_count,
"cur_hp": cur_hp,
"cur_sp": cur_sp,
"bow_distance": int(_bow_distance_bonus),
"target_distance": Callable(self, "skill_target_distance_cm"),
}
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE 200)——施法射程判定与
# MODE_USE_SKILL 预约趋近共用同一量度,保证预约触发时 __UseSkill 必然在射程内。
func skill_target_distance_cm(vid: int) -> float:
if pc == null or pc.player == null or client == null:
return -1.0
var tnode: Node3D = net_world.node_for(vid) if net_world else null
if tnode == null:
return -1.0
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
return dist_cm
# __IsReservedUseSkill(dwSkillSlotIndex)
func is_use_skill_reserved(slot: int) -> bool:
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
func _process(dt: float) -> void:
_advance_local_time(dt)
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
and not player_view.is_in_hit_reaction():
_knock_down = false
if pc:
pc.set("frozen", not _can_process_network_state())
if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null:
_clear_reserved()
_auto_attack_vid = 0
return
_attack_cd -= dt
if _reserved_delay > 0.0:
_reserved_delay -= dt
_combo_process()
_attack_process(dt) # AttackProcess:命中窗几何判定 -> OnHit
_update_auto_attack()
_refresh_reserved()
_tick_on_waiting()
_flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION
# CPythonPlayer::__Update_AutoAttack()PythonPlayer.cpp:207)——每帧检查 auto-attack 目标
# 是否仍可打,然后 __ReserveClickActor()。本工程用「按住攻击键 + 有目标」当 __OnPressActor(isAuto)。
func _update_auto_attack() -> void:
if not _attack_key_down or _target_vid == 0:
_auto_attack_vid = 0
return
_auto_attack_vid = _target_vid
if not _can_process_network_state(): # ≈ !NEW_GetMainActorPtr() / __IsSyncing()
return
if _dash_active or (_using_skill and _charge_skill): # __IsUsingChargeSkill()
return
var v: Dictionary = client.get_entity(_auto_attack_vid)
if v.is_empty():
_clear_auto_attack()
return
if bool(v.get("dead", false)):
_clear_auto_attack()
return
if _is_main_mounted() and not _can_attack_horse_level():
_clear_auto_attack()
return
if not _is_attackable(v):
return
if bool(_main_entity().get("sleeping", false)):
return
# 参考端每帧无条件 __ReserveClickActor(攻击节奏由动作数据在 swing 处 gate,不在这里)。
if _reserved_mode == ReservedMode.NONE and _can_attack():
_reserve_click_actor(_auto_attack_vid)
# NEW_RefreshMouseWalkingDirection()PythonPlayerInputMouse.cpp:287)里的模式分派——
# 四个 case 逐一照抄 :288-370(含 MODE_CLICK_ITEM 的 `< 20.0f` 拾取阈值、
# MODE_USE_SKILL 的 IS_HUGE_RACE 距离 200 修正)。
func _refresh_reserved() -> void:
match _reserved_mode:
ReservedMode.CLICK_ITEM:
_reserve_process_click_item()
ReservedMode.CLICK_ACTOR:
_reserve_process_click_actor()
ReservedMode.CLICK_POSITION:
_reserve_process_click_position()
ReservedMode.USE_SKILL:
_reserve_process_use_skill()
func _reserve_click_actor(vid: int) -> void:
_reserved_mode = ReservedMode.CLICK_ACTOR
_vid_reserved = vid
func _clear_reserved() -> void:
_reserved_mode = ReservedMode.NONE
_vid_reserved = 0
_iid_reserved = 0
_ppos_reserved = Vector3.ZERO
_skill_slot_reserved = -1
_skill_range_reserved = 0.0
_reserved_delay = 0.0
func _clear_auto_attack() -> void:
_auto_attack_vid = 0
_clear_target()
func _emit_cannot(code: String) -> void:
_last_cannot = code
cannot_act.emit(code)
# CInstanceBase::IsAttackableInstanceInstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。
# 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各
# 调用点判:__ReserveProcess_ClickActor 第 4 步、_attack_process 等)——本 POC 保留一个
# dead 提前返回作为便利,多数调用点本来也会再查一次。
func _is_attackable(e: Dictionary) -> bool:
if bool(e.get("dead", false)):
return false
var se := _main_entity()
if se.is_empty():
# headless / 主角未就位:退化到「PC / 怪可打」的旧近似。
return _entity_kind(e) in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER]
var self_e := se.duplicate()
self_e["kind"] = _entity_kind(se)
var victim_e := e.duplicate()
victim_e["kind"] = _entity_kind(e)
return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e))
# IsAttackableInstance 需要的会话级上下文:观战、自身 PK 模式、决斗、PVP 关系表、
# 公会战对表、队伍 VID 集合。数据全部来自已有的 M2Client 访问器。
func _attack_ctx(self_e: Dictionary) -> Dictionary:
var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {}
var duel_mode := EntityRules.DUEL_NONE
if bool(duel.get("active", false)):
duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \
else EntityRules.DUEL_START
var pvp_pairs := []
if client and client.has_method("get_pvp_relations"):
for r in client.get_pvp_relations():
if int(r.get("mode", 0)) != 0:
pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))])
var gvg_pairs := []
if client and client.has_method("get_guild_wars"):
for w in client.get_guild_wars():
gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))])
var party_vids := []
if client and client.has_method("get_party"):
for m in client.get_party():
party_vids.append(int(m.get("vid", 0)))
return {
"observer": _observer_mode,
"main_vid": _main_vid,
"pk_mode": int(self_e.get("pk_mode", 0)),
"duel_mode": duel_mode,
"duel_opponents": duel.get("opponents", []),
"pvp_pairs": pvp_pairs,
"gvg_pairs": gvg_pairs, # __FindGVGKey(公会战),来自 GUILD_GC_WAR 对表
"party_vids": party_vids,
}
func _can_attack_horse_level() -> bool:
# CInstanceBase::CanAttackHorseLevel() -> SHORSE::CanAttack()
# (InstanceBaseBattle.cpp:623, InstanceBase.cpp:216):骑乘等级 1 不能攻击;
# 未知坐骑沿用参考端 GetLevel()==0 的放行行为,避免凭空拒绝新坐骑。
var main := _main_entity()
var mount := int(main.get("mount_vnum", 0))
if mount == 0 and not bool(main.get("mounting", false)):
return true
return _horse_level_for_mount(mount) != 1
static func _horse_level_for_mount(mount: int) -> int:
match mount:
20101, 20102, 20103:
return 1
20104, 20105, 20106, 20119, 20219, 20220, 20221, 20222:
return 2
20107, 20108, 20109, 20110, 20111, 20112, 20113, 20114, 20115, 20116, 20117, 20118, 20120, 20121, 20122, 20123, 20124, 20125:
return 3
_ when mount >= 20205 and mount <= 20208:
return 2
_ when mount >= 20209 and mount <= 20212:
return 3
20214, 20217, 20224, 20229:
return 2
20215, 20218, 20225, 20230:
return 3
return 0
# CPythonPlayer::__ReserveProcess_ClickActor()PythonPlayerInput.cpp:877)——顺序不可改。
func _reserve_process_click_actor() -> void:
var main: Dictionary = _main_entity()
var rv: Dictionary = client.get_entity(_vid_reserved)
# 1 main / reserved 任一为空
if main.is_empty() or rv.is_empty():
_clear_reserved()
return
var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null
if tnode == null:
_clear_reserved()
return
# 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection
var dist: float = pc.player.global_position.distance_to(tnode.global_position)
if dist > _clickable_distance(rv):
if pc.has_method("walk_to"):
pc.walk_to(tnode.global_position)
return
# 3 不可攻击 -> 停 + 发点击包(NPC 对话)+ 清预约
if not _is_attackable(rv):
pc.set("frozen", false)
client.click_npc(_vid_reserved)
_clear_reserved()
return
# 4 目标已死
if bool(rv.get("dead", false)):
_clear_reserved()
return
# 5 自己在安全区
if bool(main.get("in_safe", false)):
_emit_cannot("IN_SAFE")
_clear_reserved()
return
# 6 目标在安全区
if bool(rv.get("in_safe", false)):
_emit_cannot("DEST_IN_SAFE")
_clear_reserved()
return
# 7 冲锋 / 弹射 affect 生效 -> 保持预约
if _dash_active:
return
# 8 弓箭模式且箭不足
if bool(main.get("bow_mode", false)) and _arrow_count == 0:
_emit_cannot("EMPTY_ARROW")
_clear_reserved()
return
# 9 目标未选中 -> 选中
if _target_vid != _vid_reserved:
_target_vid = _vid_reserved
client.set_target(_vid_reserved)
target_changed.emit(_vid_reserved)
_refresh_target_hud()
# 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。
# 连击 / 普攻节奏由动作数据 gate:cd 未到就本帧不 swing,仍清预约、下帧再来。
if _attack_cd <= 0.0:
_do_attack_swing(tnode, rv)
_clear_reserved()
# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case------------
# MODE_CLICK_ITEMPythonPlayerInputMouse.cpp:290):走到掉落物、到手距离内先补一包
# 站立状态再发 CG_ITEM_PICKUP,物品消失则清预约。
func _reserve_process_click_item() -> void:
if pc == null or pc.player == null:
_clear_reserved()
return
var item_pos = _ground_item_world_pos(_iid_reserved)
if item_pos == null:
_clear_reserved()
return
if pc.player.global_position.distance_to(item_pos) < CLICK_ITEM_PICKUP_CM / CM:
_send_state(FUNC_WAIT, 0, pc.player.position)
if client.has_method("pickup_item"):
client.pickup_item(_iid_reserved)
pc.set("frozen", false)
_clear_reserved()
elif pc.has_method("walk_to"):
pc.walk_to(item_pos)
# MODE_CLICK_POSITIONPythonPlayerInputMouse.cpp:320):预约延时耗尽、本地未被锁,
# 再朝目标点起步并清预约(_reserved_delay 的递减在 _process 里,≈ NEW_IsEmptyReservedDelayTime)。
func _reserve_process_click_position() -> void:
if pc and pc.has_method("_can_translate") and not pc._can_translate():
return
if _reserved_delay > 0.0:
return
if pc and pc.has_method("walk_to"):
pc.walk_to(_ppos_reserved)
_clear_reserved()
# MODE_USE_SKILLPythonPlayerInputMouse.cpp:340):目标进入技能射程(含 IS_HUGE_RACE
# 的 −200cm 修正)就选中并施法,否则继续贴近。__UseSkill 的真正施法走
# use_skill_hookgame_scene 注入的 quickbar 那条链),未注入则退化成只选中、留预约。
func _reserve_process_use_skill() -> void:
if pc == null or pc.player == null:
_clear_reserved()
return
var rv: Dictionary = client.get_entity(_vid_reserved)
var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null
if rv.is_empty() or tnode == null:
_clear_reserved()
return
var dist_cm := skill_target_distance_cm(_vid_reserved)
if dist_cm < _skill_range_reserved:
if _target_vid != _vid_reserved:
_target_vid = _vid_reserved
client.set_target(_vid_reserved)
target_changed.emit(_vid_reserved)
_refresh_target_hud()
if _invoke_reserved_skill():
_clear_reserved()
elif pc.has_method("walk_to"):
pc.walk_to(tnode.global_position)
# __UseSkill(m_dwSkillSlotIndexReserved):实际槽位施法在 quickbar,由 game_scene 通过
# use_skill_hookCallable(global_slot:int) -> bool)注入。未注入 -> 返回 false(预约保持,
# 下一帧再试,与参考端 __UseSkill 失败留预约一致)。
var use_skill_hook := Callable()
func _invoke_reserved_skill() -> bool:
if _skill_slot_reserved < 0:
return false
if use_skill_hook.is_valid():
return bool(use_skill_hook.call(_skill_slot_reserved))
return false
# NEW_GetGroundItemPositionPythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。
# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。
func _ground_item_world_pos(iid: int):
if client == null or not client.has_method("get_ground_items"):
return null
for gi in client.get_ground_items():
if int(gi.get("vid", 0)) == iid:
var p: Variant = gi.get("pos", null)
return MapCoord.to_world(p) if p is Vector3 else null
return null
# __ReserveClickItemPythonPlayerInput.cpp)——预约「走到掉落物再拾取」。
func reserve_click_item(iid: int) -> void:
_clear_reserved()
_reserved_mode = ReservedMode.CLICK_ITEM
_iid_reserved = iid
# __ReserveClickGroundPythonPlayerInput.cpp:836)——预约「延时 0.1s 后走到该点」。
func reserve_click_ground(world_pos: Vector3) -> void:
_clear_reserved()
_reserved_mode = ReservedMode.CLICK_POSITION
_ppos_reserved = world_pos
_reserved_delay = 0.1
# __ReserveUseSkillPythonPlayerInput.cpp:854)——预约「进射程再施法」。range > 100 时
# 先减 10(参考端同款收边,避免边界处反复趋近)。slot 为全局快捷栏索引。
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
_clear_reserved()
_reserved_mode = ReservedMode.USE_SKILL
_vid_reserved = vid
_skill_slot_reserved = slot
if range_cm > 100.0:
range_cm -= RESERVE_SKILL_RANGE_TRIM_CM
_skill_range_reserved = range_cm
# __OnPressItemPythonPlayerInput.cpp)——点地面掉落物:清预约 + auto-attack,在拾取
# 距离内直接 CG_ITEM_PICKUP + 停,否则预约走过去。
func pick_ground_item(iid: int) -> void:
if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null:
return
_clear_reserved()
_auto_attack_vid = 0
var item_pos = _ground_item_world_pos(iid)
if item_pos == null:
return
if pc.player.global_position.distance_to(item_pos) * CM < CLICK_DIST_RESOURCE_CM:
_send_state(FUNC_WAIT, 0, pc.player.position)
if client.has_method("pickup_item"):
client.pickup_item(iid)
pc.set("frozen", false)
else:
reserve_click_item(iid)
# IS_HUGE_RACE(vnum)(参考端是一张未随源码发布的硬编码 vnum 白名单)——用 mob_proto
# 的体型 / 阶级位近似:MOBSIZE_HUGE(3) 或 BOSS(4) 以上视为巨型。查不到 proto -> false。
func _is_huge_race(race: int) -> bool:
if race <= 0 or proto == null or not proto.has_method("mob"):
return false
var m: Dictionary = proto.mob(race)
if m.is_empty():
return false
return int(m.get("size", 0)) >= HUGE_RACE_SIZE or int(m.get("rank", 0)) >= HUGE_RACE_RANK
# __ChangeTargetToPickedInstancePythonPlayerInput.cpp:186)→ SetTarget:130):
# 可选中就选中并发 CG_TARGET,否则清空目标并发 CG_TARGET(0)。这里「目标」限定为战斗
# 目标(PC / 怪)——NPC / 石头 / warp 的交互在 _on_pick 里已分流到 click_npc
# (对齐 __OnClickActor 的拆分)。
func _change_target_to_picked_instance(vid: int) -> bool:
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or bool(e.get("dead", false)) or _entity_kind(e) not in [0, 2]:
_target_vid = 0
client.set_target(0)
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
return false
_target_vid = vid
client.set_target(vid)
target_changed.emit(vid)
_refresh_target_hud()
return true
# Public test/automation adapter for the same target path used by a real
# pointer click. It keeps all combat eligibility checks and sends the normal
# CG_TARGET through _change_target_to_picked_instance; it does not synthesize
# a hit or alter EntityStore state.
func select_target(vid: int) -> bool:
if _observer_mode or client == null or not client.has_method("is_in_game") or not client.is_in_game():
return false
_attack_cd = 0.0
return _change_target_to_picked_instance(vid)
# --- §3.4 扇形 / 圆形多目标 fly-targetingPythonPlayerSkill.cpp:680-746----------
# 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关,
# 只要 target 与 each 用同一函数(对齐 NEW_Get*InFanRange 里 fDirRot 的比较)。
func _bearing_deg(from_world: Vector3, to_world: Vector3) -> float:
var d := to_world - from_world
return rad_to_deg(atan2(d.x, -d.z))
func _dir_from_bearing(deg: float) -> Vector3:
var r := deg_to_rad(deg)
return Vector3(sin(r), 0.0, -cos(r))
# 场景节点优先(已在 world 帧),否则实体 pos 过 MapCoord。
func _entity_world_pos(vid: int):
if net_world and net_world.has_method("node_for"):
var n: Node3D = net_world.node_for(vid)
if n != null:
return n.global_position
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or not (e.get("pos", null) is Vector3):
return null
return MapCoord.to_world(e.get("pos"))
# NEW_GetInstanceVectorInCircleRangeInstanceBaseBattle.cpp:243)——射程内全部可攻击
# 实体,按距离升序。
func _instances_in_circle_range(range_cm: float) -> Array:
var out := []
if client == null or pc == null or pc.player == null:
return out
var main_pos: Vector3 = pc.player.global_position
var reach_m := range_cm / CM
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _main_vid or not _is_attackable(e):
continue
var wp = _entity_world_pos(vid)
if wp == null:
continue
var dist_m: float = main_pos.distance_to(wp)
if dist_m <= reach_m:
out.append({"vid": vid, "dist_m": dist_m, "pos": wp})
out.sort_custom(func(a, b): return a["dist_m"] < b["dist_m"])
return out
# NEW_GetInstanceVectorInFanRangeInstanceBaseBattle.cpp:192)——先按圆形取,再按
# 「随距离收窄的半角」过滤:fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN。
func _instances_in_fan_range(range_cm: float, target_vid: int) -> Array:
var circle := _instances_in_circle_range(range_cm)
var tp = _entity_world_pos(target_vid)
if tp == null:
return []
var main_pos: Vector3 = pc.player.global_position
var dst_rot := deg_to_rad(_bearing_deg(main_pos, tp))
var rpm := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) / FAN_HALF_ROT_MIN_DISTANCE
var kept := []
for c in circle:
var each_dist_cm: float = min(c["dist_m"] * CM, FAN_HALF_ROT_MIN_DISTANCE)
var half_fan := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) - rpm * each_dist_cm + FAN_HALF_ROT_MIN
var each_rot := deg_to_rad(_bearing_deg(main_pos, c["pos"]))
if absf(angle_difference(dst_rot, each_rot)) <= deg_to_rad(half_fan):
kept.append(c)
return kept
# NEW_GetRandomPositionInFanRangeInstanceBaseBattle.cpp:124)——目标方位 ±10° 抖动、
# 沿该方向 8000cm 取点(参考端绕 Z 转 (0,8000,0);这里在 world 帧等价展开)。
func _random_pos_in_fan_range(target_vid: int) -> Vector3:
var main_pos: Vector3 = pc.player.global_position
var tp = _entity_world_pos(target_vid)
var base_bearing: float = _bearing_deg(main_pos, tp) if tp != null else rad_to_deg(pc.player.rotation.y)
var rot := base_bearing + randf_range(-FAN_RANDOM_JITTER_DEG, FAN_RANDOM_JITTER_DEG)
return main_pos + _dir_from_bearing(rot) * (FAN_RANDOM_REACH_CM / CM)
# __UseSkillPythonPlayerSkill.cpp:684-746)里 GetTargetCount > 0 时的扇形 / 圆形补目标。
# 主目标的 CG_FLY_TARGETING 已由 M2Client::use_skill 发过,这里只补「附加」目标——
# 命中范围内的其他敌人(AddFlyTargetInstance+ 名额不足时的随机落点(AddFlyTargetPosition
# vid = 0)。返回补发的附加 fly-target 包数量。
func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape: int) -> int:
if client == null or not client.has_method("add_fly_targeting"):
return 0
if max_count <= 1 or target_vid == 0 or shape == FlyShape.SINGLE:
return 0
if pc == null or pc.player == null:
return 0
# __GetSkillTargetRange:基础射程再叠加主角 POINT_BOW_DISTANCE(×100,参考端此处
# 与 __GetBowRange 的「不 ×100」不一致,照抄各自的写法)。
var reach_cm := range_cm + _bow_distance_bonus * 100.0
var cands: Array = _instances_in_fan_range(reach_cm, target_vid) if shape == FlyShape.FAN \
else _instances_in_circle_range(reach_cm)
var count := 1
var extras := 0
for c in cands:
if count >= max_count:
break
var vid := int(c["vid"])
if vid == target_vid:
continue
if bool(client.get_entity(vid).get("dead", false)):
continue
var sc := _server_xy(c["pos"])
client.add_fly_targeting(vid, int(sc.x), int(sc.y))
count += 1
extras += 1
while count < max_count:
var sc2 := _server_xy(_random_pos_in_fan_range(target_vid))
client.add_fly_targeting(0, int(sc2.x), int(sc2.y))
count += 1
extras += 1
return extras
# NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand
# ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。
func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void:
var to: Vector3 = tnode.global_position - pc.player.global_position
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_was_moving = false
var vec := _combo_vec()
if vec.is_empty():
# 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 退化为单段普攻。
_emit_swing(NAME_NORMAL_ATTACK, false)
return
# m_isPreInput 挂起时忽略本次输入(等 ComboProcess 到点自动触发)。
if _is_pre_input:
return
if _combo_index == 0:
_run_next_combo(vec)
return
var t := _attacking_elapsed()
var md := _motion_data()
if bool(md.get("has_combo_input", false)):
var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime
var t_next := float(md.get("direct_input_time", 0.0)) # GetNextComboTime
var t_end := float(md.get("input_limit_time", 0.0)) # GetComboInputEndTime
if t > t_end:
if _is_bow_mode():
_is_next_pre_input = true
return
if t > t_next:
_run_next_combo(vec)
elif t > t_start:
_is_pre_input = true
else:
var dur := float(md.get("duration", attack_period))
if t > dur * 0.9:
_run_next_combo(vec)
# CActorInstance::__RunNextComboActorInstanceBattle.cpp:256
func _run_next_combo(vec: PackedInt32Array) -> void:
_combo_index += 1
var arr_idx := _combo_index - 1
if arr_idx < 0 or arr_idx >= vec.size():
_combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进
return
_emit_swing(int(vec[arr_idx]), _combo_index > 1)
if _combo_index == vec.size():
_on_end_combo()
# __OnEndCombo:骑乘时索引回 1,否则不复位(复位在动作回 Wait 时的 __ClearCombo)。
func _on_end_combo() -> void:
if combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST:
_combo_index = 1
# __ClearCombo:动作回到 Wait 时清空连击状态。
func _clear_combo() -> void:
_combo_index = 0
_is_pre_input = false
_is_next_pre_input = false
_hit_windows = []
_hit_dedup.clear()
# CActorInstance::ComboProcessActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。
func _combo_process() -> void:
if _combo_index != 0:
var t := _attacking_elapsed()
var md := _motion_data()
var has_ci := bool(md.get("has_combo_input", false))
var full := float(md.get("duration", attack_period))
if full <= 0.0:
full = attack_period
if _is_pre_input:
var t_next := float(md.get("direct_input_time", 0.0)) if has_ci else full * 0.9
if t > t_next:
_is_pre_input = false
var vec := _combo_vec()
if not vec.is_empty():
_run_next_combo(vec)
return
# 参考端这里没有「超过整段时长就清连击」:段号在动作回 Wait 时由 __SetMotion 尾
# _on_motion_bound)清零。
else:
_is_pre_input = false
if _is_next_pre_input and not _using_skill:
var vec := _combo_vec()
if not vec.is_empty():
_run_next_combo(vec)
_is_next_pre_input = false
# OnAttack 状态包 + 缓存命中窗 + 播动作 + 刷新节奏。§3.5
# OnHitSendAttackPacket)不在这里:有 `.msa` 命中窗时它由 `_attack_process()` 的几何判定
# 逐帧触发(1:1 对齐 `__ProcessDataAttackSuccess` → `OnHit`);只有拿不到命中窗数据
# headless / 缺资源)才在此退化为立即发一包。
func _emit_swing(motion_index: int, is_combo: bool) -> void:
# 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(在
# CActorInstance::__SetMotion 里 __IsNeedFlyTargetMotion() 为真时触发,
# ActorInstanceMotion.cpp:372):立即发 CG_FLY_TARGETING(目标VID, 目标包围球中心)。
# OnShoot(uSkill) 则要等 `.msa` FLY 帧,交给 game_scenebow_shot_fired → 入队)。§3.6
if _is_bow_mode():
# __CanShot 关:无箭 / 无目标 / 出弓程 → 走 OnNoArrow / OnNoTarget 文案,
# 不发 CG_FLY_TARGETING、不排 OnShoot(其余起手动作照常,seam:参考端整挥击中止)。
var why := _can_shot()
if why != "":
_emit_cannot(why)
else:
_send_fly_target()
bow_shot_fired.emit(_swing_skill)
# OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号
# PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段
# 动作,命中窗 / 连击时间才读得到这一段的 .msa。
if player_view and player_view.has_method("play_attack_motion"):
player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor)
elif player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if is_combo else "attack")
_begin_hit_windows()
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
# PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
client.attack(_swing_skill, _vid_reserved)
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
_attack_cd = attack_period
# CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTargetPythonPlayerEventHandler.cpp:235):
# SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition())
# OnGetFlyTargetPositionActorInstanceFly.cpp:4)返回目标包围球中心的世界坐标;其平面
# 坐标与角色脚点一致,而 CG_FLY_TARGETING 只带平面坐标,故直接取目标节点位置换算 server-XY。
func _send_fly_target() -> void:
var vid := _target_vid if _target_vid != 0 else _vid_reserved
if vid == 0 or client == null or net_world == null \
or not client.has_method("add_fly_targeting"):
return
var tnode: Node3D = net_world.node_for(vid)
if tnode == null:
return
var sp := _server_xy(tnode.global_position)
client.add_fly_targeting(vid, int(sp.x), int(sp.y))
# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackDataTHitDataContainer + iMotionType +
# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce),
# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。
func _begin_hit_windows() -> void:
var md := _motion_data()
var w: Variant = md.get("hit_windows", [])
_hit_windows = w if w is Array else []
_hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE))
_hit_limit_count = int(md.get("hit_limit_count", 0))
_hit_invisible_time = float(md.get("invisible_time", 0.0))
_hit_type = int(md.get("hitting_type", HIT_TYPE_NONE))
_hit_stiffen = float(md.get("stiffen_time", 0.0))
_hit_external_force = float(md.get("external_force", 0.0))
_hit_dedup.clear()
_motion_start_t = _local_time
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即
# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。
func _attack_process(dt: float) -> void:
if _hit_windows.is_empty() or client == null or not client.is_in_game():
return
if pc == null or pc.player == null or net_world == null:
return
_atk_frame += 1
var t := _attacking_elapsed()
# 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking
var latest_end := 0.0
for w in _hit_windows:
latest_end = maxf(latest_end, float(w.get("end_time", 0.0)))
if t - dt > latest_end:
_hit_windows = []
_hit_dedup.clear()
return
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
var a_pos := _actor_cm(pc.player.global_position)
var yaw := _player_yaw()
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _main_vid:
continue
if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)):
continue
var vnode: Node3D = net_world.node_for(vid)
if vnode == null:
continue
# AttackingProcessrVictim.__isInvisible() -> 跳过
if _now() < float(_victim_invisible_until.get(vid, 0.0)):
continue
_normal_attack_process(e, vid, vnode, a_pos, yaw, t, dt)
# Godot 世界 m -> 角色世界 cmX, Y, 高度)
static func _actor_cm(g: Vector3) -> Vector3:
return Vector3(g.x * CM, -g.z * CM, g.y * CM)
static func _godot_m(a: Vector3) -> Vector3:
return Vector3(a.x / CM, a.z / CM, -a.y / CM)
# 模型本地 cm 按 Godot yaw 转到角色世界朝向(= Metin2 的 Z 轴旋转)
static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
var c := cos(yaw)
var s := sin(yaw)
return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float,
t: float, dt: float) -> void:
var v_pos := _actor_cm(vnode.global_position)
var huge := HitCollision.is_huge_race(int(e.get("race", 0)))
# 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留)
var dx := v_pos.x - a_pos.x
var dz := v_pos.z - a_pos.z
var dist2 := dx * dx + 2.0 * dz * dz
var lim := HIT_DISTANCE_HUGE_CM if huge else HIT_DISTANCE_CM
if dist2 >= lim * lim:
return
var spheres := _defending_spheres(vid, vnode, v_pos)
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
continue
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
var m: Dictionary = _hit_dedup.get(wi, {})
if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time):
continue
var samples: Variant = w.get("samples", [])
if not (samples is Array):
continue
for smp in samples:
var st := float(smp.get("time", 0.0))
if st < t - dt or st > t:
continue
var p: Vector3 = smp.get("pos", Vector3.ZERO)
var lp: Vector3 = smp.get("last_pos", p)
var d_pos := _rot_cm(p, yaw) + a_pos
var d_last := _rot_cm(lp, yaw) + a_pos
for sp in spheres:
if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
continue
if not _register_hit(wi, vid):
return
var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5
_process_attack_success(e, vid, vnode, hit_pos, huge)
return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
var raw: Array = []
if vnode.has_method("get_defending_spheres"):
raw = vnode.get_defending_spheres()
if raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
var vyaw := vnode.rotation.y
var cur: Array = []
for sp in raw:
cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
var prev: Dictionary = _def_last.get(vid, {})
var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size()
var out: Array = []
for i in raw.size():
out.append({"radius": float(raw[i].get("radius", 0.0)), "pos": cur[i],
"last": prev["pos"][i] if has_prev else cur[i]})
_def_last[vid] = {"frame": _atk_frame, "pos": cur}
return out
# m_HitDataMap 登记(__NormalAttackProcess 命中分支):窗第一次命中 -> 新建 {vid: 本地时间 +
# fInvisibleTime} 直接处理;否则 map::insert(已在就不覆盖),NORMAL/COMBO 超 16、SKILL 超
# iHitLimitCount -> FALSE。
func _register_hit(wi: int, vid: int) -> bool:
if not _hit_dedup.has(wi):
_hit_dedup[wi] = {vid: _local_time + _hit_invisible_time}
return true
var m: Dictionary = _hit_dedup[wi]
if not m.has(vid):
m[vid] = _local_time + _hit_invisible_time
var cap := _hit_limit_count
if _hit_motion_type == MOTION_TYPE_COMBO or _hit_motion_type == MOTION_TYPE_NORMAL:
cap = COMBO_HIT_LIMIT
return m.size() <= cap
# CActorInstance::__ProcessDataAttackSuccessActorInstanceBattle.cpp
func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool) -> void:
if _hit_type == HIT_TYPE_NONE:
return
# InsertDelay(fStiffenTime):攻击者本地时间冻结(命中硬直)
_delay = _hit_stiffen
if player_view and player_view.has_method("insert_delay"):
player_view.insert_delay(_hit_stiffen)
var a_g: Vector3 = pc.player.global_position
var v_g := vnode.global_position
var to := v_g - a_g
# __PushCircle + IncreaseExternalForce
if _can_push(e, vid, huge) and _hit_external_force > 0.0 and net_world.has_method("push_victim"):
net_world.push_victim(vid, Vector2(to.x, -to.z).normalized(), _hit_external_force)
# 受击方 m_fInvisibleTime = 现在 + fInvisibleTime;受击方 InsertDelay
_victim_invisible_until[vid] = _now() + _hit_invisible_time
if vnode.has_method("insert_delay"):
vnode.insert_delay(_hit_stiffen)
var kind := _entity_kind(e)
var building := kind == EntityRules.KIND_BUILDING
var door := kind == EntityRules.KIND_WOODEN_DOOR
# 命中特效 m_dwBattleHitEffectID:建筑 / 门在攻击者身前 30 cm、不转;其余在受击方(巨型:命中点)
if fx and fx_parent and fx.has_method("spawn_at"):
var at := v_g
var rot := atan2(-to.x, -to.z)
if building or door:
var flat := Vector3(to.x, 0.0, to.z)
at = a_g + (flat.normalized() if flat.length() > 0.0 else Vector3.ZERO) * 0.3
rot = 0.0
elif huge:
at = _godot_m(hit_pos)
var eff: Node3D = fx.spawn_at(EFFECT_HIT, fx_parent, at, true)
if eff:
eff.rotation.y = rot
# __HitStone / __HitGood / __HitGreate
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
elif _hit_type == HIT_TYPE_GOOD:
if vnode.has_method("hit_good"):
vnode.hit_good(scalar, stunned)
elif _hit_type == HIT_TYPE_GREAT:
if vnode.has_method("hit_greate"):
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
# CActorInstance::__CanPushDestActorActorInstanceBattle.cpp
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
var kind := _entity_kind(e)
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
return false
if huge:
return false
if bool(e.get("stunned", false)):
return true
var owner := int(e.get("owner_vid", 0))
_note_owner(vid, owner)
if owner != _main_vid:
return false
return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME
# m_dwOwnerVID / m_fOwnerBaseTime:服务端 owner 变化时记下客户端观测时刻(__GetOwnerTime 的近似)。
func _note_owner(vid: int, owner: int) -> void:
var seen: Array = _owner_seen.get(vid, [])
if seen.is_empty() or int(seen[0]) != owner:
_owner_seen[vid] = [owner, _now()]
# CPythonPlayerEventHandler::OnHitSetTarget(victim, FALSE) + SendAttackPacket(uSkill, victim)
# 受击方正被击退且不是巨型 -> 记进帧末 CG_SYNC_POSITION 列表(GetBlendingPosition)。
func _on_hit(vid: int, huge: bool) -> void:
_target_vid = vid
client.attack(_swing_skill, vid)
if huge or not net_world.has_method("is_pushing") or not net_world.is_pushing(vid):
return
var sp := _server_xy(net_world.blending_position(vid))
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
func _on_entity_gone(vid: int) -> void:
_owner_seen.erase(vid)
_victim_invisible_until.erase(vid)
_def_last.erase(vid)
if vid == _target_vid:
_clear_target()
# CPythonPlayerEventHandler::FlushVictimListPythonPlayerEventHandler.cpp:194)——帧末把
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。
func _flush_victim_list() -> void:
if _victim_flush.is_empty():
return
if client and client.is_in_game():
# slice 返回新数组:既做 16 上限截断,也避免把马上要 clear() 的引用传出去。
client.sync_positions(_victim_flush.slice(0, SYNC_POSITION_LIMIT))
_victim_flush.clear()
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
# --- HUD -----------------------------------------------------------------
func _on_vitals(vid: int) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
if vid == _main_vid and hud and hud.has_method("set_vitals"):
hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)),
int(e.get("sp", 0)), int(e.get("max_sp", 0)))
if vid == _target_vid:
_refresh_target_hud()
func _refresh_target_hud() -> void:
if hud == null or not hud.has_method("set_target"):
return
var e: Dictionary = client.get_entity(_target_vid)
if e.is_empty():
return
var mh := int(e.get("max_hp", 0))
var pct := int(100.0 * float(e.get("hp", 0)) / float(mh)) if mh > 0 else 0
hud.set_target(_entity_name(e), pct)