Files
mtgodot-poc/project/ui/player_view.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

871 lines
34 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
#
# var pv := preload("res://ui/player_view.gd").new()
# pv.build(assets_root, race) # race 0..7GC_MAIN_CHARACTER.race
# game_scene.set_player_model(pv)
# pv.set_anim_state("run") # wait/walk/run/attack/dead
#
# 转发 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
# 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_bonePART_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 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_ATTACKCOMBO_ATTACK_1 = 14
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件名回退。
# 实际选择优先消费 MotionRegistry 中的 (mode,index) 注册项及其权重。
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
}
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)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var motion_dir := ""
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 被服务器踢。
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]
# pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7.
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls
var base := _resolve_dir(assets_root, pc_dir)
if base == "":
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"
# 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 == "":
model.free()
model = null
return false
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
# This view is built off-tree: set_gr2_path only stores the path until ready.
# Validate the source explicitly rather than reading a not-yet-loaded report.
if String(model.call("probe_gr2", body)).contains("FAILED"):
model.free()
model = null
return false
if pump.is_valid(): pump.call()
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
if hair != "":
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
if pump.is_valid(): pump.call()
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("loop", true)
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
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
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:
# 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)
return
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
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
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
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
# EntityStore 退回按 duration 插值,这里保持原速播放。
func set_move_speed(moving_speed: int) -> void:
_move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \
else float(moving_speed) / 100.0
if anim != null and _state in MOVE_STATES and not _hit.active:
anim.set("time_scale", _hit.scale_for(_move_speed_ratio))
func _loop_speed(state: String) -> float:
return _move_speed_ratio if state in MOVE_STATES else 1.0
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
if motion_dir == "":
return Vector2.ZERO
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
# 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
# kiss/french-kiss variants, exactly like ActDualEmotion in the native client.
func set_motion_id(motion: int, target_race: int = -1) -> bool:
if anim == null:
return false
var name := ""
if motion == 305:
name = "clap"
elif motion == 306:
name = "cheers_1"
elif motion == 307:
name = "cheers_2"
elif motion >= 308 and motion <= 311:
var job := motion - 308
if motion == 308 and target_race >= 0:
job = target_race & 3
name = "kiss_with_" + CLASS_OF[clampi(job, 0, 3)]
elif motion >= 312 and motion <= 315:
var french_job := motion - 312
if motion == 312 and target_race >= 0:
french_job = target_race & 3
name = "french_kiss_with_" + CLASS_OF[clampi(french_job, 0, 3)]
elif motion >= 316 and motion <= 319:
name = "slap_hit"
elif motion >= 320 and motion <= 323:
name = "slap_hurt"
elif motion == 324:
name = "dig"
elif motion >= 325 and motion <= 340:
name = "dance_%d" % (motion - 324)
elif motion == 341:
name = "congratulation"
elif motion == 342:
name = "forgive"
elif motion == 343:
name = "angry"
elif motion == 344:
name = "attractive"
elif motion == 345:
name = "sad"
elif motion == 346:
name = "shy"
elif motion == 347:
name = "cheerup"
elif motion == 348:
name = "banter"
elif motion == 349:
name = "joy"
elif motion == 5 or motion == 6 or motion == 8 or motion == 9:
set_anim_state("damage")
return true
elif motion == 11 or motion == 12:
set_anim_state("dead")
return true
elif motion >= 13 and motion <= 21:
set_anim_state("attack")
return true
else:
return false
var msa := action_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
return false
_bind("__motion", msa, false, 1.0)
return true
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio })
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK/COMBO_ATTACK_1 使用
# playersettingmodule 注册的 index 14,后续段使用 combo_0N.msa);普通武器模式
# 缺 key 时由 CRaceData 只回退 GENERALHORSE_* 只回退 HORSE。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool:
if anim == null or motion_dir == "":
return false
var names: Array = ["attack", "attack_1"]
if index > MOTION_NORMAL_ATTACK:
names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)]
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)
return true
# 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, loop_count: int = 0,
moving_skill: bool = false) -> 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
_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
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
# 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]
_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()
_set_weapon_trace_reach_scale(1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.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()
# 受击方 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:
# §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 in ["__motion", "__skill", "attack", "combo"]:
_skill_loop_count = 0
_skill_moving = false
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
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")
_ground_model()
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
return _hit.active
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
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)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_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.
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)
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:
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 _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 回退 GENERALHORSE_* 武器模式只回退 HORSEHORSE 也缺失时失败。
# 这条顺序不能只用“具体目录 -> general”,否则骑马武器的未注册动作会错误
# 使用普通姿态,掩盖 InterceptMotion/PushMotion 的失败分支。
func _motion_dir_for_mode(mode: int) -> String:
if motion_dir == "":
return ""
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:
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"]:
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, 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
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
var current_time: float = anim.call("get_time")
var frame := int(floor(current_time * 60.0))
if frame == _sound_frame:
return
_sound_frame = frame
_audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z,
frame, _sound_instances, true)
func _refresh_sound_script(motion_path: String) -> void:
_sound_instances = []
_sound_frame = -1
if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"):
return
if _audio.has_method("load_mss_for_motion"):
_sound_instances = _audio.load_mss_for_motion(motion_path)
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")
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.
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"))
# --- 路径解析 -------------------------------------------------------------
func _resolve_dir(assets_root: String, rel: String) -> String:
var d := assets_root.path_join(rel)
if DirAccess.dir_exists_absolute(d):
return d
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
var c := assets_root.path_join(sub).path_join(rel)
if DirAccess.dir_exists_absolute(c):
return c
return ""
func _first_existing(paths: Array) -> String:
for p: String in paths:
if FileAccess.file_exists(p):
return p
return ""