- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
582 lines
22 KiB
GDScript
582 lines
22 KiB
GDScript
# EquipModel (P2) —— 装备变化 → Metin2Model 部件切换。
|
||
#
|
||
# var em := preload("res://ui/equip_model.gd").new()
|
||
# add_child(em)
|
||
# em.setup(m2client, item_list, model_getter, assets_root, race)
|
||
#
|
||
# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment():
|
||
# WEAR_WEAPON(4) -> item_list.model(vnum) 解析后的真实路径,按左右手分给
|
||
# model.weapon_gr2(PART_WEAPON)/ model.shield_gr2(PART_WEAPON_LEFT)
|
||
# (ActorInstanceAttach.cpp AttachWeapon:匕首双手、弓左手、骑马的扇双手)
|
||
# WEAR_SHIELD(10)-> 不渲染(参考端没有盾牌挂件,item_list 的盾也没有模型)
|
||
# WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发)
|
||
# WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤)
|
||
# armor_model_map[vnum] 若给了则优先
|
||
#
|
||
# 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进
|
||
# 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退
|
||
# get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。
|
||
#
|
||
# §4.1 修改 4(部位遮挡):解出身体 shape 后过 PartHiding —— 动物 / 怪物时装
|
||
# (shape 100-103)抑制武器 + 头发;婚纱(shape 201)非捧花时抑制武器;变身
|
||
# (seam,暂无实体字段)会把身体 shape 归 0 且清武器 / 头发。刷新顺序对齐
|
||
# InstanceBase.cpp ChangeArmor:身体 → 头发 → 武器(→ 盾)。
|
||
extends Node
|
||
|
||
const RaceSpec = preload("res://ui/race_spec.gd")
|
||
const PartHiding = preload("res://part_hiding.gd")
|
||
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
||
const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
|
||
|
||
const WEAR_BODY := 0
|
||
const WEAR_HEAD := 1
|
||
const WEAR_WEAPON := 4
|
||
const WEAR_SHIELD := 10
|
||
# CHR_EQUIPPART_*(parts[] 下标)
|
||
const PART_ARMOR := 0
|
||
const PART_WEAPON := 1
|
||
const PART_HEAD := 2
|
||
const PART_HAIR := 3
|
||
const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
|
||
# item_length.h EWeaponSubTypes(CItemData::GetWeaponType = bSubType)
|
||
const WEAPON_SUB_DAGGER := 1
|
||
const WEAPON_SUB_BOW := 2
|
||
const WEAPON_SUB_TWO_HANDED := 3
|
||
const WEAPON_SUB_BELL := 4
|
||
const WEAPON_SUB_FAN := 5
|
||
const WEAPON_SUB_ARROW := 6
|
||
const ITEM_TYPE_WEAPON := 1
|
||
const ITEM_TYPE_ARMOR := 2
|
||
const ARMOR_BODY := 0
|
||
const HAND_RIGHT := 1
|
||
const HAND_LEFT := 2
|
||
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
|
||
|
||
var client: Node
|
||
var item_list: RefCounted # ItemList
|
||
var proto: Node # Metin2Proto(有的话 armor shape 走 item_proto values[3])
|
||
var assets_root := ""
|
||
var race := -1
|
||
var _model_getter: Callable
|
||
var _spec: RefCounted # RaceSpec(惰性)
|
||
var _spec_tried := false
|
||
var _last_weapon_vnum := -1
|
||
var _last_body_vnum := -1
|
||
var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵
|
||
var _last_weapon_hands := -1
|
||
var _last_hair_part := -1
|
||
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
|
||
var _remote_mode := false
|
||
var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
|
||
# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3]
|
||
# (对齐客户端 __ArmorVnumToShape,SHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。
|
||
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
|
||
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec)
|
||
var armor_model_map := {}
|
||
var effect_registry: RefCounted
|
||
var _weapon_refine_fx: Array[Node] = []
|
||
var _armor_refine_fx: Array[Node] = []
|
||
|
||
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
|
||
race_index := -1) -> void:
|
||
client = m2client
|
||
item_list = il
|
||
_model_getter = model_getter
|
||
assets_root = assets
|
||
race = race_index
|
||
if client and client.has_signal("inventory_changed"):
|
||
client.inventory_changed.connect(_on_inv_changed)
|
||
if client and client.has_signal("entity_main_set"):
|
||
client.entity_main_set.connect(func(v):
|
||
if race < 0 and client.has_method("get_entity"):
|
||
race = int(client.get_entity(v).get("race", -1))
|
||
refresh())
|
||
# GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 改了主角 parts(换装 / 时装)-> 全刷
|
||
if client and client.has_signal("entity_info"):
|
||
client.entity_info.connect(func(v, _d):
|
||
if main_getter.is_valid() and int(main_getter.call()) == v:
|
||
refresh())
|
||
# 上 / 下马改变扇子的左右手(__IsLeftHandWeapon: FAN && IsMountingHorse)
|
||
if client and client.has_signal("mount_changed"):
|
||
client.mount_changed.connect(func(v):
|
||
if main_getter.is_valid() and int(main_getter.call()) == v:
|
||
refresh())
|
||
|
||
# 远端 PC 不读取本地 get_equipment();参考端直接消费
|
||
# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。
|
||
func setup_remote(il: RefCounted, model_getter: Callable, assets := "",
|
||
race_index := -1, parts := [], proto_node: Node = null) -> void:
|
||
client = null
|
||
item_list = il
|
||
_model_getter = model_getter
|
||
assets_root = assets
|
||
race = race_index
|
||
proto = proto_node
|
||
_remote_mode = true
|
||
_remote_parts = parts.duplicate() if parts is Array else []
|
||
while _remote_parts.size() < 4:
|
||
_remote_parts.append(0)
|
||
if _remote_parts.size() > 4:
|
||
_remote_parts = _remote_parts.slice(0, 4)
|
||
refresh()
|
||
|
||
func set_race(r: int) -> void:
|
||
race = r
|
||
_spec = null
|
||
_spec_tried = false
|
||
|
||
func _on_inv_changed(window: int, _cell: int) -> void:
|
||
if window == 2: # mtnet::WINDOW_EQUIPMENT
|
||
refresh()
|
||
|
||
func refresh() -> void:
|
||
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
|
||
if model == null or (client == null and not _remote_mode):
|
||
return
|
||
var eq: Array = client.get_equipment() if client != null and client.has_method("get_equipment") else []
|
||
if not _remote_mode and eq.size() <= WEAR_WEAPON:
|
||
return
|
||
var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq
|
||
|
||
# --- 身体:parts[ARMOR] 优先(普通盔甲或时装),为 0 回退 WEAR_BODY -------------
|
||
var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY)
|
||
# 遮挡判定用的 shape(对齐 __ArmorVnumToShape:proto values[3] 非 0,否则 =vnum)
|
||
var raw_shape := int(armor_shape_of.call(body_vnum)) if armor_shape_of.is_valid() else body_vnum
|
||
var is_poly := _is_poly()
|
||
if body_vnum != _last_body_vnum:
|
||
_last_body_vnum = body_vnum
|
||
_apply_body(model, body_vnum)
|
||
|
||
# --- 头 / 发:动物·怪物时装(shape 100-103)或变身 -> 隐藏;否则头盔覆盖 / 发型 ----
|
||
var hair_off := PartHiding.hair_hidden(raw_shape, is_poly)
|
||
var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD)
|
||
var head_key := -1 if hair_off else head_vnum
|
||
if head_key != _last_head_vnum:
|
||
_last_head_vnum = head_key
|
||
if hair_off:
|
||
model.set("hair_gr2", "")
|
||
model.set("hair_skin", "")
|
||
_last_hair_part = -1 # 恢复时强制重挂发型
|
||
else:
|
||
_apply_head_or_hair(model, head_vnum)
|
||
|
||
# --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 ---------
|
||
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
|
||
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
|
||
var hands := _weapon_hands(wpn_eff)
|
||
var weapon_changed := wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands
|
||
if weapon_changed:
|
||
_last_weapon_vnum = wpn_eff
|
||
_last_weapon_hands = hands
|
||
# 左手用 GetSubModelThing():item .msm 加载在参考端被注释掉,恒为同一个 gr2
|
||
var wpath := _resolve_weapon(wpn_eff)
|
||
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
|
||
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
|
||
# SetArmor / SetWeapon both call __GetRefinedEffect.
|
||
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
|
||
_refresh_armor_refine_effect(model, _last_body_vnum)
|
||
|
||
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
|
||
static func refine_effect_level(vnum: int, item: Dictionary) -> int:
|
||
if vnum <= 0 or item.is_empty():
|
||
return 0
|
||
var sockets := int(item.get("gain_socket_pct", 0))
|
||
if sockets != 3:
|
||
return 0
|
||
return maxi(vnum % 10, 0)
|
||
|
||
static func refine_effect_specs(vnum: int, item: Dictionary) -> Array:
|
||
var out: Array = []
|
||
if vnum <= 0 or item.is_empty():
|
||
return out
|
||
var item_type := int(item.get("type", -1))
|
||
var subtype := int(item.get("sub_type", -1))
|
||
var level := refine_effect_level(vnum, item)
|
||
if item_type == ITEM_TYPE_WEAPON:
|
||
if level < 7 or level > 9:
|
||
return out
|
||
var stem := "sword_%d" % level
|
||
match subtype:
|
||
WEAPON_SUB_DAGGER:
|
||
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
|
||
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
|
||
WEAPON_SUB_FAN:
|
||
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_f.mse" % stem})
|
||
WEAPON_SUB_BOW:
|
||
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_b.mse" % stem})
|
||
WEAPON_SUB_BELL, WEAPON_SUB_ARROW:
|
||
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
|
||
_:
|
||
# Sword and two-handed sword share the normal sword effect.
|
||
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s.mse" % stem})
|
||
elif item_type == ITEM_TYPE_ARMOR and subtype == ARMOR_BODY:
|
||
# 40250 always layers these two Blue Steel armour effects, in addition
|
||
# to the regular +7/+8/+9 body effect.
|
||
if vnum >= 12010 and vnum <= 12049:
|
||
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse"})
|
||
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse"})
|
||
if level >= 7 and level <= 9:
|
||
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor_%d.mse" % level})
|
||
return out
|
||
|
||
func _item(vnum: int) -> Dictionary:
|
||
if vnum > 0 and proto and proto.has_method("item"):
|
||
return proto.item(vnum)
|
||
return {}
|
||
|
||
func _ensure_effect_registry() -> RefCounted:
|
||
if effect_registry == null and assets_root != "":
|
||
effect_registry = EffectRegistry.new()
|
||
effect_registry.call("setup", assets_root)
|
||
return effect_registry
|
||
|
||
func _clear_refine_effects(nodes: Array[Node]) -> void:
|
||
for node in nodes:
|
||
if is_instance_valid(node) and not node.is_queued_for_deletion():
|
||
node.queue_free()
|
||
nodes.clear()
|
||
|
||
func _effect_bone(model: Node, slot: String) -> String:
|
||
match slot:
|
||
"right":
|
||
if model and "_right_bone" in model and String(model.get("_right_bone")) != "":
|
||
return String(model.get("_right_bone"))
|
||
return "equip_right_hand"
|
||
"left":
|
||
if model and "_left_bone" in model and String(model.get("_left_bone")) != "":
|
||
return String(model.get("_left_bone"))
|
||
return "equip_left"
|
||
"body": return "Bip01"
|
||
return ""
|
||
|
||
func _effect_part(model: Node, slot: String) -> Node3D:
|
||
if model == null:
|
||
return null
|
||
match slot:
|
||
"right": return model.find_child("Weapon", true, false) as Node3D
|
||
"left": return model.find_child("Shield", true, false) as Node3D
|
||
"body": return model as Node3D
|
||
return model as Node3D
|
||
|
||
func _spawn_bone_refine(registry: RefCounted, model: Node, spec: Dictionary) -> Node:
|
||
# Weapons with a non-identity Granny grip pre-transform (for
|
||
# example 03150): it moves the particles away even though the mesh is held.
|
||
if not model is Node3D or not ("anim" in model):
|
||
return null
|
||
var animator: Variant = model.get("anim")
|
||
if not animator is Node or not animator.has_method("get_effect_bone_pose"):
|
||
return null
|
||
var bone := _effect_bone(model, String(spec.get("slot", "")))
|
||
if bone == "":
|
||
return null
|
||
var effect: Node = registry.call("spawn", String(spec.get("effect", "")),
|
||
model as Node3D, false)
|
||
if effect == null:
|
||
return null
|
||
var anchor := MotionEffectAnchor.new()
|
||
effect.add_child(anchor)
|
||
var event := {"attaching": true, "following": true, "independent": false,
|
||
"bone": bone, "pos": Vector3.ZERO}
|
||
if anchor.configure(model as Node3D, event):
|
||
return effect
|
||
effect.free()
|
||
return null
|
||
|
||
func _spawn_refine_specs(model: Node, specs: Array, output: Array[Node]) -> void:
|
||
var registry := _ensure_effect_registry()
|
||
if registry == null or not registry.has_method("spawn"):
|
||
return
|
||
for spec in specs:
|
||
var parent := _effect_part(model, String(spec.get("slot", "")))
|
||
var effect: Node = _spawn_bone_refine(registry, model, spec)
|
||
if effect == null and parent != null:
|
||
effect = registry.call("spawn", String(spec.get("effect", "")), parent, false)
|
||
if effect:
|
||
output.append(effect)
|
||
|
||
func _refresh_weapon_refine_effect(model: Node, vnum: int) -> void:
|
||
_clear_refine_effects(_weapon_refine_fx)
|
||
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _weapon_refine_fx)
|
||
|
||
func _refresh_armor_refine_effect(model: Node, vnum: int) -> void:
|
||
_clear_refine_effects(_armor_refine_fx)
|
||
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _armor_refine_fx)
|
||
|
||
# A RemotePlayerView is assembled before NetWorld adds it to the SceneTree.
|
||
# Re-run only the effect attachment after _ready so skeleton-following anchors
|
||
# can resolve global transforms; do not reload body/weapon geometry.
|
||
func refresh_refine_effects() -> void:
|
||
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
|
||
if model == null:
|
||
return
|
||
_refresh_armor_refine_effect(model, _last_body_vnum)
|
||
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
|
||
|
||
func _weapon_hands(vnum: int) -> int:
|
||
if vnum == 0:
|
||
return 0
|
||
var sub := -1
|
||
if proto and proto.has_method("item"):
|
||
sub = int(proto.item(vnum).get("sub_type", -1))
|
||
if sub == WEAPON_SUB_DAGGER or (sub == WEAPON_SUB_FAN and _is_mounting()):
|
||
return HAND_RIGHT | HAND_LEFT
|
||
if sub == WEAPON_SUB_BOW:
|
||
return HAND_LEFT
|
||
return HAND_RIGHT
|
||
|
||
# IsMountingHorse:主角实体 mount_vnum 非 0。远端 PC 暂无坐骑状态输入(seam)。
|
||
func _is_mounting() -> bool:
|
||
if _remote_mode or not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
|
||
return false
|
||
var vid: int = main_getter.call()
|
||
return vid != 0 and int(client.get_entity(vid).get("mount_vnum", 0)) != 0
|
||
|
||
# §4.1 修改 4 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
|
||
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
|
||
# client.get_entity(main_getter.call()).get("poly_race", 0) != 0 即可。
|
||
func _is_poly() -> bool:
|
||
return false
|
||
|
||
# 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽
|
||
func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int:
|
||
if parts.size() > part_idx and int(parts[part_idx]) != 0:
|
||
return int(parts[part_idx])
|
||
return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0
|
||
|
||
func _parts() -> Array:
|
||
if _remote_mode:
|
||
return _remote_parts
|
||
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
|
||
return []
|
||
var vid: int = main_getter.call()
|
||
if vid == 0:
|
||
return []
|
||
var p = client.get_entity(vid).get("parts", [])
|
||
return p if p is Array else []
|
||
|
||
func _apply_head_or_hair(model: Node, head_vnum: int) -> void:
|
||
# 头盔有模型 -> 用它替换发型(Metin2:头防走 hair 槽)
|
||
if head_vnum != 0:
|
||
var hm := _resolve_weapon(head_vnum) # item_list.model 解析,复用武器解析器
|
||
if hm != "":
|
||
model.set("hair_gr2", hm)
|
||
model.set("hair_skin", "")
|
||
return
|
||
# 没头盔(或头盔无模型)-> 回到角色发型
|
||
_apply_hair_from_parts(model, true)
|
||
|
||
func _apply_hair_from_parts(model: Node, force := false) -> void:
|
||
if model == null:
|
||
return
|
||
var hp := _hair_part()
|
||
if hp < 0 or (hp == _last_hair_part and not force):
|
||
return
|
||
_last_hair_part = hp
|
||
# 时装假发 / 大编号 -> parts[HAIR] 是 item vnum,走 item_list.model(同头盔)
|
||
if hp >= 100 and item_list:
|
||
var wig := _resolve_weapon(hp)
|
||
if wig != "":
|
||
model.set("hair_gr2", wig)
|
||
model.set("hair_skin", "")
|
||
return
|
||
# 小编号 -> race .msm 的 hair shape index
|
||
var e := _spec_hair(hp)
|
||
if e.is_empty():
|
||
return
|
||
var m := _resolve_spec_asset(e, String(e.get("model", "")))
|
||
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
|
||
if m != "":
|
||
model.set("hair_gr2", m)
|
||
if ts != "":
|
||
model.set("hair_skin", ts)
|
||
|
||
func _hair_part() -> int:
|
||
if _remote_mode:
|
||
return int(_remote_parts[PART_HAIR]) if _remote_parts.size() > PART_HAIR else -1
|
||
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
|
||
return -1
|
||
var vid: int = main_getter.call()
|
||
if vid == 0:
|
||
return -1
|
||
var e: Dictionary = client.get_entity(vid)
|
||
var parts = e.get("parts", [])
|
||
if parts is Array and parts.size() > CHR_EQUIPPART_HAIR:
|
||
return int(parts[CHR_EQUIPPART_HAIR])
|
||
return -1
|
||
|
||
func set_hair(index: int) -> void:
|
||
if _remote_mode:
|
||
_set_remote_part(PART_HAIR, index)
|
||
refresh()
|
||
return
|
||
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
|
||
if model == null:
|
||
return
|
||
var e := _spec_hair(index)
|
||
if e.is_empty():
|
||
return
|
||
var m := _resolve_spec_asset(e, String(e.get("model", "")))
|
||
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
|
||
if m != "":
|
||
model.set("hair_gr2", m)
|
||
if ts != "":
|
||
model.set("hair_skin", ts)
|
||
|
||
func set_armor(index: int) -> void:
|
||
if not _remote_mode:
|
||
return
|
||
_set_remote_part(PART_ARMOR, index)
|
||
refresh()
|
||
|
||
func set_weapon(index: int) -> void:
|
||
if not _remote_mode:
|
||
return
|
||
_set_remote_part(PART_WEAPON, index)
|
||
refresh()
|
||
|
||
func set_head(index: int) -> void:
|
||
if not _remote_mode:
|
||
return
|
||
_set_remote_part(PART_HEAD, index)
|
||
refresh()
|
||
|
||
func _set_remote_part(part: int, value: int) -> void:
|
||
while _remote_parts.size() <= part:
|
||
_remote_parts.append(0)
|
||
_remote_parts[part] = value
|
||
|
||
# --- body ------------------------------------------------------------------
|
||
|
||
# armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape)
|
||
func _armor_shape_default(vnum: int) -> int:
|
||
if vnum > 1 and proto and proto.has_method("item"):
|
||
var it: Dictionary = proto.item(vnum)
|
||
var v = it.get("values", [])
|
||
if not it.is_empty() and v is Array and v.size() > 3:
|
||
return int(v[3])
|
||
return vnum
|
||
|
||
func _armor_specular(vnum: int) -> int:
|
||
if proto and proto.has_method("item"):
|
||
return int(proto.item(vnum).get("specular", 0))
|
||
return 0
|
||
|
||
func _apply_body(model: Node, vnum: int) -> void:
|
||
if model == null:
|
||
return
|
||
if model.has_method("clear_skin_textures"):
|
||
model.call("clear_skin_textures")
|
||
# 强化等级越高越亮(PARITY §2.7:fSpecular = bSpecular / 100,无高光时重置为 0)
|
||
var sp := _armor_specular(vnum)
|
||
model.set("specular_power", sp / 100.0 if sp > 0 else 0.0)
|
||
if armor_model_map.has(vnum):
|
||
model.set("gr2_path", String(armor_model_map[vnum]))
|
||
return
|
||
var shape_idx: int = armor_shape_of.call(vnum) if armor_shape_of.is_valid() else vnum
|
||
var e := _spec_shape(shape_idx)
|
||
if e.is_empty():
|
||
return # 没有对应 shape:保持当前身体
|
||
var body := _resolve_spec_asset(e, String(e.get("model", "")))
|
||
if body != "":
|
||
model.set("gr2_path", body)
|
||
# 换肤(同模型不同 TargetSkin):按 40250 SetMaterialImagePointer 材质名映射
|
||
var src: String = e.get("source_skin", "")
|
||
var tgt: String = e.get("target_skin", "")
|
||
if tgt != "" and tgt != src:
|
||
var t := _resolve_spec_asset(e, tgt)
|
||
var s := _resolve_spec_asset(e, src)
|
||
if model.has_method("set_skin_texture"):
|
||
if t != "":
|
||
model.call("set_skin_texture", s if s != "" else src, t)
|
||
if model.has_method("set_surface_texture"):
|
||
if t != "":
|
||
model.call("set_surface_texture", 0, t)
|
||
|
||
func _spec_shape(idx: int) -> Dictionary:
|
||
_ensure_spec()
|
||
return _spec.shape(idx) if _spec else {}
|
||
|
||
func _spec_hair(idx: int) -> Dictionary:
|
||
_ensure_spec()
|
||
return _spec.hair(idx) if _spec else {}
|
||
|
||
func _ensure_spec() -> void:
|
||
if _spec_tried:
|
||
return
|
||
_spec_tried = true
|
||
if race < 0 or assets_root == "":
|
||
return
|
||
var cls: String = CLASS_OF[race & 3]
|
||
var suffix := "w" if race in [1, 3, 4, 6] else "m"
|
||
for rel in ["root/msm/%s_%s.msm" % [cls, suffix],
|
||
"PC/ymir work/pc/%s/%s.msm" % [cls, cls],
|
||
"pc2/ymir work/pc2/%s/%s.msm" % [cls, cls],
|
||
"season1/season1/pc/%s.msm" % cls]:
|
||
var p := _resolve_asset(rel)
|
||
if p != "":
|
||
var rs := RaceSpec.new()
|
||
if rs.load_file(p):
|
||
_spec = rs
|
||
return
|
||
|
||
# --- 路径解析 ------------------------------------------------------------
|
||
|
||
# 用 race_spec 的一条 shape/hair 记录 + 一个相对文件(model / *_skin)解析绝对路径。
|
||
# 依次试: 规格文件目录/<rel> · PathName(去盘符)+扫散包 · 按 basename 扫 pc/<cls>/ 与 pc2/<cls>/
|
||
func _resolve_spec_asset(e: Dictionary, rel: String) -> String:
|
||
if rel == "":
|
||
return ""
|
||
var sdir := String(e.get("spec_dir", ""))
|
||
if sdir != "":
|
||
var p := sdir.path_join(rel)
|
||
if FileAccess.file_exists(p):
|
||
return p
|
||
var byp := _resolve_asset(String(e.get("path", "")) + rel)
|
||
if byp != "":
|
||
return byp
|
||
# 规格覆盖层(例如 root/msm/warrior_m.msm)可能只给 Model 名而没有
|
||
# PathName。按 basename 回退时除职业根目录外,还必须查 hair/;否则
|
||
# HairIndex 1001..1012 能解析却加载失败,并静默保留 PlayerView 的
|
||
# hair_1_1 默认模型,造成选角发型遮脸。
|
||
var bn := rel.get_file()
|
||
if race >= 0:
|
||
var cls: String = CLASS_OF[race & 3]
|
||
for sub in [
|
||
"PC/ymir work/pc/%s/%s" % [cls, bn],
|
||
"PC/ymir work/pc/%s/hair/%s" % [cls, bn],
|
||
"pc2/ymir work/pc2/%s/%s" % [cls, bn],
|
||
"pc2/ymir work/pc2/%s/hair/%s" % [cls, bn],
|
||
]:
|
||
var q := _resolve_asset(sub)
|
||
if q != "":
|
||
return q
|
||
return ""
|
||
|
||
func _resolve_weapon(vnum: int) -> String:
|
||
if vnum == 0 or item_list == null:
|
||
return ""
|
||
var vpath: String = item_list.model(vnum)
|
||
if vpath == "":
|
||
var base := (vnum / 10) * 10
|
||
vpath = "d:/ymir work/item/weapon/%05d.gr2" % base
|
||
return _resolve_asset(vpath)
|
||
|
||
# "d:/ymir work/..." 或散包相对路径 -> <assets>/(<pack>/)...
|
||
func _resolve_asset(vpath: String) -> String:
|
||
if vpath == "" or assets_root == "":
|
||
return ""
|
||
var rel := vpath.replace("\\", "/")
|
||
if rel.length() >= 2 and rel[1] == ":":
|
||
rel = rel.substr(2)
|
||
rel = rel.lstrip("/")
|
||
var direct := assets_root.path_join(rel)
|
||
if FileAccess.file_exists(direct):
|
||
return direct
|
||
var da := DirAccess.open(assets_root)
|
||
if da:
|
||
for sub in da.get_directories():
|
||
var cand := assets_root.path_join(sub).path_join(rel)
|
||
if FileAccess.file_exists(cand):
|
||
return cand
|
||
return ""
|