Files
mtgodot-poc/project/ui/mob_view.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

535 lines
19 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MobView (真机字节校准收尾) —— 怪 / NPC 的真模型:Metin2Model + Metin2AnimPlayer。
#
# var mv := preload("res://ui/mob_view.gd").new()
# if mv.build(assets_root, proto, race): # race = mob_proto vnumGC_CHARACTER_ADD.race
# ...
# mv.set_anim_state("run") # wait/run/attack/damage/dead/skill/emotion
#
# 目录:<assets>/*/ymir work/{monster,npc}/<mob_proto.name>/
# <name>.gr2 = 基础网格,<name>.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::SetMoveSpeedm_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/smovSpd 100):x = walky = 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 / __HitStoneActorInstanceBattle.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:<NAME>" 本客户端的同类动作别名(如 run -> WALK
## "motlist_missing:<file>" motlist 未列出,按目录里的 <name>.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", ""))