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
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+307
View File
@@ -0,0 +1,307 @@
# 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 ""