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