Files
mtgodot-poc/project/ui/private_shop_ui.gd
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

332 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()