extends VBoxContainer
## THE ASSET BROWSER (#120 L4 §15.5) — a strip in the BuildDock's bottom area, beside the
## textures: search, which library (Both / Room / Personal), where Save as asset writes,
## and a run of tiles, one per asset (the room's and yours, by name).
##
## click a tile it becomes the CURRENT asset (Shift+A → Asset places it at the
## 3D cursor)
## drag a tile into the 3D view: a ghost of its bounds stands where it would land
## (up = the surface's nearest axis); the drop places it there
## "Update N" on a tile with placements on an older version: moves them all to
## the room's (one gesture) — your own already follow a re-save
##
## THE DROP TARGET. The View3D Control ignores the mouse (clicks belong to the game). While
## a tile's drag is in flight it is ARMED — mouse filter PASS + drag forwarding to this
## panel — and disarmed when the drag ends, so it never eats a click otherwise.
## Like the texture strip, this panel never writes the journal itself: the map and the
## library do (one writer of each path).
const TILE_PX := 92
var map: Node = null
var _search: LineEdit
var _source: OptionButton
var _room_box: CheckBox
var _personal_box: CheckBox
var _current: Label
var _row: HBoxContainer
var _empty: Label
var _search_text := ""
var _source_id := "both" # both | room | personal
var _tiles: Dictionary = {} # name -> Button
var _armed := false
var _dirty := true
func setup(map_node: Node) -> void:
map = map_node
_build_ui()
refresh()
var gs := get_node_or_null("/root/GameState")
if gs == null:
gs = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("GameState")
if gs != null and gs.has_signal("state_changed"):
gs.state_changed.connect(_on_state_changed)
var p: Node = map.get("_persist") if map != null else null
if p != null:
p.folded.connect(func(_st: Dictionary) -> void: _dirty = true)
var lib: Node = map.asset_library() if map != null and map.has_method("asset_library") else null
if lib != null and lib.has_signal("thumbnail_ready"):
lib.thumbnail_ready.connect(func(_n: String, _t: String) -> void: _dirty = true)
var co := get_node_or_null("/root/ContentOverlay")
if co != null and co.has_signal("content_ready"):
co.content_ready.connect(_on_content_ready)
func _on_state_changed(key: String, _value: Variant, _channel: int) -> void:
if key.begins_with("world:assets:asset:"):
_dirty = true
func _build_ui() -> void:
var top := HFlowContainer.new()
top.add_theme_constant_override("h_separation", 8)
add_child(top)
_search = LineEdit.new()
_search.placeholder_text = "search assets…"
_search.custom_minimum_size = Vector2(160, 0)
_search.text_changed.connect(func(t: String) -> void: set_filter(t, _source_id))
top.add_child(_search)
_source = OptionButton.new()
for pair: Array in [["Room + mine", "both"], ["Room", "room"], ["Mine", "personal"]]:
_source.add_item(String(pair[0]))
_source.set_item_metadata(_source.item_count - 1, String(pair[1]))
_source.item_selected.connect(func(i: int) -> void: set_filter(_search_text, String(_source.get_item_metadata(i))))
top.add_child(_source)
var save_lbl := Label.new()
save_lbl.text = "Save to:"
top.add_child(save_lbl)
_room_box = CheckBox.new()
_room_box.text = "Room"
_room_box.button_pressed = true
_room_box.tooltip_text = "Save collection as asset… publishes to this room's library"
_room_box.toggled.connect(func(on: bool) -> void: map.set("asset_save_room", on))
top.add_child(_room_box)
_personal_box = CheckBox.new()
_personal_box.text = "Mine"
_personal_box.button_pressed = true
_personal_box.tooltip_text = "Save collection as asset… also keeps it in your own library (any room)"
_personal_box.toggled.connect(func(on: bool) -> void: map.set("asset_save_personal", on))
top.add_child(_personal_box)
_current = Label.new()
_current.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_current.clip_text = true
top.add_child(_current)
var hint := Label.new()
hint.text = "click = current · drag into the view to place · Object → Save collection as asset…"
hint.modulate = Color(1, 1, 1, 0.6)
top.add_child(hint)
var scroll := ScrollContainer.new()
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.custom_minimum_size = Vector2(0, TILE_PX + 30)
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(scroll)
_row = HBoxContainer.new()
_row.add_theme_constant_override("separation", 4)
scroll.add_child(_row)
_empty = Label.new()
_empty.text = "No assets yet — select a collection, then Object → Save collection as asset…"
_empty.modulate = Color(1, 1, 1, 0.6)
add_child(_empty)
func set_filter(search: String, source: String) -> void:
_search_text = search
_source_id = source
if _search != null and _search.text != search:
_search.text = search
refresh()
## The library rows this strip shows, after the filter: [{name, oid, room, personal, older}].
func shown_rows() -> Array:
var lib: Node = map.asset_library() if map != null and map.has_method("asset_library") else null
if lib == null:
return []
var out: Array = []
for r: Dictionary in (lib.rows() as Array):
if _source_id == "room" and not bool(r["room"]):
continue
if _source_id == "personal" and not bool(r["personal"]):
continue
if _search_text != "" and not String(r["name"]).to_lower().contains(_search_text.to_lower()):
continue
out.append(r)
return out
func tile(name: String) -> Button:
return _tiles.get(name, null)
func refresh() -> void:
_dirty = false
if _row == null:
return
for c in _row.get_children():
c.queue_free()
_tiles.clear()
var cur := String(map.get("current_asset")) if map != null else ""
var rows := shown_rows()
for r: Dictionary in rows:
var n := String(r["name"])
var col := VBoxContainer.new()
var b := Button.new()
b.custom_minimum_size = Vector2(TILE_PX, TILE_PX)
b.toggle_mode = true
b.button_pressed = n == cur
b.text = n
b.clip_text = true
b.focus_mode = Control.FOCUS_NONE
b.tooltip_text = "%s\n%s%s" % [n, "room" if bool(r["room"]) else "", " · mine" if bool(r["personal"]) else ""]
var tex := _thumb(String(r.get("thumb", "")))
if tex != null:
b.icon = tex
b.expand_icon = true
b.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP
b.pressed.connect(pick.bind(n))
b.set_drag_forwarding(_tile_drag.bind(n), Callable(), Callable())
col.add_child(b)
var older := int(r.get("older", 0))
if older > 0:
var u := Button.new()
u.text = "Update %d" % older
u.tooltip_text = "%d placement(s) on an older version: move them to the room's" % older
u.focus_mode = Control.FOCUS_NONE
u.pressed.connect(update.bind(n))
col.add_child(u)
_row.add_child(col)
_tiles[n] = b
_empty.visible = rows.is_empty()
_current.text = "current: %s" % (cur if cur != "" else "—")
var _wanted_thumbs := {}
func _on_content_ready(content_hash: String, _path: String) -> void:
if _wanted_thumbs.has(content_hash):
_dirty = true
## A tile's thumbnail: the PNG named by `thumb` (§15.6) if it is here, else asked for over
## the mesh (the tile redraws when it lands). null without one.
func _thumb(thumb: String) -> Texture2D:
if thumb.length() != 64:
return null
var path := "user://content/%s.png" % thumb
if not FileAccess.file_exists(path):
var co := get_node_or_null("/root/ContentOverlay")
if co != null and not _wanted_thumbs.has(thumb):
_wanted_thumbs[thumb] = true
co.request(thumb, thumb + ".png")
return null
var img := Image.new()
if img.load_png_from_buffer(FileAccess.get_file_as_bytes(path)) != OK:
return null
return ImageTexture.create_from_image(img)
func _process(_delta: float) -> void:
if _dirty and is_visible_in_tree():
refresh()
## Make `name` the current asset — what a tile click does.
func pick(name: String) -> void:
if map != null:
map.set("current_asset", name)
for n: String in _tiles:
var b: Button = _tiles[n]
if is_instance_valid(b):
b.button_pressed = n == name
if _current != null:
_current.text = "current: %s" % name
func update(name: String) -> int:
var n: int = map.asset_library().update_placements(name)
_dirty = true
return n
# ---- dragging a tile into the 3D view ------------------------------------------
func _tile_drag(_at: Vector2, name: String) -> Variant:
var l := Label.new()
l.text = "▣ " + name
set_drag_preview(l)
return {"hyper_asset": name}
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_BEGIN:
var d: Variant = get_viewport().gui_get_drag_data() if get_viewport() != null else null
if d is Dictionary and (d as Dictionary).has("hyper_asset"):
arm(true)
elif what == NOTIFICATION_DRAG_END:
arm(false)
## Arm / disarm the 3D view as the drop target (see the header). Public for the gate.
func arm(on: bool) -> void:
var dock := get_node_or_null("/root/BuildDock")
var v: Control = dock.view3d() if dock != null and dock.has_method("view3d") else null
if v == null:
return
_armed = on
if on:
v.mouse_filter = Control.MOUSE_FILTER_PASS
v.set_drag_forwarding(Callable(), _view_can_drop, _view_drop)
else:
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.set_drag_forwarding(Callable(), Callable(), Callable())
if map != null and map.has_method("asset_ghost_hide"):
map.asset_ghost_hide()
func armed() -> bool:
return _armed
func _view_point(at: Vector2) -> Vector2:
var dock := get_node_or_null("/root/BuildDock")
var v: Control = dock.view3d() if dock != null else null
return (v.get_global_rect().position if v != null else Vector2.ZERO) + at
func _view_can_drop(at: Vector2, data: Variant) -> bool:
if not (data is Dictionary) or not (data as Dictionary).has("hyper_asset"):
return false
return bool(map.asset_ghost(String(data["hyper_asset"]), _view_point(at)))
func _view_drop(at: Vector2, data: Variant) -> void:
if data is Dictionary and (data as Dictionary).has("hyper_asset"):
var n := String(data["hyper_asset"])
map.place_asset_at(n, _view_point(at))
pick(n)
arm(false)