extends Node3D class_name Table ## A pinball table, built from a DATA SPEC (see Tables.gd) rather than a scene file. ## ## Same reasoning as Destroyulator's Floorplan/Levels split: a table becomes a Dictionary ## you can diff, generate and hot-swap, parallel work doesn't collide in one .tscn, and a ## headless probe can build every table and measure it without a renderer. ## ## Everything that scores emits `hit`. Nothing in here knows what a point is worth — ## Rules.gd owns that, Juice.gd owns how it feels, Hud.gd owns how it reads. ## ## Godot 4.7. Physics engine is whatever project.godot / override.cfg selects. signal hit(kind: String, id: String, at: Vector3, data: Dictionary) signal drained(ball: RigidBody3D) signal ball_launched(ball: RigidBody3D) const BALL_R := 0.0135 ## 27 mm pinball const BALL_MASS := 0.080 ## 80 g const PLAYFIELD_W := 0.52 const PLAYFIELD_L := 1.10 var spec: Dictionary = {} var balls: Array[RigidBody3D] = [] var flipper_joints: Array[HingeJoint3D] = [] var flipper_bodies: Array[RigidBody3D] = [] var _flip_side: Array[int] = [] # +1 right, -1 left var _parts: Dictionary = {} # id -> node var _mat_cache: Dictionary = {} var _drop_state: Dictionary = {} # bank name -> how many are down # The plunger lane sits at +X, and the ball is launched toward -Z (up the table). var lane_x := 0.0 var lane_z := 0.0 func build(s: Dictionary) -> void: spec = s for c in get_children(): c.queue_free() balls.clear() flipper_joints.clear() flipper_bodies.clear() _parts.clear() _drop_state.clear() _playfield() for p in spec.get("parts", []): _part(p as Dictionary) _spawn_ball() func table_name() -> String: return String(spec.get("name", "UNTITLED")) func part(id: String) -> Node: return _parts.get(id) func parts_of(kind: String) -> Array: var out: Array = [] for k in _parts: var n = _parts[k] if is_instance_valid(n) and n.get_meta("kind", "") == kind: out.append(n) return out # ---------------------------------------------------------------- materials func _mat(c: Color, metal := 0.0, rough := 0.6, emit := 0.0) -> StandardMaterial3D: var key := "%s_%.2f_%.2f_%.2f" % [c, metal, rough, emit] if _mat_cache.has(key): return _mat_cache[key] var m := StandardMaterial3D.new() m.albedo_color = c m.metallic = metal m.roughness = rough if emit > 0.0: m.emission_enabled = true m.emission = c m.emission_energy_multiplier = emit _mat_cache[key] = m return m func _pal(key: String, fallback: Color) -> Color: var p: Dictionary = spec.get("palette", {}) return p.get(key, fallback) # ---------------------------------------------------------------- the slab ## Playfield, side rails and the back wall. The whole table is built flat in XZ and the ## *gravity vector* is tilted (project.godot), which is how a real table works and keeps ## every local transform readable. func _playfield() -> void: var w: float = float(spec.get("width", PLAYFIELD_W)) var l: float = float(spec.get("length", PLAYFIELD_L)) var body := StaticBody3D.new() body.name = "Playfield" add_child(body) _slab(body, Vector3(w, 0.02, l), Vector3(0, -0.01, 0), _pal("field", Color(0.12, 0.16, 0.22))) # rails: left, right, top. The bottom is open — that's the drain. var h := 0.06 _slab(body, Vector3(0.02, h, l), Vector3(-w * 0.5, h * 0.5, 0), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25) _slab(body, Vector3(0.02, h, l), Vector3(w * 0.5, h * 0.5, 0), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25) _slab(body, Vector3(w, h, 0.02), Vector3(0, h * 0.5, -l * 0.5), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25) # plunger lane divider — a wall that stops short so the ball can enter the field lane_x = w * 0.5 - 0.035 lane_z = l * 0.5 - 0.06 _slab(body, Vector3(0.014, h, l * 0.62), Vector3(w * 0.5 - 0.07, h * 0.5, l * 0.19), _pal("rail", Color(0.55, 0.57, 0.62)), 0.8, 0.3) # the drain: an Area at the bottom, plus catch walls angled to feed it var drain := Area3D.new() drain.name = "Drain" var ds := CollisionShape3D.new() var db := BoxShape3D.new() db.size = Vector3(w, 0.10, 0.05) ds.shape = db drain.add_child(ds) add_child(drain) drain.position = Vector3(0, 0.03, l * 0.5 + 0.03) drain.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): drained.emit(b)) func _slab(host: StaticBody3D, size: Vector3, at: Vector3, col: Color, metal := 0.0, rough := 0.7) -> MeshInstance3D: var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = size mi.mesh = bm mi.material_override = _mat(col, metal, rough) mi.position = at host.add_child(mi) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = size cs.shape = sh cs.position = at host.add_child(cs) return mi # ---------------------------------------------------------------- the ball func _spawn_ball() -> RigidBody3D: var b := RigidBody3D.new() b.name = "Ball%d" % (balls.size() + 1) b.mass = BALL_MASS # CCD on: GodotPhysics and Jolt both tunnel a fast ball through thin walls without it. # Box3D contains it either way (speculative contacts are always on) — see README. b.continuous_cd = true b.contact_monitor = true b.max_contacts_reported = 8 b.can_sleep = false var pm := PhysicsMaterial.new() pm.bounce = 0.32 pm.friction = 0.08 b.physics_material_override = pm var mi := MeshInstance3D.new() var sm := SphereMesh.new() sm.radius = BALL_R sm.height = BALL_R * 2.0 mi.mesh = sm mi.material_override = _mat(Color(0.86, 0.88, 0.92), 1.0, 0.12) b.add_child(mi) var cs := CollisionShape3D.new() var sp := SphereShape3D.new() sp.radius = BALL_R cs.shape = sp b.add_child(cs) add_child(b) b.position = Vector3(lane_x, BALL_R + 0.005, lane_z) balls.append(b) return b ## Extra balls for multiball — spawned at the same feed point with a small spread. func add_ball(at := Vector3.INF) -> RigidBody3D: var b := _spawn_ball() if at != Vector3.INF: b.position = at b.linear_velocity = Vector3(randf_range(-0.4, 0.4), 0, randf_range(-0.6, -0.2)) ball_launched.emit(b) return b func remove_ball(b: RigidBody3D) -> void: balls.erase(b) if is_instance_valid(b): b.queue_free() func park_ball(b: RigidBody3D) -> void: if not is_instance_valid(b): return b.linear_velocity = Vector3.ZERO b.angular_velocity = Vector3.ZERO b.position = Vector3(lane_x, BALL_R + 0.005, lane_z) func ball_in_lane(b: RigidBody3D) -> bool: return is_instance_valid(b) and b.position.x > lane_x - 0.03 \ and b.position.z > lane_z - 0.08 and b.linear_velocity.length() < 0.06 # ---------------------------------------------------------------- parts func _part(p: Dictionary) -> void: var kind := String(p.get("kind", "")) match kind: "flipper": _flipper(p) "bumper": _bumper(p) "sling": _sling(p) "target": _target(p) "drop": _drop(p) "spinner": _spinner(p) "saucer": _saucer(p) "rollover": _rollover(p) "wall": _wall(p) "post": _post(p) "ramp": _ramp(p) _: push_warning("[table] unknown part kind: %s" % kind) func _register(n: Node, p: Dictionary, kind: String) -> void: var id := String(p.get("id", "%s_%d" % [kind, _parts.size()])) n.set_meta("kind", kind) n.set_meta("id", id) n.set_meta("points", int(p.get("points", 0))) _parts[id] = n func _emit(kind: String, n: Node, at: Vector3, extra := {}) -> void: var d := extra.duplicate() d["points"] = int(n.get_meta("points", 0)) hit.emit(kind, String(n.get_meta("id", "")), at, d) ## A flipper: a bat on a motorised hinge. The motor SIGN differs between engines — ## Box3D is inverted versus Jolt/GodotPhysics (documented in README), so `side` is applied ## through `Main.flip_sign()` rather than baked in here. func _flipper(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var side := int(p.get("side", -1)) # -1 left, +1 right var len_: float = float(p.get("length", 0.075)) var body := RigidBody3D.new() body.name = "Flipper_%s" % p.get("id", "f") body.mass = 0.12 body.can_sleep = false var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(len_, 0.016, 0.020) mi.mesh = bm mi.position = Vector3(-side * len_ * 0.5, 0, 0) mi.material_override = _mat(_pal("flipper", Color(0.90, 0.25, 0.30)), 0.2, 0.35) body.add_child(mi) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = bm.size cs.shape = sh cs.position = mi.position body.add_child(cs) add_child(body) body.position = at var anchor := StaticBody3D.new() anchor.name = "FlipperAnchor_%s" % p.get("id", "f") add_child(anchor) anchor.position = at var j := HingeJoint3D.new() add_child(j) j.position = at # Godot's HingeJoint3D spins about its own local Z. A flipper has to swing flat across # the playfield — i.e. about world Y — so stand the joint on end. Without this the # motor is commanded correctly and nothing moves, which is exactly what the autotest # caught the first time this table was built in code instead of in a .tscn. j.rotation.x = deg_to_rad(-90.0) j.node_a = j.get_path_to(anchor) j.node_b = j.get_path_to(body) j.set_flag(HingeJoint3D.FLAG_USE_LIMIT, true) var lo: float = deg_to_rad(float(p.get("limit_lo", -30.0))) var hi: float = deg_to_rad(float(p.get("limit_hi", 30.0))) j.set_param(HingeJoint3D.PARAM_LIMIT_LOWER, lo) j.set_param(HingeJoint3D.PARAM_LIMIT_UPPER, hi) j.set_flag(HingeJoint3D.FLAG_ENABLE_MOTOR, true) j.set_param(HingeJoint3D.PARAM_MOTOR_MAX_IMPULSE, float(p.get("torque", 6.0))) flipper_joints.append(j) flipper_bodies.append(body) _flip_side.append(side) _register(body, p, "flipper") func flip_side(i: int) -> int: return _flip_side[i] if i < _flip_side.size() else -1 ## Pop bumper: a post that kicks the ball away from its centre, hard. func _bumper(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var r: float = float(p.get("radius", 0.028)) var host := StaticBody3D.new() host.name = "Bumper_%s" % p.get("id", "b") var mi := MeshInstance3D.new() var cm := CylinderMesh.new() cm.top_radius = r cm.bottom_radius = r * 1.15 cm.height = 0.05 mi.mesh = cm mi.position.y = 0.025 mi.material_override = _mat(_pal("bumper", Color(0.95, 0.75, 0.2)), 0.3, 0.3, 0.6) host.add_child(mi) var cs := CollisionShape3D.new() var sh := CylinderShape3D.new() sh.radius = r sh.height = 0.05 cs.shape = sh cs.position.y = 0.025 host.add_child(cs) add_child(host) host.position = at var area := Area3D.new() var acs := CollisionShape3D.new() var asp := SphereShape3D.new() asp.radius = r + BALL_R + 0.002 acs.shape = asp area.add_child(acs) add_child(area) area.position = at + Vector3(0, 0.02, 0) var kick: float = float(p.get("kick", 0.16)) area.body_entered.connect(func(b: Node3D) -> void: if not (b is RigidBody3D) or not balls.has(b): return var away := (b.global_position - at) away.y = 0.0 if away.length() < 0.0001: away = Vector3(0, 0, -1) (b as RigidBody3D).apply_central_impulse(away.normalized() * kick) _emit("bumper", host, at)) _register(host, p, "bumper") ## Slingshot: the angled kicker above each outlane. Same idea as a bumper but it fires ## along its own normal, which is what makes a ball rattle across the lower playfield. func _sling(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var dir: Vector3 = p.get("dir", Vector3(1, 0, -1)).normalized() var len_: float = float(p.get("length", 0.09)) var host := StaticBody3D.new() host.name = "Sling_%s" % p.get("id", "s") var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(len_, 0.04, 0.014) mi.mesh = bm mi.material_override = _mat(_pal("sling", Color(0.35, 0.85, 0.45)), 0.1, 0.4, 0.35) mi.position.y = 0.02 host.add_child(mi) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = bm.size cs.shape = sh cs.position.y = 0.02 host.add_child(cs) add_child(host) host.position = at host.rotation.y = float(p.get("yaw", 0.0)) var area := Area3D.new() var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = Vector3(len_, 0.05, 0.03) acs.shape = ab area.add_child(acs) add_child(area) area.position = at + Vector3(0, 0.02, 0) area.rotation.y = host.rotation.y var kick: float = float(p.get("kick", 0.13)) area.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): (b as RigidBody3D).apply_central_impulse(dir * kick) _emit("sling", host, at)) _register(host, p, "sling") ## Standing target: hit it, score it, it stays up. func _target(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var host := StaticBody3D.new() host.name = "Target_%s" % p.get("id", "t") var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(float(p.get("width", 0.035)), 0.035, 0.010) mi.mesh = bm mi.position.y = 0.018 mi.material_override = _mat(_pal("target", Color(0.95, 0.35, 0.75)), 0.1, 0.4, 0.3) host.add_child(mi) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = bm.size cs.shape = sh cs.position.y = 0.018 host.add_child(cs) add_child(host) host.position = at host.rotation.y = float(p.get("yaw", 0.0)) var area := Area3D.new() var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = bm.size + Vector3(0.006, 0.01, 0.018) acs.shape = ab area.add_child(acs) add_child(area) area.position = at + Vector3(0, 0.018, 0) area.rotation.y = host.rotation.y area.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): _emit("target", host, at)) _register(host, p, "target") ## Drop target: falls out of the playfield when hit. A whole bank down = a bonus, and ## Rules.gd decides what that's worth. `bank` groups them. func _drop(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var bank := String(p.get("bank", "bank")) var host := StaticBody3D.new() host.name = "Drop_%s" % p.get("id", "d") var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(0.030, 0.038, 0.010) mi.mesh = bm mi.position.y = 0.019 mi.material_override = _mat(_pal("drop", Color(0.98, 0.85, 0.30)), 0.1, 0.4, 0.35) host.add_child(mi) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = bm.size cs.shape = sh cs.position.y = 0.019 host.add_child(cs) add_child(host) host.position = at host.rotation.y = float(p.get("yaw", 0.0)) host.set_meta("bank", bank) host.set_meta("down", false) var area := Area3D.new() var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = bm.size + Vector3(0.006, 0.01, 0.018) acs.shape = ab area.add_child(acs) add_child(area) area.position = at + Vector3(0, 0.019, 0) area.rotation.y = host.rotation.y area.body_entered.connect(func(b: Node3D) -> void: if not (b is RigidBody3D) or not balls.has(b) or host.get_meta("down"): return _drop_down(host, bank)) _register(host, p, "drop") func _drop_down(host: StaticBody3D, bank: String) -> void: host.set_meta("down", true) host.process_mode = Node.PROCESS_MODE_DISABLED host.visible = false for c in host.get_children(): if c is CollisionShape3D: (c as CollisionShape3D).set_deferred("disabled", true) _drop_state[bank] = int(_drop_state.get(bank, 0)) + 1 var total := 0 for k in _parts: var n = _parts[k] if is_instance_valid(n) and n.get_meta("kind", "") == "drop" and n.get_meta("bank", "") == bank: total += 1 var cleared: bool = int(_drop_state[bank]) >= total _emit("drop", host, host.global_position, {"bank": bank, "cleared": cleared}) if cleared: hit.emit("bank_cleared", bank, host.global_position, {"bank": bank, "points": 0}) ## Put a whole bank back up (Rules calls this after awarding the bonus). func reset_bank(bank: String) -> void: _drop_state[bank] = 0 for k in _parts: var n = _parts[k] if not is_instance_valid(n) or n.get_meta("kind", "") != "drop": continue if n.get_meta("bank", "") != bank: continue n.set_meta("down", false) n.process_mode = Node.PROCESS_MODE_INHERIT n.visible = true for c in (n as Node).get_children(): if c is CollisionShape3D: (c as CollisionShape3D).set_deferred("disabled", false) ## Spinner: a blade the ball whips through. Scores per revolution, so a fast shot ## through it is worth many times a slow one — the classic risk/reward lane. func _spinner(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var area := Area3D.new() area.name = "Spinner_%s" % p.get("id", "sp") var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = Vector3(0.040, 0.05, 0.012) acs.shape = ab area.add_child(acs) var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(0.038, 0.030, 0.003) mi.mesh = bm mi.material_override = _mat(_pal("spinner", Color(0.75, 0.85, 0.95)), 0.9, 0.2) mi.position.y = 0.02 area.add_child(mi) add_child(area) area.position = at area.rotation.y = float(p.get("yaw", 0.0)) area.set_meta("blade", mi) area.body_entered.connect(func(b: Node3D) -> void: if not (b is RigidBody3D) or not balls.has(b): return var spd: float = (b as RigidBody3D).linear_velocity.length() var spins: int = clampi(int(spd * 6.0), 1, 24) area.set_meta("spin_left", float(spins)) _emit("spinner", area, at, {"spins": spins, "speed": spd})) _register(area, p, "spinner") ## Saucer / kicker hole: swallows the ball, holds it, spits it back out. The classic ## "award" device — Rules decides what being captured is worth. func _saucer(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var area := Area3D.new() area.name = "Saucer_%s" % p.get("id", "sc") var acs := CollisionShape3D.new() var asp := SphereShape3D.new() asp.radius = 0.024 acs.shape = asp area.add_child(acs) var mi := MeshInstance3D.new() var cm := CylinderMesh.new() cm.top_radius = 0.024 cm.bottom_radius = 0.020 cm.height = 0.006 mi.mesh = cm mi.material_override = _mat(_pal("saucer", Color(0.15, 0.18, 0.25)), 0.4, 0.5) area.add_child(mi) add_child(area) area.position = at var eject: Vector3 = p.get("eject", Vector3(0, 0, -1)) var hold: float = float(p.get("hold", 0.9)) var power: float = float(p.get("power", 0.20)) area.body_entered.connect(func(b: Node3D) -> void: if not (b is RigidBody3D) or not balls.has(b) or area.get_meta("busy", false): return area.set_meta("busy", true) var rb := b as RigidBody3D rb.linear_velocity = Vector3.ZERO rb.angular_velocity = Vector3.ZERO rb.freeze = true rb.global_position = at + Vector3(0, BALL_R, 0) _emit("saucer", area, at, {"captured": true}) await get_tree().create_timer(hold).timeout if is_instance_valid(rb): rb.freeze = false rb.apply_central_impulse(eject.normalized() * power) area.set_meta("busy", false)) _register(area, p, "saucer") ## Rollover lane: a wire trigger you roll over. Lit lanes are how you spell a word. func _rollover(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var area := Area3D.new() area.name = "Roll_%s" % p.get("id", "r") var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = Vector3(0.034, 0.04, 0.030) acs.shape = ab area.add_child(acs) var mi := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(0.030, 0.002, 0.026) mi.mesh = bm mi.material_override = _mat(_pal("lane", Color(0.35, 0.55, 0.95)), 0.2, 0.5, 0.25) area.add_child(mi) add_child(area) area.position = at area.set_meta("lit", false) area.set_meta("glyph", String(p.get("glyph", ""))) area.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): _emit("rollover", area, at, {"glyph": area.get_meta("glyph", ""), "was_lit": area.get_meta("lit", false)})) _register(area, p, "rollover") func set_rollover_lit(id: String, lit: bool) -> void: var n = _parts.get(id) if n == null or not is_instance_valid(n): return n.set_meta("lit", lit) for c in (n as Node).get_children(): if c is MeshInstance3D: var m := (c as MeshInstance3D).material_override as StandardMaterial3D if m != null: var d := m.duplicate() as StandardMaterial3D d.emission_energy_multiplier = 1.6 if lit else 0.25 (c as MeshInstance3D).material_override = d ## A plain wall segment — the guides, the horseshoe, the outlane dividers. func _wall(p: Dictionary) -> void: var host := StaticBody3D.new() host.name = "Wall_%s" % p.get("id", "w") add_child(host) host.position = p.get("at", Vector3.ZERO) host.rotation.y = float(p.get("yaw", 0.0)) var size: Vector3 = p.get("size", Vector3(0.10, 0.05, 0.012)) _slab(host, size, Vector3(0, size.y * 0.5, 0), _pal("wall", Color(0.45, 0.48, 0.55)), 0.6, 0.35) _register(host, p, "wall") ## A round post — the pegs a ball threads between. func _post(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var host := StaticBody3D.new() host.name = "Post_%s" % p.get("id", "p") var r: float = float(p.get("radius", 0.008)) var mi := MeshInstance3D.new() var cm := CylinderMesh.new() cm.top_radius = r cm.bottom_radius = r cm.height = 0.05 mi.mesh = cm mi.position.y = 0.025 mi.material_override = _mat(_pal("post", Color(0.9, 0.9, 0.95)), 0.5, 0.25) host.add_child(mi) var cs := CollisionShape3D.new() var sh := CylinderShape3D.new() sh.radius = r sh.height = 0.05 cs.shape = sh cs.position.y = 0.025 host.add_child(cs) add_child(host) host.position = at _register(host, p, "post") ## A ramp: an inclined run the ball can take if it arrives fast enough. Built as a ## sloped slab with side rails so a slow ball rolls back down — that speed gate is the ## whole point of a ramp shot. func _ramp(p: Dictionary) -> void: var at: Vector3 = p.get("at", Vector3.ZERO) var len_: float = float(p.get("length", 0.26)) var wid: float = float(p.get("width", 0.05)) var rise: float = float(p.get("rise", 0.055)) var host := StaticBody3D.new() host.name = "Ramp_%s" % p.get("id", "rm") add_child(host) host.position = at host.rotation.y = float(p.get("yaw", 0.0)) var pitch := atan2(rise, len_) var bed := MeshInstance3D.new() var bm := BoxMesh.new() bm.size = Vector3(wid, 0.008, len_) bed.mesh = bm bed.material_override = _mat(_pal("ramp", Color(0.25, 0.65, 0.85)), 0.35, 0.3) bed.position = Vector3(0, rise * 0.5, -len_ * 0.5) bed.rotation.x = pitch host.add_child(bed) var cs := CollisionShape3D.new() var sh := BoxShape3D.new() sh.size = bm.size cs.shape = sh cs.position = bed.position cs.rotation.x = pitch host.add_child(cs) for s in [-1.0, 1.0]: var rail := CollisionShape3D.new() var rs := BoxShape3D.new() rs.size = Vector3(0.006, 0.030, len_) rail.shape = rs rail.position = bed.position + Vector3(s * (wid * 0.5), 0.018, 0) rail.rotation.x = pitch host.add_child(rail) var rm := MeshInstance3D.new() var rbm := BoxMesh.new() rbm.size = rs.size rm.mesh = rbm rm.material_override = _mat(_pal("rail", Color(0.55, 0.57, 0.62)), 0.8, 0.3) rm.position = rail.position rm.rotation.x = pitch host.add_child(rm) # the reward trigger sits at the TOP — you only score it if you made it up var area := Area3D.new() var acs := CollisionShape3D.new() var ab := BoxShape3D.new() ab.size = Vector3(wid, 0.05, 0.03) acs.shape = ab area.add_child(acs) host.add_child(area) area.position = Vector3(0, rise + 0.02, -len_) area.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): _emit("ramp", host, area.global_position, {})) _register(host, p, "ramp") # ---------------------------------------------------------------- per-frame func _process(delta: float) -> void: # spin down the spinner blades so a whipped spinner visibly keeps turning for k in _parts: var n = _parts[k] if not is_instance_valid(n) or n.get_meta("kind", "") != "spinner": continue var left := float(n.get_meta("spin_left", 0.0)) if left <= 0.0: continue var blade = n.get_meta("blade") if blade is MeshInstance3D: (blade as MeshInstance3D).rotation.x += delta * 26.0 n.set_meta("spin_left", maxf(0.0, left - delta * 6.0))