- 连击类型由 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
1894 lines
82 KiB
GDScript
1894 lines
82 KiB
GDScript
# 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_RANGE,PythonSkill.cpp:1103/1111)。
|
||
enum FlyShape { SINGLE, FAN, CIRCLE }
|
||
# NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.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
|
||
# __ReserveUseSkill(PythonPlayerInput.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 的目标框收包分派(判定树 + CanViewTargetHP)1: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`:默认 150,NPC 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::__GetBowRange(InstanceBase.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_fAttackRadius(ActorInstanceCollisionDetection.cpp:355)
|
||
const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT
|
||
# NRaceData::EHitType(GameType.h)
|
||
const HIT_TYPE_NONE := 0
|
||
const HIT_TYPE_GREAT := 1
|
||
const HIT_TYPE_GOOD := 2
|
||
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcess:fDistance >= 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_dwBattleHitEffectID(playersettingmodule.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 连击段(对齐 CActorInstance,ActorInstanceBattle.cpp:157-330)---
|
||
# chr.MOTION_MODE_*(CRaceMotionData::EMode,RaceMotionData.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::EItemType(ItemData.h:22)子集 —— 只用到武器 / 钓竿 / 镐
|
||
const ITEM_TYPE_WEAPON := 1
|
||
const ITEM_TYPE_ROD := 13
|
||
const ITEM_TYPE_PICK := 24
|
||
# CItemData::EWeaponSubTypes(ItemData.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=shaman(race & 3)
|
||
var combo_motion_mode := MOTION_MODE_GENERAL # m_wcurMotionMode(§3.5 修改 6:按装备武器种类 + 骑乘态)
|
||
var _combo_type := 0 # m_wcurComboType(SetComboType;连击技能激活时 = MIN(lv,2))
|
||
var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中)
|
||
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 每帧 += elapsed,InsertDelay 冻结(命中硬直)。
|
||
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_windows(THitDataContainer)
|
||
var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType)
|
||
var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限)
|
||
var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(fInvisibleTime)
|
||
var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_type(iHittingType)
|
||
var _hit_stiffen := 0.0 # get_motion_data().stiffen_time(fStiffenTime)
|
||
var _hit_external_force := 0.0 # get_motion_data().external_force(fExternalForce)
|
||
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::SetComboSkillFlag(PythonPlayerSkill.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::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支:
|
||
# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE /
|
||
# 否则 FISHING;骑乘时按武器 subtype 走 HORSE_* ;否则按 subtype 走 ONEHAND / TWOHAND /
|
||
# DUALHAND / BOW / FAN / BELL,ARROW 及其余 → 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 1200(InstanceBase.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_AutoClear(REF/UserInterface/PythonPlayerEventHandler.cpp:235)
|
||
# 的 OnSetFlyTarget / OnShoot 之外有 OnNoTarget() / OnNoArrow() 两个失败回调(REF/GameLib/FlyHandler.h);
|
||
# ② NEW_Attack(REF/GameLib/InstanceBaseBattle.cpp:355)的 IsDead / IsStun / IsKnockDown / IsUsingSkill 门;
|
||
# ③ 弓普攻可点击 / 攻击距离走 __GetBowRange(InstanceBase.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::__GetCurrentComboType(ActorInstance.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
|
||
# OnMoving:300 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 修改 6:parts[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::UpdateTime:m_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::__GetBowRange(InstanceBase.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 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
|
||
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
|
||
# PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」
|
||
# 目标 -> SetHPTargetBoard;NPC / 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 的「看他国玩家目标框」radio(display_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 修改 3:GetStatus(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::IsAttackableInstance(InstanceBase.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_ITEM(PythonPlayerInputMouse.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_POSITION(PythonPlayerInputMouse.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_SKILL(PythonPlayerInputMouse.cpp:340):目标进入技能射程(含 IS_HUGE_RACE
|
||
# 的 −200cm 修正)就选中并施法,否则继续贴近。__UseSkill 的真正施法走
|
||
# use_skill_hook(game_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_hook(Callable(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_GetGroundItemPosition(PythonPlayerInputMouse.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
|
||
|
||
# __ReserveClickItem(PythonPlayerInput.cpp)——预约「走到掉落物再拾取」。
|
||
func reserve_click_item(iid: int) -> void:
|
||
_clear_reserved()
|
||
_reserved_mode = ReservedMode.CLICK_ITEM
|
||
_iid_reserved = iid
|
||
|
||
# __ReserveClickGround(PythonPlayerInput.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
|
||
|
||
# __ReserveUseSkill(PythonPlayerInput.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
|
||
|
||
# __OnPressItem(PythonPlayerInput.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
|
||
|
||
# __ChangeTargetToPickedInstance(PythonPlayerInput.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-targeting(PythonPlayerSkill.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_GetInstanceVectorInCircleRange(InstanceBaseBattle.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_GetInstanceVectorInFanRange(InstanceBaseBattle.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_GetRandomPositionInFanRange(InstanceBaseBattle.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)
|
||
|
||
# __UseSkill(PythonPlayerSkill.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::__RunNextCombo(ActorInstanceBattle.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::ComboProcess(ActorInstanceBattle.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
|
||
# OnHit(SendAttackPacket)不在这里:有 `.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_scene(bow_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::OnSetFlyTarget(PythonPlayerEventHandler.cpp:235):
|
||
# SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition())
|
||
# OnGetFlyTargetPosition(ActorInstanceFly.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` 取 TMotionAttackData(THitDataContainer + 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::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess ->
|
||
# __NormalAttackProcess(ActorInstanceCollisionDetection.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
|
||
# AttackingProcess:rVictim.__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 -> 角色世界 cm(X, 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::__ProcessDataAttackSuccess(ActorInstanceBattle.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::__CanPushDestActor(ActorInstanceBattle.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::OnHit:SetTarget(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::FlushVictimList(PythonPlayerEventHandler.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)
|