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:
+175
-11
@@ -5,8 +5,8 @@
|
||||
# game_scene.set_player_model(pv)
|
||||
# pv.set_anim_state("run") # wait/walk/run/attack/dead
|
||||
#
|
||||
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 的 set 给内部 Metin2Model,
|
||||
# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`。
|
||||
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 属性及材质换肤调用给内部
|
||||
# Metin2Model,所以 equip_model.gd 可以直接操作外层 PlayerView。
|
||||
extends Node3D
|
||||
|
||||
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
|
||||
@@ -54,6 +54,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
|
||||
var _left_bone := ""
|
||||
var _race := 0
|
||||
var _hit := HitReaction.new()
|
||||
var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
|
||||
|
||||
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
|
||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||
@@ -132,12 +133,19 @@ func set_anim_state(s: String) -> void:
|
||||
if _hit.active and s in HitReaction.LOOP_STATES:
|
||||
_hit.resume = s
|
||||
return
|
||||
# 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断
|
||||
if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES:
|
||||
if anim.has_method("get_duration") and anim.has_method("get_time"):
|
||||
var d: float = float(anim.call("get_duration"))
|
||||
var t: float = float(anim.call("get_time"))
|
||||
if d > 0.0 and t < d and bool(anim.get("playing")):
|
||||
return
|
||||
else:
|
||||
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):
|
||||
var msa := _motion_file(s)
|
||||
if msa != "":
|
||||
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
|
||||
|
||||
# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
|
||||
@@ -155,10 +163,29 @@ func _loop_speed(state: String) -> float:
|
||||
## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。
|
||||
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
|
||||
func get_move_motion_speeds() -> Vector2:
|
||||
if motion_dir == "":
|
||||
var dir := _mode_motion_dir() if motion_dir != "" else ""
|
||||
if dir == "":
|
||||
return Vector2.ZERO
|
||||
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
|
||||
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
|
||||
return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
|
||||
MsaMotion.move_speed(dir.path_join("run.msa")))
|
||||
|
||||
# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
|
||||
# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
|
||||
# wait/walk/run/damage too; the left-hand grip is authored in those animations
|
||||
# while the weapon model itself remains attached to PART_WEAPON (right hand).
|
||||
func set_motion_mode(mode: int) -> void:
|
||||
var next := mode if MOTION_MODE_DIRS.has(mode) else 1
|
||||
if next == _motion_mode:
|
||||
return
|
||||
_motion_mode = next
|
||||
var resume := _state
|
||||
if resume == "" or resume == "__motion":
|
||||
resume = "wait"
|
||||
_state = "" # force rebind even though the semantic state did not change
|
||||
set_anim_state(resume)
|
||||
|
||||
func get_motion_mode() -> int:
|
||||
return _motion_mode
|
||||
|
||||
# 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
|
||||
@@ -246,6 +273,98 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
|
||||
return
|
||||
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
|
||||
|
||||
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
|
||||
# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
|
||||
func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
|
||||
if anim == null:
|
||||
return false
|
||||
var cls: String = CLASS_OF[_race & 3]
|
||||
var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls
|
||||
var sdir := _resolve_dir(_assets_root, pc_dir)
|
||||
if sdir == "":
|
||||
return false
|
||||
var suffix := ""
|
||||
if grade >= 1:
|
||||
suffix = "_%d" % (grade + 1)
|
||||
var msa := sdir.path_join(motion_name + suffix + ".msa")
|
||||
if not FileAccess.file_exists(msa):
|
||||
msa = sdir.path_join(motion_name + ".msa")
|
||||
if not FileAccess.file_exists(msa):
|
||||
return false
|
||||
_bind("__skill", msa, false, 1.0)
|
||||
return true
|
||||
|
||||
const AFFECT_EFFECT_MAP := {
|
||||
15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG
|
||||
16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN
|
||||
17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG
|
||||
19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM
|
||||
20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR
|
||||
21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP
|
||||
22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN
|
||||
23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO
|
||||
24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK
|
||||
25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN
|
||||
26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG
|
||||
29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON
|
||||
30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK
|
||||
32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP
|
||||
3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON
|
||||
4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW
|
||||
5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN
|
||||
}
|
||||
|
||||
var _fx: RefCounted
|
||||
var _affect_nodes: Dictionary = {}
|
||||
|
||||
func set_fx(fx_registry: RefCounted) -> void:
|
||||
_fx = fx_registry
|
||||
|
||||
func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
|
||||
var reg: RefCounted = fx_registry if fx_registry != null else _fx
|
||||
if not visible:
|
||||
if _affect_nodes.has(affect_type):
|
||||
var n: Node = _affect_nodes[affect_type]
|
||||
_affect_nodes.erase(affect_type)
|
||||
if is_instance_valid(n):
|
||||
n.queue_free()
|
||||
return
|
||||
|
||||
if _affect_nodes.has(affect_type) or reg == null:
|
||||
return
|
||||
if not AFFECT_EFFECT_MAP.has(affect_type):
|
||||
return
|
||||
var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type]
|
||||
var fx_name: String = String(spec.get("file", ""))
|
||||
if affect_type == 15 and _motion_mode == 3:
|
||||
fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse"
|
||||
if fx_name == "":
|
||||
return
|
||||
var effect: Node = reg.call("spawn", fx_name, self, false)
|
||||
if effect == null:
|
||||
return
|
||||
var bone: String = String(spec.get("bone", ""))
|
||||
if bone != "":
|
||||
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
|
||||
effect.add_child(anchor)
|
||||
var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO}
|
||||
if not anchor.configure(self, ev):
|
||||
effect.queue_free()
|
||||
return
|
||||
_affect_nodes[affect_type] = effect
|
||||
|
||||
func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void:
|
||||
for t in AFFECT_EFFECT_MAP.keys():
|
||||
var active: bool = (flags & (1 << t)) != 0
|
||||
set_affect(t, active, fx_registry)
|
||||
|
||||
func clear_affects() -> void:
|
||||
for t in _affect_nodes.keys():
|
||||
var n: Node = _affect_nodes[t]
|
||||
if is_instance_valid(n):
|
||||
n.queue_free()
|
||||
_affect_nodes.clear()
|
||||
|
||||
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
|
||||
func insert_delay(d: float) -> void:
|
||||
_hit.insert_delay(d, anim)
|
||||
@@ -261,6 +380,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
|
||||
func hit_stone(stunned: bool) -> void:
|
||||
_hit.stone(stunned)
|
||||
|
||||
# 40250 CActorInstance::__Shake(100)
|
||||
func shake() -> void:
|
||||
_hit.shake()
|
||||
|
||||
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
|
||||
func get_defending_spheres() -> Array:
|
||||
var sex := "w" if _race in FEMALE_RACES else "m"
|
||||
@@ -276,9 +399,18 @@ func _on_playback_finished() -> void:
|
||||
_state = ""
|
||||
set_anim_state(_hit.resume)
|
||||
return
|
||||
if _state == "__motion":
|
||||
if _state in ["__motion", "__skill", "attack", "combo"]:
|
||||
_state = ""
|
||||
set_anim_state("wait")
|
||||
motion_bound.emit("wait")
|
||||
_ground_model()
|
||||
|
||||
func reset_skill_motion() -> void:
|
||||
if _state in ["__skill", "__motion"]:
|
||||
_state = ""
|
||||
set_anim_state("wait")
|
||||
motion_bound.emit("wait")
|
||||
_ground_model()
|
||||
|
||||
# True while the one-shot hit/knockback chain is still playing.
|
||||
func is_in_hit_reaction() -> bool:
|
||||
@@ -287,7 +419,7 @@ func is_in_hit_reaction() -> bool:
|
||||
func _bind_reaction(step: String) -> bool:
|
||||
if anim == null or motion_dir == "":
|
||||
return false
|
||||
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
|
||||
var msa := _pick_mode_msa(REACTION_FILES.get(step, []))
|
||||
if msa == "":
|
||||
return false
|
||||
_bind(step, msa, false, 1.0)
|
||||
@@ -303,6 +435,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
|
||||
if not loop and String(anim.get("anim_path")) == path:
|
||||
anim.set("anim_path", "")
|
||||
anim.set("anim_path", path)
|
||||
anim.set("playing", true)
|
||||
anim.set("time_scale", _hit.scale_for(speed_ratio))
|
||||
_refresh_sound_script(path)
|
||||
motion_bound.emit(state)
|
||||
@@ -315,6 +448,26 @@ func _pick_msa(dir: String, names: Array) -> String:
|
||||
found.append(p)
|
||||
return "" if found.is_empty() else String(found[randi() % found.size()])
|
||||
|
||||
func _mode_motion_dir() -> String:
|
||||
if motion_dir == "":
|
||||
return ""
|
||||
var candidate := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(_motion_mode, "general")))
|
||||
return candidate if DirAccess.dir_exists_absolute(candidate) else motion_dir
|
||||
|
||||
func _motion_file(state: String) -> String:
|
||||
for dir in [_mode_motion_dir(), motion_dir]:
|
||||
if dir == "":
|
||||
continue
|
||||
for ext in [".msa", ".gr2"]:
|
||||
var path: String = String(dir).path_join(state + String(ext))
|
||||
if FileAccess.file_exists(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
func _pick_mode_msa(names: Array) -> String:
|
||||
var picked := _pick_msa(_mode_motion_dir(), names)
|
||||
return picked if picked != "" else _pick_msa(motion_dir, names)
|
||||
|
||||
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") \
|
||||
@@ -347,6 +500,17 @@ func _set(prop: StringName, val: Variant) -> bool:
|
||||
return true
|
||||
return false
|
||||
|
||||
# EquipModel operates on the outer PlayerView, while the material palette lives
|
||||
# on the native Metin2Model child. Keep these as explicit methods so MSM
|
||||
# SourceSkin -> TargetSkin remaps reach both character select and the game.
|
||||
func set_skin_texture(source: String, target: String) -> void:
|
||||
if model and model.has_method("set_skin_texture"):
|
||||
model.call("set_skin_texture", source, target)
|
||||
|
||||
func clear_skin_textures() -> void:
|
||||
if model and model.has_method("clear_skin_textures"):
|
||||
model.call("clear_skin_textures")
|
||||
|
||||
func _ground_model() -> void:
|
||||
if model and model.has_method("get_ground_offset"):
|
||||
model.position.y = float(model.call("get_ground_offset"))
|
||||
|
||||
Reference in New Issue
Block a user