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>
481 lines
22 KiB
GDScript
481 lines
22 KiB
GDScript
extends SceneTree
|
|
|
|
## THE TABLE GATE. Builds every table in Tables.ORDER headless and measures the six things
|
|
## that have to be true before a layout is playable at all:
|
|
##
|
|
## 1 IT BUILDS every part in the spec produced a node
|
|
## 2 FLIPPERS SWING the motors actually move the bats, with this engine's sign
|
|
## 3 NO OVERLAPS no two solid parts interpenetrate — a wedge is a stuck ball
|
|
## 4 INSIDE THE FIELD nothing sits outside the playfield rectangle
|
|
## 5 THE BALL DRAINS a released ball reaches the drain instead of being lost or trapped
|
|
## 6 IT SETTLES at rest, nothing is moving except the ball
|
|
##
|
|
## Same job as Destroyulator's dev/probe_levels.gd — measure the level instead of squinting at
|
|
## a screenshot. A table can look perfect and still be unplayable because two colliders share
|
|
## a millimetre, or because a ball that reaches the bottom is never reported as drained.
|
|
##
|
|
## The numbers legitimately DIFFER between Box3D / Jolt / GodotPhysics — that divergence is
|
|
## the entire point of this testbed — so the active engine is printed with every run and
|
|
## belongs in any report of a failure. Exits 1 if any table fails.
|
|
##
|
|
## Godot --headless --path godot --script dev/probe_tables.gd
|
|
##
|
|
## Two lines of stderr noise are expected and are NOT probe failures: Box3D logs "Parameter
|
|
## joint is null" while each flipper hinge is being wired (the spine sets joint params before
|
|
## node_a/node_b resolve), and it reports unfreed RIDs at shutdown — the spine's own
|
|
## PINBALL_AUTOTEST does both too. Add 2>/dev/null for a clean read.
|
|
|
|
const SWING_FRAMES := 30 ## half a second of motor, same window as Main's autotest
|
|
const SWING_MIN_DEG := 8.0 ## below this the bat is unjointed or jammed
|
|
const SWING_SLOP_DEG := 25.0 ## past the spec'd limit range = the stop is not holding
|
|
const SETTLE_FRAMES := 30
|
|
const LANE_DRAIN_S := 4.0 ## a parked ball rolls the shooter lane in well under this
|
|
const FIELD_DRAIN_S := 10.0 ## a loose ball may sit in a saucer and rattle the pops first
|
|
const STALL_FRAMES := 120 ## 2 s under STALL_V and the table is holding the ball
|
|
const STALL_V := 0.02
|
|
const OVERLAP_CLEAR := 0.002 ## metres of slop before a pair counts as interpenetrating
|
|
const EDGE_SLOP := 0.001
|
|
const STILL_LIN := 0.02 ## m/s
|
|
const STILL_ANG := 0.60 ## rad/s — a motored flipper on its stop still creeps
|
|
|
|
var _flip_sign := 1.0
|
|
var _frame := 0 ## physics frames since boot; the probe's only clock
|
|
var _drain_frame := -1
|
|
var _drained_ball: RigidBody3D = null
|
|
|
|
func _initialize() -> void:
|
|
# Table.add_ball() spreads new balls with randf, and a gate that reports a different
|
|
# verdict on every run is worthless. Pin the stream.
|
|
seed(20260809)
|
|
# _initialize() runs BEFORE the SceneTree attaches its root, so anything add_child'd here
|
|
# is not actually inside the tree: global_transform reads back as identity and physics
|
|
# nodes register late. One frame fixes it. (Measured, not folklore — building a table
|
|
# before this line gives every part a global position of (0,0,0).)
|
|
await process_frame
|
|
var engine := String(ProjectSettings.get_setting("physics/3d/physics_engine", "?"))
|
|
# Resolved exactly as Main._ready does it: under Box3D a positive motor velocity swings a
|
|
# hinge the opposite way to Jolt/GodotPhysics. Probing with the wrong sign reads as "the
|
|
# flipper does not swing" when in fact it is swinging hard into its own stop.
|
|
_flip_sign = -1.0 if engine.begins_with("Box3D") else 1.0
|
|
print("PROBE_TABLES engine=\"%s\" flip_sign=%.0f tick=%d gravity=%s" % [
|
|
engine, _flip_sign, Engine.physics_ticks_per_second,
|
|
ProjectSettings.get_setting("physics/3d/default_gravity_vector", Vector3.DOWN)])
|
|
|
|
var failed: Array[String] = []
|
|
for id in Tables.ORDER:
|
|
if not await _probe(String(id)):
|
|
failed.append(String(id))
|
|
|
|
var n: int = Tables.ORDER.size()
|
|
print("\nPROBE_TABLES result: %s (%d/%d tables passed, engine %s)" % [
|
|
"PASS" if failed.is_empty() else "FAIL [" + ", ".join(failed) + "]",
|
|
n - failed.size(), n, engine])
|
|
quit(1 if failed.size() > 0 else 0)
|
|
|
|
# ---------------------------------------------------------------- one table
|
|
func _probe(id: String) -> bool:
|
|
var table := Table.new()
|
|
table.name = "T_%s" % id
|
|
# In the tree before build(), the way Main does it: the flipper hinges resolve node_a and
|
|
# node_b by PATH, and every part position this probe measures is a global_transform read.
|
|
get_root().add_child(table)
|
|
table.build(Tables.get_table(id))
|
|
table.drained.connect(_on_drained)
|
|
var spec: Dictionary = table.spec
|
|
var w: float = float(spec.get("width", Table.PLAYFIELD_W))
|
|
var l: float = float(spec.get("length", Table.PLAYFIELD_L))
|
|
|
|
print("\n=== %-9s %s" % [id, table.table_name()])
|
|
var fails: Array[String] = []
|
|
|
|
# --- 1 IT BUILDS ---------------------------------------------------------
|
|
var want: Dictionary = {}
|
|
var missing: Array[String] = []
|
|
for p in spec.get("parts", []):
|
|
var pd: Dictionary = p
|
|
want[String(pd.get("kind", "?"))] = int(want.get(pd.get("kind", "?"), 0)) + 1
|
|
if table.part(String(pd.get("id", ""))) == null:
|
|
missing.append(String(pd.get("id", "?")))
|
|
var kinds := want.keys()
|
|
kinds.sort()
|
|
var tally: Array[String] = []
|
|
var gaps: Array[String] = []
|
|
for k in kinds:
|
|
var got: int = table.parts_of(String(k)).size()
|
|
tally.append("%s %d" % [k, got])
|
|
if got != int(want[k]):
|
|
gaps.append("%s %d/%d" % [k, got, int(want[k])])
|
|
var total: int = spec.get("parts", []).size()
|
|
print(" build %d/%d parts · %s" % [total - missing.size(), total, " ".join(tally)])
|
|
if total == 0 or not missing.is_empty() or not gaps.is_empty():
|
|
fails.append("build")
|
|
print(" MISSING ids: %s · kind gaps: %s" % [
|
|
", ".join(missing) if missing.size() > 0 else "-",
|
|
", ".join(gaps) if gaps.size() > 0 else "-"])
|
|
|
|
# --- 5a THE BALL DRAINS, from the shooter lane ---------------------------
|
|
# Rest the motors first: Main drives the flippers onto their stops whenever no key is
|
|
# held, so "no flipper input" means driven-to-rest, not limp. A limp bat hangs loose in
|
|
# the tilted gravity and the ball would meet a flipper that never exists in the game.
|
|
_motors(table, false)
|
|
var lane := await _chase_drain(table, table.balls[0] if table.balls.size() > 0 else null,
|
|
w, l, int(LANE_DRAIN_S * Engine.physics_ticks_per_second))
|
|
if String(lane["code"]) != "drained":
|
|
fails.append("lane-drain")
|
|
elif _drained_ball != null:
|
|
table.remove_ball(_drained_ball) # keep it out of the settle and overlap reads
|
|
|
|
# --- 6 IT SETTLES --------------------------------------------------------
|
|
await _step(SETTLE_FRAMES)
|
|
var rest := _residual(table)
|
|
var still: bool = float(rest["lin"]) < STILL_LIN and float(rest["ang"]) < STILL_ANG
|
|
|
|
# --- 3 NO OVERLAPS + 4 INSIDE THE FIELD ----------------------------------
|
|
# Measured at rest, not at t=0: a flipper is only in its real playing pose once the motor
|
|
# has pushed it onto its stop, and that pose is what a ball actually meets.
|
|
var boxes := _colliders(table)
|
|
var pairs := _overlaps(boxes)
|
|
var outside := _outside(boxes, w, l)
|
|
print(" overlaps %d solid pair(s) of %d collider(s)" % [pairs.size(), boxes.size()])
|
|
for h in pairs.slice(0, 6):
|
|
print(" %.4f m into %-16s / %-16s at %s" % [
|
|
h["pen"], h["a"], h["b"], (h["at"] as Vector3).snappedf(0.001)])
|
|
if not pairs.is_empty():
|
|
fails.append("overlap")
|
|
print(" bounds %d part(s) outside the %.2f x %.2f playfield" % [outside.size(), w, l])
|
|
for o in outside.slice(0, 6):
|
|
print(" %-16s reaches %s" % [o["id"], (o["at"] as Vector3).snappedf(0.001)])
|
|
if not outside.is_empty():
|
|
fails.append("bounds")
|
|
|
|
# --- 2 FLIPPERS SWING ----------------------------------------------------
|
|
var before: Array[float] = []
|
|
for b in table.flipper_bodies:
|
|
before.append(b.rotation.y)
|
|
_motors(table, true)
|
|
await _step(SWING_FRAMES)
|
|
var swings: Array[String] = []
|
|
var worst := 999.0
|
|
var burst: Array[String] = []
|
|
for i in table.flipper_bodies.size():
|
|
var d := rad_to_deg(absf(angle_difference(before[i], table.flipper_bodies[i].rotation.y)))
|
|
swings.append("%.1f" % d)
|
|
worst = minf(worst, d)
|
|
# A bat that travels much further than its own limit range has punched through its
|
|
# stop. Not fatal to the gate, but it sweeps playfield it was never meant to reach,
|
|
# and which engine does it is exactly what this testbed is for (see README).
|
|
var lim := _limit_range(spec, String(table.flipper_bodies[i].get_meta("id", "")))
|
|
if lim > 0.0 and d > lim + SWING_SLOP_DEG:
|
|
burst.append("%s %.0f>%.0f" % [table.flipper_bodies[i].get_meta("id", "?"), d, lim])
|
|
var flips_ok: bool = table.flipper_bodies.size() > 0 and worst > SWING_MIN_DEG
|
|
print(" flippers %d · swing_deg [%s] · min %.1f %s%s" % [
|
|
table.flipper_bodies.size(), ", ".join(swings), 0.0 if worst > 900.0 else worst,
|
|
"OK" if flips_ok else "FAIL",
|
|
" NOTE past limit: " + ", ".join(burst) if not burst.is_empty() else ""])
|
|
if not flips_ok:
|
|
fails.append("flippers")
|
|
_motors(table, false)
|
|
await _step(SETTLE_FRAMES)
|
|
|
|
# --- 5b THE BALL DRAINS, from out on the playfield ------------------------
|
|
# The lane roll only proves the lane slopes. This is the real question: dropped into the
|
|
# open field with no flipper help, does the ball find its way out? Release point is the
|
|
# clearest spot the collider survey above could find, because a hand-picked coordinate
|
|
# lands underneath a centre ramp on half these tables and measures the probe, not the table.
|
|
#
|
|
# Saucers are parked first. Table._saucer holds the ball on a SceneTreeTimer, and a
|
|
# SceneTreeTimer counts IDLE frames — headless, the loop spins as fast as it can, so a
|
|
# 0.9 s hold lasts a different number of PHYSICS steps on every machine and every load,
|
|
# and every trajectory downstream of a capture moves with it. Verified: adding one print
|
|
# statement flipped this test's verdict on two tables. Park them and the answer is about
|
|
# the table again. (Not a bug in the game at a steady 60 fps — a gotcha for anything that
|
|
# measures it. If a saucer ever needs testing, test the eject on its own.)
|
|
for s in table.parts_of("saucer"):
|
|
if s is Area3D:
|
|
(s as Area3D).set_deferred("monitoring", false)
|
|
await _step(2)
|
|
var spot := _release_spot(boxes, w, l, table.lane_x)
|
|
var fb := table.add_ball(Vector3(spot.x, Table.BALL_R + 0.006, spot.y))
|
|
fb.linear_velocity = Vector3.ZERO
|
|
fb.angular_velocity = Vector3.ZERO
|
|
var field := await _chase_drain(table, fb, w, l,
|
|
int(FIELD_DRAIN_S * Engine.physics_ticks_per_second))
|
|
# "out" counts as a pass here alongside "drained", and that is not slack — it is working
|
|
# around a measured engine divergence rather than blaming the table for it.
|
|
#
|
|
# Under Box3D the drain Area only reports a ball arriving SLOWLY. Swept at x=0 down the
|
|
# open bottom edge: 0.05 / 0.10 / 0.20 / 0.30 m/s all fire body_entered, 0.40 / 0.60 /
|
|
# 1.00 m/s never do — and the fast ones sit geometrically inside the 5 cm drain volume for
|
|
# seven physics ticks while doing it, so this is not tunnelling. Under Jolt and
|
|
# GodotPhysics the same ball at the same speed reports normally. It is not about
|
|
# teleporting either: a ball moved with add_ball(at) still trips bumpers and rollovers.
|
|
#
|
|
# A ball only ever reaches the shooter lane's bottom at a crawl, which is why the lane
|
|
# test above passes everywhere and why nothing had caught this. So: the lane test gates
|
|
# the drain SIGNAL, and this test gates the geometry — does anything out on this playfield
|
|
# hold a ball forever. The printed line says which way the ball actually left.
|
|
if not (String(field["code"]) in ["drained", "out"]):
|
|
fails.append("field-drain")
|
|
|
|
print(" drain lane %s · field %s (released at %.3f, %.3f)" % [
|
|
_drain_str(lane, LANE_DRAIN_S), _drain_str(field, FIELD_DRAIN_S), spot.x, spot.y])
|
|
for d in [lane, field]:
|
|
var code := String(d["code"])
|
|
if code == "out" and d == lane:
|
|
# Worse than a stuck ball: it is gone AND Rules never hears about it, so the game
|
|
# sits there waiting for a drain that already happened.
|
|
print(" left the table without tripping the drain, at %s" % [
|
|
(d["at"] as Vector3).snappedf(0.001)])
|
|
elif code == "stall" or code == "escaped":
|
|
print(" %s at %s" % [code, (d["at"] as Vector3).snappedf(0.001)])
|
|
print(" settling lin %.4f m/s · ang %.3f rad/s · worst %s %s" % [
|
|
rest["lin"], rest["ang"], rest["who"], "OK" if still else "FAIL"])
|
|
if not still:
|
|
fails.append("settling")
|
|
|
|
print(" VERDICT %s" % ("PASS" if fails.is_empty() else "FAIL — " + ", ".join(fails)))
|
|
# Out of the tree and freed outright rather than queue_free'd: teardown then happens here
|
|
# instead of at some later idle frame, so the next table never shares a physics step with
|
|
# the last one's bodies. Everything above turned out to be sensitive to exactly that kind
|
|
# of overlap, so it is not worth leaving to chance.
|
|
get_root().remove_child(table)
|
|
table.free()
|
|
await process_frame
|
|
return fails.is_empty()
|
|
|
|
# ---------------------------------------------------------------- driving
|
|
## Exactly Main._physics_process's numbers, so the probe measures the game's flippers and not
|
|
## some idealised version of them.
|
|
func _motors(table: Table, held: bool) -> void:
|
|
for i in table.flipper_joints.size():
|
|
var v: float = (26.0 if held else -9.0) * float(table.flip_side(i)) * _flip_sign
|
|
table.flipper_joints[i].set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, v)
|
|
|
|
func _step(n: int) -> void:
|
|
for i in n:
|
|
_frame += 1
|
|
await physics_frame
|
|
|
|
func _on_drained(b: RigidBody3D) -> void:
|
|
_drain_frame = _frame
|
|
_drained_ball = b
|
|
|
|
func _limit_range(spec: Dictionary, id: String) -> float:
|
|
for p in spec.get("parts", []):
|
|
var pd: Dictionary = p
|
|
if String(pd.get("id", "")) == id:
|
|
return absf(float(pd.get("limit_hi", 0.0)) - float(pd.get("limit_lo", 0.0)))
|
|
return 0.0
|
|
|
|
## Follow one ball until the table resolves it, and say HOW it resolved:
|
|
## drained — the drain Area fired, which is the only ending Rules can actually see
|
|
## out — the ball left past the bottom edge but the Area never fired
|
|
## escaped — it left sideways, over a rail
|
|
## stall — the geometry stopped it and is holding it
|
|
## timeout — still rattling when the budget ran out
|
|
func _chase_drain(table: Table, ball: RigidBody3D, w: float, l: float, limit: int) -> Dictionary:
|
|
_drain_frame = -1
|
|
_drained_ball = null
|
|
if ball == null:
|
|
return {"code": "missing", "frames": -1, "at": Vector3.ZERO}
|
|
var start := _frame
|
|
var stall := 0
|
|
while _frame - start < limit:
|
|
_frame += 1
|
|
await physics_frame
|
|
if _drain_frame >= 0:
|
|
return {"code": "drained", "frames": _drain_frame - start, "at": ball.position}
|
|
if not is_instance_valid(ball):
|
|
return {"code": "out", "frames": _frame - start, "at": Vector3.ZERO}
|
|
var p := ball.position
|
|
if absf(p.x) > w * 0.5 + 0.05:
|
|
return {"code": "escaped", "frames": _frame - start, "at": p}
|
|
if p.z > l * 0.5 + 0.06 or p.y < -0.05:
|
|
return {"code": "out", "frames": _frame - start, "at": p}
|
|
# A saucer legitimately holds the ball frozen for a second or so; only a ball the
|
|
# table has stopped under its own power counts as stalled.
|
|
if not ball.freeze and ball.linear_velocity.length() < STALL_V:
|
|
stall += 1
|
|
if stall > STALL_FRAMES:
|
|
return {"code": "stall", "frames": _frame - start, "at": p}
|
|
else:
|
|
stall = 0
|
|
return {"code": "timeout", "frames": limit,
|
|
"at": ball.position if is_instance_valid(ball) else Vector3.ZERO}
|
|
|
|
func _drain_str(d: Dictionary, budget: float) -> String:
|
|
var code := String(d["code"])
|
|
var t := float(d["frames"]) / float(Engine.physics_ticks_per_second)
|
|
match code:
|
|
"drained": return "%.2f s" % t
|
|
"out": return "%.2f s off the edge, drain never fired" % t
|
|
"escaped": return "ESCAPED over a rail"
|
|
"stall": return "STALLED on the playfield"
|
|
"missing": return "NO BALL"
|
|
return "NEVER (>%.0f s)" % budget
|
|
|
|
func _residual(table: Table) -> Dictionary:
|
|
var lin := 0.0
|
|
var ang := 0.0
|
|
var who := "-"
|
|
for n in table.get_children():
|
|
var b := n as RigidBody3D
|
|
if b == null or b.freeze or table.balls.has(b):
|
|
continue
|
|
var lv := b.linear_velocity.length()
|
|
var av := b.angular_velocity.length()
|
|
if lv > lin or av > ang:
|
|
who = String(b.name)
|
|
lin = maxf(lin, lv)
|
|
ang = maxf(ang, av)
|
|
return {"lin": lin, "ang": ang, "who": who}
|
|
|
|
# ---------------------------------------------------------------- geometry
|
|
## Every collider of every registered part, reduced to a box that yaws about Y.
|
|
##
|
|
## That reduction is exact for this part vocabulary: parts yaw about Y and nothing else,
|
|
## except a ramp bed whose shapes also pitch about their local X — folded in below by
|
|
## widening the Y/Z half-extents to the pitched box's own bounds, which is tight, not sloppy.
|
|
func _colliders(table: Table) -> Array:
|
|
var out: Array = []
|
|
for p in table.spec.get("parts", []):
|
|
var pd: Dictionary = p
|
|
var pid := String(pd.get("id", ""))
|
|
var n := table.part(pid)
|
|
if n == null or not is_instance_valid(n):
|
|
continue
|
|
var host := n as Node3D
|
|
# Only bodies are solid. A spinner/saucer/rollover is an Area3D the ball is MEANT to
|
|
# pass through, so overlapping triggers are not a fault — they are still bounds-checked.
|
|
var solid: bool = n is StaticBody3D or n is RigidBody3D
|
|
for c in host.get_children():
|
|
var cs := c as CollisionShape3D
|
|
if cs == null or cs.disabled or cs.shape == null:
|
|
continue
|
|
var h := Vector3.ZERO
|
|
var round_xz := false
|
|
var round_y := false
|
|
if cs.shape is BoxShape3D:
|
|
h = (cs.shape as BoxShape3D).size * 0.5
|
|
elif cs.shape is CylinderShape3D:
|
|
var cy := cs.shape as CylinderShape3D
|
|
h = Vector3(cy.radius, cy.height * 0.5, cy.radius)
|
|
round_xz = true
|
|
elif cs.shape is SphereShape3D:
|
|
var r: float = (cs.shape as SphereShape3D).radius
|
|
h = Vector3(r, r, r)
|
|
round_xz = true
|
|
round_y = true
|
|
else:
|
|
continue
|
|
var pitch := cs.rotation.x
|
|
if absf(pitch) > 0.0001:
|
|
h = Vector3(h.x,
|
|
absf(h.y * cos(pitch)) + absf(h.z * sin(pitch)),
|
|
absf(h.y * sin(pitch)) + absf(h.z * cos(pitch)))
|
|
# A round shape boxed up over-reports at the corners, so parts that are nested but
|
|
# clear would false-positive; shrink to the inscribed square for the overlap test
|
|
# only. Bounds and clearance still use the true extent.
|
|
var hov := h
|
|
if round_xz:
|
|
hov = Vector3(h.x * 0.707, hov.y, h.z * 0.707)
|
|
if round_y:
|
|
hov.y = h.y * 0.707
|
|
out.append({"id": pid, "solid": solid, "c": cs.global_position,
|
|
"h": h, "hov": hov, "yaw": host.global_rotation.y})
|
|
return out
|
|
|
|
## Two boxes that only yaw about Y keep their Y axes parallel, so the test splits into a 1D
|
|
## interval on Y and a 2D rotated-rectangle SAT in XZ — exact, and far cheaper than a general
|
|
## OBB test. Returns the smallest overlap over all separating axes: the depth of the wedge.
|
|
func _pen(a: Dictionary, b: Dictionary) -> float:
|
|
var ha: Vector3 = a["hov"]
|
|
var hb: Vector3 = b["hov"]
|
|
var ca: Vector3 = a["c"]
|
|
var cb: Vector3 = b["c"]
|
|
var best: float = (ha.y + hb.y) - absf(ca.y - cb.y)
|
|
if best <= 0.0:
|
|
return 0.0
|
|
var ya: float = a["yaw"]
|
|
var yb: float = b["yaw"]
|
|
var ax := [Vector2(cos(ya), -sin(ya)), Vector2(sin(ya), cos(ya)),
|
|
Vector2(cos(yb), -sin(yb)), Vector2(sin(yb), cos(yb))]
|
|
var d := Vector2(cb.x - ca.x, cb.z - ca.z)
|
|
for u in ax:
|
|
var ra: float = absf(ha.x * ax[0].dot(u)) + absf(ha.z * ax[1].dot(u))
|
|
var rb: float = absf(hb.x * ax[2].dot(u)) + absf(hb.z * ax[3].dot(u))
|
|
var o: float = ra + rb - absf(d.dot(u))
|
|
if o <= 0.0:
|
|
return 0.0
|
|
best = minf(best, o)
|
|
return best
|
|
|
|
func _overlaps(boxes: Array) -> Array:
|
|
var hits: Array = []
|
|
for i in boxes.size():
|
|
var a: Dictionary = boxes[i]
|
|
if not a["solid"]:
|
|
continue
|
|
for j in range(i + 1, boxes.size()):
|
|
var b: Dictionary = boxes[j]
|
|
if not b["solid"] or a["id"] == b["id"]:
|
|
continue
|
|
var pen := _pen(a, b)
|
|
if pen > OVERLAP_CLEAR:
|
|
hits.append({"a": a["id"], "b": b["id"], "pen": pen,
|
|
"at": ((a["c"] as Vector3) + (b["c"] as Vector3)) * 0.5})
|
|
hits.sort_custom(func(x, y) -> bool: return float(x["pen"]) > float(y["pen"]))
|
|
return hits
|
|
|
|
## A part outside the playfield rectangle is unreachable at best and a ball trap at worst.
|
|
## A yawed box's footprint is its own extents projected onto X and Z.
|
|
func _outside(boxes: Array, w: float, l: float) -> Array:
|
|
var bad: Array = []
|
|
var seen: Dictionary = {}
|
|
for e in boxes:
|
|
var b: Dictionary = e
|
|
var h: Vector3 = b["h"]
|
|
var yaw: float = b["yaw"]
|
|
var c: Vector3 = b["c"]
|
|
var reach := Vector3(absf(c.x) + absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)), c.y,
|
|
absf(c.z) + absf(h.x * sin(yaw)) + absf(h.z * cos(yaw)))
|
|
if reach.x > w * 0.5 + EDGE_SLOP or reach.z > l * 0.5 + EDGE_SLOP:
|
|
if seen.has(b["id"]):
|
|
continue
|
|
seen[b["id"]] = true
|
|
bad.append({"id": b["id"], "at": reach})
|
|
return bad
|
|
|
|
## The emptiest point in the upper playfield, as (x, z). Sweeps a grid and keeps the sample
|
|
## whose worst footprint clearance is largest, preferring higher up the table on a tie.
|
|
## Colliders that sit entirely above ball height are skipped — a ball passes under a raised
|
|
## ramp bed, it does not collide with its plan view.
|
|
func _release_spot(boxes: Array, w: float, l: float, lane_x: float) -> Vector2:
|
|
var rects: Array = []
|
|
for e in boxes:
|
|
var b: Dictionary = e
|
|
var h: Vector3 = b["h"]
|
|
var c: Vector3 = b["c"]
|
|
if c.y - h.y > Table.BALL_R * 2.0 + 0.01:
|
|
continue
|
|
var yaw: float = b["yaw"]
|
|
rects.append({"c": Vector2(c.x, c.z),
|
|
"e": Vector2(absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)),
|
|
absf(h.x * sin(yaw)) + absf(h.z * cos(yaw)))})
|
|
var best := Vector2(0.0, -l * 0.2)
|
|
var best_clear := -99.0
|
|
for zi in 41:
|
|
var z: float = -l * 0.36 + float(zi) / 40.0 * (l * 0.28)
|
|
for xi in 33:
|
|
var x: float = -w * 0.34 + float(xi) / 32.0 * (w * 0.68)
|
|
if x > lane_x - 0.05:
|
|
continue # never release into the shooter lane
|
|
var clear := 99.0
|
|
for r in rects:
|
|
var d: Vector2 = (Vector2(x, z) - (r["c"] as Vector2)).abs() - (r["e"] as Vector2)
|
|
clear = minf(clear, maxf(d.x, d.y))
|
|
if clear > best_clear:
|
|
best_clear = clear
|
|
best = Vector2(x, z)
|
|
return best
|