Files
mtgodot-poc/project/ui/cube_ui.gd
T
shenleiandClaude Sonnet 5 f32064c74a 40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.

Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:

- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
  new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
  (§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
  drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
  empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
  loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
  entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
  GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
  discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
  net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
  1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).

Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:13:01 +09:00

323 lines
10 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.
# CubeUI (첫 버전) —— 도깨비 방주 / 제작 (Cube) 窗。
#
# var cu := preload("res://ui/cube_ui.gd").new()
# add_child(cu)
# cu.setup(m2client, canvas_parent, proto) # proto 可空
#
# 服务器通过 GC_CHAT/COMMAND 的 "cube ..." 行驱动(见 EntityStore::apply_server_command):
# cube_opened(npc) → 显示 + 请求配方列表
# cube_changed → 刷新配方 / 材料 / 金币
# cube_result(vnum,count,ok) → 结果提示
# cube_closed → 隐藏
# 选中一个配方、把背包物品放入材料槽 → [制作] → M2Client.cube_make(index)。
# 40250 服务端按材料槽匹配配方,`result_index` 只保留给 m2dev 后端;classic
# 会安全地忽略它并执行一次 `/cube make`。
extends Node
var client: Node
var proto: Node
var _root: Control
var _title: Label
var _status: Label
var _list: VBoxContainer
var _mat: VBoxContainer
var _make_btn: Button
var _sel := -1
var _cube_slots: Dictionary = {} # cube index:int -> inventory cell:int
var _craft_in_flight := false
var _cube_npc_vnum := 0
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("cube_opened"):
client.cube_opened.connect(_on_open)
if client.has_signal("cube_closed"):
client.cube_closed.connect(func() -> void: _root.visible = false)
if client.has_signal("cube_changed"):
client.cube_changed.connect(refresh)
if client.has_signal("cube_result"):
client.cube_result.connect(_on_result)
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_window, _cell): refresh())
func is_open() -> bool:
return _root != null and _root.visible
func close() -> void:
if _root:
_root.visible = false
_sel = -1
_cube_slots.clear()
_craft_in_flight = false
func _on_open(npc_vnum: int) -> void:
if npc_vnum <= 0:
return
var current: Dictionary = client.get_cube() if client.has_method("get_cube") else {}
if current.has("open") and not bool(current.get("open", false)):
return
var current_npc := int(current.get("npc_vnum", 0))
if current_npc > 0 and current_npc != npc_vnum:
return
_sel = -1
_cube_slots.clear()
_craft_in_flight = false
_cube_npc_vnum = npc_vnum
_root.visible = true
_title.text = "제작 (Cube) · NPC #%d" % npc_vnum
_status.text = ""
if client.has_method("cube_request_result_list"):
client.cube_request_result_list(npc_vnum)
refresh()
func _on_result(vnum: int, count: int, ok: bool) -> void:
_craft_in_flight = false
if ok:
_status.text = "제작 성공: %s ×%d" % [_name_of(vnum), count]
# 40250 在成功后消耗材料;等待 inventory/cube_changed 的最终状态前,
# 先清掉本地映射,避免旧背包格继续显示为可用材料。
_cube_slots.clear()
else:
_status.text = "제작 실패"
refresh()
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:
if client == null or _root == null or not _root.visible:
return
var cube: Dictionary = client.get_cube()
if not cube.get("open", true):
_root.visible = false
return
var recipes: Array = cube.get("recipes", [])
var results: Array = cube.get("results", [])
for c in _list.get_children():
c.queue_free()
var n: int = maxi(recipes.size(), results.size())
if n == 0:
var e := Label.new()
e.text = "(이 NPC의 제작 목록 없음)"
_list.add_child(e)
for i in n:
var vnum := 0
if i < recipes.size():
vnum = int((recipes[i] as Dictionary).get("result_vnum", 0))
if vnum == 0 and i < results.size():
vnum = int((results[i] as Dictionary).get("vnum", 0))
var b := Button.new()
b.toggle_mode = true
b.button_pressed = (i == _sel)
b.text = "%d. %s" % [i + 1, _name_of(vnum)]
var idx := i
b.pressed.connect(func() -> void: _select(idx))
_list.add_child(b)
_refresh_materials()
func _select(i: int) -> void:
_sel = i
for k in _list.get_child_count():
var b := _list.get_child(k)
if b is Button:
b.button_pressed = (k == i)
# 材料信息可能未拉过 → 请求
if client.has_method("cube_request_materials"):
client.cube_request_materials(i, 1)
_refresh_materials()
func _refresh_materials() -> void:
for c in _mat.get_children():
c.queue_free()
var cube: Dictionary = client.get_cube()
var recipes: Array = cube.get("recipes", [])
var recipe: Dictionary = _selected_recipe()
_make_btn.disabled = _sel < 0 or recipe.is_empty() or _craft_in_flight \
or not _recipe_can_craft(recipe)
if _sel < 0 or _sel >= recipes.size():
_mat.add_child(_lbl("재료 정보를 불러오는 중…", 12))
else:
var r: Dictionary = recipe
_mat.add_child(_lbl("필요 골드: %d" % int(r.get("gold", 0)), 12))
var groups: Array = r.get("material_groups", [])
if groups.is_empty():
_mat.add_child(_lbl("(재료 없음)", 12))
for g in groups:
var parts: Array = []
for s in (g as Array):
parts.append("%s ×%d" % [_name_of(int((s as Dictionary).get("vnum", 0))),
int((s as Dictionary).get("count", 1))])
_mat.add_child(_lbl("• " + " / ".join(parts), 12))
if not _recipe_can_craft(r):
_mat.add_child(_lbl("(材料不足或未放入材料槽)", 11))
elif client.has_method("get_points") and int(r.get("gold", 0)) > int(client.get_points().get("gold", 0)):
_make_btn.disabled = true
_mat.add_child(_lbl("(金币不足)", 11))
_mat.add_child(HSeparator.new())
_mat.add_child(_lbl("재료 슬롯 (클릭하면 추가/삭제)", 12))
for slot in _cube_slots.keys():
var cell := int(_cube_slots[slot])
var row := HBoxContainer.new()
var label := _lbl("cube[%d] ← bag[%d]" % [int(slot), cell], 11)
row.add_child(label)
var remove := Button.new()
remove.text = "삭제"
remove.pressed.connect(func() -> void:
if client.has_method("cube_delete_item") and client.cube_delete_item(int(slot)):
_cube_slots.erase(slot)
refresh())
row.add_child(remove)
_mat.add_child(row)
_mat.add_child(_lbl("背包物品:", 12))
var used_cells := {}
for cell in _cube_slots.values():
used_cells[int(cell)] = true
var inventory: Array = client.get_inventory() if client.has_method("get_inventory") else []
var added := 0
for item in inventory:
var inv_cell := int(item.get("cell", -1))
var item_vnum := int(item.get("vnum", 0))
if used_cells.has(inv_cell):
continue
if not _recipe_accepts_vnum(recipe, item_vnum):
continue
var free_slot := _first_free_cube_slot()
if free_slot < 0:
break
var add := Button.new()
add.text = "+ bag[%d] %s ×%d" % [inv_cell, _name_of(item_vnum),
int(item.get("count", 1))]
add.alignment = HORIZONTAL_ALIGNMENT_LEFT
add.pressed.connect(func() -> void:
var target := _first_free_cube_slot()
if target >= 0 and client.has_method("cube_add_item") and client.cube_add_item(target, inv_cell):
_cube_slots[target] = inv_cell
refresh())
_mat.add_child(add)
added += 1
if added == 0:
_mat.add_child(_lbl("(可用背包物品 없음)", 11))
func _selected_recipe() -> Dictionary:
if client == null or not client.has_method("get_cube"):
return {}
var recipes: Array = client.get_cube().get("recipes", [])
if _sel < 0 or _sel >= recipes.size():
return {}
return recipes[_sel] as Dictionary
func _recipe_accepts_vnum(recipe: Dictionary, vnum: int) -> bool:
if vnum <= 0:
return false
for group in recipe.get("material_groups", []):
for option in group as Array:
if int((option as Dictionary).get("vnum", 0)) == vnum:
return true
return false
func _recipe_can_craft(recipe: Dictionary) -> bool:
if recipe.is_empty():
return false
var provided := {}
if client.has_method("get_inventory"):
var inventory: Array = client.get_inventory()
for cell in _cube_slots.values():
for item in inventory:
if int(item.get("cell", -1)) == int(cell):
var vnum := int(item.get("vnum", 0))
provided[vnum] = int(provided.get(vnum, 0)) + maxi(1, int(item.get("count", 1)))
break
for group in recipe.get("material_groups", []):
var matched := false
for option in group as Array:
var material: Dictionary = option as Dictionary
var vnum := int(material.get("vnum", 0))
var need := maxi(1, int(material.get("count", 1)))
if int(provided.get(vnum, 0)) >= need:
provided[vnum] = int(provided[vnum]) - need
matched = true
break
if not matched:
return false
return true
func _first_free_cube_slot() -> int:
for i in 24:
if not _cube_slots.has(i):
return i
return -1
func _lbl(t: String, sz: int) -> Label:
var l := Label.new()
l.text = t
l.add_theme_font_size_override("font_size", sz)
return l
func _build(parent: Node) -> void:
_root = Panel.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-200, -190)
_root.custom_minimum_size = Vector2(400, 380)
_root.size = Vector2(400, 380)
_root.visible = false
parent.add_child(_root)
var box := VBoxContainer.new()
box.position = Vector2(16, 14)
box.custom_minimum_size = Vector2(368, 0)
box.add_theme_constant_override("separation", 6)
_root.add_child(box)
_title = _lbl("제작 (Cube)", 16)
box.add_child(_title)
var split := HBoxContainer.new()
split.add_theme_constant_override("separation", 10)
box.add_child(split)
var lsc := ScrollContainer.new()
lsc.custom_minimum_size = Vector2(180, 250)
split.add_child(lsc)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 3)
lsc.add_child(_list)
_mat = VBoxContainer.new()
_mat.custom_minimum_size = Vector2(178, 250)
_mat.add_theme_constant_override("separation", 3)
split.add_child(_mat)
_status = _lbl("", 12)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
box.add_child(_status)
var brow := HBoxContainer.new()
brow.add_theme_constant_override("separation", 12)
box.add_child(brow)
_make_btn = Button.new()
_make_btn.text = "제작"
_make_btn.disabled = true
_make_btn.pressed.connect(func() -> void:
var recipe := _selected_recipe()
if _sel < 0 or recipe.is_empty():
_status.text = "请先选择配方"
return
if not _recipe_can_craft(recipe):
_status.text = "材料不足或未放入材料槽"
refresh()
return
if client.has_method("cube_make") and client.cube_make(_sel):
_craft_in_flight = true
_status.text = "制作中…"
_make_btn.disabled = true)
brow.add_child(_make_btn)
var close := Button.new()
close.text = "닫기"
close.pressed.connect(func() -> void:
if client.has_method("cube_close"):
client.cube_close()
_root.visible = false)
brow.add_child(close)