Files
mtgodot-poc/project/ui/inventory_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

404 lines
14 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.
# 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]