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
+235
View File
@@ -0,0 +1,235 @@
# Mse (P5) —— 解析 Metin2 `.mse` 特效脚本(EffectLib,非逆向,有完整源码)。
#
# `.mse` 是 CTextFileLoader 花括号文本树:
# BoundingSphereRadius 120.0
# Group Particle {
# StartTime 0.0
# List TimeEventPosition { 0.0 "MOVING_TYPE_DIRECT" 8.0 0.0 25.0 }
# Group EmitterProperty { MaxEmissionCount 5 CycleLength 0.4 List TimeEventSizeX { 0.0 64.0 } ... }
# Group ParticleProperty { SrcBlendType 5 BillboardType 1 List TimeEventAlpha { 0.0 0.71 0.86 0.0 } List TextureFiles { "x.dds" } }
# }
# Group Mesh { MeshFileName "a.mde" MeshElementCount 1 Group MeshElement00 { ... } }
#
# var m := preload("res://fx/mse.gd").new()
# var spec := m.parse_file(path)
# # spec = { bsphere_r, bsphere_pos:Vector3, particles:[Dict], meshes:[Dict], dir }
#
# 每个 particle dict 保留原始键(大小写不变):
# start_time:float, position: [ [t, "MOVING_TYPE", x,y,z], ... ],
# emitter: Dict, particle: Dict List 键 -> Array[Array],标量键 -> float/String/Array
extends RefCounted
var last_error := ""
var _toks: Array = []
var _p := 0
func parse_file(path: String) -> Dictionary:
last_error = ""
if not FileAccess.file_exists(path):
last_error = "no such file: " + path
return {}
var spec := parse_text(FileAccess.get_file_as_string(path))
spec["dir"] = path.get_base_dir()
return spec
func parse_text(src: String) -> Dictionary:
_toks = _tokenize(src)
_p = 0
var top := _parse_block(true)
var out := {
"bsphere_r": float(_scalar(top.get("BoundingSphereRadius", 0.0))),
"bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])),
"particles": [],
"meshes": [],
"dir": "",
}
for g in top.get("__group_Particle", []):
out.particles.append(_norm_particle(g))
for g in top.get("__group_Mesh", []):
out.meshes.append(_norm_mesh(g))
return out
# --- 归一化 -----------------------------------------------------------
func _norm_particle(g: Dictionary) -> Dictionary:
return {
"start_time": float(_scalar(g.get("StartTime", 0.0))),
"position": g.get("TimeEventPosition", []),
"emitter": _first(g.get("__group_EmitterProperty", [])),
"particle": _first(g.get("__group_ParticleProperty", [])),
}
func _norm_mesh(g: Dictionary) -> Dictionary:
var elems := []
for k in g:
if String(k).begins_with("__group_MeshElement"):
for e in g[k]:
elems.append(e)
return {
"start_time": float(_scalar(g.get("StartTime", 0.0))),
"position": g.get("TimeEventPosition", []),
"mesh_file": String(_scalar(g.get("MeshFileName", ""))),
"frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))),
"elements": elems,
}
func _first(a) -> Dictionary:
return a[0] if a is Array and a.size() > 0 else {}
func _scalar(v):
if v is Array:
return v[0] if v.size() > 0 else 0.0
return v
func _vec3(v) -> Vector3:
if v is Array and v.size() >= 3:
return Vector3(float(v[0]), float(v[1]), float(v[2]))
return Vector3.ZERO
# --- 词法 -----------------------------------------------------------
# tokens: "{" "}" "\n" · {"s": <quoted string>} · 裸词/数字(String)
func _tokenize(src: String) -> Array:
var out := []
var i := 0
var n := src.length()
while i < n:
var c := src[i]
if c == "\n":
out.append("\n")
i += 1
elif c in [" ", "\t", "\r"]:
i += 1
elif c == '"':
var j := i + 1
var s := ""
while j < n and src[j] != '"':
s += src[j]
j += 1
out.append({"q": s}) # 带引号的字符串(q 键区分裸词)
i = j + 1
elif c == "{" or c == "}":
out.append(c)
i += 1
elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"):
while i < n and src[i] != "\n":
i += 1
else:
var j := i
while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']):
j += 1
out.append(src.substr(i, j - i))
i = j
return out
func _eq(v, ch) -> bool:
return v is String and v == ch
func _val(t):
if t is Dictionary:
return t.get("q", "")
var s := String(t)
# 数字?
if s.is_valid_float():
return float(s)
if s.is_valid_int():
return int(s)
return s
# 解析一个 { } 块(top=true 时是隐式顶层)。
func _parse_block(top := false) -> Dictionary:
var d := {}
var guard := 0
while _p < _toks.size():
guard += 1
if guard > 500000:
last_error = "runaway"
break
var t = _toks[_p]
if _eq(t, "}"):
_p += 1
return d
if _eq(t, "\n"):
_p += 1
continue
if _eq(t, "{"):
_p += 1
continue
var word := "" if (t is Dictionary) else String(t)
if word == "Group":
_p += 1
var name := _next_word()
_skip_newlines()
if _eq(_cur(), "{"):
_p += 1
var sub := _parse_block()
var key := "__group_" + name
if not d.has(key):
d[key] = []
d[key].append(sub)
continue
if word == "List":
_p += 1
var lname := _next_word()
_skip_newlines()
var rows := []
if _eq(_cur(), "{"):
_p += 1
rows = _parse_list_rows()
d[lname] = rows
continue
# KEY v1 v2 ... (到行尾)
var key := word
_p += 1
var vals := []
while _p < _toks.size() and not _eq(_toks[_p], "\n") and not _eq(_toks[_p], "{") and not _eq(_toks[_p], "}"):
vals.append(_val(_toks[_p]))
_p += 1
d[key] = vals[0] if vals.size() == 1 else vals
if top and _p >= _toks.size():
break
return d
func _parse_list_rows() -> Array:
var rows := []
var row := []
var guard := 0
while _p < _toks.size():
guard += 1
if guard > 200000:
break
var t = _toks[_p]
if _eq(t, "}"):
_p += 1
if not row.is_empty():
rows.append(row)
break
if _eq(t, "\n"):
_p += 1
if not row.is_empty():
rows.append(row)
row = []
continue
if _eq(t, "{"):
_p += 1
continue
row.append(_val(t))
_p += 1
return rows
func _cur():
return _toks[_p] if _p < _toks.size() else null
func _next_word() -> String:
while _p < _toks.size() and _eq(_toks[_p], "\n"):
_p += 1
if _p < _toks.size() and not (_toks[_p] is Dictionary):
var w := String(_toks[_p])
_p += 1
return w
return "?"
func _skip_newlines() -> void:
while _p < _toks.size() and _eq(_toks[_p], "\n"):
_p += 1