extends Node3D class_name Cabinet ## A filing cabinet you actually have to open. ## ## Destruction here is deliberately GRANULAR. You can't one-shot a cabinet and have its ## contents evaporate: you pull a drawer, the drawer slides out and becomes its own rigid ## body, and the folders inside become individual smashables you deal with one at a time ## — unless you find the level's shredder, which eats a whole drawer at once. ## ## Every cabinet carries a visible alphabetical range on a Label3D. That label is the ## entire search mechanic: the Gauntlet tells you a client's NAME, never a cabinet, and ## working out that HENDERSON lives in F–J is the puzzle. ## ## Godot 4.7 GDScript 2.0. const CARCASS := "res://assets/store/cabinet-carcass.glb" const DRAWER := "res://assets/store/cabinet-drawer.glb" const FOLDER := "res://assets/store/file-folder.glb" const DRAWERS := 4 const DRAWER_Y := [0.10, 0.42, 0.74, 1.06] # sill heights up the carcass const OPEN_BY := 0.44 # how far a drawer slides out const FILES_PER_DRAWER := 4 var label_range := "A–E" var is_target := false var drawers: Array[Smashable] = [] var _open: Array[bool] = [] var _slide: Array[float] = [] # 0 closed, 1 fully out var _files_of: Array = [] # per drawer: Array[Smashable] var _file_offsets: Array = [] # per drawer: Array[Vector3] in cabinet space var _label: Label3D signal drawer_opened(cab: Cabinet, index: int) ## `host` is the node smashables should live under (Main's World). func setup(host: Node3D, pos: Vector3, yaw: float, range_text: String, wire: Callable) -> void: label_range = range_text host.add_child(self) position = pos rotation.y = yaw var carcass := Smashable.new() carcass.name = "cabinet" carcass.kind = "steel" carcass.start_frozen = true # it holds the drawers up carcass.mass = 60.0 _mount(carcass, CARCASS) add_child(carcass) carcass.transform = Transform3D.IDENTITY wire.call(carcass) for i in DRAWERS: var d := Smashable.new() d.name = "drawer%d" % i d.kind = "steel" d.start_frozen = true # frozen until it's been pulled fully out d.mass = 12.0 _mount(d, DRAWER) host.add_child(d) d.global_transform = global_transform * Transform3D(Basis.IDENTITY, Vector3(0.0, DRAWER_Y[i], 0.0)) wire.call(d) drawers.append(d) _open.append(false) _slide.append(0.0) carcass.supports.append(d) var fs: Array[Smashable] = [] var offs: Array = [] for f in FILES_PER_DRAWER: var file := Smashable.new() file.name = "file" file.kind = "paper" file.start_frozen = true file.mass = 0.4 _mount(file, FOLDER) host.add_child(file) var off := Vector3(0.0, DRAWER_Y[i] + 0.06 + f * 0.026, 0.0) file.global_transform = global_transform * Transform3D(Basis.IDENTITY, off) wire.call(file) fs.append(file) offs.append(off) _files_of.append(fs) _file_offsets.append(offs) # the label IS the search mechanic _label = Label3D.new() _label.text = label_range _label.font_size = 96 _label.pixel_size = 0.0012 _label.modulate = Color(0.10, 0.10, 0.12) _label.outline_size = 0 _label.billboard = BaseMaterial3D.BILLBOARD_DISABLED _label.no_depth_test = false add_child(_label) _label.position = Vector3(0.0, 1.20, -0.315) _label.rotation.y = PI func _mount(body: Smashable, path: String) -> void: if not ResourceLoader.exists(path): return var packed := load(path) as PackedScene if packed == null: return var vis: Node3D = packed.instantiate() body.add_child(vis) var ab := Floorplan.mesh_aabb(vis) if ab.size == Vector3.ZERO: ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) var col := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = ab.size col.shape = sh col.position = ab.get_center() body.add_child(col) body.set_meta("spawn_pos", Vector3.ZERO) # ---------------------------------------------------------------- interaction ## Index of the drawer nearest `from` that's still closed, or -1. func nearest_closed(from: Vector3, reach: float) -> int: var best := reach * reach var idx := -1 for i in DRAWERS: if _open[i] or not is_instance_valid(drawers[i]): continue var d: float = (drawers[i].global_position - from).length_squared() if d < best: best = d idx = i return idx func open(i: int) -> void: if i < 0 or i >= DRAWERS or _open[i]: return _open[i] = true drawer_opened.emit(self, i) func is_open(i: int) -> bool: return i >= 0 and i < DRAWERS and _open[i] ## Files still intact across the whole cabinet. func files_remaining() -> int: var n := 0 for fs in _files_of: for f in fs: if is_instance_valid(f): n += 1 return n func files_total() -> int: return DRAWERS * FILES_PER_DRAWER ## Every file in one drawer, for the shredder to eat in a single go. func drawer_files(i: int) -> Array: if i < 0 or i >= _files_of.size(): return [] return _files_of[i] # ---------------------------------------------------------------- per-frame ## Drawers slide on a spring toward open, and their folders ride with them until the ## drawer is fully out — at which point the folders go live and can be destroyed. func _physics_process(dt: float) -> void: for i in DRAWERS: var want := 1.0 if _open[i] else 0.0 if is_equal_approx(_slide[i], want): continue _slide[i] = move_toward(_slide[i], want, dt * 1.8) var out := -OPEN_BY * _slide[i] # -Z is the cabinet front var d: Smashable = drawers[i] if is_instance_valid(d) and d.freeze: d.global_transform = global_transform * Transform3D(Basis.IDENTITY, Vector3(0.0, DRAWER_Y[i], out)) var fs: Array = _files_of[i] var offs: Array = _file_offsets[i] for j in fs.size(): var f: Smashable = fs[j] if not is_instance_valid(f) or not f.freeze: continue var o: Vector3 = offs[j] f.global_transform = global_transform * Transform3D(Basis.IDENTITY, Vector3(o.x, o.y, o.z + out)) if _slide[i] >= 1.0: _release(i) ## Fully open: the drawer and its folders become live physics, so they can be pulled ## out, tipped over, and destroyed piece by piece. func _release(i: int) -> void: var d: Smashable = drawers[i] if is_instance_valid(d): d.release() for f in _files_of[i]: if is_instance_valid(f): (f as Smashable).release()