Wrench phase 1: 3D jobs on your car in your shed -- your hands are the tune

The repair game grows its 3D half (src/wrench.gd): flat tyre, oil
change and dead battery are done on the player's actual car model,
parked in the actual shed level, through five mouse verbs on projected
3D anchor points:

  GRAB   part follows the cursor on a ray-plane, drop at the target
  TWIST  circle the mouse around the anchor; torque steps have a seat
         window (release inside = clean) and blowing past it STRIPS
         the thread -- quality hit plus an extraction step inserted
  PUMP   rhythm clicks (jack up, jack down)
  YANK   press and flick (pop the wheel, bonnet up)
  STEADY keep the cursor inside a wandering ring (line up the studs,
         pour the oil)

Camera presets per step (wheel / under-car / bonnet), a screen-space
ring overlay with a live torque arc, and props spawned per job (spare,
tray, bottle, batteries). The battery job enforces RED THEN BLACK --
backwards earns a spark shower and commentary.

Workmanship is a stat: every job scores 0-100, and an 85+ fix BUFFS the
car through Garage.apply (tyres +0.2 grip, oil +3% power, battery +0.15
boost) until that fault recurs -- add_fault clears its quality entry, so
your last good fix is exactly as durable as your driving.

Faults now have gameplay sources: a red light at the tree flat-spots
the fronts, every 4th wreck rattles the sump loose (oil light, not in
junctions where wrecking is the game), and getting busted kills the
battery. All three are suite-inert by construction. New faults route to
the wrench scene via FAULTS[id].scene; gearbox/gasket keep the 2D panel
until phase 2. Wounds: flat -0.35 grip, oil -7% power, battery -0.3
boost (boost floor clamped at 0.4 -- the old ba>0 guard never subtracted).

Smoke plays a clean flat-tyre job through the API (7 steps, quality
90+, grip buff verified on the next boot) and a sabotaged oil job (blown
torque window -> stripped thread -> extraction step appears). The same
Variant-inference parse trap struck a third time (Dictionary index this
time); the suite caught it before it cost anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-31 15:36:05 +10:00
parent 3e9a8616ab
commit a5158bf6ec
9 changed files with 752 additions and 10 deletions

View File

@ -151,6 +151,25 @@ Purchases are per-car (tools are global) and persist in `user://garage.save`,
which also carries the drag **ladder rung**, per-car **quarter-mile PBs** and which also carries the drag **ladder rung**, per-car **quarter-mile PBs** and
outstanding **faults**. outstanding **faults**.
**The wrench minigame** ([src/wrench.gd](src/wrench.gd)) is the 3D half of
repairs, phase 1: flat tyre, oil change and dead battery happen on YOUR car,
parked in YOUR shed, through five mouse verbs on 3D anchor points -- GRAB
(part follows the cursor, drop it at the target), TWIST (circle the mouse;
torque steps have a seat window and blowing past it STRIPS the thread, adding
an extraction step), PUMP (rhythm clicks), YANK (flick), and STEADY (keep the
cursor in a wandering ring). Every job scores 0-100 for workmanship; finish at
85+ and the fix is a live buff via Garage.apply (grip for tyres, power for
oil, boost for battery) until that fault recurs -- your hands are the tune.
Faults have gameplay sources: a red light at the tree flat-spots the fronts,
every 4th wreck rattles the sump loose (oil light), and getting busted kills
the battery, because the divvy van jump-starts it wrong out of spite. The
whole thing routes through an API (`twist/pump/grab/drop_at/yank_ok/
steady_tick/clamp_on`), so the smoke suite plays a clean flat-tyre job
(quality 90+, buff verified on next boot) and a sabotaged oil job (stripped
thread, extraction step) headless. Gearbox and gasket still use the 2D panel
(`FAULTS[id].scene` routes each fault to its minigame) -- porting them to 3D
plus tool tiers and driveway customers is phases 2 and 3.
**The Shed is also a place** (`levels/the_shed`): a drivable suburban home base **The Shed is also a place** (`levels/the_shed`): a drivable suburban home base
— brick house, paling fence, hills hoist mid-spin with the washing still on it, — brick house, paling fence, hills hoist mid-spin with the washing still on it,
wheelie bins at the gate, and the corrugated double shed on its slab. The tools wheelie bins at the gate, and the corrugated double shed on its slab. The tools

10
push20.log Normal file
View File

