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
+327
View File
@@ -0,0 +1,327 @@
# UiScript (P1) —— 解析原始客户端的 `assets/uiscript/*.py` 布局脚本为嵌套 Dictionary。
#
# 这些文件是 Python **dict 字面量**,用到的子集很小:
# 语句: import X(忽略)· NAME = <expr>(文件常量)· window = {...}
# 表达式:str/int/float · NAME · NAME.ATTR · (e) · 元组 (e,e) · 列表 [e,e] ·
# dict {k:v} · 一元 - · 二元 + - * /
# 注释: # 和 ## 到行尾
#
# 名字解析顺序:文件常量 → {SCREEN_WIDTH, SCREEN_HEIGHT} → uiScriptLocale.<X>
# (交给 locale 回调)→ 未知则 0 / ""。除法按浮点算(调用方按需取整)。
#
# 用法:
# var us := preload("res://ui/uiscript.gd").new()
# us.screen = Vector2i(1920, 1080)
# us.locale = func(key): return Locale.t(key) # 可选
# var tree: Dictionary = us.parse_file("res://../assets/uiscript/uiscript/popupdialog.py")
# # tree = { name, type?, x, y, width, height, children:[...], ... }
extends RefCounted
var screen := Vector2i(1920, 1080)
var locale: Callable # func(key: String) -> String
var last_error := ""
var _consts := {} # 文件顶层 NAME = expr
var _s := "" # 源文本
var _p := 0 # 游标
# --- public ---------------------------------------------------------------
func parse_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
last_error = "no such file: " + path
return {}
return parse_text(FileAccess.get_file_as_string(path))
func parse_text(src: String) -> Dictionary:
last_error = ""
_consts = {"SCREEN_WIDTH": screen.x, "SCREEN_HEIGHT": screen.y}
_s = _strip_comments(src)
_p = 0
var window := {}
while true:
_skip_ws()
if _p >= _s.length():
break
if _match_kw("import"):
_skip_to_newline()
continue
var name := _read_ident()
if name == "":
# 可能是散落的括号/逗号;跳一个字符防死循环
_p += 1
continue
_skip_ws()
if _p < _s.length() and _s[_p] == "=":
_p += 1
var val = _parse_expr()
if name == "window":
window = val if val is Dictionary else {}
else:
_consts[name] = val
else:
_skip_to_newline()
if window.is_empty() and last_error == "":
last_error = "no `window = {...}` found"
return window
# --- lexing helpers -----------------------------------------------------
func _strip_comments(src: String) -> String:
var out := ""
var in_str := false
var q := ""
var i := 0
while i < src.length():
var c := src[i]
if in_str:
out += c
if c == "\\" and i + 1 < src.length():
out += src[i + 1]
i += 2
continue
if c == q:
in_str = false
i += 1
continue
if c == '"' or c == "'":
in_str = true
q = c
out += c
elif c == "#":
while i < src.length() and src[i] != "\n":
i += 1
continue
else:
out += c
i += 1
return out
func _skip_ws() -> void:
while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n", ","]:
_p += 1
func _skip_to_newline() -> void:
while _p < _s.length() and _s[_p] != "\n":
_p += 1
func _match_kw(kw: String) -> bool:
_skip_ws()
if _s.substr(_p, kw.length()) == kw:
var after := _p + kw.length()
if after >= _s.length() or not _is_ident_char(_s[after]):
_p = after
return true
return false
func _is_ident_char(c: String) -> bool:
return c == "_" or (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9")
func _read_ident() -> String:
_skip_ws()
var start := _p
while _p < _s.length() and _is_ident_char(_s[_p]):
_p += 1
return _s.substr(start, _p - start)
# --- expression parser (precedence: + - then * /) ----------------------
func _parse_expr():
var lhs = _parse_term()
while _p < _s.length():
_skip_ws_no_comma()
if _p >= _s.length():
break
var c := _s[_p]
if c == "+" or c == "-":
_p += 1
var rhs = _parse_term()
if c == "+" and (lhs is String or rhs is String):
lhs = _str_or(lhs) + _str_or(rhs)
elif c == "+":
lhs = _num(lhs) + _num(rhs)
else:
lhs = _num(lhs) - _num(rhs)
elif c == "%":
# Python 字符串格式化 "fmt" % (args):取 fmt,丢掉格式参数
_p += 1
_parse_term()
elif c == '"' or c == "'":
# Python 相邻字符串字面量隐式拼接: "a" "b"
lhs = _str_or(lhs) + _str_or(_parse_atom())
else:
break
return lhs
func _parse_term():
var lhs = _parse_atom()
while true:
_skip_ws_no_comma()
if _p < _s.length() and (_s[_p] == "*" or _s[_p] == "/"):
var op := _s[_p]
_p += 1
var rhs = _parse_atom()
if op == "*":
lhs = _num(lhs) * _num(rhs)
else:
lhs = _num(lhs) / _num(rhs) if _num(rhs) != 0.0 else 0.0
else:
break
return lhs
func _skip_ws_no_comma() -> void:
while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n"]:
_p += 1
func _parse_atom():
_skip_ws_no_comma()
if _p >= _s.length():
return 0
var c := _s[_p]
if c == "-":
_p += 1
return -_num(_parse_atom())
if c == '"' or c == "'":
return _parse_string()
if c == "{":
return _parse_dict()
if c == "[":
return _parse_seq("[", "]")
if c == "(":
return _parse_seq("(", ")") # 元组或加括号的表达式,_parse_seq 统一处理
if c == "_" or (c >= "A" and c <= "Z") or (c >= "a" and c <= "z"):
return _parse_name()
if (c >= "0" and c <= "9") or c == ".":
return _parse_number()
_p += 1
return 0
func _parse_string():
var q := _s[_p]
_p += 1
var out := ""
while _p < _s.length() and _s[_p] != q:
if _s[_p] == "\\" and _p + 1 < _s.length():
var n := _s[_p + 1]
out += "\n" if n == "n" else ("\t" if n == "t" else n)
_p += 2
continue
out += _s[_p]
_p += 1
_p += 1 # closing quote
return out
func _parse_number():
var start := _p
var is_float := false
while _p < _s.length() and ((_s[_p] >= "0" and _s[_p] <= "9") or _s[_p] == "." or _s[_p] == "x" \
or (_s[_p] >= "a" and _s[_p] <= "f") or (_s[_p] >= "A" and _s[_p] <= "F")):
if _s[_p] == ".":
is_float = true
_p += 1
var tok := _s.substr(start, _p - start)
if tok.begins_with("0x") or tok.begins_with("0X"):
return tok.hex_to_int()
return float(tok) if is_float else int(tok)
func _parse_name():
var name := _read_ident()
# 属性链:uiScriptLocale.KEY / X.Y
while _p < _s.length() and _s[_p] == ".":
_p += 1
var attr := _read_ident()
if name == "uiScriptLocale":
return _resolve_locale(attr)
name += "." + attr
if _consts.has(name):
return _consts[name]
if name == "True":
return true
if name == "False":
return false
if name == "None":
return null
# 未知名字:数值上下文当 0,字符串上下文当 "";这里返回 0_str_or 会兜
return 0
func _resolve_locale(key: String):
if locale.is_valid():
var v = locale.call(key)
if v != null and String(v) != "":
return String(v)
return "<" + key + ">"
func _parse_dict() -> Dictionary:
var d := {}
_p += 1 # {
var guard := 0
while _p < _s.length():
guard += 1
if guard > 4000:
last_error = "dict parse runaway"
break
_skip_ws()
if _p >= _s.length() or _s[_p] == "}":
_p += 1
break
var before := _p
var key = _parse_atom()
_skip_ws()
if _p < _s.length() and _s[_p] == ":":
_p += 1
var val = _parse_expr()
d[_key(key)] = val
elif _p == before:
_p += 1 # 没进展也没 ':',强制前进防死循环
# 否则:孤立 token(相邻字符串续行残留等)——丢弃,继续
_skip_ws()
return d
func _key(k) -> String:
if k is String:
return k
if k is int or k is float:
return str(k)
return "_"
# 统一处理 ( ) 和 [ ]:单元素且无尾逗号的 (x) 退化成 x,否则是数组。
func _parse_seq(open: String, close: String):
_p += 1 # opening
var items := []
var had_comma := false
var guard := 0
while _p < _s.length():
guard += 1
if guard > 4000:
break
_skip_ws_no_comma()
if _p >= _s.length() or _s[_p] == close:
_p += 1
break
if _s[_p] == ",":
had_comma = true
_p += 1
continue
var before := _p
items.append(_parse_expr())
if _p == before:
_p += 1
if open == "(" and items.size() == 1 and not had_comma:
return items[0]
return items
# --- coercion ----------------------------------------------------------
func _num(v) -> float:
if v is float:
return v
if v is int:
return float(v)
if v is bool:
return 1.0 if v else 0.0
return 0.0
func _str_or(v) -> String:
if v is String:
return v
return str(v)