destroyulator/game/scripts/Main.gd
Monster Robot Party e5f4e8c5e4 LANE7: the Gauntlet, four modes, two more provocations, and ambient attrition
THE GAUNTLET (Gauntlet.gd + Cabinet.gd)
Five identical cabinets, each with its alphabetical range on a Label3D. The brief names
a CLIENT, never a cabinet, so working out that HENDERSON lives in F-J is the puzzle —
no waypoint, nothing glows. Destruction is granular on purpose: pull a drawer, it
slides out on a spring and goes live, and the folders inside become individual
smashables. Doing that by hand inside the clock isn't really possible.

The perfect tool: every site hides a SHREDDER that eats a whole open drawer at once,
and the game never says so. Noticing it and working out what it's for IS the level.
Getting it wrong isn't fatal, just slow — the right shape for a secret, since it
rewards the observant without hard-failing everyone else.

Each site nominates its own records area in its spec. Building the bank 3.4 m in front
of wherever the player happened to be standing put it through walls.

MODES (Modes.gd, M cycles)
  CUBICLE HELL       the main loop
  THE GAUNTLET       above
  TOTAL DESTRUCTION  no job, no pay, clear the site, scored on time
  AGAINST THE CLOCK  fixed timer, maximise damage VALUE — the exact inverse of Cubicle
                     Hell, where expensive things become the goal rather than the
                     mistake. Same level, opposite instinct.
Entering a mode arms only what that mode needs; Cubicle Hell and the Gauntlet are
mutually exclusive, because you cannot be doing your job and shredding the Henderson
file at the same time.

TWO MORE PROVOCATIONS (Tasks.gd)
  STAPLES     SPACE lifts a staple, then it must be pulled the way it's bent — and it's
              bent differently each time. Dither and the sheet tears, bundle reprints.
  GUILLOTINE  align to a mark and cut. The guide has a PERMANENT offset and nothing on
              screen admits it. Once you work out the error it's easy, which is the
              joke: you have to learn to distrust the equipment.
Desks now alternate spreadsheet/staples so "a desk" isn't always the same minigame.

AMBIENCE (Ambience.gd) — the attrition you can't play around. The tube above your desk
flickers, and actually flickers the real lights. Fish in the microwave. A phone ringing
four desks away. Reply-all, forty-one recipients, "thanks!". Small periodic ticks with
no interaction at all; it's what makes the room hostile rather than merely beige.

Fixes: the payslip was rendering over the Gauntlet (it belongs to Cubicle Hell, and only
once the shift has actually ended); gauntlet drawer reach was 2.0 m when the true
distance from standing-at-a-cabinet to a bottom drawer is over 2 m.

dev/probe_gauntlet.gd drives a whole run headlessly — mode switch, labels, open every
drawer, shred, win — because that catches the failure that matters: a run you can't
finish because the files never become destroyable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:12:20 +10:00

694 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
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)
# 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
_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 _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(),
"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,
})