fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+568 -107
View File
@@ -15,7 +15,12 @@
# np.setup(m2client, player_ctl, net_world, hud)
extends Node
const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd")
signal level_up(new_level: int)
signal money_picked(amount: int)
signal status_refresh_requested()
signal skill_refresh_requested()
signal target_changed(vid: int)
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
@@ -48,8 +53,9 @@ const FAN_HALF_ROT_MIN_DISTANCE := 1000.0
# NEW_GetRandomPositionInFanRange:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。
const FAN_RANDOM_JITTER_DEG := 10.0
const FAN_RANDOM_REACH_CM := 8000.0
# MODE_CLICK_ITEM 的到手阈值(40250 server item.cpp:435 DistanceValid <= 300cm)。
const CLICK_ITEM_PICKUP_CM := 300.0
# MODE_CLICK_ITEM 的直接点击阈值(40250 __OnPressItem ->
# NEW_IsClickableDistanceDestPixelPositionfDistance > 150cm 时预约走路)。
const CLICK_ITEM_PICKUP_CM := 150.0
# MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。
const HUGE_RACE_DIST_FIX_CM := 200.0
# _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。
@@ -64,6 +70,21 @@ const FUNC_COMBO := 3
const FUNC_SKILL := 0x80
const CM := 100.0 # 米 -> 厘米
# EPointTypes from 40250 Packet.h. RecvPointChange uses these exact branches
# to decide which status/skill views are refreshed after the typed point event.
const POINT_LEVEL := 1
const POINT_ST := 12
const POINT_HT := 13
const POINT_DX := 14
const POINT_IQ := 15
const POINT_LEVEL_STEP := 25
const POINT_SUB_SKILL := 27
const POINT_SKILL := 28
const POINT_STAT_RESET_COUNT := 112
const POINT_HORSE_SKILL := 113
const POINT_GOLD := 11
const POINT_ENERGY := 128
# §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。
const EntityRules = preload("res://entity_rules.gd")
# §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP1:1。
@@ -112,6 +133,7 @@ const EFFECT_HIT := "d:/ymir work/effect/hit/blow_1/blow_1_low.mse"
# 占位胶囊(没有 .msm 防御球)退化用的一个防御球(seam)
const HIT_FALLBACK_SPHERE := {"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}
const HitCollision = preload("res://hit_collision.gd")
const MotionSplash = preload("res://motion_splash.gd")
var client: Node # M2Client
var pc: Node # PlayerController
@@ -206,6 +228,7 @@ const NAME_COMBO_ATTACK_1 := 14
var _target_vid := 0
var _main_vid := 0
var _main_dead := false
# game-option「看他国玩家目标框」(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1)。
# 关掉时 TargetBoard.Open 前置过滤:异阵营 PC 目标框直接不开(uitarget.py:189-193)。
var _view_other_empire_target := true
@@ -219,10 +242,12 @@ var _was_moving := false
var _attack_cd := 0.0
var _last_click_actor_t := -10.0
var _last_click_actor_vid := 0
var _target_end_time := 0.0 # CPythonPlayer::m_dwTargetEndTime (seconds)
var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing
var _using_skill := false # 施法中(§3.8 由 player_skill 驱动)
var _skill_cast_start_time := -1.0 # 技能动作起播时间
var _skill_cast_duration := 0.0 # 技能动作时长超时门控
var _skill_motion_driven := false # 有效 MSA 时由 motion_bound(wait) 结束;无动作时才用时长兜底
var _skill_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控
# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo /
# __OnEndCombo / __ClearCombo
@@ -241,6 +266,7 @@ var _is_attacking := false # 当前是否处于挥击攻击动作中(402
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
var _has_attack_motion_data := false # m_pkCurRaceMotionData->isAttackingMotion() / AttackingData 是否存在
var _hit_windows := [] # get_motion_data().hit_windowsTHitDataContainer
var _hit_motion_type := 0 # get_motion_data().motion_typeNRaceData::EMotionType
var _hit_limit_count := 0 # get_motion_data().hit_limit_countSKILL 类的每窗命中上限)
@@ -249,6 +275,8 @@ var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_typeiHittingType
var _hit_stiffen := 0.0 # get_motion_data().stiffen_timefStiffenTime
var _hit_external_force := 0.0 # get_motion_data().external_forcefExternalForce
var _hit_dedup := {} # m_HitDataMap{ window_idx:int -> { victim_vid:int -> 本地时间 + fInvisibleTime } }
var _motion_splash := {} # CActorInstance::m_kSplashArea(本地主角)
var _remote_motion_splashes := {} # vid:int -> CActorInstance::m_kSplashArea(远端 actor
var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}]
var _victim_invisible_until := {} # 受击方 m_fInvisibleTime{ vid -> 秒(_now()}
var _def_last := {} # 防御球 v3LastPosition{ vid -> {frame, pos: [Vector3 cm]} }
@@ -297,16 +325,12 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, au
client.mount_changed.connect(func(v):
if v == _main_vid: _refresh_motion_mode()) # §3.5 修改 6:上/下马 → HORSE_*
client.points_changed.connect(_on_points)
if client.has_signal("point_changed"):
client.point_changed.connect(_on_point_changed)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(_on_entity_gone)
client.entity_dead.connect(func(v):
if v == _target_vid:
if is_instance_valid(net_world) and net_world.has_method("_entity_pos"):
var mpos: Vector3 = net_world._entity_pos(v)
if mpos != Vector3.INF:
_last_dead_mob_pos = mpos
_clear_target())
client.entity_dead.connect(_on_entity_dead)
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
@@ -364,6 +388,72 @@ func on_manual_move() -> bool:
_clear_auto_attack()
return cancel_fishing()
# CPythonPlayer::Clear() 的 Loading 等价入口。
# 这些状态属于当前 Actor/动作生命周期,不能跟着 NetPlay 节点跨 GC_WARP、重连或
# 新的 GC_MAIN_CHARACTER 保留;账号级物品、任务和朋友数据仍由 EntityStore 保留。
func clear_for_map_change() -> void:
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
# CPythonPlayer::Clear / SetMainCharacterIndex 会清掉只属于旧主角生命周期
# 的 m_isOpenPrivateShop 与 m_bisProcessingEmotion,不能让换图期间的旧
# entity_info 延迟包继续挡住新角色输入。
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(false)
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(false)
_target_vid = 0
_main_vid = 0
_main_dead = false
_last_sent_pos = Vector3.ZERO
_last_moving_sent_t = 0.0
_last_waiting_sent_t = 0.0
_was_moving = false
_attack_cd = 0.0
_last_click_actor_t = -10.0
_last_click_actor_vid = 0
_target_end_time = 0.0
_knock_down = false
_using_skill = false
_skill_cast_start_time = -1.0
_skill_cast_duration = 0.0
_skill_motion_driven = false
_skill_cancel_enable = false
_combo_type = 0
_clear_combo()
_local_time = 0.0
_delay = 0.0
_motion_start_t = 0.0
_swing_skill = 0
_has_attack_motion_data = false
_hit_windows = []
_hit_dedup.clear()
_motion_splash.clear()
_remote_motion_splashes.clear()
_victim_flush.clear()
_victim_invisible_until.clear()
_def_last.clear()
_atk_frame = 0
_owner_seen.clear()
_attack_key_down = false
_fishing_active = false
_last_fishing_cancel_t = -1e9
_last_level = -1
_last_exp = -1
_last_dead_mob_pos = Vector3.INF
_arrow_count = -1
_bow_distance_bonus = 0.0
_dash_active = false
_charge_skill = false
_clear_reserved()
_clear_auto_attack()
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
if target_ui and target_ui.has_method("close"):
target_ui.close()
# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。
# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。
func activate_fishing() -> String:
@@ -471,14 +561,17 @@ func _on_motion_bound(_state: String) -> void:
_is_attacking = false
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) / InterceptOnceMotion
# 40250 PythonPlayerSkill.cpp:751: __ClearReservedAction(); __ClearAutoAttackTargetActorID(); NEW_Stop();
func start_skill_cast(duration: float = 2.0, is_charge: bool = false, cancel_enable: bool = false) -> void:
func start_skill_cast(duration: float = 2.0, is_charge: bool = false,
cancel_enable: bool = false, motion_driven: bool = false) -> void:
_using_skill = true
_charge_skill = is_charge
_skill_cancel_enable = cancel_enable
_skill_motion_driven = motion_driven
_skill_cast_start_time = _local_time
_skill_cast_duration = duration if duration > 0.0 else 2.0
_clear_reserved()
@@ -803,8 +896,15 @@ func on_use_skill(motion_skill: int, arg: int) -> void:
func _on_main_set(vid: int) -> void:
_main_vid = vid
_main_dead = false
_clear_reserved()
_clear_auto_attack()
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(false)
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(false)
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
if pc and pc.has_method("stop"):
pc.stop()
if client and client.has_method("get_entity"):
@@ -822,11 +922,51 @@ func _on_entity_info(vid: int, entity: Dictionary) -> void:
var speed := int(entity.get("moving_speed", 0))
if speed > 0:
pc.set_server_speed(speed)
# 40250 BINARY_PrivateShop_Appear/Disappear 最终通过主角状态落地到
# CPythonPlayer::m_isOpenPrivateShopGC_SHOP_SIGN 是当前实体的权威
# 摆摊标志。只对主角读取,避免打开别人的商店时锁住本地输入。
if pc and pc.has_method("set_private_shop_open"):
pc.set_private_shop_open(String(entity.get("shop_sign", "")) != "")
# 主角情绪动作同样由实体的 acting_emotion 状态闭合到
# CPythonPlayer::m_bisProcessingEmotion。方向键在动作期间保留,动作尾
# 到达后下一帧恢复,和原客户端 Update() 的 m_isDirKey 重试一致。
if pc and pc.has_method("set_processing_emotion"):
pc.set_processing_emotion(bool(entity.get("acting_emotion", false)))
_apply_attack_speed(int(entity.get("attack_speed", 0)))
if entity.has("race"):
combo_class = int(entity.get("race", 0)) & 3 # §3.5 连击段表按职业分块
_refresh_motion_mode() # §3.5 修改 6parts[WEAPON] / 骑乘 / 变身变化 → combo_motion_mode
func _on_entity_dead(vid: int) -> void:
if vid == _target_vid:
if is_instance_valid(net_world) and net_world.has_method("_entity_pos"):
var mpos: Vector3 = net_world._entity_pos(vid)
if mpos != Vector3.INF:
_last_dead_mob_pos = mpos
_clear_target_with_packet(true)
if vid != _main_vid:
return
_main_dead = true
_was_moving = false
_attack_cd = 0.0
_attack_key_down = false
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cast_start_time = -1.0
_skill_cast_duration = 0.0
_skill_cancel_enable = false
_knock_down = false
_fishing_active = false
_clear_reserved()
_clear_auto_attack()
if pc:
if pc.has_method("set_dead"):
pc.set_dead(true)
elif pc.has_method("stop"):
pc.stop()
pc.set("frozen", true)
func _apply_attack_speed(speed: int) -> void:
# `CInstanceBase::SetAttackSpeed`bAttackSpeed / 100 只缩放动作播放速率,
# 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。
@@ -910,8 +1050,9 @@ func _on_points(p: Dictionary) -> void:
_last_level = cur_level
var cur_exp := int(p.get("exp", 0))
if _last_exp >= 0 and cur_exp > _last_exp:
var delta_exp := cur_exp - _last_exp
var packet_gain := int(p.get("exp_gain", 0))
var delta_exp := packet_gain if packet_gain > 0 else (cur_exp - _last_exp if _last_exp >= 0 else 0)
if delta_exp > 0:
_trigger_exp_gain(delta_exp, p)
_last_exp = cur_exp
@@ -929,6 +1070,31 @@ func _on_points(p: Dictionary) -> void:
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
# PythonNetworkStreamPhaseGame::RecvPointChange — this is intentionally a
# separate path from the aggregate points_changed snapshot. 40250 first
# dispatches ShowPointEffect(type, vid), then updates the main point table and
# refreshes status/skill windows according to the point type.
func _on_point_changed(vid: int, point_type: int, _value: int, amount: int) -> void:
if point_type == POINT_GOLD and amount > 0 and vid == _main_vid:
money_picked.emit(amount)
if hud and hud.has_method("on_pick_money"):
hud.on_pick_money(amount)
if vid != _main_vid:
return
match point_type:
POINT_STAT_RESET_COUNT, POINT_ENERGY:
status_refresh_requested.emit()
POINT_LEVEL, POINT_ST, POINT_HT, POINT_DX, POINT_IQ:
status_refresh_requested.emit()
skill_refresh_requested.emit()
POINT_SUB_SKILL, POINT_SKILL, POINT_HORSE_SKILL:
skill_refresh_requested.emit()
_:
# RecvPointChange's default branch calls __RefreshStatus().
status_refresh_requested.emit()
func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
@@ -944,8 +1110,15 @@ func _trigger_level_up(new_level: int, p: Dictionary) -> void:
level_up.emit(new_level)
print("[升级] 恭喜!等级提升至 Lv. %d" % new_level)
# 1. 40250 EFFECT_LEVELUP 冲天金光特效
_spawn_level_up_effect()
# 1. 40250 EFFECT_LEVELUP 冲天金光特效。真实网络链路由
# NetWorld._on_point_changed 按注册的 level_up.mse 播放;保留几何光柱
# 作为无特效注册表的 headless / 旧适配器回退,避免测试和轻量宿主丢反馈。
var has_exact_effect: bool = false
if net_world != null and net_world.has_method("_on_point_changed") \
and fx != null and fx.has_method("spawn"):
has_exact_effect = true
if not has_exact_effect:
_spawn_level_up_effect()
# 2. 40250 播放专属升级号角音效
if audio and audio.has_method("play_ui"):
@@ -1158,7 +1331,12 @@ func set_lcontrol_down(down: bool) -> void:
_lcontrol_down = down
func _clear_target() -> void:
_clear_target_with_packet(false)
func _clear_target_with_packet(send_packet: bool) -> void:
if _target_vid == 0:
if send_packet and client and client.has_method("set_target"):
client.set_target(0)
return
_auto_attack_vid = 0
_target_vid = 0
@@ -1166,6 +1344,8 @@ func _clear_target() -> void:
if pc and pc.has_method("stop"):
pc.stop()
target_changed.emit(0)
if send_packet and client and client.has_method("set_target"):
client.set_target(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
if target_ui and target_ui.has_method("close"):
@@ -1217,7 +1397,7 @@ func _main_entity() -> Dictionary:
# CanProcessNetworkStatePacket:死亡 / 晕眩 / 击退中都不接受状态输入(≈ __IsSyncing)。
func _can_process_network_state() -> bool:
if _knock_down:
if _main_dead or _knock_down:
return false
var e := _main_entity()
return not (bool(e.get("dead", false)) or bool(e.get("stunned", false)) \
@@ -1288,6 +1468,18 @@ func get_front_instance(f_distance_cm: float = 2000.0) -> int:
func skill_context() -> Dictionary:
var e := _main_entity()
var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false))
var parts: Array = e.get("parts", []) if e.get("parts", []) is Array else []
var weapon_part := int(parts[1]) if parts.size() > 1 else 0
var polymorphed := EntityRules._is_poly(e)
var wearing_dress := bool(e.get("wedding_dress", false)) or int(e.get("shape", 0)) == 201
var holding_pickaxe := bool(e.get("holding_pickaxe", false)) \
or (weapon_part >= 29101 and weapon_part <= 29110)
var guild_war_active := false
if client and client.has_method("get_guild_war"):
var war: Dictionary = client.get_guild_war()
# Packet.h: GUILD_WAR_ON_WAR = 6; other non-zero states are setup/list
# notifications, not the IsDoingGuildWar() state used by UseGuildSkill.
guild_war_active = int(war.get("state", 0)) == 6
var wsub := -1
var vnum := _equipped_weapon_vnum()
if vnum > 0 and proto and proto.has_method("item"):
@@ -1310,6 +1502,12 @@ func skill_context() -> Dictionary:
"is_fishing": combo_motion_mode == MOTION_MODE_FISHING,
"is_using_skill": _using_skill,
"can_act": _can_process_network_state(),
"polymorphed": polymorphed,
"wearing_dress": wearing_dress,
"holding_pickaxe": holding_pickaxe,
"horse_can_use": bool(e.get("horse_can_use_skill", true)),
"graphic_can_use": bool(e.get("graphic_can_use_skill", true)),
"guild_war_active": guild_war_active,
"level_limit": true,
"cooltime_check": true,
"dash_active": _dash_active,
@@ -1347,9 +1545,12 @@ func is_lock() -> bool:
# 40250 ActorInstanceMotion.cpp:541: if (m_pkCurRaceMotionData->IsCancelEnableSkill()) return FALSE;
if _skill_cancel_enable:
return false
if _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0 and (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
if not _skill_motion_driven and _skill_cast_duration > 0.0 \
and _skill_cast_start_time >= 0.0 \
and (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
if player_view and player_view.has_method("reset_skill_motion"):
player_view.reset_skill_motion()
@@ -1371,14 +1572,18 @@ func is_lock() -> bool:
func _process(dt: float) -> void:
_advance_local_time(dt)
if _using_skill and _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0:
_remote_splash_attack_process()
if _using_skill and not _skill_motion_driven and _skill_cast_duration > 0.0 \
and _skill_cast_start_time >= 0.0:
if (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
if not _can_process_network_state():
_using_skill = false
_charge_skill = false
_skill_motion_driven = false
_skill_cancel_enable = false
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
@@ -1396,6 +1601,7 @@ func _process(dt: float) -> void:
_reserved_delay -= dt
_combo_process()
_attack_process(dt) # AttackProcess:命中窗几何判定 -> OnHit
_splash_attack_process(dt) # SPECIAL_ATTACKINGSplashArea -> OnHit
_update_auto_attack()
_refresh_reserved()
_tick_on_waiting()
@@ -1625,7 +1831,7 @@ func _reserve_process_click_item() -> void:
if item_pos == null:
_clear_reserved()
return
if pc.player.global_position.distance_to(item_pos) < CLICK_ITEM_PICKUP_CM / CM:
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)
@@ -1751,24 +1957,66 @@ func _is_huge_race(race: int) -> bool:
return false
return int(m.get("size", 0)) >= HUGE_RACE_SIZE or int(m.get("rank", 0)) >= HUGE_RACE_RANK
# __ChangeTargetToPickedInstancePythonPlayerInput.cpp:186)→ SetTarget:130):
# 可选中就选中并发 CG_TARGET,否则清空目标并发 CG_TARGET(0)。这里「目标」限定为战斗
# 目标(PC / 怪)——NPC / 石头 / warp 的交互在 _on_pick 里已分流到 click_npc
# (对齐 __OnClickActor 的拆分)。
func _change_target_to_picked_instance(vid: int) -> bool:
# CActorInstance::CanChangeTargetActorInstanceFly.cpp:111):当前动作含
# WARP/FLY/EFFECT_TO_TARGET 事件时不能换目标。get_events() 是当前 .msa 的原始事件表;
# 无动作资源时沿用参考端「没有 motion data -> 需要飞行目标动作」的保守结果。
func _motion_blocks_target_change() -> bool:
var view := _combat_view()
var anim = view.get("anim") if view and "anim" in view else null
if anim and anim.has_method("get_events"):
var events: Variant = anim.call("get_events")
if events is Array:
for event in events:
if event is Dictionary and int(event.get("type", -1)) in [6, 9, 10]:
return true
return false
# The reference returns true when the current motion data is unavailable.
return _using_skill or combo_motion_mode == MOTION_MODE_FISHING
func _can_change_target() -> bool:
return not _motion_blocks_target_change()
func _is_targetable_instance(e: Dictionary) -> bool:
if e.is_empty():
return false
var target := e.duplicate()
target["kind"] = _entity_kind(e)
# This 40250 build's __MainCanSeeHiddenThing() returns false. Keep the
# context explicit so a future detector/GM rule cannot accidentally turn
# stealth into a client-only visual check.
return EntityRules.can_pick_instance(target, {"can_see_hidden": false})
# __ChangeTargetToPickedInstancePythonPlayerInput.cpp:186)→ SetTarget:112):
# 先过 CanChangeTarget,再过同目标时间窗和 IsTargetableInstance/CanPickInstance
# 失败统一发 CG_TARGET(0),成功才发 CG_TARGET(vid)。force_change 对应 SetTarget
# 的 bForceChange(点击/脚本为 TRUE,受击自动换目标为 FALSE)。
func _change_target_to_picked_instance(vid: int, force_change := true) -> bool:
if not _can_change_target():
return false
var now := _now()
if vid == _target_vid:
# SetTarget(main VID) 是清除主角自选目标的特殊分支;其它重复点击
# 只延长 1 秒保护窗,不重复发送 CG_TARGET。
if vid == _main_vid:
_clear_target_with_packet(true)
return false
_target_end_time = now + 1.0
return true
if not force_change and _target_end_time > now:
return false
_target_end_time = now + (2.0 if force_change else 1.0)
var e: Dictionary = client.get_entity(vid)
var kind := _entity_kind(e)
if e.is_empty() or bool(e.get("dead", false)) or kind not in [
EntityRules.KIND_PC,
EntityRules.KIND_MONSTER,
EntityRules.KIND_STONE,
EntityRules.KIND_WOODEN_DOOR,
]:
_target_vid = 0
client.set_target(0)
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
# __ChangeTargetToPickedInstance is only used by the combat/PC target
# paths here; NPC/warp interaction is owned by __OnClickActor's click-NPC
# branch. The targetability gate still runs before this higher-level kind
# filter, matching SetTarget's CanPickInstance contract without turning an
# NPC click into a combat target.
if kind not in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER,
EntityRules.KIND_STONE, EntityRules.KIND_WOODEN_DOOR] \
or not _is_targetable_instance(e):
_clear_target_with_packet(true)
return false
_target_vid = vid
client.set_target(vid)
@@ -1997,28 +2245,15 @@ func _combo_process() -> void:
_is_next_pre_input = false
# OnAttack 状态包 + 缓存命中窗 + 播动作 + 刷新节奏。§3.5
# OnHitSendAttackPacket)不在这里:有 `.msa` 命中窗时它由 `_attack_process()` 的几何判定
# 逐帧触发(1:1 对齐 `__ProcessDataAttackSuccess` → `OnHit`);只有拿不到命中窗数据
# (headless / 缺资源)才在此退化为立即发一包。
# OnHitSendAttackPacket)不在这里:它只能由 `_attack_process()` 按有效 `.msa`
# AttackingData/HitDataContainer 的几何判定触发(1:1 对齐 `isValidAttacking` →
# `__NormalAttackProcess` → `__ProcessDataAttackSuccess` → `OnHit`)。40250 在当前
# 动作没有有效 `m_pkCurRaceMotionData` 或 `AttackingData` 时不会合成一包 CG_ATTACK。
func _emit_swing(motion_index: int, is_combo: bool) -> void:
# 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(在
# CActorInstance::__SetMotion 里 __IsNeedFlyTargetMotion() 为真时触发,
# ActorInstanceMotion.cpp:372):立即发 CG_FLY_TARGETING(目标VID, 目标包围球中心)。
# OnShoot(uSkill) 则要等 `.msa` FLY 帧,交给 game_scenebow_shot_fired → 入队)。§3.6
if _is_bow_mode():
# __CanShot 关:无箭 / 无目标 / 出弓程 → 走 OnNoArrow / OnNoTarget 文案,
# 不发 CG_FLY_TARGETING、不排 OnShoot(其余起手动作照常,seam:参考端整挥击中止)。
var why := _can_shot()
if why != "":
_emit_cannot(why)
else:
_send_fly_target()
bow_shot_fired.emit(_swing_skill)
# OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号
# PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段
# 动作,命中窗 / 连击时间才读得到这一段的 .msa。
# 40250 CActorInstance::ComboAttack 的顺序是:先 InterceptOnceMotion(绑定
# motion、清 m_HitDataMap、触发弓箭 OnSetFlyTarget),再 __OnAttack(发送
# FUNC_COMBO)。此前这里先发状态包再绑定动作,服务端收到状态时本地命中窗尚未
# 建立,和参考端的动作/网络事件顺序相反。
var view := _combat_view()
var motion_played := false
if view and view.has_method("play_attack_motion"):
@@ -2028,16 +2263,17 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
if not motion_played and view and view.has_method("set_anim_state"):
view.set_anim_state("combo" if is_combo else "attack")
_begin_hit_windows()
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
# PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
var target := _target_vid if _target_vid != 0 else _vid_reserved
if target == 0 and _auto_attack_vid != 0:
target = _auto_attack_vid
if target == 0:
target = get_front_instance(250.0)
if target != 0:
client.attack(_swing_skill, target)
# 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget。
# OnShoot(uSkill) 仍等 `.msa` FLY 帧,由 game_scene 入队发送。§3.6
if _is_bow_mode():
var why := _can_shot()
if why != "":
_emit_cannot(why)
else:
_send_fly_target()
bow_shot_fired.emit(_swing_skill)
# OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号。
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
@@ -2049,20 +2285,28 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
# 坐标与角色脚点一致,而 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"):
if vid == 0 or client == null or net_world == null:
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))
# CNormalBowAttack_FlyEventHandler sends OnGetFlyTargetPosition(), which is
# the model body-sphere centre rather than the actor feet/root position.
var sp := _server_xy(FlyTargetAnchor.fly_target_position(tnode))
# 40250 uses SendFlyTargetingPacket here. CG_ADD_FLY_TARGETING is reserved
# for the extra targets in PythonPlayerSkill::__UseSkill.
if client.has_method("fly_targeting"):
client.fly_targeting(vid, int(sp.x), int(sp.y))
elif client.has_method("add_fly_targeting"):
# Compatibility for headless/test clients predating the split API.
client.add_fly_targeting(vid, int(sp.x), int(sp.y))
# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackDataTHitDataContainer + iMotionType +
# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce),
# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。
func _begin_hit_windows() -> void:
var md := _motion_data()
_has_attack_motion_data = bool(md.get("has_attacking_data", false))
var w: Variant = md.get("hit_windows", [])
_hit_windows = w if w is Array else []
_hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE))
@@ -2075,12 +2319,173 @@ func _begin_hit_windows() -> void:
_motion_start_t = _local_time
_is_attacking = true
# ActorInstanceMotionEvent.cpp::ProcessMotionEventSpecialAttacking:事件帧只负责
# 建立 / 替换当前 actor 的 SplashArea;真正的目标遍历发生在下一次 Update 的
# AttackProcess 中。这样 DuringTime、EnableHitProcess、uSkill 和 CollisionData
# 都属于同一个动作事件,而不是被重新合成成一个普通攻击窗。
func start_motion_splash(event: Dictionary) -> void:
if int(event.get("type", -1)) != 4 or pc == null or pc.player == null:
return
_motion_splash = MotionSplash.begin(_main_vid, _swing_skill,
_world_pos(pc.player), _player_yaw(), event, _local_time)
# CActorInstance::MotionEventProcess / InstanceBase::AttackProcess runs for
# every visible actor. Remote actors use the same collision data, but their
# event handler is not CPythonPlayerEventHandler: the local client must never
# synthesize a CG_ATTACK from a remote splash hit.
func start_remote_motion_splash(owner_vid: int, event: Dictionary) -> void:
if int(event.get("type", -1)) != 4 or owner_vid <= 0 or owner_vid == _main_vid:
return
if net_world == null or client == null:
return
var actor: Node3D = net_world.node_for(owner_vid) if net_world.has_method("node_for") else null
if not is_instance_valid(actor):
return
var owner_e: Dictionary = client.get_entity(owner_vid) if client.has_method("get_entity") else {}
if owner_e.is_empty() or bool(owner_e.get("dead", false)) or bool(owner_e.get("in_safe", false)):
return
var skill := int(event.get("skill_index", actor.get_meta("motion_skill_index", 0)))
_remote_motion_splashes[owner_vid] = MotionSplash.begin(owner_vid, skill,
_world_pos(actor), actor.rotation.y, event, _local_time)
func _remote_actor_node(vid: int) -> Node3D:
if vid == _main_vid and pc != null and is_instance_valid(pc.player):
return pc.player
if net_world != null and net_world.has_method("node_for"):
return net_world.node_for(vid)
return null
func _remote_entity(vid: int) -> Dictionary:
if vid == _main_vid:
return _main_entity()
return client.get_entity(vid) if client and client.has_method("get_entity") else {}
func _is_attackable_from(attacker: Dictionary, victim: Dictionary) -> bool:
if attacker.is_empty() or victim.is_empty() or bool(victim.get("dead", false)):
return false
var self_e := attacker.duplicate()
self_e["kind"] = _entity_kind(attacker)
var victim_e := victim.duplicate()
victim_e["kind"] = _entity_kind(victim)
return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e))
func _remote_splash_attack_process() -> void:
if _remote_motion_splashes.is_empty() or client == null or net_world == null:
return
for owner_vid in _remote_motion_splashes.keys().duplicate():
var area: Dictionary = _remote_motion_splashes.get(owner_vid, {})
if not MotionSplash.is_active(area, _local_time):
_remote_motion_splashes.erase(owner_vid)
continue
var attacker_vid := int(owner_vid)
var attacker := _remote_entity(attacker_vid)
var attacker_node := _remote_actor_node(attacker_vid)
if attacker.is_empty() or not is_instance_valid(attacker_node) \
or bool(attacker.get("dead", false)) or bool(attacker.get("in_safe", false)):
_remote_motion_splashes.erase(attacker_vid)
continue
var attacker_cm := _actor_cm(_world_pos(attacker_node))
for victim in client.get_entities():
if not victim is Dictionary:
continue
var victim_vid := int(victim.get("vid", 0))
if victim_vid <= 0 or victim_vid == attacker_vid or not _is_attackable_from(attacker, victim):
continue
var victim_node := _remote_actor_node(victim_vid)
if not is_instance_valid(victim_node):
continue
if _now() < float(_victim_invisible_until.get(victim_vid, 0.0)):
continue
var victim_cm := _actor_cm(_world_pos(victim_node))
var dx := victim_cm.x - attacker_cm.x
var dz := victim_cm.z - attacker_cm.z
if dx * dx + 2.0 * dz * dz >= 1000.0 * 1000.0:
continue
if int(area.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \
and _entity_kind(victim) == EntityRules.KIND_PC \
and victim_vid != int(attacker_node.get_meta("fly_target_vid", 0)):
continue
var defenders := _defending_spheres(victim_vid, victim_node, victim_cm)
if defenders.is_empty():
continue
var result := MotionSplash.try_hit(area, victim_vid, defenders, _local_time)
if not bool(result.get("hit", false)):
continue
_process_attack_success(victim, victim_vid, victim_node,
result.get("hit_position", (attacker_cm + victim_cm) * 0.5),
HitCollision.is_huge_race(int(victim.get("race", 0))),
int(area.get("hitting_type", HIT_TYPE_NONE)),
float(area.get("stiffen_time", 0.0)),
float(area.get("invisible_time", 0.0)),
float(area.get("external_force", 0.0)),
int(area.get("skill", 0)), false, attacker_node, attacker_vid)
# CInstanceBase::AttackProcess -> CActorInstance::__SplashAttackProcess。
# 40250 只在主角色的本地输入处理器里把 isEnableHitProcess=true 的命中转成
# CG_ATTACKEnableHitProcess=false 仍然执行命中特效 / 硬直 / 击退,但不伪造上行包。
func _splash_attack_process(_dt: float) -> void:
if _motion_splash.is_empty():
return
if not MotionSplash.is_active(_motion_splash, _local_time):
_motion_splash.clear()
return
if client == null or not client.is_in_game() or pc == null or pc.player == null \
or net_world == null:
return
if bool(_main_entity().get("in_safe", false)):
return
var attacker_pos := _actor_cm(_world_pos(pc.player))
var dx_limit := 1000.0 # __SplashAttackProcess: 1000cm broad-phase limit
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _main_vid:
continue
if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)):
continue
var vnode: Node3D = net_world.node_for(vid)
if vnode == null or not is_instance_valid(vnode):
continue
# AttackingProcess 的 __isInvisible() 门;命中的受击方在
# __ProcessDataAttackSuccess 中写入 m_fInvisibleTime。
if _now() < float(_victim_invisible_until.get(vid, 0.0)):
continue
var victim_pos := _actor_cm(_world_pos(vnode))
# 参考端这里的 v3Distance 是 (dx, dz, dz),保持其出货行为。
var dx := victim_pos.x - attacker_pos.x
var dz := victim_pos.z - attacker_pos.z
if dx * dx + 2.0 * dz * dz >= dx_limit * dx_limit:
continue
# ATTACK_TYPE_SNIPE 对 PC 只接受当前飞行目标;PVE 目标不受这条
# PC 专用限制影响。
if int(_motion_splash.get("attack_type", 0)) == MotionSplash.ATTACK_TYPE_SNIPE \
and _entity_kind(e) == EntityRules.KIND_PC and vid != _target_vid:
continue
var defenders := _defending_spheres(vid, vnode, victim_pos)
if defenders.is_empty():
continue
var result: Dictionary = MotionSplash.try_hit(_motion_splash, vid, defenders, _local_time)
if not bool(result.get("hit", false)):
continue
var hit_pos: Vector3 = result.get("hit_position", (attacker_pos + victim_pos) * 0.5)
_process_attack_success(e, vid, vnode, hit_pos,
HitCollision.is_huge_race(int(e.get("race", 0))),
int(_motion_splash.get("hitting_type", HIT_TYPE_NONE)),
float(_motion_splash.get("stiffen_time", 0.0)),
float(_motion_splash.get("invisible_time", 0.0)),
float(_motion_splash.get("external_force", 0.0)),
int(_motion_splash.get("skill", 0)),
bool(_motion_splash.get("enable_hit_process", true)))
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即
# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。
func _attack_process(dt: float) -> void:
if _hit_windows.is_empty() or client == null or not client.is_in_game():
# CActorInstance::isValidAttacking() rejects a missing current motion or a motion
# without AttackingData before __NormalAttackProcess reads HitDataContainer.
# An existing AttackingData with zero windows also produces no local hit; it is
# intentionally different from the old synthetic forward-cone fallback.
if not _has_attack_motion_data or _hit_windows.is_empty() or client == null or not client.is_in_game():
return
if pc == null or pc.player == null or net_world == null:
return
@@ -2127,6 +2532,23 @@ static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
var s := sin(yaw)
return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
func _attack_reach_scale() -> float:
# CActorInstance::__GetReachScale:当前视图持有 AFFECT_GEOMGYEONG
# 的 m_fReachScale;没有模型(headless/旧测试桩)时保持默认 1.0。
if player_view != null and player_view.has_method("weapon_reach_scale"):
return maxf(float(player_view.call("weapon_reach_scale")), 0.0)
return 1.0
func _attack_sample_positions(last_pos: Vector3, pos: Vector3, yaw: float,
actor_pos: Vector3) -> Dictionary:
# ActorInstanceCollisionDetection.cpp 只缩放刀尖球从 last 到 current 的
# 位移向量,防御球的 last 点和攻击者位置不缩放。
var scaled_pos := last_pos + (pos - last_pos) * _attack_reach_scale()
return {
"last": _rot_cm(last_pos, yaw) + actor_pos,
"pos": _rot_cm(scaled_pos, yaw) + actor_pos,
}
func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float,
t: float, dt: float) -> void:
var v_pos := _actor_cm(_world_pos(vnode))
@@ -2139,6 +2561,11 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
if dist2 >= lim * lim:
return
var spheres := _defending_spheres(vid, vnode, v_pos)
# CActorInstance::__NormalAttackProcess only iterates the victim's actual
# m_DefendingPointInstanceList. A node without parsed .msm collision data is
# not a valid collision target; do not invent a capsule or a proximity hit.
if spheres.is_empty():
return
var eff_dt := dt * _atk_speed_factor
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
@@ -2157,8 +2584,9 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
continue
var p: Vector3 = smp.get("pos", Vector3.ZERO)
var lp: Vector3 = smp.get("last_pos", p)
var d_pos := _rot_cm(p, yaw) + a_pos
var d_last := _rot_cm(lp, yaw) + a_pos
var sweep := _attack_sample_positions(lp, p, yaw, a_pos)
var d_pos: Vector3 = sweep["pos"]
var d_last: Vector3 = sweep["last"]
for sp in spheres:
if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
@@ -2166,20 +2594,6 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
break
if hit_detected:
break
else:
# 采样点为空时的近战朝向判定(正前方扇面)
var fwd := Vector3(sin(yaw), 0, -cos(yaw))
var to_mob := Vector3(dx, 0, dz).normalized()
if fwd.dot(to_mob) > 0.2:
hit_detected = true
# 近战兜底:如果在 2 米内且正对目标(锥角 ~60 度),在命中窗口内判定命中
if not hit_detected and dist2 < 200.0 * 200.0:
var fwd := Vector3(sin(yaw), 0, -cos(yaw))
var to_mob := Vector3(dx, 0, dz).normalized()
if fwd.dot(to_mob) > 0.3:
hit_detected = true
if hit_detected:
if not _register_hit(wi, vid):
return
@@ -2189,13 +2603,14 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorldActorInstanceCollisionDetection.cpp:66);
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 防御球时返回空,保持
# __NormalAttackProcess 对空 m_DefendingPointInstanceList 的无命中结果。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
var raw: Array = []
if vnode.has_method("get_defending_spheres"):
raw = vnode.get_defending_spheres()
if vnode == null or not vnode.has_method("get_defending_spheres"):
return []
var raw: Array = vnode.get_defending_spheres()
if raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
return []
var vyaw := vnode.rotation.y
var anim: Node = vnode.get("anim") if vnode else null
var model: Node3D = vnode.get("model") as Node3D if vnode else null
@@ -2242,18 +2657,47 @@ func _register_hit(wi: int, vid: int) -> bool:
return m.size() <= cap
# CActorInstance::__ProcessDataAttackSuccessActorInstanceBattle.cpp
func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool) -> void:
func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool,
hit_type_override: int = -1, stiffen_override: float = -1.0,
invisible_override: float = -1.0, external_force_override: float = -1.0,
skill_override: int = -1, send_packet: bool = true,
attacker_override: Node3D = null, attacker_vid: int = 0) -> void:
var old_hit_type := _hit_type
var old_hit_stiffen := _hit_stiffen
var old_hit_invisible := _hit_invisible_time
var old_hit_external_force := _hit_external_force
var old_swing_skill := _swing_skill
if hit_type_override >= 0:
_hit_type = hit_type_override
if stiffen_override >= 0.0:
_hit_stiffen = stiffen_override
if invisible_override >= 0.0:
_hit_invisible_time = invisible_override
if external_force_override >= 0.0:
_hit_external_force = external_force_override
if skill_override >= 0:
_swing_skill = skill_override
if _hit_type == HIT_TYPE_NONE:
_hit_type = old_hit_type
_hit_stiffen = old_hit_stiffen
_hit_invisible_time = old_hit_invisible
_hit_external_force = old_hit_external_force
_swing_skill = old_swing_skill
return
# InsertDelay(fStiffenTime)攻击者本地时间冻结(命中硬直)
_delay = _hit_stiffen
if player_view and player_view.has_method("insert_delay"):
player_view.insert_delay(_hit_stiffen)
var a_g: Vector3 = pc.player.global_position
var v_g := vnode.global_position
# InsertDelay(fStiffenTime)命中硬直属于真正的攻击者;远端攻击不能
# 锁住本地主角的输入时间,只能锁远端 actor 的动作。
var attacker_node: Node3D = attacker_override if is_instance_valid(attacker_override) else pc.player
if attacker_vid == 0 or attacker_vid == _main_vid:
_delay = _hit_stiffen
if player_view and player_view.has_method("insert_delay"):
player_view.insert_delay(_hit_stiffen)
elif is_instance_valid(attacker_node) and attacker_node.has_method("insert_delay"):
attacker_node.insert_delay(_hit_stiffen)
var a_g: Vector3 = _world_pos(attacker_node)
var v_g: Vector3 = _world_pos(vnode)
var to := v_g - a_g
# __PushCircle + IncreaseExternalForce
if _can_push(e, vid, huge) and _hit_external_force > 0.0 and net_world.has_method("push_victim"):
if _can_push(e, vid, huge, attacker_vid) and _hit_external_force > 0.0 and net_world.has_method("push_victim"):
net_world.push_victim(vid, Vector2(to.x, -to.z).normalized(), _hit_external_force)
# 受击方 m_fInvisibleTime = 现在 + fInvisibleTime;受击方 InsertDelay
_victim_invisible_until[vid] = _now() + _hit_invisible_time
@@ -2278,7 +2722,8 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
# __HitStone / __HitGood / __HitGreate
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
var attacker_yaw := attacker_node.rotation.y if is_instance_valid(attacker_node) else _player_yaw()
var scalar := cos(attacker_yaw - vnode.rotation.y)
var resist_fallen := _is_resist_fallen(e)
# 40250 CActorInstance::__Shake(100)
if vnode.has_method("shake"):
@@ -2293,7 +2738,13 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
elif _hit_type == HIT_TYPE_GREAT:
if vnode.has_method("hit_greate"):
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
if send_packet:
_on_hit(vid, huge, true)
_hit_type = old_hit_type
_hit_stiffen = old_hit_stiffen
_hit_invisible_time = old_hit_invisible
_hit_external_force = old_hit_external_force
_swing_skill = old_swing_skill
const AFFECT_CHEONGEUN_BIT := 1 << 16
const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33
@@ -2312,7 +2763,7 @@ func _is_resist_fallen(e: Dictionary) -> bool:
return false
# CActorInstance::__CanPushDestActorActorInstanceBattle.cpp
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
func _can_push(e: Dictionary, vid: int, huge: bool, attacker_vid: int = 0) -> bool:
var kind := _entity_kind(e)
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
return false
@@ -2322,7 +2773,8 @@ func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
return true
var owner := int(e.get("owner_vid", 0))
_note_owner(vid, owner)
if owner != _main_vid:
var source_vid := attacker_vid if attacker_vid != 0 else _main_vid
if owner != source_vid:
return false
return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME
@@ -2334,9 +2786,12 @@ func _note_owner(vid: int, owner: int) -> void:
# CPythonPlayerEventHandler::OnHitSetTarget(victim, FALSE) + SendAttackPacket(uSkill, victim)
# 受击方正被击退且不是巨型 -> 记进帧末 CG_SYNC_POSITION 列表(GetBlendingPosition)。
func _on_hit(vid: int, huge: bool) -> void:
_target_vid = vid
client.attack(_swing_skill, vid)
func _on_hit(vid: int, huge: bool, send_packet: bool = true) -> void:
# CPythonPlayerEventHandler::OnHit uses SetTarget(victim, FALSE), so the
# automatic target update obeys the non-forced time window and target gate.
_change_target_to_picked_instance(vid, false)
if send_packet:
client.attack(_swing_skill, vid)
if huge or not net_world.has_method("is_pushing") or not net_world.is_pushing(vid):
return
var sp := _server_xy(net_world.blending_position(vid))
@@ -2347,7 +2802,9 @@ func _on_entity_gone(vid: int) -> void:
_victim_invisible_until.erase(vid)
_def_last.erase(vid)
if vid == _target_vid:
_clear_target()
# PythonPlayer::NotifyCharacterDead/Update reaches SetTarget(0), which
# also emits the explicit CG_TARGET(0) instead of only hiding the UI.
_clear_target_with_packet(true)
# CPythonPlayerEventHandler::FlushVictimListPythonPlayerEventHandler.cpp:194)——帧末把
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。
@@ -2369,6 +2826,10 @@ func _on_vitals(vid: int) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
if vid == _main_vid and int(e.get("hp", 0)) > 0 and not bool(e.get("dead", false)):
_main_dead = false
if pc and pc.has_method("set_dead"):
pc.set_dead(false)
if vid == _main_vid and hud and hud.has_method("set_vitals"):
hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)),
int(e.get("sp", 0)), int(e.get("max_sp", 0)))