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

620 lines
19 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
match String(task.get("kind", "")):
"spreadsheet":
_work_grid.text = _spreadsheet_grid(task)
"staples":
_work_grid.text = _staples_art(task)
"guillotine":
_work_grid.text = _guillotine_art(task)
_:
if 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 bundle, drawn as text. The arrow only appears once the staple is lifted, so you
## can't pre-empt it — you lift, read, and pull, under a clock.
func _staples_art(task: Dictionary) -> String:
var armed := bool(task.get("armed", false))
var d := String(task.get("dir", "?"))
var urg := float(task.get("urgency", 0.0))
var bar := ""
var n := int(round(urg * 22.0))
for i in 22:
bar += "#" if i < n else "."
var out := PackedStringArray()
out.append("")
out.append(" +--------------------------+")
out.append(" | |")
if armed:
out.append(" | << %s >> |" % d)
else:
out.append(" | [ lift ] |")
out.append(" | |")
out.append(" +--------------------------+")
out.append("")
out.append(" tearing: " + bar)
return "\n".join(out)
## A rule, a mark, and the sheet. The blade does NOT land where the sheet sits — the
## guide has a permanent offset — and nothing on screen tells you that.
func _guillotine_art(task: Dictionary) -> String:
var target := float(task.get("target", 0.5))
var at := float(task.get("at", 0.5))
const W := 46
var rule := ""
for i in W:
rule += "|" if i % 5 == 0 else "-"
var marks := ""
var ti := int(round(target * (W - 1)))
for i in W:
marks += "v" if i == ti else " "
var sheet := ""
var ai := int(round(at * (W - 1)))
for i in W:
sheet += "#" if i == ai else ("=" if absi(i - ai) < 6 else " ")
var out := PackedStringArray()
out.append("")
out.append(" " + marks)
out.append(" " + rule)
out.append(" " + sheet)
out.append("")
out.append(" the guide has always been a little out")
return "\n".join(out)
## 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