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
+331
View File
@@ -0,0 +1,331 @@
# PrivateShopUI —— 개인상점 개설창 (CG_MYSHOP)1:1 迁移 `assets/root/uiprivateshopbuilder.py`
# 的 `PrivateShopBuilder`(布局走真 `assets/uiscript/uiscript/privateshopbuilder.py`)。
#
# var pu := preload("res://ui/private_shop_ui.gd").new()
# add_child(pu)
# pu.setup(m2client, ui_manager, proto, assets_root)
# pu.toggle()
#
# 布局:`privateshopbuilder.py`board + TitleBar + NameLine + ItemSlot 5×8=40 grid + Ok/Close)。
# 交互 = uiprivateshopbuilder.itemStock:左侧(挂在窗右边的附加面板)列背包候选,点一件
# “拿起”,再点右侧 40 格的空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位
# `AddPrivateShopItemStock` + `itemStock[targetSlot]=(src)`);点已占用格 → 撤下
# `OnSelectItemSlot` / `DelPrivateShopItemStock`)。OkButton(`BuildPrivateShop`)stock 按
# 格号排序、`display_pos = 格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)打包成
# `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
# = `TPacketCGMyShop` + `TShopItemTable`×N)。철수 `close_private_shop()` = SHOP_CG_END。
extends Node
const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限)
const SIGN_MAX := 25 # NameLine input_limit
var client: Node
var ui: CanvasLayer # UiManager
var proto: Node
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {}
var _sign: LineEdit
var _inv_list: VBoxContainer
var _cells: Dictionary = {} # 格号:int -> Panel
var _status: Label
var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price}
var _picked = null # {cell, vnum, count} 等待落位
var _price_dialog: Control = null
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
client = m2client
ui = ui_manager
proto = proto_node
assets_root = assets
if assets_root == "" and ui and "assets_root" in ui:
assets_root = ui.assets_root
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh())
# --- open / close --------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
_dismiss_price_dialog()
if is_open():
ui.close(_win["root"])
_win = {}
_cells.clear()
_stock.clear()
_picked = null
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("privateshopbuilder.py")
if not FileAccess.file_exists(path):
push_warning("PrivateShopUI: no privateshopbuilder.py at " + path)
return
_stock.clear()
_picked = null
_win = ui.open_script(path, assets_root)
if not is_open():
return
var root: Control = _win["root"]
_index_cells(root)
_overlay_name_edit(root)
_add_inv_panel(root)
_wire_buttons(root)
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _index_cells(root: Node) -> void:
_cells.clear()
for n in root.find_children("slot_*", "Panel", true, false):
if not n.has_meta("slot_index"):
continue
var slot := int(n.get_meta("slot_index"))
_cells[slot] = n
var idx := slot
n.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_slot_clicked(idx))
# NameLine 在 uiscript 里是 textLabel)—— 盖一个 LineEdit 上去
func _overlay_name_edit(root: Control) -> void:
var name_line := _node("NameLine")
_sign = LineEdit.new()
_sign.max_length = SIGN_MAX
_sign.placeholder_text = "상점 이름"
if name_line:
name_line.visible = false
var slot := name_line.get_parent()
_sign.position = name_line.position
_sign.size = Vector2(maxf(name_line.size.x, 150), 18)
(slot if slot is Control else root).add_child(_sign)
else:
_sign.position = Vector2(13, 35)
_sign.size = Vector2(157, 18)
root.add_child(_sign)
func _wire_buttons(root: Control) -> void:
var ok := _node("OkButton")
if ok is BaseButton:
ok.pressed.connect(_ok)
var cl := _node("CloseButton")
if cl is BaseButton:
cl.pressed.connect(close)
var tb := _node("TitleBar")
if tb:
for b in tb.find_children("*", "BaseButton", true, false):
b.pressed.connect(close)
# 원 클라는 메인 인벤토리에서 드래그 —— 여기선 창 오른쪽에 후보 패널을 붙임
func _add_inv_panel(root: Control) -> void:
var panel := Panel.new()
panel.name = "inv_candidates"
panel.position = Vector2(root.size.x + 6, 0)
panel.size = Vector2(220, root.size.y)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.06, 0.06, 0.09, 0.95)
bg.border_color = Color(0.4, 0.36, 0.24, 0.9)
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
var title := Label.new()
title.text = "배낭 (클릭 → 집기)"
title.position = Vector2(10, 8)
title.add_theme_font_size_override("font_size", 12)
panel.add_child(title)
var sc := ScrollContainer.new()
sc.position = Vector2(8, 30)
sc.size = Vector2(204, root.size.y - 66)
panel.add_child(sc)
_inv_list = VBoxContainer.new()
_inv_list.add_theme_constant_override("separation", 3)
sc.add_child(_inv_list)
_status = Label.new()
_status.position = Vector2(10, root.size.y - 30)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
panel.add_child(_status)
# --- 명칭 --------------------------------------------------------------
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
# --- 새로고침 --------------------------------------------------------
func _refresh() -> void:
_refresh_inv()
_refresh_grid()
func _refresh_inv() -> void:
if not is_instance_valid(_inv_list):
return
for c in _inv_list.get_children():
_inv_list.remove_child(c)
c.queue_free()
var used := {}
for s in _stock.values():
used[int(s["cell"])] = true
var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for it in inv:
var cell := int(it.get("cell", 0))
if used.has(cell):
continue
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var b := Button.new()
b.text = "%s ×%d" % [_name_of(vnum), count]
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
b.custom_minimum_size = Vector2(196, 22)
b.toggle_mode = true
var entry := {"cell": cell, "vnum": vnum, "count": count}
b.pressed.connect(func() -> void: _pick(entry, b))
if _picked != null and int(_picked["cell"]) == cell:
b.button_pressed = true
_inv_list.add_child(b)
shown += 1
if shown == 0:
var l := Label.new()
l.text = "(배낭이 비어 있음)"
l.add_theme_font_size_override("font_size", 12)
_inv_list.add_child(l)
func _refresh_grid() -> void:
for slot in _cells.keys():
var cell: Panel = _cells[slot]
for ch in cell.get_children():
cell.remove_child(ch)
ch.queue_free()
cell.tooltip_text = ""
if _stock.has(slot):
var s: Dictionary = _stock[slot]
var nm := Label.new()
nm.text = _name_of(int(s["vnum"])).substr(0, 4)
nm.add_theme_font_size_override("font_size", 8)
nm.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(nm)
var pr := Label.new()
pr.text = str(int(s["price"]))
pr.position = Vector2(1, 18)
pr.add_theme_font_size_override("font_size", 8)
pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5))
pr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(pr)
cell.tooltip_text = "%s ×%d\n%d" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])]
cell.modulate = Color(1, 1, 1)
else:
cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1)
# --- 상호작용 --------------------------------------------------------
func _pick(entry: Dictionary, _btn: Button) -> void:
if _picked != null and int(_picked["cell"]) == int(entry["cell"]):
_picked = null
else:
_picked = entry
_refresh_grid()
func _on_slot_clicked(slot: int) -> void:
if _stock.has(slot):
_stock.erase(slot)
if is_instance_valid(_status):
_status.text = ""
_refresh()
return
if _picked == null:
return
_ask_price(slot)
func _ask_price(slot: int) -> void:
_dismiss_price_dialog()
var dlg := Panel.new()
dlg.set_anchors_preset(Control.PRESET_CENTER)
dlg.position = Vector2(-120, -70)
dlg.size = Vector2(240, 128)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.06, 0.09, 0.98)
sb.border_color = Color(0.7, 0.55, 0.22, 0.9)
sb.set_border_width_all(1)
dlg.add_theme_stylebox_override("panel", sb)
var t := Label.new()
t.text = "판매 가격"
t.position = Vector2(12, 10)
dlg.add_child(t)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
spin.value = int(_stock.get(slot, {}).get("price", 1))
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
dlg.add_child(spin)
var ok := Button.new()
ok.text = "확인"
ok.position = Vector2(60, 86)
ok.pressed.connect(func() -> void: _place(slot, int(spin.value)))
dlg.add_child(ok)
var cancel := Button.new()
cancel.text = "취소"
cancel.position = Vector2(130, 86)
cancel.pressed.connect(_dismiss_price_dialog)
dlg.add_child(cancel)
_win["root"].get_parent().add_child(dlg)
_price_dialog = dlg
func _dismiss_price_dialog() -> void:
if is_instance_valid(_price_dialog):
_price_dialog.queue_free()
_price_dialog = null
func _place(slot: int, price: int) -> void:
if price <= 0 or _picked == null:
_dismiss_price_dialog()
return
_stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]),
"count": int(_picked["count"]), "price": price}
_picked = null
_dismiss_price_dialog()
_refresh()
func _ok() -> void:
if _stock.is_empty():
if is_instance_valid(_status):
_status.text = "판매할 아이템을 올리세요"
return
var slots := _stock.keys()
slots.sort()
var items: Array = []
for slot in slots:
if items.size() >= PRIVATE_SHOP_ITEM_MAX:
break
var s: Dictionary = _stock[slot]
items.append({"vnum": s["vnum"], "count": s["count"], "inv_cell": s["cell"],
"price": s["price"], "display_pos": slot})
if client and client.has_method("open_private_shop"):
client.open_private_shop(_sign.text.strip_edges(), items)
close()
func _close_shop() -> void:
if client and client.has_method("close_private_shop"):
client.close_private_shop()