@ -0,0 +1,10 @@
Enumerating objects: 38, done.
Counting objects: 2% (1/38) Counting objects: 5% (2/38) Counting objects: 7% (3/38) Counting objects: 10% (4/38) Counting objects: 13% (5/38) Counting objects: 15% (6/38) Counting objects: 18% (7/38) Counting objects: 21% (8/38) Counting objects: 23% (9/38) Counting objects: 26% (10/38) Counting objects: 28% (11/38) Counting objects: 31% (12/38) Counting objects: 34% (13/38) Counting objects: 36% (14/38) Counting objects: 39% (15/38) Counting objects: 42% (16/38) Counting objects: 44% (17/38) Counting objects: 47% (18/38) Counting objects: 50% (19/38) Counting objects: 52% (20/38) Counting objects: 55% (21/38) Counting objects: 57% (22/38) Counting objects: 60% (23/38) Counting objects: 63% (24/38) Counting objects: 65% (25/38) Counting objects: 68% (26/38) Counting objects: 71% (27/38) Counting objects: 73% (28/38) Counting objects: 76% (29/38) Counting objects: 78% (30/38) Counting objects: 81% (31/38) Counting objects: 84% (32/38) Counting objects: 86% (33/38) Counting objects: 89% (34/38) Counting objects: 92% (35/38) Counting objects: 94% (36/38) Counting objects: 97% (37/38) Counting objects: 100% (38/38) Counting objects: 100% (38/38), done.
Delta compression using up to 32 threads
Compressing objects: 4% (1/23) Compressing objects: 8% (2/23) Compressing objects: 13% (3/23) Compressing objects: 17% (4/23) Compressing objects: 21% (5/23) Compressing objects: 26% (6/23) Compressing objects: 30% (7/23) Compressing objects: 34% (8/23) Compressing objects: 39% (9/23) Compressing objects: 43% (10/23) Compressing objects: 47% (11/23) Compressing objects: 52% (12/23) Compressing objects: 56% (13/23) Compressing objects: 60% (14/23) Compressing objects: 65% (15/23) Compressing objects: 69% (16/23) Compressing objects: 73% (17/23) Compressing objects: 78% (18/23) Compressing objects: 82% (19/23) Compressing objects: 86% (20/23) Compressing objects: 91% (21/23) Compressing objects: 95% (22/23) Compressing objects: 100% (23/23) Compressing objects: 100% (23/23), done.
Writing objects: 4% (1/25) Writing objects: 8% (2/25) Writing objects: 12% (3/25) Writing objects: 16% (4/25) Writing objects: 20% (5/25) Writing objects: 24% (6/25) Writing objects: 32% (8/25) Writing objects: 36% (9/25) Writing objects: 40% (10/25) Writing objects: 44% (11/25) Writing objects: 48% (12/25) Writing objects: 52% (13/25) Writing objects: 56% (14/25) Writing objects: 60% (15/25) Writing objects: 64% (16/25) Writing objects: 68% (17/25) Writing objects: 72% (18/25) Writing objects: 76% (19/25) Writing objects: 80% (20/25) Writing objects: 84% (21/25) Writing objects: 88% (22/25) Writing objects: 92% (23/25) Writing objects: 96% (24/25) Writing objects: 100% (25/25) Writing objects: 100% (25/25), 211.82 KiB | 30.26 MiB/s, done.
Total 25 (delta 13), reused 0 (delta 0), pack-reused 0 (from 0)
remote: . Processing 1 references
remote: Processed 1 references in total
To ssh://100.71.119.27:222/monster/burnoutinfinityshitbox.git
8967972..6d740ad main -> main

View File

@ -960,10 +960,11 @@ func _drag_finish(crossed: bool) -> void:
lines.append("UP THE LADDER: next is %s" % DRAG_LADDER[drag_rung_now + 1]["name"]) lines.append("UP THE LADDER: next is %s" % DRAG_LADDER[drag_rung_now + 1]["name"])
elif drag_rung_now == DRAG_LADDER.size() - 1: elif drag_rung_now == DRAG_LADDER.size() - 1:
lines.append("TOP OF THE LADDER. AGAIN?") lines.append("TOP OF THE LADDER. AGAIN?")
if jumped:
lines.append("(red light. we saw it. everyone saw it.)")
# what you broke follows the car home: faults persist until repaired # what you broke follows the car home: faults persist until repaired
var cid := Garage.car_id_of(car_path) var cid := Garage.car_id_of(car_path)
if jumped:
Garage.add_fault(cid, "flat")
lines.append("(red light. we saw it. and you've flat-spotted the fronts -- FIX IT)")
if box_blown: if box_blown:
Garage.add_fault(cid, "gearbox") Garage.add_fault(cid, "gearbox")
lines.append("(the gearbox is on the strip somewhere behind you -- FIX IT IN THE GARAGE)") lines.append("(the gearbox is on the strip somewhere behind you -- FIX IT IN THE GARAGE)")
@ -1040,7 +1041,9 @@ func _end_pursuit(escaped: bool) -> void:
else: else:
busts += 1 busts += 1
Sfx.shot(car, "sting_lose", -4.0) Sfx.shot(car, "sting_lose", -4.0)
_msg("BUSTED", 52) # impound treatment: the divvy van jump-starts it wrong, out of spite
Garage.add_fault(Garage.car_id_of(car_path), "battery")
_msg("BUSTED\n(and now the battery's dead. coincidence?)", 48)
func _pursuit_tick(delta: float) -> void: func _pursuit_tick(delta: float) -> void:
heat_cd = maxf(heat_cd - delta, 0.0) heat_cd = maxf(heat_cd - delta, 0.0)
@ -1241,6 +1244,11 @@ func _player_wreck() -> void:
get_tree().create_timer(1.0, true, false, true).timeout.connect( get_tree().create_timer(1.0, true, false, true).timeout.connect(
func() -> void: Engine.time_scale = 1.0) func() -> void: Engine.time_scale = 1.0)
_msg("WRECKED", 48) _msg("WRECKED", 48)
# wrecks wear the car: every 4th rattles the sump loose (not in junctions,
# where wrecking IS the game and shouldn't bill you for playing it)
if mode != "junction" and Garage.bump_wreck(Garage.car_id_of(car_path)):
get_tree().create_timer(2.0, true, false, true).timeout.connect(
func() -> void: _msg("SOMETHING'S RATTLED LOOSE -- OIL LIGHT'S ON", 32))
func _rival_wrecked(r: Rival) -> void: func _rival_wrecked(r: Rival) -> void:
if car.wrecked or car.global_position.distance_to(r.global_position) > 8.0: if car.wrecked or car.global_position.distance_to(r.global_position) > 8.0:

View File

