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>
510 lines
16 KiB
GDScript
510 lines
16 KiB
GDScript
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
|