Files
mtgodot-poc/project/net_play.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

2848 lines
121 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd")
signal level_up(new_level: int)
signal money_picked(amount: int)
signal status_refresh_requested()
signal skill_refresh_requested()
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 的直接点击阈值(40250 __OnPressItem ->
# NEW_IsClickableDistanceDestPixelPositionfDistance > 150cm 时预约走路)。
const CLICK_ITEM_PICKUP_CM := 150.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 # 米 -> 厘米
# EPointTypes from 40250 Packet.h. RecvPointChange uses these exact branches
# to decide which status/skill views are refreshed after the typed point event.
const POINT_LEVEL := 1
const POINT_ST := 12
const POINT_HT := 13
const POINT_DX := 14
const POINT_IQ := 15
const POINT_LEVEL_STEP := 25
const POINT_SUB_SKILL := 27
const POINT_SKILL := 28
const POINT_STAT_RESET_COUNT := 112
const POINT_HORSE_SKILL := 113
const POINT_GOLD := 11
const POINT_ENERGY := 128
# §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")
const MotionSplash = preload("res://motion_splash.gd")
var client: Node # M2Client
var pc: Node # PlayerController
var net_world: Node # NetWorld
var hud: Node # 可空
var target_ui: Node # 40250 uitarget.py TargetBoard (可空)
var audio: Node # 可空
var locale: RefCounted = null # 语言包 Locale 实例(查 mob_names.txt / 翻译)
var _last_level := -1
var _last_exp := -1
var _last_dead_mob_pos := Vector3.INF
# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.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)
if v and v.has_method("set_motion_mode"):
v.set_motion_mode(combo_motion_mode)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount
var proto: Node: # Metin2Proto(可空,按 race 分类 NPC/怪 + 武器动作模式)
set(v):
proto = v
# GameScene receives the initial entity/equipment burst before item_proto
# finishes loading. Re-evaluate here or a 3150 weapon remains GENERAL until
# the next inventory packet, visually leaving the left hand off the grip.
if v:
_refresh_motion_mode()
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
var _main_dead := false
# 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 _last_click_actor_t := -10.0
var _last_click_actor_vid := 0
var _target_end_time := 0.0 # CPythonPlayer::m_dwTargetEndTime (seconds)
var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing
var _using_skill := false # 施法中(§3.8 由 player_skill 驱动)
var _skill_cast_start_time := -1.0 # 技能动作起播时间
var _skill_cast_duration := 0.0 # 技能动作时长超时门控
var _skill_motion_driven := false # 有效 MSA 时由 motion_bound(wait) 结束;无动作时才用时长兜底
var _skill_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控
# §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 基准)
var _is_attacking := false # 当前是否处于挥击攻击动作中(40250 isLock 门)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
var _has_attack_motion_data := false # m_pkCurRaceMotionData->isAttackingMotion() / AttackingData 是否存在
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 _motion_splash := {} # CActorInstance::m_kSplashArea(本地主角)
var _remote_motion_splashes := {} # vid:int -> CActorInstance::m_kSplashArea(远端 actor
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, audio_node: Node = null) -> void:
client = m2client
pc = player_ctl
net_world = nw
hud = hud_node
audio = audio_node
pc.target_selected.connect(_on_pick)
if pc.has_signal("ground_item_clicked"):
pc.ground_item_clicked.connect(pick_ground_item)
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)
if client.has_signal("point_changed"):
client.point_changed.connect(_on_point_changed)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(_on_entity_gone)
client.entity_dead.connect(_on_entity_dead)
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
# GameScene 的资源加载会让首个 entity_main_set 早于 NetPlay.setup 到达;补读当前
# 主角,和 NetWorld.catch_up 一样避免丢失进场状态(动作模式、本地主角 VID、目标)。
if client.has_method("get_main_vid"):
var current_main := int(client.get_main_vid())
if current_main > 0:
_on_main_set(current_main)
# setup 通常发生在网络已完成首轮 POINTS 同步之后。先建立经验/等级基线,
# 否则进入游戏后的第一次击杀只会把 _last_exp 从 -1 初始化,表现为没有经验反馈。
if client.has_method("get_points"):
var initial_points: Dictionary = client.get_points()
if not initial_points.is_empty():
_on_points(initial_points)
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
_clear_auto_attack()
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
# 键盘 / 摇杆手动移动:对齐 NEW_MoveToDirection,清预约并取消钓鱼
func on_manual_move() -> bool:
_clear_reserved()
_clear_auto_attack()
return cancel_fishing()
# CPythonPlayer::Clear() 的 Loading 等价入口。
# 这些状态属于当前 Actor/动作生命周期,不能跟着 NetPlay 节点跨 GC_WARP、重连或
# 新的 GC_MAIN_CHARACTER 保留;账号级物品、任务和朋友数据仍由 EntityStore 保留。
func clear_for_map_change() -> void:
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
# CPythonPlayer::Clear / SetMainCharacterIndex 会清掉只属于旧主角生命周期
# 的 m_isOpenPrivateShop 与 m_bisProcessingEmotion,不能让换图期间的旧
# entity_info 延迟包继续挡住新角色输入。
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(false)
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(false)
_target_vid = 0
_main_vid = 0
_main_dead = false
_last_sent_pos = Vector3.ZERO
_last_moving_sent_t = 0.0
_last_waiting_sent_t = 0.0
_was_moving = false
_attack_cd = 0.0
_last_click_actor_t = -10.0
_last_click_actor_vid = 0
_target_end_time = 0.0
_knock_down = false
_using_skill = false
_skill_cast_start_time = -1.0
_skill_cast_duration = 0.0
_skill_motion_driven = false
_skill_cancel_enable = false
_combo_type = 0
_clear_combo()
_local_time = 0.0
_delay = 0.0
_motion_start_t = 0.0
_swing_skill = 0
_has_attack_motion_data = false
_hit_windows = []
_hit_dedup.clear()
_motion_splash.clear()
_remote_motion_splashes.clear()
_victim_flush.clear()
_victim_invisible_until.clear()
_def_last.clear()
_atk_frame = 0
_owner_seen.clear()
_attack_key_down = false
_fishing_active = false
_last_fishing_cancel_t = -1e9
_last_level = -1
_last_exp = -1
_last_dead_mob_pos = Vector3.INF
_arrow_count = -1
_bow_distance_bonus = 0.0
_dash_active = false
_charge_skill = false
_clear_reserved()
_clear_auto_attack()
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
if target_ui and target_ui.has_method("close"):
target_ui.close()
# 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
if _state == "wait":
_is_attacking = false
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) / InterceptOnceMotion
# 40250 PythonPlayerSkill.cpp:751: __ClearReservedAction(); __ClearAutoAttackTargetActorID(); NEW_Stop();
func start_skill_cast(duration: float = 2.0, is_charge: bool = false,
cancel_enable: bool = false, motion_driven: bool = false) -> void:
_using_skill = true
_charge_skill = is_charge
_skill_cancel_enable = cancel_enable
_skill_motion_driven = motion_driven
_skill_cast_start_time = _local_time
_skill_cast_duration = duration if duration > 0.0 else 2.0
_clear_reserved()
_clear_auto_attack()
if pc and pc.has_method("stop"):
pc.stop()
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
_clear_combo()
if player_view and player_view.has_method("set_motion_mode"):
player_view.set_motion_mode(mode)
# §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 view := _combat_view()
var pv_anim: Object = view.get("anim") if view and "anim" in view else null
if pv_anim and pv_anim.has_method("get_motion_data"):
return pv_anim.get_motion_data()
return {}
func _combat_view() -> Node:
if is_instance_valid(player_view):
return player_view
# GameScene 会在占位角色替换成 PlayerView 后注入 player_view;替换同帧发生攻击时,
# pc.player 已经是权威本地模型,因此用它兜底,避免只发包却没有攻击动作。
if pc and "player" in pc and is_instance_valid(pc.player):
var candidate: Node = pc.player
if candidate.has_method("play_attack_motion") or candidate.has_method("set_anim_state"):
return candidate
return null
# --- 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 race := int(e.get("race", 0))
if EntityRules._is_wooden_door(e):
return EntityRules.KIND_WOODEN_DOOR
# 优先查 mob_proto:服务端 GC_CHARACTER_ADD 在经典协议 / 部分服务端实现下 bType
# 经常全发 0(导致被客户端转为 MONSTER=2),权威分类必须以 mob_protobType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR)为准。
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
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
var ct := int(e.get("ch_type", 0))
if ct != 0:
return ct
if explicit_kind >= 0:
return explicit_kind
return 0
func _get_locale() -> RefCounted:
if locale != null:
return locale
var ar_class = load("res://asset_root.gd")
var root_path: String = ar_class.path() if ar_class and ar_class.has_method("path") else ""
if root_path != "":
var loc_script = load("res://locale.gd")
if loc_script:
var loc: RefCounted = loc_script.new()
var cur_lang: String = loc_script.get_default_lang() if loc_script.has_method("get_default_lang") else "zh"
loc.setup(root_path, cur_lang)
locale = loc
return locale
return null
# 实体显示名:
# 1. 玩家实体(PC):使用其自定义角色名(含变身中玩家、主玩家)
# 2. 怪物 / NPC / 陨石 / 建筑等(或 race >= 1 的非PC实体):优先查本地化 mob_names (locale),其次 mob_proto
# 3. 兜底回退:实体字典自带名称
func _entity_name(e: Dictionary) -> String:
var race := int(e.get("race", 0))
var kind := _entity_kind(e)
var is_main := bool(e.get("is_main", false)) or (client and client.has_method("get_main_vid") and int(e.get("vid", 0)) == int(client.get_main_vid()))
var is_poly := EntityRules._is_poly(e)
# 1. 玩家实体(PC):使用其自定义角色名(含变身中玩家、主玩家)
if is_main or is_poly or kind == EntityRules.KIND_PC or (race < 8 and not EntityRules._is_enemy(e)):
var nm := String(e.get("name", ""))
if nm != "":
return nm
# 2. 怪物 / NPC / 陨石 / 建筑等:优先查本地化词表 (mob_names.txt)
if race >= 1:
var loc := _get_locale()
if loc and loc.has_method("has_mob_name") and loc.has_mob_name(race):
return loc.mob_name(race)
if proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
var ln := String(m.get("locale_name", ""))
if ln != "":
return ln
var pn := String(m.get("name", ""))
if pn != "":
return pn
# 3. 兜底回退:实体字典自带名称
return String(e.get("name", ""))
# --- 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
_main_dead = false
_clear_reserved()
_clear_auto_attack()
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(false)
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(false)
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
if pc and pc.has_method("stop"):
pc.stop()
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)
# 40250 BINARY_PrivateShop_Appear/Disappear 最终通过主角状态落地到
# CPythonPlayer::m_isOpenPrivateShopGC_SHOP_SIGN 是当前实体的权威
# 摆摊标志。只对主角读取,避免打开别人的商店时锁住本地输入。
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(String(entity.get("shop_sign", "")) != "")
# 主角情绪动作同样由实体的 acting_emotion 状态闭合到
# CPythonPlayer::m_bisProcessingEmotion。方向键在动作期间保留,动作尾
# 到达后下一帧恢复,和原客户端 Update() 的 m_isDirKey 重试一致。
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(bool(entity.get("acting_emotion", false)))
_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 _on_entity_dead(vid: int) -> void:
if vid == _target_vid:
if is_instance_valid(net_world) and net_world.has_method("_entity_pos"):
var mpos: Vector3 = net_world._entity_pos(vid)
if mpos != Vector3.INF:
_last_dead_mob_pos = mpos
_clear_target_with_packet(true)
if vid != _main_vid:
return
_main_dead = true
_was_moving = false
_attack_cd = 0.0
_attack_key_down = false
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cast_start_time = -1.0
_skill_cast_duration = 0.0
_skill_cancel_enable = false
_knock_down = false
_fishing_active = false
_clear_reserved()
_clear_auto_attack()
if pc:
if pc.has_method("set_dead"):
pc.set_dead(true)
elif pc.has_method("stop"):
pc.stop()
pc.set("frozen", true)
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, 4: cm = CLICK_DIST_NPC_CM # NPC / 传送门
3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物
return cm / CM
# 40250 CActorInstance::IsClickableDistanceDestInstance (ActorInstanceBattle.cpp:117)
# 遍历目标防御球,若存在防御球,判断 ||rkSphere.v3Position - kD3DVct3Src|| < rkSphere.fRadius + fDistance
func _is_clickable_distance(tnode: Node3D, te: Dictionary) -> bool:
if pc == null or pc.player == null or tnode == null:
return false
var base_dist := _clickable_distance(te)
var my_pos := _world_pos(pc.player)
var target_pos := _world_pos(tnode)
var flat_dist := Vector2(my_pos.x - target_pos.x, my_pos.z - target_pos.z).length()
if flat_dist <= base_dist:
return true
var spheres := _defending_spheres(int(te.get("vid", 0)), tnode, _actor_cm(target_pos))
if not spheres.is_empty():
for s in spheres:
if s is Dictionary:
var spos_cm: Vector3 = s.get("pos", Vector3.ZERO)
var spos_m := _godot_m(spos_cm)
var srad_m: float = float(s.get("radius", 0.0)) / CM
var d_sph := Vector2(my_pos.x - spos_m.x, my_pos.z - spos_m.z).length()
if d_sph < (base_dist + srad_m):
return true
return false
return flat_dist <= base_dist
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()
var cur_level := int(p.get("level", 0))
if cur_level > 0:
if _last_level > 0 and cur_level > _last_level:
_trigger_level_up(cur_level, p)
_last_level = cur_level
var cur_exp := int(p.get("exp", 0))
var packet_gain := int(p.get("exp_gain", 0))
var delta_exp := packet_gain if packet_gain > 0 else (cur_exp - _last_exp if _last_exp >= 0 else 0)
if delta_exp > 0:
_trigger_exp_gain(delta_exp, p)
_last_exp = cur_exp
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(cur_level)
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
# PythonNetworkStreamPhaseGame::RecvPointChange — this is intentionally a
# separate path from the aggregate points_changed snapshot. 40250 first
# dispatches ShowPointEffect(type, vid), then updates the main point table and
# refreshes status/skill windows according to the point type.
func _on_point_changed(vid: int, point_type: int, _value: int, amount: int) -> void:
if point_type == POINT_GOLD and amount > 0 and vid == _main_vid:
money_picked.emit(amount)
if hud and hud.has_method("on_pick_money"):
hud.on_pick_money(amount)
if vid != _main_vid:
return
match point_type:
POINT_STAT_RESET_COUNT, POINT_ENERGY:
status_refresh_requested.emit()
POINT_LEVEL, POINT_ST, POINT_HT, POINT_DX, POINT_IQ:
status_refresh_requested.emit()
skill_refresh_requested.emit()
POINT_SUB_SKILL, POINT_SKILL, POINT_HORSE_SKILL:
skill_refresh_requested.emit()
_:
# RecvPointChange's default branch calls __RefreshStatus().
status_refresh_requested.emit()
func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
# 40250 的经验球由服务端 `from->CreateFly(FLY_EXP, to)` 下发 GC_CREATE_FLY
# NetWorld._on_fly 负责唯一一次播放。这里不能再合成一组,否则正常收包时会出现双份。
_last_dead_mob_pos = Vector3.INF
# 经验数值反馈音效;飞行和吸收闪光由权威 GC_CREATE_FLY 驱动。
if audio and audio.has_method("play_ui"):
audio.play_ui("money.wav")
func _trigger_level_up(new_level: int, p: Dictionary) -> void:
level_up.emit(new_level)
print("[升级] 恭喜!等级提升至 Lv. %d" % new_level)
# 1. 40250 EFFECT_LEVELUP 冲天金光特效。真实网络链路由
# NetWorld._on_point_changed 按注册的 level_up.mse 播放;保留几何光柱
# 作为无特效注册表的 headless / 旧适配器回退,避免测试和轻量宿主丢反馈。
var has_exact_effect: bool = false
if net_world != null and net_world.has_method("_on_point_changed") \
and fx != null and fx.has_method("spawn"):
has_exact_effect = true
if not has_exact_effect:
_spawn_level_up_effect()
# 2. 40250 播放专属升级号角音效
if audio and audio.has_method("play_ui"):
audio.play_ui("levelup1_1.wav")
# 3. 40250 规则:升级瞬间 HP、SP 立即 100% 满状态恢复
var max_hp := int(p.get("max_hp", 0))
var max_sp := int(p.get("max_sp", 0))
if max_hp > 0 and hud and hud.has_method("set_vitals"):
hud.set_vitals(max_hp, max_hp, max_sp, max_sp)
# 4. 通知 HUD 刷新属性加点
if hud and hud.has_method("on_level_up"):
hud.on_level_up(new_level)
func _spawn_level_up_effect() -> void:
var pl: Node3D = pc.player if (pc and "player" in pc) else null
if pl == null or not is_instance_valid(pl):
return
var beam := MeshInstance3D.new()
beam.name = "level_up_beam"
var cyl := CylinderMesh.new()
cyl.top_radius = 0.45
cyl.bottom_radius = 0.65
cyl.height = 7.0
beam.mesh = cyl
beam.position.y = 3.5
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.85, 0.25, 0.75)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.88, 0.3)
beam.material_override = mat
pl.add_child(beam)
var tw := pl.create_tween()
tw.set_parallel(true)
tw.tween_property(beam, "scale:y", 1.4, 1.2)
tw.tween_property(mat, "albedo_color:a", 0.0, 1.2)
tw.chain().tween_callback(beam.queue_free)
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))
var w: Node = pc.world if (pc and "world" in pc and pc.world) else (net_world.world if (net_world and "world" in net_world) else null)
if w and w.has_method("sample_height"):
sp.y = float(w.call("sample_height", sp.x, sp.z))
else:
sp.y = pc.player.position.y # 无地形时的兜底
var d: float = pc.player.position.distance_to(sp)
# 40250 对齐:CInstanceBase::PushTCPState 对主玩家显式忽略(if (__IsMainInstance()) return)。
# 本地移动由客户端权威推进,常规移动不作平滑插值(避免网络 RTT 滞后引发反拉与按键对抗)。
# 仅在距离超出瞬移阈值时(服务端传送、换图、防作弊重置)直接 Snap 同步。
if d > CORRECTION_SNAP:
pc.player.position = sp
if pc and pc.has_method("stop"):
pc.stop()
# --- targeting / attack -----------------------------------------------------
static func _world_pos(n: Node3D) -> Vector3:
if n == null:
return Vector3.ZERO
return n.global_position if n.is_inside_tree() else n.position
# 40250 CPythonPlayer::__SendClickActorPacket (PythonPlayer.cpp:1196)
# 节流保护:s_dwNextTCPTime = dwCurTime + 1000(同目标 1 秒内不重复发送点击包,防止狂点刷包)
func _send_click_actor_packet(vid: int) -> bool:
if vid <= 0 or client == null or not client.has_method("click_npc"):
return false
var now := Time.get_ticks_msec() / 1000.0
if vid == _last_click_actor_vid and (now - _last_click_actor_t) < 1.0:
return false
_last_click_actor_t = now
_last_click_actor_vid = vid
return client.click_npc(vid)
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
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var kind := _entity_kind(e)
var tnode: Node3D = net_world.node_for(vid) if (net_world and net_world.node_for(vid)) else node
# 不可攻击目标(NPC / warp / 同阵营PC等) -> 交互或目标框
if not _is_attackable(e):
_auto_attack_vid = 0
if kind in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
if not _is_clickable_distance(tnode, e):
# 40250 __OnClickActor: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近 NPC)
_reserve_click_actor(vid)
if pc and pc.has_method("walk_to") and tnode:
pc.walk_to(_world_pos(tnode))
return
# 已在交互距离内(<= 5m):停止移动、朝向 NPC、发送点击包(40250 __SendClickActorPacket + NEW_Stop
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.player and tnode:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_send_click_actor_packet(vid)
return
_clear_reserved()
_change_target_to_picked_instance(vid)
if kind == 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)
return
# --- 40250 __OnPressActor: 可攻击实体(怪物、陨石、敌对PC等)---
_attack_cd = 0.0
_change_target_to_picked_instance(vid)
# 40250 __SetAutoAttackTargetActorID: 点击可攻击实体开启持续连击/追击
_auto_attack_vid = vid
_clear_reserved()
if not _can_attack():
return
if not _is_clickable_distance(tnode, e):
# 40250: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近)
_reserve_click_actor(vid)
if pc and pc.has_method("walk_to") and tnode:
pc.walk_to(_world_pos(tnode))
return
# 已在攻击距离内:立即朝向目标出刀
if tnode != null:
_do_attack_swing(tnode, e)
# 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)
var nm := _entity_name(e)
var hp_clamped := TargetBoard.clamp_hp_pct(hp_pct)
if hud and hud.has_method("set_target"):
hud.set_target(nm if nm != "" else "目标", hp_clamped)
if target_ui and target_ui.has_method("open"):
var lvl := int(e.get("level", -1))
var grade := int(e.get("grade", -1))
var mob_race := int(e.get("race", 0))
if not is_pc and mob_race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(mob_race)
if not m.is_empty():
if lvl <= 0:
lvl = int(m.get("level", -1))
if grade < 0:
grade = int(m.get("rank", -1))
target_ui.open(vid, nm if nm != "" else "目标", lvl, grade, is_pc)
target_ui.set_hp(hp_clamped)
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:
_clear_target_with_packet(false)
func _clear_target_with_packet(send_packet: bool) -> void:
if _target_vid == 0:
if send_packet and client and client.has_method("set_target"):
client.set_target(0)
return
_auto_attack_vid = 0
_target_vid = 0
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
target_changed.emit(0)
if send_packet and client and client.has_method("set_target"):
client.set_target(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
if target_ui and target_ui.has_method("close"):
target_ui.close()
# 受击:40250 RecvDamageInfoPacketPythonNetworkStreamPhaseGame.cpp:2415
# 仅展示伤害飘字和受击动作;普通伤害不晃动摄像机、不锁死移动控制(非 DAMAGE_FLYING 击飞)。
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid != _main_vid:
return
if (flag & DAMAGE_DODGE) != 0:
return
_knock_down = true
if camera and camera.has_method("shake"):
camera.shake(0.2, 0.5)
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 _main_dead or _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()
# 40250 CInstanceBase::NEW_GetFrontInstance (InstanceBaseBattle.cpp:145)
# 在正前方扇面检索最近的可攻击存活目标(最大射程默认 2000cm / 20m
func get_front_instance(f_distance_cm: float = 2000.0) -> int:
if pc == null or pc.player == null or client == null:
return 0
var my_pos: Vector3 = _world_pos(pc.player)
var my_yaw: float = pc.player.rotation.y
# Godot 空间中 yaw=0 面向 -Z,朝向向量为 (-sin(yaw), 0, -cos(yaw))
var fwd := Vector3(-sin(my_yaw), 0, -cos(my_yaw)).normalized()
var best_vid := 0
var min_dist_m := 1e9
var max_dist_m := f_distance_cm / CM
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 tnode: Node3D = net_world.node_for(vid) if net_world else null
var pos := Vector3.ZERO
if tnode != null:
pos = _world_pos(tnode)
else:
pos = MapCoord.to_world(e.get("pos", my_pos))
var to_target := pos - my_pos
to_target.y = 0.0
var d := to_target.length()
if d > max_dist_m or d < 0.01:
continue
var to_dir := to_target.normalized()
var dot := fwd.dot(to_dir)
# 40250 扇形检测(HALF_FAN_ROT 10°~50°,约 60°~70° 锥角内)
if dot > 0.35:
if d < min_dist_m:
min_dist_m = d
best_vid = vid
return best_vid
# §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 parts: Array = e.get("parts", []) if e.get("parts", []) is Array else []
var weapon_part := int(parts[1]) if parts.size() > 1 else 0
var polymorphed := EntityRules._is_poly(e)
var wearing_dress := bool(e.get("wedding_dress", false)) or int(e.get("shape", 0)) == 201
var holding_pickaxe := bool(e.get("holding_pickaxe", false)) \
or (weapon_part >= 29101 and weapon_part <= 29110)
var guild_war_active := false
if client and client.has_method("get_guild_war"):
var war: Dictionary = client.get_guild_war()
# Packet.h: GUILD_WAR_ON_WAR = 6; other non-zero states are setup/list
# notifications, not the IsDoingGuildWar() state used by UseGuildSkill.
guild_war_active = int(war.get("state", 0)) == 6
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(),
"polymorphed": polymorphed,
"wearing_dress": wearing_dress,
"holding_pickaxe": holding_pickaxe,
"horse_can_use": bool(e.get("horse_can_use_skill", true)),
"graphic_can_use": bool(e.get("graphic_can_use_skill", true)),
"guild_war_active": guild_war_active,
"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"),
"get_front_instance": Callable(self, "get_front_instance"),
}
# 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 = _world_pos(pc.player).distance_to(_world_pos(tnode)) * 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
# 40250 CActorInstance::isLock()ActorInstanceMotion.cpp:472
# 挥击动作(普攻/连击/技能/钓鱼)期间,严格锁定移动(PythonPlayerInput.cpp:457)。
# 连击段号仅用于连击输入窗口判断,动作播放结束自动切回 wait 状态并解锁。
func is_lock() -> bool:
if _using_skill:
# 40250 ActorInstanceMotion.cpp:541: if (m_pkCurRaceMotionData->IsCancelEnableSkill()) return FALSE;
if _skill_cancel_enable:
return false
if not _skill_motion_driven and _skill_cast_duration > 0.0 \
and _skill_cast_start_time >= 0.0 \
and (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
if player_view and player_view.has_method("reset_skill_motion"):
player_view.reset_skill_motion()
elif not _charge_skill:
return true
if combo_motion_mode == MOTION_MODE_FISHING:
return true
if _is_attacking:
var md := _motion_data()
var dur := float(md.get("duration", attack_period))
if dur <= 0.0:
dur = attack_period
if _attacking_elapsed() < dur:
return true
else:
_is_attacking = false
_combo_index = 0
return false
func _process(dt: float) -> void:
_advance_local_time(dt)
_remote_splash_attack_process()
if _using_skill and not _skill_motion_driven and _skill_cast_duration > 0.0 \
and _skill_cast_start_time >= 0.0:
if (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
if not _can_process_network_state():
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
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())
pc.set("locked", is_lock())
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
_splash_attack_process(dt) # SPECIAL_ATTACKINGSplashArea -> OnHit
_update_auto_attack()
_refresh_reserved()
_tick_on_waiting()
_flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION
# CPythonPlayer::__Update_AutoAttack()PythonPlayer.cpp:207
# 以及 NEW_Attack()PythonPlayerInput.cpp:550,空格键自由挥击)
func _update_auto_attack() -> void:
if _attack_key_down:
if _auto_attack_vid == 0 and _target_vid != 0:
_auto_attack_vid = _target_vid
elif _auto_attack_vid == 0 and _target_vid == 0:
# 40250 NEW_Attack(): 无目标按下空格键时朝当前面向自由挥剑
# 连击 / 普攻节奏由动作数据 ComboInputData / 90% duration 自行 gate,不使用外层 cd 吞键
if _can_attack():
_do_attack_swing(null, {})
return
if _auto_attack_vid == 0:
return
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() or 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):
_clear_auto_attack()
return
if bool(_main_entity().get("sleeping", false)):
return
# 对齐 40250 CPythonPlayer::__Update_AutoAttack():只要目标有效存活且可攻击,每帧 __ReserveClickActor
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
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
if not _is_clickable_distance(tnode, rv):
if pc.has_method("walk_to"):
pc.walk_to(_world_pos(tnode))
return
elif pc and pc.has_method("is_going") and pc.is_going():
if pc.has_method("stop"):
pc.stop()
# 3 不可攻击 -> 停 + 朝向 NPC + 发点击包(NPC 对话)+ 清预约
if not _is_attackable(rv):
pc.set("frozen", false)
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.player and tnode:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_send_click_actor_packet(_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 重新预约)。
# 对齐 40250 PythonPlayerInput.cpp:947:直接调用攻击,由动作状态机判定连击推进与输入窗
_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 pc and "ground_items" in pc and pc.ground_items != null and pc.ground_items.has_method("get_item_world_pos"):
var p = pc.ground_items.get_item_world_pos(iid)
if p != null:
return p
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_ITEM_PICKUP_CM:
_send_state(FUNC_WAIT, 0, pc.player.position)
var gi = pc.ground_items if (pc and "ground_items" in pc) else null
if gi != null and gi.has_method("try_pickup_vid"):
gi.try_pickup_vid(iid)
elif 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
# CActorInstance::CanChangeTargetActorInstanceFly.cpp:111):当前动作含
# WARP/FLY/EFFECT_TO_TARGET 事件时不能换目标。get_events() 是当前 .msa 的原始事件表;
# 无动作资源时沿用参考端「没有 motion data -> 需要飞行目标动作」的保守结果。
func _motion_blocks_target_change() -> bool:
var view := _combat_view()
var anim = view.get("anim") if view and "anim" in view else null
if anim and anim.has_method("get_events"):
var events: Variant = anim.call("get_events")
if events is Array:
for event in events:
if event is Dictionary and int(event.get("type", -1)) in [6, 9, 10]:
return true
return false
# The reference returns true when the current motion data is unavailable.
return _using_skill or combo_motion_mode == MOTION_MODE_FISHING
func _can_change_target() -> bool:
return not _motion_blocks_target_change()
func _is_targetable_instance(e: Dictionary) -> bool:
if e.is_empty():
return false
var target := e.duplicate()
target["kind"] = _entity_kind(e)
# This 40250 build's __MainCanSeeHiddenThing() returns false. Keep the
# context explicit so a future detector/GM rule cannot accidentally turn
# stealth into a client-only visual check.
return EntityRules.can_pick_instance(target, {"can_see_hidden": false})
# __ChangeTargetToPickedInstancePythonPlayerInput.cpp:186)→ SetTarget:112):
# 先过 CanChangeTarget,再过同目标时间窗和 IsTargetableInstance/CanPickInstance
# 失败统一发 CG_TARGET(0),成功才发 CG_TARGET(vid)。force_change 对应 SetTarget
# 的 bForceChange(点击/脚本为 TRUE,受击自动换目标为 FALSE)。
func _change_target_to_picked_instance(vid: int, force_change := true) -> bool:
if not _can_change_target():
return false
var now := _now()
if vid == _target_vid:
# SetTarget(main VID) 是清除主角自选目标的特殊分支;其它重复点击
# 只延长 1 秒保护窗,不重复发送 CG_TARGET。
if vid == _main_vid:
_clear_target_with_packet(true)
return false
_target_end_time = now + 1.0
return true
if not force_change and _target_end_time > now:
return false
_target_end_time = now + (2.0 if force_change else 1.0)
var e: Dictionary = client.get_entity(vid)
var kind := _entity_kind(e)
# __ChangeTargetToPickedInstance is only used by the combat/PC target
# paths here; NPC/warp interaction is owned by __OnClickActor's click-NPC
# branch. The targetability gate still runs before this higher-level kind
# filter, matching SetTarget's CanPickInstance contract without turning an
# NPC click into a combat target.
if kind not in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER,
EntityRules.KIND_STONE, EntityRules.KIND_WOODEN_DOOR] \
or not _is_targetable_instance(e):
_clear_target_with_packet(true)
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:
if tnode != null and is_instance_valid(tnode) and pc and pc.player:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
to.y = 0.0
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 未登记)→ 对齐 40250 __CanInputNormalAttackCommand
if _is_attacking and _attack_cd > 0.0:
var md := _motion_data()
var dur := float(md.get("duration", attack_period))
if dur <= 0.0:
dur = attack_period
if _attacking_elapsed() <= dur * 0.9:
return
_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
_is_attacking = 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)不在这里:它只能由 `_attack_process()` 按有效 `.msa`
# AttackingData/HitDataContainer 的几何判定触发(1:1 对齐 `isValidAttacking` →
# `__NormalAttackProcess` → `__ProcessDataAttackSuccess` → `OnHit`)。40250 在当前
# 动作没有有效 `m_pkCurRaceMotionData` 或 `AttackingData` 时不会合成一包 CG_ATTACK。
func _emit_swing(motion_index: int, is_combo: bool) -> void:
# 40250 CActorInstance::ComboAttack 的顺序是:先 InterceptOnceMotion(绑定
# motion、清 m_HitDataMap、触发弓箭 OnSetFlyTarget),再 __OnAttack(发送
# FUNC_COMBO)。此前这里先发状态包再绑定动作,服务端收到状态时本地命中窗尚未
# 建立,和参考端的动作/网络事件顺序相反。
var view := _combat_view()
var motion_played := false
if view and view.has_method("play_attack_motion"):
# 旧视图/测试桩的方法没有返回值;只有新版 PlayerView 明确返回 true 时才认为
# 已成功绑定 .msa,其余情况继续走 set_anim_state 兼容兜底。
motion_played = view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) == true
if not motion_played and view and view.has_method("set_anim_state"):
view.set_anim_state("combo" if is_combo else "attack")
_begin_hit_windows()
# 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget。
# OnShoot(uSkill) 仍等 `.msa` FLY 帧,由 game_scene 入队发送。§3.6
if _is_bow_mode():
var why := _can_shot()
if why != "":
_emit_cannot(why)
else:
_send_fly_target()
bow_shot_fired.emit(_swing_skill)
# OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号。
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# 节奏来自动作数据(.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:
return
var tnode: Node3D = net_world.node_for(vid)
if tnode == null:
return
# CNormalBowAttack_FlyEventHandler sends OnGetFlyTargetPosition(), which is
# the model body-sphere centre rather than the actor feet/root position.
var sp := _server_xy(FlyTargetAnchor.fly_target_position(tnode))
# 40250 uses SendFlyTargetingPacket here. CG_ADD_FLY_TARGETING is reserved
# for the extra targets in PythonPlayerSkill::__UseSkill.
if client.has_method("fly_targeting"):
client.fly_targeting(vid, int(sp.x), int(sp.y))
elif client.has_method("add_fly_targeting"):
# Compatibility for headless/test clients predating the split API.
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()
_has_attack_motion_data = bool(md.get("has_attacking_data", false))
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
_is_attacking = true
# ActorInstanceMotionEvent.cpp::ProcessMotionEventSpecialAttacking:事件帧只负责
# 建立 / 替换当前 actor 的 SplashArea;真正的目标遍历发生在下一次 Update 的
# AttackProcess 中。这样 DuringTime、EnableHitProcess、uSkill 和 CollisionData
# 都属于同一个动作事件,而不是被重新合成成一个普通攻击窗。
func start_motion_splash(event: Dictionary) -> void:
if int(event.get("type", -1)) != 4 or pc == null or pc.player == null:
return
_motion_splash = MotionSplash.begin(_main_vid, _swing_skill,
_world_pos(pc.player), _player_yaw(), event, _local_time)
# CActorInstance::MotionEventProcess / InstanceBase::AttackProcess runs for
# every visible actor. Remote actors use the same collision data, but their
# event handler is not CPythonPlayerEventHandler: the local client must never
# synthesize a CG_ATTACK from a remote splash hit.
func start_remote_motion_splash(owner_vid: int, event: Dictionary) -> void:
if int(event.get("type", -1)) != 4 or owner_vid <= 0 or owner_vid == _main_vid:
return
if net_world == null or client == null:
return
var actor: Node3D = net_world.node_for(owner_vid) if net_world.has_method("node_for") else null
if not is_instance_valid(actor):
return
var owner_e: Dictionary = client.get_entity(owner_vid) if client.has_method("get_entity") else {}
if owner_e.is_empty() or bool(owner_e.get("dead", false)) or bool(owner_e.get("in_safe", false)):
return
var skill := int(event.get("skill_index", actor.get_meta("motion_skill_index", 0)))
_remote_motion_splashes[owner_vid] = MotionSplash.begin(owner_vid, skill,
_world_pos(actor), actor.rotation.y, event, _local_time)
func _remote_actor_node(vid: int) -> Node3D:
if vid == _main_vid and pc != null and is_instance_valid(pc.player):
return pc.player
if net_world != null and net_world.has_method("node_for"):
return net_world.node_for(vid)
return null
func _remote_entity(vid: int) -> Dictionary:
if vid == _main_vid:
return _main_entity()
return client.get_entity(vid) if client and client.has_method("get_entity") else {}
func _is_attackable_from(attacker: Dictionary, victim: Dictionary) -> bool:
if attacker.is_empty() or victim.is_empty() or bool(victim.get("dead", false)):
return false
var self_e := attacker.duplicate()
self_e["kind"] = _entity_kind(attacker)
var victim_e := victim.duplicate()
victim_e["kind"] = _entity_kind(victim)
return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e))
func _remote_splash_attack_process() -> void:
if _remote_motion_splashes.is_empty() or client == null or net_world == null:
return
for owner_vid in _remote_motion_splashes.keys().duplicate():
var area: Dictionary = _remote_motion_splashes.get(owner_vid, {})
if not MotionSplash.is_active(area, _local_time):
_remote_motion_splashes.erase(owner_vid)
continue
var attacker_vid := int(owner_vid)
var attacker := _remote_entity(attacker_vid)
var attacker_node := _remote_actor_node(attacker_vid)
if attacker.is_empty() or not is_instance_valid(attacker_node) \
or bool(attacker.get("dead", false)) or bool(attacker.get("in_safe", false)):
_remote_motion_splashes.erase(attacker_vid)
continue
var attacker_cm := _actor_cm(_world_pos(attacker_node))
for victim in client.get_entities():
if not victim is Dictionary:
continue
var victim_vid := int(victim.get("vid", 0))
if victim_vid <= 0 or victim_vid == attacker_vid or not _is_attackable_from(attacker, victim):
continue
var victim_node := _remote_actor_node(victim_vid)
if not is_instance_valid(victim_node):
continue
if _now() < float(_victim_invisible_until.get(victim_vid, 0.0)):
continue
var victim_cm := _actor_cm(_world_pos(victim_node))
var dx := victim_cm.x - attacker_cm.x
var dz := victim_cm.z - attacker_cm.z
if dx * dx + 2.0 * dz * dz >= 1000.0 * 1000.0:
continue
if int(area.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \
and _entity_kind(victim) == EntityRules.KIND_PC \
and victim_vid != int(attacker_node.get_meta("fly_target_vid", 0)):
continue
var defenders := _defending_spheres(victim_vid, victim_node, victim_cm)
if defenders.is_empty():
continue
var result := MotionSplash.try_hit(area, victim_vid, defenders, _local_time)
if not bool(result.get("hit", false)):
continue
_process_attack_success(victim, victim_vid, victim_node,
result.get("hit_position", (attacker_cm + victim_cm) * 0.5),
HitCollision.is_huge_race(int(victim.get("race", 0))),
int(area.get("hitting_type", HIT_TYPE_NONE)),
float(area.get("stiffen_time", 0.0)),
float(area.get("invisible_time", 0.0)),
float(area.get("external_force", 0.0)),
int(area.get("skill", 0)), false, attacker_node, attacker_vid)
# CInstanceBase::AttackProcess -> CActorInstance::__SplashAttackProcess。
# 40250 只在主角色的本地输入处理器里把 isEnableHitProcess=true 的命中转成
# CG_ATTACKEnableHitProcess=false 仍然执行命中特效 / 硬直 / 击退,但不伪造上行包。
func _splash_attack_process(_dt: float) -> void:
if _motion_splash.is_empty():
return
if not MotionSplash.is_active(_motion_splash, _local_time):
_motion_splash.clear()
return
if client == null or not client.is_in_game() or pc == null or pc.player == null \
or net_world == null:
return
if bool(_main_entity().get("in_safe", false)):
return
var attacker_pos := _actor_cm(_world_pos(pc.player))
var dx_limit := 1000.0 # __SplashAttackProcess: 1000cm broad-phase limit
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 or not is_instance_valid(vnode):
continue
# AttackingProcess 的 __isInvisible() 门;命中的受击方在
# __ProcessDataAttackSuccess 中写入 m_fInvisibleTime。
if _now() < float(_victim_invisible_until.get(vid, 0.0)):
continue
var victim_pos := _actor_cm(_world_pos(vnode))
# 参考端这里的 v3Distance 是 (dx, dz, dz),保持其出货行为。
var dx := victim_pos.x - attacker_pos.x
var dz := victim_pos.z - attacker_pos.z
if dx * dx + 2.0 * dz * dz >= dx_limit * dx_limit:
continue
# ATTACK_TYPE_SNIPE 对 PC 只接受当前飞行目标;PVE 目标不受这条
# PC 专用限制影响。
if int(_motion_splash.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \
and _entity_kind(e) == EntityRules.KIND_PC and vid != _target_vid:
continue
var defenders := _defending_spheres(vid, vnode, victim_pos)
if defenders.is_empty():
continue
var result: Dictionary = MotionSplash.try_hit(_motion_splash, vid, defenders, _local_time)
if not bool(result.get("hit", false)):
continue
var hit_pos: Vector3 = result.get("hit_position", (attacker_pos + victim_pos) * 0.5)
_process_attack_success(e, vid, vnode, hit_pos,
HitCollision.is_huge_race(int(e.get("race", 0))),
int(_motion_splash.get("hitting_type", HIT_TYPE_NONE)),
float(_motion_splash.get("stiffen_time", 0.0)),
float(_motion_splash.get("invisible_time", 0.0)),
float(_motion_splash.get("external_force", 0.0)),
int(_motion_splash.get("skill", 0)),
bool(_motion_splash.get("enable_hit_process", true)))
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即
# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。
func _attack_process(dt: float) -> void:
# CActorInstance::isValidAttacking() rejects a missing current motion or a motion
# without AttackingData before __NormalAttackProcess reads HitDataContainer.
# An existing AttackingData with zero windows also produces no local hit; it is
# intentionally different from the old synthetic forward-cone fallback.
if not _has_attack_motion_data or _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()
var eff_dt := dt * _atk_speed_factor
# 动作已越过所有命中窗 -> 收起(等同 !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 - eff_dt > latest_end:
_hit_windows = []
_hit_dedup.clear()
return
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
var a_pos := _actor_cm(_world_pos(pc.player))
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 _attack_reach_scale() -> float:
# CActorInstance::__GetReachScale:当前视图持有 AFFECT_GEOMGYEONG
# 的 m_fReachScale;没有模型(headless/旧测试桩)时保持默认 1.0。
if player_view != null and player_view.has_method("weapon_reach_scale"):
return maxf(float(player_view.call("weapon_reach_scale")), 0.0)
return 1.0
func _attack_sample_positions(last_pos: Vector3, pos: Vector3, yaw: float,
actor_pos: Vector3) -> Dictionary:
# ActorInstanceCollisionDetection.cpp 只缩放刀尖球从 last 到 current 的
# 位移向量,防御球的 last 点和攻击者位置不缩放。
var scaled_pos := last_pos + (pos - last_pos) * _attack_reach_scale()
return {
"last": _rot_cm(last_pos, yaw) + actor_pos,
"pos": _rot_cm(scaled_pos, yaw) + actor_pos,
}
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(_world_pos(vnode))
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)
# CActorInstance::__NormalAttackProcess only iterates the victim's actual
# m_DefendingPointInstanceList. A node without parsed .msm collision data is
# not a valid collision target; do not invent a capsule or a proximity hit.
if spheres.is_empty():
return
var eff_dt := dt * _atk_speed_factor
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
if t < float(w.get("start_time", 0.0)) or (t - eff_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", [])
var hit_detected := false
if samples is Array and not (samples as Array).is_empty():
for smp in samples:
var st := float(smp.get("time", 0.0))
if st < t - eff_dt or st > t:
continue
var p: Vector3 = smp.get("pos", Vector3.ZERO)
var lp: Vector3 = smp.get("last_pos", p)
var sweep := _attack_sample_positions(lp, p, yaw, a_pos)
var d_pos: Vector3 = sweep["pos"]
var d_last: Vector3 = sweep["last"]
for sp in spheres:
if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
hit_detected = true
break
if hit_detected:
break
if hit_detected:
if not _register_hit(wi, vid):
return
var hit_pos: Vector3 = (a_pos + (spheres[0]["pos"] as Vector3)) * 0.5 if huge and not spheres.is_empty() else (a_pos + v_pos) * 0.5
_process_attack_success(e, vid, vnode, hit_pos, huge)
return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorldActorInstanceCollisionDetection.cpp:66);
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 防御球时返回空,保持
# __NormalAttackProcess 对空 m_DefendingPointInstanceList 的无命中结果。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
if vnode == null or not vnode.has_method("get_defending_spheres"):
return []
var raw: Array = vnode.get_defending_spheres()
if raw.is_empty():
return []
var vyaw := vnode.rotation.y
var anim: Node = vnode.get("anim") if vnode else null
var model: Node3D = vnode.get("model") as Node3D if vnode else null
var cur: Array = []
for sp in raw:
var spos: Vector3 = sp.get("pos", Vector3.ZERO)
var bone: String = sp.get("bone", "")
var sphere_pos_cm := Vector3.ZERO
var has_bone_transform := false
if bone != "" and anim != null and anim.has_method("get_effect_bone_pose") and model != null:
var pose: Dictionary = anim.get_effect_bone_pose(bone)
if pose.has("transform"):
var bone_xform: Transform3D = pose["transform"]
# 40250 ActorInstanceCollisionDetection.cpp:40: matDefending = matBone * matWorld, D3DXVec3TransformCoord
# spos in .msm is in cm, bone_xform / model transform is in meters
var world_m: Vector3 = model.global_transform * (bone_xform * (spos / 100.0))
sphere_pos_cm = _actor_cm(world_m)
has_bone_transform = true
if not has_bone_transform:
sphere_pos_cm = _rot_cm(spos, vyaw) + v_pos
cur.append(sphere_pos_cm)
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,
hit_type_override: int = -1, stiffen_override: float = -1.0,
invisible_override: float = -1.0, external_force_override: float = -1.0,
skill_override: int = -1, send_packet: bool = true,
attacker_override: Node3D = null, attacker_vid: int = 0) -> void:
var old_hit_type := _hit_type
var old_hit_stiffen := _hit_stiffen
var old_hit_invisible := _hit_invisible_time
var old_hit_external_force := _hit_external_force
var old_swing_skill := _swing_skill
if hit_type_override >= 0:
_hit_type = hit_type_override
if stiffen_override >= 0.0:
_hit_stiffen = stiffen_override
if invisible_override >= 0.0:
_hit_invisible_time = invisible_override
if external_force_override >= 0.0:
_hit_external_force = external_force_override
if skill_override >= 0:
_swing_skill = skill_override
if _hit_type == HIT_TYPE_NONE:
_hit_type = old_hit_type
_hit_stiffen = old_hit_stiffen
_hit_invisible_time = old_hit_invisible
_hit_external_force = old_hit_external_force
_swing_skill = old_swing_skill
return
# InsertDelay(fStiffenTime):命中硬直属于真正的攻击者;远端攻击不能
# 锁住本地主角的输入时间,只能锁远端 actor 的动作。
var attacker_node: Node3D = attacker_override if is_instance_valid(attacker_override) else pc.player
if attacker_vid == 0 or attacker_vid == _main_vid:
_delay = _hit_stiffen
if player_view and player_view.has_method("insert_delay"):
player_view.insert_delay(_hit_stiffen)
elif is_instance_valid(attacker_node) and attacker_node.has_method("insert_delay"):
attacker_node.insert_delay(_hit_stiffen)
var a_g: Vector3 = _world_pos(attacker_node)
var v_g: Vector3 = _world_pos(vnode)
var to := v_g - a_g
# __PushCircle + IncreaseExternalForce
if _can_push(e, vid, huge, attacker_vid) 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 attacker_yaw := attacker_node.rotation.y if is_instance_valid(attacker_node) else _player_yaw()
var scalar := cos(attacker_yaw - vnode.rotation.y)
var resist_fallen := _is_resist_fallen(e)
# 40250 CActorInstance::__Shake(100)
if vnode.has_method("shake"):
vnode.shake()
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
elif _hit_type == HIT_TYPE_GOOD or resist_fallen:
# 40250 ActorInstanceBattle.cpp:687: if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || rVictim.IsResistFallen())
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)
if send_packet:
_on_hit(vid, huge, true)
_hit_type = old_hit_type
_hit_stiffen = old_hit_stiffen
_hit_invisible_time = old_hit_invisible
_hit_external_force = old_hit_external_force
_swing_skill = old_swing_skill
const AFFECT_CHEONGEUN_BIT := 1 << 16
const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33
# 40250 CActorInstance::IsResistFallen (ActorInstance.cpp:405 / InstanceBaseEffect.cpp:891)
# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN)、Boss 或巨型怪物拥有霸体抗击退状态
func _is_resist_fallen(e: Dictionary) -> bool:
var flags: int = int(e.get("affect_flags", 0))
if (flags & AFFECT_CHEONGEUN_BIT) != 0 or (flags & AFFECT_FALLEN_CHEONGEUN_BIT) != 0:
return true
if bool(e.get("resist_fallen", false)):
return true
var race: int = int(e.get("race", 0))
if HitCollision.is_huge_race(race) or _is_huge_race(race):
return true
return false
# CActorInstance::__CanPushDestActorActorInstanceBattle.cpp
func _can_push(e: Dictionary, vid: int, huge: bool, attacker_vid: int = 0) -> 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 or _is_resist_fallen(e):
return false
if bool(e.get("stunned", false)):
return true
var owner := int(e.get("owner_vid", 0))
_note_owner(vid, owner)
var source_vid := attacker_vid if attacker_vid != 0 else _main_vid
if owner != source_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, send_packet: bool = true) -> void:
# CPythonPlayerEventHandler::OnHit uses SetTarget(victim, FALSE), so the
# automatic target update obeys the non-forced time window and target gate.
_change_target_to_picked_instance(vid, false)
if send_packet:
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:
# PythonPlayer::NotifyCharacterDead/Update reaches SetTarget(0), which
# also emits the explicit CG_TARGET(0) instead of only hiding the UI.
_clear_target_with_packet(true)
# 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 int(e.get("hp", 0)) > 0 and not bool(e.get("dead", false)):
_main_dead = false
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
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)