394 lines
14 KiB
GDScript
394 lines
14 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) -> model.weapon_gr2 = item_list.model(vnum) 解析后的真实路径
|
||
# WEAR_SHIELD(10)-> model.shield_gr2(左手刚体挂点,同 weapon)
|
||
# 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 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 # 兼容旧引用
|
||
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_shield_vnum := -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 := {}
|
||
|
||
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())
|
||
|
||
# 远端 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)
|
||
if wpn_eff != _last_weapon_vnum:
|
||
_last_weapon_vnum = wpn_eff
|
||
model.set("weapon_gr2", _resolve_weapon(wpn_eff))
|
||
|
||
# --- 盾(左手挂点,同武器遮挡规则)--------------------------------------------
|
||
if eq.size() > WEAR_SHIELD:
|
||
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
|
||
var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum
|
||
if sh_eff != _last_shield_vnum:
|
||
_last_shield_vnum = sh_eff
|
||
model.set("shield_gr2", _resolve_weapon(sh_eff))
|
||
|
||
# §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 v is Array and v.size() > 3 and int(v[3]) != 0:
|
||
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:
|
||
# 强化等级越高越亮(PARITY §2.7:fSpecular = bSpecular / 100)
|
||
var sp := _armor_specular(vnum)
|
||
if sp > 0:
|
||
model.set("specular_power", sp / 100.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):覆盖 surface 0
|
||
var src: String = e.get("source_skin", "")
|
||
var tgt: String = e.get("target_skin", "")
|
||
if tgt != "" and tgt != src and model.has_method("set_surface_texture"):
|
||
var t := _resolve_spec_asset(e, tgt)
|
||
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 ""
|