@ -194,13 +194,23 @@ static func buy_tune(car_id: String, tune: String) -> bool:
## "tool" is the shed tool that makes the DIY fix civilised; without it the ## "tool" is the shed tool that makes the DIY fix civilised; without it the
## minigame is longer and the commentary crueller. ## minigame is longer and the commentary crueller.
## scene: which minigame fixes it -- "repair" (2D panel) or "wrench" (3D shed)
const FAULTS := { const FAULTS := {
"gearbox": {"name": "GRENADED GEARBOX", "light": "DRIVETRAIN", "shop": 900, "gearbox": {"name": "GRENADED GEARBOX", "light": "DRIVETRAIN", "shop": 900,
"tool": "hoist", "tool": "hoist", "scene": "repair",
"desc": "Third gear is a maraca now. Leaks on everything it loves."}, "desc": "Third gear is a maraca now. Leaks on everything it loves."},
"gasket": {"name": "BLOWN HEAD GASKET", "light": "ENGINE TEMP", "shop": 700, "gasket": {"name": "BLOWN HEAD GASKET", "light": "ENGINE TEMP", "shop": 700,
"tool": "sockets", "tool": "sockets", "scene": "repair",
"desc": "Mayonnaise under the oil cap. Not the good kind."}, "desc": "Mayonnaise under the oil cap. Not the good kind."},
"flat": {"name": "FLAT-SPOTTED TYRES", "light": "TYRE PRESSURE", "shop": 250,
"tool": "sockets", "scene": "wrench",
"desc": "That red-light launch took the fronts with it. Grip is a memory."},
"oil": {"name": "OIL LIGHT'S ON", "light": "OIL PRESSURE", "shop": 180,
"tool": "ramps", "scene": "wrench",
"desc": "Service due. The sump remembers every wreck you've walked away from."},
"battery": {"name": "DEAD BATTERY", "light": "BATTERY", "shop": 220,
"tool": "reader", "scene": "wrench",
"desc": "The divvy van jump-started it wrong out of spite."},
} }
static func faults(car_id: String) -> Array: static func faults(car_id: String) -> Array:
@ -211,6 +221,11 @@ static func add_fault(car_id: String, fault: String) -> void:
_load() _load()
if not FAULTS.has(fault): if not FAULTS.has(fault):
return return
# the fault recurring takes its quality buff with it: your last good fix
# is exactly as durable as your driving
var qd: Dictionary = _data.get("quality", {})
if qd.has(car_id):
qd[car_id].erase(fault)
var f: Dictionary = _data.get("faults", {}) var f: Dictionary = _data.get("faults", {})
var lst: Array = f.get(car_id, []) var lst: Array = f.get(car_id, [])
if fault in lst: if fault in lst:
@ -220,6 +235,33 @@ static func add_fault(car_id: String, fault: String) -> void:
_data["faults"] = f _data["faults"] = f
_flush() _flush()
## ---- workmanship: 0-100 per fixed system, 85+ is a buff --------------------
static func quality(car_id: String, sys: String) -> float:
_load()
return float(_data.get("quality", {}).get(car_id, {}).get(sys, 0.0))
static func set_quality(car_id: String, sys: String, score: float) -> void:
_load()
var q: Dictionary = _data.get("quality", {})
var c: Dictionary = q.get(car_id, {})
c[sys] = score
q[car_id] = c
_data["quality"] = q
_flush()
static func bump_wreck(car_id: String) -> bool:
## Wrecks wear the sump loose: every 4th one turns the oil light on.
_load()
var w: Dictionary = _data.get("wrecks", {})
w[car_id] = int(w.get(car_id, 0)) + 1
_data["wrecks"] = w
_flush()
if int(w[car_id]) % 4 == 0 and not "oil" in faults(car_id):
add_fault(car_id, "oil")
return true
return false
static func clear_fault(car_id: String, fault: String) -> void: static func clear_fault(car_id: String, fault: String) -> void:
_load() _load()
var f: Dictionary = _data.get("faults", {}) var f: Dictionary = _data.get("faults", {})
@ -284,7 +326,9 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
var ups := owned(cid) var ups := owned(cid)
var tns := tunes(cid) var tns := tunes(cid)
var flt := faults(cid) var flt := faults(cid)
if ups.is_empty() and tns.is_empty() and flt.is_empty(): _load()
var qual: Dictionary = _data.get("quality", {}).get(cid, {})
if ups.is_empty() and tns.is_empty() and flt.is_empty() and qual.is_empty():
return return
var st = car.get("stats") var st = car.get("stats")
if st != null: if st != null:
@ -300,6 +344,20 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
pm *= 0.85 pm *= 0.85
if car.has_method("set_steam"): if car.has_method("set_steam"):
car.call("set_steam", true) car.call("set_steam", true)
if "flat" in flt:
ga -= 0.35
if "oil" in flt:
pm *= 0.93
if "battery" in flt:
ba -= 0.3
# workmanship buffs: an 85+ fix drives BETTER than stock, until you
# break that system again (add_fault clears its quality entry)
if float(qual.get("flat", 0.0)) >= 85.0:
ga += 0.2
if float(qual.get("oil", 0.0)) >= 85.0:
pm *= 1.03
if float(qual.get("battery", 0.0)) >= 85.0:
ba += 0.15
for u in ups: for u in ups:
match u: match u:
"stripes": pm += 0.06 "stripes": pm += 0.06
@ -323,8 +381,8 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
st.engine_power *= pm st.engine_power *= pm
st.grip += ga st.grip += ga
car.set("stats", st) car.set("stats", st)
if ba > 0.0 and car.get("boost_max") != null: if ba != 0.0 and car.get("boost_max") != null:
car.set("boost_max", float(car.get("boost_max")) + ba) car.set("boost_max", maxf(float(car.get("boost_max")) + ba, 0.4))
_bolt_visuals(car, ups) _bolt_visuals(car, ups)
static func _mat(rgba: Color, rough := 0.5, metal := 0.0, emit := Color.BLACK) -> StandardMaterial3D: static func _mat(rgba: Color, rough := 0.5, metal := 0.0, emit := Color.BLACK) -> StandardMaterial3D:

