373 lines
14 KiB
GDScript
373 lines
14 KiB
GDScript
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
|
||
#
|
||
# var pv := preload("res://ui/player_view.gd").new()
|
||
# pv.build(assets_root, race) # race 0..7(GC_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_bone;PART_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_ATTACK;COMBO_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::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:
|
||
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).msa,COMBO_ATTACK_N -> combo_0N.msa),
|
||
# 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 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 / __HitStone(ActorInstanceBattle.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 ""
|