destroyulator/game/scripts/Main.gd
Monster Robot Party c96c2f1a67 LANE8: the greengrocer — soft stock, stacks that collapse, and litres
The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".

Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
  * produce has a low brittle_speed, so anything heavy landing on it squashes it — which
    means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
    Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
    42 litres of juice out of the collapse.
  * puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
    sliding rather than as being slowed down. The more mess you make, the less the floor
    cooperates.

Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.

Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.

REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.

tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.

dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:38:35 +10:00

795 lines
30 KiB
GDScript

extends Node3D
## Destroyulator — LEVEL 01, the office.
##
## Main is the glue: it builds the level shell (Office), spawns everything SMASHABLE into
## it, owns score/tally, and routes each Smashable's three outcomes (break / dent /
## futile) to Juice and the HUD.
##
## Two things it still proves, from the prototype days:
## THE DAY-0 GATE: press B to rain 500 rigid bodies and watch the FPS hold.
## THE CASCADE: smash a frozen desk -> the monitor on it falls; smash a crate ->
## the records spill -> a record cracks a beat later when it lands.
##
## Controls: see KEYS_LINE below, or the Dev HUD (H cycles HUD styles).
@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
var _plan: Floorplan # the level shell: walls, fittings, lighting — see Levels.gd
var _level_id := "scranton"
var _rage: Rage # the tilt meter — see Rage.gd
var _rage_hud: RageOverlay
var _tasks: Tasks
var _shift: CubicleHell
var _env_node: WorldEnvironment
var _ambience: Ambience
var _modes: Modes
var _gauntlet: Gauntlet
var _dust: Dust
var _backrooms: Backrooms
var _splat: Splat
func _ready() -> void:
randomize()
_setup_world()
_juice = Juice.new()
add_child(_juice)
_juice.setup(_cam) # _setup_world() set _cam to the player's camera
_populate()
_spawn_guard = SPAWN_GUARD_TIME
# --- CUBICLE HELL: the tilt meter, the job, and the payslip that scores both ---
_rage = Rage.new()
add_child(_rage)
_rage_hud = RageOverlay.new()
_rage_hud.setup(_rage)
add_child(_rage_hud)
_tasks = Tasks.new()
add_child(_tasks)
_tasks.setup(_player, _rage)
_register_stations()
_player.tasks = _tasks
_shift = CubicleHell.new()
add_child(_shift)
_shift.setup(_rage, _tasks)
_shift.begin()
# the provocations you can't play around: flickering tube, fish in the microwave
_ambience = Ambience.new()
add_child(_ambience)
_modes = Modes.new()
add_child(_modes)
_modes.changed.connect(_on_mode_changed)
_gauntlet = Gauntlet.new()
add_child(_gauntlet)
_dust = Dust.new()
add_child(_dust)
_backrooms = Backrooms.new()
add_child(_backrooms)
_splat = Splat.new()
add_child(_splat)
# rules layer: the record modes still exist behind M
_game_mode = GameMode.new()
add_child(_game_mode)
_game_mode.setup(_player.grab)
_game_mode.begin_free_play(_records)
_setup_hud()
_ambience.setup(_rage, _hud)
_ambience.adopt_lights(_plan)
_gauntlet.setup(_player, _hud)
_player.gauntlet = _gauntlet
_splat.setup(self, _player)
_player.splat = _splat
_dust.setup(_player, _cam, _rage, _hud)
_player.dust = _dust
_backrooms.setup(_player, _cam, _hud, _rage, _dust, self)
_dust.heard_by = _backrooms
_apply_site_horror()
_modes.begin(get_tree().get_nodes_in_group("smashable").size())
## Every desk is a spreadsheet you have to fill in; the copier is the copier.
func _register_stations() -> void:
var i := 1
for t in _plan.desk_spots():
# alternating, so walking to "a desk" isn't always the same minigame
if i % 2 == 1:
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
else:
_tasks.register(Tasks.Kind.STAPLES, (t as Transform3D).origin,
"de-staple the bundle on desk %d" % i)
i += 1
if _plan.has_copier():
_tasks.register(Tasks.Kind.COPIER, _plan.copier_spot().origin,
"use the photocopier")
# a station wherever the site put a guillotine
for e in _plan.smashables():
var d: Dictionary = e
if String(d.get("glb", "")) == "guillotine":
var a: Vector2 = d["at"]
_tasks.register(Tasks.Kind.GUILLOTINE, Vector3(a.x, 0.9, a.y),
"trim the ream")
# ---------------------------------------------------------------- world / camera
func _setup_world() -> void:
# The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting.
# It owns the environment too, because interior lighting and the ambient/tonemap
# settings are one decision, not two.
_plan = Floorplan.new()
add_child(_plan)
_plan.build(Levels.get_level(_level_id))
_env_node = WorldEnvironment.new()
var env := Environment.new()
_plan.configure_environment(env)
_env_node.environment = env
add_child(_env_node)
# First-person player. add_child() runs its _ready() synchronously, so player.camera
# and player.grab exist on the next line; we reuse them.
_player = Player.new()
add_child(_player)
_player.global_position = _plan.spawn_point()
_player.rotation.y = _plan.spawn_yaw()
_cam = _player.camera
_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"
const CHAIR_GLB := "res://assets/store/office-chair.glb"
const P := "res://assets/store/"
## Mass by material, so a thrown record can't tip a filing cabinet but a sledgehammer
## still can. Everything defaulted to 1 kg before, which is why the level fell over.
const MASSES := {
"paper": 0.3, "cardboard": 1.2, "plastic": 6.0, "vinyl": 0.6,
"wood": 18.0, "glass": 12.0, "steel": 40.0,
}
# 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
## Fill the office with the SMASHABLE half of the level. The Office class owns the shell
## and the static fittings; everything spawned here is a rigid body you can wreck.
func _populate() -> void:
_records.clear() # stale refs from the freed world; repopulated by _place_record
# --- the bullpen: a desk at every cluster spot, each with a monitor on it ---
var spots := _plan.desk_spots()
for i in spots.size():
var t: Transform3D = spots[i]
var xz := Vector2(t.origin.x, t.origin.z)
var desk := _glb_piece(DESK_GLB, "wood", xz, true, 0.0, t.basis.get_euler().y)
var top: float = desk["top"]
var crt := _glb_piece(CRT_GLB, "glass", xz + Vector2(0.0, 0.10), false, top)
(desk["piece"] as Smashable).supports.append(crt["piece"])
# Chair goes behind the desk, offset by the desk's OWN measured depth. Office.gd
# used to guess a constant here and kept putting chairs inside desks (or, once
# pushed out far enough to clear them, inside each other across the aisle).
var depth: float = (desk["aabb"] as AABB).size.z
var back := signf(t.origin.z - _plan.desk_cluster(i).y)
if is_zero_approx(back):
back = 1.0
_glb_piece(CHAIR_GLB, "plastic", xz + Vector2(0.0, back * (depth * 0.5 + 0.42)),
false, 0.0, t.basis.get_euler().y)
# --- the copier alcove: the PRINTER is the level boss, on its own desk ---
if _plan.has_copier():
var cs := _plan.copier_spot()
var cxz := Vector2(cs.origin.x, cs.origin.z)
var cdesk := _glb_piece(DESK_GLB, "wood", cxz, true, 0.0, cs.basis.get_euler().y)
var printer := _glb_piece(PRINTER_GLB, "steel", cxz, false, cdesk["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: hits rock it but don't shove it off the desk
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
# --- everything else this site contains, straight from its spec ---
for e in _plan.smashables():
var d: Dictionary = e
_glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
d["at"], bool(d.get("frozen", false)), float(d.get("sit_on", 0.0)),
float(d.get("yaw", 0.0)))
# --- crates of records, where the site has any. Records stand side by side ON the
# --- crate lid, offset by more than a sleeve is thick: they used to be stacked 3 cm
# --- apart vertically while being 30 cm TALL, so Jolt resolved 27 cm of
# --- interpenetration explosively on frame one.
for spot in _plan.spec.get("records", []):
var crate := _glb_piece(CRATE_GLB, "wood", spot, false)
var crate_top: float = crate["top"]
for i in range(3):
var rec := _place_record(spot + Vector2(randf_range(-0.02, 0.02),
(float(i) - 1.0) * 0.045), crate_top)
(crate["piece"] as Smashable).supports.append(rec)
# --- stacked produce: pyramids that collapse honestly ---
for st in _plan.spec.get("stacks", []):
_build_stack(st)
# --- chairs: the best thing in an office to send across the room ---
for t in _plan.chair_spots():
_glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0,
t.basis.get_euler().y)
print("[art] records skinned with sleeve art: ", _records_skinned)
## 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.name = "record"
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
## A pyramid of produce, built bottom-up and left to settle.
##
## Nothing here is glued or frozen: it stands because it's stacked, which means pulling
## one out of the bottom row does exactly what you'd hope. Rows are inset and the whole
## thing spawns a few millimetres apart so the spawn guard has nothing to complain about.
func _build_stack(st: Dictionary) -> void:
var at: Vector3 = st["at"]
var item := String(st["item"])
var rows := int(st.get("rows", 4))
var glb := P + "produce-" + item + ".glb"
if not ResourceLoader.exists(glb):
return
# radius per item, so the lattice spacing matches what's actually being stacked
var radii := {"apple": 0.041, "orange": 0.038, "tomato": 0.034,
"melon": 0.150, "cabbage": 0.084}
var r: float = radii.get(item, 0.040)
var step: float = r * 2.06 # a whisker of air between neighbours
var lift: float = r * 1.72 # rows nest into the gaps below
for row in range(rows):
var n := rows - row
var y: float = at.y + r + row * lift
for i in range(n):
for j in range(n):
var x: float = at.x + (float(i) - (n - 1) * 0.5) * step
var z: float = at.z + (float(j) - (n - 1) * 0.5) * step
var s := _glb_piece(glb, "produce", Vector2(x, z), false, 0.0,
randf_range(0.0, TAU), true)
var body := s["piece"] as Smashable
body.global_position = Vector3(x, y, z)
body.set_meta("spawn_pos", body.global_position)
body.mass = maxf(r * r * r * 900.0, 0.08)
## 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,
yaw := 0.0, centred := false) -> Dictionary:
var s := Smashable.new()
s.kind = kind
s.start_frozen = frozen
s.mass = float(MASSES.get(kind, 5.0))
s.name = path.get_file().get_basename() # so physics diagnostics name the culprit
# 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
# AABB is measured BEFORE yaw so the collider stays axis-aligned in body space; the
# body is then rotated as a whole, which keeps box/mesh in agreement.
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.rotation.y = yaw
# Round produce is authored CENTRED so it rolls properly, so it must not be dropped
# by its AABB bottom like everything else — _build_stack places it directly.
if centred:
s.global_position = Vector3(xz.x, sit_on, xz.y)
else:
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # bottom on sit_on
s.set_meta("spawn_pos", s.global_position) # for the spawn-guard warning
_wire(s)
return {"piece": s, "top": sit_on + ab.size.y, "aabb": ab}
## 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
# ---------------------------------------------------------------- spawn guard
## A prop that spawns even slightly inside another collider gets depenetrated by Jolt,
## and depenetration is not gentle: a chair overlapping a table left the building at
## 450 m/s on frame one. Placing 30-odd props by formula means this WILL happen again
## the next time a room is re-laid-out, so it's handled systemically rather than by
## nudging coordinates until the symptom goes away.
##
## For the first fraction of a second, dynamic bodies are speed-limited — enough for a
## real overlap to push apart calmly, far too little to launch anything. Each offender
## is named once in a warning, so the underlying overlap stays visible and fixable
## instead of being silently papered over.
const SPAWN_GUARD_TIME := 0.75
const SPAWN_MAX_SPEED := 2.5
const SPAWN_MAX_SPIN := 10.0
var _spawn_guard := 0.0
var _guard_warned := {}
func _physics_process(dt: float) -> void:
if _spawn_guard <= 0.0:
return
_spawn_guard -= dt
for n in get_tree().get_nodes_in_group("smashable"):
var b := n as RigidBody3D
if b == null or b.freeze:
continue
var v := b.linear_velocity.length()
if v > SPAWN_MAX_SPEED:
if not _guard_warned.has(b):
_guard_warned[b] = true
# report where it was PLACED, not where it is now — at 500 m/s a body is
# already 8 m away by the time this runs, which points at nothing useful
var at = b.get_meta("spawn_pos", b.global_position)
push_warning("[spawn] %s (%s) placed overlapping something at %s (%.0f m/s) — clamped" % [
b.name, (b as Smashable).kind if b is Smashable else "?",
(at as Vector3).snappedf(0.01), v])
b.linear_velocity = b.linear_velocity.normalized() * SPAWN_MAX_SPEED
if b.angular_velocity.length() > SPAWN_MAX_SPIN:
b.angular_velocity = b.angular_velocity.normalized() * SPAWN_MAX_SPIN
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_smash_count += 1
_tally[kind] = int(_tally.get(kind, 0)) + 1
if kind == "produce":
# Splat owns this one: pulp, a puddle that stays, a squelch, and litres. Juice's
# shard-and-crack voice is exactly the wrong sound for a tomato.
if _splat != null:
_splat.burst(at, _last_produce, self)
if _juice != null:
_juice.add_trauma(0.10)
elif _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))
# Rage drains a FLAT amount per object; the payslip bills each object at its own
# value. That gap is the whole decision during a meltdown — break a lot of cheap
# things fast, or take one expensive swing and pay for it.
if _rage != null:
_rage.on_smashed()
if _shift != null:
_shift.on_smashed(int(POINTS.get(kind, 15)))
if _modes != null:
_modes.on_smashed(int(POINTS.get(kind, 15)))
# LEVEL 0: the entity hunts sound, and breaking things is the objective. Playing well
# is what feeds it.
if _backrooms != null:
_backrooms.on_noise(at, 1.0)
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.
var _last_produce := "generic"
func _wire(s: Smashable) -> void:
if s.kind == "produce":
# `smashed` only carries the MATERIAL, and every fruit is "produce". Stash the
# specific item off the node name so Splat can pick the right colour and yield.
var item := Splat.kind_of(s)
s.smashed.connect(func(_k, _a, _n): _last_produce = item, CONNECT_DEFERRED)
s.smashed.connect(func(_k, _a, _n): _last_produce = item)
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:
if _backrooms != null and _backrooms.dying:
_backrooms.revive()
_reset()
KEY_M:
if _modes != null:
_modes.cycle()
KEY_G:
if _modes != null and _modes.mode == Modes.M.GAUNTLET:
_start_gauntlet()
KEY_H:
if _hud != null:
_hud.cycle()
KEY_L:
_load_level(Levels.next_id(_level_id))
KEY_F:
if _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
## Swap sites without restarting: tear down the shell and everything in it, build the
## new spec, and re-arm the shift. The player is moved to the new spawn because the old
## one is very likely now inside a wall.
func _load_level(id: String) -> void:
_level_id = id
for c in _world.get_children():
c.queue_free()
_plan.queue_free()
if _env_node != null:
_env_node.queue_free()
await get_tree().process_frame
_plan = Floorplan.new()
add_child(_plan)
_plan.build(Levels.get_level(_level_id))
_env_node = WorldEnvironment.new()
var env := Environment.new()
_plan.configure_environment(env)
_env_node.environment = env
add_child(_env_node)
_player.global_position = _plan.spawn_point()
_player.rotation.y = _plan.spawn_yaw()
_player.velocity = Vector3.ZERO
_smash_count = 0
_score = 0
_tally.clear()
_populate()
_spawn_guard = SPAWN_GUARD_TIME
_guard_warned.clear()
_tasks.clear_stations()
_register_stations()
if _ambience != null:
_ambience.adopt_lights(_plan)
_apply_site_horror()
if _game_mode != null:
_game_mode.begin_free_play(_records)
if _shift != null:
_shift.begin()
if _hud != null:
_hud.toast("%s%s" % [_plan.level_name(), _plan.subtitle()])
## Switching mode re-arms whatever that mode needs. Cubicle Hell and the Gauntlet are
## mutually exclusive — you cannot be doing your job and shredding the Henderson file at
## the same time — so entering one shuts the other down.
func _on_mode_changed(m: int) -> void:
var hell := m == Modes.M.CUBICLE_HELL
_shift.running = hell
_ambience.running = hell
_tasks.clear_stations()
if hell:
_register_stations()
_shift.begin()
_gauntlet.running = false
if m == Modes.M.GAUNTLET:
_start_gauntlet()
_modes.begin(get_tree().get_nodes_in_group("smashable").size())
if _hud != null:
_hud.toast(_modes.name_of())
## Build the cabinet bank in this site's records area and stand the player at the row.
## Placing it relative to wherever the player happened to be standing put it through
## walls and behind them; every level now nominates the spot.
func _start_gauntlet() -> void:
var g: Dictionary = _plan.spec.get("gauntlet", {})
var origin: Vector3 = g.get("at", _player.global_position
+ (-_player.global_transform.basis.z) * 3.4)
var yaw := float(g.get("yaw", _player.rotation.y + PI))
_gauntlet.begin(_world, origin, yaw, _wire, _find_shredder(g))
if g.has("stand"):
_player.global_position = g["stand"]
_player.velocity = Vector3.ZERO
_player.rotation.y = yaw + PI # face the row
## The level's perfect tool. Deliberately NOT signposted — see Gauntlet.gd.
func _find_shredder(g: Dictionary) -> Node3D:
for n in get_tree().get_nodes_in_group("smashable"):
if (n as Node).name.begins_with("shredder"):
return n as Node3D
# the site doesn't already have one, so put it where the spec says — in the room,
# in plain sight, and completely unremarked upon
var at: Vector3 = g.get("shredder", Vector3(
_player.global_position.x + 2.2, 0.0, _player.global_position.z - 2.6))
var res := _glb_piece(P + "shredder.glb", "plastic", Vector2(at.x, at.z), false)
return res["piece"] as Node3D
## Turn the invisibility and the horror layer on or off for whatever site we just built.
## Both are LEVEL 0 only, and both have to be explicitly torn down on the way out or they
## leak into an ordinary Tuesday at the Scranton branch.
func _apply_site_horror() -> void:
if _splat != null:
_splat.reset()
var blind := bool(_plan.spec.get("invisible", false))
if _dust != null:
_dust.adopt(get_tree(), blind)
if _backrooms != null:
if blind:
_backrooms.enter(float((_plan.spec["bounds"] as Dictionary)["x1"]))
else:
_backrooms.leave()
if blind and _player != null:
_player.select(5) # you will need the extinguisher
# 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
_populate()
_spawn_guard = SPAWN_GUARD_TIME
_guard_warned.clear()
if _shift != null:
_shift.begin()
# 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)
## Whichever mode is running gets the status line.
func _mode_line() -> String:
if _modes == null:
return ""
match _modes.mode:
Modes.M.CUBICLE_HELL:
return _shift.hud_line() if _shift != null else ""
Modes.M.GAUNTLET:
return _gauntlet.hud_line() if _gauntlet != null else ""
return _modes.hud_line()
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 · L site · 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
if _tasks != null:
var slip := PackedStringArray()
if _modes != null and _modes.mode == Modes.M.CUBICLE_HELL \
and _shift != null and not _shift.running and _shift.time_left <= 0.0:
slip = _shift.payslip()
_hud.feed_work(_tasks.panel_state(), _tasks.prompt(), slip)
_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": _mode_line()
+ ("\n" + _dust.hud_line() if _dust != null and _dust.active else "")
+ ("\n" + _backrooms.hud_line() if _backrooms != null
and _backrooms.active else "")
+ ("\n" + _splat.hud_line() if _splat != null
and _splat.hud_line() != "" else ""),
"mode_name": _modes.name_of() if _modes != null else "",
"headline": _modes.headline() if _modes != null else "",
"keys": KEYS_LINE,
"rage": _rage.value if _rage != null else 0.0,
"rage_state": _rage.state_name() if _rage != null else "",
"rage_status": _rage.status_line() if _rage != null else "",
"pay": _shift.wages() if _shift != null else 0,
"damages": _shift.damages if _shift != null else 0,
"net": _shift.net() if _shift != null else 0,
})