# 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"], # §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"], } 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 _state := "" var _move_speed_ratio := 1.0 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 } # 同一种怪/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() 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() _race_spec_cache[race] = { "dir": _dir, "mesh_stem": _mesh_stem, "gr2": gr2, "motions": _motions.duplicate() } 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 "") func set_anim_state(s: String) -> void: if s == _state 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 _hit.cancel() _state = s _last_resolution = resolve_motion(s) var msa := String(_last_resolution.path) if msa == "": # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 return _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)) if w <= 0.0 and r > 0.0: w = r elif r <= 0.0 and w > 0.0: r = w 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: _hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction) func hit_greate(scalar: float, stunned: bool) -> void: _hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned) func hit_stone(stunned: bool) -> void: _hit.stone(stunned) # 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 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": ""} 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 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() -> 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: 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 _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} # 2. 目录同名主网格 gr2 = d.path_join(d.get_file() + ".gr2") if FileAccess.file_exists(gr2): return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2} # 3. 目录里第一个非动作 .gr2 gr2 = _pick_base_gr2_in(d) if gr2 != "": return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2} # 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} return {} func _find_dir(assets_root: String, folder: String) -> String: var spec := _resolve_code_spec(assets_root, folder) return String(spec.get("dir", ""))