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
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
— 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

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"])
elif drag_rung_now == DRAG_LADDER.size() - 1:
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
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:
Garage.add_fault(cid, "gearbox")
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:
busts += 1
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:
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(
func() -> void: Engine.time_scale = 1.0)
_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:
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
## minigame is longer and the commentary crueller.
## scene: which minigame fixes it -- "repair" (2D panel) or "wrench" (3D shed)
const FAULTS := {
"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."},
"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."},
"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:
@ -211,6 +221,11 @@ static func add_fault(car_id: String, fault: String) -> void:
_load()
if not FAULTS.has(fault):
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 lst: Array = f.get(car_id, [])
if fault in lst:
@ -220,6 +235,33 @@ static func add_fault(car_id: String, fault: String) -> void:
_data["faults"] = f
_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:
_load()
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 tns := tunes(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
var st = car.get("stats")
if st != null:
@ -300,6 +344,20 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
pm *= 0.85
if car.has_method("set_steam"):
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:
match u:
"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.grip += ga
car.set("stats", st)
if ba > 0.0 and car.get("boost_max") != null:
car.set("boost_max", float(car.get("boost_max")) + ba)
if ba != 0.0 and car.get("boost_max") != null:
car.set("boost_max", maxf(float(car.get("boost_max")) + ba, 0.4))
_bolt_visuals(car, ups)
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"])
"shopfix": ok = Garage.shop_fix(car_id, e["id"])
"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"]
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
if ok:
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")
gg.free()
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
# 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])
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:
## NOT assert(): a failed assert in a headless SceneTree script halts the
## script but leaves the tree running, so the process spins forever instead