destroyulator/game/scripts/Main.gd
Monster Robot Party dd905d908d LANE7: CUBICLE HELL — the tilt loop, the job, and the payslip
The game stopped being a smash sandbox. You work a shift, the work provokes you, a
meter fills, you snap, and you cannot go back to your desk until you have broken
enough things. Then you sit back down and try to earn the rest of your day.

The score is your payslip: WAGES (tasks completed) - DAMAGES (everything you broke).
The load-bearing detail is that rage drains a FLAT amount per object destroyed while
each object bills at its OWN value — so during a meltdown the play is to wreck a lot
of cheap rubbish fast rather than put the sledgehammer through a filing cabinet. You
don't choose whether you lose your temper, you choose how much it costs. FURY (objects
broken while melting down) is tracked separately and deliberately isn't money, so
"I ended the shift owing them $2,400" stays a brag.

Rage.gd — WORKING -> MELTDOWN -> COOLDOWN. During a meltdown the clock STALLS if you
stop destroying; you must actually be breaking things.

RageOverlay.gd — the blood vessel. A vignette that closes in with rage and jumps
inward on every heartbeat, procedural veins that creep further into frame the angrier
you get, and a synthesized lub-dub going 62 -> 172 BPM. Zero assets: the shader is
built in code, the veins are draw_polyline from a seeded RNG, the heartbeat is a
generated WAV. Tops out as a heavy frame rather than a red screen — you have to be
able to see what you're about to destroy.

Tasks.gd — stations you walk to and are LOCKED IN PLACE at, because you're at your
desk and that's the joke. Two so far:
  SPREADSHEET  the selected cell silently drifts one cell partway through entry, with
               no sound and no animation. Commit without noticing and the figure lands
               in the wrong cell and you start again. This is the whole thesis of the
               game in about forty lines.
  PHOTOCOPIER  jams, and wants the right tray out of 1, 2, 2A, 3, 3B.
Snapping ejects you from the station at no penalty — you didn't choose to walk away.

CubicleHell.gd — 5-minute shift, wages vs damages, meltdown count, end-of-shift
payslip with a verdict line.

Also: HUD gets a monospace face (Godot's fallback is proportional, so the spreadsheet
grid and the work-order docket never lined up), and the arcade HUD's headline becomes
net pay, red when negative.

LANES/LANE7-cubicle-hell.md captures the rest of the design: more provocations, the
GAUNTLET mode (find which of many cabinets, pull drawers, destroy files individually,
and work out which tool the level wants without being told), the other three
workplaces, and why doratracyer/floor_plan can't help — it's OpenSCAD SVG->STL for
3D printing, no generation, no room polygons, GPL-3.0. Office.gd is already most of
a parametric floor-plan builder; lifting its numbers into a data spec is the real path.

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

557 lines
22 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 _office: Office # the level shell: walls, fittings, lighting
var _rage: Rage # the tilt meter — see Rage.gd
var _rage_hud: RageOverlay
var _tasks: Tasks
var _shift: CubicleHell
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()
# 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()
## 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 _office.desk_spots():
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
i += 1
_tasks.register(Tasks.Kind.COPIER, _office.copier_spot().origin,
"use the photocopier")
# ---------------------------------------------------------------- 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.
_office = Office.new()
add_child(_office)
_office.build()
var we := WorldEnvironment.new()
var env := Environment.new()
_office.configure_environment(env)
we.environment = env
add_child(we)
# 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 = _office.spawn_point()
_player.rotation.y = _office.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"
## 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 := _office.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 - _office.CLUSTERS[i / 2].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 ---
var cs := _office.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, so hits rock it but don't shove it off the desk
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
# --- filing cabinets down the wall by the copier, water coolers, cartons ---
for z in [-1.6, -0.9, -0.2]:
_glb_piece(CABINET_GLB, "steel", Vector2(4.5, z), false)
_glb_piece(COOLER_GLB, "steel", Vector2(-6.2, 3.4), false)
_glb_piece(COOLER_GLB, "steel", Vector2(5.9, -0.6), false)
# Cartons go on OPEN floor only. Tucking them under desks or against the conference
# table spawned them inside those colliders, and Jolt resolved the overlap by firing
# them across the room on frame one.
for c in [Vector2(-2.3, 5.2), Vector2(1.6, 5.4), Vector2(2.1, 4.7),
Vector2(6.4, -3.3), Vector2(7.3, -3.8), Vector2(-9.7, 1.2)]:
_glb_piece(BOX_GLB, "cardboard", c, false)
# --- the warehouse corner: shelving, crates, and somebody's record collection.
# --- This is where the grab/slide/throw ritual still lives.
_glb_piece(RACK_GLB, "wood", Vector2(9.4, -6.6), true)
_glb_piece(RACK_GLB, "wood", Vector2(9.4, -4.3), true)
_glb_piece(TURNTABLE_GLB, "wood", Vector2(6.9, -5.9), false)
for spot in [Vector2(6.2, -6.6), Vector2(7.0, -6.9), Vector2(7.8, -6.6)]:
var crate := _glb_piece(CRATE_GLB, "wood", spot, false)
var crate_top: float = crate["top"]
# Records stand side by side ON the crate lid, offset along Z 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 and shoved the surrounding furniture over before you touched anything.
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 _office.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 placed overlapping something at %s (%.0f m/s) — clamped" % [
b.name, (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 _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:
_switch_mode(GameMode.Mode.FIND_MISFILED
if _game_mode.mode == GameMode.Mode.FREE_PLAY
else GameMode.Mode.FREE_PLAY)
KEY_H:
if _hud != null:
_hud.cycle()
KEY_F:
if _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
func _rain(n: int) -> void:
for i in n:
var b := RigidBody3D.new()
b.add_to_group("debris")
var mesh := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(0.12, 0.12, 0.12)
mesh.mesh = bm
b.add_child(mesh)
var col := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = bm.size
col.shape = shape
b.add_child(col)
_world.add_child(b)
b.global_position = Vector3(randf_range(-2.2, 2.2), randf_range(3.5, 8.0), randf_range(-2.2, 2.2))
func _reset() -> void:
for c in _world.get_children():
c.queue_free()
_smash_count = 0
_score = 0
_tally.clear()
await get_tree().process_frame
_populate()
# 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)
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 · 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 _shift != null and not _shift.running:
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": _shift.hud_line() if _shift != null else (
_game_mode.hud_line() if _game_mode != 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,
})