Files
mtgodot-poc/project/ui/player_view.gd
T

221 lines
7.5 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]
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 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"
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.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
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)
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)
return true
return false
# --- 路径解析 -------------------------------------------------------------
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 ""