macrosoft3dpinball/godot/scripts/Hud.gd
m3ultra 7347ec4db7 Pinball: Rules, Juice, HUD, a table probe — and the three bugs it found
Four lanes built in parallel onto the data-driven spine:

- Rules.gd (509) — score with a decaying combo multiplier, 3 balls, ball save,
  escalating drop-bank bonuses, lit rollover lanes that level up when swept,
  saucer-lock multiball, two-warning tilt, end-of-ball bonus, per-table high
  scores in user://pinball_scores.cfg.
- Juice.gd (784) — 12 procedural voices synthesized at boot, no assets. The
  spinner ratchet is timed to Table.gd bleeding spin_left at 6.0/s so the ticking
  stops exactly when the blade does. Pooled voices/lights/sparks, quadratic-falloff
  shake, and a damped spring for nudge. Palette-tinted lights with a value floor,
  because a light the colour of a near-black saucer illuminates nothing.
- Hud.gd (935) — score that ROLLS UP rather than snapping, combo, award callouts,
  plunger meter, tilt banner, ball save, game over panel.
- dev/probe_tables.gd (480) — the gate: builds every table and checks parts, part
  overlaps, playfield bounds, flipper swing, that the ball actually DRAINS, and
  that everything settles.

The probe immediately found three real bugs the flipper autotest could not see:

1. The drain sat 3 cm PAST the playfield lip, so on four of five tables the ball
   rolled off the end into the void and the trigger never fired. A table you cannot
   lose a ball on is not a table. Moved inside the slab.
2. Inlane posts were level with the slingshots and interpenetrated them on three
   tables — on a real table the posts sit below. Moved down-table.
3. THE DIG's counter ramp ran through the crate bank, and a 15 deg yaw over a 28 cm
   ramp swings its far end 7 cm sideways, which then walked it through the left rail.
   Less yaw, tucked in, crates slid right.

PROBE_TABLES: PASS 5/5. AUTOTEST: ALL TABLES OK.

Still open, and it is an ENGINE finding not a table one: godot-box3d does not enforce
HingeJoint3D angular limits — flippers swing 147-160 deg against a 62 deg limit. The
probe reports it as a NOTE per table. Worth an upstream issue alongside the motor-sign
inversion already in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:18:15 +10:00

