fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+358 -25
View File
@@ -19,15 +19,18 @@ const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_r
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MotionRegistry = preload("res://ui/motion_registry.gd")
const MOVE_STATES := ["walk", "run"]
const HitCollision = preload("res://hit_collision.gd")
const WeaponTrace = preload("res://ui/weapon_trace.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 的文件(同名多份按权重随机,这里等权)
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件名回退。
# 实际选择优先消费 MotionRegistry 中的 (mode,index) 注册项及其权重。
const REACTION_FILES := {
"damage": ["damage", "damage_1"], # NAME_DAMAGE
"damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK
@@ -36,6 +39,28 @@ const REACTION_FILES := {
"standup": ["falling_stand"], # NAME_STAND_UP
"standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK
}
const MOTION_INDEX_BY_STATE := {
"wait": 1,
"walk": 2,
"run": 3,
"damage": 5,
"knockdown": 6,
"standup": 7,
"damage_back": 8,
"knockdown_back": 9,
"standup_back": 10,
"dead": 11,
"attack": 14, # PC playersettingmodule stores normal/first swing as COMBO_ATTACK_1
"combo": 14,
}
const REACTION_INDEX := {
"damage": 5,
"damage_back": 8,
"knockdown": 6,
"knockdown_back": 9,
"standup": 7,
"standup_back": 10,
}
# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。
signal motion_bound(state: String)
@@ -47,14 +72,22 @@ var action_dir := ""
var _state := ""
var _move_speed_ratio := 1.0
var _assets_root := ""
var _motion_registry: RefCounted
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 _skill_loop_count := 0
var _skill_moving := false
var _motion_hidden := false
var _hit := HitReaction.new()
var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
var _weapon_trace: Node
var _weapon_traces: Array = [] # 40250 m_WeaponTraceVector:右手/左手逐部件保存
var _weapon_trace_allowed := true
var _weapon_trace_reach_scale := 1.0 # CActorInstance::m_fReachScale
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
@@ -71,6 +104,7 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
return false
motion_dir = base.path_join("general")
action_dir = base.path_join("action")
_motion_registry = MotionRegistry.load_for(_assets_root, _race)
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
@@ -116,6 +150,7 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
if pump.is_valid(): pump.call()
set_anim_state("wait") # 重:解 .msa
if pump.is_valid(): pump.call()
call_deferred("_refresh_weapon_trace")
CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow
return true
@@ -123,8 +158,93 @@ func set_audio(audio_node: Node) -> void:
_audio = audio_node
_refresh_sound_script(String(anim.get("anim_path")) if anim else "")
# 40250 CActorInstance::__HideEvent / __ShowEvent. Keep the state on the
# actor view so a type-8 event cannot be undone by an unrelated child refresh.
func set_motion_hidden(hidden: bool) -> void:
_motion_hidden = hidden
if is_instance_valid(model) and model is Node3D:
(model as Node3D).visible = not hidden
func is_motion_hidden() -> bool:
return _motion_hidden
# 40250 CActorInstance::ProcessMotionEventFly 起点:FlyPosition 先按角色
# 资源坐标转换,AttachingBoneName 再叠加当前骨骼平移。复用 motion effect
# 的同一坐标变换,确保本地箭矢与挂骨特效从同一个点出发。
func motion_event_world_position(event: Dictionary) -> Vector3:
var source_variant: Variant = event.get("fly_pos", Vector3.ZERO)
var source: Vector3 = source_variant if source_variant is Vector3 else Vector3.ZERO
if is_instance_valid(model):
var bone := Transform3D.IDENTITY
if bool(event.get("fly_attaching", false)) and anim != null \
and anim.has_method("get_effect_bone_pose"):
var bone_name := String(event.get("fly_bone", ""))
if bone_name != "":
var pose: Dictionary = anim.get_effect_bone_pose(bone_name)
if pose.has("transform"):
bone = pose["transform"]
var source_to_metres := Transform3D(
Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)),
Vector3.ZERO)
var offset := Transform3D(Basis.IDENTITY, source)
return (model.global_transform * offset * bone * source_to_metres.affine_inverse()).origin
return global_transform * Vector3(source.x * 0.01, source.z * 0.01, -source.y * 0.01)
# ActorInstanceAttach::__IsWeaponTrace / __DestroyWeaponTrace seam. EquipModel
# calls this after it knows the item subtype; direct PlayerView callers keep the
# default enabled behavior for ordinary sword-like weapon meshes.
func set_weapon_trace_enabled(enabled: bool) -> void:
_weapon_trace_allowed = enabled
if not enabled:
_destroy_weapon_trace()
call_deferred("_refresh_weapon_trace")
func weapon_trace_state() -> Dictionary:
if _weapon_traces.is_empty():
return {"present": false, "enabled": _weapon_trace_allowed}
var primary: Node = _weapon_traces[0]
var state: Dictionary = primary.debug_state() if is_instance_valid(primary) and primary.has_method("debug_state") else {}
var any_playing := false
for trace in _weapon_traces:
if is_instance_valid(trace) and trace.has_method("is_playing") and trace.is_playing():
any_playing = true
state["count"] = _weapon_traces.size()
state["playing"] = any_playing
state["present"] = true
state["enabled"] = _weapon_trace_allowed
return state
func weapon_reach_scale() -> float:
return _weapon_trace_reach_scale
# 40250 PythonCharacterModule::chrWeaponTraceSetTexture / UseTextureWeaponTrace /
# UseAlphaWeaponTrace。当前端按 m_WeaponTraceVector 广播到每个武器部件。
func weapon_trace_set_texture(path: String) -> void:
for trace in _weapon_traces:
if is_instance_valid(trace) and trace.has_method("set_texture"):
trace.set_texture(path)
func weapon_trace_use_texture() -> void:
for trace in _weapon_traces:
if is_instance_valid(trace) and trace.has_method("use_texture_mode"):
trace.use_texture_mode()
func weapon_trace_use_alpha() -> void:
for trace in _weapon_traces:
if is_instance_valid(trace) and trace.has_method("use_alpha"):
trace.use_alpha()
func _set_weapon_trace_reach_scale(value: float) -> void:
_weapon_trace_reach_scale = maxf(value, 0.0)
for trace in _weapon_traces:
if is_instance_valid(trace) and trace.has_method("set_reach_scale"):
trace.set_reach_scale(_weapon_trace_reach_scale)
func set_anim_state(s: String) -> void:
if s == _state or anim == null or motion_dir == "":
# 40250 SetLoopMotion still reaches __SetMotion for the current motion;
# that path calls __ShowEvent() when a previous CHARACTER_HIDE event left
# the actor hidden. Do not let the same-state fast path keep it invisible.
if (s == _state and not _motion_hidden) or anim == null or motion_dir == "":
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
@@ -142,11 +262,15 @@ func set_anim_state(s: String) -> void:
return
else:
return
var msa := _motion_file(s)
# CActorInstance::SetLoopMotion/InterceptMotion resolve GetMotionKey
# before mutating the current motion node. A missing registered key must
# leave the old animation/state in place, rather than becoming a dead label.
if msa == "":
return
_hit.cancel()
_state = s
var msa := _motion_file(s)
if msa != "":
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# (Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
@@ -163,11 +287,11 @@ 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:
var dir := _mode_motion_dir() if motion_dir != "" else ""
if dir == "":
if motion_dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
MsaMotion.move_speed(dir.path_join("run.msa")))
var walk := _motion_file("walk")
var run := _motion_file("run")
return Vector2(MsaMotion.move_speed(walk), MsaMotion.move_speed(run))
# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
@@ -257,18 +381,28 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
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_* 也注册这两份)。
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK/COMBO_ATTACK_1 使用
# playersettingmodule 注册的 index 14,后续段使用 combo_0N.msa);普通武器模式
# 缺 key 时由 CRaceData 只回退 GENERAL,HORSE_* 只回退 HORSE。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool:
if anim == null or motion_dir == "":
return false
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"])
var registry_index := index if index > MOTION_NORMAL_ATTACK else MOTION_NORMAL_ATTACK + 1
var msa := ""
for dir in _motion_dirs_for_motion(mode, registry_index):
var registry_mode := _motion_mode_for_dir(dir)
# PC playersettingmodule registers the normal swing under
# COMBO_ATTACK_1 (14), including the GENERAL attack/attack_1 pair.
# The network's NAME_NORMAL_ATTACK (13) therefore resolves to the same
# registered resource when the mode has no dedicated normal entry.
msa = _pick_registered_msa(dir, registry_mode, registry_index, names)
if msa == "" and (_motion_registry == null or not _motion_registry.source_loaded):
msa = _pick_msa(dir, names)
if msa != "":
break
if msa == "":
return false
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
@@ -276,7 +410,8 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool:
# 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:
func play_skill_motion(motion_name: String, grade: int = 0, loop_count: int = 0,
moving_skill: bool = false) -> bool:
if anim == null:
return false
var cls: String = CLASS_OF[_race & 3]
@@ -292,9 +427,24 @@ func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
msa = sdir.path_join(motion_name + ".msa")
if not FileAccess.file_exists(msa):
return false
_skill_loop_count = maxi(0, loop_count)
_skill_moving = moving_skill
_bind("__skill", msa, false, 1.0)
# 40250 applies the network/local uMotLoopCount after binding the motion;
# the native player then uses the MSA LoopData segment, not a whole-clip
# loop. Zero leaves the resource-defined LoopData count intact.
if anim.has_method("set_motion_loop_count"):
anim.call("set_motion_loop_count", _skill_loop_count)
# Keep the 40250 OnUseSkill payload visible to the animation/effect bridge;
# the native player still owns clip timing, while callers can distinguish a
# finite loop override from an ordinary one-shot and moving-skill motion.
anim.set_meta("skill_loop_count", _skill_loop_count)
anim.set_meta("skill_moving", _skill_moving)
return true
func skill_motion_state() -> Dictionary:
return {"loop_count": _skill_loop_count, "moving": _skill_moving}
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
@@ -323,6 +473,11 @@ func set_fx(fx_registry: RefCounted) -> void:
func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
var reg: RefCounted = fx_registry if fx_registry != null else _fx
# InstanceBaseEffect.cpp::__Warrior_SetGeomgyeongAffect changes the actor's
# reach scale independently of whether the visual MSE can be spawned. The
# same value is consumed by both WeaponTrace::Update and attack collision.
if affect_type == 15:
_set_weapon_trace_reach_scale(1.5 if visible else 1.0)
if not visible:
if _affect_nodes.has(affect_type):
var n: Node = _affect_nodes[affect_type]
@@ -365,6 +520,7 @@ func clear_affects() -> void:
if is_instance_valid(n):
n.queue_free()
_affect_nodes.clear()
_set_weapon_trace_reach_scale(1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
@@ -373,14 +529,30 @@ func insert_delay(d: float) -> void:
# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)。
# scalar = dot(攻击方朝向, 受击方朝向):< 0 面对面 -> 正面受击动作。
func hit_good(scalar: float, stunned: bool) -> void:
# ActorInstanceBattle::__HitGood returns while NAME_DAMAGE_FLYING is
# active; the semantic resume state is still "run"/"wait", so checking
# _state alone would incorrectly replace the knockdown chain with DAMAGE.
if _hit.current_step in HitReaction.KNOCKDOWN_STATES:
return
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
# __HitGreate has both IsKnockDown and __IsStandUpMotion early returns.
if _hit.current_step in HitReaction.KNOCKDOWN_STATES or \
_hit.current_step in HitReaction.STANDUP_STATES:
return
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# CActorInstance::TEMP_Push large-correction tail:
# InterceptOnceMotion(DAMAGE_FLYING) + PushOnceMotion(STAND_UP).
func sync_push_motion() -> bool:
if anim == null:
return false
return _hit.start([["knockdown", "standup"], ["standup"]], _state, _bind_reaction)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
@@ -401,6 +573,8 @@ func _on_playback_finished() -> void:
set_anim_state(_hit.resume)
return
if _state in ["__motion", "__skill", "attack", "combo"]:
_skill_loop_count = 0
_skill_moving = false
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
@@ -408,6 +582,8 @@ func _on_playback_finished() -> void:
func reset_skill_motion() -> void:
if _state in ["__skill", "__motion"]:
_skill_loop_count = 0
_skill_moving = false
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
@@ -420,7 +596,7 @@ func is_in_hit_reaction() -> bool:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_mode_msa(REACTION_FILES.get(step, []))
var msa := _pick_mode_msa(REACTION_FILES.get(step, []), int(REACTION_INDEX.get(step, -1)))
if msa == "":
return false
_bind(step, msa, false, 1.0)
@@ -429,7 +605,16 @@ func _bind_reaction(step: String) -> bool:
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
_state = state
# CActorInstance::__SetMotion calls __ShowEvent() after a valid motion key
# is resolved, before the new motion starts.
if _motion_hidden:
set_motion_hidden(false)
# 40250 __SetMotion always hides the old trace before binding a new motion;
# only a successfully bound attack motion shows it again below.
for trace in _weapon_traces:
if is_instance_valid(trace):
trace.turn_off()
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.
@@ -439,6 +624,11 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
if state in ["attack", "combo"] and _weapon_trace_allowed:
_refresh_weapon_trace()
for trace in _weapon_traces:
if is_instance_valid(trace):
trace.turn_on()
motion_bound.emit(state)
func _pick_msa(dir: String, names: Array) -> String:
@@ -449,25 +639,124 @@ func _pick_msa(dir: String, names: Array) -> String:
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
func _motion_mode_for_dir(dir: String) -> int:
var name := String(dir).get_file()
for mode in MOTION_MODE_DIRS:
if String(MOTION_MODE_DIRS[mode]) == name:
return int(mode)
return 1
func _pick_registered_msa(dir: String, mode: int, index: int, fallback_names: Array) -> String:
if _motion_registry == null or index < 0:
return ""
var registered: Array = _motion_registry.variants(mode, index)
if registered.is_empty():
return ""
var available: Array = []
for entry in registered:
var name := String(entry.get("name", ""))
if name == "":
continue
var path := String(dir).path_join(name)
if FileAccess.file_exists(path):
available.append({"path": path, "weight": int(entry.get("weight", 100))})
if available.is_empty():
return ""
if available.size() == 1:
return String(available[0]["path"])
# CActorInstance::GetRandomMotionKey uses random() % 100, subtracts
# byPercentage in registration order, and leaves sub-index 0 on overflow.
var percentage := randi() % 100
for entry in available:
percentage -= int(entry["weight"])
if percentage < 0:
return String(entry["path"])
return String(available[0]["path"])
func _mode_motion_dir() -> String:
var dirs := _motion_dirs_for_mode(_motion_mode)
return String(dirs[0]) if not dirs.is_empty() else ""
# 40250 CRaceData::GetMotionKey:先确认 mode 已注册,再按 motion index 查找。
# 普通模式缺 key 回退 GENERAL;HORSE_* 武器模式只回退 HORSE,HORSE 也缺失时失败。
# 这条顺序不能只用“具体目录 -> general”,否则骑马武器的未注册动作会错误
# 使用普通姿态,掩盖 InterceptMotion/PushMotion 的失败分支。
func _motion_dir_for_mode(mode: int) -> 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
var base := motion_dir.get_base_dir()
var path := base.path_join(String(MOTION_MODE_DIRS.get(mode, "")))
return path if path != "" and DirAccess.dir_exists_absolute(path) else ""
func _motion_dirs_for_motion(mode: int, index: int) -> Array[String]:
if _motion_registry == null or not _motion_registry.source_loaded:
return _motion_dirs_for_mode(mode)
if not _motion_registry.has_mode(mode):
return []
var selected := mode
if not _motion_registry.has_variants(mode, index):
selected = 9 if mode >= 10 and mode <= 15 else 1
if not _motion_registry.has_variants(selected, index):
return []
var path := _motion_dir_for_mode(selected)
return [path] if path != "" else []
func _motion_dirs_for_mode(mode: int) -> Array[String]:
if motion_dir == "":
return []
var dirs: Array[String] = []
var base := motion_dir.get_base_dir()
var specific := base.path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
if DirAccess.dir_exists_absolute(specific):
dirs.append(specific)
if mode >= 10 and mode <= 15:
var horse := base.path_join(String(MOTION_MODE_DIRS[9]))
if DirAccess.dir_exists_absolute(horse) and not dirs.has(horse):
dirs.append(horse)
if not dirs.has(motion_dir):
dirs.append(motion_dir)
return dirs
func _motion_file(state: String) -> String:
for dir in [_mode_motion_dir(), motion_dir]:
var index := int(MOTION_INDEX_BY_STATE.get(state, -1))
var fallback_names: Array = [state]
if state == "damage":
fallback_names = ["damage", "damage_1"]
elif state == "damage_back":
fallback_names = ["damage_2", "damage_3"]
elif state == "knockdown":
fallback_names = ["damage_flying"]
elif state == "knockdown_back":
fallback_names = ["back_damage_flying"]
elif state == "standup":
fallback_names = ["falling_stand"]
elif state == "standup_back":
fallback_names = ["back_falling_stand"]
elif state in ["attack", "combo"]:
fallback_names = ["attack", "attack_1"] if state == "attack" else ["combo_01"]
for dir in _motion_dirs_for_motion(_motion_mode, index):
if dir == "":
continue
if index >= 0:
var registered := _pick_registered_msa(dir, _motion_mode_for_dir(dir), index, fallback_names)
if registered != "":
return registered
for ext in [".msa", ".gr2"]:
var path: String = String(dir).path_join(state + String(ext))
if _motion_registry != null and _motion_registry.source_loaded:
continue
var path: String = String(dir).path_join(String(fallback_names[0]) + 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 _pick_mode_msa(names: Array, motion_index: int = -1) -> String:
for dir in _motion_dirs_for_motion(_motion_mode, motion_index):
var picked := _pick_registered_msa(dir, _motion_mode_for_dir(dir), motion_index, names)
if picked == "" and (_motion_registry == null or not _motion_registry.source_loaded):
picked = _pick_msa(dir, names)
if picked != "":
return picked
return ""
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
@@ -498,9 +787,53 @@ func _set(prop: StringName, val: Variant) -> bool:
model.set(prop, val)
if String(prop) == "gr2_path":
call_deferred("_ground_model")
if String(prop) == "weapon_gr2":
if String(val) == "":
_destroy_weapon_trace()
else:
call_deferred("_refresh_weapon_trace")
return true
return false
func _refresh_weapon_trace() -> void:
if not _weapon_trace_allowed or not is_instance_valid(model):
return
# ActorInstanceAttach.cpp creates one CWeaponTrace for each attached weapon
# part. Dagger and mounted fan can populate both PART_WEAPON and
# PART_WEAPON_LEFT; keep the same part order and never collapse them into a
# single trace.
var weapons: Array[Node3D] = []
for part_name in ["Weapon", "Shield"]:
var part := model.find_child(part_name, true, false) as Node3D
if part != null and is_instance_valid(part):
weapons.append(part)
if weapons.is_empty():
_destroy_weapon_trace()
return
while _weapon_traces.size() > weapons.size():
var extra: Node = _weapon_traces.pop_back()
if is_instance_valid(extra):
extra.detach_weapon()
extra.free()
while _weapon_traces.size() < weapons.size():
var trace: Node = WeaponTrace.new()
trace.name = "WeaponTrace_%d" % _weapon_traces.size()
add_child(trace)
_weapon_traces.append(trace)
for i in weapons.size():
var trace: Node = _weapon_traces[i]
trace.attach_to_weapon(weapons[i])
trace.set_reach_scale(_weapon_trace_reach_scale)
_weapon_trace = _weapon_traces[0]
func _destroy_weapon_trace() -> void:
for trace in _weapon_traces:
if is_instance_valid(trace):
trace.detach_weapon()
trace.free()
_weapon_traces.clear()
_weapon_trace = null
# 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.