destroyulator/game/scripts/Hud.gd
Monster Robot Party d17e2314aa LANE6: LEVEL 01 is an office — Dunder Mifflin floor plan, and a spawn guard
The game had no room. Props sat on a grey disc in a black void, which quietly broke
the premise: a game about wrecking your workplace needs a workplace.

Office.gd — LEVEL 01
- Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk
  pairs behind cubicle partitions, reception, glass-walled manager's office, conference
  room, break room with vending machines, copier alcove, warehouse roller door.
- Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources
  (the old scene lit an interior with one outdoor directional lamp, which is exactly
  why it read as "props on a plane"), window wall with blinds, magnolia and carpet.
- Office owns the shell + static fittings; Main._populate() owns everything smashable
  and asks Office where things go. Walls/desks/counters are boxes because they ARE
  boxes; chairs, vending machines, microwave and plant come from a new Blender
  generator (tools/gen_office_props.py) because a box reads as wrong for those.

The level no longer falls over on its own
- Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved
  27 cm of interpenetration explosively on frame one and shoved the furniture over
  before the player touched anything. They now stand side by side.
- Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a
  filing cabinet.
- Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and
  depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are
  speed-limited for 0.75 s, and each offender is named once with the position it was
  PLACED at, so the cause stays visible instead of being papered over. It then found
  the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting
  back-to-back with 3 cm to spare. Rows are now 4.8 m apart.
- dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now
  reads 0.00 m/s with zero spawn warnings.

Two real melee bugs found while testing the level
- The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's
  reach fell in front of it and was missed — you could stand against the printer with
  a sledgehammer and swing straight through it. Now a capsule swept from the camera.
- Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even
  standing over it and short-reach weapons could never touch anything on the ground.
  Swings now originate at hand height.

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

348 lines
10 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"]
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()
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
func _label(parent: Control, size: int, col: Color, shadow := true) -> Label:
var l := Label.new()
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)
_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
# ---------------------------------------------------------------- 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:
_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", ""))
_a_mode.text = str(_g("mode_line", ""))
_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