destroyulator/game/scripts/Hud.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

563 lines
17 KiB
GDScript

extends CanvasLayer
class_name Hud
## Four selectable HUD styles over one shared data feed. Cycle with H.
##
## ARCADE ...... big score, combo meter, weapon card. The default: this is a
## score-attack game about a rising combo, so the combo is the loudest
## thing on screen.
## MINIMAL ..... crosshair-adjacent only. For screenshots and for people who want the
## room, not the numbers. Combo appears only while it's alive.
## WORK ORDER .. the joke, played straight: a corporate destruction docket that fills
## in line items as you wreck the place. Same data, funnier framing.
## DEV ......... FPS / body count / weapon internals. What the prototype used to show.
##
## Main pushes a plain Dictionary in via `feed()` once a frame; every style reads the
## same keys, so adding a style never means touching the game code.
##
## Godot 4.7 GDScript 2.0.
enum Style { ARCADE, MINIMAL, WORKORDER, DEV }
const STYLE_NAMES := ["ARCADE", "MINIMAL", "WORK ORDER", "DEV"]
# The task panel, the interaction prompt and the payslip sit OUTSIDE the style system:
# they're the CUBICLE HELL mode's own furniture and have to be visible whatever HUD
# skin is selected, so they get their own always-on layer.
var _work_root: Control
var _work_panel: ColorRect
var _work_title: Label
var _work_instr: Label
var _work_grid: Label
var _work_foot: Label
var _work_bar_bg: ColorRect
var _work_bar: ColorRect
var _prompt: Label
var _slip_root: Control
var _slip: Label
const PINK := Color(1.0, 0.36, 0.62)
const CREAM := Color(0.94, 0.92, 0.86)
const INK := Color(0.11, 0.10, 0.12)
var style: int = Style.ARCADE
var _data: Dictionary = {}
var _roots: Array[Control] = []
# --- arcade widgets
var _a_score: Label
var _a_combo: Label
var _a_combo_bar: ColorRect
var _a_combo_bg: ColorRect
var _a_weapon: Label
var _a_weapon_sub: Label
var _a_mode: Label
var _a_toast: Label
var _toast_left := 0.0
# --- minimal
var _m_combo: Label
var _m_weapon: Label
# --- work order
var _w_lines: Label
var _w_head: Label
# --- dev
var _d_text: Label
func _ready() -> void:
layer = 10
_build_arcade()
_build_minimal()
_build_workorder()
_build_dev()
_apply()
_build_work()
_build_payslip()
func cycle() -> void:
style = (style + 1) % STYLE_NAMES.size()
_apply()
toast("HUD: %s" % STYLE_NAMES[style])
func _apply() -> void:
for i in _roots.size():
_roots[i].visible = (i == style)
## Main calls this every frame. Keys: score, combo, smashed, bodies, fps, weapon,
## weapon_blurb, slot, mode_line, time_left, tally (Dictionary kind->count).
func feed(d: Dictionary) -> void:
_data = d
## A transient centre-screen message (weapon pickup, HUD change, futile-hit hint).
func toast(msg: String) -> void:
if _a_toast != null:
_a_toast.text = msg
_toast_left = 1.7
# ---------------------------------------------------------------- helpers
## A monospace face for anything drawn as a text grid. Godot's fallback font is
## proportional, so column padding with %-9s lands wherever it likes — the spreadsheet
## and the work-order docket both need real fixed pitch to line up.
static func _mono() -> Font:
var f := SystemFont.new()
f.font_names = PackedStringArray(["Menlo", "SF Mono", "Courier New", "monospace"])
return f
func _label(parent: Control, size: int, col: Color, shadow := true, mono := false) -> Label:
var l := Label.new()
if mono:
l.add_theme_font_override("font", _mono())
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
if shadow:
l.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.75))
l.add_theme_constant_override("shadow_offset_x", 2)
l.add_theme_constant_override("shadow_offset_y", 2)
parent.add_child(l)
return l
func _panel(parent: Control, col: Color) -> ColorRect:
var r := ColorRect.new()
r.color = col
parent.add_child(r)
return r
func _root() -> Control:
var c := Control.new()
c.set_anchors_preset(Control.PRESET_FULL_RECT)
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(c)
_roots.append(c)
return c
# ---------------------------------------------------------------- ARCADE
func _build_arcade() -> void:
var r := _root()
_a_score = _label(r, 54, Color.WHITE)
_a_score.anchor_left = 0.5
_a_score.anchor_right = 0.5
_a_score.offset_left = -260
_a_score.offset_right = 260
_a_score.offset_top = 14
_a_score.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_a_combo = _label(r, 26, PINK)
_a_combo.anchor_left = 0.5
_a_combo.anchor_right = 0.5
_a_combo.offset_left = -260
_a_combo.offset_right = 260
_a_combo.offset_top = 74
_a_combo.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_a_combo_bg = _panel(r, Color(1, 1, 1, 0.13))
_a_combo_bg.anchor_left = 0.5
_a_combo_bg.anchor_right = 0.5
_a_combo_bg.offset_left = -130
_a_combo_bg.offset_right = 130
_a_combo_bg.offset_top = 108
_a_combo_bg.offset_bottom = 116
_a_combo_bar = _panel(r, PINK)
_a_combo_bar.anchor_left = 0.5
_a_combo_bar.anchor_right = 0.5
_a_combo_bar.offset_left = -130
_a_combo_bar.offset_right = -130
_a_combo_bar.offset_top = 108
_a_combo_bar.offset_bottom = 116
# weapon card, bottom right
var card := _panel(r, Color(0, 0, 0, 0.42))
card.anchor_left = 1.0
card.anchor_right = 1.0
card.anchor_top = 1.0
card.anchor_bottom = 1.0
card.offset_left = -330
card.offset_right = -18
card.offset_top = -84
card.offset_bottom = -18
_a_weapon = _label(r, 25, Color.WHITE)
_a_weapon.anchor_left = 1.0
_a_weapon.anchor_right = 1.0
_a_weapon.anchor_top = 1.0
_a_weapon.anchor_bottom = 1.0
_a_weapon.offset_left = -318
_a_weapon.offset_right = -26
_a_weapon.offset_top = -78
_a_weapon.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_a_weapon_sub = _label(r, 14, Color(1, 1, 1, 0.62))
_a_weapon_sub.anchor_left = 1.0
_a_weapon_sub.anchor_right = 1.0
_a_weapon_sub.anchor_top = 1.0
_a_weapon_sub.anchor_bottom = 1.0
_a_weapon_sub.offset_left = -318
_a_weapon_sub.offset_right = -26
_a_weapon_sub.offset_top = -46
_a_weapon_sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_a_weapon_sub.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_a_mode = _label(r, 16, Color(1, 1, 1, 0.78))
_a_mode.anchor_top = 1.0
_a_mode.anchor_bottom = 1.0
_a_mode.offset_left = 20
_a_mode.offset_top = -44
_a_toast = _label(r, 22, Color.WHITE)
_a_toast.anchor_left = 0.5
_a_toast.anchor_right = 0.5
_a_toast.anchor_top = 0.5
_a_toast.anchor_bottom = 0.5
_a_toast.offset_left = -300
_a_toast.offset_right = 300
_a_toast.offset_top = 84
_a_toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# ---------------------------------------------------------------- MINIMAL
func _build_minimal() -> void:
var r := _root()
_m_combo = _label(r, 22, PINK)
_m_combo.anchor_left = 0.5
_m_combo.anchor_right = 0.5
_m_combo.anchor_top = 0.5
_m_combo.anchor_bottom = 0.5
_m_combo.offset_left = -160
_m_combo.offset_right = 160
_m_combo.offset_top = 46
_m_combo.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_m_weapon = _label(r, 15, Color(1, 1, 1, 0.5))
_m_weapon.anchor_left = 1.0
_m_weapon.anchor_right = 1.0
_m_weapon.anchor_top = 1.0
_m_weapon.anchor_bottom = 1.0
_m_weapon.offset_left = -260
_m_weapon.offset_right = -20
_m_weapon.offset_top = -36
_m_weapon.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
# ---------------------------------------------------------------- WORK ORDER
func _build_workorder() -> void:
var r := _root()
# tall enough for the header + 7 material rows + the totals block; the docket runs
# off the bottom of a shorter panel once you've broken one of everything
var paper := _panel(r, CREAM)
paper.offset_left = 18
paper.offset_top = 16
paper.offset_right = 372
paper.offset_bottom = 330
var strip := _panel(r, INK)
strip.offset_left = 18
strip.offset_top = 16
strip.offset_right = 372
strip.offset_bottom = 52
_w_head = _label(r, 15, CREAM, false)
_w_head.offset_left = 30
_w_head.offset_top = 24
_w_head.offset_right = 366
_w_lines = _label(r, 14, INK, false, true)
_w_lines.offset_left = 30
_w_lines.offset_top = 62
_w_lines.offset_right = 362
_w_lines.offset_bottom = 322
# ---------------------------------------------------------------- DEV
func _build_dev() -> void:
var r := _root()
_d_text = _label(r, 16, PINK)
_d_text.offset_left = 16
_d_text.offset_top = 12
# ---------------------------------------------------------------- work panel
## The thing you stare at while doing your job. Deliberately drab: a beige box with a
## monospace grid, so that when the vignette goes red around it the contrast lands.
func _build_work() -> void:
_work_root = Control.new()
_work_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_work_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_work_root.visible = false
add_child(_work_root)
_work_panel = _panel(_work_root, Color(0.90, 0.89, 0.84, 0.97))
_work_panel.anchor_left = 0.5
_work_panel.anchor_right = 0.5
_work_panel.anchor_top = 0.5
_work_panel.anchor_bottom = 0.5
_work_panel.offset_left = -300
_work_panel.offset_right = 300
_work_panel.offset_top = -180
_work_panel.offset_bottom = 150
var bar := _panel(_work_root, INK)
bar.anchor_left = 0.5
bar.anchor_right = 0.5
bar.anchor_top = 0.5
bar.anchor_bottom = 0.5
bar.offset_left = -300
bar.offset_right = 300
bar.offset_top = -180
bar.offset_bottom = -146
_work_title = _label(_work_root, 15, CREAM, false)
_work_title.anchor_left = 0.5
_work_title.anchor_right = 0.5
_work_title.anchor_top = 0.5
_work_title.offset_left = -288
_work_title.offset_right = 288
_work_title.offset_top = -173
_work_instr = _label(_work_root, 19, INK, false)
_work_instr.anchor_left = 0.5
_work_instr.anchor_right = 0.5
_work_instr.anchor_top = 0.5
_work_instr.offset_left = -288
_work_instr.offset_right = 288
_work_instr.offset_top = -134
_work_instr.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_work_grid = _label(_work_root, 16, INK, false, true)
_work_grid.anchor_left = 0.5
_work_grid.anchor_right = 0.5
_work_grid.anchor_top = 0.5
_work_grid.offset_left = -288
_work_grid.offset_right = 288
_work_grid.offset_top = -96
_work_bar_bg = _panel(_work_root, Color(0, 0, 0, 0.18))
_work_bar_bg.anchor_left = 0.5
_work_bar_bg.anchor_right = 0.5
_work_bar_bg.anchor_top = 0.5
_work_bar_bg.offset_left = -250
_work_bar_bg.offset_right = 250
_work_bar_bg.offset_top = -30
_work_bar_bg.offset_bottom = -8
_work_bar = _panel(_work_root, Color(0.16, 0.42, 0.22))
_work_bar.anchor_left = 0.5
_work_bar.anchor_right = 0.5
_work_bar.anchor_top = 0.5
_work_bar.offset_left = -250
_work_bar.offset_right = -250
_work_bar.offset_top = -30
_work_bar.offset_bottom = -8
_work_foot = _label(_work_root, 13, Color(0.35, 0.34, 0.32), false)
_work_foot.anchor_left = 0.5
_work_foot.anchor_right = 0.5
_work_foot.anchor_top = 0.5
_work_foot.offset_left = -288
_work_foot.offset_right = 288
_work_foot.offset_top = 122
_work_foot.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_prompt = _label(_work_root, 18, Color.WHITE)
_prompt.anchor_left = 0.5
_prompt.anchor_right = 0.5
_prompt.anchor_top = 0.5
_prompt.anchor_bottom = 0.5
_prompt.offset_left = -240
_prompt.offset_right = 240
_prompt.offset_top = 52
_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
func _build_payslip() -> void:
_slip_root = Control.new()
_slip_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_slip_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_slip_root.visible = false
add_child(_slip_root)
var p := _panel(_slip_root, CREAM)
p.anchor_left = 0.5
p.anchor_right = 0.5
p.anchor_top = 0.5
p.anchor_bottom = 0.5
p.offset_left = -230
p.offset_right = 230
p.offset_top = -200
p.offset_bottom = 200
_slip = _label(_slip_root, 15, INK, false, true)
_slip.anchor_left = 0.5
_slip.anchor_right = 0.5
_slip.anchor_top = 0.5
_slip.offset_left = -206
_slip.offset_right = 206
_slip.offset_top = -178
## Main pushes the task/prompt/payslip state here, separately from `feed`, because it
## has to render regardless of which HUD skin is selected.
func feed_work(task: Dictionary, prompt_text: String, payslip: PackedStringArray) -> void:
if _work_root == null:
return
var busy := not task.is_empty()
var slip := payslip.size() > 0
_slip_root.visible = slip
if slip:
_slip.text = "\n".join(payslip)
_work_root.visible = (busy or prompt_text != "") and not slip
_work_panel.visible = busy
_work_title.visible = busy
_work_instr.visible = busy
_work_grid.visible = busy
_work_foot.visible = busy
_prompt.visible = (not busy) and prompt_text != ""
_prompt.text = prompt_text
var show_bar := busy and task.has("progress")
_work_bar_bg.visible = show_bar
_work_bar.visible = show_bar
if not busy:
return
_work_title.text = String(task.get("title", ""))
_work_instr.text = String(task.get("instruction", ""))
_work_foot.text = String(task.get("footer", ""))
if show_bar:
var f := clampf(float(task["progress"]), 0.0, 1.0)
_work_bar.offset_right = -250.0 + 500.0 * f
if String(task.get("kind", "")) == "spreadsheet":
_work_grid.text = _spreadsheet_grid(task)
elif bool(task.get("jam", false)):
var trays: Array = task.get("trays", [])
var line := " ".join(PackedStringArray(trays.map(func(t): return "[%s]" % t)))
_work_grid.text = "\n TRAYS: " + line
else:
_work_grid.text = ""
## The spreadsheet, drawn as text. The selected cell is bracketed — which is the ONLY
## indication that it has quietly moved, and that is entirely the point.
func _spreadsheet_grid(task: Dictionary) -> String:
var cols: Array = task.get("cols", [])
var rows: int = int(task.get("rows", 0))
var sel: Vector2i = task.get("sel", Vector2i.ZERO)
var typed: String = String(task.get("typed", ""))
var out := PackedStringArray()
var head := " "
for c in cols:
head += "%-9s" % String(c)
out.append(head)
for r in range(rows):
var line := "%-5s" % str(r + 1)
for c in range(cols.size()):
if Vector2i(c, r) == sel:
line += "%-9s" % ("[" + (typed if typed != "" else "_") + "]")
else:
line += "%-9s" % "."
out.append(line)
return "\n".join(out)
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
if _data.is_empty():
return
_toast_left = maxf(0.0, _toast_left - dt)
match style:
Style.ARCADE:
_tick_arcade()
Style.MINIMAL:
_tick_minimal()
Style.WORKORDER:
_tick_workorder()
Style.DEV:
_tick_dev()
func _g(k: String, dflt = 0):
return _data.get(k, dflt)
func _tick_arcade() -> void:
# in CUBICLE HELL the headline number is your payslip, not an arcade score —
# net pay is the thing you're actually playing against
var state := String(_g("rage_state", ""))
if state != "":
var net := int(_g("net"))
_a_score.text = ("$%s" % _comma(net)) if net >= 0 else ("-$%s" % _comma(-net))
_a_score.add_theme_color_override("font_color",
Color(1, 0.42, 0.38) if net < 0 else Color.WHITE)
else:
_a_score.text = "%s" % _comma(int(_g("score")))
var combo := int(_g("combo"))
if combo > 1:
_a_combo.text = "COMBO x%d" % combo
_a_combo.modulate.a = 1.0
else:
_a_combo.text = ""
var decay := float(_g("combo_decay", 0.0)) # 1 = fresh, 0 = about to drop
_a_combo_bg.visible = combo > 1
_a_combo_bar.visible = combo > 1
_a_combo_bar.offset_right = -130.0 + 260.0 * clampf(decay, 0.0, 1.0)
_a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", ""))]
_a_weapon_sub.text = str(_g("weapon_blurb", ""))
var mode := str(_g("mode_line", ""))
var status := str(_g("rage_status", ""))
if status != "":
mode += "\n" + status
_a_mode.text = mode
_a_toast.modulate.a = clampf(_toast_left / 0.5, 0.0, 1.0)
func _tick_minimal() -> void:
var combo := int(_g("combo"))
_m_combo.text = ("x%d" % combo) if combo > 1 else ""
_m_weapon.text = str(_g("weapon", ""))
func _tick_workorder() -> void:
_w_head.text = "MONSTER ROBOT PARTY · WORK ORDER #%04d" % int(_g("order_no", 4471))
var tally: Dictionary = _g("tally", {})
var lines := PackedStringArray()
lines.append("SITE: LEVEL 01 — BRANCH OFFICE, 2ND FLOOR")
lines.append("TOOL: %s" % str(_g("weapon", "")))
lines.append("")
lines.append("ITEM QTY")
lines.append("---------------------------- --")
var order := ["wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper"]
var any := false
for k in order:
var n := int(tally.get(k, 0))
if n <= 0:
continue
any = true
lines.append("%-27s %3d" % [_item_name(k), n])
if not any:
lines.append("(nothing logged yet)")
lines.append("")
lines.append("TOTAL UNITS DESTROYED %6d" % int(_g("smashed")))
lines.append("ASSESSED VALUE %6s" % _comma(int(_g("score"))))
var combo := int(_g("combo"))
if combo > 1:
lines.append("EFFICIENCY BONUS x%d" % combo)
_w_lines.text = "\n".join(lines)
func _item_name(kind: String) -> String:
match kind:
"wood": return "Shelving, timber"
"cardboard": return "Cartons, corrugated"
"vinyl": return "Stock, 12in vinyl"
"glass": return "Glazing / CRT"
"steel": return "Fixtures, steel"
"plastic": return "Fittings, moulded"
"paper": return "Paperwork"
return kind
func _tick_dev() -> void:
_d_text.text = "FPS %d bodies %d smashed %d score %d combo x%d\n%s\n%s\n%s" % [
int(_g("fps")), int(_g("bodies")), int(_g("smashed")),
int(_g("score")), int(_g("combo")),
"weapon: %s (slot %d) %s" % [str(_g("weapon", "-")), int(_g("slot")) + 1,
str(_g("weapon_stats", ""))],
str(_g("mode_line", "")),
str(_g("keys", ""))]
func _comma(n: int) -> String:
var s := str(absi(n))
var out := ""
var c := 0
for i in range(s.length() - 1, -1, -1):
out = s[i] + out
c += 1
if c % 3 == 0 and i > 0:
out = "," + out
return ("-" if n < 0 else "") + out