# MobView (真机字节校准收尾) —— 怪 / NPC 的真模型:Metin2Model + Metin2AnimPlayer。 # # var mv := preload("res://ui/mob_view.gd").new() # if mv.build(assets_root, proto, race): # race = mob_proto vnum(GC_CHARACTER_ADD.race) # ... # mv.set_anim_state("run") # wait/run/attack/damage/dead/skill/emotion # # 目录:/*/ymir work/{monster,npc}// # .gr2 = 基础网格,.dds = 贴图,motlist.txt = 动作索引表: # GENERAL WAIT 00.msa 65 (组 动作名 文件 权重) extends Node3D # net_world 的状态名 -> motlist.txt 的动作名(按优先级取第一个存在的) const STATE_MOTIONS := { "wait": ["WAIT", "WAIT1"], "walk": ["WALK", "RUN"], "run": ["RUN", "WALK"], "attack": ["NORMAL_ATTACK", "NORMAL_ATTACK1", "SPECIAL_1"], "combo": ["NORMAL_ATTACK1", "NORMAL_ATTACK"], "skill": ["SPECIAL_1", "SKILL", "NORMAL_ATTACK"], "damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"], "dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"], "emotion": ["WAIT"], } var model: Node # Metin2Model var anim: Node # Metin2AnimPlayer var _dir := "" var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _state := "" # root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。 # 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。 static var _npclist: Dictionary = {} static var _npclist_loaded := false static func _load_npclist(assets_root: String) -> void: if _npclist_loaded: return _npclist_loaded = true for rel in ["root/npclist.txt", "npclist.txt", "locale/npclist.txt"]: var p := assets_root.path_join(rel) if not FileAccess.file_exists(p): continue var f := FileAccess.open(p, FileAccess.READ) while f and not f.eof_reached(): var line := f.get_line().strip_edges() if line == "": continue var parts := line.split("\t", false) if parts.size() < 2: parts = line.split(" ", false) if parts.size() < 2: continue var vnum := int(parts[0]) var code := String(parts[1]).strip_edges() if vnum > 0 and code != "": _npclist[vnum] = code break func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool: if not ClassDB.class_exists("Metin2Model") or race < 1: return false _load_npclist(assets_root) # 候选代号:npclist 优先,proto 名兜底 var stems: Array[String] = [] var code: String = _npclist.get(race, "") if code != "": stems.append(code) for c: String in _folder_candidates(proto, race): if not stems.has(c): stems.append(c) # 逐个代号:目录名从完整代号逐段回退(bear_brown -> bear),网格名保留完整代号 var mesh_stem := "" for stem in stems: var segs := stem.split("_") for cut in range(segs.size(), 0, -1): var folder := "_".join(segs.slice(0, cut)) var d := _find_dir(assets_root, folder) if d != "": _dir = d mesh_stem = stem break if _dir != "": break if _dir == "": return false var gr2 := _dir.path_join(mesh_stem + ".gr2") if not FileAccess.file_exists(gr2): gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格 if not FileAccess.file_exists(gr2): gr2 = _pick_base_gr2() # 目录里第一个非动作 .gr2 if gr2 == "": return false if pump.is_valid(): pump.call() model = ClassDB.instantiate("Metin2Model") model.name = "Metin2Model" model.set("texture_dir", _dir) model.set("gr2_path", gr2) # 重:解 gr2 + 建网格 if pump.is_valid(): pump.call() add_child(model) _load_motlist() if ClassDB.class_exists("Metin2AnimPlayer"): anim = ClassDB.instantiate("Metin2AnimPlayer") anim.set("model_path", NodePath("../Metin2Model")) anim.set("blend_time", 0.15) add_child(anim) set_anim_state("wait") CharShadow.attach(self, 1.6) # 怪脚印大一点 return true func set_anim_state(s: String) -> void: if s == _state or anim == null: return _state = s var msa := _motion_for(s) if msa == "": return anim.set("loop", s in ["wait", "walk", "run"]) anim.set("anim_path", msa) # 供 net_world._on_info 用(如果它调 set_display_name) func set_display_name(_n: String) -> void: pass # --- 内部 ------------------------------------------------------------------- func _folder_candidates(proto: Node, race: int) -> Array: if proto == null or not proto.has_method("mob"): return [] var m: Dictionary = proto.mob(race) if m.is_empty(): return [] var out: Array = [] var raws: Array[String] = [String(m.get("name", "")), String(m.get("locale_name", ""))] for raw in raws: var s: String = raw.strip_edges().to_lower() if s == "": continue for c in [s.replace(" ", "_"), s.replace(" ", ""), s.replace(" ", "-")]: if c != "" and not out.has(c): out.append(c) return out func _motion_for(state: String) -> String: for mo: String in STATE_MOTIONS.get(state, []): if _motions.has(mo): return _motions[mo] # 兜底:目录里有 .msa 直接用 for mo: String in STATE_MOTIONS.get(state, []): var p := _dir.path_join(mo.to_lower() + ".msa") if FileAccess.file_exists(p): return p return _motions.get("WAIT", "") func _load_motlist() -> void: var ml := _dir.path_join("motlist.txt") if not FileAccess.file_exists(ml): return var f := FileAccess.open(ml, FileAccess.READ) while f and not f.eof_reached(): var parts := f.get_line().strip_edges().split(" ", false) if parts.size() >= 3: var motion := String(parts[1]) var file := String(parts[2]) var abs := _dir.path_join(file) if FileAccess.file_exists(abs): _motions[motion] = abs func _pick_base_gr2() -> String: var da := DirAccess.open(_dir) if da == null: return "" for fn in da.get_files(): if not fn.ends_with(".gr2"): continue if fn.contains("_lod_"): continue # 动作文件通常是纯数字命名(00.gr2 / 20_1.gr2) var stem := fn.get_basename() if stem.is_valid_int() or (stem.contains("_") and stem.split("_")[0].is_valid_int()): continue return _dir.path_join(fn) return "" func _find_dir(assets_root: String, folder: String) -> String: # /*/ymir work/{monster,npc}/ var rels := [ "ymir work/monster/%s" % folder, "ymir work/monster2/%s" % folder, "ymir work/npc/%s" % folder, "ymir work/npc2/%s" % folder, "Monster/ymir work/monster/%s" % folder, "Monster/ymir work/monster2/%s" % folder, "NPC/ymir work/npc/%s" % folder, "NPC/ymir work/npc2/%s" % folder, ] for rel: String in rels: 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(): for rel: String in rels: var c := assets_root.path_join(sub).path_join(rel) if DirAccess.dir_exists_absolute(c): return c return ""