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
+217
View File
@@ -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()