Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions. Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
238 lines
8.1 KiB
GDScript
238 lines
8.1 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"
|
||
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 ""
|