View File

@ -177,9 +177,11 @@ func _on_buy() -> void:
"tune": ok = Garage.buy_tune(car_id, e["id"]) "tune": ok = Garage.buy_tune(car_id, e["id"])
"shopfix": ok = Garage.shop_fix(car_id, e["id"]) "shopfix": ok = Garage.shop_fix(car_id, e["id"])
"fault": "fault":
# DIY: off to the shed minigame; it clears the fault on completion # DIY: off to whichever minigame owns this fault (repair = 2D panel,
# wrench = 3D shed); it clears the fault on completion
Garage.ui_fault = e["id"] Garage.ui_fault = e["id"]
get_tree().change_scene_to_file("res://src/repair.tscn") get_tree().change_scene_to_file("res://src/%s.tscn"
% String(Garage.FAULTS[e["id"]].get("scene", "repair")))
return return
if ok: if ok:
Sfx.shot_2d("clunk") Sfx.shot_2d("clunk")

568
src/wrench.gd Normal file
View File

@ -0,0 +1,568 @@
extends Node3D
## The 3D wrenching minigame, Phase 1: flat tyre, oil change, dead battery.
## Your ACTUAL car in your ACTUAL shed, fixed by hand with five verbs:
## GRAB -- click a loose part/tool, it follows the cursor, drop at the target
## TWIST -- hold near the bolt and circle the mouse; torque jobs have a seat
## window -- release inside it for a clean torque, blow past it and
## the thread STRIPS (quality hit + an extraction step appears)
## PUMP -- click the anchor repeatedly (jack, pump)
## YANK -- press the anchor and flick the mouse hard
## STEADY- keep the cursor inside a wandering ring (lining up studs, pouring)
## Every job scores 0-100. Finish at 85+ and the fix is a BUFF (Garage.apply)
## until that fault recurs. Which car/fault come via Garage.ui_stats_path /
## Garage.ui_fault. All input routes through the API methods (twist, pump,
## grab, drop, yank_ok, steady_tick, clamp, twist_release, skip_wait) so the
## smoke suite plays entire jobs headless.
const WINDOW := 0.8 # rad of "seated" travel after the turns are in
const CAR_AT := Vector3(12, 0.0, -14.2) # the shed's painted bay
var car_id := ""
var fault := ""
var stats: CarStats
var steps: Array = []
var step_index := 0
var quality := 100.0
var done := false
var carried := "" # GRAB: name of the prop on the cursor
var twist_prog := 0.0
var _stripped := false # current twist step already stripped
var pump_count := 0
var steady_need := 0.0
var wait_left := 0.0
var clamps_done: Array = []
var props := {} # name -> Node3D (spare wheel, battery, tray...)
var model: Node3D
var cam: Camera3D
var step_label: Label
var note_label: Label
var qual_label: Label
var ring: Control # screen-space anchor/steady indicator
var _wheel_mesh: MeshInstance3D = null
var _bonnet: Node3D = null
var _oil_fx: GPUParticles3D = null
func _ready() -> void:
car_id = Garage.car_id_of(Garage.ui_stats_path)
fault = Garage.ui_fault
stats = load(Garage.ui_stats_path)
add_child((load("res://levels/the_shed/level.tscn") as PackedScene).instantiate())
_environment()
# the patient: model only, no physics -- it isn't going anywhere on stands
if stats.model_path != "" and ResourceLoader.exists(stats.model_path):
model = (load(stats.model_path) as PackedScene).instantiate()
else:
model = Node3D.new()
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(stats.width, 1.0, stats.length)
mi.mesh = bm
model.add_child(mi)
add_child(model)
model.position = CAR_AT + Vector3.UP * 0.72
for w in model.find_children("wheel_*", "MeshInstance3D", true, false):
var mi := w as MeshInstance3D
if mi.position.x < 0.0 and mi.position.z < 0.0:
_wheel_mesh = mi # front-left is the patient's patient
var b := model.find_children("det_bonnet*", "Node3D", true, false)
_bonnet = b[0] if not b.is_empty() else null
cam = Camera3D.new()
cam.fov = 60.0
add_child(cam)
_hud()
steps = _job()
_show_step()
func _environment() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55, 40, 0)
sun.light_energy = 1.1
sun.shadow_enabled = true
add_child(sun)
var e := Environment.new()
e.background_mode = Environment.BG_SKY
e.sky = Sky.new()
var sm := ProceduralSkyMaterial.new()
sm.sky_horizon_color = Color(0.8, 0.85, 0.9)
e.sky.sky_material = sm
e.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
e.ambient_light_energy = 0.6
e.tonemap_mode = Environment.TONE_MAPPER_ACES
var env := WorldEnvironment.new()
env.environment = e
add_child(env)
func _hud() -> void:
var hud := CanvasLayer.new()
add_child(hud)
step_label = Label.new()
step_label.anchor_left = 0.5
step_label.anchor_right = 0.5
step_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
step_label.anchor_top = 0.04
step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
step_label.add_theme_font_size_override("font_size", 34)
hud.add_child(step_label)
note_label = Label.new()
note_label.anchor_left = 0.5
note_label.anchor_right = 0.5
note_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
note_label.anchor_top = 0.11
note_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
note_label.add_theme_font_size_override("font_size", 19)
note_label.add_theme_color_override("font_color", Color(0.7, 0.73, 0.78))
hud.add_child(note_label)
qual_label = Label.new()
qual_label.position = Vector2(24, 24)
qual_label.add_theme_font_size_override("font_size", 26)
hud.add_child(qual_label)
ring = RingDraw.new()
ring.set_anchors_preset(Control.PRESET_FULL_RECT)
ring.mouse_filter = Control.MOUSE_FILTER_IGNORE
hud.add_child(ring)
## ---- job sheets ------------------------------------------------------------
func _anchor_wheel() -> Vector3:
return CAR_AT + Vector3(-stats.width * 0.5, 0.45, -(stats.length * 0.5 - 0.55))
func _anchor_engine() -> Vector3:
return CAR_AT + Vector3(0, 1.1, -stats.length * 0.30)
func _anchor_sump() -> Vector3:
return CAR_AT + Vector3(0.2, 0.28, -stats.length * 0.18)
func _job() -> Array:
var have_sockets := Garage.has_tool("sockets")
var t := 3.0 if have_sockets else 6.0
match fault:
"flat":
return [
{"verb": "pump", "clicks": 8, "cam": "wheel", "label": "JACK IT UP",
"note": "click it like you mean it"},
{"verb": "twist", "dir": -1.0, "turns": t, "window": false, "cam": "wheel",
"label": "CRACK THE NUTS OFF",
"note": "ALDI SOCKETS: fits, mostly" if have_sockets else "VICE GRIPS: this will end badly"},
{"verb": "yank", "cam": "wheel", "label": "POP THE FLAT OFF",
"note": "press on it and FLICK"},
{"verb": "grab", "item": "spare", "at": "wheel", "cam": "wheel",
"label": "SPARE ON", "note": "grab it, hang it on the studs"},
{"verb": "steady", "secs": 2.0, "cam": "wheel", "label": "LINE UP THE STUDS",
"note": "keep the cursor in the ring"},
{"verb": "twist", "dir": 1.0, "turns": t, "window": true, "cam": "wheel",
"label": "TORQUE THE NUTS", "note": "release IN the green -- past it strips"},
{"verb": "pump", "clicks": 4, "cam": "wheel", "label": "LET IT DOWN",
"note": "gently. gently."},
]
"oil":
return [
{"verb": "grab", "item": "tray", "at": "sump", "cam": "under",
"label": "TRAY UNDER THE SUMP", "note": "future-you says thanks"},
{"verb": "twist", "dir": -1.0, "turns": 1.5, "window": false, "cam": "under",
"label": "CRACK THE DRAIN PLUG", "note": "it's tighter than it needs to be"},
{"verb": "yank", "cam": "under", "label": "PLUG OUT", "note": "flick -- and stand back"},
{"verb": "wait", "secs": 2.0, "cam": "under", "label": "LET HER DRAIN",
"note": "black gold. worthless black gold."},
{"verb": "twist", "dir": 1.0, "turns": 1.5, "window": true, "cam": "under",
"label": "PLUG BACK IN", "note": "release in the green: sump threads are soft"},
{"verb": "grab", "item": "bottle", "at": "engine", "cam": "bonnet",
"label": "FRESH OIL UP TOP", "note": "the good stuff ($12/5L)"},
{"verb": "steady", "secs": 2.5, "cam": "bonnet", "label": "POUR IT IN, STEADY",
"note": "outside the ring = on the manifold"},
]
"battery":
return [
{"verb": "yank", "cam": "bonnet", "label": "BONNET UP", "note": "flick it open"},
{"verb": "twist", "dir": -1.0, "turns": 1.0, "window": false, "cam": "bonnet",
"label": "CLAMPS OFF", "note": "10mm. it's always 10mm. where's the 10mm"},
{"verb": "grab", "item": "old_batt", "at": "bench", "cam": "bonnet",
"label": "DEAD ONE OUT", "note": "heavier than it looks"},
{"verb": "grab", "item": "new_batt", "at": "engine", "cam": "bonnet",
"label": "CASHIES BATTERY IN",
"note": "charged overnight" if Garage.has_tool("reader") else "\"probably holds charge\" -- the label"},
{"verb": "clamps", "cam": "bonnet", "label": "CLAMP IT UP: RED THEN BLACK",
"note": "get it backwards and you'll know"},
{"verb": "yank", "cam": "bonnet", "label": "BONNET DOWN", "note": "one dignified slam"},
]
return []
## ---- step lifecycle --------------------------------------------------------
func _show_step() -> void:
twist_prog = 0.0
_stripped = false
pump_count = 0
steady_need = 0.0
clamps_done = []
carried = ""
if step_index >= steps.size():
_finish()
return
var st: Dictionary = steps[step_index]
step_label.text = "%s (%d/%d)" % [st["label"], step_index + 1, steps.size()]
note_label.text = String(st.get("note", ""))
qual_label.text = "QUALITY %d%%" % int(quality)
_camera(String(st.get("cam", "wheel")))
match st["verb"]:
"wait":
wait_left = float(st["secs"])
_oil_start()
"steady":
steady_need = float(st["secs"])
"grab":
_spawn_prop(String(st["item"]))
"yank":
if fault == "battery" and step_index == 0:
pass # bonnet handled on success
ring.anchor_world = _step_anchor()
ring.cam = cam
ring.mode = String(st["verb"])
func _step_anchor() -> Vector3:
var st: Dictionary = steps[step_index]
match String(st.get("cam", "wheel")):
"under":
return _anchor_sump()
"bonnet":
return _anchor_engine()
return _anchor_wheel()
func _camera(preset: String) -> void:
match preset:
"under":
cam.global_position = CAR_AT + Vector3(0.4, 0.5, -stats.length * 0.18 + 2.6)
cam.look_at(_anchor_sump())
"bonnet":
cam.global_position = CAR_AT + Vector3(-1.6, 2.3, -stats.length * 0.30 + 2.4)
cam.look_at(_anchor_engine())
_:
cam.global_position = CAR_AT + Vector3(-stats.width * 0.5 - 2.6, 1.4,
-(stats.length * 0.5 - 0.55) + 2.2)
cam.look_at(_anchor_wheel())
func _spawn_prop(item: String) -> void:
if props.has(item):
return
var n := Node3D.new()
var mi := MeshInstance3D.new()
match item:
"spare":
var cm := CylinderMesh.new()
cm.top_radius = 0.31
cm.bottom_radius = 0.31
cm.height = 0.22
cm.material = _dark(Color(0.09, 0.09, 0.1))
mi.mesh = cm
mi.rotation_degrees = Vector3(0, 0, 90)
n.position = CAR_AT + Vector3(-2.6, 0.31, 2.2)
"tray":
var bm := BoxMesh.new()
bm.size = Vector3(0.5, 0.12, 0.5)
bm.material = _dark(Color(0.15, 0.15, 0.17))
mi.mesh = bm
n.position = CAR_AT + Vector3(-2.0, 0.06, 0.5)
"bottle":
var bm := BoxMesh.new()
bm.size = Vector3(0.18, 0.32, 0.14)
bm.material = _dark(Color(0.75, 0.7, 0.2))
mi.mesh = bm
n.position = Vector3(11.5, 1.06, -19.8) # on the bench
"old_batt", "new_batt":
var bm := BoxMesh.new()
bm.size = Vector3(0.32, 0.22, 0.19)
bm.material = _dark(Color(0.08, 0.08, 0.09) if item == "old_batt" else Color(0.12, 0.3, 0.12))
mi.mesh = bm
n.position = _anchor_engine() if item == "old_batt" else Vector3(10.5, 1.08, -19.8)
n.add_child(mi)
n.name = item
add_child(n)
props[item] = n
func _dark(c: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = c
m.roughness = 0.7
return m
func _oil_start() -> void:
if _oil_fx == null:
_oil_fx = Fx.smoker(self, Vector3.ZERO)
_oil_fx.position = _anchor_sump()
_oil_fx.amount = 12
_oil_fx.emitting = true
func _process(delta: float) -> void:
if done:
return
if step_index < steps.size() and steps[step_index]["verb"] == "wait":
wait_left -= delta
if wait_left <= 0.0:
if _oil_fx:
_oil_fx.emitting = false
_advance()
## ---- the verbs (test API) --------------------------------------------------
func twist(rad: float) -> void:
if done or steps[step_index]["verb"] != "twist":
return
var st: Dictionary = steps[step_index]
var toward: float = rad * float(st["dir"])
if toward <= 0.0:
return
twist_prog += toward
var need: float = float(st["turns"]) * TAU
if not bool(st["window"]):
if twist_prog >= need:
Sfx.shot_2d("clunk", -6.0)
_advance()
return
# torque window: overshoot past the seat and the thread strips
if twist_prog > need + WINDOW and not _stripped:
_stripped = true
quality -= 10.0
Sfx.shot_2d("bog", -4.0)
note_label.text = "STRIPPED IT. that's going in the jar."
steps.insert(step_index + 1, {"verb": "twist", "dir": -1.0, "turns": 2.0,
"window": false, "cam": st.get("cam", "wheel"),
"label": "EXTRACT THE STRIPPED BOLT", "note": "(you did this)"})
_advance()
func twist_release() -> void:
## releasing the mouse ends a torque attempt: clean if inside the window
if done or steps[step_index]["verb"] != "twist" or not bool(steps[step_index]["window"]):
return
var need: float = float(steps[step_index]["turns"]) * TAU
if twist_prog >= need and twist_prog <= need + WINDOW:
Sfx.shot_2d("clunk", -4.0)
_advance()
elif twist_prog > 0.5:
quality -= 1.0 # fumbled attempt, re-grip
twist_prog = maxf(twist_prog - 1.0, 0.0)
func pump() -> void:
if done or steps[step_index]["verb"] != "pump":
return
pump_count += 1
Sfx.shot_2d("clunk", -12.0)
if pump_count >= int(steps[step_index]["clicks"]):
_advance()
func yank_ok() -> void:
if done or steps[step_index]["verb"] != "yank":
return
Sfx.shot_2d("clunk", -6.0)
if fault == "flat" and _wheel_mesh:
_wheel_mesh.visible = false
if fault == "battery" and _bonnet:
# explicit bool: a Dictionary index is Variant and := can't see through it
var opening: bool = steps[step_index]["label"] == "BONNET UP"
var tw := create_tween()
tw.tween_property(_bonnet, "rotation:x", -0.9 if opening else 0.0, 0.4)
_advance()
func grab(item: String) -> void:
if done or steps[step_index]["verb"] != "grab":
return
if steps[step_index]["item"] == item:
carried = item
func drop_at(world: Vector3) -> void:
if done or carried == "" or steps[step_index]["verb"] != "grab":
return
var st: Dictionary = steps[step_index]
var target: Vector3
match String(st["at"]):
"wheel": target = _anchor_wheel()
"sump": target = _anchor_sump() - Vector3(0, 0.2, 0)
"engine": target = _anchor_engine()
"bench": target = Vector3(11.5, 1.0, -19.8)
_: target = _anchor_wheel()
if world.distance_to(target) < 1.2:
var p: Node3D = props.get(carried)
if p:
p.position = target
if carried == "spare":
p.get_child(0).rotation_degrees = Vector3(0, 0, 90)
Sfx.shot_2d("clunk", -6.0)
carried = ""
_advance()
func steady_tick(inside: bool, delta: float) -> void:
if done or steps[step_index]["verb"] != "steady":
return
if inside:
steady_need -= delta
if steady_need <= 0.0:
Sfx.shot_2d("clunk", -6.0)
if fault == "flat" and props.has("spare") and _wheel_mesh:
(props["spare"] as Node3D).visible = false
_wheel_mesh.visible = true # the spare becomes the wheel
_advance()
else:
quality -= 2.0 * delta * 10.0 * 0.1 # excursions cost, gently
qual_label.text = "QUALITY %d%%" % int(quality)
func clamp_on(colour: String) -> void:
if done or steps[step_index]["verb"] != "clamps":
return
if colour in clamps_done:
return
if clamps_done.is_empty() and colour != "red":
quality -= 10.0
Fx.sparks(self, 30)
Sfx.shot_2d("glass", -6.0)
note_label.text = "WRONG ORDER. smell that? that's the smell of learning."
clamps_done.append(colour)
Sfx.shot_2d("clunk", -8.0)
if clamps_done.size() >= 2:
_advance()
func skip_wait() -> void:
if not done and step_index < steps.size() and steps[step_index]["verb"] == "wait":
wait_left = 0.0
func _advance() -> void:
step_index += 1
_show_step()
func _finish() -> void:
done = true
quality = clampf(quality, 0.0, 100.0)
Garage.clear_fault(car_id, fault)
Garage.set_quality(car_id, fault, quality)
step_label.text = "DONE. QUALITY %d%%" % int(quality)
note_label.text = ("that's a PROPER fix -- it'll drive better than stock" if quality >= 85.0
else "it'll hold. probably. no buff at that standard, mate")
Sfx.shot_2d("sting_win" if quality >= 85.0 else "clunk", -4.0)
var b := Button.new()
b.text = "BACK TO THE GARAGE"
b.add_theme_font_size_override("font_size", 26)
b.anchor_left = 0.5
b.anchor_right = 0.5
b.anchor_top = 0.8
b.grow_horizontal = Control.GROW_DIRECTION_BOTH
b.pressed.connect(func() -> void:
get_tree().change_scene_to_file("res://src/garage.tscn"))
var hud := CanvasLayer.new()
add_child(hud)
hud.add_child(b)
## ---- mouse plumbing --------------------------------------------------------
var _pressing := false
var _prev_angle := 0.0
var _press_pos := Vector2.ZERO
var _press_t := 0.0
func _anchor_screen() -> Vector2:
return cam.unproject_position(_step_anchor())
func _unhandled_input(event: InputEvent) -> void:
if done or step_index >= steps.size():
return
var st: Dictionary = steps[step_index]
var verb := String(st["verb"])
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_pressing = true
_press_pos = event.position
_press_t = Time.get_ticks_msec() / 1000.0
var d: float = event.position.distance_to(_anchor_screen())
match verb:
"twist":
_prev_angle = (event.position - _anchor_screen()).angle()
"pump":
if d < 160.0:
pump()
"grab":
if carried == "":
# grab the prop if clicked near it, else it's a miss
var p: Node3D = props.get(String(st["item"]))
if p and event.position.distance_to(cam.unproject_position(p.global_position)) < 120.0:
grab(String(st["item"]))
"clamps":
# left half of the anchor = red clamp, right half = black
clamp_on("red" if event.position.x < _anchor_screen().x else "black")
else:
_pressing = false
match verb:
"twist":
twist_release()
"yank":
var dt := Time.get_ticks_msec() / 1000.0 - _press_t
if dt < 0.35 and event.position.distance_to(_press_pos) > 120.0:
yank_ok()
"grab":
if carried != "":
# drop: project the cursor onto the anchor's height plane
var from := cam.project_ray_origin(event.position)
var dir := cam.project_ray_normal(event.position)
var plane := Plane(Vector3.UP, _step_anchor().y)
var hit = plane.intersects_ray(from, dir)
if hit != null:
drop_at(hit)
elif event is InputEventMouseMotion:
if _pressing and verb == "twist":
var a: float = (event.position - _anchor_screen()).angle()
twist(wrapf(a - _prev_angle, -PI, PI))
_prev_angle = a
elif carried != "":
var p: Node3D = props.get(carried)
if p:
var from := cam.project_ray_origin(event.position)
var dir := cam.project_ray_normal(event.position)
var plane := Plane(Vector3.UP, _step_anchor().y)
var hit = plane.intersects_ray(from, dir)
if hit != null:
p.global_position = hit
func _physics_process(delta: float) -> void:
if done or step_index >= steps.size():
return
if steps[step_index]["verb"] == "steady":
var inside: bool = get_viewport().get_mouse_position().distance_to(
ring.steady_centre()) < 46.0
steady_tick(inside, delta)
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file("res://src/garage.tscn")
class RingDraw extends Control:
## Screen-space indicator: anchor ring, twist progress arc, wandering
## steady target. Pure decoration -- the verbs live on the parent.
var anchor_world := Vector3.ZERO
var cam: Camera3D = null
var mode := ""
var _t := 0.0
func steady_centre() -> Vector2:
var base := cam.unproject_position(anchor_world) if cam else size * 0.5
return base + Vector2(sin(_t * 1.3), cos(_t * 0.9)) * 34.0
func _process(delta: float) -> void:
_t += delta
queue_redraw()
func _draw() -> void:
if cam == null:
return
var p := cam.unproject_position(anchor_world)
var parent := get_parent().get_parent() as Node3D # CanvasLayer -> wrench
match mode:
"twist":
draw_arc(p, 42.0, 0, TAU, 40, Color(1, 0.82, 0.35, 0.8), 3.0)
var w = parent.get("steps")[parent.get("step_index")]
if bool(w.get("window", false)):
var need: float = float(w["turns"]) * TAU
var frac: float = clampf(float(parent.get("twist_prog")) / need, 0.0, 1.3)
draw_arc(p, 52.0, -PI / 2, -PI / 2 + TAU * minf(frac, 1.0), 40,
Color(0.3, 0.9, 0.4, 0.9) if frac >= 1.0 else Color(0.9, 0.9, 0.9, 0.7), 4.0)
"steady":
draw_arc(steady_centre(), 46.0, 0, TAU, 40, Color(0.3, 0.9, 0.4, 0.85), 3.0)
"pump", "yank", "grab", "clamps":
draw_arc(p, 40.0 + sin(_t * 5.0) * 5.0, 0, TAU, 40, Color(1, 0.82, 0.35, 0.7), 3.0)

1
src/wrench.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dfyx45imad8y6

6
src/wrench.tscn Normal file
View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/wrench.gd" id="1"]
[node name="Wrench" type="Node3D"]
script = ExtResource("1")

View File

@ -124,6 +124,37 @@ func _run() -> void:
_check(gg.garage_requested, "rolling into the bay never opened the garage") _check(gg.garage_requested, "rolling into the bay never opened the garage")
gg.free() gg.free()
Game.level_path = levels.get("woolies_carpark", levels.values()[0]) Game.level_path = levels.get("woolies_carpark", levels.values()[0])
# wrench phase 1: the 3D shed jobs. A clean flat-tyre run scores 90+ and
# buffs the car; a sabotaged torque window strips a thread on the oil job.
print(" .. wrench")
Garage.add_fault("datto_120y", "flat")
Garage.ui_stats_path = "res://cars/datto_120y/stats.tres"
Garage.ui_fault = "flat"
var wr = (load("res://src/wrench.tscn") as PackedScene).instantiate()
root.add_child(wr)
await process_frame
_check(wr.steps.size() == 7, "flat-tyre job sheet wrong (%d steps)" % wr.steps.size())
await _drive_wrench(wr, false)
_check(wr.done, "flat-tyre job never finished (step %d)" % wr.step_index)
_check(wr.quality >= 90.0, "clean flat run scored %d" % int(wr.quality))
_check(not Garage.faults("datto_120y").has("flat"), "flat fault not cleared")
_check(Garage.quality("datto_120y", "flat") >= 85.0, "workmanship not recorded")
wr.free()
gg = await _boot("cruise") # 85+ fix = grip buff on the next boot
var dstats: CarStats = load("res://cars/datto_120y/stats.tres")
_check(gg.car.stats.grip > dstats.grip + 0.7, "85+ fix paid no grip buff (%.2f vs %.2f)"
% [gg.car.stats.grip, dstats.grip])
gg.free()
Garage.add_fault("datto_120y", "oil")
Garage.ui_fault = "oil"
wr = (load("res://src/wrench.tscn") as PackedScene).instantiate()
root.add_child(wr)
await process_frame
await _drive_wrench(wr, true)
_check(wr.done, "oil job never finished (step %d)" % wr.step_index)
_check(wr.quality <= 90.0, "stripped thread cost nothing (%d)" % int(wr.quality))
_check(wr.steps.size() == 8, "extraction step never appeared (%d steps)" % wr.steps.size())
wr.free()
Garage._reset_for_test() # later stages must run stock Garage._reset_for_test() # later stages must run stock
# cruise: throttle 3s, car must move # cruise: throttle 3s, car must move
@ -389,6 +420,45 @@ func _run() -> void:
[cars.size(), levels.size(), dist, int(crash_score), int(jct_score), Game.DERBY_CARS]) [cars.size(), levels.size(), dist, int(crash_score), int(jct_score), Game.DERBY_CARS])
quit(0) quit(0)
func _drive_wrench(wr, sabotage: bool) -> void:
## Plays any wrench job through its API. sabotage blows the first torque
## window wide open to prove that threads strip.
var guard := 0
var sabotaged := false
while not wr.done and guard < 30:
guard += 1
var st: Dictionary = wr.steps[wr.step_index]
match String(st["verb"]):
"pump":
for i in int(st["clicks"]):
wr.pump()
"twist":
if bool(st.get("window", false)):
if sabotage and not sabotaged:
sabotaged = true
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 2.0))
else:
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.3))
wr.twist_release()
else:
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.2))
"yank":
wr.yank_ok()
"grab":
wr.grab(String(st["item"]))
wr.drop_at(Vector3(11.5, 1.0, -19.8) if String(st["at"]) == "bench"
else wr._step_anchor())
"steady":
while not wr.done and wr.steps[wr.step_index]["verb"] == "steady":
wr.steady_tick(true, 0.5)
"clamps":
wr.clamp_on("red")
wr.clamp_on("black")
"wait":
wr.skip_wait()
await physics_frame
await physics_frame
func _check(cond: bool, msg: String) -> void: func _check(cond: bool, msg: String) -> void:
## NOT assert(): a failed assert in a headless SceneTree script halts the ## NOT assert(): a failed assert in a headless SceneTree script halts the
## script but leaves the tree running, so the process spins forever instead ## script but leaves the tree running, so the process spins forever instead