Pinball: data-driven table spine (3 tables, 11 part kinds)
Replaces the single hand-built .tscn table with the Floorplan/Levels pattern from Destroyulator: Table.gd builds a playfield from a spec, Tables.gd holds the specs, Main.gd orchestrates. A table is now a Dictionary you can diff and hot-swap (T), parallel work doesn't collide in one scene file, and a headless autotest builds every table and proves the flippers swing. Part vocabulary: flipper, bumper, sling, target, drop (banked, with cleared detection), spinner (scores per revolution), saucer (captures + ejects), rollover (lit lanes/glyphs), ramp (speed-gated, scores only at the top), wall, post. Three ORIGINAL tables — NEON ARCADE (wide open, 3-bank, spinner lane), THE GROTTO (narrow, open outlanes, a 5-bank guarding the only ramp), THE FOUNDRY (long, twin ramps, upper flipper, 2x3 target array). Not traced from the vendored decomp. Main also centralises the engine divergence: Box3D's hinge motor runs opposite to Jolt/GodotPhysics, so flip_sign is resolved once at boot from the engine name rather than authoring two sets of tables. Caught by the new autotest: Godot's HingeJoint3D spins about its own local Z, so a flipper built in code needs the joint stood on end to swing about world Y. Commanded correctly and utterly motionless until fixed. AUTOTEST now: 3 tables, 7 flippers, 68-84 deg swing, ALL TABLES OK. Rules/Juice/Hud are spawned by class name if present, so those lanes land independently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5fa6bbf67b
commit
2f1a1250cc
@ -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]
|
||||
|
||||
6
godot/scenes/main.tscn
Normal file
6
godot/scenes/main.tscn
Normal file
@ -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")
|
||||
236
godot/scripts/Main.gd
Normal file
236
godot/scripts/Main.gd
Normal file
@ -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)
|
||||
1
godot/scripts/Main.gd.uid
Normal file
1
godot/scripts/Main.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://csr8hsdi5l3e
|
||||
698
godot/scripts/Table.gd
Normal file
698
godot/scripts/Table.gd
Normal file
@ -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))
|
||||
1
godot/scripts/Table.gd.uid
Normal file
1
godot/scripts/Table.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://caxsihhuv2gwf
|
||||
219
godot/scripts/Tables.gd
Normal file
219
godot/scripts/Tables.gd
Normal file
@ -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,
|
||||
}
|
||||
1
godot/scripts/Tables.gd.uid
Normal file
1
godot/scripts/Tables.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b1ghqe4fkrdvm
|
||||
Loading…
Reference in New Issue
Block a user