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
+233
View File
@@ -0,0 +1,233 @@
# UiBuild (P1) —— uiscript 嵌套 Dictionary → Godot Control 树。
#
# var spec := UiScript.new().parse_file(path)
# var r := UiBuild.build(spec, assets_root)
# add_child(r.root)
# r.nodes["accept"].pressed.connect(...) # 按 uiscript 里的 "name" 取节点
#
# 覆盖 uiscript 最常用的 ~18 种 type;其它降级为 Control / Button / TextureRect。
# board/thinboard 的九宫格用 ui_kit(真实 ymir 贴图,缺失则 StyleBoxFlat 兜底)。
extends RefCounted
const UiKit = preload("res://ui_kit.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# spec: 顶层 window dict。返回 { root: Control, nodes: {name: Control} }
static func build(spec: Dictionary, assets_root: String) -> Dictionary:
var ctx := {"assets": assets_root, "nodes": {}}
var root := _make(spec, ctx, Vector2i.ZERO)
if root == null:
root = Control.new()
return {"root": root, "nodes": ctx["nodes"]}
# --- recursion ---------------------------------------------------------
static func _i(spec: Dictionary, key: String, def := 0) -> int:
var v = spec.get(key, def)
return int(v) if (v is int or v is float or v is bool or v is String) else def
static func _str(v) -> String:
if v == null:
return ""
return v if v is String else str(v)
static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> Control:
var type := _str(spec.get("type", "window"))
var w := _i(spec, "width", 0)
var h := _i(spec, "height", 0)
var node := _make_by_type(type, spec, ctx, Vector2i(w, h))
if node == null:
node = Control.new()
node.name = _safe_name(_str(spec.get("name", type)))
if w > 0:
node.custom_minimum_size = Vector2(w, h)
node.size = Vector2(w, h)
# 位置:x/y + horizontal_align / vertical_align(相对父)
var x := _i(spec, "x", 0)
var y := _i(spec, "y", 0)
match _str(spec.get("horizontal_align", "left")):
"center": x = (parent_size.x - w) / 2 + x
"right": x = parent_size.x - w - x
match _str(spec.get("vertical_align", "top")):
"center": y = (parent_size.y - h) / 2 + y
"bottom": y = parent_size.y - h - y
node.position = Vector2(x, y)
var nm := _str(spec.get("name", ""))
if nm != "":
ctx["nodes"][nm] = node
var self_size := Vector2i(w, h) if w > 0 else parent_size
for child in spec.get("children", []):
if child is Dictionary:
var c := _make(child, ctx, self_size)
if c:
node.add_child(c)
return node
static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: Vector2i) -> Control:
var assets: String = ctx["assets"]
match type:
"window", "box":
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_PASS
return c
"board", "thinboard", "board_with_titlebar":
var p := _board(assets, "thinboard" if type == "thinboard" else "board", sz)
if type == "board_with_titlebar" and spec.has("title"):
var tl := Label.new()
tl.name = "TitleText"
tl.text = _str(spec.get("title", ""))
tl.position = Vector2(12, 6)
p.add_child(tl)
p.set_meta("titlebar_h", 24)
return p
"titlebar":
var t := Panel.new()
t.set_meta("is_titlebar", true)
return t
"text":
var lbl := Label.new()
lbl.text = _str(spec.get("text", ""))
if spec.has("text_color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["text_color"])))
match _str(spec.get("text_horizontal_align", "left")):
"center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
return lbl
"button", "toggle_button":
var b := Button.new()
b.text = _str(spec.get("text", ""))
b.toggle_mode = (type == "toggle_button")
var nrm := UiAssets.load_tex(assets, _str(spec.get("default_image", "")))
var ovr := UiAssets.load_tex(assets, _str(spec.get("over_image", "")))
var dwn := UiAssets.load_tex(assets, _str(spec.get("down_image", "")))
if nrm:
b.add_theme_stylebox_override("normal", _sb_tex(nrm))
if ovr: b.add_theme_stylebox_override("hover", _sb_tex(ovr))
if dwn: b.add_theme_stylebox_override("pressed", _sb_tex(dwn))
return b
"radio_button":
var cb := CheckBox.new()
cb.text = _str(spec.get("text", ""))
return cb
"image", "expanded_image", "ani_image", "mark":
var ir := TextureRect.new()
ir.texture = UiAssets.load_tex(assets, _str(spec.get("image", "")))
ir.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
ir.stretch_mode = TextureRect.STRETCH_SCALE if type == "expanded_image" \
else TextureRect.STRETCH_KEEP
ir.mouse_filter = Control.MOUSE_FILTER_IGNORE
return ir
"editline":
var e := LineEdit.new()
e.text = _str(spec.get("text", ""))
e.secret = bool(spec.get("secret", false))
if spec.has("input_limit"):
e.max_length = int(spec["input_limit"])
return e
"line":
var ln := ColorRect.new()
ln.color = Color(1, 1, 1, 0.15)
if _i(spec, "height", 0) == 0:
ln.custom_minimum_size = Vector2(_i(spec, "width", 100), 1)
return ln
"listbox", "candidate_list":
return ItemList.new()
"gauge", "bar", "horizontalbar":
var pb := ProgressBar.new()
pb.show_percentage = false
pb.value = 100.0
return pb
"grid_table", "slotbar", "slot":
# Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格;
# 否则 grid_table 退化成普通 GridContainer。
var cells := _slot_cells(spec)
if cells.is_empty() and type == "grid_table":
var g := GridContainer.new()
g.columns = maxi(1, _i(spec, "x_count", _i(spec, "column", 1)))
return g
var s := Control.new()
s.set_meta("is_slot_container", true)
for cd in cells:
var cell := Panel.new()
cell.name = "slot_%d" % int(cd["index"])
cell.position = Vector2(cd["x"], cd["y"])
cell.custom_minimum_size = Vector2(cd["w"], cd["h"])
cell.size = Vector2(cd["w"], cd["h"])
cell.set_meta("slot_index", int(cd["index"]))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
s.add_child(cell)
return s
"sliderbar":
return HSlider.new()
"scrollbar", "thin_scrollbar":
return VScrollBar.new()
_:
return Control.new()
# --- helpers ---------------------------------------------------------
static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
var p := Panel.new()
var np: NinePatchRect = null
if assets != "":
np = UiKit.board(assets, prefix, 32, 128)
if np and np.texture:
np.set_anchors_preset(Control.PRESET_FULL_RECT)
np.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_child(np)
p.get_child(0).show_behind_parent = true
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.10, 0.11, 0.14, 0.92)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
sb.set_corner_radius_all(3)
p.add_theme_stylebox_override("panel", sb)
return p
# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。
static func _slot_cells(spec: Dictionary) -> Array:
var cells := []
if spec.get("slot", null) is Array:
for sd in spec["slot"]:
if sd is Dictionary:
cells.append({"index": _i(sd, "index"), "x": _i(sd, "x"), "y": _i(sd, "y"),
"w": _i(sd, "width", 32), "h": _i(sd, "height", 32)})
elif spec.has("x_count"):
var si := _i(spec, "start_index", 0)
var xc := maxi(1, _i(spec, "x_count", 1))
var yc := maxi(1, _i(spec, "y_count", 1))
var xs := _i(spec, "x_step", 32)
var ys := _i(spec, "y_step", 32)
for row in yc:
for col in xc:
cells.append({"index": si + row * xc + col, "x": col * xs, "y": row * ys,
"w": 32, "h": 32})
return cells
static func _sb_tex(t: Texture2D) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = t
return sb
static func _argb(v: int) -> Color:
# 0xAARRGGBB
var a := (v >> 24) & 0xff
var r := (v >> 16) & 0xff
var g := (v >> 8) & 0xff
var b := v & 0xff
if a == 0:
a = 255
return Color8(r, g, b, a)
static func _safe_name(s: String) -> String:
var out := s
for bad: String in [".", ":", "@", "/", "%", " "]:
out = out.replace(bad, "_")
return out if out != "" else "node"