Files
mtgodot-poc/project/ui/player_view.gd
T
2026-09-16 22:15:52 +09:00

373 lines
14 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.
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
#
# var pv := preload("res://ui/player_view.gd").new()
# pv.build(assets_root, race) # race 0..7GC_MAIN_CHARACTER.race
# game_scene.set_player_model(pv)
# pv.set_anim_state("run") # wait/walk/run/attack/dead
#
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 的 set 给内部 Metin2Model
# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`。
extends Node3D
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
const FEMALE_RACES := [1, 3, 4, 6]
# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名):
# PART_WEAPON -> model.weapon_bonePART_WEAPON_LEFT -> model.shield_bone"" = 未登记,不挂)
const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
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")
# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级)
const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword",
5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword",
11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan",
15: "horse_bell", 16: "wedding"}
const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACKCOMBO_ATTACK_1 = 14
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件(同名多份按权重随机,这里等权)
const REACTION_FILES := {
"damage": ["damage", "damage_1"], # NAME_DAMAGE
"damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK
"knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING
"knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK
"standup": ["falling_stand"], # NAME_STAND_UP
"standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK
}
# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var motion_dir := ""
var action_dir := ""
var _state := ""
var _move_speed_ratio := 1.0
var _assets_root := ""
var _audio: Node
var _sound_instances: Array = []
var _sound_frame := -1
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
func build(assets_root: String, race: int, pump := Callable()) -> bool:
_assets_root = assets_root
_race = race
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
# pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7.
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls
var base := _resolve_dir(assets_root, pc_dir)
if base == "":
return false
motion_dir = base.path_join("general")
action_dir = base.path_join("action")
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
# PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses.
model.set("flip_winding", true)
model.set("texture_dir", base)
# 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有,
# 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。
model.set("weapon_bone", WEAPON_BONE[race & 3])
_left_bone = WEAPON_LEFT_BONE[race & 3]
if _left_bone != "":
model.set("shield_bone", _left_bone)
if pump.is_valid(): pump.call()
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
if body == "":
model.free()
model = null
return false
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
# This view is built off-tree: set_gr2_path only stores the path until ready.
# Validate the source explicitly rather than reading a not-yet-loaded report.
if String(model.call("probe_gr2", body)).contains("FAILED"):
model.free()
model = null
return false
if pump.is_valid(): pump.call()
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
if hair != "":
model.set("hair_gr2", hair) # 重:解 hair 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("loop", true)
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
if pump.is_valid(): pump.call()
set_anim_state("wait") # 重:解 .msa
if pump.is_valid(): pump.call()
CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow
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 or motion_dir == "":
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
var msa := motion_dir.path_join(s + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa):
_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:
if motion_dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select
# kiss/french-kiss variants, exactly like ActDualEmotion in the native client.
func set_motion_id(motion: int, target_race: int = -1) -> bool:
if anim == null:
return false
var name := ""
if motion == 305:
name = "clap"
elif motion == 306:
name = "cheers_1"
elif motion == 307:
name = "cheers_2"
elif motion >= 308 and motion <= 311:
var job := motion - 308
if motion == 308 and target_race >= 0:
job = target_race & 3
name = "kiss_with_" + CLASS_OF[clampi(job, 0, 3)]
elif motion >= 312 and motion <= 315:
var french_job := motion - 312
if motion == 312 and target_race >= 0:
french_job = target_race & 3
name = "french_kiss_with_" + CLASS_OF[clampi(french_job, 0, 3)]
elif motion >= 316 and motion <= 319:
name = "slap_hit"
elif motion >= 320 and motion <= 323:
name = "slap_hurt"
elif motion == 324:
name = "dig"
elif motion >= 325 and motion <= 340:
name = "dance_%d" % (motion - 324)
elif motion == 341:
name = "congratulation"
elif motion == 342:
name = "forgive"
elif motion == 343:
name = "angry"
elif motion == 344:
name = "attractive"
elif motion == 345:
name = "sad"
elif motion == 346:
name = "shy"
elif motion == 347:
name = "cheerup"
elif motion == 348:
name = "banter"
elif motion == 349:
name = "joy"
elif motion == 5 or motion == 6 or motion == 8 or motion == 9:
set_anim_state("damage")
return true
elif motion == 11 or motion == 12:
set_anim_state("dead")
return true
elif motion >= 13 and motion <= 21:
set_anim_state("attack")
return true
else:
return false
var msa := action_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
return false
_bind("__motion", msa, false, 1.0)
return true
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio })
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msaCOMBO_ATTACK_N -> combo_0N.msa),
# 模式目录缺该段时退回 general 的 attack(_1).msaGENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
if anim == null or motion_dir == "":
return
var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
var names: Array = ["attack", "attack_1"]
if index > MOTION_NORMAL_ATTACK:
names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)]
var msa := _pick_msa(mode_dir, names)
if msa == "":
msa = _pick_msa(motion_dir, ["attack", "attack_1"])
if msa == "":
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)。
# scalar = dot(攻击方朝向, 受击方朝向)< 0 面对面 -> 正面受击动作。
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)
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
return HitCollision.parse_msm_defending(
_assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex]))
func _on_playback_finished() -> void:
# §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。
# 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate.
if _hit.active:
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
return
if _state == "__motion":
_state = ""
set_anim_state("wait")
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
return _hit.active
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
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)
# A repeated one-shot can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
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)
func _pick_msa(dir: String, names: Array) -> String:
var found: Array = []
for n in names:
var p := dir.path_join(String(n) + ".msa")
if FileAccess.file_exists(p):
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
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)
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
# ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手
if String(prop) == "shield_gr2" and _left_bone == "":
val = ""
model.set(prop, val)
if String(prop) == "gr2_path":
call_deferred("_ground_model")
return true
return false
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
# --- 路径解析 -------------------------------------------------------------
func _resolve_dir(assets_root: String, rel: String) -> String:
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():
var c := assets_root.path_join(sub).path_join(rel)
if DirAccess.dir_exists_absolute(c):
return c
return ""
func _first_existing(paths: Array) -> String:
for p: String in paths:
if FileAccess.file_exists(p):
return p
return ""