Files
mtgodot-poc/project/net_play.gd
T

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