# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。 # # 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s) # 停下 -> M2Client.move(FUNC_WAIT, …) # 点实体 -> M2Client.set_target(vid);按住 SPACE 才在攻击距离内按攻速 attack() # 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp) # points_changed / vitals_changed / target_info -> HUD # # 需要:M2Client、PlayerController(`player_controller.gd`)、NetWorld(`net_world.gd`)。 # 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。 # # 用法: # var np := preload("res://net_play.gd").new() # add_child(np) # np.setup(m2client, player_ctl, net_world, hud) extends Node signal target_changed(vid: int) # game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框 # (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。 signal whisper_requested(name: String) # OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调), # 供 HUD 弹 localeInfo 文案。§3.4 signal cannot_act(code: String) # PythonPlayerInput::OnFishingWrongPlace 的独立反馈通道。当前场景若没有 # 水面查询器不会误报,等 Metin2World 接上水面采样后由这里统一上抛。 signal fishing_feedback(code: String) # 弓动作起手:本次挥击的 uSkill(普攻 0)。对齐 # CNormalBowAttack_FlyEventHandler_AutoClear —— OnSetFlyTarget() 已在 _emit_swing 里 # 直接发 CG_FLY_TARGETING,剩下的 OnShoot(uSkill) 要等 `.msa` MOTION_EVENT_TYPE_FLY 帧, # 由 game_scene 收到本信号后入队、在 _on_local_motion_event(6) 里 pop 出来发 CG_SHOOT。§3.6 signal bow_shot_fired(skill: int) # CPythonPlayer 的预约动作模式(PythonPlayerInput.cpp:820-856)。每帧在 # _refresh_reserved()(≈ NEW_RefreshMouseWalkingDirection)里按模式分派。 enum ReservedMode { NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL } # 扇形 / 圆形多目标 fly-targeting 的范围形状(skilldesc / .msk 的 # SKILL_ATTRIBUTE_FLAG_FAN_RANGE / _CIRCLE_RANGE,PythonSkill.cpp:1103/1111)。 enum FlyShape { SINGLE, FAN, CIRCLE } # NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)的扇形半角随距离收窄: # fHalfFanRot = (MAX-MIN) - RPM * min(dist, MIN_DIST) + MIN, RPM = (MAX-MIN)/MIN_DIST const FAN_HALF_ROT_MIN := 20.0 const FAN_HALF_ROT_MAX := 40.0 const FAN_HALF_ROT_MIN_DISTANCE := 1000.0 # NEW_GetRandomPositionInFanRange(:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。 const FAN_RANDOM_JITTER_DEG := 10.0 const FAN_RANDOM_REACH_CM := 8000.0 # MODE_CLICK_ITEM 的到手阈值(PythonPlayerInputMouse.cpp:297 `< 20.0f`,单位 cm)。 const CLICK_ITEM_PICKUP_CM := 20.0 # MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。 const HUGE_RACE_DIST_FIX_CM := 200.0 # _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。 const HUGE_RACE_SIZE := 3 # MOBSIZE_HUGE const HUGE_RACE_RANK := 4 # MOB_RANK_BOSS # __ReserveUseSkill(PythonPlayerInput.cpp:854):range > 100 时先减 10 再存。 const RESERVE_SKILL_RANGE_TRIM_CM := 10.0 const FUNC_WAIT := 0 const FUNC_MOVE := 1 const FUNC_COMBO := 3 const FUNC_SKILL := 0x80 const CM := 100.0 # 米 -> 厘米 # §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。 const EntityRules = preload("res://entity_rules.gd") # §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP)1:1。 const TargetBoard = preload("res://target_board.gd") # §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调): # OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口 # OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE # OnWaiting—— 站立中周期上行:100 ms 节流且相对上次上行位移 ≥ 1 cm 才发 FUNC_WAIT # OnStop —— 停下收尾:不节流,发 FUNC_WAIT # OnAttack —— 不节流,发 FUNC_COMBO + motion index(见 _do_attack_swing) # OnUseSkill—— 不节流,发 FUNC_SKILL|motion + arg(§3.8 施法路径) const SEND_MOVING_INTERVAL := 0.3 # OnMoving 最小间隔(秒) const SEND_WAITING_INTERVAL := 0.1 # OnWaiting 最小间隔(秒) const SEND_WAITING_MIN_DISP_CM := 1.0 # OnWaiting 触发的最小位移(服务器 cm) # 可点击 / 攻击距离(厘米),照 `InstanceBaseBattle.cpp:294` # `NEW_IsClickableDistanceDestInstance`:默认 150,NPC 500,采集物 100,弓箭走 __GetBowRange。 const CLICK_DIST_DEFAULT_CM := 150.0 const CLICK_DIST_NPC_CM := 500.0 const CLICK_DIST_RESOURCE_CM := 100.0 # CInstanceBase::__GetBowRange(InstanceBase.cpp:694):基础 2500-100 cm, # 主角再加 GetStatus(POINT_BOW_DISTANCE)(参考端直接按 cm 相加,不 ×100)。 const BOW_RANGE_BASE_CM := 2500.0 - 100.0 const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移 const CORRECTION_LERP := 0.25 # 否则每次校正插值比例 # §3.5 修改 4 —— 命中判定(对齐 `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess` # + `InstanceBaseBattle.cpp:413 AttackProcess` + `PythonPlayerEventHandler.cpp:135 OnHit` / # `:194 FlushVictimList`)。`NRaceData::EMotionType`(GameType.h:52): const MOTION_TYPE_NONE := 0 const MOTION_TYPE_NORMAL := 1 const MOTION_TYPE_COMBO := 2 const MOTION_TYPE_SKILL := 3 const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadius(ActorInstanceCollisionDetection.cpp:355) const 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 / 100(0.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 连击段(对齐 CActorInstance,ActorInstanceBattle.cpp:157-330)--- # chr.MOTION_MODE_*(CRaceMotionData::EMode,RaceMotionData.h:30)——完整枚举 const MOTION_MODE_RESERVED := 0 const MOTION_MODE_GENERAL := 1 const MOTION_MODE_ONEHAND_SWORD := 2 const MOTION_MODE_TWOHAND_SWORD := 3 const MOTION_MODE_DUALHAND_SWORD := 4 const MOTION_MODE_BOW := 5 const MOTION_MODE_FAN := 6 const MOTION_MODE_BELL := 7 const MOTION_MODE_FISHING := 8 const MOTION_MODE_HORSE := 9 const MOTION_MODE_HORSE_ONEHAND_SWORD := 10 const MOTION_MODE_HORSE_TWOHAND_SWORD := 11 const MOTION_MODE_HORSE_DUALHAND_SWORD := 12 const MOTION_MODE_HORSE_BOW := 13 const MOTION_MODE_HORSE_FAN := 14 const MOTION_MODE_HORSE_BELL := 15 const MOTION_MODE_WEDDING_DRESS := 16 const MOTION_MODE_HORSE_FIRST := 9 # HORSE..HORSE_BELL = 9..15 const MOTION_MODE_HORSE_LAST := 15 # CItemData::EItemType(ItemData.h:22)子集 —— 只用到武器 / 钓竿 / 镐 const ITEM_TYPE_WEAPON := 1 const ITEM_TYPE_ROD := 13 const ITEM_TYPE_PICK := 24 # CItemData::EWeaponSubTypes(ItemData.h:63)—— GetWeaponType() 返回的 bSubType const WEAPON_SUB_SWORD := 0 const WEAPON_SUB_DAGGER := 1 const WEAPON_SUB_BOW := 2 const WEAPON_SUB_TWO_HANDED := 3 const WEAPON_SUB_BELL := 4 const WEAPON_SUB_FAN := 5 const WEAPON_SUB_ARROW := 6 # 装备槽 / parts[] 下标(对齐 EquipModel) const WEAR_WEAPON := 4 const PART_WEAPON := 1 # 与 Packet.h 的 FISHING_SUBHEADER_GC_* 一致。 const FISHING_START := 0 const FISHING_STOP := 1 const FISHING_REACT := 2 const FISHING_SUCCESS := 3 const FISHING_FAIL := 4 const FISHING_FISH := 5 const FISHING_CANCEL_COOLDOWN := 0.5 # 段号 = CRaceMotionData::NAME_* ;ComboIndexVector 里存的就是这些 const NAME_NORMAL_ATTACK := 13 const NAME_COMBO_ATTACK_1 := 14 var _target_vid := 0 var _main_vid := 0 # game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。 # 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。 var _view_other_empire_target := true # game.py SetPCTargetBoard :807 —— app.IsPressed(app.DIK_LCONTROL)。poc 侧由 game_scene # 的 KEY_CTRL 按下 / 松开喂 set_lcontrol_down(),点 PC 时轮询(对齐参考端 IsPressed 语义)。 var _lcontrol_down := false var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标 var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒) var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒) var _was_moving := false var _attack_cd := 0.0 var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing) var _using_skill := false # 施法中(§3.8 由 player_skill 驱动) # §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo / # __OnEndCombo / __ClearCombo) var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shaman(race & 3) var combo_motion_mode := MOTION_MODE_GENERAL # m_wcurMotionMode(§3.5 修改 6:按装备武器种类 + 骑乘态) var _combo_type := 0 # m_wcurComboType(SetComboType;连击技能激活时 = MIN(lv,2)) var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中) var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发) var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击) var _combo_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_windows(THitDataContainer) var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType) var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限) var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(同目标再命中的冷却) 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::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支: # Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE / # 否则 FISHING;骑乘时按武器 subtype 走 HORSE_* ;否则按 subtype 走 ONEHAND / TWOHAND / # DUALHAND / BOW / FAN / BELL,ARROW 及其余 → GENERAL。 static func motion_mode_for(item_type: int, weapon_sub: int, mounted: bool, poly: bool, dress: bool) -> int: if poly: return MOTION_MODE_GENERAL if dress: return MOTION_MODE_WEDDING_DRESS if item_type == ITEM_TYPE_PICK: return MOTION_MODE_HORSE if mounted else MOTION_MODE_GENERAL if item_type == ITEM_TYPE_ROD: return MOTION_MODE_HORSE if mounted else MOTION_MODE_FISHING if mounted: match weapon_sub: WEAPON_SUB_SWORD: return MOTION_MODE_HORSE_ONEHAND_SWORD WEAPON_SUB_TWO_HANDED: return MOTION_MODE_HORSE_TWOHAND_SWORD WEAPON_SUB_DAGGER: return MOTION_MODE_HORSE_DUALHAND_SWORD WEAPON_SUB_FAN: return MOTION_MODE_HORSE_FAN WEAPON_SUB_BELL: return MOTION_MODE_HORSE_BELL WEAPON_SUB_BOW: return MOTION_MODE_HORSE_BOW _: return MOTION_MODE_HORSE match weapon_sub: WEAPON_SUB_SWORD: return MOTION_MODE_ONEHAND_SWORD WEAPON_SUB_TWO_HANDED: return MOTION_MODE_TWOHAND_SWORD WEAPON_SUB_DAGGER: return MOTION_MODE_DUALHAND_SWORD WEAPON_SUB_BOW: return MOTION_MODE_BOW WEAPON_SUB_FAN: return MOTION_MODE_FAN WEAPON_SUB_BELL: return MOTION_MODE_BELL _: return MOTION_MODE_GENERAL # 当前装备武器 vnum:主角 parts[WEAPON] 优先(时装 / GC_CHARACTER_UPDATE 写这里), # 为 0 回退 EQUIPMENT 窗 WEAR_WEAPON 槽(对齐 EquipModel._eff)。 func _equipped_weapon_vnum() -> int: var e := _main_entity() var parts: Variant = e.get("parts", []) if parts is Array and parts.size() > PART_WEAPON and int(parts[PART_WEAPON]) != 0: return int(parts[PART_WEAPON]) if client and client.has_method("get_equipment"): var eq: Variant = client.get_equipment() if eq is Array and eq.size() > WEAR_WEAPON: return int(eq[WEAR_WEAPON].get("vnum", 0)) return 0 func _is_main_mounted() -> bool: var main := _main_entity() return int(main.get("mount_vnum", 0)) != 0 or bool(main.get("mounting", false)) static func _is_new_mount(mount: int) -> bool: return (mount >= 20205 and mount <= 20212) or mount in [20214, 20215, 20217, 20218, 20220] func _horse_skill_unlocked(min_level: int) -> bool: if client == null or not client.has_method("get_skills"): return false for skill in client.get_skills(): if int(skill.get("id", 0)) == 109: return int(skill.get("master", 0)) >= 1 or int(skill.get("level", 0)) >= min_level return false # 读装备 / 骑乘 / 变身状态 -> motion_mode_for -> set_combo_motion_mode(换武器 / 上下马时调用)。 func _refresh_motion_mode() -> void: var e := _main_entity() var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) var poly := int(e.get("polymorph", 0)) != 0 or int(e.get("poly_vnum", 0)) != 0 var dress := bool(e.get("wedding_dress", false)) var it := 0 var sub := 0 var vnum := _equipped_weapon_vnum() if vnum > 0 and proto and proto.has_method("item"): var d: Dictionary = proto.item(vnum) it = int(d.get("type", 0)) sub = int(d.get("sub_type", 0)) set_combo_motion_mode(motion_mode_for(it, sub, mounted, poly, dress)) # CInstanceBase::MountHorse → SetRotationSpeed(c_fDefaultHorseRotationSpeed 300); # 下马恢复 c_fDefaultRotationSpeed 1200(InstanceBase.cpp:954 / 3020)。 if pc and "rotation_speed_deg" in pc: pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG func _on_inv_changed_mode(window: int, _cell: int) -> void: if window == 2: # mtnet::WINDOW_EQUIPMENT _refresh_motion_mode() func _is_bow_mode() -> bool: return combo_motion_mode == MOTION_MODE_BOW or combo_motion_mode == MOTION_MODE_HORSE_BOW # __CanShot 弓箭合法性 —— 40250 源码树没有成品 `__CanShot` / `CanShoot` 定义 # (`grep -rn "CanShot|CanShoot"` 全树无匹配),按能门控射箭的三处反推: # ① CNormalBowAttack_FlyEventHandler_AutoClear(REF/UserInterface/PythonPlayerEventHandler.cpp:235) # 的 OnSetFlyTarget / OnShoot 之外有 OnNoTarget() / OnNoArrow() 两个失败回调(REF/GameLib/FlyHandler.h); # ② NEW_Attack(REF/GameLib/InstanceBaseBattle.cpp:355)的 IsDead / IsStun / IsKnockDown / IsUsingSkill 门; # ③ 弓普攻可点击 / 攻击距离走 __GetBowRange(InstanceBase.cpp:694),空箭走 EMPTY_ARROW 文案。 # → 弓模式 && 有箭 && 有可攻击目标 && 目标在弓程内。返回 "" = 可射;否则返回 cannot 文案码。 func _can_shot(target_vid := -1) -> String: if not _is_bow_mode(): return "NOT_BOW_MODE" if _arrow_count == 0: # IFlyEventHandler::OnNoArrow —— -1=未跟踪/无限 return "EMPTY_ARROW" var tv := target_vid if target_vid >= 0 else _target_vid if tv <= 0: tv = _vid_reserved if tv <= 0: # IFlyEventHandler::OnNoTarget return "NO_TARGET" var te: Dictionary = client.get_entity(tv) if client else {} # 实体数据缺失(headless / 未进 store)时不据此否决——沿用 _send_fly_target 的「有节点即可」, # 只在确知目标不可攻击 / 已死时否决。 if not te.is_empty() and not _is_attackable(te): return "NO_TARGET" var tp = _entity_world_pos(tv) var mp = _entity_world_pos(_main_vid) if tp is Vector3 and mp is Vector3: if (tp as Vector3).distance_to(mp) * CM > _bow_range_cm(): return "OUT_OF_RANGE" return "" # CActorInstance::__GetCurrentComboType(ActorInstance.cpp:164)——弓 / 徒手 / 骑乘模式恒 0。 func _current_combo_type() -> int: if _is_bow_mode() or combo_motion_mode == MOTION_MODE_GENERAL \ or (combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST): return 0 return _combo_type # GetComboDataPointer(m_wcurMotionMode, wComboType) 的等价:命中返回段号数组,否则空。 func _combo_vec() -> PackedInt32Array: var cls: Dictionary = _combo_tables.get(combo_class, {}) var key := (combo_motion_mode << 16) | _current_combo_type() return cls.get(key, PackedInt32Array()) func _motion_data() -> Dictionary: var pv_anim: Object = player_view.get("anim") if player_view else null if pv_anim and pv_anim.has_method("get_motion_data"): return pv_anim.get_motion_data() return {} # --- helpers -------------------------------------------------------------- func _vid_of(node: Object) -> int: if node and node.has_meta("vid"): return int(node.get_meta("vid")) return 0 # 玩家节点在 Metin2World 本地帧 -> 服务器全局 cm(含 BasePosition) func _server_xy(world_pos: Vector3) -> Vector2: return MapCoord.to_server_cm(world_pos) func _heading_deg(yaw_rad: float) -> float: return MapCoord.yaw_to_heading(yaw_rad) func _player_yaw() -> float: return pc.player.rotation.y if pc.player else 0.0 # CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP / 5 BUILDING / # 6 WOODEN_DOOR。门类型既可由实体 fixture 的 building / kind 给出,也可由 # mob_proto 的 bType=4 + 13000 / 30111..30119 race 识别。 # 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。 func _entity_kind(e: Dictionary) -> int: if bool(e.get("building", false)): return EntityRules.KIND_BUILDING var explicit_kind := int(e.get("kind", -1)) if explicit_kind >= EntityRules.KIND_BUILDING: return explicit_kind var ct := int(e.get("ch_type", 0)) if ct != 0: return ct var race := int(e.get("race", 0)) if EntityRules._is_wooden_door(e): return EntityRules.KIND_WOODEN_DOOR if race >= 1 and proto and proto.has_method("mob"): var m: Dictionary = proto.mob(race) if not m.is_empty(): # mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR … match int(m.get("type", 0)): 0: return 2 1: return 1 2: return 3 3: return 4 4: return EntityRules.KIND_WOODEN_DOOR \ if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING return 0 # 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。 func _entity_name(e: Dictionary) -> String: var nm := String(e.get("name", "")) if nm != "": return nm var race := int(e.get("race", 0)) if race >= 1 and proto and proto.has_method("mob"): var m: Dictionary = proto.mob(race) if not m.is_empty(): return String(m.get("locale_name", m.get("name", ""))) return "" # --- movement ---------------------------------------------------------------- # `pc.moved` 在平移的每一帧发。第一帧 = OnMove,之后各帧 = OnMoving。 func _on_local_moved(pos: Vector3) -> void: if _observer_mode or client == null or not client.is_in_game(): return var now := _now() if not _was_moving: # OnMove:立即上行,并把 moving/waiting 两个节流窗口都推到「刚发过」, # 让随后的 OnMoving / OnWaiting 各自等一个完整间隔(PythonPlayerEventHandler.cpp:76)。 _send_state(FUNC_MOVE, 0, pos) _was_moving = true _last_moving_sent_t = now _last_waiting_sent_t = now return # OnMoving:300 ms 节流(:62)。 if now - _last_moving_sent_t < SEND_MOVING_INTERVAL: return _send_state(FUNC_MOVE, 0, pos) _last_moving_sent_t = now func _on_anim_state(state: String) -> void: if _observer_mode: return # OnStop:停下时不节流,落一包 FUNC_WAIT 收尾(PythonPlayerEventHandler.cpp:88)。 if state == "wait" and _was_moving: _was_moving = false if client and client.is_in_game() and pc.player: _send_state(FUNC_WAIT, 0, pc.player.position) # 统一上行出口 —— 对齐 `SendCharacterStatePacket` / `TPacketCGMove` # (PythonNetworkStreamPhaseGame.cpp:1111):bFunc / bArg / bRot / lX / lY / dwTime。 # rot 的 /5.0 与坐标的 __LocalPositionToGlobalPosition 由 C++ 侧 client.move() 负责。 func _send_state(func_id: int, arg: int, world_pos: Vector3) -> void: if client == null or not client.is_in_game(): return var xy := _server_xy(world_pos) client.move(func_id, arg, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) _last_sent_pos = world_pos # OnWaiting —— 站立时周期性把当前位置补发上行,防止本地被击退 / 碰撞推挤后与 # 服务器不一致(PythonPlayerEventHandler.cpp:38):100 ms 节流且相对上次上行位移 # ≥ SEND_WAITING_MIN_DISP_CM 才发。 func _tick_on_waiting() -> void: if _was_moving or _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: return var now := _now() if now - _last_waiting_sent_t < SEND_WAITING_INTERVAL: return var disp_cm := _server_xy(pc.player.position).distance_to(_server_xy(_last_sent_pos)) if disp_cm < SEND_WAITING_MIN_DISP_CM: return _send_state(FUNC_WAIT, 0, pc.player.position) _last_waiting_sent_t = now # OnUseSkill —— 施法瞬间的状态上行:FUNC_SKILL|uMotSkill + uArg(不节流, # PythonPlayerEventHandler.cpp:120)。调用点(技能合法性 / CD / charge)属 §3.8 的 # `player_skill.gd`,这里只留 1:1 的上行出口,做法同 §3.4 的预留桩。 func on_use_skill(motion_skill: int, arg: int) -> void: if _observer_mode or pc == null or pc.player == null: return _was_moving = false _send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pc.player.position) func _on_main_set(vid: int) -> void: _main_vid = vid if client and client.has_method("get_entity"): _on_entity_info(vid, client.get_entity(vid)) # net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的 if net_world and net_world.has_method("set_local_vid"): net_world.set_local_vid(vid) func _on_entity_info(vid: int, entity: Dictionary) -> void: if vid != _main_vid: return if pc and pc.has_method("set_server_speed"): var speed := int(entity.get("moving_speed", 0)) if speed > 0: pc.set_server_speed(speed) _apply_attack_speed(int(entity.get("attack_speed", 0))) if entity.has("race"): combo_class = int(entity.get("race", 0)) & 3 # §3.5 连击段表按职业分块 _refresh_motion_mode() # §3.5 修改 6:parts[WEAPON] / 骑乘 / 变身变化 → combo_motion_mode func _apply_attack_speed(speed: int) -> void: # `CInstanceBase::SetAttackSpeed`:bAttackSpeed / 100 只缩放动作播放速率, # 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。 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::__GetBowRange(InstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。 func _bow_range_cm() -> float: return BOW_RANGE_BASE_CM + _bow_distance_bonus # 可点击 / 攻击距离(米),逐分支对齐 NEW_IsClickableDistanceDestInstance # (InstanceBaseBattle.cpp:294):默认 150 → 弓箭 __GetBowRange() → NPC 500 → 采集物 100, # 后面的 if 覆盖前面的(NPC / 采集物优先于弓箭距离)。 func _clickable_distance(te: Dictionary) -> float: var cm := CLICK_DIST_DEFAULT_CM if _is_bow_mode(): cm = _bow_range_cm() match _entity_kind(te): 1: cm = CLICK_DIST_NPC_CM # NPC 3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物 return cm / CM func _on_points(p: Dictionary) -> void: _apply_attack_speed(int(p.get("attack_speed", 0))) _bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange() if hud == null: return if hud.has_method("set_vitals"): hud.set_vitals(int(p.get("hp", 0)), int(p.get("max_hp", 0)), int(p.get("sp", 0)), int(p.get("max_sp", 0))) if hud.has_method("set_stamina"): hud.set_stamina(int(p.get("stamina", 0)), int(p.get("max_stamina", 0))) if hud.has_method("set_exp"): hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0))) if hud.has_method("set_level"): hud.set_level(int(p.get("level", 0))) if hud.has_method("set_energy"): hud.set_energy(int(p.get("energy", 0)), 100) func _on_net_moved(vid: int) -> void: if client == null or vid != _main_vid or pc.player == null: return var e: Dictionary = client.get_entity(vid) if e.is_empty(): return var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position)) sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z var d: float = pc.player.position.distance_to(sp) if d > CORRECTION_SNAP: pc.player.position = sp elif d > 0.3: pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP) # --- targeting / attack ----------------------------------------------------- func _on_pick(node: Node3D) -> void: if _observer_mode: return var vid := _vid_of(node) if vid == 0 or vid == _main_vid: return # __OnPressActor 特例(PythonPlayerInput.cpp:324):预约技能 + 冲锋 affect 生效时, # 只改预约目标、不立即选中 / 施法。 if _reserved_mode == ReservedMode.USE_SKILL and _dash_active: _vid_reserved = vid return # NPC / 石头 / warp -> 交互,不是攻击目标 var e: Dictionary = client.get_entity(vid) if _entity_kind(e) in [1, 3, 4]: client.click_npc(vid) return _attack_cd = 0.0 _change_target_to_picked_instance(vid) # game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL # 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building # (:814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC # 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别, # 不满足 PC 分支时不会误触发悄悄话。 if _entity_kind(e) == EntityRules.KIND_PC: var same_empire := EntityRules._is_same_empire(_main_entity(), e) if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false): var nm := _entity_name(e) if nm != "": whisper_requested.emit(nm) # poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore # 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框; # PC / building 目标 -> 只在框上是别的 VID 时关掉(不显血条);石头 / 敌对怪等「可看血」 # 目标 -> SetHPTargetBoard;NPC / warp 等不可看血目标 -> 关框。经 target_board.gd 纯静态镜像。 func _on_target_info(vid: int, hp_pct: int) -> void: var e: Dictionary = client.get_entity(vid) if vid != 0 else {} var exists := vid != 0 and not e.is_empty() var dead := exists and bool(e.get("dead", false)) var kind := _entity_kind(e) if exists else -1 var is_pc := kind == EntityRules.KIND_PC var is_building := EntityRules._is_building(e) # uitarget.py:189-193 前置过滤:game-option 关「看他国目标框」时异阵营目标不开框。 # empire 0 的怪/NPC/石头在 _is_same_empire 里算同阵营 → 不受影响,只挡真·他国 PC。 if exists: var same_empire := EntityRules._is_same_empire(_main_entity(), e) if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire): _clear_target() return var can_view := TargetBoard.can_view_target_hp( kind == EntityRules.KIND_STONE, EntityRules._is_wooden_door(e), kind == EntityRules.KIND_MONSTER) match TargetBoard.classify(exists, dead, is_pc, is_building, can_view): TargetBoard.Action.CLOSE: _clear_target() TargetBoard.Action.CLOSE_IF_DIFFERENT: if TargetBoard.should_close_if_different(vid, _target_vid): _clear_target() TargetBoard.Action.SET_HP: if TargetBoard.needs_reset(vid, _target_vid): _target_vid = vid target_changed.emit(vid) if hud and hud.has_method("set_target"): var nm := _entity_name(e) hud.set_target(nm if nm != "" else "目标", TargetBoard.clamp_hp_pct(hp_pct)) TargetBoard.Action.NONE: pass # game_option_ui 的「看他国玩家目标框」radio(display_option_changed "target_board")。 # uigameoption.py → constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)。 func set_view_other_empire_target(on: bool) -> void: _view_other_empire_target = on # game_scene 的 KEY_CTRL 按下 / 松开 → app.IsPressed(app.DIK_LCONTROL) 语义(game.py:807)。 func set_lcontrol_down(down: bool) -> void: _lcontrol_down = down func _clear_target() -> void: if _target_vid == 0: return _target_vid = 0 target_changed.emit(0) if hud and hud.has_method("clear_target"): hud.clear_target() # 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。 # 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING), # 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。 func _on_damage(vid: int, amount: int, flag: int) -> void: if vid != _main_vid: return var crit := (flag & DAMAGE_CRITICAL) != 0 if camera and camera.has_method("shake"): camera.shake(0.08 if crit else 0.04, 10.0) if (flag & DAMAGE_DODGE) != 0: return _knock_down = true if player_view and player_view.has_method("set_anim_state"): player_view.set_anim_state("damage") # GC_FISHING motion events for the local player. `info` is the fisher VID for # START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only. func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: if info != _main_vid: return # CActorInstance::IsFishing() 仅在 FISHING_WAIT / FISHING_REACT 为真; # START 到达时先记录意图,随后 STOP/SUCCESS/FAIL 收尾清掉。 match subheader: FISHING_START, FISHING_REACT: _fishing_active = true FISHING_STOP, FISHING_SUCCESS, FISHING_FAIL, FISHING_FISH: _fishing_active = false if player_view == null or not player_view.has_method("set_anim_state"): return if subheader == FISHING_FISH: return var state := "fishing" match subheader: FISHING_STOP: state = "wait" FISHING_REACT: state = "fishing_react" FISHING_SUCCESS: state = "fishing_catch" FISHING_FAIL: state = "fishing_fail" player_view.set_anim_state(state) # --- 行动闸门(对齐 InstanceBase.cpp:1247 / InstanceBaseBattle.cpp:355 / :310)--- func _main_entity() -> Dictionary: if client and client.has_method("get_entity") and _main_vid != 0: return client.get_entity(_main_vid) return {} # CanProcessNetworkStatePacket:死亡 / 晕眩 / 击退中都不接受状态输入(≈ __IsSyncing)。 func _can_process_network_state() -> bool: if _knock_down: return false var e := _main_entity() return not (bool(e.get("dead", false)) or bool(e.get("stunned", false)) \ or bool(e.get("knock_down", false))) # __CanAttack:可处理状态 + 不在施法中。 func _can_attack() -> bool: if not _can_process_network_state() or _using_skill: return false # CPythonPlayer::__CanAttack() 的新坐骑等级 11 门(:759), # 只作用于 IsNewMount;普通坐骑仍由 CanAttackHorseLevel() 的 level<=1 门处理。 var mount := int(_main_entity().get("mount_vnum", 0)) if _is_new_mount(mount) and not _horse_skill_unlocked(11): return false return true # __CanUseSkill:同上(技能合法性 / CD 归 §3.8)。 func _can_use_skill() -> bool: return _can_process_network_state() and not _using_skill # 兼容旧调用:真正语义 = 「不能处理网络状态包 / 不能行动」。 func is_stunned() -> bool: return not _can_process_network_state() # §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。 # 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* / # GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。 func skill_context() -> Dictionary: var e := _main_entity() var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) var wsub := -1 var vnum := _equipped_weapon_vnum() if vnum > 0 and proto and proto.has_method("item"): var d: Dictionary = proto.item(vnum) if int(d.get("type", -1)) == ITEM_TYPE_WEAPON: wsub = int(d.get("sub_type", -1)) # §3.8 修改 3:GetStatus(POINT_HP/SP) —— __CheckShortLife / __CheckShortMana 的即时值。 var cur_hp := -1 var cur_sp := -1 if client and client.has_method("get_points"): var pts: Dictionary = client.get_points() if pts.has("hp"): cur_hp = int(pts.get("hp", -1)) if pts.has("sp"): cur_sp = int(pts.get("sp", -1)) return { "observer_mode": _observer_mode, "mounting": mounted, "in_safe": bool(e.get("in_safe", false)), "is_fishing": combo_motion_mode == MOTION_MODE_FISHING, "is_using_skill": _using_skill, "can_act": _can_process_network_state(), "level_limit": true, "cooltime_check": true, "dash_active": _dash_active, "weapon_sub_type": wsub, "arrow_count": _arrow_count, "cur_hp": cur_hp, "cur_sp": cur_sp, } 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::IsAttackableInstance(InstanceBase.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_ITEM(PythonPlayerInputMouse.cpp:290):走到掉落物、到手距离内先补一包 # 站立状态再发 CG_ITEM_PICKUP,物品消失则清预约。 func _reserve_process_click_item() -> void: if pc == null or pc.player == null: _clear_reserved() return var item_pos = _ground_item_world_pos(_iid_reserved) if item_pos == null: _clear_reserved() return if pc.player.global_position.distance_to(item_pos) < CLICK_ITEM_PICKUP_CM / CM: _send_state(FUNC_WAIT, 0, pc.player.position) if client.has_method("pickup_item"): client.pickup_item(_iid_reserved) pc.set("frozen", false) _clear_reserved() elif pc.has_method("walk_to"): pc.walk_to(item_pos) # MODE_CLICK_POSITION(PythonPlayerInputMouse.cpp:320):预约延时耗尽、本地未被锁, # 再朝目标点起步并清预约(_reserved_delay 的递减在 _process 里,≈ NEW_IsEmptyReservedDelayTime)。 func _reserve_process_click_position() -> void: if pc and pc.has_method("_can_translate") and not pc._can_translate(): return if _reserved_delay > 0.0: return if pc and pc.has_method("walk_to"): pc.walk_to(_ppos_reserved) _clear_reserved() # MODE_USE_SKILL(PythonPlayerInputMouse.cpp:340):目标进入技能射程(含 IS_HUGE_RACE # 的 −200cm 修正)就选中并施法,否则继续贴近。__UseSkill 的真正施法走 # use_skill_hook(game_scene 注入的 quickbar 那条链),未注入则退化成只选中、留预约。 func _reserve_process_use_skill() -> void: if pc == null or pc.player == null: _clear_reserved() return var rv: Dictionary = client.get_entity(_vid_reserved) var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null if rv.is_empty() or tnode == null: _clear_reserved() return var dist_cm: 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_hook(Callable(global_slot:int) -> bool)注入。未注入 -> 返回 false(预约保持, # 下一帧再试,与参考端 __UseSkill 失败留预约一致)。 var use_skill_hook := Callable() func _invoke_reserved_skill() -> bool: if _skill_slot_reserved < 0: return false if use_skill_hook.is_valid(): return bool(use_skill_hook.call(_skill_slot_reserved)) return false # NEW_GetGroundItemPosition(PythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。 # M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。 func _ground_item_world_pos(iid: int): if client == null or not client.has_method("get_ground_items"): return null for gi in client.get_ground_items(): if int(gi.get("vid", 0)) == iid: var p: Variant = gi.get("pos", null) return MapCoord.to_world(p) if p is Vector3 else null return null # __ReserveClickItem(PythonPlayerInput.cpp)——预约「走到掉落物再拾取」。 func reserve_click_item(iid: int) -> void: _clear_reserved() _reserved_mode = ReservedMode.CLICK_ITEM _iid_reserved = iid # __ReserveClickGround(PythonPlayerInput.cpp:836)——预约「延时 0.1s 后走到该点」。 func reserve_click_ground(world_pos: Vector3) -> void: _clear_reserved() _reserved_mode = ReservedMode.CLICK_POSITION _ppos_reserved = world_pos _reserved_delay = 0.1 # __ReserveUseSkill(PythonPlayerInput.cpp:854)——预约「进射程再施法」。range > 100 时 # 先减 10(参考端同款收边,避免边界处反复趋近)。slot 为全局快捷栏索引。 func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: _clear_reserved() _reserved_mode = ReservedMode.USE_SKILL _vid_reserved = vid _skill_slot_reserved = slot if range_cm > 100.0: range_cm -= RESERVE_SKILL_RANGE_TRIM_CM _skill_range_reserved = range_cm # __OnPressItem(PythonPlayerInput.cpp)——点地面掉落物:清预约 + auto-attack,在拾取 # 距离内直接 CG_ITEM_PICKUP + 停,否则预约走过去。 func pick_ground_item(iid: int) -> void: if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: return _clear_reserved() _auto_attack_vid = 0 var item_pos = _ground_item_world_pos(iid) if item_pos == null: return if pc.player.global_position.distance_to(item_pos) * CM < CLICK_DIST_RESOURCE_CM: _send_state(FUNC_WAIT, 0, pc.player.position) if client.has_method("pickup_item"): client.pickup_item(iid) pc.set("frozen", false) else: reserve_click_item(iid) # IS_HUGE_RACE(vnum)(参考端是一张未随源码发布的硬编码 vnum 白名单)——用 mob_proto # 的体型 / 阶级位近似:MOBSIZE_HUGE(3) 或 BOSS(4) 以上视为巨型。查不到 proto -> false。 func _is_huge_race(race: int) -> bool: if race <= 0 or proto == null or not proto.has_method("mob"): return false var m: Dictionary = proto.mob(race) if m.is_empty(): return false return int(m.get("size", 0)) >= HUGE_RACE_SIZE or int(m.get("rank", 0)) >= HUGE_RACE_RANK # __ChangeTargetToPickedInstance(PythonPlayerInput.cpp:186)→ SetTarget(:130): # 可选中就选中并发 CG_TARGET,否则清空目标并发 CG_TARGET(0)。这里「目标」限定为战斗 # 目标(PC / 怪)——NPC / 石头 / warp 的交互在 _on_pick 里已分流到 click_npc # (对齐 __OnClickActor 的拆分)。 func _change_target_to_picked_instance(vid: int) -> bool: var e: Dictionary = client.get_entity(vid) if e.is_empty() or bool(e.get("dead", false)) or _entity_kind(e) not in [0, 2]: _target_vid = 0 client.set_target(0) target_changed.emit(0) if hud and hud.has_method("clear_target"): hud.clear_target() return false _target_vid = vid client.set_target(vid) target_changed.emit(vid) _refresh_target_hud() return true # --- §3.4 扇形 / 圆形多目标 fly-targeting(PythonPlayerSkill.cpp:680-746)---------- # 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关, # 只要 target 与 each 用同一函数(对齐 NEW_Get*InFanRange 里 fDirRot 的比较)。 func _bearing_deg(from_world: Vector3, to_world: Vector3) -> float: var d := to_world - from_world return rad_to_deg(atan2(d.x, -d.z)) func _dir_from_bearing(deg: float) -> Vector3: var r := deg_to_rad(deg) return Vector3(sin(r), 0.0, -cos(r)) # 场景节点优先(已在 world 帧),否则实体 pos 过 MapCoord。 func _entity_world_pos(vid: int): if net_world and net_world.has_method("node_for"): var n: Node3D = net_world.node_for(vid) if n != null: return n.global_position var e: Dictionary = client.get_entity(vid) if e.is_empty() or not (e.get("pos", null) is Vector3): return null return MapCoord.to_world(e.get("pos")) # NEW_GetInstanceVectorInCircleRange(InstanceBaseBattle.cpp:243)——射程内全部可攻击 # 实体,按距离升序。 func _instances_in_circle_range(range_cm: float) -> Array: var out := [] if client == null or pc == null or pc.player == null: return out var main_pos: Vector3 = pc.player.global_position var reach_m := range_cm / CM for e in client.get_entities(): var vid := int(e.get("vid", 0)) if vid == 0 or vid == _main_vid or not _is_attackable(e): continue var wp = _entity_world_pos(vid) if wp == null: continue var dist_m: float = main_pos.distance_to(wp) if dist_m <= reach_m: out.append({"vid": vid, "dist_m": dist_m, "pos": wp}) out.sort_custom(func(a, b): return a["dist_m"] < b["dist_m"]) return out # NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)——先按圆形取,再按 # 「随距离收窄的半角」过滤:fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN。 func _instances_in_fan_range(range_cm: float, target_vid: int) -> Array: var circle := _instances_in_circle_range(range_cm) var tp = _entity_world_pos(target_vid) if tp == null: return [] var main_pos: Vector3 = pc.player.global_position var dst_rot := deg_to_rad(_bearing_deg(main_pos, tp)) var rpm := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) / FAN_HALF_ROT_MIN_DISTANCE var kept := [] for c in circle: var each_dist_cm: float = min(c["dist_m"] * CM, FAN_HALF_ROT_MIN_DISTANCE) var half_fan := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) - rpm * each_dist_cm + FAN_HALF_ROT_MIN var each_rot := deg_to_rad(_bearing_deg(main_pos, c["pos"])) if absf(angle_difference(dst_rot, each_rot)) <= deg_to_rad(half_fan): kept.append(c) return kept # NEW_GetRandomPositionInFanRange(InstanceBaseBattle.cpp:124)——目标方位 ±10° 抖动、 # 沿该方向 8000cm 取点(参考端绕 Z 转 (0,8000,0);这里在 world 帧等价展开)。 func _random_pos_in_fan_range(target_vid: int) -> Vector3: var main_pos: Vector3 = pc.player.global_position var tp = _entity_world_pos(target_vid) var base_bearing: float = _bearing_deg(main_pos, tp) if tp != null else rad_to_deg(pc.player.rotation.y) var rot := base_bearing + randf_range(-FAN_RANDOM_JITTER_DEG, FAN_RANDOM_JITTER_DEG) return main_pos + _dir_from_bearing(rot) * (FAN_RANDOM_REACH_CM / CM) # __UseSkill(PythonPlayerSkill.cpp:684-746)里 GetTargetCount > 0 时的扇形 / 圆形补目标。 # 主目标的 CG_FLY_TARGETING 已由 M2Client::use_skill 发过,这里只补「附加」目标—— # 命中范围内的其他敌人(AddFlyTargetInstance)+ 名额不足时的随机落点(AddFlyTargetPosition, # vid = 0)。返回补发的附加 fly-target 包数量。 func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape: int) -> int: if client == null or not client.has_method("add_fly_targeting"): return 0 if max_count <= 1 or target_vid == 0 or shape == FlyShape.SINGLE: return 0 if pc == null or pc.player == null: return 0 # __GetSkillTargetRange:基础射程再叠加主角 POINT_BOW_DISTANCE(×100,参考端此处 # 与 __GetBowRange 的「不 ×100」不一致,照抄各自的写法)。 var reach_cm := range_cm + _bow_distance_bonus * 100.0 var cands: Array = _instances_in_fan_range(reach_cm, target_vid) if shape == FlyShape.FAN \ else _instances_in_circle_range(reach_cm) var count := 1 var extras := 0 for c in cands: if count >= max_count: break var vid := int(c["vid"]) if vid == target_vid: continue if bool(client.get_entity(vid).get("dead", false)): continue var sc := _server_xy(c["pos"]) client.add_fly_targeting(vid, int(sc.x), int(sc.y)) count += 1 extras += 1 while count < max_count: var sc2 := _server_xy(_random_pos_in_fan_range(target_vid)) client.add_fly_targeting(0, int(sc2.x), int(sc2.y)) count += 1 extras += 1 return extras # NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand # (ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。 func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void: var to: Vector3 = tnode.global_position - pc.player.global_position if to.length() > 0.01: pc.player.rotation.y = atan2(to.x, to.z) _was_moving = false var vec := _combo_vec() if vec.is_empty(): # 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 退化为单段普攻。 _emit_swing(NAME_NORMAL_ATTACK, false) return # m_isPreInput 挂起时忽略本次输入(等 ComboProcess 到点自动触发)。 if _is_pre_input: return if _combo_index == 0: _run_next_combo(vec) return var t := _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::__RunNextCombo(ActorInstanceBattle.cpp:256) func _run_next_combo(vec: PackedInt32Array) -> void: _combo_index += 1 var arr_idx := _combo_index - 1 if arr_idx < 0 or arr_idx >= vec.size(): _combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进 return _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::ComboProcess(ActorInstanceBattle.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 # OnHit(SendAttackPacket)不在这里:有 `.msa` 命中窗时它由 `_attack_process()` 的几何判定 # 逐帧触发(1:1 对齐 `__ProcessDataAttackSuccess` → `OnHit`);只有拿不到命中窗数据 # (headless / 缺资源)才在此退化为立即发一包。 func _emit_swing(motion_index: int, is_combo: bool) -> void: # 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(在 # CActorInstance::__SetMotion 里 __IsNeedFlyTargetMotion() 为真时触发, # ActorInstanceMotion.cpp:372):立即发 CG_FLY_TARGETING(目标VID, 目标包围球中心)。 # OnShoot(uSkill) 则要等 `.msa` FLY 帧,交给 game_scene(bow_shot_fired → 入队)。§3.6 if _is_bow_mode(): # __CanShot 关:无箭 / 无目标 / 出弓程 → 走 OnNoArrow / OnNoTarget 文案, # 不发 CG_FLY_TARGETING、不排 OnShoot(其余起手动作照常,seam:参考端整挥击中止)。 var why := _can_shot() if why != "": _emit_cannot(why) else: _send_fly_target() bow_shot_fired.emit(_swing_skill) # OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号 # (PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。 _send_state(FUNC_COMBO, motion_index, pc.player.position) _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::OnSetFlyTarget(PythonPlayerEventHandler.cpp:235): # SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) # OnGetFlyTargetPosition(ActorInstanceFly.cpp:4)返回目标包围球中心的世界坐标;其平面 # 坐标与角色脚点一致,而 CG_FLY_TARGETING 只带平面坐标,故直接取目标节点位置换算 server-XY。 func _send_fly_target() -> void: var vid := _target_vid if _target_vid != 0 else _vid_reserved if vid == 0 or client == null or net_world == null \ or not client.has_method("add_fly_targeting"): return var tnode: Node3D = net_world.node_for(vid) if tnode == null: return var sp := _server_xy(tnode.global_position) client.add_fly_targeting(vid, int(sp.x), int(sp.y)) # 挥击起播:从当前 `.msa` 取 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::AttackProcess(InstanceBaseBattle.cpp:413)+ CActorInstance::__NormalAttackProcess # (ActorInstanceCollisionDetection.cpp:333)——挥击动作播放中,每帧按 hit_window [start,end] # 对所有可攻击实体做扫掠球几何判定,命中即 OnHit(SendAttackPacket)。 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 # 该窗命中数超上限 -> 本帧不再处理这个实体 # OnHit:SetTarget(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 上限 16,SKILL 用 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::FlushVictimList(PythonPlayerEventHandler.cpp:194)——帧末把 # 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。 func _flush_victim_list() -> void: if _victim_flush.is_empty(): return if client and client.is_in_game(): # slice 返回新数组:既做 16 上限截断,也避免把马上要 clear() 的引用传出去。 client.sync_positions(_victim_flush.slice(0, SYNC_POSITION_LIMIT)) _victim_flush.clear() func _now() -> float: return Time.get_ticks_msec() / 1000.0 # --- HUD ----------------------------------------------------------------- func _on_vitals(vid: int) -> void: var e: Dictionary = client.get_entity(vid) if e.is_empty(): return if vid == _main_vid and hud and hud.has_method("set_vitals"): hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)), int(e.get("sp", 0)), int(e.get("max_sp", 0))) if vid == _target_vid: _refresh_target_hud() func _refresh_target_hud() -> void: if hud == null or not hud.has_method("set_target"): return var e: Dictionary = client.get_entity(_target_vid) if e.is_empty(): return var mh := int(e.get("max_hp", 0)) var pct := int(100.0 * float(e.get("hp", 0)) / float(mh)) if mh > 0 else 0 hud.set_target(_entity_name(e), pct)