936 lines
36 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends CanvasLayer
class_name Hud
## The read-out — everything a player learns without looking at the ball: score, ball number,
## combo, what just got awarded, how hard the plunger is wound, and whether the machine is
## about to tilt.
##
## The HUD is a READER, never a source of truth. It polls the Rules node each frame and listens
## to its `awarded` signal. Every one of those is probed defensively (has_method / property
## list) because Rules is a separate lane that may not exist, and may spell things differently.
## With no Rules present the HUD still works: it falls back to totting up the table's own hit
## points so the score reel has something to roll.
##
## Built entirely in code — no .tscn, no font or image files. Type is SystemFont wrapped in a
## FontVariation, which is the only way to get letter-spacing: Label has no tracking constant.
##
## Godot 4.7.
# --- score reel. A real reel never crawls and never takes forever; both ends are clamped.
const REEL_SPAN := 0.55 ## seconds to close any gap, however large
const REEL_MIN_RATE := 900.0 ## ...but at least this many points/sec, so small adds tick
const REEL_MIN_DIGITS := 6 ## the display is this wide even at zero, like a real backglass
const CALLOUT_SLOTS := 3
const CALLOUT_LIFE := 2.4
const CALLOUT_FADE := 0.7
const CALLOUT_STEP := 46.0 ## vertical gap between stacked awards, in 720p pixels
const COMBO_CEIL := 8.0 ## combo value at which the badge is maximally loud
const HINT := "Z / SLASH flippers · SPACE plunger · ARROWS nudge · T table · R new game"
# ---------------------------------------------------------------- state
var main: Node = null
var _rules: Node = null
var _rules_props: Dictionary = {} ## property names Rules actually has, so we never blind-get
var _rules_consts: Dictionary = {} ## its script constants — window lengths we must divide by
var _awarded_wired := false
var _table_wired := false
var _t := 0.0
var _ui := 1.0 ## everything is sized in "720p pixels" times this
var _vp := Vector2(1152, 648)
var _accent := Color(1.0, 0.35, 0.55)
var _accent2 := Color(0.35, 0.95, 0.90)
var _score_target := 0.0
var _score_shown := 0.0
var _reel_digits := REEL_MIN_DIGITS
var _delta_amount := 0
var _delta_t := 99.0
var _combo := 1.0
var _combo_loud := 0.0
var _combo_pulse := 0.0
var _combo_col := Color.WHITE
var _combo_left := -1.0 ## 0..1 combo timer if Rules exposes one, else -1
var _combo_span := 0.001 ## longest combo_left ever seen — normalises a seconds timer
var _plunge_view := 0.0
var _plunge_flash := 0.0
var _tilt_t := 0.0
var _warnings := 0
var _over_t := 0.0
var _was_over := false
var _new_high := false
var _fallback_score := 0 ## only used when no Rules lane is present
var _callouts: Array[Dictionary] = []
# ---------------------------------------------------------------- nodes
var _root: Control
var _vig: Control
var _bar: Panel
var _table_lbl: Label
var _sub_lbl: Label
var _extra_lbl: Label
var _score_ghost: Label
var _score_glow: Label
var _score_lbl: Label
var _delta_lbl: Label
var _ball_lbl: Label
var _combo_box: Control
var _combo_lbl: Label
var _combo_cap: Label
var _status_lbl: Label
var _cal_slots: Array[Control] = []
var _meter: Control
var _meter_cap: Label
var _save_pill: Panel
var _mb_pill: Panel
var _tilt_lbl: Label
var _warn_lbl: Label
var _over_panel: Panel
var _over_title: Label
var _over_score: Label
var _over_high: Label
var _over_hint: Label
var _hint_lbl: Label
var _font_cache: Dictionary = {}
var _sf_display: SystemFont
var _sf_mono: SystemFont
var _sb_bar: StyleBoxFlat
var _sb_meter: StyleBoxFlat
var _sb_combo: StyleBoxFlat
var _sb_glow: StyleBoxFlat
var _sb_save: StyleBoxFlat
var _sb_mb: StyleBoxFlat
var _sb_over: StyleBoxFlat
# home positions captured at layout time; per-frame animation offsets from these
var _combo_home := Vector2.ZERO
var _cal_home_y := 0.0
# ---------------------------------------------------------------- lifecycle
func _ready() -> void:
layer = 100 # the read-out is always the topmost thing on screen
_build()
_layout()
var vp := get_viewport()
if vp != null and not vp.size_changed.is_connected(_layout):
vp.size_changed.connect(_layout)
## Main hands us itself after add_child, so _ready has already run by the time we get here.
func setup(m: Node) -> void:
main = m
_find_rules()
_sync_table()
## Called by Main every time a table is built. Also the moment we can steal the table's own
## palette — the HUD adopting the playfield's colours is free cohesion for three tables.
func set_table(tname: String, subtitle: String) -> void:
_table_lbl.text = tname.to_upper()
_sub_lbl.text = subtitle
_sync_table()
var spec: Dictionary = _table_spec()
var pal: Dictionary = spec.get("palette", {})
if pal.has("flipper"):
_accent = _ui_tint(pal["flipper"])
if pal.has("bumper"):
_accent2 = _ui_tint(pal["bumper"])
elif pal.has("lane"):
_accent2 = _ui_tint(pal["lane"])
_restyle_accent()
func _table_spec() -> Dictionary:
if main == null:
return {}
var t = main.get("table")
if t == null or not is_instance_valid(t):
return {}
var s = t.get("spec")
return s if s is Dictionary else {}
## Playfield colours are chosen to look good under a light; on a dark HUD plate the dim ones
## turn to mud. Push saturation and value into a band that stays legible.
func _ui_tint(c: Color) -> Color:
return Color.from_hsv(c.h, clampf(c.s, 0.35, 0.92), maxf(c.v, 0.88))
# ---------------------------------------------------------------- rules discovery
func _find_rules() -> void:
if is_instance_valid(_rules):
return
var p := get_parent()
if p == null:
return
var r := p.get_node_or_null("Rules")
if r == null:
return
_rules = r
_rules_props.clear()
# Object has has_method() but no has_property(), and a blind get() on a missing name is a
# silent null we can't tell from a real null. Index the property list once instead.
for pr in r.get_property_list():
_rules_props[String(pr.get("name", ""))] = true
# Timers come back as seconds; to draw a depleting bar we need the window they started
# from, and that lives in Rules' consts (which never show up in get_property_list()).
var scr: Variant = r.get_script()
if scr is Script:
_rules_consts = (scr as Script).get_script_constant_map()
if not _awarded_wired and r.has_signal("awarded"):
r.connect("awarded", _on_awarded)
_awarded_wired = true
## Ask Rules for a value under any of its plausible spellings, method first, then property.
func _ask(names: PackedStringArray, fallback: Variant) -> Variant:
if not is_instance_valid(_rules):
return fallback
for n in names:
if _rules.has_method(n):
return _rules.call(n)
for n in names:
if _rules_props.has(n):
return _rules.get(n)
return fallback
func _ask_f(names: PackedStringArray, fallback: float) -> float:
var v: Variant = _ask(names, fallback)
return float(v) if (v is float or v is int or v is bool) else fallback
func _ask_i(names: PackedStringArray, fallback: int) -> int:
return int(round(_ask_f(names, float(fallback))))
func _ask_b(names: PackedStringArray, fallback: bool) -> bool:
var v: Variant = _ask(names, fallback)
if v is bool:
return v
if v is int or v is float:
return float(v) > 0.0
return fallback
func _ask_s(names: PackedStringArray, fallback: String) -> String:
var v: Variant = _ask(names, fallback)
return String(v) if v is String else fallback
func _rule_const(names: PackedStringArray, fallback: float) -> float:
for n in names:
if _rules_consts.has(n):
var v: Variant = _rules_consts[n]
if v is float or v is int:
return float(v)
return fallback
func _rules_scores() -> bool:
return is_instance_valid(_rules) and (_rules.has_method("score") or _rules_props.has("score"))
## Hook the table's raw hit stream. Only consumed when no Rules lane is scoring — see _on_hit.
func _sync_table() -> void:
if _table_wired or main == null:
return
var t = main.get("table")
if t == null or not is_instance_valid(t) or not t.has_signal("hit"):
return
t.connect("hit", _on_hit)
_table_wired = true
# ---------------------------------------------------------------- build
func _build() -> void:
_sf_display = SystemFont.new()
# Impact first for the machine-shop look, then progressively less exciting fallbacks. This
# is a font *request*, not a font file — nothing ships on disk.
_sf_display.font_names = PackedStringArray(["Impact", "Haettenschweiler", "Arial Black",
"Helvetica Neue", "Helvetica", "Arial", "Sans-Serif"])
_sf_display.font_weight = 900
_sf_display.antialiasing = TextServer.FONT_ANTIALIASING_GRAY
_sf_mono = SystemFont.new()
_sf_mono.font_names = PackedStringArray(["SF Mono", "Menlo", "Monaco", "Consolas",
"DejaVu Sans Mono", "Courier New", "Monospace"])
_sf_mono.font_weight = 700
_sf_mono.antialiasing = TextServer.FONT_ANTIALIASING_GRAY
_root = Control.new()
_root.name = "Root"
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(_root)
# tilt vignette goes in first so every readout draws over it
_vig = _mk_ctrl()
_vig.draw.connect(_draw_vignette)
_sb_bar = StyleBoxFlat.new()
_sb_bar.bg_color = Color(0.015, 0.02, 0.04, 0.78)
_sb_bar.border_width_bottom = 2
_sb_bar.border_color = _accent
_bar = Panel.new()
_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bar.add_theme_stylebox_override("panel", _sb_bar)
_root.add_child(_bar)
_table_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT)
_sub_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT)
_extra_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT)
_extra_lbl.visible = false
# unlit segments: a full row of 8s behind the live score, the way a real display idles
_score_ghost = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_score_glow = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_score_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_delta_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_ball_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_combo_box = _mk_ctrl()
_combo_box.draw.connect(_draw_combo)
_combo_cap = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _combo_box)
_combo_cap.text = "COMBO"
_combo_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _combo_box)
_combo_lbl.text = "1x"
_status_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER)
for i in CALLOUT_SLOTS:
var slot := _mk_ctrl()
var title := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, slot)
title.name = "T"
var pts := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, slot)
pts.name = "P"
slot.visible = false
_cal_slots.append(slot)
_sb_meter = StyleBoxFlat.new()
_sb_meter.bg_color = Color(0.02, 0.03, 0.06, 0.75)
_sb_meter.border_color = Color(1, 1, 1, 0.16)
_sb_meter.set_border_width_all(1)
_sb_meter.set_corner_radius_all(3)
_meter = _mk_ctrl()
_meter.draw.connect(_draw_meter)
_meter.visible = false
_meter_cap = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT)
_meter_cap.text = "PLUNGER"
_meter_cap.visible = false
_sb_mb = _mk_pill_box(Color(1.0, 0.55, 0.15))
_mb_pill = _mk_pill("MULTIBALL", _sb_mb)
_sb_save = _mk_pill_box(Color(0.35, 1.0, 0.55))
_save_pill = _mk_pill("BALL SAVE", _sb_save)
_tilt_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER)
_tilt_lbl.text = "TILT"
_tilt_lbl.visible = false
_warn_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER)
_warn_lbl.visible = false
_sb_over = StyleBoxFlat.new()
_sb_over.bg_color = Color(0.02, 0.025, 0.05, 0.93)
_sb_over.border_color = _accent
_sb_over.set_border_width_all(2)
_sb_over.set_corner_radius_all(4)
_over_panel = Panel.new()
_over_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_panel.add_theme_stylebox_override("panel", _sb_over)
_over_panel.visible = false
_root.add_child(_over_panel)
_over_title = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel)
_over_title.text = "GAME OVER"
_over_score = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel)
_over_high = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel)
_over_hint = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel)
_over_hint.text = "PRESS R FOR A NEW GAME"
_hint_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT)
_hint_lbl.text = HINT
_sb_combo = StyleBoxFlat.new()
_sb_combo.set_corner_radius_all(5)
_sb_glow = StyleBoxFlat.new()
func _mk_ctrl(parent: Node = null) -> Control:
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
c.clip_contents = false
(parent if parent != null else _root).add_child(c)
return c
func _mk_label(align: int, parent: Node = null) -> Label:
var l := Label.new()
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.horizontal_alignment = align
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
l.clip_text = false
(parent if parent != null else _root).add_child(l)
return l
func _mk_pill_box(tint: Color) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(tint.r * 0.2, tint.g * 0.2, tint.b * 0.2, 0.8)
sb.border_color = tint
sb.set_border_width_all(2)
sb.set_corner_radius_all(14)
return sb
func _mk_pill(txt: String, sb: StyleBoxFlat) -> Panel:
var p := Panel.new()
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_theme_stylebox_override("panel", sb)
p.visible = false
_root.add_child(p)
var l := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, p)
l.name = "L"
l.text = txt
return p
# ---------------------------------------------------------------- fonts
## FontVariation.spacing_glyph is in whole pixels and does NOT scale with font size, so tracking
## has to be baked per (family, amount). Cheap to cache, ugly to forget.
func _font(mono: bool, track: int) -> FontVariation:
var key := "%s|%d" % ["m" if mono else "d", track]
if _font_cache.has(key):
return _font_cache[key]
var fv := FontVariation.new()
fv.base_font = _sf_mono if mono else _sf_display
fv.spacing_glyph = track
_font_cache[key] = fv
return fv
func _type(l: Label, mono: bool, px: float, track: float, outline := 0.0) -> void:
l.add_theme_font_override("font", _font(mono, int(round(track * _ui))))
l.add_theme_font_size_override("font_size", maxi(6, int(round(px * _ui))))
l.add_theme_color_override("font_color", Color.WHITE)
l.add_theme_constant_override("outline_size", int(round(maxf(0.0, outline) * _ui)))
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
func _place(c: Control, x: float, y: float, w: float, h: float) -> void:
c.position = Vector2(x, y)
c.size = Vector2(w, h)
c.pivot_offset = Vector2(w, h) * 0.5
# ---------------------------------------------------------------- layout
func _layout() -> void:
var v := get_viewport()
_vp = v.get_visible_rect().size if v != null else Vector2(1152, 648)
# headless has no real window; fall back so the geometry maths never divides by nothing
if _vp.x < 16.0 or _vp.y < 16.0:
_vp = Vector2(1152, 648)
_ui = clampf(_vp.y / 720.0, 0.62, 2.4)
var u := _ui
var W := _vp.x
var H := _vp.y
# _root is anchored FULL_RECT — assigning its size here would fight the anchors and warn
_place(_vig, 0, 0, W, H)
var bar_h := 96.0 * u
_place(_bar, 0, 0, W, bar_h)
_sb_bar.border_width_bottom = maxi(1, int(2.0 * u))
_place(_table_lbl, 24 * u, 12 * u, W * 0.45, 36 * u)
_type(_table_lbl, false, 27, 2, 4)
_place(_sub_lbl, 26 * u, 48 * u, W * 0.45, 20 * u)
_type(_sub_lbl, false, 13, 1, 3)
_place(_extra_lbl, 26 * u, 70 * u, W * 0.35, 20 * u)
_type(_extra_lbl, true, 13, 1, 3)
var sw := minf(W * 0.52, 660.0 * u)
var sx := W - 24 * u - sw
for l: Label in [_score_ghost, _score_glow, _score_lbl]:
_place(l, sx, 6 * u, sw, 58 * u)
_type(l, true, 50, 1, 0)
# pivot on the RIGHT edge: the score pops when it rolls, and a centre pivot would
# shove right-aligned digits past the margin and out of step with the ghost behind
l.pivot_offset = Vector2(sw, 29 * u)
_score_glow.add_theme_constant_override("outline_size", int(round(9.0 * u)))
_score_lbl.add_theme_constant_override("outline_size", int(round(5.0 * u)))
_score_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95))
_place(_ball_lbl, sx, 64 * u, sw, 24 * u)
_type(_ball_lbl, false, 15, 3, 3)
_place(_delta_lbl, sx, bar_h + 4 * u, sw, 26 * u)
_type(_delta_lbl, true, 20, 1, 4)
_combo_home = Vector2(W * 0.5 - 110 * u, bar_h - 62 * u)
_place(_combo_box, _combo_home.x, _combo_home.y, 220 * u, 92 * u)
_place(_combo_cap, 0, 8 * u, 220 * u, 16 * u)
_type(_combo_cap, false, 11, 5, 3)
_place(_combo_lbl, 0, 22 * u, 220 * u, 58 * u)
_type(_combo_lbl, false, 44, 0, 5)
_cal_home_y = H * 0.35
for slot in _cal_slots:
_place(slot, 0, _cal_home_y, W, 86 * u)
var t := slot.get_node("T") as Label
var p := slot.get_node("P") as Label
_place(t, 0, 0, W, 56 * u)
_type(t, false, 46, 3, 7)
_place(p, 0, 54 * u, W, 30 * u)
_type(p, true, 24, 2, 5)
var meter_h := H * 0.34
_place(_meter, W - 40 * u, H * 0.40, 18 * u, meter_h)
_place(_meter_cap, W - 156 * u, H * 0.40 - 24 * u, 116 * u, 18 * u)
_type(_meter_cap, false, 11, 4, 3)
_place(_mb_pill, W * 0.5 - 110 * u, H - 176 * u, 220 * u, 34 * u)
_place(_save_pill, W * 0.5 - 100 * u, H - 136 * u, 200 * u, 34 * u)
for p in [_mb_pill, _save_pill]:
var l := p.get_node("L") as Label
_place(l, 0, 0, p.size.x, p.size.y)
_type(l, false, 16, 4, 0)
_place(_status_lbl, 0, H - 92 * u, W, 24 * u)
_type(_status_lbl, false, 15, 4, 4)
_place(_tilt_lbl, 0, H * 0.44, W, 130 * u)
_type(_tilt_lbl, false, 118, 16, 12)
_place(_warn_lbl, 0, H * 0.56, W, 34 * u)
_type(_warn_lbl, false, 24, 6, 6)
var ow := minf(W * 0.7, 640.0 * u)
var oh := 278.0 * u
_place(_over_panel, (W - ow) * 0.5, (H - oh) * 0.5, ow, oh)
_sb_over.set_border_width_all(maxi(1, int(2.0 * u)))
_place(_over_title, 0, 24 * u, ow, 44 * u)
_type(_over_title, false, 38, 10, 5)
_place(_over_score, 0, 80 * u, ow, 70 * u)
_type(_over_score, true, 56, 1, 5)
_place(_over_high, 0, 158 * u, ow, 30 * u)
_type(_over_high, false, 18, 4, 3)
_place(_over_hint, 0, 222 * u, ow, 26 * u)
_type(_over_hint, false, 14, 5, 3)
_place(_hint_lbl, 20 * u, H - 34 * u, W * 0.8, 20 * u)
_type(_hint_lbl, false, 12, 2, 3)
_restyle_accent()
func _restyle_accent() -> void:
if _sb_bar == null:
return
_sb_bar.border_color = Color(_accent.r, _accent.g, _accent.b, 0.65)
_sb_over.border_color = Color(_accent.r, _accent.g, _accent.b, 0.8)
if _table_lbl != null:
_table_lbl.modulate = _accent
_sub_lbl.modulate = Color(0.72, 0.76, 0.86, 0.75)
_hint_lbl.modulate = Color(0.80, 0.84, 0.92, 0.55)
_meter_cap.modulate = Color(0.75, 0.80, 0.90, 0.6)
_status_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.85)
_over_title.modulate = _accent
_over_hint.modulate = Color(0.7, 0.74, 0.84, 0.6)
# ---------------------------------------------------------------- per-frame
func _process(delta: float) -> void:
_t += delta
if not is_instance_valid(_rules):
_find_rules() # Rules may be a slower lane; keep looking, it's one node lookup
if not _table_wired:
_sync_table()
_tick_score(delta)
_tick_combo(delta)
_tick_callouts(delta)
_tick_plunger(delta)
_tick_tilt(delta)
_tick_flags()
_tick_over(delta)
func _tick_score(delta: float) -> void:
var target := float(_ask_i(PackedStringArray(["score"]), _fallback_score))
if target < _score_target - 0.5:
# score went backwards: a new game. Drop the reel width back to its resting size.
_score_shown = target
_reel_digits = REEL_MIN_DIGITS
_delta_t = 99.0
_callouts.clear()
_new_high = false
elif target > _score_target + 0.5:
_delta_amount = int(round(target - _score_target))
_delta_t = 0.0
_score_target = target
var gap := _score_target - _score_shown
if absf(gap) < 0.5:
_score_shown = _score_target
else:
# Clamp both ends: any gap closes inside REEL_SPAN, but a 250-point tickle still gets a
# visible roll instead of resolving in one frame.
var rate := maxf(absf(gap) / REEL_SPAN, REEL_MIN_RATE)
_score_shown = move_toward(_score_shown, _score_target, rate * delta)
var txt := _commas(int(_score_shown))
_reel_digits = maxi(_reel_digits, _digits_of(int(maxf(_score_target, 0.0))))
_score_lbl.text = txt
_score_glow.text = txt
_score_ghost.text = _ghost(maxi(_reel_digits, REEL_MIN_DIGITS))
var rolling: float = clampf(absf(gap) / 4000.0, 0.0, 1.0)
_score_ghost.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.07)
_score_glow.modulate = Color(_accent.r, _accent.g, _accent.b, 0.10 + 0.35 * rolling)
_score_lbl.modulate = Color(1, 1, 1).lerp(Color(1.0, 0.98, 0.86), rolling)
var pop := 1.0 + 0.035 * rolling
_score_lbl.scale = Vector2(pop, pop)
_score_glow.scale = _score_lbl.scale
_score_ghost.scale = _score_lbl.scale # the unlit segments swell with the lit ones
var ball := _ask_i(PackedStringArray(["ball", "ball_number", "ball_num"]), 1)
var total := _ask_i(PackedStringArray(["balls_total", "balls_per_game", "max_balls",
"ball_count"]), 3)
var hi := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0)
var line := "BALL %d OF %d" % [maxi(ball, 1), maxi(total, 1)] if total > 0 else "BALL %d" % maxi(ball, 1)
if hi > 0:
line += " HI %s" % _commas(hi)
_ball_lbl.text = line
_ball_lbl.modulate = Color(0.78, 0.83, 0.94, 0.8)
_delta_t += delta
var showing := _delta_t < 0.9 and _delta_amount > 0
_delta_lbl.visible = showing
if showing:
var k := _delta_t / 0.9
_delta_lbl.text = "+%s" % _commas(_delta_amount)
_delta_lbl.position.y = 96.0 * _ui + 4.0 * _ui - 22.0 * _ui * k
_delta_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 1.0 - k * k)
func _tick_combo(delta: float) -> void:
# multiplier() is the number that actually multiplies a shot (combo × any multiball bonus),
# so it is the honest thing to put on the glass; combo() is the fallback if that's all there is.
var c := _ask_f(PackedStringArray(["multiplier", "combo", "combo_multiplier"]), 1.0)
if c > _combo + 0.01:
_combo_pulse = 1.0
_combo = c
_combo_pulse = maxf(0.0, _combo_pulse - delta * 3.2)
var loud := clampf((c - 1.0) / (COMBO_CEIL - 1.0), 0.0, 1.0)
_combo_loud = move_toward(_combo_loud, loud, delta * 4.0)
_combo_col = _combo_ramp(c)
# combo_left may be seconds or an already-normalised 0..1. Divide by the declared window if
# Rules has one, else by the largest value we've ever seen it hold — both land on 0..1.
var lv := _ask_f(PackedStringArray(["combo_left", "combo_time_left", "combo_frac"]), -1.0)
if lv > 0.0:
_combo_span = maxf(_combo_span, lv)
var span := _rule_const(PackedStringArray(["COMBO_WINDOW", "COMBO_TIME"]), _combo_span)
_combo_left = clampf(lv / maxf(span, 0.001), 0.0, 1.0)
else:
_combo_left = -1.0
var chain := _ask_i(PackedStringArray(["combo_count", "chain"]), 0)
_combo_cap.text = "CHAIN %d" % chain if chain > 0 else "COMBO"
# integer combos read as "3x", a fractional multiplier as "1.5x" — Rules may use either
_combo_lbl.text = ("%dx" % int(round(c))) if absf(c - round(c)) < 0.01 else ("%.1fx" % c)
_combo_lbl.add_theme_font_size_override("font_size",
maxi(6, int(round(lerpf(16.0, 46.0, _combo_loud) * _ui))))
_combo_lbl.modulate = Color(0.55, 0.60, 0.72, 0.75).lerp(_combo_col, _combo_loud)
_combo_cap.modulate = Color(_combo_col.r, _combo_col.g, _combo_col.b, _combo_loud * 0.8)
var sc := 1.0 + 0.22 * _combo_pulse
_combo_box.scale = Vector2(sc, sc)
# a loud combo physically vibrates; at 1x it sits perfectly still
var shake := _combo_loud * 2.6 * _ui
_combo_box.position = _combo_home + Vector2(randf_range(-shake, shake), randf_range(-shake, shake))
_combo_box.queue_redraw()
func _combo_ramp(c: float) -> Color:
const STOPS := [
Color(0.58, 0.63, 0.75), Color(0.55, 0.88, 1.00), Color(0.45, 1.00, 0.72),
Color(1.00, 0.92, 0.35), Color(1.00, 0.62, 0.25), Color(1.00, 0.30, 0.58),
]
var f := clampf(c - 1.0, 0.0, float(STOPS.size() - 1) - 0.001)
var i := int(f)
return (STOPS[i] as Color).lerp(STOPS[mini(i + 1, STOPS.size() - 1)], f - float(i))
func _tick_callouts(delta: float) -> void:
for i in range(_callouts.size() - 1, -1, -1):
_callouts[i]["t"] = float(_callouts[i]["t"]) + delta
if float(_callouts[i]["t"]) > CALLOUT_LIFE:
_callouts.remove_at(i)
# A tilt or a game-over panel owns the centre of the screen; awards get out of the way.
var mask: float = (1.0 - _over_t) * (0.0 if _tilt_t > 0.0 else 1.0)
for i in _cal_slots.size():
# newest award takes the LAST slot: children draw in tree order, and a fresh callout
# punching in behind a stale one is exactly backwards
var slot := _cal_slots[_cal_slots.size() - 1 - i]
if i >= _callouts.size() or mask <= 0.001:
slot.visible = false
continue
var e: Dictionary = _callouts[i]
var age := float(e["t"])
slot.visible = true
var title := slot.get_node("T") as Label
var pts := slot.get_node("P") as Label
title.text = String(e["text"])
var p := int(e["points"])
# only the live award shows its value — older ones would collide with the title below
pts.visible = i == 0 and p > 0
pts.text = "+%s" % _commas(p)
# punch in: overshoot then settle. The first 0.14 s is the whole feel of an award.
var k: float = clampf(age / 0.14, 0.0, 1.0)
var sc: float = lerpf(1.65, 1.0, ease(k, 0.30)) * (1.0 + 0.07 * sin(k * PI))
sc *= 1.0 - 0.26 * float(i)
var fade: float = 1.0 - clampf((age - (CALLOUT_LIFE - CALLOUT_FADE)) / CALLOUT_FADE, 0.0, 1.0)
slot.scale = Vector2(sc, sc)
slot.position.y = _cal_home_y - float(i) * CALLOUT_STEP * _ui - (1.0 - fade) * 18.0 * _ui
slot.modulate = Color(1, 1, 1, fade * pow(0.5, float(i)) * mask)
title.modulate = Color.WHITE.lerp(_accent, 0.14)
pts.modulate = _accent2
func _tick_plunger(delta: float) -> void:
var charge := 0.0
if main != null and main.has_method("plunge_charge"):
charge = clampf(float(main.call("plunge_charge")), 0.0, 1.0)
# Main zeroes its charge the instant it fires, so hold a flash for a beat — otherwise the
# meter vanishes at exactly the moment you want to see how hard you hit it.
if charge <= 0.001 and _plunge_view > 0.06:
_plunge_flash = 1.0
_plunge_flash = maxf(0.0, _plunge_flash - delta * 3.0)
_plunge_view = charge if charge > _plunge_view else move_toward(_plunge_view, charge, delta * 3.0)
var show := _plunge_view > 0.005 or _plunge_flash > 0.0
_meter.visible = show
if show:
_meter.queue_redraw()
# "HOLD SPACE" only while a ball is actually sitting in the lane, else it is noise
var ready := false
var t = main.get("table") if main != null else null
if t != null and is_instance_valid(t) and t.has_method("ball_in_lane"):
for b in t.get("balls"):
if t.call("ball_in_lane", b):
ready = true
break
_meter_cap.visible = show or ready
_meter_cap.text = "PLUNGER" if show else "HOLD SPACE"
func _tick_tilt(delta: float) -> void:
# the game-over panel owns the screen; a tilt banner or warning glow under a 93%-opaque
# panel just reads as a smudge
var tilted := _ask_b(PackedStringArray(["tilted", "is_tilted"]), false) and _over_t < 0.02
# "_warns_given" is last on purpose: it is Rules' private counter and only read because no
# public getter exists yet. Add a warnings() to Rules and this line stops mattering.
_warnings = _ask_i(PackedStringArray(["warnings", "tilt_warnings", "warning_count",
"_warns_given"]), 0)
_tilt_t = (_tilt_t + delta) if tilted else 0.0
_tilt_lbl.visible = tilted
if tilted:
# Strobe the FILL, not the alpha: modulate would fade the black outline with it and the
# banner would go translucent and muddy over a lit playfield instead of flashing.
var flash := 0.55 + 0.45 * sin(_tilt_t * 11.0)
_tilt_lbl.add_theme_color_override("font_color",
Color(flash, 0.16 * flash, 0.20 * flash))
var sc := 1.0 + 0.06 * sin(_tilt_t * 7.0)
_tilt_lbl.scale = Vector2(sc, sc)
_warn_lbl.visible = _warnings > 0 and not tilted and _over_t < 0.02
if _warn_lbl.visible:
var wmax := 0
var wc: Variant = _rules_consts.get("TILT_WARN_AT")
if wc is Array:
wmax = (wc as Array).size()
_warn_lbl.text = ("TILT WARNING %d/%d" % [_warnings, wmax]) if wmax > 0 \
else ("TILT WARNING %d" % _warnings)
var wf := 0.55 + 0.45 * sin(_t * 8.0)
_warn_lbl.add_theme_color_override("font_color", Color(wf, 0.72 * wf, 0.18 * wf))
_vig.visible = (tilted or _warnings > 0) and _over_t < 0.02
if _vig.visible:
_vig.queue_redraw()
func _tick_flags() -> void:
# ball save may be a bool or a countdown in seconds; show the seconds when we get them,
# because "how long have I got" is the only question that indicator is answering
var sv: Variant = _ask(PackedStringArray(["ball_save_left", "ball_save_time",
"ball_save_active", "ball_save", "ball_saved", "saving"]), false)
var save := false
var save_txt := "BALL SAVE"
if sv is bool:
save = sv
elif sv is int or sv is float:
save = float(sv) > 0.0
if save and float(sv) > 1.0:
save_txt = "BALL SAVE %d" % int(ceil(float(sv)))
_save_pill.visible = save
if save:
var a := 0.6 + 0.4 * sin(_t * 9.0)
_sb_save.border_color = Color(0.35, 1.0, 0.55, a)
var sl := _save_pill.get_node("L") as Label
sl.text = save_txt
sl.modulate = Color(0.75, 1.0, 0.85, 0.7 + 0.3 * a)
# balls in play is readable straight off the table, so multiball shows even with no Rules
var n := 0
var t = main.get("table") if main != null else null
if t != null and is_instance_valid(t):
var arr = t.get("balls")
if arr is Array:
n = (arr as Array).size()
var mb := _ask_b(PackedStringArray(["multiball", "multiball_active", "mb_active"]), n > 1)
_mb_pill.visible = mb or n > 1
if _mb_pill.visible:
(_mb_pill.get_node("L") as Label).text = "MULTIBALL x%d" % maxi(n, 2)
_sb_mb.border_color = Color(1.0, 0.55, 0.15, 0.6 + 0.4 * sin(_t * 7.0))
# end-of-ball bonus is the one number Rules' status line doesn't carry
var bonus := _ask_i(PackedStringArray(["bonus", "end_bonus"]), 0)
_extra_lbl.visible = bonus > 0
if _extra_lbl.visible:
_extra_lbl.text = "BONUS %s" % _commas(bonus)
_extra_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.7)
var status := _ask_s(PackedStringArray(["status_line", "status", "hint_line"]), "")
_status_lbl.text = status
_status_lbl.visible = status != ""
func _tick_over(delta: float) -> void:
var over := _ask_b(PackedStringArray(["game_over", "is_game_over"]), false)
if over and not _was_over:
var hi := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0)
_new_high = _score_target > 0.0 and int(_score_target) >= hi
_was_over = over
_over_t = clampf(_over_t + (delta if over else -delta * 3.0), 0.0, 1.0)
_over_panel.visible = _over_t > 0.001
if not _over_panel.visible:
return
var k: float = ease(clampf(_over_t / 0.35, 0.0, 1.0), 0.4)
_over_panel.modulate = Color(1, 1, 1, k)
var sc: float = lerpf(0.92, 1.0, k)
_over_panel.scale = Vector2(sc, sc)
_over_title.text = "NEW HIGH SCORE" if _new_high else "GAME OVER"
_over_title.modulate = _accent2 if _new_high else _accent
_over_score.text = _commas(int(_score_shown))
var hi2 := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0)
_over_high.text = ("HIGH SCORE %s" % _commas(hi2)) if hi2 > 0 else ""
_over_high.modulate = Color(0.8, 0.84, 0.94, 0.7)
# ---------------------------------------------------------------- award intake
func _on_awarded(text: String, points: int) -> void:
_push_callout(text, points)
func _push_callout(text: String, points: int) -> void:
if text.strip_edges() == "":
return
_callouts.push_front({"text": text.to_upper(), "points": maxi(points, 0), "t": 0.0})
while _callouts.size() > CALLOUT_SLOTS:
_callouts.pop_back()
## Standalone mode only. With a Rules lane scoring, this is inert — Rules owns points and the
## `awarded` signal owns callouts, and double-counting either would be a lie on screen.
func _on_hit(kind: String, id: String, _at: Vector3, data: Dictionary) -> void:
if _rules_scores():
return
var pts := int(data.get("points", 0))
if kind == "spinner":
pts *= maxi(1, int(data.get("spins", 1)))
_fallback_score += pts
# only the shots worth shouting about; a pop bumper firing six times a second is not one
match kind:
"bank_cleared": _push_callout("BANK CLEARED", 0)
"ramp": _push_callout("RAMP!", pts)
"saucer": _push_callout("CAPTURED!", pts)
"spinner": _push_callout("SPINNER x%d" % int(data.get("spins", 1)), pts)
"drop":
if not bool(data.get("cleared", false)):
_push_callout("DROP TARGET", pts)
"rollover":
if not bool(data.get("was_lit", false)):
_push_callout("LANE %s" % String(data.get("glyph", id)), pts)
# ---------------------------------------------------------------- custom draws
func _draw_meter() -> void:
var w := _meter.size.x
var h := _meter.size.y
_meter.draw_style_box(_sb_meter, Rect2(Vector2.ZERO, _meter.size))
var v := clampf(_plunge_view, 0.0, 1.0)
var pad := 2.0 * _ui
var fh := (h - pad * 2.0) * v
if fh > 0.5:
var col := Color(0.35, 1.0, 0.50).lerp(Color(1.0, 0.85, 0.20), clampf(v * 1.6, 0.0, 1.0))
col = col.lerp(Color(1.0, 0.25, 0.25), clampf((v - 0.72) / 0.28, 0.0, 1.0))
_meter.draw_rect(Rect2(pad, h - pad - fh, w - pad * 2.0, fh), col)
# a brighter cap on the column so the top edge reads at a glance
_meter.draw_rect(Rect2(pad, h - pad - fh, w - pad * 2.0, maxf(2.0 * _ui, fh * 0.06)),
Color(1, 1, 1, 0.75))
for i in range(1, 5):
var y := h - h * (float(i) / 5.0)
_meter.draw_line(Vector2(0, y), Vector2(w * 0.38, y), Color(1, 1, 1, 0.22), maxf(1.0, _ui))
if _plunge_flash > 0.0:
_meter.draw_rect(Rect2(Vector2.ZERO, _meter.size), Color(1, 1, 1, 0.55 * _plunge_flash))
func _draw_combo() -> void:
if _combo_loud <= 0.01:
return
var r := Rect2(Vector2.ZERO, _combo_box.size)
var pulse := 0.72 + 0.28 * sin(_t * 6.5)
# A CanvasLayer sits outside the 3D glow pass, so bloom has to be faked: concentric plates
# of falling alpha behind the badge.
for i in range(3, -1, -1):
var g := float(i + 1) * 6.0 * _ui
var a := _combo_loud * (0.15 - float(i) * 0.032) * pulse
if a <= 0.001:
continue
_sb_glow.bg_color = Color(_combo_col.r, _combo_col.g, _combo_col.b, a)
_sb_glow.set_corner_radius_all(int(round(5.0 * _ui + g)))
_combo_box.draw_style_box(_sb_glow, r.grow(g))
_sb_combo.bg_color = Color(0.03, 0.035, 0.06, 0.62 * _combo_loud)
_sb_combo.border_color = Color(_combo_col.r, _combo_col.g, _combo_col.b, 0.9 * _combo_loud)
_sb_combo.set_border_width_all(maxi(1, int(round(2.0 * _ui))))
_sb_combo.set_corner_radius_all(int(round(5.0 * _ui)))
_combo_box.draw_style_box(_sb_combo, r)
if _combo_left >= 0.0:
var bw := r.size.x * 0.66
var bx := (r.size.x - bw) * 0.5
var by := r.size.y - 9.0 * _ui
var bh := 3.0 * _ui
_combo_box.draw_rect(Rect2(bx, by, bw, bh), Color(1, 1, 1, 0.12 * _combo_loud))
_combo_box.draw_rect(Rect2(bx, by, bw * _combo_left, bh),
Color(_combo_col, 0.9 * _combo_loud))
func _draw_vignette() -> void:
var heat: float = 1.0 if _tilt_t > 0.0 else clampf(float(_warnings) / 3.0, 0.0, 0.7)
if heat <= 0.0:
return
var pulse := 0.72 + 0.28 * sin(_t * (11.0 if _tilt_t > 0.0 else 7.0))
var col := Color(1.0, 0.10, 0.14)
var bands := 32
var vy := _vp.y * 0.22 / float(bands)
var vx := _vp.x * 0.15 / float(bands)
for i in bands:
var k := float(i) / float(bands)
var a := (1.0 - k) * (1.0 - k) * 0.46 * heat * pulse
var c := Color(col.r, col.g, col.b, a)
# Snap each band to whole pixels and butt them edge to edge. Overlapping by a pixel
# doubles the alpha on the seam and the gradient turns into venetian blinds.
var y0 := floorf(float(i) * vy)
var y1 := floorf(float(i + 1) * vy)
var x0 := floorf(float(i) * vx)
var x1 := floorf(float(i + 1) * vx)
_vig.draw_rect(Rect2(0, y0, _vp.x, y1 - y0), c)
_vig.draw_rect(Rect2(0, _vp.y - y1, _vp.x, y1 - y0), c)
_vig.draw_rect(Rect2(x0, 0, x1 - x0, _vp.y), c)
_vig.draw_rect(Rect2(_vp.x - x1, 0, x1 - x0, _vp.y), c)
# ---------------------------------------------------------------- text helpers
func _commas(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 ("-" + out) if n < 0 else out
func _digits_of(n: int) -> int:
return str(absi(n)).length()
## The unlit half of the display: same grouping as a live score of `digits` digits, all eights.
func _ghost(digits: int) -> String:
var out := ""
for i in digits:
if i > 0 and (digits - i) % 3 == 0:
out += ","
out += "8"
return out