extends Node class_name Rules ## The game layer: what a shot is worth, when a ball ends, and when the game does. ## ## Table emits, Main forwards, this decides. It owns no geometry — the only things it calls ## back into the table with are reset_bank / set_rollover_lit / add_ball / park_ball, which ## are exactly the devices whose *state* a ruleset is supposed to own. ## ## The shape of the scoring: bumpers and slings are chatter, ramps and saucers are money, ## a whipped spinner is a jackpot and a dribbled one is a rounding error, and the combo ## multiplier is where a good player pulls away from a lucky one. Pops deliberately do NOT ## build combo — if they did, the top of the table would max the multiplier for free and ## aiming would stop mattering. ## ## Godot 4.7. No assets, no scene: pure state. ## Fired for anything worth putting on a display. Small hits stay silent (see AWARD_FLOOR) ## so a HUD isn't a waterfall of "+250 BUMPER"; poll score() for the running total. signal awarded(text: String, points: int) const BALLS_PER_GAME := 3 const COMBO_WINDOW := 2.5 ## seconds of grace before a chain goes cold const COMBO_STEP := 0.5 ## each chained shot adds this to the multiplier const COMBO_CAP := 8.0 const BALL_SAVE := 7.0 ## grace after launch — an early drain is a robbery otherwise const DRAIN_DEBOUNCE := 400 ## msec; see on_drained() const AWARD_FLOOR := 1000 ## below this an award is scored but not announced const MB_MULT := 3.0 const LOCKS_FOR_MB := 3 const JACKPOT := 10000 const JACKPOT_STEP := 5000 const BANK_BONUS := 5000 ## × how many times that bank has been cleared const BANK_ESCALATE_CAP := 6 const BANK_RESET_DELAY := 0.7 ## see _reset_bank_soon() const LANE_BONUS := 3000 ## × current lane level, for completing the word const LANE_LEVEL_CAP := 6 ## Nudge heat: each shove adds 1.0 and it bleeds off at NUDGE_COOL/sec. Two warnings, then ## the tilt — a tilt with no warning is just the table stealing a ball off you. const NUDGE_COOL := 0.75 const TILT_WARN_AT := [2.0, 3.0] const TILT_AT := 4.0 const SCORES_PATH := "user://pinball_scores.cfg" ## End-of-ball bonus units per hit, paid out at drain × lane level. Deliberately flat and ## unmultiplied: the bonus rewards *playing the ball out*, the score rewards playing it well. const BONUS_UNITS := { "bumper": 10, "sling": 10, "target": 50, "drop": 100, "spinner": 8, "saucer": 500, "rollover": 120, "ramp": 250, } ## Which shots keep a chain alive. Aimed shots only. const COMBO_KINDS := ["target", "drop", "bank_cleared", "spinner", "saucer", "rollover", "ramp"] var tilted := false ## Main reads this directly to kill the flippers var _main: Node = null var _table: Table = null var _score := 0 var _ball := 1 var _game_over := false var _high := 0 var _high_key := "table" var _combo := 0 var _combo_left := 0.0 var _bonus := 0 var _lane_level := 1 var _lane_ids: PackedStringArray = [] var _save_left := 0.0 var _save_used := false var _locks := 0 var _mb_active := false var _jackpot := JACKPOT var _bank_clears: Dictionary = {} # bank name -> times cleared this game var _nudge_heat := 0.0 var _warns_given := 0 ## instance id -> msec of its last drain. Keyed by id rather than node because a drained ## multiball ball is freed while this is still holding it, and a timestamp rather than a ## coroutine because a cleanup that fails to run would wedge that ball out of the game. var _draining: Dictionary = {} # ---------------------------------------------------------------- lifecycle func setup(main: Node) -> void: _main = main func start_game(table: Table) -> void: _table = table _score = 0 _ball = 1 _game_over = false _combo = 0 _combo_left = 0.0 _bonus = 0 _lane_level = 1 _locks = 0 _mb_active = false _jackpot = JACKPOT _bank_clears.clear() _save_left = 0.0 _save_used = false _nudge_heat = 0.0 _warns_given = 0 _draining.clear() tilted = false _high_key = table.table_name().to_lower().replace(" ", "_") _high = _read_high(_high_key) _cache_lanes() for id in _lane_ids: table.set_rollover_lit(id, false) # Ball save has to arm on the PLUNGE, not on start_game, and Main doesn't forward # ball_launched to us — so listen to the table directly. Main rebuilds the same Table # node per layout, hence the is_connected guard rather than a fresh connect. if not table.ball_launched.is_connected(_on_ball_launched): table.ball_launched.connect(_on_ball_launched) print("[rules] game start — %s · %d balls · high %s" % [ table.table_name(), BALLS_PER_GAME, commas(_high)]) awarded.emit("BALL 1", 0) func _process(delta: float) -> void: if _combo_left > 0.0: _combo_left = maxf(0.0, _combo_left - delta) if _combo_left == 0.0: _combo = 0 if _save_left > 0.0: _save_left = maxf(0.0, _save_left - delta) if _save_left == 0.0 and not _save_used: awarded.emit("SAVE OVER", 0) if _nudge_heat > 0.0: _nudge_heat = maxf(0.0, _nudge_heat - delta * NUDGE_COOL) if _nudge_heat == 0.0: _warns_given = 0 func _on_ball_launched(_b: RigidBody3D) -> void: # Multiball adds balls through this same signal; those must not re-arm the save. if _game_over or _mb_active or _save_used or _save_left > 0.0: return _save_left = BALL_SAVE awarded.emit("BALL SAVE", 0) # ---------------------------------------------------------------- scoring func on_hit(kind: String, id: String, _at: Vector3, data: Dictionary) -> void: if _game_over or tilted or _table == null: return if kind == "bank_cleared": _bank_cleared(id) return var base := int(data.get("points", 0)) var label := kind.to_upper() match kind: "spinner": # The whole point of a spinner: points PER SPIN, and spins come from speed. var spins := maxi(1, int(data.get("spins", 1))) base *= spins label = "SPINNER %d" % spins "rollover": label = "LANE %s" % String(data.get("glyph", "?")) _bonus += _bonus_for(kind, data) _award(base, label) # Combo grows AFTER the award, so the first shot of a chain pays 1x and the reward for # chaining shows up on the next one. Extending first would silently gift a lone shot 1.5x. _extend_combo(kind) # Device state runs after the award so a lane bonus lands on top of the lane's own value. match kind: "rollover": _lane_rolled(id) "saucer": _saucer_captured() "ramp": if _mb_active: _pay_jackpot("RAMP JACKPOT") ## Everything that adds to the score goes through here, so the multipliers can never be ## forgotten at a call site. func _award(base: int, label: String, announce := false) -> int: if base <= 0: return 0 var pts := int(round(float(base) * combo() * _mb_mult())) _score += pts if _score > _high: _high = _score # live, so a HUD can show the record falling as it happens if announce or pts >= AWARD_FLOOR: awarded.emit(label, pts) return pts func _extend_combo(kind: String) -> void: if not COMBO_KINDS.has(kind): return # pops and slings ride the multiplier but never build it _combo = mini(_combo + 1, int((COMBO_CAP - 1.0) / COMBO_STEP)) _combo_left = COMBO_WINDOW func _bonus_for(kind: String, data: Dictionary) -> int: var unit := int(BONUS_UNITS.get(kind, 25)) if kind == "spinner": unit *= maxi(1, int(data.get("spins", 1))) return unit func _mb_mult() -> float: return MB_MULT if _mb_active else 1.0 # ---------------------------------------------------------------- devices ## A whole drop bank down. Escalates every time you knock it over again in the same game, ## which is what turns a bank from a chore into a strategy. func _bank_cleared(bank: String) -> void: var n := int(_bank_clears.get(bank, 0)) + 1 _bank_clears[bank] = n var step := mini(n, BANK_ESCALATE_CAP) _bonus += 1000 * step _award(BANK_BONUS * step, "%s BANK x%d" % [bank.to_upper(), step], true) _extend_combo("bank_cleared") _reset_bank_soon(bank) ## Reset on a beat, not instantly: the ball is still sitting inside the last target's ## trigger volume when this fires, and popping the bank up underneath it re-clears the ## whole thing for free — an infinite bonus loop from one lucky shot. func _reset_bank_soon(bank: String) -> void: var t := _table var tree := get_tree() if tree == null: t.reset_bank(bank) # no tree, no timers, and nothing to race either return await tree.create_timer(BANK_RESET_DELAY).timeout if is_instance_valid(t): t.reset_bank(bank) # no-op if the table was swapped out from under us func _cache_lanes() -> void: _lane_ids = PackedStringArray() if _table == null: return for n in _table.parts_of("rollover"): _lane_ids.append(String((n as Node).get_meta("id", ""))) func _lane_rolled(id: String) -> void: var n := _table.part(id) if n == null or _lane_ids.is_empty(): return # an uncached lane set would read as "all lit" and pay out forever if not bool(n.get_meta("lit", false)): _table.set_rollover_lit(id, true) for lid in _lane_ids: var l := _table.part(lid) if l == null or not bool(l.get_meta("lit", false)): return # every lane lit: pay the word, advance the bonus multiplier, wipe them for the next lap _award(LANE_BONUS * _lane_level, "%s COMPLETE" % _lane_word(), true) _lane_level = mini(_lane_level + 1, LANE_LEVEL_CAP) for lid in _lane_ids: _table.set_rollover_lit(lid, false) awarded.emit("BONUS x%d" % _lane_level, 0) func _lane_word() -> String: var s := "" for lid in _lane_ids: var n := _table.part(lid) if n != null: s += String(n.get_meta("glyph", "")) return s if s != "" else "LANES" ## Saucers are the lock device on every table in Tables.gd, so any capture counts. func _saucer_captured() -> void: if _mb_active: _pay_jackpot("SAUCER JACKPOT") return _locks += 1 if _locks < LOCKS_FOR_MB: awarded.emit("LOCK %d/%d" % [_locks, LOCKS_FOR_MB], 0) return _start_multiball() func _start_multiball() -> void: _locks = 0 _jackpot = JACKPOT _mb_active = true # set BEFORE add_ball: it emits ball_launched, which must not re-arm the save awarded.emit("MULTIBALL", 0) for i in 2: var b := _table.add_ball() # add_ball only nudges the new ball at ~0.4 m/s and it spawns in the plunger lane, # where the tilted gravity vector will just roll it back down. Give it a real plunge. if is_instance_valid(b): b.apply_central_impulse(Vector3(0, 0, -0.30)) func _pay_jackpot(label: String) -> void: _award(_jackpot, label, true) _jackpot += JACKPOT_STEP # ---------------------------------------------------------------- drain / ball count func on_drained(ball: RigidBody3D) -> void: if _table == null or not is_instance_valid(ball): return # The drain Area fires on entry and park_ball's teleport takes a frame to register, so # the same ball can arrive here twice before it has actually gone anywhere. var iid := ball.get_instance_id() var now := Time.get_ticks_msec() if now - int(_draining.get(iid, -DRAIN_DEBOUNCE)) < DRAIN_DEBOUNCE: return _draining[iid] = now if _game_over: _table.park_ball(ball) return # Multiball: any ball but the last just leaves the game quietly. if _table.balls.size() > 1: _table.remove_ball(ball) if _mb_active and _table.balls.size() <= 1: _mb_active = false awarded.emit("MULTIBALL OVER", 0) return if _mb_active: _mb_active = false # defensive: balls vanished some other way # Ball save. A tilt forfeits it — that is the price of shoving the table. if _save_left > 0.0 and not _save_used and not tilted: _save_used = true _save_left = 0.0 _combo = 0 _combo_left = 0.0 _table.park_ball(ball) awarded.emit("BALL SAVED", 0) return _end_ball(ball) func _end_ball(ball: RigidBody3D) -> void: var payout := _bonus * _lane_level if payout > 0: _score += payout if _score > _high: _high = _score awarded.emit("BONUS x%d" % _lane_level, payout) _ball += 1 _combo = 0 _combo_left = 0.0 _bonus = 0 _save_left = 0.0 _save_used = false _nudge_heat = 0.0 _warns_given = 0 tilted = false # the tilt dies with the ball, not with the game if _ball > BALLS_PER_GAME: _game_over = true _table.park_ball(ball) _finish() return _table.park_ball(ball) awarded.emit("BALL %d" % _ball, 0) func _finish() -> void: var record := _score >= _read_high(_high_key) and _score > 0 _write_high(_high_key, _score) awarded.emit("GAME OVER", _score) if record: awarded.emit("HIGH SCORE", _score) print("[rules] game over — %s: %s%s" % [ _table.table_name(), commas(_score), " (NEW HIGH)" if record else ""]) # ---------------------------------------------------------------- tilt func on_nudge() -> void: if _game_over or tilted: return _nudge_heat += 1.0 if _nudge_heat >= TILT_AT: _tilt() return while _warns_given < TILT_WARN_AT.size() and _nudge_heat >= float(TILT_WARN_AT[_warns_given]): _warns_given += 1 awarded.emit("DANGER" if _warns_given > 1 else "WARNING", 0) func _tilt() -> void: tilted = true _bonus = 0 # the bonus is what a tilt actually costs you _combo = 0 _combo_left = 0.0 _nudge_heat = 0.0 awarded.emit("TILT", 0) # ---------------------------------------------------------------- high scores func _read_high(key: String) -> int: var cf := ConfigFile.new() if cf.load(SCORES_PATH) != OK: return 0 return int(cf.get_value("high", key, 0)) func _write_high(key: String, value: int) -> void: var cf := ConfigFile.new() cf.load(SCORES_PATH) # ignore the error: a missing file is just an empty one if value > int(cf.get_value("high", key, 0)): cf.set_value("high", key, value) cf.save(SCORES_PATH) # ---------------------------------------------------------------- getters (the HUD lane's API) func score() -> int: return _score func ball() -> int: return mini(_ball, BALLS_PER_GAME) func balls_total() -> int: return BALLS_PER_GAME ## How many tilt warnings are showing. TILT_WARN_AT.size() is the max before the tilt lands, ## and the Hud lane reads that constant to render "n/max" — keep them in step. func warnings() -> int: return _warns_given ## The live scoring multiplier, e.g. 2.5 — not the chain length. combo_count() is that. func combo() -> float: return minf(1.0 + COMBO_STEP * float(_combo), COMBO_CAP) func combo_count() -> int: return _combo func combo_left() -> float: return _combo_left func high_score() -> int: return _high func bonus() -> int: return _bonus func lane_level() -> int: return _lane_level func locks() -> int: return _locks func multiball() -> bool: return _mb_active func multiplier() -> float: return combo() * _mb_mult() func ball_save_left() -> float: return _save_left func game_over() -> bool: return _game_over func balls_in_play() -> int: return _table.balls.size() if _table != null else 0 func score_text() -> String: return commas(_score) ## Which layout the score belongs to — high scores are kept per table, so a HUD showing ## the record needs this to label it. Empty if Rules was spawned without a Main. func table_id() -> String: if _main == null: return "" var v: Variant = _main.get("table_id") return String(v) if v != null else "" func status_line() -> String: if _game_over: return "GAME OVER · HIGH %s · R FOR A NEW GAME" % commas(_high) if tilted: return "TILT · FLIPPERS DEAD UNTIL THE DRAIN" var bits := PackedStringArray() bits.append("BALL %d/%d" % [ball(), BALLS_PER_GAME]) if _mb_active: bits.append("MULTIBALL x%d" % int(MB_MULT)) elif _locks > 0: bits.append("LOCK %d/%d" % [_locks, LOCKS_FOR_MB]) if _combo > 0: bits.append("COMBO x%s" % _mult_text(combo())) if _save_left > 0.0: bits.append("SAVE %d" % int(ceil(_save_left))) if _lane_level > 1: bits.append("BONUS x%d" % _lane_level) if _warns_given > 0: bits.append("NUDGE %d/2" % mini(_warns_given, 2)) return " · ".join(bits) func _mult_text(m: float) -> String: return "%d" % int(m) if is_equal_approx(m, floor(m)) else "%.1f" % m ## Score displays need thousands separators or a six-figure number reads as noise. static func commas(n: int) -> String: var s := str(absi(n)) var out := "" for i in s.length(): if i > 0 and (s.length() - i) % 3 == 0: out += "," out += s[i] return ("-" + out) if n < 0 else out