# 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 的状态名 -> 40250 CRaceMotionData::EName。 # motlist.txt 中的多个别名会先归并到同一个动作索引,再按权重选 vector 中的 # 一个文件;这里保留名称表只为兼容没有 motlist 的测试替身。 const STATE_MOTIONS := { "wait": ["WAIT"], "walk": ["WALK"], "run": ["RUN"], "attack": ["NORMAL_ATTACK"], "combo": ["COMBO_ATTACK"], "skill": ["SPECIAL", "SKILL1", "SKILL2", "SKILL3", "SKILL4", "SKILL5"], "damage": ["FRONT_DAMAGE"], "dead": ["FRONT_DEAD"], "emotion": ["WAIT"], # §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。 # 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。 "damage_back": ["BACK_DAMAGE"], "knockdown": ["FRONT_KNOCKDOWN"], "knockdown_back": ["BACK_KNOCKDOWN"], "standup": ["FRONT_STANDUP"], "standup_back": ["BACK_STANDUP"], } # CRaceManager::__LoadRaceMotionList 的完整名称 -> EName 映射。 # 后缀回退(WAIT3 -> WAIT 等)在 _npc_motion_index 中单独实现,不能把 # FRONT_DAMAGE / BACK_DAMAGE 等不同方向错误地合并。 const NPC_MOTION_INDEX := { "SPAWN": 25, "WAIT": 1, "WAIT1": 1, "WAIT2": 1, "WALK": 2, "WALK1": 2, "WALK2": 2, "RUN": 3, "RUN1": 3, "RUN2": 3, "STOP": 32, "DEAD": 11, "COMBO_ATTACK": 14, "COMBO_ATTACK1": 15, "COMBO_ATTACK2": 16, "NORMAL_ATTACK": 13, "NORMAL_ATTACK1": 13, "NORMAL_ATTACK2": 13, "FRONT_DAMAGE": 5, "FRONT_DAMAGE1": 5, "FRONT_DAMAGE2": 5, "FRONT_DAMAGE3": 5, "FRONT_DEAD": 11, "FRONT_DEAD1": 11, "FRONT_DEAD2": 11, "FRONT_KNOCKDOWN": 6, "FRONT_KNOCKDOWN1": 6, "FRONT_STANDUP": 7, "FRONT_STANDUP1": 7, "BACK_DAMAGE": 8, "BACK_DAMAGE1": 8, "BACK_DEAD": 12, "BACK_DEAD1": 12, "BACK_DEAD2": 12, "BACK_KNOCKDOWN": 9, "BACK_KNOCKDOWN1": 9, "BACK_STANDUP": 10, "BACK_STANDUP1": 10, "SPECIAL": 33, "SPECIAL1": 34, "SPECIAL2": 35, "SPECIAL3": 36, "SPECIAL4": 37, "SPECIAL5": 38, "SKILL1": 171, "SKILL2": 172, "SKILL3": 173, "SKILL4": 174, "SKILL5": 175, } const STATE_MOTION_INDEXES := { "wait": [1], "walk": [2], "run": [3], "attack": [13], "combo": [14], "skill": [33, 171, 172, 173, 174, 175], "damage": [5], "dead": [11], "emotion": [1], "damage_back": [8], "knockdown": [6], "knockdown_back": [9], "standup": [7], "standup_back": [10], } const HitReaction = preload("res://ui/hit_reaction.gd") const MsaMotion = preload("res://ui/msa_motion.gd") const MOVE_STATES := ["walk", "run"] const HitCollision = preload("res://hit_collision.gd") # CActorInstance::__SetMotion 尾:动作绑定完成。 signal motion_bound(state: String) var model: Node # Metin2Model var anim: Node # Metin2AnimPlayer var _dir := "" var _mesh_stem := "" var _hit := HitReaction.new() var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _motion_variants := {} # EName(int) -> [{motion, path, weight}, ...] var _motlist_loaded := false var _state := "" var _move_speed_ratio := 1.0 var _motion_hidden := false var _last_resolution := {} var _audio: Node var _sound_instances: Array = [] var _sound_frame := -1 # root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。 # 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。 static var _npclist: Dictionary = {} static var _race_src_name: Dictionary = {} static var _npclist_loaded := false # race -> { "dir": String, "mesh_stem": String, "gr2": String, # "motions": Dictionary, "motion_variants": Dictionary, "motlist_loaded": bool } # 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。 static var _race_spec_cache: Dictionary = {} static func clear_cache() -> void: _race_spec_cache.clear() 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 elif vnum == 0 and parts.size() >= 3: var src := String(parts[2]).strip_edges() if code != "" and src != "": _race_src_name[code] = src break func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool: if not ClassDB.class_exists("Metin2Model") or race < 1: return false var gr2 := "" var cached: Variant = _race_spec_cache.get(race, null) if cached is Dictionary: _dir = String(cached.get("dir", "")) _mesh_stem = String(cached.get("mesh_stem", "")) gr2 = String(cached.get("gr2", "")) _motions = (cached.get("motions", {}) as Dictionary).duplicate() _motion_variants = (cached.get("motion_variants", {}) as Dictionary).duplicate(true) _motlist_loaded = bool(cached.get("motlist_loaded", false)) else: _load_npclist(assets_root) var spec := _find_model_spec(assets_root, proto, race) if spec.is_empty(): return false _dir = String(spec.get("dir", "")) _mesh_stem = String(spec.get("mesh_stem", "")) gr2 = String(spec.get("gr2", "")) _load_motlist(String(spec.get("motion_list", ""))) _race_spec_cache[race] = { "dir": _dir, "mesh_stem": _mesh_stem, "gr2": gr2, "motions": _motions.duplicate(), "motion_variants": _motion_variants.duplicate(true), "motlist_loaded": _motlist_loaded, } if pump.is_valid(): pump.call() model = ClassDB.instantiate("Metin2Model") model.name = "Metin2Model" # NPC/monster GR2 uses the same source winding as PCs. Without conversion, # back-face culling exposes interiors and hides the outward-facing surfaces. model.set("flip_winding", true) model.set("texture_dir", _dir) model.set("gr2_path", gr2) # 重:解 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("blend_time", 0.15) add_child(anim) if anim.has_signal("playback_finished"): anim.playback_finished.connect(_on_playback_finished) set_anim_state("wait") CharShadow.attach(self, 1.6) # 怪脚印大一点 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. Mob/NPC views expose the # same actor-level seam as PlayerView even before net_world gets unified event # routing for every remote actor. 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 func set_anim_state(s: String) -> void: # 40250 SetLoopMotion reaches __SetMotion even for the current motion, which # also restores visibility after a CHARACTER_HIDE motion event. if (s == _state and not _motion_hidden) or anim == null: 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 # 40250 SetLoopMotion first resolves GetMotionKey. A missing key returns # without cancelling the current motion or changing the logical state. var resolution := resolve_motion(s) _last_resolution = resolution var msa := String(_last_resolution.path) if msa == "": # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 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: var w := MsaMotion.move_speed(String(resolve_motion("walk").path)) var r := MsaMotion.move_speed(String(resolve_motion("run").path)) return Vector2(w, r) # CGraphicThingInstance::InsertDelay(fStiffenTime) func insert_delay(d: float) -> void: _hit.insert_delay(d, anim) # CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp) func hit_good(scalar: float, stunned: bool) -> void: if _hit.current_step in HitReaction.KNOCKDOWN_STATES or _hit.current_step in HitReaction.STANDUP_STATES: return _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) func hit_greate(scalar: float, stunned: bool) -> void: 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() func is_in_hit_reaction() -> bool: return _hit.active # 受击方防御球:怪目录的 .msm(<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。 func get_defending_spheres() -> Array: if _dir == "": return [] var cands: Array = [] if _mesh_stem != "": cands.append(_dir.path_join(_mesh_stem + ".msm")) cands.append(_dir.path_join(_dir.get_file() + ".msm")) for p: String in cands: if FileAccess.file_exists(p): return HitCollision.parse_msm_defending(p) var da := DirAccess.open(_dir) if da: for fn in da.get_files(): if fn.to_lower().ends_with(".msm"): return HitCollision.parse_msm_defending(_dir.path_join(fn)) return [] func _on_playback_finished() -> void: if not _hit.active: return if _hit.advance(_bind_reaction) or _hit.real_dead: return _state = "" set_anim_state(_hit.resume) func _bind_reaction(step: String) -> bool: if anim == null: return false var r := resolve_motion(step) _last_resolution = r var msa := String(r.path) 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() once a valid motion key is # selected, so a subsequent action cannot remain hidden accidentally. if _motion_hidden: set_motion_hidden(false) anim.set("loop", loop) 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) motion_bound.emit(state) # Read-only motion resolver used by the forest acceptance harness. Returning # the selected .msa before playback lets the test distinguish a real action # from a silent "keep the previous animation" fallback. func get_motion_path(state: String) -> String: return String(resolve_motion(state).path) ## requested_state / motion / path / fallback_reason。fallback_reason: ## "" 首选动作名命中 ## "client_alias:" 本客户端的同类动作别名(如 run -> WALK) ## "motlist_missing:" motlist 未列出,按目录里的 .msa 兜底 ## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作 func resolve_motion(state: String) -> Dictionary: var names: Array = STATE_MOTIONS.get(state, []) var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""} # Once a real motlist was loaded, use the same EName vector as CRaceData. # Do not fall through to a different state (RUN -> WALK, DAMAGE -> WAIT) # when the registered key is absent: SetLoopMotion would return FALSE. if _motlist_loaded: for motion_index: int in STATE_MOTION_INDEXES.get(state, []): if not _motion_variants.has(motion_index): continue var selected: Dictionary = _pick_motion_variant(motion_index) if selected.is_empty(): continue out.motion = String(selected.get("motion", "")) out.path = String(selected.get("path", "")) return out out.fallback_reason = "reference_keep_current_motion" return out for mo: String in names: if _motions.has(mo): out.motion = mo out.path = String(_motions[mo]) if mo != String(names[0]): out.fallback_reason = "client_alias:" + mo return out for mo: String in names: var p := _dir.path_join(mo.to_lower() + ".msa") if FileAccess.file_exists(p): out.motion = mo out.path = p out.fallback_reason = "motlist_missing:" + p.get_file() return out out.fallback_reason = "reference_keep_current_motion" return out func _pick_motion_variant(motion_index: int) -> Dictionary: var variants: Array = _motion_variants.get(motion_index, []) if variants.is_empty(): return {} if variants.size() == 1: return variants[0] # CActorInstance::GetRandomMotionKey: random() % 100, subtract each # byPercentage in registration order, and leave sub-index 0 when the # configured weights do not cover the whole [0, 100) interval. var remaining := randi() % 100 for variant: Dictionary in variants: remaining -= int(variant.get("weight", 0)) if remaining < 0: return variant return variants[0] func last_motion_resolution() -> Dictionary: return _last_resolution.duplicate() 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) # 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。 func set_display_name(n: String) -> void: var tag := get_node_or_null("Label3D") as Label3D if tag == null: tag = Label3D.new() tag.name = "Label3D" tag.position.y = 2.1 tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.pixel_size = 0.006 add_child(tag) tag.text = n func _ground_model() -> void: if model and model.has_method("get_ground_offset"): model.position.y = float(model.call("get_ground_offset")) # --- 内部 ------------------------------------------------------------------- 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 _load_motlist(motion_list_path: String = "") -> void: _motions.clear() _motion_variants.clear() _motlist_loaded = false var ml := motion_list_path if motion_list_path != "" else _dir.path_join("motlist.txt") if not FileAccess.file_exists(ml): return _motlist_loaded = true var f := FileAccess.open(ml, FileAccess.READ) if f == null: _motlist_loaded = false return var last_percent := 0 var last_damage_path := "" var has_spawn := false while f and not f.eof_reached(): # CMemoryTextFileLoader + sscanf treat tabs and repeated spaces as # whitespace; normalize both before splitting. var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false) if parts.size() >= 3: var motion := String(parts[1]).to_upper() var file := String(parts[2]) if parts.size() >= 4: last_percent = int(parts[3]) var motion_index := _npc_motion_index(motion) if motion_index < 0: continue var abs := _dir.path_join(file) if not FileAccess.file_exists(abs): continue var entry := {"motion": motion, "path": abs, "weight": last_percent} if not _motion_variants.has(motion_index): _motion_variants[motion_index] = [] (_motion_variants[motion_index] as Array).append(entry) # Keep the first direct alias for compatibility/debug output. The # actual loaded path is always selected from _motion_variants. if not _motions.has(motion): _motions[motion] = abs if motion_index == 25: has_spawn = true elif motion_index == 5: last_damage_path = abs if not has_spawn and last_damage_path != "": # Preserve the reference loader's exact fallback: reuse the final # parsed nPercent value, not the damage row's own weight. var spawn_entry := {"motion": "SPAWN", "path": last_damage_path, "weight": last_percent} _motion_variants[25] = [spawn_entry] if not _motions.has("SPAWN"): _motions["SPAWN"] = last_damage_path func _npc_motion_index(raw_motion: String) -> int: var motion := raw_motion.to_upper() if NPC_MOTION_INDEX.has(motion): return int(NPC_MOTION_INDEX[motion]) # CRaceManager accepts at most two trailing characters as an alias # (WAIT3 / WAIT20 -> WAIT), but rejects unrelated names such as DAMAGE. for trim_count in [1, 2]: if motion.length() <= trim_count: break var base := motion.substr(0, motion.length() - trim_count) if NPC_MOTION_INDEX.has(base): return int(NPC_MOTION_INDEX[base]) return -1 func _pick_base_gr2() -> String: return _pick_base_gr2_in(_dir) func _pick_base_gr2_in(d: String) -> String: var da := DirAccess.open(d) 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 d.path_join(fn) return "" # MSM BaseModelFileName 解析:如 campfire.msm 指向 zone/b/obj/general_obj_campfire.gr2 func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String: if not FileAccess.file_exists(msm_path): return "" var f := FileAccess.open(msm_path, FileAccess.READ) if f == null: return "" var content := f.get_as_text() var regex := RegEx.create_from_string("(?i)BaseModelFileName\\s+\"([^\"]+)\"") if regex == null: return "" var match := regex.search(content) if match == null: return "" var raw_path := match.get_string(1).replace("\\", "/").strip_edges() var lower := raw_path.to_lower() var rel := "" for prefix in ["d:/ymir work/", "c:/ymir work/", "ymir work/", "/ymir work/"]: if lower.begins_with(prefix): rel = raw_path.substr(prefix.length()) break if rel == "": rel = raw_path for candidate in [ assets_root.path_join("ymir work").path_join(rel), assets_root.path_join(rel), assets_root.path_join("Zone/ymir work").path_join(rel), assets_root.path_join("NPC/ymir work").path_join(rel), assets_root.path_join("Monster/ymir work").path_join(rel), ]: if FileAccess.file_exists(candidate): return candidate var da := DirAccess.open(assets_root) if da: for sub in da.get_directories(): var c := assets_root.path_join(sub).path_join("ymir work").path_join(rel) if FileAccess.file_exists(c): return c c = assets_root.path_join(sub).path_join(rel) if FileAccess.file_exists(c): return c return "" func _resolve_msm_motion_list(msm_path: String) -> String: if not FileAccess.file_exists(msm_path): return "" var f := FileAccess.open(msm_path, FileAccess.READ) if f == null: return "" var regex := RegEx.create_from_string("(?i)MotionListFileName\\s+\\\"([^\\\"]+)\\\"") if regex == null: return "" var match := regex.search(f.get_as_text()) if match == null: return "" var raw := match.get_string(1).replace("\\", "/").strip_edges() var path := msm_path.get_base_dir().path_join(raw) return path if FileAccess.file_exists(path) else "" func _motion_list_for_dir(d: String, mesh_stem: String) -> String: # RaceData::LoadRaceData gets the motion list name from the selected MSM. # The plain motlist.txt fallback covers older NPC assets whose MSM has no # MotionListFileName field and is not used when an explicit field exists. var msm_candidates := [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"] for msm_name: String in msm_candidates: var msm_path := d.path_join(msm_name) if not FileAccess.file_exists(msm_path): continue var explicit := _resolve_msm_motion_list(msm_path) if explicit != "": return explicit var msm_text := FileAccess.get_file_as_string(msm_path) if msm_text.findn("MotionListFileName") >= 0: return "" var fallback := d.path_join("motlist.txt") return fallback if FileAccess.file_exists(fallback) else "" func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary: var codes: Array[String] = [] var primary: String = _npclist.get(race, "") if primary != "": codes.append(primary) for c: String in _folder_candidates(proto, race): if not codes.has(c): codes.append(c) for code in codes: var spec := _resolve_code_spec(assets_root, code) if not spec.is_empty(): return spec return {} func _resolve_code_spec(assets_root: String, code: String) -> Dictionary: if code.begins_with("#"): # 40250 LOAD_LOCAL_RESOURCE:直接相对路径(如 #season1/npc/chagirap/) var rel := code.trim_prefix("#").strip_edges().trim_prefix("/").trim_suffix("/") var stem := rel.get_file() var candidate_dirs: Array[String] = [ assets_root.path_join(rel), assets_root.path_join(rel.replace("chenjo", "cheonjo")), ] var sub := rel.split("/")[0] candidate_dirs.append(assets_root.path_join(sub).path_join(rel)) candidate_dirs.append(assets_root.path_join(sub).path_join(rel.replace("chenjo", "cheonjo"))) var da := DirAccess.open(assets_root) if da: for d in da.get_directories(): candidate_dirs.append(assets_root.path_join(d).path_join("ymir work").path_join(rel.get_slice("/", 1)).path_join(stem)) candidate_dirs.append(assets_root.path_join(d).path_join(rel)) for d in candidate_dirs: var spec := _check_dir_for_model(assets_root, d, stem) if not spec.is_empty(): return spec return {} var mesh_stem := code var folders: Array[String] = [] # npclist vnum=0 别名(如 red_wild_boar -> wild_boar) if _race_src_name.has(code): folders.append(String(_race_src_name[code])) folders.append(code) var segs := code.split("_") for cut in range(segs.size(), 0, -1): var sub_folder := "_".join(segs.slice(0, cut)) if not folders.has(sub_folder): folders.append(sub_folder) for folder in folders: var candidate_dirs: Array[String] = [ assets_root.path_join("ymir work/monster/%s" % folder), assets_root.path_join("ymir work/monster2/%s" % folder), assets_root.path_join("ymir work/npc/%s" % folder), assets_root.path_join("ymir work/npc2/%s" % folder), assets_root.path_join("ymir work/guild/%s" % folder), assets_root.path_join("Monster/ymir work/monster/%s" % folder), assets_root.path_join("Monster/ymir work/monster2/%s" % folder), assets_root.path_join("monster2/ymir work/monster2/%s" % folder), assets_root.path_join("NPC/ymir work/npc/%s" % folder), assets_root.path_join("NPC/ymir work/npc2/%s" % folder), assets_root.path_join("npc2/ymir work/npc2/%s" % folder), assets_root.path_join("guild/ymir work/guild/%s" % folder), ] var da := DirAccess.open(assets_root) if da: for sub_dir in da.get_directories(): for rel in [ "ymir work/monster/%s" % folder, "ymir work/monster2/%s" % folder, "ymir work/npc/%s" % folder, "ymir work/npc2/%s" % folder, "ymir work/guild/%s" % folder, ]: candidate_dirs.append(assets_root.path_join(sub_dir).path_join(rel)) for d in candidate_dirs: var spec := _check_dir_for_model(assets_root, d, mesh_stem) if not spec.is_empty(): return spec return {} func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) -> Dictionary: if not DirAccess.dir_exists_absolute(d): return {} # 1. 对应代号的网格 var gr2 := d.path_join(mesh_stem + ".gr2") if FileAccess.file_exists(gr2): return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, "motion_list": _motion_list_for_dir(d, mesh_stem)} # 2. 目录同名主网格 gr2 = d.path_join(d.get_file() + ".gr2") if FileAccess.file_exists(gr2): return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, "motion_list": _motion_list_for_dir(d, mesh_stem)} # 3. 目录里第一个非动作 .gr2 gr2 = _pick_base_gr2_in(d) if gr2 != "": return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2, "motion_list": _motion_list_for_dir(d, mesh_stem)} # 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm) for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]: var msm_p := d.path_join(msm_name) if FileAccess.file_exists(msm_p): var base_gr2 := _resolve_msm_base_model(assets_root, msm_p) if base_gr2 != "": return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2, "motion_list": _resolve_msm_motion_list(msm_p)} return {} func _find_dir(assets_root: String, folder: String) -> String: var spec := _resolve_code_spec(assets_root, folder) return String(spec.get("dir", ""))