OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / asset_browser.gd

291 lines · gd ref:
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)