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:
@@ -0,0 +1,217 @@
|
||||
# SelectItemUI (P11) —— 选魔石窗(1:1 迁移 `assets/root/uiselectitem.py`)。
|
||||
#
|
||||
# var si := preload("res://ui/select_item_ui.gd").new()
|
||||
# add_child(si)
|
||||
# si.setup(ui_manager, m2client, proto, assets_root, item_list)
|
||||
# si.open() # 由 quest_dialog.select_item_requested 触发
|
||||
#
|
||||
# 布局走 `assets/uiscript/uiscript/selectitemwindow.py`(UiScript → UiBuild)。
|
||||
# 逐字对照 uiselectitem.py.RefreshSlot:遍历背包前 `INVENTORY_PAGE_SIZE*2` 格,
|
||||
# 只保留 `item.IsMetin`(proto.type == ITEM_TYPE_METIN 10)且 `GetItemGrade <= 2`
|
||||
# (= 物品内部名最后一位数字,非数字按 0)的物品,最多 54 个。
|
||||
# 点格子 -> `net.SendSelectItemPacket(inventoryCell)`(= M2Client.script_select_item)+ 关窗;
|
||||
# 关闭 / ExitButton -> `net.SendSelectItemPacket(0)` + 关窗(uiselectitem.Close)。
|
||||
extends Node
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
|
||||
const ITEM_TYPE_METIN := 10 # GameLib/ItemData.h EItemType
|
||||
const INVENTORY_PAGE_SIZE := 45 # player.INVENTORY_PAGE_SIZE
|
||||
const MAX_ITEMS := 54 # uiselectitem: slotPos > 54 -> break
|
||||
const WINDOW_INVENTORY := 1
|
||||
|
||||
var ui: CanvasLayer # UiManager
|
||||
var client: Node # M2Client
|
||||
var proto: Node # Metin2Proto
|
||||
var item_list: RefCounted # ItemListDB(可空)
|
||||
var assets_root := ""
|
||||
var uiscript_dir := ""
|
||||
|
||||
var _win: Dictionary = {} # { root, nodes }
|
||||
var _cells := {} # slot_index:int -> Panel
|
||||
var _slot_to_inv := {} # 选择窗格号 -> 背包格号(inventorySlotPosDict)
|
||||
var _sent := false # 已发过一次 select(避免关窗再补发 0)
|
||||
|
||||
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets := "",
|
||||
il: RefCounted = null) -> void:
|
||||
ui = ui_manager
|
||||
client = m2client
|
||||
proto = proto_node
|
||||
item_list = il
|
||||
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()
|
||||
|
||||
# uiselectitem.Close: net.SendSelectItemPacket(0) 再隐藏
|
||||
func close() -> void:
|
||||
if is_open():
|
||||
if not _sent and client and client.has_method("script_select_item"):
|
||||
client.script_select_item(0)
|
||||
ui.close(_win["root"])
|
||||
_win = {}
|
||||
_cells.clear()
|
||||
_slot_to_inv.clear()
|
||||
|
||||
func open() -> void:
|
||||
if is_open():
|
||||
_refresh()
|
||||
return
|
||||
var path := uiscript_dir.path_join("selectitemwindow.py")
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("SelectItemUI: no selectitemwindow.py at " + path)
|
||||
return
|
||||
_sent = false
|
||||
_win = ui.open_script(path, assets_root)
|
||||
if not is_open():
|
||||
return
|
||||
_index_cells(_win["root"])
|
||||
_wire()
|
||||
_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 n.has_meta("slot_index"):
|
||||
_cells[int(n.get_meta("slot_index"))] = n
|
||||
|
||||
func _wire() -> void:
|
||||
for idx in _cells:
|
||||
var cell: Panel = _cells[idx]
|
||||
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e))
|
||||
var exit_btn := _node("ExitButton")
|
||||
if exit_btn is BaseButton:
|
||||
exit_btn.pressed.connect(close)
|
||||
var tb := _node("TitleBar")
|
||||
if tb:
|
||||
for b in tb.find_children("*", "BaseButton", true, false):
|
||||
b.pressed.connect(close)
|
||||
|
||||
# --- fill (uiselectitem.RefreshSlot) --------------------------------
|
||||
|
||||
func _item_grade(vnum: int) -> int:
|
||||
# player.GetItemGrade: 物品内部名最后一位数字(非数字 -> 0)
|
||||
var nm := ""
|
||||
if proto:
|
||||
var pd: Dictionary = proto.item(vnum)
|
||||
nm = String(pd.get("name", ""))
|
||||
if nm == "":
|
||||
return 0
|
||||
var last := nm.substr(nm.length() - 1, 1)
|
||||
return int(last) if last.is_valid_int() else 0
|
||||
|
||||
func _is_metin(vnum: int) -> bool:
|
||||
if proto == null:
|
||||
return false
|
||||
var pd: Dictionary = proto.item(vnum)
|
||||
return int(pd.get("type", 0)) == ITEM_TYPE_METIN
|
||||
|
||||
func _refresh() -> void:
|
||||
if not is_open() or client == null:
|
||||
return
|
||||
for idx in _cells:
|
||||
_clear_cell(_cells[idx])
|
||||
_slot_to_inv.clear()
|
||||
|
||||
# 背包按格号索引,只看前 INVENTORY_PAGE_SIZE*2 格(uiselectitem 的 range)
|
||||
var by_cell := {}
|
||||
for d in client.get_inventory():
|
||||
by_cell[int(d["cell"])] = d
|
||||
var slot_pos := 0
|
||||
for i in range(INVENTORY_PAGE_SIZE * 2):
|
||||
var d = by_cell.get(i, null)
|
||||
if d == null:
|
||||
continue
|
||||
var vnum := int(d["vnum"])
|
||||
if vnum == 0 or not _is_metin(vnum):
|
||||
continue
|
||||
if _item_grade(vnum) > 2:
|
||||
continue
|
||||
_slot_to_inv[slot_pos] = i
|
||||
var cell: Panel = _cells.get(slot_pos, null)
|
||||
if cell:
|
||||
_fill_cell(cell, vnum, int(d.get("count", 1)))
|
||||
slot_pos += 1
|
||||
if slot_pos > MAX_ITEMS:
|
||||
break
|
||||
|
||||
func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
|
||||
_clear_cell(cell)
|
||||
cell.set_meta("vnum", vnum)
|
||||
var nm := "item %d" % vnum
|
||||
if proto:
|
||||
var pd: Dictionary = proto.item(vnum)
|
||||
if not pd.is_empty():
|
||||
nm = String(pd.get("locale_name", pd.get("name", nm)))
|
||||
cell.tooltip_text = "%s\n#%d" % [nm, vnum]
|
||||
var tex := _icon(vnum)
|
||||
if tex:
|
||||
var tr := TextureRect.new()
|
||||
tr.name = "icon"
|
||||
tr.texture = tex
|
||||
tr.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tr.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cell.add_child(tr)
|
||||
else:
|
||||
var lb := Label.new()
|
||||
lb.name = "icon"
|
||||
lb.text = nm.substr(0, 6)
|
||||
lb.add_theme_font_size_override("font_size", 9)
|
||||
lb.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cell.add_child(lb)
|
||||
if count > 1:
|
||||
var cnt := Label.new()
|
||||
cnt.name = "count"
|
||||
cnt.text = str(count)
|
||||
cnt.position = Vector2(2, 16)
|
||||
cnt.add_theme_font_size_override("font_size", 9)
|
||||
cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
cell.add_child(cnt)
|
||||
|
||||
func _clear_cell(cell: Panel) -> void:
|
||||
cell.set_meta("vnum", 0)
|
||||
cell.tooltip_text = ""
|
||||
for c in cell.get_children():
|
||||
c.queue_free()
|
||||
|
||||
func _icon(vnum: int) -> Texture2D:
|
||||
var rel := ""
|
||||
if item_list and item_list.has(vnum):
|
||||
rel = item_list.icon(vnum)
|
||||
if rel == "":
|
||||
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
|
||||
return UiAssets.load_tex(assets_root, rel)
|
||||
|
||||
# --- select (uiselectitem.SelectItemSlot) --------------------------
|
||||
|
||||
func _on_cell_input(slot_pos: int, e: InputEvent) -> void:
|
||||
if not (e is InputEventMouseButton) or e.button_index != MOUSE_BUTTON_LEFT or not e.pressed:
|
||||
return
|
||||
if not _slot_to_inv.has(slot_pos):
|
||||
return
|
||||
var inv_cell := int(_slot_to_inv[slot_pos])
|
||||
if client and client.has_method("script_select_item"):
|
||||
client.script_select_item(inv_cell) # = net.SendSelectItemPacket(inventorySlotPos)
|
||||
_sent = true
|
||||
close()
|
||||
Reference in New Issue
Block a user