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:
+289
-92
@@ -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_fAttackRadius(ActorInstanceCollisionDetection.cpp:355)
|
||||
const HIT_VICTIM_RADIUS_CM := 45.0 # 受击方防御圆柱近似半径(真实取自 defending sphere 数据)
|
||||
const HIT_FRONT_COS := 0.5 # 正面 ±60° 弧内才判定命中(近似动态圆柱扫掠)
|
||||
const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT
|
||||
# NRaceData::EHitType(GameType.h)
|
||||
const HIT_TYPE_NONE := 0
|
||||
const HIT_TYPE_GREAT := 1
|
||||
const HIT_TYPE_GOOD := 2
|
||||
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcess:fDistance >= 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_dwBattleHitEffectID(playersettingmodule.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 / 100(0.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_wcurComboType(SetComboType;连击技能
|
||||
var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中)
|
||||
var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发)
|
||||
var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击)
|
||||
var _combo_started_t := 0.0 # 当前连击段动作起播时刻(GetAttackingElapsedTime 基准)
|
||||
var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } }
|
||||
# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsed,InsertDelay 冻结(命中硬直)。
|
||||
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_windows(THitDataContainer)
|
||||
var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType)
|
||||
var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限)
|
||||
var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(同目标再命中的冷却)
|
||||
var _hit_dedup := {} # { window_idx:int -> { victim_vid:int -> cooldown_until:float } }
|
||||
var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(fInvisibleTime)
|
||||
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 _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::SetComboSkillFlag(PythonPlayerSkill.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::UpdateTime:m_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::__GetBowRange(InstanceBase.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::IsAttackableInstance(InstanceBase.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::ComboProcess(ActorInstanceBattle.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` 取 TMotionAttackData(THitDataContainer + 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::AttackProcess(InstanceBaseBattle.cpp:413)+ CActorInstance::__NormalAttackProcess
|
||||
# (ActorInstanceCollisionDetection.cpp:333)——挥击动作播放中,每帧按 hit_window [start,end]
|
||||
# 对所有可攻击实体做扫掠球几何判定,命中即 OnHit(SendAttackPacket)。
|
||||
# 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():
|
||||
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 # 该窗命中数超上限 -> 本帧不再处理这个实体
|
||||
# OnHit:SetTarget(FALSE) + SendAttackPacket(uSkill, victimVID)
|
||||
if _target_vid != vid:
|
||||
_target_vid = vid
|
||||
client.attack(_swing_skill, vid)
|
||||
# IsPushing 的目标:帧末 CG_SYNC_POSITION 上报其被击退后的位置
|
||||
if bool(e.get("knock_down", false)) or bool(e.get("stunned", false)):
|
||||
var sp := _server_xy(vnode.global_position)
|
||||
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
|
||||
break # 一个实体一帧命中一次(对齐 __NormalAttackProcess 命中即 return TRUE)
|
||||
# AttackingProcess:rVictim.__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 -> 角色世界 cm(X, Y, 高度)
|
||||
static func _actor_cm(g: Vector3) -> Vector3:
|
||||
return Vector3(g.x * CM, -g.z * CM, g.y * CM)
|
||||
|
||||
# 记一次命中并做每窗命中上限校验:NORMAL/COMBO 上限 16,SKILL 用 iHitLimitCount。
|
||||
# 返回 false = 超上限,本帧跳过(对齐 iCurrentHitCount > … return FALSE)。
|
||||
func _register_hit(wi: int, vid: int) -> bool:
|
||||
var m: Dictionary = _hit_dedup.get(wi, {})
|
||||
var first := not m.has(vid)
|
||||
m[vid] = _now() + _hit_invisible_time
|
||||
_hit_dedup[wi] = m
|
||||
if first:
|
||||
return true
|
||||
var cap := 16
|
||||
if _hit_motion_type != MOTION_TYPE_COMBO and _hit_motion_type != MOTION_TYPE_NORMAL:
|
||||
cap = _hit_limit_count
|
||||
return m.size() <= cap
|
||||
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::__ProcessDataAttackSuccess(ActorInstanceBattle.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::__CanPushDestActor(ActorInstanceBattle.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::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)
|
||||
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::FlushVictimList(PythonPlayerEventHandler.cpp:194)——帧末把
|
||||
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。
|
||||
|
||||
Reference in New Issue
Block a user