OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / a2e22b8

#120 L4 §15 step 5: the asset browser — tiles in the bottom area, drag into the view
AssetBrowser strip ("Assets"): search, source filter, Save to Room / Mine switches, a tile per asset (click = current, drag to place, "Update N" for older placements). While a tile's drag is in flight BuildDock.view3d() is armed as the drop target (PASS + drag forwarding), disarmed after; a bounds ghost follows the pointer. drop_frame: up = the hit normal snapped to its axis. Add menus: "Asset: place the current one at the 3D cursor" (menu-only builder.place_asset). gate_asset_browser 22/22; mutants 4 killed + 1 equivalent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a2e22b88ecce8636a3d11e8034607c9a4baa2b89
by plankatron <fleeky@gmail.com> · 2026-10-19T22:52:18.000Z

Changed files (8) +604 -7

autoloads/build_dock.gd +7 -0
……@@ -377,6 +377,13 @@ func is_modal() -> bool:
377377
378378## R, the free rectangle the 3D shows through (screen pixels). The whole window when the
379379## 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.
383func view3d() -> Control:
384 return _view3d
385
386
380387func view_rect() -> Rect2:
381388 if visible and _view3d != null and _view3d.is_visible_in_tree():
382389 return _view3d.get_global_rect()
autoloads/keymap.gd +1 -0
……@@ -385,6 +385,7 @@ const DEFAULTS: Dictionary = {
385385 "builder.collection_new": {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"},
386386 # #120 L4: menu-only (user 2026-10-19), Object menu.
387387 "builder.save_asset": {"label": "Save collection as asset…"},
388 "builder.place_asset": {"label": "Asset: place the current one at the 3D cursor"},
388389 "builder.collection_move": {"key": KEY_M, "label": "Move to collection…"},
389390 "builder.collection_link": {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"},
390391 "builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"},
docs/node-tree-and-stacks.md +14 -3
……@@ -1346,9 +1346,20 @@ Save as asset is menu-only for now).
13461346 synthetic piece" (it caught scale writing brush.scale on every piece while leaving the
13471347 frame alone, and the same hole in mixed move / rotate). 6 mutants killed. **DONE
13481348 2026-10-19.**
13495. **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`.
13495. **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.**
135213636. **Thumbnails** (SubViewport render at save).
135313647. **Measure**: fold cost of N placements of a 50-shape asset (the _probe_fold_split
13541365 method) — the follow-up perf issue is #128.
scenes/game/asset_browser.gd +253 -0
……@@ -0,0 +1,253 @@
1extends 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
19const TILE_PX := 92
20
21var map: Node = null
22
23var _search: LineEdit
24var _source: OptionButton
25var _room_box: CheckBox
26var _personal_box: CheckBox
27var _current: Label
28var _row: HBoxContainer
29var _empty: Label
30var _search_text := ""
31var _source_id := "both" # both | room | personal
32var _tiles: Dictionary = {} # name -> Button
33var _armed := false
34var _dirty := true
35
36
37func 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
51func _on_state_changed(key: String, _value: Variant, _channel: int) -> void:
52 if key.begins_with("world:assets:asset:"):
53 _dirty = true
54
55
56func _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
108func 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}].
117func 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
133func tile(name: String) -> Button:
134 return _tiles.get(name, null)
135
136
137func 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
174func _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.
180func 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
191func 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
199func _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
206func _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.
216func 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
232func armed() -> bool:
233 return _armed
234
235
236func _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
242func _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
248func _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)
scenes/game/editor_top_bar.gd +1 -1
……@@ -26,7 +26,7 @@ const MENUS: Array = [
2626 "build_mode.sel_brush", "builder.element_toggle", "builder.select_mode_menu"]],
2727 ["Add", ["builder.add_menu", "-", "builder.add_box", "builder.add_stairs", "builder.add_spiral_stairs",
2828 "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"]],
3030 # Blender 2.79's OBJECT menu (was "Brush"): transforms, CSG, pivot, and since #120 F1
3131 # parenting, join / separate and collections. One menu, not a new one: a seventh title
3232 # pushed the top band past a 1280-px window (gate_editor_e1).
scenes/maps/map_dc_level.gd +121 -3
……@@ -155,6 +155,12 @@ func _exit_tree() -> void:
155155 dock.unregister("face")
156156 _tex_strip = null
157157 _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
158164 # The bars drive THIS map; the next map may not be a brush editor at all.
159165 var dock2 := get_node_or_null("/root/BuildDock")
160166 if dock2 != null:
……@@ -393,6 +399,7 @@ func _start_empty_level() -> void:
393399 print("[MapDCLevel] dc_terrain build has no texture API — plain material")
394400 _refresh_brush_view()
395401 _register_outliner()
402 _register_asset_browser()
396403 var st: Dictionary = _persist.refold() if _persist != null else {}
397404 print("[MapDCLevel] empty level (level_import off) — %d brush record(s) replayed"
398405 % int(st.get("applied", 0)))
……@@ -1230,7 +1237,7 @@ func save_asset_prompt() -> void:
12301237 return
12311238 var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"])
12321239 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)
12341241 for line: String in (r.get("report", []) as Array):
12351242 print("[MapDCLevel] save as asset: %s" % line)
12361243 if r.has("error"):
……@@ -1245,6 +1252,116 @@ func save_asset_prompt() -> void:
12451252 get_viewport().set_input_as_handled()
12461253
12471254
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.
1258var current_asset := ""
1259## Where Save as asset writes: the browser's "Save to" switches. Both by default (§15.4).
1260var asset_save_room := true
1261var asset_save_personal := true
1262var _asset_browser: Node = null
1263var _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.
1269static 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 "".
1285func 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.
1296func 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.
1302func 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.
1312func 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
1335func 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.
1341func _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
1354func _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
12481365## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be
12491366## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the
12501367## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection".
……@@ -1397,7 +1514,7 @@ func add_sections() -> Array:
13971514 for s: String in ADD_SHAPES:
13981515 rows.append(["builder.add_%s" % s])
13991516 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"]]]]
14011518
14021519
14031520## Start the create tool already set to `shape_id`.
……@@ -2566,6 +2683,7 @@ func _import_from(map_path: String) -> void:
25662683 # AFTER the import: the outliner's derived groups are counts over the brush
25672684 # set, so registering at _ready would build a tree over an empty level.
25682685 _register_outliner()
2686 _register_asset_browser()
25692687 drop_placeholder_floor()
25702688 _move_player_to_start(importer)
25712689 # 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:
42264344 ["builder.unhide_all", "unhide_all"], ["builder.make_real", "_q_make_real"],
42274345 ["builder.cuts_clear", "clear_cuts"], ["builder.select_cut_target", "select_cut_target"],
42284346 ["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"]]:
42304348 if Keymap.tapped(event, String(hp[0])):
42314349 call(String(hp[1]))
42324350 return
scenes/test/gate_asset_browser.gd +201 -0
……@@ -0,0 +1,201 @@
1extends 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
19const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
20
21var fails := 0
22var checks := 0
23var map: Node3D
24var persist: Node
25var lib: Node
26var ab: Node
27
28
29func _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
36func _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
62func _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
68func _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
74func _names() -> Array:
75 return (ab.shown_rows() as Array).map(func(r: Dictionary) -> String: return String(r["name"]))
76
77
78func _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"))
scenes/test/gate_asset_browser.tscn +6 -0
……@@ -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"]
6script = ExtResource("1")