destroyulator/game/scripts/Main.gd
Monster Robot Party 51210215e2 LANE4 (Godot): real fractured-chunk destruction + printer boss
Consume Lane 2's pre-fractured props in the Godot game. Smashable.shatter() now prefers
a <name>.fractured.glb sibling: it instances the fractured template and turns each
chunk_00…chunk_NN mesh into its own debris RigidBody3D that inherits the intact body's
velocity + the smash impulse (box collider per chunk, debris group so the HUD count +
Juice keep working), falling back to primitive shards when no fractured sibling exists.
_glb_piece auto-wires the sibling, so every GLB prop upgrades for free.

Smashable gains hits_to_break + is_boss: the office PRINTER is the level boss — it sits on
a desk, soaks 5 hits (rocking with each), then bursts in a bigger juice cloud into its 16
GLB chunks. Six more Lane 2 props (crt-monitor, filing-cabinet, water-cooler, cardboard-box,
turntable, office-desk) are scattered as smashable furniture; the store crate/rack also pick
up real 8-chunk shatter from their mirrored fractured GLBs.

Assets pulled into game/assets/store/ (intact + .fractured.glb): office-printer, office-desk,
crt-monitor, filing-cabinet, water-cooler, cardboard-box, turntable + record/crate/rack
fractured siblings (generated via meshgod's Voronoi fracture CLI).

Verified headless: smoke test clean (0 script errors), printer boss soaks 4 hits then
spawns 16 chunk bodies, a crate spawns 8 — both real GLB chunks, not primitive shards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:27:50 +10:00

390 lines
15 KiB
GDScript

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 <name>.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]