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
+403
View File
@@ -0,0 +1,403 @@
# InventoryUI (P2) —— 背包 + 装备窗(走 P1 uiscript 装载器)。
#
# var inv := preload("res://ui/inventory_ui.gd").new()
# add_child(inv)
# inv.setup(ui_manager, m2client, proto, assets_root)
# inv.toggle() # I 键
#
# 从 `assets/uiscript/inventorywindow.py` 建窗,用 M2Client.get_inventory/get_equipment
# 填格子,Metin2Proto 出名字,icon/item/<vnum>.tga 出图标。右键=use_item
# 拖到别的格=move_item。inventory_changed 信号来时刷新。
#
# 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板;发包仍
# 使用原客户端的 INVENTORY 全局 cell90+wear / 152+belt cell)。
extends Node
# 右键点道具时发出(window, cell 为 wire 坐标)。P8 的商店 / 交易 / 仓库窗接它。
signal item_context(window: int, cell: int)
# 若设置:右键道具时先调它 (window, cell) -> bool;返回 true 表示已处理,不再 use_item。
var context_consumer: Callable = Callable()
const UiAssets = preload("res://ui/ui_assets.gd")
const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX
const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞
const BELT_SLOT_START := 152 # GameType.c_Belt_Inventory_Slot_Start
const BELT_CELL_COUNT := 16
const WEAR_COUNT := 24
const WEAR_BELT := 23
const WINDOW_INVENTORY := 1 # mtnet::WINDOW_*
const BELT_RULES := [1, 2, 4, 6, 3, 3, 4, 6, 5, 5, 5, 6, 7, 7, 7, 7]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var proto: Node # Metin2Proto
var item_list: RefCounted # ItemList(可空;给了就用它的 icon 路径)
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _cells := {} # ui_index:int -> Panel
var _drag_from := -1
var _combine_from := -1 # Shift 选中的“使用到物品”来源格
var _hint: Label
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String,
il: RefCounted = null) -> void:
ui = ui_manager
client = m2client
proto = proto_node
item_list = il
assets_root = assets
uiscript_dir = assets.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
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:
if is_open():
ui.close(_win["root"])
_win = {}
_cells.clear()
_combine_from = -1
_hint = null
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("inventorywindow.py")
if not FileAccess.file_exists(path):
push_warning("InventoryUI: no inventorywindow.py at " + path)
return
_win = ui.open_script(path, assets_root)
_index_cells(_win["root"])
_add_extended_cells(_win["root"])
_wire_cells()
refresh()
# --- 填充 ---------------------------------------------------------------
func refresh() -> void:
if not is_open() or client == null:
return
for idx in _cells:
_clear_cell(_cells[idx])
for d in client.get_inventory():
var cell: Panel = _cells.get(int(d["cell"]), null)
if cell:
_fill_cell(cell, int(d["vnum"]), int(d["count"]))
var eq: Array = client.get_equipment()
for i in eq.size():
var it: Dictionary = eq[i]
var cell: Panel = _cells.get(EQUIP_BASE + i, null)
if cell and int(it.get("vnum", 0)) != 0:
_fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)))
for d in client.get_belt_inventory():
var cell: Panel = _cells.get(BELT_BASE + int(d["cell"]), null)
if cell:
_fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)))
_apply_belt_locks(eq)
func _on_inv_changed(_window: int, _cell: int) -> void:
refresh()
func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
_clear_cell(cell)
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
var name := "item %d" % vnum
var itype := 0
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
name = String(pd.get("locale_name", pd.get("name", name)))
itype = int(pd.get("type", 0))
cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""]
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 = name.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.set_meta("count", 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) # basevnum 约定
return UiAssets.load_tex(assets_root, rel)
# --- 交互(右键用 / 拖动移动)---------------------------------------------
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_cells() -> void:
for idx in _cells:
var cell: Panel = _cells[idx]
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e))
func _add_extended_cells(root: Control) -> void:
# 旧 inventorywindow.py 只排出前 11 个传统装备位。剩余能力 / 时装 /
# 戒指 / 腰带与 4x4 腰带背包在同一扩展面板中保持可拖放。
var panel := Panel.new()
panel.name = "extended_equipment"
panel.position = Vector2(root.size.x + 8, 0)
panel.size = Vector2(204, 460)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.05, 0.07, 0.92)
bg.border_color = Color(0.45, 0.38, 0.2, 0.9)
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
_add_grid(panel, "扩展装备", 11, WEAR_COUNT, EQUIP_BASE, Vector2(10, 30))
_add_grid(panel, "腰带背包", 0, BELT_CELL_COUNT, BELT_BASE, Vector2(10, 242))
_hint = Label.new()
_hint.position = Vector2(10, 430)
_hint.size = Vector2(184, 28)
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_hint.add_theme_font_size_override("font_size", 10)
_hint.text = "Shift+左键:使用到物品\nCtrl+右键:赠与目标;Alt+右键:按数量丢弃"
panel.add_child(_hint)
func _add_grid(parent: Panel, title_text: String, first: int, count: int, base: int, pos: Vector2) -> void:
var title := Label.new()
title.text = title_text
title.position = pos - Vector2(0, 22)
title.add_theme_font_size_override("font_size", 13)
parent.add_child(title)
var grid := GridContainer.new()
grid.columns = 4
grid.position = pos
grid.add_theme_constant_override("h_separation", 4)
grid.add_theme_constant_override("v_separation", 4)
parent.add_child(grid)
for local in count:
var cell := Panel.new()
cell.name = "slot_%d" % (base + first + local)
cell.custom_minimum_size = Vector2(42, 42)
cell.set_meta("slot_index", base + first + local)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.13, 0.16, 0.95)
style.border_color = Color(0.35, 0.32, 0.22, 0.9)
style.set_border_width_all(1)
cell.add_theme_stylebox_override("panel", style)
grid.add_child(cell)
_cells[base + first + local] = cell
func _apply_belt_locks(eq: Array) -> void:
var grade := 0
if eq.size() > WEAR_BELT:
var belt: Dictionary = eq[WEAR_BELT]
if int(belt.get("vnum", 0)) != 0 and proto:
var data: Dictionary = proto.item(int(belt["vnum"]))
var values: Array = data.get("values", [])
if not values.is_empty():
grade = int(values[0])
for local in BELT_CELL_COUNT:
var cell: Panel = _cells.get(BELT_BASE + local, null)
if cell == null:
continue
var locked: bool = grade < int(BELT_RULES[local])
cell.set_meta("locked", locked)
cell.modulate = Color(0.45, 0.45, 0.45, 0.9) if locked else Color.WHITE
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE if locked else Control.MOUSE_FILTER_STOP
if locked and int(cell.get_meta("vnum", 0)) == 0:
cell.tooltip_text = "需要腰带等级 %d" % BELT_RULES[local]
func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
if not (e is InputEventMouseButton):
return
var cell: Panel = _cells[ui_idx]
if bool(cell.get_meta("locked", false)):
return
var vnum := int(cell.get_meta("vnum", 0))
if e.button_index == MOUSE_BUTTON_RIGHT and e.pressed and vnum != 0:
if e.shift_pressed:
_begin_combine(ui_idx)
return
if e.ctrl_pressed:
give_to_target(ui_idx)
return
if e.alt_pressed:
drop_count(ui_idx)
return
var w := _to_wire(ui_idx)
item_context.emit(w[0], w[1])
if context_consumer.is_valid() and bool(context_consumer.call(w[0], w[1])):
return
use(ui_idx)
elif e.button_index == MOUSE_BUTTON_LEFT:
if e.pressed and vnum != 0:
if e.shift_pressed:
_begin_combine(ui_idx)
return
if _combine_from != -1:
if _combine_from != ui_idx:
_use_to_item(_combine_from, ui_idx)
return
_drag_from = ui_idx
elif not e.pressed and _drag_from != -1:
var over := _cell_under_mouse()
if over != -1 and over != _drag_from:
move_to(_drag_from, over, 1)
_drag_from = -1
# 把 ui 格子 from 的物品挪到 ui 格子 to(发 CG_ITEM_MOVE
func move_to(from_ui: int, to_ui: int, count := 1) -> bool:
if client == null:
return false
var a := _to_wire(from_ui)
var b := _to_wire(to_ui)
return client.move_item(a[0], a[1], b[0], b[1], count)
# 用掉 ui 格子里的物品(发 CG_ITEM_USE
func use(ui_idx: int) -> bool:
if client == null:
return false
var w := _to_wire(ui_idx)
return client.use_item(w[0], w[1])
# Shift+左键(或 Shift+右键)选来源,再左键另一个非空格。
func _begin_combine(ui_idx: int) -> void:
if _combine_from == ui_idx:
_combine_from = -1
_set_hint("已取消物品对物品")
return
_combine_from = ui_idx
_drag_from = -1
_set_hint("请选择目标物品(左键);再次 Shift 点击来源格可取消")
func _use_to_item(source_ui: int, target_ui: int) -> bool:
var target: Panel = _cells.get(target_ui, null)
if target == null or int(target.get_meta("vnum", 0)) == 0:
_set_hint("目标格必须有物品")
return false
var source := _to_wire(source_ui)
var dest := _to_wire(target_ui)
_combine_from = -1
_set_hint("已发送物品对物品请求")
return client != null and client.use_item_to_item(source[0], source[1], dest[0], dest[1])
# Ctrl+右键把物品赠与当前选中的实体;数量由用户确认。
func give_to_target(ui_idx: int) -> void:
if client == null or not client.has_method("get_target"):
return
var target: Dictionary = client.get_target()
var target_vid := int(target.get("vid", 0))
if target_vid == 0:
_set_hint("请先选中要赠与的角色")
return
var wire := _to_wire(ui_idx)
_ask_count("赠与数量", _item_count(ui_idx), func(amount: int):
client.give_item(target_vid, wire[0], wire[1], amount))
# Alt+右键丢弃;非金币物品走 CG_ITEM_DROP2,精确保留用户填写的数量。
func drop_count(ui_idx: int) -> void:
if client == null:
return
var wire := _to_wire(ui_idx)
_ask_count("丢弃数量", _item_count(ui_idx), func(amount: int):
client.drop_item_count(wire[0], wire[1], 0, amount))
func _item_count(ui_idx: int) -> int:
var cell: Panel = _cells.get(ui_idx, null)
return max(1, int(cell.get_meta("count", 1))) if cell else 1
func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> void:
if ui == null:
return
var dialog := Panel.new()
dialog.position = Vector2(760, 420)
dialog.size = Vector2(240, 132)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.05, 0.06, 0.09, 0.97)
bg.border_color = Color(0.7, 0.55, 0.22, 0.9)
bg.set_border_width_all(1)
dialog.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.text = title_text
title.position = Vector2(12, 10)
dialog.add_child(title)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = max(1, max_count)
spin.value = 1
spin.step = 1
spin.allow_greater = false
spin.position = Vector2(12, 42)
spin.size = Vector2(216, 28)
dialog.add_child(spin)
var ok := Button.new()
ok.text = "确认"
ok.position = Vector2(76, 88)
ok.pressed.connect(func():
var amount := int(spin.value)
ui.close(dialog)
on_confirm.call(amount))
dialog.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.position = Vector2(142, 88)
cancel.pressed.connect(func(): ui.close(dialog))
dialog.add_child(cancel)
ui.open(dialog, true)
func _set_hint(text: String) -> void:
if is_instance_valid(_hint):
_hint.text = text
func _cell_under_mouse() -> int:
for idx in _cells:
var cell: Panel = _cells[idx]
if cell.get_global_rect().has_point(cell.get_global_mouse_position()):
return int(idx)
return -1
# ui 格子编号 -> (window, cell)
func _to_wire(ui_idx: int) -> Array:
if ui_idx >= BELT_BASE:
return [WINDOW_INVENTORY, BELT_SLOT_START + ui_idx - BELT_BASE]
if ui_idx >= EQUIP_BASE:
return [WINDOW_INVENTORY, ui_idx]
return [WINDOW_INVENTORY, ui_idx]