From 2ca67bb28bc4f31992bfa57ac1b222dda87240aa Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 6 Aug 2026 05:33:07 +1000 Subject: [PATCH] Add Godot 4.7 physics testbed: Box3D/Jolt/GodotPhysics A/B pinball table Playable table (hinge-motor flippers, plunger, bumper, drain) plus a headless probe harness that measured, per engine: tunneling of a fast ball through a 1 cm wall with CCD off/on, and hinge-motor drive. Findings in godot/README.md: Box3D never tunnels even with CCD off, but its hinge motor sign is inverted vs Jolt/GodotPhysics, and Jolt blows through angular limits that Box3D holds. Ships a from-source macOS arm64 libgodot-box3d.dylib (upstream has no releases). Also gitignore the remaining Microsoft media at repo root. Co-Authored-By: Claude Fable 5 --- .gitignore | 11 ++ CLAUDE.md | 10 +- godot/README.md | 92 +++++++++++ godot/godot-box3d.gdextension | 14 ++ godot/godot-box3d.gdextension.uid | 1 + godot/probe/probe.gd | 112 +++++++++++++ godot/probe/probe.gd.uid | 1 + godot/probe/probe.tscn | 6 + godot/project.godot | 17 ++ godot/run_probe.sh | 20 +++ godot/scenes/table.gd | 72 ++++++++ godot/scenes/table.gd.uid | 1 + godot/scenes/table.tscn | 262 ++++++++++++++++++++++++++++++ 13 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 godot/README.md create mode 100644 godot/godot-box3d.gdextension create mode 100644 godot/godot-box3d.gdextension.uid create mode 100644 godot/probe/probe.gd create mode 100644 godot/probe/probe.gd.uid create mode 100644 godot/probe/probe.tscn create mode 100644 godot/project.godot create mode 100755 godot/run_probe.sh create mode 100644 godot/scenes/table.gd create mode 100644 godot/scenes/table.gd.uid create mode 100644 godot/scenes/table.tscn diff --git a/.gitignore b/.gitignore index 251ac2e..ff969ce 100644 --- a/.gitignore +++ b/.gitignore @@ -315,3 +315,14 @@ Full_Tilt_Pinball_ISO/ *.ccd *.sub *.cue + +# More original game media at repo root (same policy as *.DAT/*.MID above) +/SOUND*.WAV +/Pinball.exe +/table.bmp +/wavemix.inf + +# Godot testbed transients +godot/.godot/ +godot/override.cfg +godot/probe/results/ diff --git a/CLAUDE.md b/CLAUDE.md index 5b37136..701504f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,14 @@ # macrosoft3dpinball -`3D Pinball for Windows – Space Cadet` on macOS. Vendored copy of +`3D Pinball for Windows – Space Cadet` on macOS. Two halves: + +- The vendored decompilation (below) — the faithful original. +- `godot/` — a Godot 4.7 pinball testbed whose real job is A/B-ing 3D physics + backends (Box3D vs Jolt vs GodotPhysics) on CCD, hinge-motor flippers, and joint + limits. See `godot/README.md` for the probe harness and current findings (Box3D + never tunnels with CCD off but has an inverted hinge-motor sign vs Jolt). + +Vendored copy of [k4zmu2a/SpaceCadetPinball](https://github.com/k4zmu2a/SpaceCadetPinball) (MIT) at upstream commit `cb9b7b8`, not a submodule — patch it in place, pull upstream by re-vendoring. diff --git a/godot/README.md b/godot/README.md new file mode 100644 index 0000000..9aa5ef5 --- /dev/null +++ b/godot/README.md @@ -0,0 +1,92 @@ +# Godot physics testbed (Box3D / Jolt / GodotPhysics A/B) + +A minimal playable pinball table for Godot 4.7 whose real job is comparing 3D physics +backends on the things pinball stresses: fast-small-ball CCD, motorised hinge flippers, +and joint limits. The backend is one project setting, so the same table runs under all +three engines. + +## Run it + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --path godot +``` + +Keys: `Z` left flipper, `/` right flipper, `Space` plunger, `R` reset ball. + +The project defaults to `Box3D Physics (Extension)` via +[godot-box3d](https://github.com/bearlikelion/godot-box3d) (MIT). `bin/` carries a +macOS arm64 dylib built from source (upstream has no releases; build recipe below). + +## A/B probe harness + +```bash +godot/run_probe.sh # GODOT_BIN=... to point at a different binary +``` + +Writes `override.cfg` to switch `physics/3d/physics_engine`, runs `probe/probe.tscn` +headless under each backend, and prints per-engine results (also saved to +`probe/results/`, untracked). Two tests: + +1. **Tunneling** — ball fired at a 1 cm static wall at 5–160 m/s, CCD off and on. +2. **Hinge motor** — a motorised HingeJoint3D arm; a flipper is a motorised hinge. + +There is also a table-level flipper check: +`PINBALL_AUTOTEST=1 godot --headless --path godot` flips both real flippers for 0.5 s +and prints the swing angles. + +## Results (2026-08-06, Godot 4.7.stable, M3 Ultra) + +Tunneling — ball contained by the 1 cm wall? + +| speed m/s | Godot ccd off | Jolt ccd off | Box3D ccd off | all three, ccd on | +|---|---|---|---|---| +| 5 | tunneled* | tunneled* | **contained** | contained | +| 10 | tunneled | contained* | **contained** | contained | +| 20 | contained* | contained* | **contained** | contained | +| 40 | contained* | contained* | **contained** | contained | +| 80 | tunneled | tunneled | **contained** | contained | +| 160 | tunneled | tunneled | **contained** | contained | + +\* With a 60 Hz tick, every speed here can step over a 1 cm wall; the discrete-engine +"contained" rows are phase luck (2 m at 20 and 40 m/s lands a tick exactly on the wall +— and both engines produce identical trajectories, so they get lucky identically). +The signal is the pattern, not individual rows: **Box3D never tunnels even with the +CCD flag off** (speculative contacts are always on), the other two need +`continuous_cd = true`, and with it all three hold at 160 m/s. + +Hinge motor (target 5 rad/s, 1 s): + +| engine | reached rad/s | moved | verdict | +|---|---|---|---| +| GodotPhysics3D | −5.0 | 73.5° (wrapped) | works | +| Jolt | −5.0 | 75.1° (wrapped) | works | +| Box3D | **+5.0** | 73.5° (wrapped) | works | + +Flipper autotest on the real table (motors at ±25 rad/s, limits −5°…+50°): + +| engine | left swing | right swing | +|---|---|---| +| Box3D | +51.8° — **stops at the 50° limit** | −51.8° — stops at limit | +| Jolt | −106.6° — wrong way, **blows ~100° past the limit** | +104.3° — same | + +Two real findings, half a second of headless runtime each: + +1. **Box3D's hinge motor sign is inverted** relative to GodotPhysics/Jolt (upstream + drop-in-compatibility bug candidate for godot-box3d). The table's flipper signs are + tuned for Box3D, the project default — flip `FLIP_SPEED`/`REST_SPEED` signs in + `scenes/table.gd` to play under Jolt. +2. With the motor overdriving into them, **Box3D enforces the angular limits hard** + while Jolt lets this setup punch far through them. + +## Rebuilding the extension + +```bash +git clone --recurse-submodules https://github.com/bearlikelion/godot-box3d +cd godot-box3d && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j +cp bin/libgodot-box3d.dylib /bin/ +``` + +Known Box3D gaps that don't matter for pinball: no Generic6DOF/ConeTwist joints, no +SoftBody3D, Area3D can't detect trimesh/heightmap bodies (the ball is a sphere; fine). +Box3D also ignores Godot's hinge BIAS/LIMIT_SOFTNESS/LIMIT_RELAXATION params (warns at +load; harmless here). diff --git a/godot/godot-box3d.gdextension b/godot/godot-box3d.gdextension new file mode 100644 index 0000000..601693e --- /dev/null +++ b/godot/godot-box3d.gdextension @@ -0,0 +1,14 @@ +[configuration] + +entry_symbol = "godot_box3d_main" +compatibility_minimum = "4.3" +reloadable = true + +[libraries] + +linux.debug.x86_64 = "res://bin/libgodot-box3d.so" +linux.release.x86_64 = "res://bin/libgodot-box3d.so" +windows.debug.x86_64 = "res://bin/godot-box3d.dll" +windows.release.x86_64 = "res://bin/godot-box3d.dll" +macos.debug = "res://bin/libgodot-box3d.dylib" +macos.release = "res://bin/libgodot-box3d.dylib" diff --git a/godot/godot-box3d.gdextension.uid b/godot/godot-box3d.gdextension.uid new file mode 100644 index 0000000..ec9109f --- /dev/null +++ b/godot/godot-box3d.gdextension.uid @@ -0,0 +1 @@ +uid://bhuvu8ccww6xt diff --git a/godot/probe/probe.gd b/godot/probe/probe.gd new file mode 100644 index 0000000..af9f0a4 --- /dev/null +++ b/godot/probe/probe.gd @@ -0,0 +1,112 @@ +extends Node3D +## Headless physics probe. Run via run_probe.sh, which switches the physics +## engine through override.cfg and launches this scene under each backend. +## +## Test 1 — tunneling: fire a ball at a 1 cm static wall at increasing speeds, +## with CCD off and on, and report whether the wall contains it. +## Test 2 — hinge motor: drive a hinged arm with a joint motor and report +## whether it actually rotates (a flipper is a motorised hinge). + +const WALL_THICKNESS := 0.01 +const BALL_RADIUS := 0.0135 +const START_Z := 2.0 +const SPEEDS := [5.0, 10.0, 20.0, 40.0, 80.0, 160.0] + +func _ready() -> void: + var engine := str(ProjectSettings.get_setting("physics/3d/physics_engine")) + print("PROBE_START engine=%s tick=%d" % [engine, Engine.physics_ticks_per_second]) + _run(engine) + +func _run(engine: String) -> void: + var wall := StaticBody3D.new() + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(1.0, 1.0, WALL_THICKNESS) + cs.shape = box + wall.add_child(cs) + add_child(wall) + + for ccd in [false, true]: + for speed in SPEEDS: + var r := await _fire(float(speed), ccd) + print("PROBE engine=\"%s\" speed=%d ccd=%s result=%s final_z=%.3f drift_y=%.4f" % [ + engine, int(speed), "on" if ccd else "off", + "tunneled" if r.tunneled else "contained", r.final_z, r.drift_y]) + + var m := await _motor_test() + print("PROBE_MOTOR engine=\"%s\" moved_deg=%.1f works=%s" % [ + engine, m.moved_deg, "yes" if m.moved_deg > 20.0 else "NO"]) + + get_tree().quit(0) + +func _fire(speed: float, ccd: bool) -> Dictionary: + var ball := RigidBody3D.new() + var cs := CollisionShape3D.new() + var sph := SphereShape3D.new() + sph.radius = BALL_RADIUS + cs.shape = sph + ball.add_child(cs) + ball.mass = 0.08 + ball.continuous_cd = ccd + ball.gravity_scale = 0.0 + ball.can_sleep = false + ball.position = Vector3(0, 0, START_Z) + add_child(ball) + ball.linear_velocity = Vector3(0, 0, -speed) + + # Enough ticks to cover the distance plus settle time. + var ticks := maxi(90, int(ceil((START_Z + 1.0) / speed * 60.0)) + 30) + for i in ticks: + await get_tree().physics_frame + + var result := { + "tunneled": ball.position.z < -(WALL_THICKNESS / 2.0 + BALL_RADIUS + 0.05), + "final_z": ball.position.z, + "drift_y": absf(ball.position.y), # nonzero = gravity_scale unsupported + } + ball.queue_free() + await get_tree().physics_frame + return result + +func _motor_test() -> Dictionary: + var anchor := StaticBody3D.new() + var acs := CollisionShape3D.new() + var abox := BoxShape3D.new() + abox.size = Vector3(0.05, 0.05, 0.05) + acs.shape = abox + anchor.add_child(acs) + anchor.position = Vector3(3, 0, 0) # far from the wall test + add_child(anchor) + + var arm := RigidBody3D.new() + var ccs := CollisionShape3D.new() + var bbox := BoxShape3D.new() + bbox.size = Vector3(0.1, 0.03, 0.022) + ccs.shape = bbox + ccs.position = Vector3(0.05, 0, 0) + arm.add_child(ccs) + arm.mass = 0.15 + arm.gravity_scale = 0.0 + arm.can_sleep = false + arm.position = Vector3(3.1, 0, 0) + add_child(arm) + + var joint := HingeJoint3D.new() + # Local Z (the hinge axis) pointed along world +Y. + joint.transform = Transform3D(Basis(Vector3(1, 0, 0), -PI / 2.0), Vector3(3.1, 0, 0)) + joint.set_flag(HingeJoint3D.FLAG_ENABLE_MOTOR, true) + joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, 5.0) + joint.set_param(HingeJoint3D.PARAM_MOTOR_MAX_IMPULSE, 5.0) + add_child(joint) + # Paths only resolve once the joint is inside the tree. + joint.node_a = joint.get_path_to(anchor) + joint.node_b = joint.get_path_to(arm) + + var start_q := arm.global_transform.basis.get_rotation_quaternion() + for i in 60: + await get_tree().physics_frame + var end_q := arm.global_transform.basis.get_rotation_quaternion() + var moved := start_q.angle_to(end_q) + print("PROBE_MOTOR_DEBUG rotation=%s ang_vel=%s pos=%s" % [ + arm.rotation, arm.angular_velocity, arm.position]) + return {"moved_deg": rad_to_deg(moved)} diff --git a/godot/probe/probe.gd.uid b/godot/probe/probe.gd.uid new file mode 100644 index 0000000..08cd6d4 --- /dev/null +++ b/godot/probe/probe.gd.uid @@ -0,0 +1 @@ +uid://djjx4g8ojqrt7 diff --git a/godot/probe/probe.tscn b/godot/probe/probe.tscn new file mode 100644 index 0000000..16bc80b --- /dev/null +++ b/godot/probe/probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://probe/probe.gd" id="1"] + +[node name="Probe" type="Node3D"] +script = ExtResource("1") diff --git a/godot/project.godot b/godot/project.godot new file mode 100644 index 0000000..60536de --- /dev/null +++ b/godot/project.godot @@ -0,0 +1,17 @@ +; Godot 4.7 physics testbed for macrosoft3dpinball. +; Physics engine is switched via this setting (or an override.cfg written by run_probe.sh): +; "GodotPhysics3D" | "Jolt Physics" | "Box3D Physics (Extension)" + +config_version=5 + +[application] + +config/name="macrosoft3dpinball testbed" +run/main_scene="res://scenes/table.tscn" +config/features=PackedStringArray("4.7") + +[physics] + +3d/physics_engine="Box3D Physics (Extension)" +3d/default_gravity=9.81 +3d/default_gravity_vector=Vector3(0, -0.99357, 0.1132) diff --git a/godot/run_probe.sh b/godot/run_probe.sh new file mode 100755 index 0000000..981e9ff --- /dev/null +++ b/godot/run_probe.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# A/B/C physics probe: runs probe/probe.tscn headless under each backend by +# switching physics/3d/physics_engine through override.cfg. +set -euo pipefail +cd "$(dirname "$0")" + +GODOT="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}" +ENGINES=("GodotPhysics3D" "Jolt Physics" "Box3D Physics (Extension)") + +mkdir -p probe/results +"$GODOT" --headless --path . --import > /dev/null 2>&1 || true + +for e in "${ENGINES[@]}"; do + printf '[physics]\n3d/physics_engine="%s"\n' "$e" > override.cfg + slug=$(echo "$e" | tr ' ()' '_' | tr -s '_') + echo "=== $e ===" + "$GODOT" --headless --path . res://probe/probe.tscn 2>/dev/null \ + | grep '^PROBE' | tee "probe/results/$slug.txt" +done +rm -f override.cfg diff --git a/godot/scenes/table.gd b/godot/scenes/table.gd new file mode 100644 index 0000000..a7383c9 --- /dev/null +++ b/godot/scenes/table.gd @@ -0,0 +1,72 @@ +extends Node3D +## Playable Box3D/Jolt/GodotPhysics pinball testbed. +## Z = left flipper, / = right flipper, Space = plunger, R = reset ball. + +const FLIP_SPEED := 25.0 +const REST_SPEED := 8.0 +const PLUNGE_IMPULSE := 0.32 # ~4 m/s on the 80 g ball +const BUMPER_IMPULSE := 0.12 + +@onready var ball: RigidBody3D = $Ball +@onready var left_joint: HingeJoint3D = $LeftFlipperJoint +@onready var right_joint: HingeJoint3D = $RightFlipperJoint + +var _ball_start: Transform3D + +func _ready() -> void: + _ball_start = ball.transform + print("physics engine: ", ProjectSettings.get_setting("physics/3d/physics_engine")) + $Drain.body_entered.connect(_on_drain) + $BumperArea.body_entered.connect(_on_bumper) + if OS.get_environment("PINBALL_AUTOTEST") == "1": + _autotest() + +## Headless check that the real table flippers flip: hold both motors at flip +## speed for half a second and report how far each flipper swung. +func _autotest() -> void: + set_physics_process(false) + var lf: RigidBody3D = $LeftFlipper + var rf: RigidBody3D = $RightFlipper + var l0 := lf.rotation.y + var r0 := rf.rotation.y + left_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, FLIP_SPEED) + right_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, -FLIP_SPEED) + for i in 30: + await get_tree().physics_frame + print("AUTOTEST left_swing_deg=%.1f right_swing_deg=%.1f" % [ + rad_to_deg(angle_difference(l0, lf.rotation.y)), + rad_to_deg(angle_difference(r0, rf.rotation.y))]) + get_tree().quit(0) + +func _physics_process(_delta: float) -> void: + var left := Input.is_physical_key_pressed(KEY_Z) + var right := Input.is_physical_key_pressed(KEY_SLASH) + left_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, FLIP_SPEED if left else -REST_SPEED) + right_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, -FLIP_SPEED if right else REST_SPEED) + if Input.is_physical_key_pressed(KEY_SPACE) and _ball_in_lane(): + ball.apply_central_impulse(Vector3(0, 0, -PLUNGE_IMPULSE)) + if Input.is_physical_key_pressed(KEY_R): + _reset_ball() + +func _ball_in_lane() -> bool: + return ball.position.x > 0.21 and ball.position.z > 0.30 \ + and ball.linear_velocity.length() < 0.05 + +func _on_drain(body: Node3D) -> void: + if body == ball: + _reset_ball.call_deferred() + +func _on_bumper(body: Node3D) -> void: + if body != ball: + return + var bumper: StaticBody3D = $Bumper + var dir := ball.global_position - bumper.global_position + dir.y = 0.0 + if dir.length() < 0.001: + dir = Vector3(0, 0, 1) + ball.apply_central_impulse(dir.normalized() * BUMPER_IMPULSE) + +func _reset_ball() -> void: + ball.linear_velocity = Vector3.ZERO + ball.angular_velocity = Vector3.ZERO + ball.transform = _ball_start diff --git a/godot/scenes/table.gd.uid b/godot/scenes/table.gd.uid new file mode 100644 index 0000000..24778e2 --- /dev/null +++ b/godot/scenes/table.gd.uid @@ -0,0 +1 @@ +uid://cfsbqmggqh2jg diff --git a/godot/scenes/table.tscn b/godot/scenes/table.tscn new file mode 100644 index 0000000..8da6a42 --- /dev/null +++ b/godot/scenes/table.tscn @@ -0,0 +1,262 @@ +[gd_scene load_steps=28 format=3] + +[ext_resource type="Script" path="res://scenes/table.gd" id="1"] + +[sub_resource type="BoxShape3D" id="floor_shape"] +size = Vector3(0.54, 0.02, 1.04) + +[sub_resource type="BoxMesh" id="floor_mesh"] +size = Vector3(0.54, 0.02, 1.04) + +[sub_resource type="StandardMaterial3D" id="mat_floor"] +albedo_color = Color(0.13, 0.28, 0.16, 1) + +[sub_resource type="BoxShape3D" id="wall_long_shape"] +size = Vector3(0.02, 0.06, 1.04) + +[sub_resource type="BoxMesh" id="wall_long_mesh"] +size = Vector3(0.02, 0.06, 1.04) + +[sub_resource type="BoxShape3D" id="wall_short_shape"] +size = Vector3(0.54, 0.06, 0.02) + +[sub_resource type="BoxMesh" id="wall_short_mesh"] +size = Vector3(0.54, 0.06, 0.02) + +[sub_resource type="StandardMaterial3D" id="mat_wall"] +albedo_color = Color(0.45, 0.42, 0.38, 1) + +[sub_resource type="BoxShape3D" id="lane_shape"] +size = Vector3(0.01, 0.06, 0.79) + +[sub_resource type="BoxMesh" id="lane_mesh"] +size = Vector3(0.01, 0.06, 0.79) + +[sub_resource type="BoxShape3D" id="guide_l_shape"] +size = Vector3(0.16, 0.06, 0.01) + +[sub_resource type="BoxMesh" id="guide_l_mesh"] +size = Vector3(0.16, 0.06, 0.01) + +[sub_resource type="BoxShape3D" id="guide_r_shape"] +size = Vector3(0.13, 0.06, 0.01) + +[sub_resource type="BoxMesh" id="guide_r_mesh"] +size = Vector3(0.13, 0.06, 0.01) + +[sub_resource type="BoxShape3D" id="flipper_shape"] +size = Vector3(0.1, 0.03, 0.022) + +[sub_resource type="BoxMesh" id="flipper_mesh"] +size = Vector3(0.1, 0.03, 0.022) + +[sub_resource type="StandardMaterial3D" id="mat_flipper"] +albedo_color = Color(0.85, 0.15, 0.12, 1) + +[sub_resource type="SphereShape3D" id="ball_shape"] +radius = 0.0135 + +[sub_resource type="SphereMesh" id="ball_mesh"] +radius = 0.0135 +height = 0.027 + +[sub_resource type="StandardMaterial3D" id="mat_ball"] +albedo_color = Color(0.75, 0.75, 0.8, 1) +metallic = 0.9 +roughness = 0.25 + +[sub_resource type="PhysicsMaterial" id="ball_phys"] +friction = 0.25 +bounce = 0.3 + +[sub_resource type="CylinderShape3D" id="bumper_shape"] +height = 0.05 +radius = 0.035 + +[sub_resource type="CylinderMesh" id="bumper_mesh"] +top_radius = 0.035 +bottom_radius = 0.035 +height = 0.05 + +[sub_resource type="StandardMaterial3D" id="mat_bumper"] +albedo_color = Color(0.95, 0.55, 0.1, 1) + +[sub_resource type="CylinderShape3D" id="bumper_area_shape"] +height = 0.08 +radius = 0.055 + +[sub_resource type="BoxShape3D" id="drain_shape"] +size = Vector3(0.2, 0.06, 0.04) + +[node name="Table" type="Node3D"] +script = ExtResource("1") + +[node name="Playfield" type="StaticBody3D" parent="."] + +[node name="FloorShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0) +shape = SubResource("floor_shape") + +[node name="FloorMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0) +mesh = SubResource("floor_mesh") +surface_material_override/0 = SubResource("mat_floor") + +[node name="WallLeftShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.26, 0.03, 0) +shape = SubResource("wall_long_shape") + +[node name="WallLeftMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.26, 0.03, 0) +mesh = SubResource("wall_long_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="WallRightShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.26, 0.03, 0) +shape = SubResource("wall_long_shape") + +[node name="WallRightMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.26, 0.03, 0) +mesh = SubResource("wall_long_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="WallFarShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, -0.51) +shape = SubResource("wall_short_shape") + +[node name="WallFarMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, -0.51) +mesh = SubResource("wall_short_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="WallNearShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.51) +shape = SubResource("wall_short_shape") + +[node name="WallNearMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.51) +mesh = SubResource("wall_short_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="LaneWallShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.21, 0.03, 0.075) +shape = SubResource("lane_shape") + +[node name="LaneWallMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.21, 0.03, 0.075) +mesh = SubResource("lane_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="GuideLeftShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(0.768, 0, -0.64, 0, 1, 0, 0.64, 0, 0.768, -0.19, 0.03, 0.35) +shape = SubResource("guide_l_shape") + +[node name="GuideLeftMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(0.768, 0, -0.64, 0, 1, 0, 0.64, 0, 0.768, -0.19, 0.03, 0.35) +mesh = SubResource("guide_l_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="GuideRightShape" type="CollisionShape3D" parent="Playfield"] +transform = Transform3D(-0.768, 0, -0.64, 0, 1, 0, 0.64, 0, -0.768, 0.167, 0.03, 0.35) +shape = SubResource("guide_r_shape") + +[node name="GuideRightMesh" type="MeshInstance3D" parent="Playfield"] +transform = Transform3D(-0.768, 0, -0.64, 0, 1, 0, 0.64, 0, -0.768, 0.167, 0.03, 0.35) +mesh = SubResource("guide_r_mesh") +surface_material_override/0 = SubResource("mat_wall") + +[node name="LeftFlipper" type="RigidBody3D" parent="."] +transform = Transform3D(0.906, 0, -0.423, 0, 1, 0, 0.423, 0, 0.906, -0.115, 0.02, 0.42) +mass = 0.15 +continuous_cd = true +can_sleep = false + +[node name="Shape" type="CollisionShape3D" parent="LeftFlipper"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.05, 0, 0) +shape = SubResource("flipper_shape") + +[node name="Mesh" type="MeshInstance3D" parent="LeftFlipper"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.05, 0, 0) +mesh = SubResource("flipper_mesh") +surface_material_override/0 = SubResource("mat_flipper") + +[node name="RightFlipper" type="RigidBody3D" parent="."] +transform = Transform3D(0.906, 0, 0.423, 0, 1, 0, -0.423, 0, 0.906, 0.115, 0.02, 0.42) +mass = 0.15 +continuous_cd = true +can_sleep = false + +[node name="Shape" type="CollisionShape3D" parent="RightFlipper"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.05, 0, 0) +shape = SubResource("flipper_shape") + +[node name="Mesh" type="MeshInstance3D" parent="RightFlipper"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.05, 0, 0) +mesh = SubResource("flipper_mesh") +surface_material_override/0 = SubResource("mat_flipper") + +[node name="LeftFlipperJoint" type="HingeJoint3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, -0.115, 0.02, 0.42) +node_a = NodePath("../Playfield") +node_b = NodePath("../LeftFlipper") +angular_limit/enable = true +angular_limit/upper = 0.873 +angular_limit/lower = -0.087 +motor/enable = true +motor/target_velocity = -8.0 +motor/max_impulse = 0.6 + +[node name="RightFlipperJoint" type="HingeJoint3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, 0.115, 0.02, 0.42) +node_a = NodePath("../Playfield") +node_b = NodePath("../RightFlipper") +angular_limit/enable = true +angular_limit/upper = 0.087 +angular_limit/lower = -0.873 +motor/enable = true +motor/target_velocity = 8.0 +motor/max_impulse = 0.6 + +[node name="Ball" type="RigidBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.2325, 0.0135, 0.44) +mass = 0.08 +physics_material_override = SubResource("ball_phys") +continuous_cd = true +can_sleep = false + +[node name="Shape" type="CollisionShape3D" parent="Ball"] +shape = SubResource("ball_shape") + +[node name="Mesh" type="MeshInstance3D" parent="Ball"] +mesh = SubResource("ball_mesh") +surface_material_override/0 = SubResource("mat_ball") + +[node name="Bumper" type="StaticBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, -0.1) + +[node name="Shape" type="CollisionShape3D" parent="Bumper"] +shape = SubResource("bumper_shape") + +[node name="Mesh" type="MeshInstance3D" parent="Bumper"] +mesh = SubResource("bumper_mesh") +surface_material_override/0 = SubResource("mat_bumper") + +[node name="BumperArea" type="Area3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, -0.1) + +[node name="Shape" type="CollisionShape3D" parent="BumperArea"] +shape = SubResource("bumper_area_shape") + +[node name="Drain" type="Area3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.485) + +[node name="Shape" type="CollisionShape3D" parent="Drain"] +shape = SubResource("drain_shape") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.417, 0.909, 0, -0.909, 0.417, 0, 1.2, 0.55) +fov = 30.0 + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.5, 0.866, 0, -0.866, 0.5, 0, 1, 0) +shadow_enabled = true