Files
mtgodot-poc/project/ui/equip_model.gd
T
shenandClaude Sonnet 5 47baf6c0c6 Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
2026-08-31 20:02:12 +09:00

308 lines
11 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.
# 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「自动」生效,也是渲染别的玩家用的同一条路。
extends Node
const RaceSpec = preload("res://ui/race_spec.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 := -1
var _last_shield_vnum := -1
var _last_hair_part := -1
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3]
# (对齐客户端 __ArmorVnumToShapeSHAPE_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())
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:
return
var eq: Array = client.get_equipment()
if eq.size() <= WEAR_WEAPON:
return
var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq
# 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
if wpn_vnum != _last_weapon_vnum:
_last_weapon_vnum = wpn_vnum
model.set("weapon_gr2", _resolve_weapon(wpn_vnum))
if eq.size() > WEAR_SHIELD:
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
if sh_vnum != _last_shield_vnum:
_last_shield_vnum = sh_vnum
model.set("shield_gr2", _resolve_weapon(sh_vnum))
# 身体:parts[ARMOR] 优先(= 普通盔甲或时装),为 0 回退 WEAR_BODY
var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY)
if body_vnum != _last_body_vnum:
_last_body_vnum = body_vnum
_apply_body(model, body_vnum)
# 头:parts[HEAD] 优先,为 0 回退 WEAR_HEAD;有模型的头盔覆盖发型槽,否则用发型
var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD)
if head_vnum != _last_head_vnum:
_last_head_vnum = head_vnum
_apply_head_or_hair(model, head_vnum)
# 有效 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 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 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:
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)
# --- 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.7fSpecular = 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]
for rel in ["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
# 按 basename 在 pc/<cls>/ 和 pc2/<cls>/ 下找
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], "pc2/ymir work/pc2/%s/%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 ""