1600 lines
68 KiB
GDScript
1600 lines
68 KiB
GDScript
# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。
|
||
#
|
||
# 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s)
|
||
# 停下 -> M2Client.move(FUNC_WAIT, …)
|
||
# 点实体 -> M2Client.set_target(vid);按住 SPACE 才在攻击距离内按攻速 attack()
|
||
# 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp)
|
||
# points_changed / vitals_changed / target_info -> HUD
|
||
#
|
||
# 需要:M2Client、PlayerController(`player_controller.gd`)、NetWorld(`net_world.gd`)。
|
||
# 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。
|
||
#
|
||
# 用法:
|
||
# var np := preload("res://net_play.gd").new()
|
||
# add_child(np)
|
||
# np.setup(m2client, player_ctl, net_world, hud)
|
||
extends Node
|
||
|
||
signal target_changed(vid: int)
|
||
# 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")
|
||
# §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
|
||
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。
|
||
# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。
|
||
func _entity_kind(e: Dictionary) -> int:
|
||
var ct := int(e.get("ch_type", 0))
|
||
if ct != 0:
|
||
return ct
|
||
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():
|
||
# 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
|
||
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)
|
||
|
||
func _on_target_info(vid: int, hp_pct: int) -> void:
|
||
if vid == 0:
|
||
_clear_target()
|
||
return
|
||
_target_vid = vid
|
||
if hud and hud.has_method("set_target"):
|
||
var e: Dictionary = client.get_entity(vid)
|
||
var nm := _entity_name(e) if not e.is_empty() else ""
|
||
hud.set_target(nm if nm != "" else "目标", hp_pct)
|
||
|
||
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)
|