- game/assets/store/{rack,crate,record}.glb from the 3D-STORE library
- _glb_piece(): instances a GLB as a Smashable's visual, auto-fits a box
collider to the mesh AABB, and drops it flush to the floor (no hardcoded dims)
- _build_rack now assembles two smashable racks + crates of records on the floor
- cascade preserved: smashing a crate spills the records stacked on it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
8.8 KiB
GDScript
250 lines
8.8 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
|
|
|
|
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)
|
|
_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 exists on the next line; we reuse it for HUD/debug.
|
|
var 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"
|
|
|
|
func _build_rack(origin: Vector3) -> void:
|
|
# 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
|
|
for i in range(3):
|
|
var jitter := Vector2(randf_range(-0.06, 0.06), randf_range(-0.06, 0.06))
|
|
var rec := _glb_piece(RECORD_GLB, "vinyl", xz + jitter, false, crate_top + float(i) * 0.03)
|
|
(crate["piece"] as Smashable).supports.append(rec["piece"])
|
|
|
|
## 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
|
|
var packed := load(path) as PackedScene
|
|
var vis: Node3D = packed.instantiate()
|
|
s.add_child(vis)
|
|
_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}
|
|
|
|
## 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)
|
|
|
|
# ---------------------------------------------------------------- input
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
# Melee (L-click punch / R-click kick) and Esc live in Player.gd. Here we keep the
|
|
# debug keys. Rain moved off S -> B, because the player now uses S to walk backward.
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
if event.keycode == KEY_B:
|
|
_rain(500)
|
|
elif event.keycode == KEY_R:
|
|
_reset()
|
|
|
|
# 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)
|
|
|
|
# ---------------------------------------------------------------- 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 ""
|
|
_hud.text = "FPS %d bodies %d smashed %d%s\nWASD move Space jump L-click punch R-click kick Esc release B: rain 500 R: reset" % [
|
|
Engine.get_frames_per_second(), bodies, _smash_count, combo_str]
|