Files
mtgodot-poc/project/ui/player_view.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

537 lines
21 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 属性及材质换肤调用给内部
# Metin2Model,所以 equip_model.gd 可以直接操作外层 PlayerView。
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()
var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
# 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
# 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断
if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES:
if anim.has_method("get_duration") and anim.has_method("get_time"):
var d: float = float(anim.call("get_duration"))
var t: float = float(anim.call("get_time"))
if d > 0.0 and t < d and bool(anim.get("playing")):
return
else:
return
_hit.cancel()
_state = s
var msa := _motion_file(s)
if 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:
var dir := _mode_motion_dir() if motion_dir != "" else ""
if dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
MsaMotion.move_speed(dir.path_join("run.msa")))
# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
# wait/walk/run/damage too; the left-hand grip is authored in those animations
# while the weapon model itself remains attached to PART_WEAPON (right hand).
func set_motion_mode(mode: int) -> void:
var next := mode if MOTION_MODE_DIRS.has(mode) else 1
if next == _motion_mode:
return
_motion_mode = next
var resume := _state
if resume == "" or resume == "__motion":
resume = "wait"
_state = "" # force rebind even though the semantic state did not change
set_anim_state(resume)
func get_motion_mode() -> int:
return _motion_mode
# 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)
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
if anim == null:
return false
var cls: String = CLASS_OF[_race & 3]
var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls
var sdir := _resolve_dir(_assets_root, pc_dir)
if sdir == "":
return false
var suffix := ""
if grade >= 1:
suffix = "_%d" % (grade + 1)
var msa := sdir.path_join(motion_name + suffix + ".msa")
if not FileAccess.file_exists(msa):
msa = sdir.path_join(motion_name + ".msa")
if not FileAccess.file_exists(msa):
return false
_bind("__skill", msa, false, 1.0)
return true
const AFFECT_EFFECT_MAP := {
15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG
16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN
17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG
19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM
20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR
21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP
22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN
23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO
24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK
25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN
26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG
29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON
30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK
32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP
3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON
4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW
5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN
}
var _fx: RefCounted
var _affect_nodes: Dictionary = {}
func set_fx(fx_registry: RefCounted) -> void:
_fx = fx_registry
func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
var reg: RefCounted = fx_registry if fx_registry != null else _fx
if not visible:
if _affect_nodes.has(affect_type):
var n: Node = _affect_nodes[affect_type]
_affect_nodes.erase(affect_type)
if is_instance_valid(n):
n.queue_free()
return
if _affect_nodes.has(affect_type) or reg == null:
return
if not AFFECT_EFFECT_MAP.has(affect_type):
return
var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type]
var fx_name: String = String(spec.get("file", ""))
if affect_type == 15 and _motion_mode == 3:
fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse"
if fx_name == "":
return
var effect: Node = reg.call("spawn", fx_name, self, false)
if effect == null:
return
var bone: String = String(spec.get("bone", ""))
if bone != "":
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
effect.add_child(anchor)
var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO}
if not anchor.configure(self, ev):
effect.queue_free()
return
_affect_nodes[affect_type] = effect
func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void:
for t in AFFECT_EFFECT_MAP.keys():
var active: bool = (flags & (1 << t)) != 0
set_affect(t, active, fx_registry)
func clear_affects() -> void:
for t in _affect_nodes.keys():
var n: Node = _affect_nodes[t]
if is_instance_valid(n):
n.queue_free()
_affect_nodes.clear()
# 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)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
# 受击方 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 in ["__motion", "__skill", "attack", "combo"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
func reset_skill_motion() -> void:
if _state in ["__skill", "__motion"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
# 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_mode_msa(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("playing", true)
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 _mode_motion_dir() -> String:
if motion_dir == "":
return ""
var candidate := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(_motion_mode, "general")))
return candidate if DirAccess.dir_exists_absolute(candidate) else motion_dir
func _motion_file(state: String) -> String:
for dir in [_mode_motion_dir(), motion_dir]:
if dir == "":
continue
for ext in [".msa", ".gr2"]:
var path: String = String(dir).path_join(state + String(ext))
if FileAccess.file_exists(path):
return path
return ""
func _pick_mode_msa(names: Array) -> String:
var picked := _pick_msa(_mode_motion_dir(), names)
return picked if picked != "" else _pick_msa(motion_dir, names)
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
# EquipModel operates on the outer PlayerView, while the material palette lives
# on the native Metin2Model child. Keep these as explicit methods so MSM
# SourceSkin -> TargetSkin remaps reach both character select and the game.
func set_skin_texture(source: String, target: String) -> void:
if model and model.has_method("set_skin_texture"):
model.call("set_skin_texture", source, target)
func clear_skin_textures() -> void:
if model and model.has_method("clear_skin_textures"):
model.call("clear_skin_textures")
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 ""