destroyulator/game/scripts/Main.gd
Monster Robot Party 7c282c298e LANE7: LEVEL 0 — the Backrooms, generated walls, and the dust mechanic
"stuff is invisible have to release dust or gas or something to coat items to see them
to destroy them? and you inhale some of it too"

THE SITE (Levels.backrooms)
Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows
and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec
can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m
lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos
rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big
offset slabs in an open floor, and a proper maze would be legible. Legible is wrong.

THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point.
Everything smashable here is invisible but SOLID; you find things by walking into them.
RMB with the extinguisher throws a cloud of powder. Anything it touches is coated,
glows faintly out of all that yellow for 7 seconds, then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is
on YOUR side of the glass. So: spray more to see individual objects, and see the room
less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course
it does. Intended rhythm is spray -> switch to something heavy -> break it before the
coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level
IS a search.

Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very
thing it was revealing (150 finer, shorter-lived particles now), and a coated object has
to POP out of a room that is entirely one colour, so the coat is near-white and faintly
self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is
just a walk.

Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot
4, so the validity check has to come BEFORE the cast — destroying anything mid-level was
spewing "trying to cast a freed object" every frame.

dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic
does NOT leak into the other four sites when you leave.

All five sites still settle to 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:05:56 +10:00

706 lines
26 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
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)
# 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
_dust.setup(_player, _cam, _rage, _hud)
_player.dust = _dust
_dust.adopt(get_tree(), bool(_plan.spec.get("invisible", false)))
_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)
# --- 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
## 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) -> 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
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands 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 _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)))
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:
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)
if _dust != null:
var blind := bool(_plan.spec.get("invisible", false))
_dust.adopt(get_tree(), blind)
if blind:
_player.select(5) # you will need the extinguisher
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
# 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 ""),
"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,
})