feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+517 -89
View File
@@ -15,6 +15,8 @@
# np.setup(m2client, player_ctl, net_world, hud)
extends Node
signal level_up(new_level: int)
signal target_changed(vid: int)
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。
@@ -46,8 +48,8 @@ const FAN_HALF_ROT_MIN_DISTANCE := 1000.0
# NEW_GetRandomPositionInFanRange:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。
const FAN_RANDOM_JITTER_DEG := 10.0
const FAN_RANDOM_REACH_CM := 8000.0
# MODE_CLICK_ITEM 的到手阈值(PythonPlayerInputMouse.cpp:297 `< 20.0f`,单位 cm)。
const CLICK_ITEM_PICKUP_CM := 20.0
# MODE_CLICK_ITEM 的到手阈值(40250 server item.cpp:435 DistanceValid <= 300cm)。
const CLICK_ITEM_PICKUP_CM := 300.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() 字典)。
@@ -115,6 +117,11 @@ var client: Node # M2Client
var pc: Node # PlayerController
var net_world: Node # NetWorld
var hud: Node # 可空
var target_ui: Node # 40250 uitarget.py TargetBoard (可空)
var audio: Node # 可空
var _last_level := -1
var _last_exp := -1
var _last_dead_mob_pos := Vector3.INF
# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime`
# `CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
@@ -132,9 +139,18 @@ var player_view: Node:
player_view = v
if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound):
v.motion_bound.connect(_on_motion_bound)
if v and v.has_method("set_motion_mode"):
v.set_motion_mode(combo_motion_mode)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
var proto: Node: # Metin2Proto(可空,按 race 分类 NPC/怪 + 武器动作模式
set(v):
proto = v
# GameScene receives the initial entity/equipment burst before item_proto
# finishes loading. Re-evaluate here or a 3150 weapon remains GENERAL until
# the next inventory packet, visually leaving the left hand off the grip.
if v:
_refresh_motion_mode()
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
@@ -200,8 +216,13 @@ var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒)
var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒)
var _was_moving := false
var _attack_cd := 0.0
var _last_click_actor_t := -10.0
var _last_click_actor_vid := 0
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_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控
# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo /
# __OnEndCombo / __ClearCombo
var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shamanrace & 3
@@ -215,6 +236,7 @@ var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array }
var _local_time := 0.0 # GetLocalTime()
var _delay := 0.0 # m_fDelay
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
var _is_attacking := false # 当前是否处于挥击攻击动作中(40250 isLock 门)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
@@ -253,12 +275,15 @@ var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBo
var _dash_active := false # __CheckDashAffect
var _charge_skill := false # 预约技能 IsChargeSkill()
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, audio_node: Node = null) -> void:
client = m2client
pc = player_ctl
net_world = nw
hud = hud_node
audio = audio_node
pc.target_selected.connect(_on_pick)
if pc.has_signal("ground_item_clicked"):
pc.ground_item_clicked.connect(pick_ground_item)
pc.moved.connect(_on_local_moved)
pc.anim_state.connect(_on_anim_state)
client.entity_main_set.connect(_on_main_set)
@@ -274,7 +299,13 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
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: _clear_target())
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())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
@@ -309,6 +340,16 @@ func set_attack_key(down: bool) -> void:
_attack_key_down = down
if not down:
_attack_cd = 0.0
_clear_auto_attack()
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
# 键盘 / 摇杆手动移动:对齐 NEW_MoveToDirection,清预约并取消钓鱼
func on_manual_move() -> bool:
_clear_reserved()
_clear_auto_attack()
return cancel_fishing()
# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。
# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。
@@ -413,11 +454,31 @@ func _on_motion_bound(_state: String) -> void:
_hit_dedup.clear()
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
_combo_index = 0
if _state == "wait":
_is_attacking = false
_using_skill = false
_charge_skill = 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:
_using_skill = true
_charge_skill = is_charge
_skill_cancel_enable = cancel_enable
_skill_cast_start_time = _local_time
_skill_cast_duration = duration if duration > 0.0 else 2.0
_clear_reserved()
_clear_auto_attack()
if pc and pc.has_method("stop"):
pc.stop()
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
_clear_combo()
if player_view and player_view.has_method("set_motion_mode"):
player_view.set_motion_mode(mode)
# §3.5 修改 6 —— CInstanceBase::RefreshStateInstanceBase.cpp:2851)里 SetMotionMode 的分支:
# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE /
@@ -580,16 +641,14 @@ func _entity_kind(e: Dictionary) -> int:
var explicit_kind := int(e.get("kind", -1))
if explicit_kind >= EntityRules.KIND_BUILDING:
return explicit_kind
var ct := int(e.get("ch_type", 0))
if ct != 0:
return ct
var race := int(e.get("race", 0))
if EntityRules._is_wooden_door(e):
return EntityRules.KIND_WOODEN_DOOR
# 优先查 mob_proto:服务端 GC_CHARACTER_ADD 在经典协议 / 部分服务端实现下 bType
# 经常全发 0(导致被客户端转为 MONSTER=2),权威分类必须以 mob_protobType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR)为准。
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
# mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
match int(m.get("type", 0)):
0: return 2
1: return 1
@@ -597,6 +656,11 @@ func _entity_kind(e: Dictionary) -> int:
3: return 4
4: return EntityRules.KIND_WOODEN_DOOR \
if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING
var ct := int(e.get("ch_type", 0))
if ct != 0:
return ct
if explicit_kind >= 0:
return explicit_kind
return 0
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
@@ -677,6 +741,10 @@ func on_use_skill(motion_skill: int, arg: int) -> void:
func _on_main_set(vid: int) -> void:
_main_vid = vid
_clear_reserved()
_clear_auto_attack()
if pc and pc.has_method("stop"):
pc.stop()
if client and client.has_method("get_entity"):
_on_entity_info(vid, client.get_entity(vid))
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
@@ -741,13 +809,50 @@ func _clickable_distance(te: Dictionary) -> float:
if _is_bow_mode():
cm = _bow_range_cm()
match _entity_kind(te):
1: cm = CLICK_DIST_NPC_CM # NPC
1, 4: cm = CLICK_DIST_NPC_CM # NPC / 传送门
3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物
return cm / CM
# 40250 CActorInstance::IsClickableDistanceDestInstance (ActorInstanceBattle.cpp:117)
# 遍历目标防御球,若存在防御球,判断 ||rkSphere.v3Position - kD3DVct3Src|| < rkSphere.fRadius + fDistance
func _is_clickable_distance(tnode: Node3D, te: Dictionary) -> bool:
if pc == null or pc.player == null or tnode == null:
return false
var base_dist := _clickable_distance(te)
var my_pos := _world_pos(pc.player)
var target_pos := _world_pos(tnode)
var flat_dist := Vector2(my_pos.x - target_pos.x, my_pos.z - target_pos.z).length()
if flat_dist <= base_dist:
return true
var spheres := _defending_spheres(int(te.get("vid", 0)), tnode, _actor_cm(target_pos))
if not spheres.is_empty():
for s in spheres:
if s is Dictionary:
var spos_cm: Vector3 = s.get("pos", Vector3.ZERO)
var spos_m := _godot_m(spos_cm)
var srad_m: float = float(s.get("radius", 0.0)) / CM
var d_sph := Vector2(my_pos.x - spos_m.x, my_pos.z - spos_m.z).length()
if d_sph < (base_dist + srad_m):
return true
return false
return flat_dist <= base_dist
func _on_points(p: Dictionary) -> void:
_apply_attack_speed(int(p.get("attack_speed", 0)))
_bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange()
var cur_level := int(p.get("level", 0))
if cur_level > 0:
if _last_level > 0 and cur_level > _last_level:
_trigger_level_up(cur_level, p)
_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
_trigger_exp_gain(delta_exp, p)
_last_exp = cur_exp
if hud == null:
return
if hud.has_method("set_vitals"):
@@ -758,10 +863,78 @@ func _on_points(p: Dictionary) -> void:
if hud.has_method("set_exp"):
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
if hud.has_method("set_level"):
hud.set_level(int(p.get("level", 0)))
hud.set_level(cur_level)
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
# 1. 40250 官方经验聚能光球飞行与吸收
if is_instance_valid(net_world) and net_world.has_method("spawn_exp_fly"):
var spawn_pos := _last_dead_mob_pos
var player_node: Node3D = pc.player if (pc and is_instance_valid(pc.player)) else null
if spawn_pos == Vector3.INF and is_instance_valid(player_node):
# 若无死亡怪物坐标记录,则在玩家正前方 2.5 米处生成光球回吸
var p_pos := player_node.global_position if player_node.is_inside_tree() else player_node.position
var forward := -player_node.global_transform.basis.z if player_node.is_inside_tree() else Vector3(0, 0, -1)
spawn_pos = p_pos + forward * 2.5
if spawn_pos != Vector3.INF:
net_world.spawn_exp_fly(spawn_pos, player_node, 3)
_last_dead_mob_pos = Vector3.INF
# 2. 40250 吸收音效与任务栏反馈
if audio and audio.has_method("play_ui"):
audio.play_ui("money.wav")
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()
# 2. 40250 播放专属升级号角音效
if audio and audio.has_method("play_ui"):
audio.play_ui("levelup1_1.wav")
# 3. 40250 规则:升级瞬间 HP、SP 立即 100% 满状态恢复
var max_hp := int(p.get("max_hp", 0))
var max_sp := int(p.get("max_sp", 0))
if max_hp > 0 and hud and hud.has_method("set_vitals"):
hud.set_vitals(max_hp, max_hp, max_sp, max_sp)
# 4. 通知 HUD 刷新属性加点
if hud and hud.has_method("on_level_up"):
hud.on_level_up(new_level)
func _spawn_level_up_effect() -> void:
var pl: Node3D = pc.player if (pc and "player" in pc) else null
if pl == null or not is_instance_valid(pl):
return
var beam := MeshInstance3D.new()
beam.name = "level_up_beam"
var cyl := CylinderMesh.new()
cyl.top_radius = 0.45
cyl.bottom_radius = 0.65
cyl.height = 7.0
beam.mesh = cyl
beam.position.y = 3.5
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.85, 0.25, 0.75)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.88, 0.3)
beam.material_override = mat
pl.add_child(beam)
var tw := pl.create_tween()
tw.set_parallel(true)
tw.tween_property(beam, "scale:y", 1.4, 1.2)
tw.tween_property(mat, "albedo_color:a", 0.0, 1.2)
tw.chain().tween_callback(beam.queue_free)
func _on_net_moved(vid: int) -> void:
if client == null or vid != _main_vid or pc.player == null:
return
@@ -769,15 +942,39 @@ func _on_net_moved(vid: int) -> void:
if e.is_empty():
return
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
var w: Node = pc.world if (pc and "world" in pc and pc.world) else (net_world.world if (net_world and "world" in net_world) else null)
if w and w.has_method("sample_height"):
sp.y = float(w.call("sample_height", sp.x, sp.z))
else:
sp.y = pc.player.position.y # 无地形时的兜底
var d: float = pc.player.position.distance_to(sp)
# 40250 对齐:CInstanceBase::PushTCPState 对主玩家显式忽略(if (__IsMainInstance()) return)。
# 本地移动由客户端权威推进,常规移动不作平滑插值(避免网络 RTT 滞后引发反拉与按键对抗)。
# 仅在距离超出瞬移阈值时(服务端传送、换图、防作弊重置)直接 Snap 同步。
if d > CORRECTION_SNAP:
pc.player.position = sp
elif d > 0.3:
pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
if pc and pc.has_method("stop"):
pc.stop()
# --- targeting / attack -----------------------------------------------------
static func _world_pos(n: Node3D) -> Vector3:
if n == null:
return Vector3.ZERO
return n.global_position if n.is_inside_tree() else n.position
# 40250 CPythonPlayer::__SendClickActorPacket (PythonPlayer.cpp:1196)
# 节流保护:s_dwNextTCPTime = dwCurTime + 1000(同目标 1 秒内不重复发送点击包,防止狂点刷包)
func _send_click_actor_packet(vid: int) -> bool:
if vid <= 0 or client == null or not client.has_method("click_npc"):
return false
var now := Time.get_ticks_msec() / 1000.0
if vid == _last_click_actor_vid and (now - _last_click_actor_t) < 1.0:
return false
_last_click_actor_t = now
_last_click_actor_vid = vid
return client.click_npc(vid)
func _on_pick(node: Node3D) -> void:
if _observer_mode:
return
@@ -789,24 +986,63 @@ func _on_pick(node: Node3D) -> void:
if _reserved_mode == ReservedMode.USE_SKILL and _dash_active:
_vid_reserved = vid
return
# NPC / 石头 / warp -> 交互,不是攻击目标
var e: Dictionary = client.get_entity(vid)
if _entity_kind(e) in [1, 3, 4]:
client.click_npc(vid)
if e.is_empty():
return
var kind := _entity_kind(e)
var tnode: Node3D = net_world.node_for(vid) if (net_world and net_world.node_for(vid)) else node
# 不可攻击目标(NPC / warp / 同阵营PC等) -> 交互或目标框
if not _is_attackable(e):
_auto_attack_vid = 0
if kind in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
if not _is_clickable_distance(tnode, e):
# 40250 __OnClickActor: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近 NPC)
_reserve_click_actor(vid)
if pc and pc.has_method("walk_to") and tnode:
pc.walk_to(_world_pos(tnode))
return
# 已在交互距离内(<= 5m):停止移动、朝向 NPC、发送点击包(40250 __SendClickActorPacket + NEW_Stop
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.player and tnode:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_send_click_actor_packet(vid)
return
_clear_reserved()
_change_target_to_picked_instance(vid)
if kind == EntityRules.KIND_PC:
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
var nm := _entity_name(e)
if nm != "":
whisper_requested.emit(nm)
return
# --- 40250 __OnPressActor: 可攻击实体(怪物、陨石、敌对PC等)---
_attack_cd = 0.0
_change_target_to_picked_instance(vid)
# game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL
# 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building
# :814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC
# 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别,
# 不满足 PC 分支时不会误触发悄悄话。
if _entity_kind(e) == EntityRules.KIND_PC:
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
var nm := _entity_name(e)
if nm != "":
whisper_requested.emit(nm)
# 40250 __SetAutoAttackTargetActorID: 点击可攻击实体开启持续连击/追击
_auto_attack_vid = vid
_clear_reserved()
if not _can_attack():
return
if not _is_clickable_distance(tnode, e):
# 40250: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近)
_reserve_click_actor(vid)
if pc and pc.has_method("walk_to") and tnode:
pc.walk_to(_world_pos(tnode))
return
# 已在攻击距离内:立即朝向目标出刀
if tnode != null:
_do_attack_swing(tnode, e)
# poc 版 RecvTargetPacketPythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
@@ -839,10 +1075,15 @@ func _on_target_info(vid: int, hp_pct: int) -> void:
if TargetBoard.needs_reset(vid, _target_vid):
_target_vid = vid
target_changed.emit(vid)
var nm := _entity_name(e)
var hp_clamped := TargetBoard.clamp_hp_pct(hp_pct)
if hud and hud.has_method("set_target"):
var nm := _entity_name(e)
hud.set_target(nm if nm != "" else "目标",
TargetBoard.clamp_hp_pct(hp_pct))
hud.set_target(nm if nm != "" else "目标", hp_clamped)
if target_ui and target_ui.has_method("open"):
var lvl := int(e.get("level", -1))
var grade := int(e.get("grade", -1))
target_ui.open(vid, nm if nm != "" else "目标", lvl, grade, is_pc)
target_ui.set_hp(hp_clamped)
TargetBoard.Action.NONE:
pass
@@ -858,23 +1099,27 @@ func set_lcontrol_down(down: bool) -> void:
func _clear_target() -> void:
if _target_vid == 0:
return
_auto_attack_vid = 0
_target_vid = 0
_clear_reserved()
if pc and pc.has_method("stop"):
pc.stop()
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()
# 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。
# 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING)
# 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。
# 受击:40250 RecvDamageInfoPacketPythonNetworkStreamPhaseGame.cpp:2415
# 仅展示伤害飘字和受击动作;普通伤害不晃动摄像机、不锁死移动控制(非 DAMAGE_FLYING 击飞)。
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid != _main_vid:
return
var crit := (flag & DAMAGE_CRITICAL) != 0
if camera and camera.has_method("shake"):
camera.shake(0.08 if crit else 0.04, 10.0)
if (flag & DAMAGE_DODGE) != 0:
return
_knock_down = true
if camera and camera.has_method("shake"):
camera.shake(0.2, 0.5)
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("damage")
@@ -936,6 +1181,46 @@ func _can_use_skill() -> bool:
func is_stunned() -> bool:
return not _can_process_network_state()
# 40250 CInstanceBase::NEW_GetFrontInstance (InstanceBaseBattle.cpp:145)
# 在正前方扇面检索最近的可攻击存活目标(最大射程默认 2000cm / 20m
func get_front_instance(f_distance_cm: float = 2000.0) -> int:
if pc == null or pc.player == null or client == null:
return 0
var my_pos: Vector3 = _world_pos(pc.player)
var my_yaw: float = pc.player.rotation.y
# Godot 空间中 yaw=0 面向 -Z,朝向向量为 (-sin(yaw), 0, -cos(yaw))
var fwd := Vector3(-sin(my_yaw), 0, -cos(my_yaw)).normalized()
var best_vid := 0
var min_dist_m := 1e9
var max_dist_m := f_distance_cm / CM
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 tnode: Node3D = net_world.node_for(vid) if net_world else null
var pos := Vector3.ZERO
if tnode != null:
pos = _world_pos(tnode)
else:
pos = MapCoord.to_world(e.get("pos", my_pos))
var to_target := pos - my_pos
to_target.y = 0.0
var d := to_target.length()
if d > max_dist_m or d < 0.01:
continue
var to_dir := to_target.normalized()
var dot := fwd.dot(to_dir)
# 40250 扇形检测(HALF_FAN_ROT 10°~50°,约 60°~70° 锥角内)
if dot > 0.35:
if d < min_dist_m:
min_dist_m = d
best_vid = vid
return best_vid
# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。
# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* /
# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。
@@ -973,6 +1258,7 @@ func skill_context() -> Dictionary:
"cur_sp": cur_sp,
"bow_distance": int(_bow_distance_bonus),
"target_distance": Callable(self, "skill_target_distance_cm"),
"get_front_instance": Callable(self, "get_front_instance"),
}
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE 200)——施法射程判定与
@@ -983,7 +1269,7 @@ func skill_target_distance_cm(vid: int) -> float:
var tnode: Node3D = net_world.node_for(vid) if net_world else null
if tnode == null:
return -1.0
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
var dist_cm: float = _world_pos(pc.player).distance_to(_world_pos(tnode)) * CM
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
return dist_cm
@@ -992,14 +1278,54 @@ func skill_target_distance_cm(vid: int) -> float:
func is_use_skill_reserved(slot: int) -> bool:
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
# 40250 CActorInstance::isLock()ActorInstanceMotion.cpp:472
# 挥击动作(普攻/连击/技能/钓鱼)期间,严格锁定移动(PythonPlayerInput.cpp:457)。
# 连击段号仅用于连击输入窗口判断,动作播放结束自动切回 wait 状态并解锁。
func is_lock() -> bool:
if _using_skill:
# 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:
_using_skill = false
_charge_skill = false
_skill_cancel_enable = false
if player_view and player_view.has_method("reset_skill_motion"):
player_view.reset_skill_motion()
elif not _charge_skill:
return true
if combo_motion_mode == MOTION_MODE_FISHING:
return true
if _is_attacking:
var md := _motion_data()
var dur := float(md.get("duration", attack_period))
if dur <= 0.0:
dur = attack_period
if _attacking_elapsed() < dur:
return true
else:
_is_attacking = false
_combo_index = 0
return false
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:
if (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
_using_skill = false
_charge_skill = false
_skill_cancel_enable = false
if not _can_process_network_state():
_using_skill = false
_charge_skill = false
_skill_cancel_enable = false
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
and not player_view.is_in_hit_reaction():
_knock_down = false
if pc:
pc.set("frozen", not _can_process_network_state())
pc.set("locked", is_lock())
if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null:
_clear_reserved()
_auto_attack_vid = 0
@@ -1014,32 +1340,39 @@ func _process(dt: float) -> void:
_tick_on_waiting()
_flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION
# CPythonPlayer::__Update_AutoAttack()PythonPlayer.cpp:207——每帧检查 auto-attack 目标
# 是否仍可打,然后 __ReserveClickActor()。本工程用「按住攻击键 + 有目标」当 __OnPressActor(isAuto)。
# CPythonPlayer::__Update_AutoAttack()PythonPlayer.cpp:207
# 以及 NEW_Attack()PythonPlayerInput.cpp:550,空格键自由挥击)
func _update_auto_attack() -> void:
if not _attack_key_down or _target_vid == 0:
_auto_attack_vid = 0
if _attack_key_down:
if _auto_attack_vid == 0 and _target_vid != 0:
_auto_attack_vid = _target_vid
elif _auto_attack_vid == 0 and _target_vid == 0:
# 40250 NEW_Attack(): 无目标按下空格键时朝当前面向自由挥剑
# 连击 / 普攻节奏由动作数据 ComboInputData / 90% duration 自行 gate,不使用外层 cd 吞键
if _can_attack():
_do_attack_swing(null, {})
return
if _auto_attack_vid == 0:
return
_auto_attack_vid = _target_vid
if not _can_process_network_state(): # ≈ !NEW_GetMainActorPtr() / __IsSyncing()
return
if _dash_active or (_using_skill and _charge_skill): # __IsUsingChargeSkill()
return
var v: Dictionary = client.get_entity(_auto_attack_vid)
if v.is_empty():
_clear_auto_attack()
return
if bool(v.get("dead", false)):
if v.is_empty() or bool(v.get("dead", false)):
_clear_auto_attack()
return
if _is_main_mounted() and not _can_attack_horse_level():
_clear_auto_attack()
return
if not _is_attackable(v):
_clear_auto_attack()
return
if bool(_main_entity().get("sleeping", false)):
return
# 参考端每帧无条件 __ReserveClickActor(攻击节奏由动作数据在 swing 处 gate,不在这里)。
# 对齐 40250 CPythonPlayer::__Update_AutoAttack():只要目标有效存活且可攻击,每帧 __ReserveClickActor
if _reserved_mode == ReservedMode.NONE and _can_attack():
_reserve_click_actor(_auto_attack_vid)
@@ -1072,7 +1405,6 @@ func _clear_reserved() -> void:
func _clear_auto_attack() -> void:
_auto_attack_vid = 0
_clear_target()
func _emit_cannot(code: String) -> void:
_last_cannot = code
@@ -1168,15 +1500,23 @@ func _reserve_process_click_actor() -> void:
_clear_reserved()
return
# 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection
var dist: float = pc.player.global_position.distance_to(tnode.global_position)
if dist > _clickable_distance(rv):
if not _is_clickable_distance(tnode, rv):
if pc.has_method("walk_to"):
pc.walk_to(tnode.global_position)
pc.walk_to(_world_pos(tnode))
return
# 3 不可攻击 -> 停 + 发点击包(NPC 对话)+ 清预约
elif pc and pc.has_method("is_going") and pc.is_going():
if pc.has_method("stop"):
pc.stop()
# 3 不可攻击 -> 停 + 朝向 NPC + 发点击包(NPC 对话)+ 清预约
if not _is_attackable(rv):
pc.set("frozen", false)
client.click_npc(_vid_reserved)
if pc and pc.has_method("stop"):
pc.stop()
if pc and pc.player and tnode:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_send_click_actor_packet(_vid_reserved)
_clear_reserved()
return
# 4 目标已死
@@ -1208,9 +1548,8 @@ func _reserve_process_click_actor() -> void:
target_changed.emit(_vid_reserved)
_refresh_target_hud()
# 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。
# 连击 / 普攻节奏由动作数据 gate:cd 未到就本帧不 swing,仍清预约、下帧再来。
if _attack_cd <= 0.0:
_do_attack_swing(tnode, rv)
# 对齐 40250 PythonPlayerInput.cpp:947:直接调用攻击,由动作状态机判定连击推进与输入窗
_do_attack_swing(tnode, rv)
_clear_reserved()
# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case------------
@@ -1284,6 +1623,10 @@ func _invoke_reserved_skill() -> bool:
# NEW_GetGroundItemPositionPythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。
# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。
func _ground_item_world_pos(iid: int):
if pc and "ground_items" in pc and pc.ground_items != null and pc.ground_items.has_method("get_item_world_pos"):
var p = pc.ground_items.get_item_world_pos(iid)
if p != null:
return p
if client == null or not client.has_method("get_ground_items"):
return null
for gi in client.get_ground_items():
@@ -1326,9 +1669,12 @@ func pick_ground_item(iid: int) -> void:
var item_pos = _ground_item_world_pos(iid)
if item_pos == null:
return
if pc.player.global_position.distance_to(item_pos) * CM < CLICK_DIST_RESOURCE_CM:
if pc.player.global_position.distance_to(item_pos) * CM <= CLICK_ITEM_PICKUP_CM:
_send_state(FUNC_WAIT, 0, pc.player.position)
if client.has_method("pickup_item"):
var gi = pc.ground_items if (pc and "ground_items" in pc) else null
if gi != null and gi.has_method("try_pickup_vid"):
gi.try_pickup_vid(iid)
elif client.has_method("pickup_item"):
client.pickup_item(iid)
pc.set("frozen", false)
else:
@@ -1350,7 +1696,13 @@ func _is_huge_race(race: int) -> bool:
# (对齐 __OnClickActor 的拆分)。
func _change_target_to_picked_instance(vid: int) -> bool:
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or bool(e.get("dead", false)) or _entity_kind(e) not in [0, 2]:
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)
@@ -1485,14 +1837,23 @@ func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape:
# NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand
# ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。
func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void:
var to: Vector3 = tnode.global_position - pc.player.global_position
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
if tnode != null and is_instance_valid(tnode) and pc and pc.player:
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
to.y = 0.0
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
_was_moving = false
var vec := _combo_vec()
if vec.is_empty():
# 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 退化为单段普攻。
# 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 对齐 40250 __CanInputNormalAttackCommand
if _is_attacking and _attack_cd > 0.0:
var md := _motion_data()
var dur := float(md.get("duration", attack_period))
if dur <= 0.0:
dur = attack_period
if _attacking_elapsed() <= dur * 0.9:
return
_emit_swing(NAME_NORMAL_ATTACK, false)
return
@@ -1543,6 +1904,7 @@ func _clear_combo() -> void:
_combo_index = 0
_is_pre_input = false
_is_next_pre_input = false
_is_attacking = false
_hit_windows = []
_hit_dedup.clear()
@@ -1604,7 +1966,13 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
# PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
client.attack(_swing_skill, _vid_reserved)
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)
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
@@ -1640,6 +2008,7 @@ func _begin_hit_windows() -> void:
_hit_external_force = float(md.get("external_force", 0.0))
_hit_dedup.clear()
_motion_start_t = _local_time
_is_attacking = true
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
@@ -1652,18 +2021,19 @@ func _attack_process(dt: float) -> void:
return
_atk_frame += 1
var t := _attacking_elapsed()
var eff_dt := dt * _atk_speed_factor
# 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking
var latest_end := 0.0
for w in _hit_windows:
latest_end = maxf(latest_end, float(w.get("end_time", 0.0)))
if t - dt > latest_end:
if t - eff_dt > latest_end:
_hit_windows = []
_hit_dedup.clear()
return
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
var a_pos := _actor_cm(pc.player.global_position)
var a_pos := _actor_cm(_world_pos(pc.player))
var yaw := _player_yaw()
for e in client.get_entities():
var vid := int(e.get("vid", 0))
@@ -1694,7 +2064,7 @@ static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
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(vnode.global_position)
var v_pos := _actor_cm(_world_pos(vnode))
var huge := HitCollision.is_huge_race(int(e.get("race", 0)))
# 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留)
var dx := v_pos.x - a_pos.x
@@ -1704,36 +2074,56 @@ 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)
var eff_dt := dt * _atk_speed_factor
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
if t < float(w.get("start_time", 0.0)) or (t - eff_dt) > float(w.get("end_time", 0.0)):
continue
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
var m: Dictionary = _hit_dedup.get(wi, {})
if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time):
continue
var samples: Variant = w.get("samples", [])
if not (samples is Array):
continue
for smp in samples:
var st := float(smp.get("time", 0.0))
if st < t - dt or st > t:
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
for sp in spheres:
if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
var hit_detected := false
if samples is Array and not (samples as Array).is_empty():
for smp in samples:
var st := float(smp.get("time", 0.0))
if st < t - eff_dt or st > t:
continue
if not _register_hit(wi, vid):
return
var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5
_process_attack_success(e, vid, vnode, hit_pos, huge)
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
for sp in spheres:
if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
hit_detected = true
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
var hit_pos: Vector3 = (a_pos + (spheres[0]["pos"] as Vector3)) * 0.5 if huge and not spheres.is_empty() else (a_pos + v_pos) * 0.5
_process_attack_success(e, vid, vnode, hit_pos, huge)
return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorldActorInstanceCollisionDetection.cpp:66);
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
var raw: Array = []
@@ -1742,9 +2132,26 @@ func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
if raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
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
var cur: Array = []
for sp in raw:
cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
var spos: Vector3 = sp.get("pos", Vector3.ZERO)
var bone: String = sp.get("bone", "")
var sphere_pos_cm := Vector3.ZERO
var has_bone_transform := false
if bone != "" and anim != null and anim.has_method("get_effect_bone_pose") and model != null:
var pose: Dictionary = anim.get_effect_bone_pose(bone)
if pose.has("transform"):
var bone_xform: Transform3D = pose["transform"]
# 40250 ActorInstanceCollisionDetection.cpp:40: matDefending = matBone * matWorld, D3DXVec3TransformCoord
# spos in .msm is in cm, bone_xform / model transform is in meters
var world_m: Vector3 = model.global_transform * (bone_xform * (spos / 100.0))
sphere_pos_cm = _actor_cm(world_m)
has_bone_transform = true
if not has_bone_transform:
sphere_pos_cm = _rot_cm(spos, vyaw) + v_pos
cur.append(sphere_pos_cm)
var prev: Dictionary = _def_last.get(vid, {})
var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size()
var out: Array = []
@@ -1807,10 +2214,15 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
var resist_fallen := _is_resist_fallen(e)
# 40250 CActorInstance::__Shake(100)
if vnode.has_method("shake"):
vnode.shake()
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
elif _hit_type == HIT_TYPE_GOOD:
elif _hit_type == HIT_TYPE_GOOD or resist_fallen:
# 40250 ActorInstanceBattle.cpp:687: if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || rVictim.IsResistFallen())
if vnode.has_method("hit_good"):
vnode.hit_good(scalar, stunned)
elif _hit_type == HIT_TYPE_GREAT:
@@ -1818,12 +2230,28 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
const AFFECT_CHEONGEUN_BIT := 1 << 16
const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33
# 40250 CActorInstance::IsResistFallen (ActorInstance.cpp:405 / InstanceBaseEffect.cpp:891)
# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN)、Boss 或巨型怪物拥有霸体抗击退状态
func _is_resist_fallen(e: Dictionary) -> bool:
var flags: int = int(e.get("affect_flags", 0))
if (flags & AFFECT_CHEONGEUN_BIT) != 0 or (flags & AFFECT_FALLEN_CHEONGEUN_BIT) != 0:
return true
if bool(e.get("resist_fallen", false)):
return true
var race: int = int(e.get("race", 0))
if HitCollision.is_huge_race(race) or _is_huge_race(race):
return true
return false
# CActorInstance::__CanPushDestActorActorInstanceBattle.cpp
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
var kind := _entity_kind(e)
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
return false
if huge:
if huge or _is_resist_fallen(e):
return false
if bool(e.get("stunned", false)):
return true