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: Hud var _smash_count := 0 var _score := 0 var _tally := {} # material -> how many of it you've destroyed (work-order HUD) ## Score per material. Steel is worth the most because it's the hardest to break, which ## is what makes the sledgehammer worth its cooldown. const POINTS := { "paper": 5, "cardboard": 10, "plastic": 20, "vinyl": 25, "wood": 30, "glass": 40, "steel": 60, } 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.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge 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) _wire(rec) # the empty sleeve breaking counts + juices var disc := rec.disc_body() if disc != null: _wire(disc) # 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 _wire(s) 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 _wire(s) return s func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 _tally[kind] = int(_tally.get(kind, 0)) + 1 if _juice != null: _juice.impact(kind, at, shards) # score AFTER impact() so this break's own combo step is counted _score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1) else: _score += int(POINTS.get(kind, 15)) if _game_mode != null: _game_mode.on_smashed() # scores the mess during a FindMisfiled round ## A swing that couldn't hurt what it hit. No score, no combo — just the clank, plus a ## one-time nudge toward the right tool. func _on_resisted(kind: String, at: Vector3) -> void: if _juice != null: _juice.resist(kind, at) if _hud != null: _hud.toast("%s shrugs it off — try a heavier tool" % kind.to_upper()) func _on_damaged(kind: String, at: Vector3, frac: float) -> void: if _juice != null: _juice.dent(kind, at, frac) ## Every Smashable routes its three outcomes here. func _wire(s: Smashable) -> void: s.smashed.connect(_on_smashed) s.resisted.connect(_on_resisted) s.damaged.connect(_on_damaged) # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: # Swings, weapon slots (1-6 / wheel / Q), grab (E/G/drag) and Esc live in Player.gd. # Number keys are the LOADOUT now, so game modes moved to M and the HUD cycles on H. if event is InputEventKey and event.pressed and not event.echo: match (event as InputEventKey).keycode: KEY_B: _rain(500) KEY_R: _reset() KEY_M: _switch_mode(GameMode.Mode.FIND_MISFILED if _game_mode.mode == GameMode.Mode.FREE_PLAY else GameMode.Mode.FREE_PLAY) KEY_H: if _hud != null: _hud.cycle() KEY_F: if _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 _score = 0 _tally.clear() 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: _hud = Hud.new() add_child(_hud) if _player != null: _player.weapon_changed.connect(_on_weapon_changed) func _on_weapon_changed(w: Weapon) -> void: if _hud != null and w != null: _hud.toast(w.display_name) const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · B rain · R reset" 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 w: Weapon = _player.weapon() if _player != null else null _hud.feed({ "fps": Engine.get_frames_per_second(), "bodies": bodies, "smashed": _smash_count, "score": _score, "tally": _tally, "combo": _juice.combo() if _juice != null else 0, "combo_decay": _juice.combo_decay() if _juice != null else 0.0, "weapon": w.display_name if w != null else "-", "weapon_blurb": w.blurb if w != null else "", "weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "", "slot": _player.slot() if _player != null else 0, "mode_line": _game_mode.hud_line() if _game_mode != null else "", "keys": KEYS_LINE, })