| … | … | @@ -377,6 +377,13 @@ func is_modal() -> bool: |
| 377 | 377 | |
| 378 | 378 | ## R, the free rectangle the 3D shows through (screen pixels). The whole window when the |
| 379 | 379 | ## areas are hidden (play mode). |
| 380 | ## The 3D view's Control (MOUSE_FILTER_IGNORE: clicks pass to the game). A panel that | |
| 381 | ## drags something INTO the view (the asset browser, #120 L4) arms it as a drop target for | |
| 382 | ## the drag's duration and disarms it after. | |
| 383 | func view3d() -> Control: | |
| 384 | return _view3d | |
| 385 | ||
| 386 | ||
| 380 | 387 | func view_rect() -> Rect2: |
| 381 | 388 | if visible and _view3d != null and _view3d.is_visible_in_tree(): |
| 382 | 389 | return _view3d.get_global_rect() |
| … | … | @@ -385,6 +385,7 @@ const DEFAULTS: Dictionary = { |
| 385 | 385 | "builder.collection_new": {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"}, |
| 386 | 386 | # #120 L4: menu-only (user 2026-10-19), Object menu. |
| 387 | 387 | "builder.save_asset": {"label": "Save collection as asset…"}, |
| 388 | "builder.place_asset": {"label": "Asset: place the current one at the 3D cursor"}, | |
| 388 | 389 | "builder.collection_move": {"key": KEY_M, "label": "Move to collection…"}, |
| 389 | 390 | "builder.collection_link": {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"}, |
| 390 | 391 | "builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"}, |
| … | … | @@ -1346,9 +1346,20 @@ Save as asset is menu-only for now). |
| 1346 | 1346 | synthetic piece" (it caught scale writing brush.scale on every piece while leaving the |
| 1347 | 1347 | frame alone, and the same hole in mixed move / rotate). 6 mutants killed. **DONE |
| 1348 | 1348 | 2026-10-19.** |
| 1349 | 5. **Browser**: the bottom panel, tiles, click + Shift+A, drag into the view (the drop | |
| 1350 | path called directly in the gate, as the outliner's drop_on is). | |
| 1351 | `gate_asset_browser`. | |
| 1349 | 5. **Browser** (`scenes/game/asset_browser.gd`, BuildDock bottom area "Assets"): search, | |
| 1350 | source (Room + mine / Room / Mine), "Save to" Room / Mine switches (what Save as asset | |
| 1351 | writes), tiles (click = current; drag into the view; "Update N" on a tile with | |
| 1352 | placements on an older version). The drop: BuildDock.view3d() is ARMED (mouse filter | |
| 1353 | PASS + drag forwarding) only while a tile's drag is in flight; a bounds ghost follows | |
| 1354 | the pointer; `drop_frame(position, normal)` — up = the normal snapped to its axis, x | |
| 1355 | level on a wall (so a floor-modelled asset's local z points down there: an arbitrary | |
| 1356 | but fixed choice; R adjusts). Add menu (top bar + Shift+A): "Asset: place the current | |
| 1357 | one at the 3D cursor" (menu-only `builder.place_asset`). `gate_asset_browser` (22); | |
| 1358 | mutants: 4 killed, 1 equivalent in behaviour (removing the drop's data check makes it | |
| 1359 | crash, and a typed bool function returns false — only a SCRIPT ERROR shows; the check | |
| 1360 | now requires a real `false`, not `not <result>`, which passed on null). The ghost and | |
| 1361 | the live drag itself are not exercised headless (no reliable camera ray). **DONE | |
| 1362 | 2026-10-19.** | |
| 1352 | 1363 | 6. **Thumbnails** (SubViewport render at save). |
| 1353 | 1364 | 7. **Measure**: fold cost of N placements of a 50-shape asset (the _probe_fold_split |
| 1354 | 1365 | method) — the follow-up perf issue is #128. |
| … | … | @@ -0,0 +1,253 @@ |
| 1 | extends VBoxContainer | |
| 2 | ## THE ASSET BROWSER (#120 L4 §15.5) — a strip in the BuildDock's bottom area, beside the | |
| 3 | ## textures: search, which library (Both / Room / Personal), where Save as asset writes, | |
| 4 | ## and a run of tiles, one per asset (the room's and yours, by name). | |
| 5 | ## | |
| 6 | ## click a tile it becomes the CURRENT asset (Shift+A → Asset places it at the | |
| 7 | ## 3D cursor) | |
| 8 | ## drag a tile into the 3D view: a ghost of its bounds stands where it would land | |
| 9 | ## (up = the surface's nearest axis); the drop places it there | |
| 10 | ## "Update N" on a tile with placements on an older version: moves them all to | |
| 11 | ## the room's (one gesture) — your own already follow a re-save | |
| 12 | ## | |
| 13 | ## THE DROP TARGET. The View3D Control ignores the mouse (clicks belong to the game). While | |
| 14 | ## a tile's drag is in flight it is ARMED — mouse filter PASS + drag forwarding to this | |
| 15 | ## panel — and disarmed when the drag ends, so it never eats a click otherwise. | |
| 16 | ## Like the texture strip, this panel never writes the journal itself: the map and the | |
| 17 | ## library do (one writer of each path). | |
| 18 | ||
| 19 | const TILE_PX := 92 | |
| 20 | ||
| 21 | var map: Node = null | |
| 22 | ||
| 23 | var _search: LineEdit | |
| 24 | var _source: OptionButton | |
| 25 | var _room_box: CheckBox | |
| 26 | var _personal_box: CheckBox | |
| 27 | var _current: Label | |
| 28 | var _row: HBoxContainer | |
| 29 | var _empty: Label | |
| 30 | var _search_text := "" | |
| 31 | var _source_id := "both" # both | room | personal | |
| 32 | var _tiles: Dictionary = {} # name -> Button | |
| 33 | var _armed := false | |
| 34 | var _dirty := true | |
| 35 | ||
| 36 | ||
| 37 | func setup(map_node: Node) -> void: | |
| 38 | map = map_node | |
| 39 | _build_ui() | |
| 40 | refresh() | |
| 41 | var gs := get_node_or_null("/root/GameState") | |
| 42 | if gs == null: | |
| 43 | gs = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("GameState") | |
| 44 | if gs != null and gs.has_signal("state_changed"): | |
| 45 | gs.state_changed.connect(_on_state_changed) | |
| 46 | var p: Node = map.get("_persist") if map != null else null | |
| 47 | if p != null: | |
| 48 | p.folded.connect(func(_st: Dictionary) -> void: _dirty = true) | |
| 49 | ||
| 50 | ||
| 51 | func _on_state_changed(key: String, _value: Variant, _channel: int) -> void: | |
| 52 | if key.begins_with("world:assets:asset:"): | |
| 53 | _dirty = true | |
| 54 | ||
| 55 | ||
| 56 | func _build_ui() -> void: | |
| 57 | var top := HFlowContainer.new() | |
| 58 | top.add_theme_constant_override("h_separation", 8) | |
| 59 | add_child(top) | |
| 60 | _search = LineEdit.new() | |
| 61 | _search.placeholder_text = "search assets…" | |
| 62 | _search.custom_minimum_size = Vector2(160, 0) | |
| 63 | _search.text_changed.connect(func(t: String) -> void: set_filter(t, _source_id)) | |
| 64 | top.add_child(_search) | |
| 65 | _source = OptionButton.new() | |
| 66 | for pair: Array in [["Room + mine", "both"], ["Room", "room"], ["Mine", "personal"]]: | |
| 67 | _source.add_item(String(pair[0])) | |
| 68 | _source.set_item_metadata(_source.item_count - 1, String(pair[1])) | |
| 69 | _source.item_selected.connect(func(i: int) -> void: set_filter(_search_text, String(_source.get_item_metadata(i)))) | |
| 70 | top.add_child(_source) | |
| 71 | var save_lbl := Label.new() | |
| 72 | save_lbl.text = "Save to:" | |
| 73 | top.add_child(save_lbl) | |
| 74 | _room_box = CheckBox.new() | |
| 75 | _room_box.text = "Room" | |
| 76 | _room_box.button_pressed = true | |
| 77 | _room_box.tooltip_text = "Save collection as asset… publishes to this room's library" | |
| 78 | _room_box.toggled.connect(func(on: bool) -> void: map.set("asset_save_room", on)) | |
| 79 | top.add_child(_room_box) | |
| 80 | _personal_box = CheckBox.new() | |
| 81 | _personal_box.text = "Mine" | |
| 82 | _personal_box.button_pressed = true | |
| 83 | _personal_box.tooltip_text = "Save collection as asset… also keeps it in your own library (any room)" | |
| 84 | _personal_box.toggled.connect(func(on: bool) -> void: map.set("asset_save_personal", on)) | |
| 85 | top.add_child(_personal_box) | |
| 86 | _current = Label.new() | |
| 87 | _current.size_flags_horizontal = Control.SIZE_EXPAND_FILL | |
| 88 | _current.clip_text = true | |
| 89 | top.add_child(_current) | |
| 90 | var hint := Label.new() | |
| 91 | hint.text = "click = current · drag into the view to place · Object → Save collection as asset…" | |
| 92 | hint.modulate = Color(1, 1, 1, 0.6) | |
| 93 | top.add_child(hint) | |
| 94 | var scroll := ScrollContainer.new() | |
| 95 | scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED | |
| 96 | scroll.custom_minimum_size = Vector2(0, TILE_PX + 30) | |
| 97 | scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL | |
| 98 | add_child(scroll) | |
| 99 | _row = HBoxContainer.new() | |
| 100 | _row.add_theme_constant_override("separation", 4) | |
| 101 | scroll.add_child(_row) | |
| 102 | _empty = Label.new() | |
| 103 | _empty.text = "No assets yet — select a collection, then Object → Save collection as asset…" | |
| 104 | _empty.modulate = Color(1, 1, 1, 0.6) | |
| 105 | add_child(_empty) | |
| 106 | ||
| 107 | ||
| 108 | func set_filter(search: String, source: String) -> void: | |
| 109 | _search_text = search | |
| 110 | _source_id = source | |
| 111 | if _search != null and _search.text != search: | |
| 112 | _search.text = search | |
| 113 | refresh() | |
| 114 | ||
| 115 | ||
| 116 | ## The library rows this strip shows, after the filter: [{name, oid, room, personal, older}]. | |
| 117 | func shown_rows() -> Array: | |
| 118 | var lib: Node = map.asset_library() if map != null and map.has_method("asset_library") else null | |
| 119 | if lib == null: | |
| 120 | return [] | |
| 121 | var out: Array = [] | |
| 122 | for r: Dictionary in (lib.rows() as Array): | |
| 123 | if _source_id == "room" and not bool(r["room"]): | |
| 124 | continue | |
| 125 | if _source_id == "personal" and not bool(r["personal"]): | |
| 126 | continue | |
| 127 | if _search_text != "" and not String(r["name"]).to_lower().contains(_search_text.to_lower()): | |
| 128 | continue | |
| 129 | out.append(r) | |
| 130 | return out | |
| 131 | ||
| 132 | ||
| 133 | func tile(name: String) -> Button: | |
| 134 | return _tiles.get(name, null) | |
| 135 | ||
| 136 | ||
| 137 | func refresh() -> void: | |
| 138 | _dirty = false | |
| 139 | if _row == null: | |
| 140 | return | |
| 141 | for c in _row.get_children(): | |
| 142 | c.queue_free() | |
| 143 | _tiles.clear() | |
| 144 | var cur := String(map.get("current_asset")) if map != null else "" | |
| 145 | var rows := shown_rows() | |
| 146 | for r: Dictionary in rows: | |
| 147 | var n := String(r["name"]) | |
| 148 | var col := VBoxContainer.new() | |
| 149 | var b := Button.new() | |
| 150 | b.custom_minimum_size = Vector2(TILE_PX, TILE_PX) | |
| 151 | b.toggle_mode = true | |
| 152 | b.button_pressed = n == cur | |
| 153 | b.text = n | |
| 154 | b.clip_text = true | |
| 155 | b.focus_mode = Control.FOCUS_NONE | |
| 156 | b.tooltip_text = "%s\n%s%s" % [n, "room" if bool(r["room"]) else "", " · mine" if bool(r["personal"]) else ""] | |
| 157 | b.pressed.connect(pick.bind(n)) | |
| 158 | b.set_drag_forwarding(_tile_drag.bind(n), Callable(), Callable()) | |
| 159 | col.add_child(b) | |
| 160 | var older := int(r.get("older", 0)) | |
| 161 | if older > 0: | |
| 162 | var u := Button.new() | |
| 163 | u.text = "Update %d" % older | |
| 164 | u.tooltip_text = "%d placement(s) on an older version: move them to the room's" % older | |
| 165 | u.focus_mode = Control.FOCUS_NONE | |
| 166 | u.pressed.connect(update.bind(n)) | |
| 167 | col.add_child(u) | |
| 168 | _row.add_child(col) | |
| 169 | _tiles[n] = b | |
| 170 | _empty.visible = rows.is_empty() | |
| 171 | _current.text = "current: %s" % (cur if cur != "" else "—") | |
| 172 | ||
| 173 | ||
| 174 | func _process(_delta: float) -> void: | |
| 175 | if _dirty and is_visible_in_tree(): | |
| 176 | refresh() | |
| 177 | ||
| 178 | ||
| 179 | ## Make `name` the current asset — what a tile click does. | |
| 180 | func pick(name: String) -> void: | |
| 181 | if map != null: | |
| 182 | map.set("current_asset", name) | |
| 183 | for n: String in _tiles: | |
| 184 | var b: Button = _tiles[n] | |
| 185 | if is_instance_valid(b): | |
| 186 | b.button_pressed = n == name | |
| 187 | if _current != null: | |
| 188 | _current.text = "current: %s" % name | |
| 189 | ||
| 190 | ||
| 191 | func update(name: String) -> int: | |
| 192 | var n: int = map.asset_library().update_placements(name) | |
| 193 | _dirty = true | |
| 194 | return n | |
| 195 | ||
| 196 | ||
| 197 | # ---- dragging a tile into the 3D view ------------------------------------------ | |
| 198 | ||
| 199 | func _tile_drag(_at: Vector2, name: String) -> Variant: | |
| 200 | var l := Label.new() | |
| 201 | l.text = "▣ " + name | |
| 202 | set_drag_preview(l) | |
| 203 | return {"hyper_asset": name} | |
| 204 | ||
| 205 | ||
| 206 | func _notification(what: int) -> void: | |
| 207 | if what == NOTIFICATION_DRAG_BEGIN: | |
| 208 | var d: Variant = get_viewport().gui_get_drag_data() if get_viewport() != null else null | |
| 209 | if d is Dictionary and (d as Dictionary).has("hyper_asset"): | |
| 210 | arm(true) | |
| 211 | elif what == NOTIFICATION_DRAG_END: | |
| 212 | arm(false) | |
| 213 | ||
| 214 | ||
| 215 | ## Arm / disarm the 3D view as the drop target (see the header). Public for the gate. | |
| 216 | func arm(on: bool) -> void: | |
| 217 | var dock := get_node_or_null("/root/BuildDock") | |
| 218 | var v: Control = dock.view3d() if dock != null and dock.has_method("view3d") else null | |
| 219 | if v == null: | |
| 220 | return | |
| 221 | _armed = on | |
| 222 | if on: | |
| 223 | v.mouse_filter = Control.MOUSE_FILTER_PASS | |
| 224 | v.set_drag_forwarding(Callable(), _view_can_drop, _view_drop) | |
| 225 | else: | |
| 226 | v.mouse_filter = Control.MOUSE_FILTER_IGNORE | |
| 227 | v.set_drag_forwarding(Callable(), Callable(), Callable()) | |
| 228 | if map != null and map.has_method("asset_ghost_hide"): | |
| 229 | map.asset_ghost_hide() | |
| 230 | ||
| 231 | ||
| 232 | func armed() -> bool: | |
| 233 | return _armed | |
| 234 | ||
| 235 | ||
| 236 | func _view_point(at: Vector2) -> Vector2: | |
| 237 | var dock := get_node_or_null("/root/BuildDock") | |
| 238 | var v: Control = dock.view3d() if dock != null else null | |
| 239 | return (v.get_global_rect().position if v != null else Vector2.ZERO) + at | |
| 240 | ||
| 241 | ||
| 242 | func _view_can_drop(at: Vector2, data: Variant) -> bool: | |
| 243 | if not (data is Dictionary) or not (data as Dictionary).has("hyper_asset"): | |
| 244 | return false | |
| 245 | return bool(map.asset_ghost(String(data["hyper_asset"]), _view_point(at))) | |
| 246 | ||
| 247 | ||
| 248 | func _view_drop(at: Vector2, data: Variant) -> void: | |
| 249 | if data is Dictionary and (data as Dictionary).has("hyper_asset"): | |
| 250 | var n := String(data["hyper_asset"]) | |
| 251 | map.place_asset_at(n, _view_point(at)) | |
| 252 | pick(n) | |
| 253 | arm(false) |
| … | … | @@ -26,7 +26,7 @@ const MENUS: Array = [ |
| 26 | 26 | "build_mode.sel_brush", "builder.element_toggle", "builder.select_mode_menu"]], |
| 27 | 27 | ["Add", ["builder.add_menu", "-", "builder.add_box", "builder.add_stairs", "builder.add_spiral_stairs", |
| 28 | 28 | "builder.add_tunnel", "builder.add_cylinder", "builder.add_cone", "builder.add_sphere", "-", |
| 29 | "builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "builder.cable_bundle", "builder.carve"]], | |
| 29 | "builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "builder.cable_bundle", "builder.carve", "builder.place_asset"]], | |
| 30 | 30 | # Blender 2.79's OBJECT menu (was "Brush"): transforms, CSG, pivot, and since #120 F1 |
| 31 | 31 | # parenting, join / separate and collections. One menu, not a new one: a seventh title |
| 32 | 32 | # pushed the top band past a 1280-px window (gate_editor_e1). |
| … | … | @@ -155,6 +155,12 @@ func _exit_tree() -> void: |
| 155 | 155 | dock.unregister("face") |
| 156 | 156 | _tex_strip = null |
| 157 | 157 | _face_panel = null |
| 158 | # The asset browser drives THIS map's libraries (#120 L4). | |
| 159 | if _asset_browser != null: | |
| 160 | var dock3 := get_node_or_null("/root/BuildDock") | |
| 161 | if dock3 != null: | |
| 162 | dock3.unregister("assets") | |
| 163 | _asset_browser = null | |
| 158 | 164 | # The bars drive THIS map; the next map may not be a brush editor at all. |
| 159 | 165 | var dock2 := get_node_or_null("/root/BuildDock") |
| 160 | 166 | if dock2 != null: |
| … | … | @@ -393,6 +399,7 @@ func _start_empty_level() -> void: |
| 393 | 399 | print("[MapDCLevel] dc_terrain build has no texture API — plain material") |
| 394 | 400 | _refresh_brush_view() |
| 395 | 401 | _register_outliner() |
| 402 | _register_asset_browser() | |
| 396 | 403 | var st: Dictionary = _persist.refold() if _persist != null else {} |
| 397 | 404 | print("[MapDCLevel] empty level (level_import off) — %d brush record(s) replayed" |
| 398 | 405 | % int(st.get("applied", 0))) |
| … | … | @@ -1230,7 +1237,7 @@ func save_asset_prompt() -> void: |
| 1230 | 1237 | return |
| 1231 | 1238 | var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"]) |
| 1232 | 1239 | var make := func(n: String) -> void: |
| 1233 | var r: Dictionary = asset_library().save_collection(col, n) | |
| 1240 | var r: Dictionary = asset_library().save_collection(col, n, asset_save_room, asset_save_personal) | |
| 1234 | 1241 | for line: String in (r.get("report", []) as Array): |
| 1235 | 1242 | print("[MapDCLevel] save as asset: %s" % line) |
| 1236 | 1243 | if r.has("error"): |
| … | … | @@ -1245,6 +1252,116 @@ func save_asset_prompt() -> void: |
| 1245 | 1252 | get_viewport().set_input_as_handled() |
| 1246 | 1253 | |
| 1247 | 1254 | |
| 1255 | ## ---- the ASSET BROWSER's side (#120 L4 §15.5) ---- | |
| 1256 | ||
| 1257 | ## The asset Shift+A → Asset places (a browser click picks it). Per player, a tool setting. | |
| 1258 | var current_asset := "" | |
| 1259 | ## Where Save as asset writes: the browser's "Save to" switches. Both by default (§15.4). | |
| 1260 | var asset_save_room := true | |
| 1261 | var asset_save_personal := true | |
| 1262 | var _asset_browser: Node = null | |
| 1263 | var _asset_ghost: MeshInstance3D = null | |
| 1264 | ||
| 1265 | ||
| 1266 | ## A placement frame for a drop on a surface (§15.5, user 2026-10-19): standing ON the hit, | |
| 1267 | ## up = the hit normal snapped to the nearest world axis (floor stands, wall sticks out, | |
| 1268 | ## ceiling hangs; a slope takes its nearest axis). Right-handed: x then z = x × up. | |
| 1269 | static func drop_frame(position: Vector3, normal: Vector3) -> Transform3D: | |
| 1270 | var a := normal.abs() | |
| 1271 | var up := Vector3.UP | |
| 1272 | if a.x >= a.y and a.x >= a.z: | |
| 1273 | up = Vector3(signf(normal.x), 0, 0) | |
| 1274 | elif a.z >= a.y: | |
| 1275 | up = Vector3(0, 0, signf(normal.z)) | |
| 1276 | else: | |
| 1277 | up = Vector3(0, signf(normal.y) if normal.y != 0.0 else 1.0, 0) | |
| 1278 | # x: world +X on a floor or ceiling; on a wall, world up × the wall normal (level). | |
| 1279 | var x := Vector3.RIGHT if absf(up.y) > 0.5 else Vector3.UP.cross(up) | |
| 1280 | return Transform3D(Basis(x, up, x.cross(up)), position) | |
| 1281 | ||
| 1282 | ||
| 1283 | ## Place asset `name` on the surface `hit` ({position, normal}) — the drop. Selected after, | |
| 1284 | ## so G / R adjust it. Returns the placement's dot, or "". | |
| 1285 | func place_asset_hit(name: String, hit: Dictionary) -> String: | |
| 1286 | if hit.is_empty() or not hit.has("position"): | |
| 1287 | return "" | |
| 1288 | var d: String = asset_library().place(name, drop_frame(hit["position"], hit.get("normal", Vector3.UP))) | |
| 1289 | if d != "": | |
| 1290 | select_asset(d) | |
| 1291 | print("[MapDCLevel] placed asset \"%s\" %s" % [name, d]) | |
| 1292 | return d | |
| 1293 | ||
| 1294 | ||
| 1295 | ## The drop at screen point `at` (viewport coordinates): what is under the pointer. | |
| 1296 | func place_asset_at(name: String, at: Vector2) -> String: | |
| 1297 | asset_ghost_hide() | |
| 1298 | return place_asset_hit(name, _crosshair_hit(at)) | |
| 1299 | ||
| 1300 | ||
| 1301 | ## Shift+A → Asset (menu-only): the current asset at the 3D cursor, standing up. | |
| 1302 | func place_current_asset() -> String: | |
| 1303 | if current_asset == "": | |
| 1304 | print("[MapDCLevel] place asset: pick one in the Assets strip first") | |
| 1305 | return "" | |
| 1306 | var at: Vector3 = (_cursor3d as Node3D).global_position if _cursor3d is Node3D else Vector3.ZERO | |
| 1307 | return place_asset_hit(current_asset, {"position": at, "normal": Vector3.UP}) | |
| 1308 | ||
| 1309 | ||
| 1310 | ## The drop GHOST while a tile is dragged over the view: the asset's bounds box standing | |
| 1311 | ## where it would land. False (and hidden) when the pointer is over nothing. | |
| 1312 | func asset_ghost(name: String, at: Vector2) -> bool: | |
| 1313 | var hit := _crosshair_hit(at) | |
| 1314 | var bb := _asset_local_bounds(name) | |
| 1315 | if hit.is_empty() or bb.size == Vector3.ZERO: | |
| 1316 | asset_ghost_hide() | |
| 1317 | return false | |
| 1318 | if _asset_ghost == null or not is_instance_valid(_asset_ghost): | |
| 1319 | _asset_ghost = MeshInstance3D.new() | |
| 1320 | _asset_ghost.name = "AssetGhost" | |
| 1321 | var mat := StandardMaterial3D.new() | |
| 1322 | mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED | |
| 1323 | mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA | |
| 1324 | mat.albedo_color = Color(0.4, 0.8, 1.0, 0.25) | |
| 1325 | _asset_ghost.material_override = mat | |
| 1326 | _asset_ghost.mesh = BoxMesh.new() | |
| 1327 | add_child(_asset_ghost) | |
| 1328 | (_asset_ghost.mesh as BoxMesh).size = bb.size | |
| 1329 | _asset_ghost.global_transform = drop_frame(hit["position"], hit.get("normal", Vector3.UP)) \ | |
| 1330 | * Transform3D(Basis.IDENTITY, bb.get_center()) | |
| 1331 | _asset_ghost.visible = true | |
| 1332 | return true | |
| 1333 | ||
| 1334 | ||
| 1335 | func asset_ghost_hide() -> void: | |
| 1336 | if _asset_ghost != null and is_instance_valid(_asset_ghost): | |
| 1337 | _asset_ghost.visible = false | |
| 1338 | ||
| 1339 | ||
| 1340 | ## An asset's bounds relative to its pivot (the base centre), from the library row. | |
| 1341 | func _asset_local_bounds(name: String) -> AABB: | |
| 1342 | for r: Dictionary in (asset_library().rows() as Array): | |
| 1343 | if String(r["name"]) == name: | |
| 1344 | var b: Array = r.get("bounds", []) | |
| 1345 | if b.size() == 6: | |
| 1346 | var lo := Vector3(float(b[0]), float(b[1]), float(b[2])) | |
| 1347 | var hi := Vector3(float(b[3]), float(b[4]), float(b[5])) | |
| 1348 | var piv := Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5) | |
| 1349 | return AABB(lo - piv, hi - lo) | |
| 1350 | return AABB(Vector3(-0.5, 0, -0.5), Vector3.ONE) # personal-only: no catalogue bounds | |
| 1351 | return AABB() | |
| 1352 | ||
| 1353 | ||
| 1354 | func _register_asset_browser() -> void: | |
| 1355 | var dock := get_node_or_null("/root/BuildDock") | |
| 1356 | if dock == null or (_asset_browser != null and is_instance_valid(_asset_browser)): | |
| 1357 | return | |
| 1358 | var ab := VBoxContainer.new() | |
| 1359 | ab.set_script(load("res://scenes/game/asset_browser.gd")) | |
| 1360 | ab.setup(self) | |
| 1361 | dock.register("assets", "Assets", ab, true, dock.SIDE_BOTTOM, dock.WS_LEVEL) | |
| 1362 | _asset_browser = ab | |
| 1363 | ||
| 1364 | ||
| 1248 | 1365 | ## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be |
| 1249 | 1366 | ## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the |
| 1250 | 1367 | ## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection". |
| … | … | @@ -1397,7 +1514,7 @@ func add_sections() -> Array: |
| 1397 | 1514 | for s: String in ADD_SHAPES: |
| 1398 | 1515 | rows.append(["builder.add_%s" % s]) |
| 1399 | 1516 | return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.cable_from_edges"], |
| 1400 | ["builder.cable_bundle"], ["builder.carve"]]]] | |
| 1517 | ["builder.cable_bundle"], ["builder.carve"], ["builder.place_asset"]]]] | |
| 1401 | 1518 | |
| 1402 | 1519 | |
| 1403 | 1520 | ## Start the create tool already set to `shape_id`. |
| … | … | @@ -2566,6 +2683,7 @@ func _import_from(map_path: String) -> void: |
| 2566 | 2683 | # AFTER the import: the outliner's derived groups are counts over the brush |
| 2567 | 2684 | # set, so registering at _ready would build a tree over an empty level. |
| 2568 | 2685 | _register_outliner() |
| 2686 | _register_asset_browser() | |
| 2569 | 2687 | drop_placeholder_floor() |
| 2570 | 2688 | _move_player_to_start(importer) |
| 2571 | 2689 | # AFTER _move_player_to_start: it anchors the map's teleporters to info_player_start |
| … | … | @@ -4226,7 +4344,7 @@ func _builder_key(event: InputEvent) -> void: |
| 4226 | 4344 | ["builder.unhide_all", "unhide_all"], ["builder.make_real", "_q_make_real"], |
| 4227 | 4345 | ["builder.cuts_clear", "clear_cuts"], ["builder.select_cut_target", "select_cut_target"], |
| 4228 | 4346 | ["builder.extrude_make_real", "_q_extrude_make_real"], ["builder.prompt_cancel", "_q_prompt_cancel"], |
| 4229 | ["builder.make_real_then", "_q_make_real_then"]]: | |
| 4347 | ["builder.make_real_then", "_q_make_real_then"], ["builder.place_asset", "place_current_asset"]]: | |
| 4230 | 4348 | if Keymap.tapped(event, String(hp[0])): |
| 4231 | 4349 | call(String(hp[1])) |
| 4232 | 4350 | return |
| … | … | @@ -0,0 +1,201 @@ |
| 1 | extends Node | |
| 2 | ## GATE — THE ASSET BROWSER (#120 L4 step 5, docs/node-tree-and-stacks.md §15.5, | |
| 3 | ## asset_browser.gd). "Kit" saved to both libraries, "Solo" to yours only. | |
| 4 | ## W1 the map registers an "Assets" strip in the BuildDock | |
| 5 | ## W2 drop_frame: floor stands (up +y), wall sticks out (up = the wall's axis, x level), | |
| 6 | ## ceiling hangs (up -y), a slope takes its nearest axis; always right-handed | |
| 7 | ## W3 tiles: one per asset; Room shows only Kit, Mine both, a search narrows | |
| 8 | ## W4 a tile click makes it the CURRENT asset | |
| 9 | ## W5 Shift+A → Asset (place_current_asset): at the 3D cursor, standing, selected | |
| 10 | ## W6 a drop on a wall (place_asset_hit): the placement's up is the wall's axis | |
| 11 | ## W7 the "Save to" switches set what Save as asset writes | |
| 12 | ## W8 a tile with a placement on an older version offers "Update 1"; pressing it updates | |
| 13 | ## it and the button goes | |
| 14 | ## W9 arm / disarm: the 3D view takes drops only while a tile's drag is in flight | |
| 15 | ## W10 the Add menus list "Asset: place the current one…" (menu-only) | |
| 16 | ## | |
| 17 | ## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_browser.tscn | |
| 18 | ||
| 19 | const AssetLibrary := preload("res://scenes/maps/asset_library.gd") | |
| 20 | ||
| 21 | var fails := 0 | |
| 22 | var checks := 0 | |
| 23 | var map: Node3D | |
| 24 | var persist: Node | |
| 25 | var lib: Node | |
| 26 | var ab: Node | |
| 27 | ||
| 28 | ||
| 29 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 30 | checks += 1 | |
| 31 | if not cond: | |
| 32 | fails += 1 | |
| 33 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 34 | ||
| 35 | ||
| 36 | func _ready() -> void: | |
| 37 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 38 | gs.route("world:level:*", 1, "peer") | |
| 39 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 40 | if keet != null: | |
| 41 | keet.current_member_id = "probe" | |
| 42 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 43 | var pd := AssetLibrary.personal_dir() | |
| 44 | if pd.ends_with("_ci"): | |
| 45 | for f: String in DirAccess.get_files_at(pd): | |
| 46 | DirAccess.remove_absolute(ProjectSettings.globalize_path(pd.path_join(f))) | |
| 47 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 48 | add_child(map) | |
| 49 | for i in 6: | |
| 50 | await get_tree().process_frame | |
| 51 | persist = map.get("_persist") | |
| 52 | lib = map.asset_library() | |
| 53 | ab = map.get("_asset_browser") | |
| 54 | _run() | |
| 55 | if checks < 20: | |
| 56 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 57 | fails += 1 | |
| 58 | print("\nGATE asset_browser: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 59 | get_tree().quit(0 if fails == 0 else 1) | |
| 60 | ||
| 61 | ||
| 62 | func _box(at: Vector3, size: Vector3) -> String: | |
| 63 | return persist.record("shape.create", {"type": "box", "gv": 1, "params": {}, | |
| 64 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z], | |
| 65 | "material_id": 2, "level_id": 0}) | |
| 66 | ||
| 67 | ||
| 68 | func _frame_ok(f: Transform3D, up: Vector3) -> bool: | |
| 69 | var b := f.basis | |
| 70 | return b.y.is_equal_approx(up) and is_equal_approx(b.determinant(), 1.0) \ | |
| 71 | and is_zero_approx(b.x.dot(b.y)) and is_zero_approx(b.y.dot(b.z)) and is_zero_approx(b.x.dot(b.z)) | |
| 72 | ||
| 73 | ||
| 74 | func _names() -> Array: | |
| 75 | return (ab.shown_rows() as Array).map(func(r: Dictionary) -> String: return String(r["name"])) | |
| 76 | ||
| 77 | ||
| 78 | func _run() -> void: | |
| 79 | var dock: Node = get_tree().root.get_node("BuildDock") | |
| 80 | _ok("W1 the map registers an Assets strip", ab != null and dock.has_panel("assets")) | |
| 81 | if ab == null: | |
| 82 | return | |
| 83 | ||
| 84 | # ---- W2 ---- | |
| 85 | var MD: Script = map.get_script() | |
| 86 | _ok("W2 floor: stands (up +y)", _frame_ok(MD.drop_frame(Vector3.ZERO, Vector3.UP), Vector3.UP)) | |
| 87 | var wall: Transform3D = MD.drop_frame(Vector3.ZERO, Vector3(-1, 0, 0)) | |
| 88 | _ok("W2 wall: up = the wall's axis, x level", _frame_ok(wall, Vector3(-1, 0, 0)) and is_zero_approx(wall.basis.x.y), wall.basis) | |
| 89 | _ok("W2 ceiling: hangs (up -y)", _frame_ok(MD.drop_frame(Vector3.ZERO, Vector3.DOWN), Vector3.DOWN)) | |
| 90 | _ok("W2 a slope takes its nearest axis", _frame_ok(MD.drop_frame(Vector3.ZERO, Vector3(0.3, 0.9, 0).normalized()), Vector3.UP) | |
| 91 | and _frame_ok(MD.drop_frame(Vector3.ZERO, Vector3(-0.2, 0.1, -0.95).normalized()), Vector3(0, 0, -1))) | |
| 92 | _ok("W2 the frame stands ON the hit", MD.drop_frame(Vector3(3, 4, 5), Vector3.UP).origin == Vector3(3, 4, 5)) | |
| 93 | ||
| 94 | var O0 := Vector3(900.137, 0.31, 899.873) | |
| 95 | persist.begin_gesture("room") | |
| 96 | var a := _box(O0, Vector3(2, 2, 2)) | |
| 97 | var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""}) | |
| 98 | persist.record("col.link", {"target": kit, "members": ["g:" + a]}) | |
| 99 | var s := _box(O0 + Vector3(0, 0, -20), Vector3(1, 1, 1)) | |
| 100 | var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""}) | |
| 101 | persist.record("col.link", {"target": solo, "members": ["g:" + s]}) | |
| 102 | persist.end_gesture() | |
| 103 | persist.refold() | |
| 104 | lib.save_collection(kit, "Kit") | |
| 105 | lib.save_collection(solo, "Solo", false, true) | |
| 106 | ||
| 107 | # ---- W3 ---- | |
| 108 | ab.refresh() | |
| 109 | _ok("W3 a tile per asset", ab.tile("Kit") != null and ab.tile("Solo") != null, _names()) | |
| 110 | ab.set_filter("", "room") | |
| 111 | _ok("W3 Room: only Kit", _names() == ["Kit"], _names()) | |
| 112 | ab.set_filter("", "personal") | |
| 113 | _ok("W3 Mine: both", _names() == ["Kit", "Solo"], _names()) | |
| 114 | ab.set_filter("so", "both") | |
| 115 | _ok("W3 a search narrows", _names() == ["Solo"], _names()) | |
| 116 | ab.set_filter("", "both") | |
| 117 | ||
| 118 | # ---- W4 ---- | |
| 119 | ab.tile("Kit").pressed.emit() | |
| 120 | _ok("W4 a tile click makes it current", String(map.get("current_asset")) == "Kit" and ab.tile("Kit").button_pressed) | |
| 121 | ||
| 122 | # ---- W5 ---- | |
| 123 | var cur: Node3D = map.get("_cursor3d") | |
| 124 | if cur != null: | |
| 125 | cur.global_position = O0 + Vector3(10, 0, 10) | |
| 126 | var p5: String = map.place_current_asset() | |
| 127 | var f5: Transform3D = (persist.placements() as Dictionary).get(p5, {}).get("frame", Transform3D()) | |
| 128 | _ok("W5 Shift+A → Asset: at the 3D cursor, standing, selected", p5 != "" and cur != null | |
| 129 | and f5.origin.is_equal_approx(cur.global_position) and _frame_ok(f5, Vector3.UP) and map.selected_asset() == p5, [p5, f5.origin]) | |
| 130 | ||
| 131 | # ---- W6 ---- | |
| 132 | var p6: String = map.place_asset_hit("Solo", {"position": O0 + Vector3(0, 2, 30), "normal": Vector3(0, 0, 1)}) | |
| 133 | var f6: Transform3D = (persist.placements() as Dictionary).get(p6, {}).get("frame", Transform3D()) | |
| 134 | _ok("W6 a drop on a wall: up is the wall's axis", p6 != "" and _frame_ok(f6, Vector3(0, 0, 1)), f6.basis) | |
| 135 | _ok("W6 ... and a personal-only asset was published to the room first", | |
| 136 | String(get_tree().root.get_node("AssetSync").resolve("asset", "Solo")) != "") | |
| 137 | ||
| 138 | # ---- W7 ---- | |
| 139 | var rb: CheckBox = ab.get("_room_box") | |
| 140 | var pb: CheckBox = ab.get("_personal_box") | |
| 141 | rb.button_pressed = false | |
| 142 | pb.button_pressed = false | |
| 143 | _ok("W7 the Save to switches set what Save as asset writes", not bool(map.get("asset_save_room")) | |
| 144 | and not bool(map.get("asset_save_personal"))) | |
| 145 | rb.button_pressed = true | |
| 146 | pb.button_pressed = true | |
| 147 | ||
| 148 | # ---- W8 ---- | |
| 149 | var gs: Node = get_tree().root.get_node("GameState") | |
| 150 | var oseq := int(p5.get_slice(":", 1)) + 9 | |
| 151 | var row_kit: Dictionary = {} | |
| 152 | for r: Dictionary in (lib.rows() as Array): | |
| 153 | if String(r["name"]) == "Kit": | |
| 154 | row_kit = r | |
| 155 | gs._on_durable_updated("", "map:%s:world:level:zz9other:op:%d" % [String(gs.current_map_id), oseq], {"v": LevelPersistence.RECORD_V, | |
| 156 | "kind": "asset.place", "undone": false, "g": "zz9other:%d" % oseq, "src": String(persist.find_source().get("hash", "")), | |
| 157 | "asset": row_kit["oid"], "name": "Kit", "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 50)))}) | |
| 158 | persist.refold() | |
| 159 | persist.begin_gesture("edit") | |
| 160 | persist.record("shape.size", {"target": a, "size": [3, 2, 2]}) | |
| 161 | persist.end_gesture() | |
| 162 | persist.refold() | |
| 163 | lib.save_collection(kit, "Kit") | |
| 164 | ab.refresh() | |
| 165 | var upd: Button = null | |
| 166 | for c: Node in (ab.tile("Kit").get_parent().get_children()): | |
| 167 | if c is Button and String((c as Button).text).begins_with("Update"): | |
| 168 | upd = c | |
| 169 | _ok("W8 a placement on an older version: the tile offers 'Update 1'", upd != null and upd.text == "Update 1", | |
| 170 | upd.text if upd != null else null) | |
| 171 | if upd != null: | |
| 172 | upd.pressed.emit() | |
| 173 | ab.refresh() | |
| 174 | var still := false | |
| 175 | for c2: Node in (ab.tile("Kit").get_parent().get_children()): | |
| 176 | if c2 is Button and String((c2 as Button).text).begins_with("Update"): | |
| 177 | still = true | |
| 178 | _ok("W8 ... pressing it updates them and the button goes", not still) | |
| 179 | ||
| 180 | # ---- W9 ---- | |
| 181 | var v: Control = dock.view3d() | |
| 182 | _ok("W9 the 3D view ignores the mouse when no tile is dragged", v.mouse_filter == Control.MOUSE_FILTER_IGNORE) | |
| 183 | ab.arm(true) | |
| 184 | _ok("W9 armed while a tile's drag is in flight", v.mouse_filter == Control.MOUSE_FILTER_PASS and ab.armed()) | |
| 185 | # == false, not `not …`: a crashing call returns null, and `not null` passed. | |
| 186 | var took: Variant = ab.call("_view_can_drop", Vector2.ZERO, {"something": 1}) | |
| 187 | _ok("W9 ... it takes only an asset tile's data", took is bool and took == false, took) | |
| 188 | ab.arm(false) | |
| 189 | _ok("W9 disarmed after", v.mouse_filter == Control.MOUSE_FILTER_IGNORE and not ab.armed()) | |
| 190 | ||
| 191 | # ---- W10 ---- | |
| 192 | var add_rows: Array = [] | |
| 193 | for sec: Array in (map.add_sections() as Array): | |
| 194 | for r2: Array in (sec[1] as Array): | |
| 195 | add_rows.append(String(r2[0])) | |
| 196 | var bar_add: Array = [] | |
| 197 | for m: Array in load("res://scenes/game/editor_top_bar.gd").MENUS: | |
| 198 | if String(m[0]) == "Add": | |
| 199 | bar_add = m[1] | |
| 200 | _ok("W10 Add menus list Asset: place the current one (menu-only)", add_rows.has("builder.place_asset") | |
| 201 | and bar_add.has("builder.place_asset") and not Keymap.binding_of("builder.place_asset").has("key")) |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_asset_browser.gd" id="1"] | |
| 4 | ||
| 5 | [node name="GateAssetBrowser" type="Node"] | |
| 6 | script = ExtResource("1") |