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
+174
View File
@@ -0,0 +1,174 @@
# ui_test —— P1 UI 工具层 headless 自检(解析器 + 构建器 + 窗口管理器)。
# godot --headless --path project --script ui_test.gd
extends SceneTree
const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiManager = preload("res://ui/ui_manager.gd")
const Dialogs = preload("res://ui/dialogs.gd")
const FIXTURE := """
import uiScriptLocale
ROOT = "d:/ymir work/ui/public/"
PAD = 10
window = {
"name" : "TestWin",
"style" : ("float",),
"x" : SCREEN_WIDTH/2 - 250,
"y" : SCREEN_HEIGHT/2 - 40,
"width" : 280,
"height" : 105,
"children" : (
{
"name" : "board",
"type" : "board_with_titlebar",
"x" : 0, "y" : 0,
"width" : 280, "height" : 105,
"title" : uiScriptLocale.HELLO,
"children" : (
{
"name" : "message",
"type" : "text",
"x" : PAD, "y" : 38,
"text" : uiScriptLocale.MESSAGE,
"text_horizontal_align" : "center",
"text_color" : 0xffF8BF24,
},
{
"name" : "accept",
"type" : "button",
"x" : 0, "y" : 63,
"width" : 61, "height" : 21,
"horizontal_align" : "center",
"text" : uiScriptLocale.OK,
"default_image" : ROOT + "middle_button_01.sub",
},
),
},
),
}
"""
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_test_parser()
_test_builder()
_test_manager()
_test_dialogs()
_test_real_file()
if _fail == 0:
print("PASS: ui_test (parser + builder + manager)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _test_parser() -> void:
var us := UiScript.new()
us.screen = Vector2i(1920, 1080)
us.locale = func(k: String) -> String:
return {"HELLO": "Hi", "MESSAGE": "Body", "OK": "OK"}.get(k, "")
var w := us.parse_text(FIXTURE)
_ck(us.last_error == "", "parser: no error (%s)" % us.last_error)
_ck(w.get("name") == "TestWin", "parser: window name")
_ck(int(w.get("x")) == 710, "parser: arithmetic SCREEN_WIDTH/2-250 = 710, got %s" % w.get("x"))
_ck(int(w.get("width")) == 280, "parser: width")
var kids: Array = w.get("children", [])
_ck(kids.size() == 1 and kids[0].get("type") == "board_with_titlebar", "parser: child type")
_ck(kids[0].get("title") == "Hi", "parser: locale HELLO -> 'Hi'")
var inner: Array = kids[0].get("children", [])
_ck(inner.size() == 2, "parser: 2 grandchildren")
_ck(inner[0].get("text") == "Body", "parser: locale MESSAGE")
_ck(int(inner[0].get("text_color")) == 0xffF8BF24, "parser: hex color")
_ck(int(inner[1].get("x")) == 0 and inner[1].get("horizontal_align") == "center",
"parser: const PAD + align kept")
# 空输入 / 坏输入
_ck(UiScript.new().parse_text("import x\n").is_empty(), "parser: no window -> {}")
func _test_builder() -> void:
var us := UiScript.new()
us.screen = Vector2i(1920, 1080)
us.locale = func(k: String) -> String: return k
var spec := us.parse_text(FIXTURE)
var r := UiBuild.build(spec, "") # 无 assets -> 主题兜底
var root: Control = r.root
_ck(root is Control, "builder: root is Control")
_ck(root.size == Vector2(280, 105), "builder: root size")
_ck(root.position == Vector2(710, 500), "builder: root pos, got %s" % root.position)
var nodes: Dictionary = r.nodes
_ck(nodes.has("accept") and nodes["accept"] is Button, "builder: accept is Button")
_ck(nodes["accept"].text == "OK", "builder: button text")
# horizontal_align centerx = (280 - 61)/2 + 0 = 109(父 board 宽 280
_ck(nodes["accept"].position.x == 109, "builder: center align x = 109, got %s" % nodes["accept"].position.x)
_ck(nodes.has("message") and nodes["message"] is Label, "builder: message is Label")
_ck(nodes["message"].horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, "builder: text align")
_ck(nodes["board"] is Panel, "builder: board is Panel")
_ck(nodes["board"].has_node("TitleText"), "builder: board_with_titlebar has title label")
func _test_manager() -> void:
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready() # ensure built (add_child 后一帧才 _ready,这里直接调)
var w1 := Control.new()
w1.custom_minimum_size = Vector2(100, 100)
var w2 := Control.new()
ui.open(w1)
ui.open(w2, true) # modal
_ck(ui.top() == w2, "manager: w2 on top")
_ck(ui._dim.visible, "manager: modal dim shown")
_ck(ui.close_top(), "manager: close_top ok")
_ck(ui.top() == w1, "manager: w1 now top")
_ck(not ui._dim.visible, "manager: dim hidden after modal closed")
_ck(not ui.close_top() == false, "manager: close last")
ui.close_top()
_ck(ui.top() == null, "manager: stack empty")
ui.queue_free()
func _test_dialogs() -> void:
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready()
var dlg := Dialogs.new(ui)
var got := [0]
dlg.confirm("delete?", func(): got[0] = 1, func(): got[0] = -1)
_ck(ui.top() != null, "dialogs: confirm opened")
_ck(ui._dim.visible, "dialogs: confirm is modal")
# 找到「确定」按钮点一下
var ok_btn: Button = null
for n in ui.top().get_children():
if n is Button and n.text == "确定":
ok_btn = n
_ck(ok_btn != null, "dialogs: 确定 button present")
if ok_btn:
ok_btn.pressed.emit()
_ck(got[0] == 1, "dialogs: on_ok fired")
_ck(ui.top() == null, "dialogs: closed after confirm")
ui.queue_free()
func _test_real_file() -> void:
var p := AssetRoot.sub("uiscript/uiscript/popupdialog.py")
if not FileAccess.file_exists(p):
print(" (skip real-file test: %s not present)" % p)
return
var us := UiScript.new()
us.locale = func(k: String) -> String: return "L_" + k
var w := us.parse_file(p)
_ck(us.last_error == "", "real: popupdialog parsed (%s)" % us.last_error)
_ck(w.get("name") == "PopupDialog", "real: name == PopupDialog, got %s" % w.get("name"))
var kids: Array = w.get("children", [])
_ck(kids.size() >= 1 and kids[0].get("type") == "board", "real: first child is board")
var found_btn := false
for c in kids[0].get("children", []):
if c.get("type") == "button" and c.get("name") == "accept":
found_btn = true
_ck(String(c.get("default_image", "")).ends_with(".sub"), "real: button default_image is .sub")
_ck(found_btn, "real: found accept button")
# 构建也不炸
var r := UiBuild.build(w, "")
_ck(r.root is Control and r.nodes.has("accept"), "real: build ok")