diff --git a/godot/project.godot b/godot/project.godot index 60536de..19015a7 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -7,7 +7,7 @@ config_version=5 [application] config/name="macrosoft3dpinball testbed" -run/main_scene="res://scenes/table.tscn" +run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.7") [physics] diff --git a/godot/scenes/main.tscn b/godot/scenes/main.tscn new file mode 100644 index 0000000..48e3563 --- /dev/null +++ b/godot/scenes/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/Main.gd" id="1_main"] + +[node name="Main" type="Node3D"] +script = ExtResource("1_main") diff --git a/godot/scripts/Main.gd b/godot/scripts/Main.gd new file mode 100644 index 0000000..521becd --- /dev/null +++ b/godot/scripts/Main.gd @@ -0,0 +1,236 @@ +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) diff --git a/godot/scripts/Main.gd.uid b/godot/scripts/Main.gd.uid new file mode 100644 index 0000000..42f0b5c --- /dev/null +++ b/godot/scripts/Main.gd.uid @@ -0,0 +1 @@ +uid://csr8hsdi5l3e diff --git a/godot/scripts/Table.gd b/godot/scripts/Table.gd new file mode 100644 index 0000000..ae8797a --- /dev/null +++ b/godot/scripts/Table.gd @@ -0,0 +1,698 @@ +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)) diff --git a/godot/scripts/Table.gd.uid b/godot/scripts/Table.gd.uid new file mode 100644 index 0000000..9e4da0a --- /dev/null +++ b/godot/scripts/Table.gd.uid @@ -0,0 +1 @@ +uid://caxsihhuv2gwf diff --git a/godot/scripts/Tables.gd b/godot/scripts/Tables.gd new file mode 100644 index 0000000..919b681 --- /dev/null +++ b/godot/scripts/Tables.gd @@ -0,0 +1,219 @@ +extends RefCounted +class_name Tables + +## The tables, as data. Each is a Table spec (see Table.gd for the part vocabulary). +## +## These are ORIGINAL layouts, not recreations of the Microsoft table vendored next door — +## that decomp is here as a reference and a build target, not something to trace. +## +## A table is a Dictionary: name, palette, size, and a flat list of parts. That means a new +## table is authorable in minutes, tables can be diffed, and dev/probe_tables.gd can build +## every one of them headless and assert the playfield is sane. + +const ORDER := ["neon", "grotto", "foundry"] + +static func get_table(id: String) -> Dictionary: + match id: + "grotto": return grotto() + "foundry": return foundry() + return neon() + +static func next_id(id: String) -> String: + var i := ORDER.find(id) + return ORDER[(i + 1) % ORDER.size()] if i >= 0 else ORDER[0] + +# ================================================================ TABLE 1 +## NEON ARCADE — the friendly one. Wide open lower field, a three-bank of drop targets, +## two pop bumpers up top and a spinner lane on the left. Learn the flippers here. +static func neon() -> Dictionary: + var parts: Array = [] + # --- the lower field: flippers, slings, outlane guides --- + parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.062, 0.022, 0.400), + "side": -1, "length": 0.078, "limit_lo": -32.0, "limit_hi": 30.0, "torque": 7.0}) + parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.062, 0.022, 0.400), + "side": 1, "length": 0.078, "limit_lo": -30.0, "limit_hi": 32.0, "torque": 7.0}) + parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.125, 0.0, 0.320), + "dir": Vector3(1, 0, -0.55), "yaw": deg_to_rad(-32.0), "kick": 0.135, "points": 120}) + parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.125, 0.0, 0.320), + "dir": Vector3(-1, 0, -0.55), "yaw": deg_to_rad(32.0), "kick": 0.135, "points": 120}) + # outlane / inlane dividers — the guides that decide whether a drain was fair + parts.append({"kind": "wall", "id": "guide_l", "at": Vector3(-0.175, 0.0, 0.380), + "size": Vector3(0.012, 0.05, 0.150), "yaw": deg_to_rad(9.0)}) + parts.append({"kind": "wall", "id": "guide_r", "at": Vector3(0.175, 0.0, 0.380), + "size": Vector3(0.012, 0.05, 0.150), "yaw": deg_to_rad(-9.0)}) + parts.append({"kind": "post", "id": "post_l", "at": Vector3(-0.108, 0.0, 0.330)}) + parts.append({"kind": "post", "id": "post_r", "at": Vector3(0.108, 0.0, 0.330)}) + + # --- mid field: the drop-target bank and a pair of standing targets --- + for i in 3: + parts.append({"kind": "drop", "id": "drop_%d" % i, "bank": "neon", + "at": Vector3(-0.130 + i * 0.034, 0.0, 0.055), "points": 500}) + parts.append({"kind": "target", "id": "targ_l", "at": Vector3(-0.196, 0.0, -0.070), + "yaw": deg_to_rad(24.0), "points": 300}) + parts.append({"kind": "target", "id": "targ_r", "at": Vector3(0.196, 0.0, -0.070), + "yaw": deg_to_rad(-24.0), "points": 300}) + + # --- the spinner lane, left side, fed by a hard left flipper shot --- + parts.append({"kind": "spinner", "id": "spin_main", "at": Vector3(-0.155, 0.0, 0.170), + "yaw": deg_to_rad(12.0), "points": 90}) + parts.append({"kind": "wall", "id": "spin_guide", "at": Vector3(-0.205, 0.0, 0.170), + "size": Vector3(0.012, 0.05, 0.180), "yaw": deg_to_rad(6.0)}) + + # --- top: two pops and a saucer between them --- + parts.append({"kind": "bumper", "id": "pop_l", "at": Vector3(-0.075, 0.0, -0.250), + "kick": 0.165, "points": 250}) + parts.append({"kind": "bumper", "id": "pop_r", "at": Vector3(0.075, 0.0, -0.250), + "kick": 0.165, "points": 250}) + parts.append({"kind": "bumper", "id": "pop_t", "at": Vector3(0.0, 0.0, -0.335), + "kick": 0.165, "points": 250}) + parts.append({"kind": "saucer", "id": "saucer_top", "at": Vector3(0.0, 0.0, -0.150), + "eject": Vector3(0.1, 0, 1), "hold": 0.85, "power": 0.20, "points": 2500}) + + # --- the N-E-O-N rollover lanes across the top arch --- + var glyphs := ["N", "E", "O", "N"] + for i in glyphs.size(): + parts.append({"kind": "rollover", "id": "lane_%d" % i, "glyph": glyphs[i], + "at": Vector3(-0.105 + i * 0.070, 0.0, -0.430), "points": 150}) + + # --- the right ramp: the money shot --- + parts.append({"kind": "ramp", "id": "ramp_r", "at": Vector3(0.120, 0.0, 0.230), + "yaw": deg_to_rad(-14.0), "length": 0.30, "width": 0.052, "rise": 0.06, + "points": 1500}) + + return { + "name": "NEON ARCADE", + "subtitle": "wide open. learn the flippers here.", + "width": 0.52, "length": 1.10, + "palette": { + "field": Color(0.10, 0.09, 0.20), "rail": Color(0.62, 0.64, 0.72), + "flipper": Color(0.98, 0.30, 0.55), "bumper": Color(0.35, 0.95, 0.90), + "sling": Color(0.55, 0.95, 0.45), "target": Color(0.98, 0.55, 0.20), + "drop": Color(0.98, 0.88, 0.30), "spinner": Color(0.80, 0.90, 1.00), + "lane": Color(0.45, 0.60, 1.00), "ramp": Color(0.30, 0.75, 0.95), + "wall": Color(0.30, 0.32, 0.45), "post": Color(0.90, 0.92, 1.00), + "saucer": Color(0.08, 0.10, 0.18), + }, + "parts": parts, + } + +# ================================================================ TABLE 2 +## THE GROTTO — tight and mean. Narrower field, a five-bank guarding the only ramp, and +## the outlanes are wide open, so a bad shot is punished immediately. +static func grotto() -> Dictionary: + var parts: Array = [] + parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.058, 0.022, 0.410), + "side": -1, "length": 0.072, "limit_lo": -34.0, "limit_hi": 28.0, "torque": 6.5}) + parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.058, 0.022, 0.410), + "side": 1, "length": 0.072, "limit_lo": -28.0, "limit_hi": 34.0, "torque": 6.5}) + parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.118, 0.0, 0.330), + "dir": Vector3(1, 0, -0.7), "yaw": deg_to_rad(-38.0), "kick": 0.155, "points": 150}) + parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.118, 0.0, 0.330), + "dir": Vector3(-1, 0, -0.7), "yaw": deg_to_rad(38.0), "kick": 0.155, "points": 150}) + # no inlane posts — the outlanes are open, which is the whole personality of this table + + # a five-bank straight across the middle: it BLOCKS the ramp until you clear it + for i in 5: + parts.append({"kind": "drop", "id": "gd_%d" % i, "bank": "gate", + "at": Vector3(-0.096 + i * 0.048, 0.0, 0.010), "points": 400}) + parts.append({"kind": "ramp", "id": "ramp_c", "at": Vector3(0.0, 0.0, -0.040), + "yaw": 0.0, "length": 0.26, "width": 0.048, "rise": 0.07, "points": 3000}) + + parts.append({"kind": "spinner", "id": "spin_l", "at": Vector3(-0.170, 0.0, 0.130), + "yaw": deg_to_rad(16.0), "points": 120}) + parts.append({"kind": "spinner", "id": "spin_r", "at": Vector3(0.170, 0.0, 0.130), + "yaw": deg_to_rad(-16.0), "points": 120}) + parts.append({"kind": "bumper", "id": "pop_a", "at": Vector3(-0.060, 0.0, -0.290), + "kick": 0.180, "points": 300}) + parts.append({"kind": "bumper", "id": "pop_b", "at": Vector3(0.060, 0.0, -0.290), + "kick": 0.180, "points": 300}) + parts.append({"kind": "saucer", "id": "saucer_l", "at": Vector3(-0.150, 0.0, -0.220), + "eject": Vector3(0.4, 0, 1), "hold": 1.1, "power": 0.22, "points": 4000}) + parts.append({"kind": "saucer", "id": "saucer_r", "at": Vector3(0.150, 0.0, -0.220), + "eject": Vector3(-0.4, 0, 1), "hold": 1.1, "power": 0.22, "points": 4000}) + for i in 3: + parts.append({"kind": "rollover", "id": "glane_%d" % i, "glyph": "DIG".substr(i, 1), + "at": Vector3(-0.085 + i * 0.085, 0.0, -0.420), "points": 200}) + parts.append({"kind": "post", "id": "gp_l", "at": Vector3(-0.100, 0.0, 0.190), "radius": 0.010}) + parts.append({"kind": "post", "id": "gp_r", "at": Vector3(0.100, 0.0, 0.190), "radius": 0.010}) + + return { + "name": "THE GROTTO", + "subtitle": "open outlanes. the bank guards the ramp.", + "width": 0.46, "length": 1.10, + "palette": { + "field": Color(0.06, 0.14, 0.13), "rail": Color(0.45, 0.55, 0.52), + "flipper": Color(0.20, 0.85, 0.75), "bumper": Color(0.95, 0.60, 0.25), + "sling": Color(0.30, 0.70, 0.95), "target": Color(0.90, 0.35, 0.45), + "drop": Color(0.85, 0.95, 0.55), "spinner": Color(0.70, 0.95, 0.90), + "lane": Color(0.30, 0.85, 0.70), "ramp": Color(0.95, 0.75, 0.35), + "wall": Color(0.14, 0.26, 0.24), "post": Color(0.75, 0.90, 0.86), + "saucer": Color(0.04, 0.10, 0.09), + }, + "parts": parts, + } + +# ================================================================ TABLE 3 +## THE FOUNDRY — the long one. A tall upper field stacked with pops, twin ramps, and a +## target array you have to pick apart. Slower, more deliberate, higher ceiling. +static func foundry() -> Dictionary: + var parts: Array = [] + parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.064, 0.022, 0.430), + "side": -1, "length": 0.082, "limit_lo": -30.0, "limit_hi": 32.0, "torque": 8.0}) + parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.064, 0.022, 0.430), + "side": 1, "length": 0.082, "limit_lo": -32.0, "limit_hi": 30.0, "torque": 8.0}) + # an upper-left third flipper, fed by the left ramp — the skill shot of this table + parts.append({"kind": "flipper", "id": "flip_u", "at": Vector3(-0.090, 0.022, -0.120), + "side": -1, "length": 0.062, "limit_lo": -28.0, "limit_hi": 26.0, "torque": 6.0}) + parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.132, 0.0, 0.350), + "dir": Vector3(1, 0, -0.5), "yaw": deg_to_rad(-30.0), "kick": 0.130, "points": 100}) + parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.132, 0.0, 0.350), + "dir": Vector3(-1, 0, -0.5), "yaw": deg_to_rad(30.0), "kick": 0.130, "points": 100}) + parts.append({"kind": "post", "id": "fp_l", "at": Vector3(-0.112, 0.0, 0.345)}) + parts.append({"kind": "post", "id": "fp_r", "at": Vector3(0.112, 0.0, 0.345)}) + + # twin ramps flanking the centre + parts.append({"kind": "ramp", "id": "ramp_l", "at": Vector3(-0.135, 0.0, 0.190), + "yaw": deg_to_rad(12.0), "length": 0.28, "width": 0.050, "rise": 0.065, "points": 2000}) + parts.append({"kind": "ramp", "id": "ramp_r", "at": Vector3(0.135, 0.0, 0.190), + "yaw": deg_to_rad(-12.0), "length": 0.28, "width": 0.050, "rise": 0.065, "points": 2000}) + + # a 2x3 target array in the centre you pick apart shot by shot + for row in 2: + for col in 3: + parts.append({"kind": "target", "id": "arr_%d_%d" % [row, col], + "at": Vector3(-0.058 + col * 0.058, 0.0, 0.020 - row * 0.075), + "width": 0.030, "points": 350}) + # a four-bank up top + for i in 4: + parts.append({"kind": "drop", "id": "fd_%d" % i, "bank": "smelt", + "at": Vector3(-0.075 + i * 0.050, 0.0, -0.230), "points": 600}) + + parts.append({"kind": "spinner", "id": "spin_c", "at": Vector3(0.0, 0.0, 0.150), + "points": 150}) + parts.append({"kind": "bumper", "id": "pop_1", "at": Vector3(-0.100, 0.0, -0.330), + "kick": 0.170, "points": 300}) + parts.append({"kind": "bumper", "id": "pop_2", "at": Vector3(0.0, 0.0, -0.390), + "kick": 0.170, "points": 300}) + parts.append({"kind": "bumper", "id": "pop_3", "at": Vector3(0.100, 0.0, -0.330), + "kick": 0.170, "points": 300}) + parts.append({"kind": "saucer", "id": "saucer_deep", "at": Vector3(0.170, 0.0, -0.130), + "eject": Vector3(-0.6, 0, 1), "hold": 1.2, "power": 0.24, "points": 5000}) + for i in 4: + parts.append({"kind": "rollover", "id": "flane_%d" % i, "glyph": "CAST".substr(i, 1), + "at": Vector3(-0.120 + i * 0.080, 0.0, -0.455), "points": 175}) + + return { + "name": "THE FOUNDRY", + "subtitle": "twin ramps, an upper flipper, and a long way up.", + "width": 0.54, "length": 1.20, + "palette": { + "field": Color(0.16, 0.10, 0.08), "rail": Color(0.58, 0.52, 0.46), + "flipper": Color(0.95, 0.55, 0.15), "bumper": Color(0.98, 0.85, 0.35), + "sling": Color(0.85, 0.40, 0.20), "target": Color(0.75, 0.80, 0.90), + "drop": Color(0.95, 0.45, 0.25), "spinner": Color(0.90, 0.85, 0.75), + "lane": Color(0.98, 0.70, 0.30), "ramp": Color(0.65, 0.68, 0.75), + "wall": Color(0.26, 0.18, 0.14), "post": Color(0.85, 0.80, 0.72), + "saucer": Color(0.10, 0.06, 0.05), + }, + "parts": parts, + } diff --git a/godot/scripts/Tables.gd.uid b/godot/scripts/Tables.gd.uid new file mode 100644 index 0000000..583533e --- /dev/null +++ b/godot/scripts/Tables.gd.uid @@ -0,0 +1 @@ +uid://b1ghqe4fkrdvm