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>
936 lines
36 KiB
GDScript
936 lines
36 KiB
GDScript
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
|