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
+94 -4
View File
@@ -21,11 +21,25 @@ const STATE_MOTIONS := {
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"emotion": ["WAIT"],
# §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。
# 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。
"damage_back": ["BACK_DAMAGE"],
"knockdown": ["FRONT_KNOCKDOWN"],
"knockdown_back": ["BACK_KNOCKDOWN"],
"standup": ["FRONT_STANDUP"],
"standup_back": ["BACK_STANDUP"],
}
const HitReaction = preload("res://ui/hit_reaction.gd")
const HitCollision = preload("res://hit_collision.gd")
# CActorInstance::__SetMotion 尾:动作绑定完成。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var _dir := ""
var _mesh_stem := ""
var _hit := HitReaction.new()
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := ""
var _last_resolution := {}
@@ -89,6 +103,7 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
break
if _dir == "":
return false
_mesh_stem = mesh_stem
var gr2 := _dir.path_join(mesh_stem + ".gr2")
if not FileAccess.file_exists(gr2):
gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
@@ -114,6 +129,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
set_anim_state("wait")
CharShadow.attach(self, 1.6) # 怪脚印大一点
return true
@@ -125,15 +142,87 @@ func set_audio(audio_node: Node) -> void:
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
return
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
_last_resolution = resolve_motion(s)
var msa := String(_last_resolution.path)
if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind(s, msa, s in HitReaction.LOOP_STATES, 1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp
func hit_good(scalar: float, stunned: bool) -> void:
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
func is_in_hit_reaction() -> bool:
return _hit.active
# 受击方防御球:怪目录的 .msm<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。
func get_defending_spheres() -> Array:
if _dir == "":
return []
var cands: Array = []
if _mesh_stem != "":
cands.append(_dir.path_join(_mesh_stem + ".msm"))
cands.append(_dir.path_join(_dir.get_file() + ".msm"))
for p: String in cands:
if FileAccess.file_exists(p):
return HitCollision.parse_msm_defending(p)
var da := DirAccess.open(_dir)
if da:
for fn in da.get_files():
if fn.to_lower().ends_with(".msm"):
return HitCollision.parse_msm_defending(_dir.path_join(fn))
return []
func _on_playback_finished() -> void:
if not _hit.active:
return
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
func _bind_reaction(step: String) -> bool:
if anim == null:
return false
var r := resolve_motion(step)
_last_resolution = r
var msa := String(r.path)
if msa == "":
return false
_bind(step, msa, false, 1.0)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
anim.set("loop", loop)
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
# Read-only motion resolver used by the forest acceptance harness. Returning
# the selected .msa before playback lets the test distinguish a real action
@@ -169,7 +258,8 @@ func resolve_motion(state: String) -> Dictionary:
func last_motion_resolution() -> Dictionary:
return _last_resolution.duplicate()
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return