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:
@@ -0,0 +1,98 @@
|
||||
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
|
||||
#
|
||||
# var pv := preload("res://ui/player_view.gd").new()
|
||||
# pv.build(assets_root, race) # race 0..7(GC_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 的 set 给内部 Metin2Model,
|
||||
# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`。
|
||||
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]
|
||||
|
||||
var model: Node # Metin2Model
|
||||
var anim: Node # Metin2AnimPlayer
|
||||
var motion_dir := ""
|
||||
var _state := ""
|
||||
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
|
||||
|
||||
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
|
||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
if not ClassDB.class_exists("Metin2Model"):
|
||||
return false
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
||||
var base := _resolve_dir(assets_root, pc_dir)
|
||||
if base == "":
|
||||
base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模
|
||||
if base == "":
|
||||
return false
|
||||
motion_dir = base.path_join("general")
|
||||
|
||||
model = ClassDB.instantiate("Metin2Model")
|
||||
model.name = "Metin2Model"
|
||||
model.set("texture_dir", base)
|
||||
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.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
|
||||
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)
|
||||
|
||||
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 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_anim_state(s: String) -> void:
|
||||
if s == _state or anim == null or motion_dir == "":
|
||||
return
|
||||
_state = s
|
||||
var msa := motion_dir.path_join(s + ".msa")
|
||||
if not FileAccess.file_exists(msa):
|
||||
msa = motion_dir.path_join(s + ".gr2")
|
||||
if FileAccess.file_exists(msa):
|
||||
anim.set("loop", s in ["wait", "walk", "run"])
|
||||
anim.set("anim_path", msa)
|
||||
|
||||
func _set(prop: StringName, val: Variant) -> bool:
|
||||
if String(prop) in _forward and model:
|
||||
model.set(prop, val)
|
||||
return true
|
||||
return false
|
||||
|
||||
# --- 路径解析 -------------------------------------------------------------
|
||||
|
||||
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 ""
|
||||
Reference in New Issue
Block a user