Hands/POV - Cut a real first-person arms rig out of the GODVERSE modular character kit (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime. Textures shrunk to 1k; 4.2 MB. - ViewModel.gd instances that rig ONCE PER HAND and places each instance so its own hand bone lands on the grip — no IK. Grip orientation is measured off the rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so it survives re-tuning grip_rest_rot instead of needing new euler angles. - Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon heft, idle breathe, landing dip, weapon lower/raise on swap. - Sleeve material overridden (donor asset is a fantasy leather bracer); hand material forced non-metallic (its spec/gloss maps rendered skin as bronze). Weapons - Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the weapon-vs-material matrix from the founding chat. - Smashable moves from binary hits_to_break to hp/toughness, giving three outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet. - The hit lands at Weapon.contact THROUGH the swing, not on the click — that delay is most of why the sledge feels different from the cutter. - Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H. HUD - Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills in line items) and Dev, over one shared data feed. Scoring + combo multipliers. Dev harness (not shipped) - macOS screen-recording perms aren't available to the CLI, so the game records itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie, and dev/probe_*.gd print rig/scale/placement numbers. Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's already-unit side length, halving every box — which detached the bat's blade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
10 KiB
GDScript
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 — THE RECORD STORE")
|
|
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
|