extends Node3D class_name Main ## Orchestrator: builds a table, drives the flippers, and wires the three systems that ## don't know about each other — Rules (what a shot is worth), Juice (how it feels), ## Hud (how it reads). Everything hangs off Table's `hit` / `drained` signals. ## ## Controls: Z / SLASH flippers · SPACE plunger · ARROWS nudge (tilt if you lean on it) ## T next table · R new game · ESC quit ## ## Godot 4.7. Runs under Box3D, Jolt or GodotPhysics — see BOX3D_ENGINE_NOTES.md. const NUDGE_IMPULSE := 0.035 const NUDGE_COOLDOWN := 0.25 const PLUNGE_MAX := 0.44 const PLUNGE_CHARGE := 1.6 ## how fast the plunger winds up, per second var table: Table var rules: Node ## Rules.gd — scoring, multiball, ball count var juice: Node ## Juice.gd — audio, flash, shake var hud: CanvasLayer ## Hud.gd — score, ball, messages var table_id := "neon" var _cam: Camera3D var _flip_sign := 1.0 ## Box3D's hinge motor runs opposite Jolt/GodotPhysics var _plunge := 0.0 var _nudge_cd := 0.0 func _ready() -> void: randomize() var engine := String(ProjectSettings.get_setting("physics/3d/physics_engine", "?")) # THE ENGINE DIVERGENCE, handled in one place: under Box3D a positive motor velocity # swings a flipper the opposite way to Jolt/GodotPhysics. Rather than author two sets # of tables, flip the sign once here. See README's A/B results. _flip_sign = -1.0 if engine.begins_with("Box3D") else 1.0 print("[main] physics engine: %s flip_sign=%.0f" % [engine, _flip_sign]) _world() table = Table.new() table.name = "Table" add_child(table) # The three systems, each optional and each ignorant of the others. Instantiated by # class name so a lane can land its file and it just plugs in — nothing here needs # editing to pick up Rules/Juice/Hud, and the game still runs if one is missing. rules = _spawn_system("Rules") juice = _spawn_system("Juice") hud = _spawn_system("Hud") _load_table(table_id) if OS.get_environment("PINBALL_AUTOTEST") == "1": await _autotest() ## Instantiate a system by class name if that class exists yet, else return null. Lets the ## Rules/Juice/Hud lanes land independently without anyone editing this file. func _spawn_system(cls: String) -> Node: if not ClassDB.class_exists(cls) and not _script_class_exists(cls): print("[main] %s not present — skipping" % cls) return null var n: Node = ClassDB.instantiate(cls) if ClassDB.class_exists(cls) else null if n == null: var path := "res://scripts/%s.gd" % cls if not ResourceLoader.exists(path): return null var scr := load(path) as Script if scr == null: return null var o = scr.new() if o is Node: n = o else: return null n.name = cls add_child(n) if n.has_method("setup"): n.call("setup", self) print("[main] %s online" % cls) return n func _script_class_exists(cls: String) -> bool: return ResourceLoader.exists("res://scripts/%s.gd" % cls) func _world() -> void: var we := WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = Color(0.02, 0.02, 0.04) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.45, 0.48, 0.60) env.ambient_light_energy = 0.55 env.glow_enabled = true env.glow_intensity = 0.5 env.glow_bloom = 0.15 we.environment = env add_child(we) var key := DirectionalLight3D.new() key.rotation_degrees = Vector3(-62, 24, 0) key.light_energy = 1.1 key.shadow_enabled = true add_child(key) _cam = Camera3D.new() add_child(_cam) _cam.position = Vector3(0.0, 0.92, 0.86) _cam.rotation_degrees = Vector3(-46, 0, 0) _cam.fov = 52.0 _cam.current = true ## Build a table and re-wire every system to it. Safe to call at any time. func _load_table(id: String) -> void: table_id = id table.build(Tables.get_table(id)) table.hit.connect(_on_hit) table.drained.connect(_on_drained) if rules != null and rules.has_method("start_game"): rules.call("start_game", table) if hud != null and hud.has_method("set_table"): hud.call("set_table", table.table_name(), String(table.spec.get("subtitle", ""))) print("[main] table: %s (%d parts)" % [table.table_name(), table.spec.get("parts", []).size()]) # ---------------------------------------------------------------- signal fan-out func _on_hit(kind: String, id: String, at: Vector3, data: Dictionary) -> void: if rules != null and rules.has_method("on_hit"): rules.call("on_hit", kind, id, at, data) if juice != null and juice.has_method("on_hit"): juice.call("on_hit", kind, id, at, data) func _on_drained(ball: RigidBody3D) -> void: if rules != null and rules.has_method("on_drained"): rules.call("on_drained", ball) else: table.park_ball(ball) # no rules loaded: just recycle it if juice != null and juice.has_method("on_drained"): juice.call("on_drained", ball) # ---------------------------------------------------------------- input func _physics_process(delta: float) -> void: _nudge_cd = maxf(0.0, _nudge_cd - delta) var tilted: bool = rules != null and rules.get("tilted") == true # --- flippers. Held = drive to the stop, released = fall back. A tilt kills them, # --- which is the entire point of a tilt. var lf := Input.is_physical_key_pressed(KEY_Z) and not tilted var rf := Input.is_physical_key_pressed(KEY_SLASH) and not tilted for i in table.flipper_joints.size(): var j := table.flipper_joints[i] var side := table.flip_side(i) var held := lf if side < 0 else rf var v: float = (26.0 if held else -9.0) * float(side) * _flip_sign j.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, v) # --- plunger: hold SPACE to wind up, release to fire. A real plunger rewards feel. var in_lane := false for b in table.balls: if table.ball_in_lane(b): in_lane = true break if in_lane and Input.is_physical_key_pressed(KEY_SPACE): _plunge = minf(PLUNGE_MAX, _plunge + PLUNGE_CHARGE * delta * PLUNGE_MAX) elif _plunge > 0.0: for b in table.balls: if table.ball_in_lane(b): b.apply_central_impulse(Vector3(0, 0, -_plunge)) table.ball_launched.emit(b) if juice != null and juice.has_method("on_plunge"): juice.call("on_plunge", _plunge / PLUNGE_MAX) break _plunge = 0.0 # --- nudge: shove the whole table. Too much, too fast and Rules tilts you. if _nudge_cd <= 0.0: var n := Vector3.ZERO if Input.is_physical_key_pressed(KEY_LEFT): n.x -= 1.0 if Input.is_physical_key_pressed(KEY_RIGHT): n.x += 1.0 if Input.is_physical_key_pressed(KEY_UP): n.z -= 1.0 if n != Vector3.ZERO and not tilted: _nudge_cd = NUDGE_COOLDOWN for b in table.balls: b.apply_central_impulse(n.normalized() * NUDGE_IMPULSE) if rules != null and rules.has_method("on_nudge"): rules.call("on_nudge") if juice != null and juice.has_method("on_nudge"): juice.call("on_nudge", n) func plunge_charge() -> float: return _plunge / PLUNGE_MAX func _unhandled_input(e: InputEvent) -> void: if not (e is InputEventKey) or not e.pressed or e.echo: return match (e as InputEventKey).keycode: KEY_T: _load_table(Tables.next_id(table_id)) KEY_R: if rules != null and rules.has_method("start_game"): rules.call("start_game", table) else: for b in table.balls: table.park_ball(b) KEY_ESCAPE: get_tree().quit(0) # ---------------------------------------------------------------- headless test ## Build every table, prove the flippers actually swing under whatever engine is ## selected, and report. This is the gate — a table that doesn't flip isn't a table. func _autotest() -> void: set_physics_process(false) var fails := 0 for id in Tables.ORDER: _load_table(id) for i in 8: await get_tree().physics_frame var before: Array[float] = [] for b in table.flipper_bodies: before.append(b.rotation.y) for i in table.flipper_joints.size(): var side := table.flip_side(i) table.flipper_joints[i].set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, 26.0 * float(side) * _flip_sign) for i in 30: await get_tree().physics_frame var swings: Array[String] = [] var worst := 999.0 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("%.0f" % d) worst = minf(worst, d) var parts: int = table.spec.get("parts", []).size() var ok := worst > 8.0 if not ok: fails += 1 print("AUTOTEST %-14s parts=%-3d flippers=%d swing_deg=[%s] %s" % [ id, parts, table.flipper_bodies.size(), ", ".join(swings), "OK" if ok else "FAIL(flipper did not swing)"]) print("AUTOTEST result: %s" % ("ALL TABLES OK" if fails == 0 else "%d TABLE(S) FAILED" % fails)) get_tree().quit(1 if fails > 0 else 0)