240 lines
8.2 KiB
GDScript
240 lines
8.2 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]
|
||
|
||
var model: Node # Metin2Model
|
||
var anim: Node # Metin2AnimPlayer
|
||
var motion_dir := ""
|
||
var action_dir := ""
|
||
var _state := ""
|
||
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"]
|
||
|
||
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
|
||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||
_assets_root = assets_root
|
||
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
|
||
return false
|
||
var cls: String = CLASS_OF[race & 3]
|
||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
||
var base := _resolve_dir(assets_root, pc_dir)
|
||
if base == "":
|
||
base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模
|
||
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)
|
||
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
|
||
_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):
|
||
anim.set("loop", s in ["wait", "walk", "run"])
|
||
anim.set("anim_path", msa)
|
||
_refresh_sound_script(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
|
||
_state = "__motion"
|
||
anim.set("loop", false)
|
||
# A repeated command can name the same clip; clear first because the native
|
||
# player treats assigning the current anim_path as a no-op.
|
||
if String(anim.get("anim_path")) == msa:
|
||
anim.set("anim_path", "")
|
||
anim.set("anim_path", msa)
|
||
_refresh_sound_script(msa)
|
||
return true
|
||
|
||
func _on_playback_finished() -> void:
|
||
# CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip.
|
||
# When it ends the actor leaves the pushed state (CActorInstance::IsPushing),
|
||
# so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate.
|
||
if _state == "__motion" or _state == "damage":
|
||
_state = ""
|
||
set_anim_state("wait")
|
||
|
||
# True while the one-shot hit/knockback clip is still playing.
|
||
func is_in_hit_reaction() -> bool:
|
||
return _state == "damage"
|
||
|
||
func _process(_delta: float) -> void:
|
||
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:
|
||
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 ""
|