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:
+129
-18
@@ -13,6 +13,30 @@ extends Node3D
|
||||
# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M
|
||||
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
|
||||
const FEMALE_RACES := [1, 3, 4, 6]
|
||||
# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名):
|
||||
# PART_WEAPON -> model.weapon_bone;PART_WEAPON_LEFT -> model.shield_bone("" = 未登记,不挂)
|
||||
const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
|
||||
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
|
||||
const HitReaction = preload("res://ui/hit_reaction.gd")
|
||||
const HitCollision = preload("res://hit_collision.gd")
|
||||
# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级)
|
||||
const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword",
|
||||
5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword",
|
||||
11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan",
|
||||
15: "horse_bell", 16: "wedding"}
|
||||
const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACK;COMBO_ATTACK_1 = 14
|
||||
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件(同名多份按权重随机,这里等权)
|
||||
const REACTION_FILES := {
|
||||
"damage": ["damage", "damage_1"], # NAME_DAMAGE
|
||||
"damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK
|
||||
"knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING
|
||||
"knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK
|
||||
"standup": ["falling_stand"], # NAME_STAND_UP
|
||||
"standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK
|
||||
}
|
||||
|
||||
# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。
|
||||
signal motion_bound(state: String)
|
||||
|
||||
var model: Node # Metin2Model
|
||||
var anim: Node # Metin2AnimPlayer
|
||||
@@ -24,11 +48,15 @@ var _audio: Node
|
||||
var _sound_instances: Array = []
|
||||
var _sound_frame := -1
|
||||
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
|
||||
var _left_bone := ""
|
||||
var _race := 0
|
||||
var _hit := HitReaction.new()
|
||||
|
||||
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
|
||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
_assets_root = assets_root
|
||||
_race = race
|
||||
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
|
||||
return false
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
@@ -45,6 +73,12 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
# PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses.
|
||||
model.set("flip_winding", true)
|
||||
model.set("texture_dir", base)
|
||||
# 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有,
|
||||
# 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。
|
||||
model.set("weapon_bone", WEAPON_BONE[race & 3])
|
||||
_left_bone = WEAPON_LEFT_BONE[race & 3]
|
||||
if _left_bone != "":
|
||||
model.set("shield_bone", _left_bone)
|
||||
if pump.is_valid(): pump.call()
|
||||
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
|
||||
if body == "":
|
||||
@@ -88,14 +122,20 @@ func set_audio(audio_node: Node) -> void:
|
||||
func set_anim_state(s: String) -> void:
|
||||
if s == _state or anim == null or motion_dir == "":
|
||||
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
|
||||
var msa := motion_dir.path_join(s + ".msa")
|
||||
if not FileAccess.file_exists(msa):
|
||||
msa = motion_dir.path_join(s + ".gr2")
|
||||
if FileAccess.file_exists(msa):
|
||||
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)
|
||||
|
||||
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
|
||||
# the numeric motion id; paired emotions use the other entity's race to select
|
||||
@@ -163,29 +203,97 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
|
||||
msa = motion_dir.path_join(name + ".msa")
|
||||
if not FileAccess.file_exists(msa):
|
||||
return false
|
||||
_state = "__motion"
|
||||
anim.set("loop", false)
|
||||
# A repeated command can name the same clip; clear first because the native
|
||||
# player treats assigning the current anim_path as a no-op.
|
||||
if String(anim.get("anim_path")) == msa:
|
||||
anim.set("anim_path", "")
|
||||
anim.set("anim_path", msa)
|
||||
_refresh_sound_script(msa)
|
||||
_bind("__motion", msa, false, 1.0)
|
||||
return true
|
||||
|
||||
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio }):
|
||||
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msa,COMBO_ATTACK_N -> combo_0N.msa),
|
||||
# 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。
|
||||
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
|
||||
if anim == null or motion_dir == "":
|
||||
return
|
||||
var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
|
||||
var names: Array = ["attack", "attack_1"]
|
||||
if index > MOTION_NORMAL_ATTACK:
|
||||
names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)]
|
||||
var msa := _pick_msa(mode_dir, names)
|
||||
if msa == "":
|
||||
msa = _pick_msa(motion_dir, ["attack", "attack_1"])
|
||||
if msa == "":
|
||||
return
|
||||
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
|
||||
|
||||
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
|
||||
func insert_delay(d: float) -> void:
|
||||
_hit.insert_delay(d, anim)
|
||||
|
||||
# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)。
|
||||
# scalar = dot(攻击方朝向, 受击方朝向):< 0 面对面 -> 正面受击动作。
|
||||
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)
|
||||
|
||||
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
|
||||
func get_defending_spheres() -> Array:
|
||||
var sex := "w" if _race in FEMALE_RACES else "m"
|
||||
return HitCollision.parse_msm_defending(
|
||||
_assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex]))
|
||||
|
||||
func _on_playback_finished() -> void:
|
||||
# CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip.
|
||||
# When it ends the actor leaves the pushed state (CActorInstance::IsPushing),
|
||||
# so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate.
|
||||
if _state == "__motion" or _state == "damage":
|
||||
# §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。
|
||||
# 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate.
|
||||
if _hit.active:
|
||||
if _hit.advance(_bind_reaction) or _hit.real_dead:
|
||||
return
|
||||
_state = ""
|
||||
set_anim_state(_hit.resume)
|
||||
return
|
||||
if _state == "__motion":
|
||||
_state = ""
|
||||
set_anim_state("wait")
|
||||
|
||||
# True while the one-shot hit/knockback clip is still playing.
|
||||
# True while the one-shot hit/knockback chain is still playing.
|
||||
func is_in_hit_reaction() -> bool:
|
||||
return _state == "damage"
|
||||
return _hit.active
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
func _bind_reaction(step: String) -> bool:
|
||||
if anim == null or motion_dir == "":
|
||||
return false
|
||||
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
|
||||
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)
|
||||
# A repeated one-shot can name the same clip; clear first because the native
|
||||
# player treats assigning the current anim_path as a no-op.
|
||||
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)
|
||||
|
||||
func _pick_msa(dir: String, names: Array) -> String:
|
||||
var found: Array = []
|
||||
for n in names:
|
||||
var p := dir.path_join(String(n) + ".msa")
|
||||
if FileAccess.file_exists(p):
|
||||
found.append(p)
|
||||
return "" if found.is_empty() else String(found[randi() % found.size()])
|
||||
|
||||
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
|
||||
@@ -207,6 +315,9 @@ func _refresh_sound_script(motion_path: String) -> void:
|
||||
|
||||
func _set(prop: StringName, val: Variant) -> bool:
|
||||
if String(prop) in _forward and model:
|
||||
# ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手
|
||||
if String(prop) == "shield_gr2" and _left_bone == "":
|
||||
val = ""
|
||||
model.set(prop, val)
|
||||
if String(prop) == "gr2_path":
|
||||
call_deferred("_ground_model")
|
||||
|
||||
Reference in New Issue
Block a user