# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。 # # var pv := preload("res://ui/player_view.gd").new() # pv.build(assets_root, race) # race 0..7(GC_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_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 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 的文件名回退。 # 实际选择优先消费 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::SetMoveSpeed:m_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/s,movSpd 100):x = walk,y = 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 只回退 GENERAL,HORSE_* 只回退 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 / __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() # 受击方 m_DefendingPointInstanceList 的来源:root/msm/_.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 回退 GENERAL;HORSE_* 武器模式只回退 HORSE,HORSE 也缺失时失败。 # 这条顺序不能只用“具体目录 -> 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 ""