# 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 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 _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 _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 _mesh_stem = mesh_stem 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" # 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() _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) 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, 1.0) # 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) 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("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: 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 ""