fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点

- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-12 13:15:28 +09:00
co-authored by Claude Opus 5
parent 96c29d2bb7
commit 1933a5ceda
30 changed files with 2353 additions and 209 deletions
+289 -92
View File
@@ -95,9 +95,21 @@ const MOTION_TYPE_NORMAL := 1
const MOTION_TYPE_COMBO := 2
const MOTION_TYPE_SKILL := 3
const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadiusActorInstanceCollisionDetection.cpp:355
const HIT_VICTIM_RADIUS_CM := 45.0 # 受击方防御圆柱近似半径(真实取自 defending sphere 数据)
const HIT_FRONT_COS := 0.5 # 正面 ±60° 弧内才判定命中(近似动态圆柱扫掠)
const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT
# NRaceData::EHitTypeGameType.h
const HIT_TYPE_NONE := 0
const HIT_TYPE_GREAT := 1
const HIT_TYPE_GOOD := 2
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcessfDistance >= 300² -> FALSE
const HIT_DISTANCE_HUGE_CM := 500.0 # IS_HUGE_RACE(victim)>= 500² -> FALSE
const COMBO_HIT_LIMIT := 16 # MOTION_TYPE_COMBO / NORMAL 每个命中窗最多 16 个目标
const PUSH_OWNER_TIME := 3.0 # __CanPushDestActor__GetOwnerTime() > 3.0f -> 不推
const COMBO_SKILL_ID := 122 # SetComboSkillFlag 的 c_iSkillIndex_Combo
# m_dwBattleHitEffectIDplayersettingmodule.py RegisterCacheEffect(EFFECT_HIT, ...)
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")
var client: Node # M2Client
var pc: Node # PlayerController
@@ -108,10 +120,20 @@ var hud: Node # 可空
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
const DEFAULT_ATTACK_PERIOD := 0.6
var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取
var _atk_speed_factor := 1.0 # bAttackSpeed / 1000.25..3),只缩放播放速率
var _atk_speed_factor := 1.0 # m_fAtkSpd = bAttackSpeed / 100>1100 -> 0),动作 fSpeedRatio
var camera: Node # GameCamera(可空,用于受击抖屏)
var player_view: Node # PlayerView(可空,set_anim_state("damage")
# PlayerView(可空)。motion_bound(state) = __SetMotion 尾(清命中表 / 连击段号)。
var player_view: Node:
set(v):
if is_instance_valid(player_view) and player_view.has_signal("motion_bound") \
and player_view.motion_bound.is_connected(_on_motion_bound):
player_view.motion_bound.disconnect(_on_motion_bound)
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)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
const DAMAGE_DODGE := 1 << 2
@@ -188,18 +210,27 @@ var _combo_type := 0 # m_wcurComboTypeSetComboType;连击技能
var _combo_index := 0 # m_dwcurComboIndex0 = 不在连击中)
var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发)
var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击)
var _combo_started_t := 0.0 # 当前连击段动作起播时刻(GetAttackingElapsedTime 基准)
var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } }
# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsedInsertDelay 冻结(命中硬直)。
var _local_time := 0.0 # GetLocalTime()
var _delay := 0.0 # m_fDelay
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_start_t := 0.0 # 本次挥击动作起播时刻(命中窗时间基准,独立于连击输入计时)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
var _hit_windows := [] # get_motion_data().hit_windowsTHitDataContainer
var _hit_motion_type := 0 # get_motion_data().motion_typeNRaceData::EMotionType
var _hit_limit_count := 0 # get_motion_data().hit_limit_countSKILL 类的每窗命中上限)
var _hit_invisible_time := 0.0 # get_motion_data().invisible_time同目标再命中的冷却
var _hit_dedup := {} # { window_idx:int -> { victim_vid:int -> cooldown_until:float } }
var _hit_invisible_time := 0.0 # get_motion_data().invisible_timefInvisibleTime
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 _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]} }
var _atk_frame := 0 # _attack_process 帧号(判断 _def_last 是否是上一帧)
var _owner_seen := {} # m_fOwnerBaseTime 的客户端观测:{ vid -> [owner_vid, 秒] }
var _observer_mode := false
var _attack_key_down := false
var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角
@@ -242,12 +273,14 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
client.points_changed.connect(_on_points)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(func(v): if v == _target_vid: _clear_target())
client.entity_despawned.connect(_on_entity_gone)
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
if client.has_signal("combo_changed"):
client.combo_changed.connect(_on_combo_changed)
if client.has_signal("observer_mode_changed"):
client.observer_mode_changed.connect(_on_observer_mode)
if client.has_method("is_observer_mode"):
@@ -357,6 +390,30 @@ func set_asset_root(root: String) -> void:
func set_combo_type(t: int) -> void:
_combo_type = clampi(t, 0, 2)
# CPythonPlayer::SetComboSkillFlagPythonPlayerSkill.cpp:916):GC 连击开关到达时查连击技能
# 122 的槽位 / 等级,缺槽或等级 <= 0 直接 return(不改 combo type),否则
# SetComboType(bFlag ? MIN(iLevel, 2) : 0)。
func _on_combo_changed(enabled: bool) -> void:
if client == null or not client.has_method("get_skills"):
return
var level := -1
for skill in client.get_skills():
if int(skill.get("id", 0)) == COMBO_SKILL_ID:
level = int(skill.get("level", 0))
break
if level <= 0:
return
_combo_type = mini(level, 2) if enabled else 0
# CActorInstance::__SetMotion 尾(ActorInstanceMotion.cpp):绑定带 MotionAttackData 的动作 ->
# m_HitDataMap.clear();连击中换到没有 ComboInputData 的动作 -> m_dwcurComboIndex = 0。
func _on_motion_bound(_state: String) -> void:
var md := _motion_data()
if bool(md.get("has_attacking_data", false)):
_hit_dedup.clear()
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
_combo_index = 0
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
@@ -627,6 +684,8 @@ func _on_main_set(vid: int) -> void:
net_world.set_local_vid(vid)
func _on_entity_info(vid: int, entity: Dictionary) -> void:
if entity.has("owner_vid"):
_note_owner(vid, int(entity.get("owner_vid", 0)))
if vid != _main_vid:
return
if pc and pc.has_method("set_server_speed"):
@@ -641,8 +700,9 @@ func _on_entity_info(vid: int, entity: Dictionary) -> void:
func _apply_attack_speed(speed: int) -> void:
# `CInstanceBase::SetAttackSpeed`bAttackSpeed / 100 只缩放动作播放速率,
# 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。
# 出货代码:uAtkSpd > 1100 -> 0,否则 uAtkSpd / 100,没有上下限。speed 0 = 数据未到,保留旧值。
if speed > 0:
_atk_speed_factor = clampf(float(speed) / 100.0, 0.25, 3.0)
_atk_speed_factor = 0.0 if speed > 1100 else float(speed) / 100.0
# 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。
func _current_attack_period() -> float:
@@ -650,8 +710,25 @@ func _current_attack_period() -> float:
var nc := float(_motion_data().get("next_combo", 0.0))
if nc > 0.0:
base = nc
if _atk_speed_factor <= 0.0:
return base # fSpeedRatio 0 = 动作冻结;节奏只作 HUD / 冷却兜底,不除零
return base / _atk_speed_factor
# CGraphicThingInstance::UpdateTimem_fDelay 先吃掉本帧 elapsed,余下才推进本地时间。
func _advance_local_time(dt: float) -> void:
var elapsed := dt
if _delay > elapsed:
_delay -= elapsed
elapsed = 0.0
else:
elapsed -= _delay
_delay = 0.0
_local_time += elapsed
# CActorInstance::GetAttackingElapsedTime(GetLocalTime() - fStartTime) * fSpeedRatio
func _attacking_elapsed() -> float:
return (_local_time - _motion_start_t) * _atk_speed_factor
# CInstanceBase::__GetBowRangeInstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。
func _bow_range_cm() -> float:
return BOW_RANGE_BASE_CM + _bow_distance_bonus
@@ -916,6 +993,7 @@ func is_use_skill_reserved(slot: int) -> bool:
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
func _process(dt: float) -> void:
_advance_local_time(dt)
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
and not player_view.is_in_hit_reaction():
@@ -1002,7 +1080,7 @@ func _emit_cannot(code: String) -> void:
# CInstanceBase::IsAttackableInstanceInstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。
# 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各
# 调用点判:__ReserveProcess_ClickActor 第 4 步、_hit_geometry 等)——本 POC 保留一个
# 调用点判:__ReserveProcess_ClickActor 第 4 步、_attack_process 等)——本 POC 保留一个
# dead 提前返回作为便利,多数调用点本来也会再查一次。
func _is_attackable(e: Dictionary) -> bool:
if bool(e.get("dead", false)):
@@ -1425,7 +1503,7 @@ func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void:
_run_next_combo(vec)
return
var t := _now() - _combo_started_t
var t := _attacking_elapsed()
var md := _motion_data()
if bool(md.get("has_combo_input", false)):
var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime
@@ -1451,7 +1529,6 @@ func _run_next_combo(vec: PackedInt32Array) -> void:
if arr_idx < 0 or arr_idx >= vec.size():
_combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进
return
_combo_started_t = _now()
_emit_swing(int(vec[arr_idx]), _combo_index > 1)
if _combo_index == vec.size():
_on_end_combo()
@@ -1472,7 +1549,7 @@ func _clear_combo() -> void:
# CActorInstance::ComboProcessActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。
func _combo_process() -> void:
if _combo_index != 0:
var t := _now() - _combo_started_t
var t := _attacking_elapsed()
var md := _motion_data()
var has_ci := bool(md.get("has_combo_input", false))
var full := float(md.get("duration", attack_period))
@@ -1486,9 +1563,8 @@ func _combo_process() -> void:
if not vec.is_empty():
_run_next_combo(vec)
return
# 动作已回到 Wait(超过整段时长、无挂起输入)→ __ClearCombo
if not _is_pre_input and t > full:
_clear_combo()
# 参考端这里没有「超过整段时长就清连击」:段号在动作回 Wait 时由 __SetMotion 尾
# _on_motion_bound)清零。
else:
_is_pre_input = false
if _is_next_pre_input and not _using_skill:
@@ -1518,13 +1594,17 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
# 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。
if player_view and player_view.has_method("play_attack_motion"):
player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor)
elif player_view and player_view.has_method("set_anim_state"):
player_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)。
client.attack(_swing_skill, _vid_reserved)
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if is_combo else "attack")
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
@@ -1545,27 +1625,33 @@ func _send_fly_target() -> void:
var sp := _server_xy(tnode.global_position)
client.add_fly_targeting(vid, int(sp.x), int(sp.y))
# 挥击起播:从当前 `.msa` 取 THitDataContainer + iMotionType + iHitLimitCount + fInvisibleTime
# 清空上一击的 m_HitDataMap(对齐 __ProcessNormalAttack 里对 m_HitDataMap 的重建)。
# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackDataTHitDataContainer + iMotionType +
# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce),
# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。
func _begin_hit_windows() -> void:
var md := _motion_data()
var w: Variant = md.get("hit_windows", [])
_hit_windows = w if w is Array else []
_hit_motion_type = int(md.get("motion_type", 0))
_hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE))
_hit_limit_count = int(md.get("hit_limit_count", 0))
_hit_invisible_time = float(md.get("invisible_time", 0.0))
_hit_type = int(md.get("hitting_type", HIT_TYPE_NONE))
_hit_stiffen = float(md.get("stiffen_time", 0.0))
_hit_external_force = float(md.get("external_force", 0.0))
_hit_dedup.clear()
_swing_start_t = _now()
_motion_start_t = _local_time
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413+ CActorInstance::__NormalAttackProcess
# ActorInstanceCollisionDetection.cpp:333——挥击动作播放中,每帧按 hit_window [start,end]
# 对所有可攻击实体做扫掠球几何判定,命中即 OnHitSendAttackPacket)。
# 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():
return
if pc == null or pc.player == null or net_world == null:
return
var t := _now() - _swing_start_t
_atk_frame += 1
var t := _attacking_elapsed()
# 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking
var latest_end := 0.0
for w in _hit_windows:
@@ -1577,8 +1663,8 @@ func _attack_process(dt: float) -> void:
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
var a_pos := _actor_cm(pc.player.global_position)
var yaw := _player_yaw()
var origin: Vector3 = pc.player.global_position
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _main_vid:
@@ -1588,76 +1674,187 @@ func _attack_process(dt: float) -> void:
var vnode: Node3D = net_world.node_for(vid)
if vnode == null:
continue
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
var ws := float(w.get("start_time", 0.0))
var we := float(w.get("end_time", 0.0))
# 扫掠:动作时间窗 [t-dt, t] 与命中窗 [ws, we] 相交(对齐 lower_bound(motiontime-elapsed)
if t < ws or (t - dt) > we:
continue
if _hit_deduped(wi, vid):
continue
if not _hit_geometry(w, origin, yaw, vnode.global_position, t, dt):
continue
if not _register_hit(wi, vid):
break # 该窗命中数超上限 -> 本帧不再处理这个实体
# OnHitSetTarget(FALSE) + SendAttackPacket(uSkill, victimVID)
if _target_vid != vid:
_target_vid = vid
client.attack(_swing_skill, vid)
# IsPushing 的目标:帧末 CG_SYNC_POSITION 上报其被击退后的位置
if bool(e.get("knock_down", false)) or bool(e.get("stunned", false)):
var sp := _server_xy(vnode.global_position)
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
break # 一个实体一帧命中一次(对齐 __NormalAttackProcess 命中即 return TRUE
# AttackingProcessrVictim.__isInvisible() -> 跳过
if _now() < float(_victim_invisible_until.get(vid, 0.0)):
continue
_normal_attack_process(e, vid, vnode, a_pos, yaw, t, dt)
# m_HitDataMap 查重:COMBO 动作同窗同目标只一次;其余按 fInvisibleTime 冷却。
func _hit_deduped(wi: int, vid: int) -> bool:
var m: Dictionary = _hit_dedup.get(wi, {})
if not m.has(vid):
return false
if _hit_motion_type == MOTION_TYPE_COMBO:
return true
return float(m[vid]) > _now()
# Godot 世界 m -> 角色世界 cmX, Y, 高度)
static func _actor_cm(g: Vector3) -> Vector3:
return Vector3(g.x * CM, -g.z * CM, g.y * CM)
# 记一次命中并做每窗命中上限校验:NORMAL/COMBO 上限 16SKILL 用 iHitLimitCount。
# 返回 false = 超上限,本帧跳过(对齐 iCurrentHitCount > … return FALSE)。
func _register_hit(wi: int, vid: int) -> bool:
var m: Dictionary = _hit_dedup.get(wi, {})
var first := not m.has(vid)
m[vid] = _now() + _hit_invisible_time
_hit_dedup[wi] = m
if first:
return true
var cap := 16
if _hit_motion_type != MOTION_TYPE_COMBO and _hit_motion_type != MOTION_TYPE_NORMAL:
cap = _hit_limit_count
return m.size() <= cap
static func _godot_m(a: Vector3) -> Vector3:
return Vector3(a.x / CM, a.z / CM, -a.y / CM)
# 扫掠球几何近似:reach = max(WeaponLength, 窗内采样最大水平偏移),命中条件 = 目标落在
# 正面 reach 弧内。真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending sphere 数据(同 §3.1 留桩)。
func _hit_geometry(w: Dictionary, origin: Vector3, yaw: float, victim_pos: Vector3, t: float, dt: float) -> bool:
var reach_cm := float(w.get("weapon_length", 0.0))
var samples: Variant = w.get("samples", [])
if samples is Array:
for s in samples:
var st := float(s.get("time", 0.0))
# 模型本地 cm 按 Godot yaw 转到角色世界朝向(= Metin2 的 Z 轴旋转)
static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
var c := cos(yaw)
var s := sin(yaw)
return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
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 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
var dz := v_pos.z - a_pos.z
var dist2 := dx * dx + 2.0 * dz * dz
var lim := HIT_DISTANCE_HUGE_CM if huge else HIT_DISTANCE_CM
if dist2 >= lim * lim:
return
var spheres := _defending_spheres(vid, vnode, v_pos)
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)):
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 = s.get("pos", Vector3.ZERO)
reach_cm = maxf(reach_cm, Vector2(p.x, p.y).length())
if reach_cm <= 0.0:
reach_cm = CLICK_DIST_DEFAULT_CM
var max_dist := (reach_cm + HIT_ATTACK_RADIUS_CM + HIT_VICTIM_RADIUS_CM) / CM
var to: Vector3 = victim_pos - origin
to.y = 0.0
var d := to.length()
if d > max_dist:
return false
if d < 0.001:
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"])):
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)
return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
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 raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
var vyaw := vnode.rotation.y
var cur: Array = []
for sp in raw:
cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
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 = []
for i in raw.size():
out.append({"radius": float(raw[i].get("radius", 0.0)), "pos": cur[i],
"last": prev["pos"][i] if has_prev else cur[i]})
_def_last[vid] = {"frame": _atk_frame, "pos": cur}
return out
# m_HitDataMap 登记(__NormalAttackProcess 命中分支):窗第一次命中 -> 新建 {vid: 本地时间 +
# fInvisibleTime} 直接处理;否则 map::insert(已在就不覆盖),NORMAL/COMBO 超 16、SKILL 超
# iHitLimitCount -> FALSE。
func _register_hit(wi: int, vid: int) -> bool:
if not _hit_dedup.has(wi):
_hit_dedup[wi] = {vid: _local_time + _hit_invisible_time}
return true
var fwd := Vector3(sin(yaw), 0.0, cos(yaw))
return fwd.dot(to / d) >= HIT_FRONT_COS
var m: Dictionary = _hit_dedup[wi]
if not m.has(vid):
m[vid] = _local_time + _hit_invisible_time
var cap := _hit_limit_count
if _hit_motion_type == MOTION_TYPE_COMBO or _hit_motion_type == MOTION_TYPE_NORMAL:
cap = COMBO_HIT_LIMIT
return m.size() <= cap
# CActorInstance::__ProcessDataAttackSuccessActorInstanceBattle.cpp
func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool) -> void:
if _hit_type == HIT_TYPE_NONE:
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
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"):
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
if vnode.has_method("insert_delay"):
vnode.insert_delay(_hit_stiffen)
var kind := _entity_kind(e)
var building := kind == EntityRules.KIND_BUILDING
var door := kind == EntityRules.KIND_WOODEN_DOOR
# 命中特效 m_dwBattleHitEffectID:建筑 / 门在攻击者身前 30 cm、不转;其余在受击方(巨型:命中点)
if fx and fx_parent and fx.has_method("spawn_at"):
var at := v_g
var rot := atan2(-to.x, -to.z)
if building or door:
var flat := Vector3(to.x, 0.0, to.z)
at = a_g + (flat.normalized() if flat.length() > 0.0 else Vector3.ZERO) * 0.3
rot = 0.0
elif huge:
at = _godot_m(hit_pos)
var eff: Node3D = fx.spawn_at(EFFECT_HIT, fx_parent, at, true)
if eff:
eff.rotation.y = rot
# __HitStone / __HitGood / __HitGreate
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
elif _hit_type == HIT_TYPE_GOOD:
if vnode.has_method("hit_good"):
vnode.hit_good(scalar, stunned)
elif _hit_type == HIT_TYPE_GREAT:
if vnode.has_method("hit_greate"):
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
# 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:
return false
if bool(e.get("stunned", false)):
return true
var owner := int(e.get("owner_vid", 0))
_note_owner(vid, owner)
if owner != _main_vid:
return false
return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME
# m_dwOwnerVID / m_fOwnerBaseTime:服务端 owner 变化时记下客户端观测时刻(__GetOwnerTime 的近似)。
func _note_owner(vid: int, owner: int) -> void:
var seen: Array = _owner_seen.get(vid, [])
if seen.is_empty() or int(seen[0]) != owner:
_owner_seen[vid] = [owner, _now()]
# 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)
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))
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
func _on_entity_gone(vid: int) -> void:
_owner_seen.erase(vid)
_victim_invisible_until.erase(vid)
_def_last.erase(vid)
if vid == _target_vid:
_clear_target()
# CPythonPlayerEventHandler::FlushVictimListPythonPlayerEventHandler.cpp:194)——帧末把
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。