# 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 的 set 给内部 Metin2Model, # 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`。 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] var model: Node # Metin2Model var anim: Node # Metin2AnimPlayer var motion_dir := "" var action_dir := "" var _state := "" var _assets_root := "" 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"] # pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下 # 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。 func build(assets_root: String, race: int, pump := Callable()) -> bool: _assets_root = assets_root if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"): return false var cls: String = CLASS_OF[race & 3] var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls var base := _resolve_dir(assets_root, pc_dir) if base == "": base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模 if base == "": return false motion_dir = base.path_join("general") action_dir = base.path_join("action") 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) 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() 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 "") func set_anim_state(s: String) -> void: if s == _state or anim == null or motion_dir == "": return _state = s var msa := motion_dir.path_join(s + ".msa") if not FileAccess.file_exists(msa): msa = motion_dir.path_join(s + ".gr2") if FileAccess.file_exists(msa): anim.set("loop", s in ["wait", "walk", "run"]) anim.set("anim_path", msa) _refresh_sound_script(msa) # 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 _state = "__motion" anim.set("loop", false) # A repeated command can name the same clip; clear first because the native # player treats assigning the current anim_path as a no-op. if String(anim.get("anim_path")) == msa: anim.set("anim_path", "") anim.set("anim_path", msa) _refresh_sound_script(msa) return true func _on_playback_finished() -> void: # CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip. # When it ends the actor leaves the pushed state (CActorInstance::IsPushing), # so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate. if _state == "__motion" or _state == "damage": _state = "" set_anim_state("wait") # True while the one-shot hit/knockback clip is still playing. func is_in_hit_reaction() -> bool: return _state == "damage" func _process(_delta: float) -> void: 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: model.set(prop, val) if String(prop) == "gr2_path": call_deferred("_ground_model") return true return false 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 ""