- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级) - 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植) - _register_hit 改为 map::insert 语义,修正命中上限 - 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段 - 去掉本地连击超时,motion_bound 清命中表 / 连击段号 - __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、 命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd) - ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING - mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上 - 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试; gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体 - 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
372 lines
13 KiB
GDScript
372 lines
13 KiB
GDScript
# 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
|
||
#
|
||
# 目录:<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 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:<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:
|
||
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:
|
||
# <assets>/*/ymir work/{monster,npc}/<folder>
|
||
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 ""
|