extends Node3D ## Destroyulator — cascade prototype. ## ## Builds a little record rack out of primitives and lets you smash it. The point ## isn't the art (that's a one-line swap for your real .glb racks later) — it's to ## prove two things on your actual M3, today: ## 1. THE DAY-0 GATE: press S to rain 500 rigid bodies and watch the FPS hold. ## 2. THE CASCADE: smash a frozen shelf -> the crates on it fall -> jackets spill ## -> records tumble out -> records crack when they hit the floor (brittle). ## ## Controls: WASD move · Space jump · L-click punch · R-click kick · Esc release mouse ## · B = rain 500 (stress gate) · R = reset @onready var _cam: Camera3D var _world: Node3D # everything smashable/debris lives here so reset is one line var _hud: Label var _smash_count := 0 var _juice: Juice var _player: Player # kept so GameMode can reach player.grab var _records: Array[Record] = [] # the live records, handed to GameMode each round var _game_mode: GameMode func _ready() -> void: randomize() _setup_world() _juice = Juice.new() add_child(_juice) _juice.setup(_cam) # _setup_world() set _cam to the player's camera _build_rack(Vector3.ZERO) # rules layer: starts in FreePlay; keys 1/2 switch, F reports in FindMisfiled _game_mode = GameMode.new() add_child(_game_mode) _game_mode.setup(_player.grab) _game_mode.begin_free_play(_records) _setup_hud() # ---------------------------------------------------------------- world / camera func _setup_world() -> void: var we := WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = Color(0.07, 0.06, 0.09) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.5, 0.5, 0.62) env.ambient_light_energy = 0.6 we.environment = env add_child(we) var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-52, -38, 0) sun.light_energy = 1.3 sun.shadow_enabled = true add_child(sun) # First-person player replaces the old fixed camera. add_child() runs its _ready() # synchronously, so player.camera/grab exist on the next line; we reuse them. _player = Player.new() add_child(_player) _player.global_position = Vector3(0, 0, 4) # stand back, facing -Z toward the rack _cam = _player.camera # floor var floor_body := StaticBody3D.new() var fmesh := MeshInstance3D.new() var plane := BoxMesh.new() plane.size = Vector3(24, 0.2, 24) fmesh.mesh = plane var fmat := StandardMaterial3D.new() fmat.albedo_color = Color(0.13, 0.12, 0.15) fmesh.material_override = fmat floor_body.add_child(fmesh) var fcol := CollisionShape3D.new() var fshape := BoxShape3D.new() fshape.size = plane.size fcol.shape = fshape floor_body.add_child(fcol) floor_body.position = Vector3(0, -0.1, 0) add_child(floor_body) _world = Node3D.new() _world.name = "World" add_child(_world) # ---------------------------------------------------------------- the store # Real Monster Robot Party assets: wooden racks (frozen backdrop you can smash) and # crates of records in front. Colliders auto-size to each GLB's mesh AABB and every # piece is dropped so its bottom rests exactly on the floor — no hardcoded dimensions. const RACK_GLB := "res://assets/store/rack.glb" const CRATE_GLB := "res://assets/store/crate.glb" const RECORD_GLB := "res://assets/store/record.glb" # Lane 2 hero props (each ships a .fractured.glb sibling → real shard-by-shard smashing) const DESK_GLB := "res://assets/store/office-desk.glb" const PRINTER_GLB := "res://assets/store/office-printer.glb" const CRT_GLB := "res://assets/store/crt-monitor.glb" const CABINET_GLB := "res://assets/store/filing-cabinet.glb" const COOLER_GLB := "res://assets/store/water-cooler.glb" const BOX_GLB := "res://assets/store/cardboard-box.glb" const TURNTABLE_GLB := "res://assets/store/turntable.glb" # Generated sleeve art (Nano Banana): each file is a 3x3 sheet of nine original # fictional album covers. A record picks one tile at random via uv1 scale/offset — # one texture in VRAM, eighteen different-looking records on the floor. const SLEEVE_ATLASES: Array[String] = [ "res://assets/art/sleeves_a.jpg", "res://assets/art/sleeves_b.jpg", ] var _sleeve_textures: Array[Texture2D] = [] var _records_skinned := 0 func _build_rack(origin: Vector3) -> void: _records.clear() # stale refs from the freed world; repopulated by _place_record # two racks form a smashable back wall of the booth _glb_piece(RACK_GLB, "wood", Vector2(origin.x - 0.95, origin.z - 0.7), true) _glb_piece(RACK_GLB, "wood", Vector2(origin.x + 0.95, origin.z - 0.7), true) # crates of records on the floor in front, in two rows var spots := [ Vector2(-0.8, 0.5), Vector2(0.0, 0.5), Vector2(0.8, 0.5), Vector2(-0.4, 1.2), Vector2(0.4, 1.2), ] for spot in spots: var xz: Vector2 = spot + Vector2(origin.x, origin.z) var crate := _glb_piece(CRATE_GLB, "wood", xz, false) var crate_top: float = crate["top"] # a few records stacked on the crate; smashing the crate spills them, and each # one is now a grabbable Record (sleeve + hidden disc), not a one-hit prop for i in range(3): var jitter := Vector2(randf_range(-0.06, 0.06), randf_range(-0.06, 0.06)) var rec := _place_record(xz + jitter, crate_top + float(i) * 0.03) (crate["piece"] as Smashable).supports.append(rec) print("[art] records skinned with sleeve art: ", _records_skinned) _build_office(origin) ## Lane 2's workplace props scattered as furniture, with the office PRINTER as the level ## boss on its desk (soaks several hits, dies in a bigger burst). Each of these ships a ## .fractured.glb, so _glb_piece auto-wires real chunk destruction (see there). func _build_office(origin: Vector3) -> void: var o := Vector2(origin.x, origin.z) # the boss: a printer on a desk. Desk is a frozen support; smashing it drops the printer. var desk := _glb_piece(DESK_GLB, "wood", o + Vector2(2.7, -0.3), true) var printer := _glb_piece(PRINTER_GLB, "steel", o + Vector2(2.7, -0.3), false, desk["top"]) var boss := printer["piece"] as Smashable boss.is_boss = true boss.hits_to_break = 5 # the boss soaks a beating before it bursts boss.mass = 8.0 # heavy, so hits rock it but don't shove it off the desk (desk["piece"] as Smashable).supports.append(printer["piece"]) # furniture around the booth — floor props you can wreck into real chunks _glb_piece(CABINET_GLB, "steel", o + Vector2(-2.7, -0.3), false) _glb_piece(COOLER_GLB, "steel", o + Vector2(3.3, 1.6), false) _glb_piece(CRT_GLB, "glass", o + Vector2(-2.7, 1.3), false) _glb_piece(BOX_GLB, "cardboard", o + Vector2(1.9, 2.4), false) _glb_piece(TURNTABLE_GLB, "wood", o + Vector2(-1.7, -1.3), false) ## Spawn one grabbable Record (sleeve + nested disc) resting on `sit_on`. Mirrors ## _glb_piece's "drop the AABB bottom onto sit_on" trick, but the Record builds its own ## visuals/colliders/disc in _ready, so we just skin the sleeve and place it. func _place_record(xz: Vector2, sit_on: float) -> Record: var rec := Record.new() rec.setup() var g: String = GameMode.GENRES[randi() % GameMode.GENRES.size()] rec.sleeve_genre = g rec.disc_genre = g # matches until GameMode plants a mismatch _world.add_child(rec) # _ready() splits record.glb, builds the disc if rec.sleeve_visual != null: _skin_record(rec.sleeve_visual) # random cover art on the jacket faces var ab := _body_local_aabb(rec, rec.sleeve_visual) if rec.sleeve_visual != null else AABB() rec.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) rec.smashed.connect(_on_smashed) # the empty sleeve breaking counts + juices var disc := rec.disc_body() if disc != null: disc.smashed.connect(_on_smashed) # the cracked disc counts + juices too _records.append(rec) return rec ## Instance a GLB as the VISUAL of a Smashable, give it a box collider auto-sized to ## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns ## { piece: Smashable, top: float } so a caller can stack the next thing on top. func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0) -> Dictionary: var s := Smashable.new() s.kind = kind s.start_frozen = frozen # real destruction: if Lane 2 shipped a .fractured.glb next to this GLB, use it var frac := path.get_basename() + ".fractured.glb" if ResourceLoader.exists(frac): s.fractured_scene = load(frac) as PackedScene var packed := load(path) as PackedScene var vis: Node3D = packed.instantiate() s.add_child(vis) if kind == "vinyl": _skin_record(vis) # random sleeve art on the jacket faces _world.add_child(s) # in-tree so transforms/AABBs resolve var ab := _body_local_aabb(s, vis) if ab.size == Vector3.ZERO: ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback var col := CollisionShape3D.new() var box := BoxShape3D.new() box.size = ab.size col.shape = box col.position = ab.get_center() s.add_child(col) s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on s.smashed.connect(_on_smashed) return {"piece": s, "top": sit_on + ab.size.y} ## Give a record's sleeve faces a random cover from the generated 3x3 sheets. ## The record.glb sleeve cube carries M_Sleeve_Front / M_Sleeve_Back materials ## with real UVs — we duplicate them and window one tile of the atlas via ## uv1_scale/uv1_offset (no image slicing, one texture serves nine covers). func _skin_record(vis: Node3D) -> void: if _sleeve_textures.is_empty(): for p in SLEEVE_ATLASES: var t := load(p) as Texture2D if t != null: _sleeve_textures.append(t) if _sleeve_textures.is_empty(): return # art not imported yet — records stay plain var tex: Texture2D = _sleeve_textures[randi() % _sleeve_textures.size()] var tile_col := randi() % 3 var tile_row := randi() % 3 var mis: Array[MeshInstance3D] = [] _collect_meshes(vis, mis) var did := false for mi in mis: if mi.mesh == null: continue for si in mi.mesh.get_surface_count(): var mat := mi.mesh.surface_get_material(si) if mat == null or not (mat is BaseMaterial3D): continue var nm: String = mat.resource_name if nm.contains("Sleeve_Front") or nm.contains("Sleeve_Back"): var dup := mat.duplicate() as BaseMaterial3D dup.albedo_color = Color.WHITE dup.albedo_texture = tex dup.uv1_scale = Vector3(1.0 / 3.0, 1.0 / 3.0, 1.0) dup.uv1_offset = Vector3(tile_col / 3.0, tile_row / 3.0, 0.0) mi.set_surface_override_material(si, dup) did = true if did: _records_skinned += 1 ## Combined AABB of every MeshInstance3D under `visual_root`, in `body`'s local space ## (independent of where `body` currently sits in the world). func _body_local_aabb(body: Node3D, visual_root: Node3D) -> AABB: var mis: Array[MeshInstance3D] = [] _collect_meshes(visual_root, mis) var inv := body.global_transform.affine_inverse() var acc := AABB() var started := false for mi in mis: var a := mi.get_aabb() var rel := inv * mi.global_transform for i in range(8): var corner := a.position + Vector3( a.size.x * float(i & 1), a.size.y * float((i >> 1) & 1), a.size.z * float((i >> 2) & 1)) var p := rel * corner if not started: acc = AABB(p, Vector3.ZERO) started = true else: acc = acc.expand(p) return acc func _collect_meshes(n: Node, acc: Array[MeshInstance3D]) -> void: if n is MeshInstance3D: acc.append(n) for c in n.get_children(): _collect_meshes(c, acc) func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := false) -> Smashable: var s := Smashable.new() s.kind = kind s.start_frozen = frozen var mat := StandardMaterial3D.new() mat.albedo_color = Smashable.PROFILES[kind]["color"] var mesh := MeshInstance3D.new() mesh.material_override = mat var col := CollisionShape3D.new() if cylinder: var cm := CylinderMesh.new() cm.top_radius = size.x cm.bottom_radius = size.x cm.height = size.y mesh.mesh = cm var cs := CylinderShape3D.new() cs.radius = size.x cs.height = size.y col.shape = cs else: var bm := BoxMesh.new() bm.size = size mesh.mesh = bm var bs := BoxShape3D.new() bs.size = size col.shape = bs s.add_child(mesh) s.add_child(col) _world.add_child(s) # _ready() runs here (kind + frozen already set) s.global_position = pos s.smashed.connect(_on_smashed) return s func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 if _juice != null: _juice.impact(kind, at, shards) if _game_mode != null: _game_mode.on_smashed() # scores the mess during a FindMisfiled round # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: # Melee (L-click punch / R-click kick), grab (E/G/drag), and Esc live in Player.gd. # Here: debug keys + the game-mode keys. Rain is on B (S walks backward now). if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_B: _rain(500) elif event.keycode == KEY_R: _reset() elif event.keycode == KEY_1: _switch_mode(GameMode.Mode.FREE_PLAY) elif event.keycode == KEY_2: _switch_mode(GameMode.Mode.FIND_MISFILED) elif event.keycode == KEY_F and _game_mode != null: _game_mode.report() # report the disc you're holding (FindMisfiled) # The Day-0 stress gate: rain N bodies, watch the FPS counter hold. func _rain(n: int) -> void: for i in n: var b := RigidBody3D.new() b.add_to_group("debris") var mesh := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(0.12, 0.12, 0.12) mesh.mesh = bm b.add_child(mesh) var col := CollisionShape3D.new() var shape := BoxShape3D.new() shape.size = bm.size col.shape = shape b.add_child(col) _world.add_child(b) b.global_position = Vector3(randf_range(-2.2, 2.2), randf_range(3.5, 8.0), randf_range(-2.2, 2.2)) func _reset() -> void: for c in _world.get_children(): c.queue_free() _smash_count = 0 await get_tree().process_frame _build_rack(Vector3.ZERO) # re-arm whatever mode we're in against the fresh set of records if _game_mode != null: if _game_mode.mode == GameMode.Mode.FIND_MISFILED: _game_mode.begin_find_misfiled(_records) else: _game_mode.begin_free_play(_records) ## Switch mode and rebuild the store fresh so a round always starts clean. func _switch_mode(m: int) -> void: if _game_mode == null: return _game_mode.mode = m # _reset() reads this to pick begin_free_play/begin_find_misfiled await _reset() # ---------------------------------------------------------------- HUD func _setup_hud() -> void: var layer := CanvasLayer.new() add_child(layer) _hud = Label.new() _hud.position = Vector2(16, 12) _hud.add_theme_font_size_override("font_size", 18) _hud.add_theme_color_override("font_color", Color(1, 0.36, 0.62)) layer.add_child(_hud) func _process(_dt: float) -> void: if _hud == null: return var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size() var c := _juice.combo() if _juice != null else 0 var combo_str := (" COMBO x%d" % c) if c > 1 else "" var mode_line := _game_mode.hud_line() if _game_mode != null else "" _hud.text = "FPS %d bodies %d smashed %d%s\n%s\nWASD move E grab drag ←→ pull LMB throw/punch G drop F report B rain R reset" % [ Engine.get_frames_per_second(), bodies, _smash_count, combo_str, mode_line]