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
332 lines
11 KiB
GDScript
332 lines
11 KiB
GDScript
# 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 里是 text(Label)—— 盖一个 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()
|