fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
This commit is contained in:
+568
-107
@@ -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_IsClickableDistanceDestPixelPosition:fDistance > 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 的目标框收包分派(判定树 + CanViewTargetHP)1: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_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 类的每窗命中上限)
|
||||
@@ -249,6 +275,8 @@ var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_type(iHittingType
|
||||
var _hit_stiffen := 0.0 # get_motion_data().stiffen_time(fStiffenTime)
|
||||
var _hit_external_force := 0.0 # get_motion_data().external_force(fExternalForce)
|
||||
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_isOpenPrivateShop;GC_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 修改 6:parts[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_ATTACKING:SplashArea -> 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
|
||||
|
||||
# __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:
|
||||
# CActorInstance::CanChangeTarget(ActorInstanceFly.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})
|
||||
|
||||
# __ChangeTargetToPickedInstance(PythonPlayerInput.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
|
||||
# OnHit(SendAttackPacket)不在这里:有 `.msa` 命中窗时它由 `_attack_process()` 的几何判定
|
||||
# 逐帧触发(1:1 对齐 `__ProcessDataAttackSuccess` → `OnHit`);只有拿不到命中窗数据
|
||||
# (headless / 缺资源)才在此退化为立即发一包。
|
||||
# OnHit(SendAttackPacket)不在这里:它只能由 `_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_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)
|
||||
# __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` 取 TMotionAttackData(THitDataContainer + 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_ATTACK;EnableHitProcess=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::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess ->
|
||||
# __NormalAttackProcess(ActorInstanceCollisionDetection.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 * matWorld,ActorInstanceCollisionDetection.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::__ProcessDataAttackSuccess(ActorInstanceBattle.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::__CanPushDestActor(ActorInstanceBattle.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::OnHit:SetTarget(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::FlushVictimList(PythonPlayerEventHandler.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)))
|
||||
|
||||
Reference in New Issue
Block a user