The repair minigame: what you break, you fix -- with YOUR tools

Carnage now follows the car home. Grenading the gearbox or blowing the
head gasket on the strip records a persistent FAULT in the garage save,
and the car boots WOUNDED everywhere -- gearbox: 80% power and leaking
oil patches; gasket: 85% power and steaming from the bonnet -- until
someone fixes it. The garage screen grows an "IT'S BROKEN" section with
two routes per fault: pay the shop's someone-else's-problem rate ($700
to $900), or GET UNDER IT for free in the shed.

Getting under it opens the repair minigame (src/repair_ui.gd, all code
like every other screen). Step one is diagnosis off the dash warning
lights: with the CASHIES CODE READER the fault name comes through the
cracked screen ("D!IV!TR!IN") and the right light glows amber; without
it you're on vibes, and a wrong pick adds two penalty turns to every
nut in the job. Then the hands-on steps: hold-and-circle the mouse to
spin nuts (direction enforced -- a full turn the wrong way earns
"OTHER WAY."), click to pump the jack, drag the dead box to the bench.

The shed skill tree pays off physically: ALDI SOCKETS halve every nut,
SUPERCHEAP RAMPS halve the jacking, the CASHIES HOIST deletes the 60 kg
drag-the-box step entirely, the ADDITIVE CRATE shortens the coolant
top-up -- and finishing a job without the right tools leaves three
bolts over ("they're spares now").

Mouse plumbing routes everything through pick_light/twist/pump/drop_at,
so the smoke suite plays a complete toolless gearbox swap headless:
wrong light penalised, right light advances, every step type exercised,
fault cleared at the end. Wounded boot, shop fix and the drag-finish
fault handoff are covered too.

Two traps re-confirmed on the way: `var x :=` cannot infer through
InputEvent.position (base-class Variant) -- the parse error took the
whole suite down as a silent hang; and a full-rect background ColorRect
eats every mouse event before root._gui_input sees it unless its
mouse_filter is IGNORE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-31 12:46:56 +10:00
parent a8fed95d42
commit e47fecf2a8
9 changed files with 552 additions and 5 deletions

View File

@ -128,8 +128,28 @@ one thing parts can't buy: panels that hang on through bigger hits (it raises
the car's shed-a-panel and wreck thresholds). Parts buy bling; the shed buys
labour.
**Faults and the repair minigame.** What you break follows the car home:
grenading the gearbox (three blown shifts in one drag run) or blowing the head
gasket (cooking the engine on the limiter) records a persistent **fault**, and
the car boots WOUNDED — down on power, leaking oil patches or steaming — until
it's fixed. The garage screen offers two routes: pay the shop's
"someone else's problem" rate, or **GET UNDER IT** in the shed for free. That
opens the repair minigame ([src/repair_ui.gd](src/repair_ui.gd)): diagnose off
the dash warning lights first (the CASHIES CODE READER names the fault through
its cracked screen — "D!IV!TR!IN" — and points at the right light; without it
you're on vibes, and a wrong guess adds penalty turns to every nut), then work
through drag / click / twist steps — hold-and-circle the mouse to spin nuts
(righty-tighty enforced; a full turn the wrong way earns "OTHER WAY."), pump
the jack by clicking, drag the dead box to the bench. Owning the right shed
tool shortens the job: sockets halve every nut, ramps halve the jacking, the
hoist deletes the 60 kg gearbox-drag step entirely, and finishing without the
right tools leaves three bolts over. The whole minigame routes through a
testable API (`pick_light`/`twist`/`pump`/`drop_at`), so the smoke suite plays
a complete toolless gearbox swap headless.
Purchases are per-car (tools are global) and persist in `user://garage.save`,
which also carries the drag **ladder rung** and per-car **quarter-mile PBs**.
which also carries the drag **ladder rung**, per-car **quarter-mile PBs** and
outstanding **faults**.
Upgrades apply to a duplicate of the CarStats resource -- the .tres is shared
with traffic and parked spawns of the same model, and tuning your Lazza must
not secretly tune every Lazza in the city. Bolt-ons attach after the deformer

10
push17.log Normal file
View File

@ -0,0 +1,10 @@
Enumerating objects: 16, done.
Counting objects: 6% (1/16) Counting objects: 12% (2/16) Counting objects: 18% (3/16) Counting objects: 25% (4/16) Counting objects: 31% (5/16) Counting objects: 37% (6/16) Counting objects: 43% (7/16) Counting objects: 50% (8/16) Counting objects: 56% (9/16) Counting objects: 62% (10/16) Counting objects: 68% (11/16) Counting objects: 75% (12/16) Counting objects: 81% (13/16) Counting objects: 87% (14/16) Counting objects: 93% (15/16) Counting objects: 100% (16/16) Counting objects: 100% (16/16), done.
Delta compression using up to 32 threads
Compressing objects: 11% (1/9) Compressing objects: 22% (2/9) Compressing objects: 33% (3/9) Compressing objects: 44% (4/9) Compressing objects: 55% (5/9) Compressing objects: 66% (6/9) Compressing objects: 77% (7/9) Compressing objects: 88% (8/9) Compressing objects: 100% (9/9) Compressing objects: 100% (9/9), done.
Writing objects: 11% (1/9) Writing objects: 22% (2/9) Writing objects: 33% (3/9) Writing objects: 44% (4/9) Writing objects: 55% (5/9) Writing objects: 66% (6/9) Writing objects: 77% (7/9) Writing objects: 88% (8/9) Writing objects: 100% (9/9) Writing objects: 100% (9/9), 5.50 KiB | 5.50 MiB/s, done.
Total 9 (delta 7), 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
17aabd7..927b3a9 main -> main

View File

@ -939,10 +939,14 @@ func _drag_finish(crossed: bool) -> void:
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 box_blown:
lines.append("(the gearbox is on the strip somewhere behind you)")
Garage.add_fault(cid, "gearbox")
lines.append("(the gearbox is on the strip somewhere behind you -- FIX IT IN THE GARAGE)")
if gasket_blown:
lines.append("(and the radiator's boiled itself dry)")
Garage.add_fault(cid, "gasket")
lines.append("(and the radiator's boiled itself dry -- FIX IT IN THE GARAGE)")
Garage.earn(pay)
lines.append("+$%d bucks | %d clean / %d blown" % [pay, clean_shifts, blown_shifts])
lines.append("R to retry, Esc for menu")

View File

@ -15,6 +15,7 @@ const SAVE := "user://garage.save"
## REAL path would wipe the player's actual progression.
static var save_path := SAVE
static var ui_stats_path := "" # menu -> garage screen: which car is selected
static var ui_fault := "" # garage screen -> repair minigame: which fault
## id -> {name, desc, price, effects, build (visual constructor)}
## Effects: power_mult (multiplies engine_power), grip_add, boost_add (base
@ -186,6 +187,60 @@ static func buy_tune(car_id: String, tune: String) -> bool:
_flush()
return true
## ---- faults: what you broke stays broken until you fix it -----------------
## Recorded by game.gd when a run grenades something; the car boots WOUNDED
## (power down, leaking/steaming) until repaired -- either the shed minigame
## (free, needs your hands) or paying the shop's "someone else's problem" rate.
## "tool" is the shed tool that makes the DIY fix civilised; without it the
## minigame is longer and the commentary crueller.
const FAULTS := {
"gearbox": {"name": "GRENADED GEARBOX", "light": "DRIVETRAIN", "shop": 900,
"tool": "hoist",
"desc": "Third gear is a maraca now. Leaks on everything it loves."},
"gasket": {"name": "BLOWN HEAD GASKET", "light": "ENGINE TEMP", "shop": 700,
"tool": "sockets",
"desc": "Mayonnaise under the oil cap. Not the good kind."},
}
static func faults(car_id: String) -> Array:
_load()
return _data.get("faults", {}).get(car_id, [])
static func add_fault(car_id: String, fault: String) -> void:
_load()
if not FAULTS.has(fault):
return
var f: Dictionary = _data.get("faults", {})
var lst: Array = f.get(car_id, [])
if fault in lst:
return
lst.append(fault)
f[car_id] = lst
_data["faults"] = f
_flush()
static func clear_fault(car_id: String, fault: String) -> void:
_load()
var f: Dictionary = _data.get("faults", {})
var lst: Array = f.get(car_id, [])
lst.erase(fault)
f[car_id] = lst
_data["faults"] = f
_flush()
static func shop_fix(car_id: String, fault: String) -> bool:
## The coward's route: full price, no left-over bolts.
_load()
if not fault in faults(car_id):
return false
var price: int = FAULTS[fault]["shop"]
if int(_data["bucks"]) < price:
return false
_data["bucks"] = int(_data["bucks"]) - price
clear_fault(car_id, fault)
return true
## ---- raceday: the drag ladder + personal bests ----------------------------
## Rung index into game.gd's DRAG_LADDER; beat your current rung to advance.
## PBs are per-car elapsed times (seconds from the green), 0.0 = none yet.
@ -228,7 +283,8 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
var cid := car_id_of(stats_path)
var ups := owned(cid)
var tns := tunes(cid)
if ups.is_empty() and tns.is_empty():
var flt := faults(cid)
if ups.is_empty() and tns.is_empty() and flt.is_empty():
return
var st = car.get("stats")
if st != null:
@ -236,6 +292,14 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
var pm := 1.0
var ga := 0.0
var ba := 0.0
# unfixed faults: the car boots wounded and looks it
if "gearbox" in flt:
pm *= 0.80
car.set("leaking", true)
if "gasket" in flt:
pm *= 0.85
if car.has_method("set_steam"):
car.call("set_steam", true)
for u in ups:
match u:
"stripes": pm += 0.06

View File

@ -87,6 +87,15 @@ func _refresh() -> void:
list.clear()
entries.clear()
bucks_label.text = "BUCKS: $%d" % Garage.bucks()
var flt := Garage.faults(car_id)
if not flt.is_empty():
_header("--- IT'S BROKEN (fix it or pay up) ---")
for id in flt:
var c: Dictionary = Garage.FAULTS[id]
list.add_item("%-28s %s" % ["FIX: " + c["name"], "DIY IN THE SHED"])
entries.append({"kind": "fault", "id": id})
list.add_item("%-28s $%d" % [" ...or pay someone", c["shop"]])
entries.append({"kind": "shopfix", "id": id})
_header("--- BOLT-ON PARTS (this car) ---")
for id in Garage.CATALOGUE:
var c: Dictionary = Garage.CATALOGUE[id]
@ -117,6 +126,14 @@ func _refresh() -> void:
func _entry_info(i: int) -> Dictionary:
var e: Dictionary = entries[i]
match e.get("kind"):
"fault":
var c: Dictionary = Garage.FAULTS[e["id"]]
return {"desc": c["desc"] + " (free, uses your tools and your dignity)",
"price": 0, "done": false, "locked": false}
"shopfix":
var c: Dictionary = Garage.FAULTS[e["id"]]
return {"desc": "Someone else's problem for $%d. No questions, no bolts left over." % c["shop"],
"price": c["shop"], "done": false, "locked": false}
"part":
var c: Dictionary = Garage.CATALOGUE[e["id"]]
return {"desc": c["desc"], "price": c["price"],
@ -144,7 +161,8 @@ func _on_select(i: int) -> void:
buy_btn.text = "LOCKED (buy the tool)"
buy_btn.disabled = true
else:
buy_btn.text = "BUY ($%d)" % int(info["price"])
buy_btn.text = "BUY ($%d)" % int(info["price"]) if int(info["price"]) > 0 \
else "GET UNDER IT (free)"
buy_btn.disabled = Garage.bucks() < int(info["price"])
func _on_buy() -> void:
@ -157,6 +175,12 @@ func _on_buy() -> void:
"part": ok = Garage.buy(car_id, e["id"])
"tool": ok = Garage.buy_tool(e["id"])
"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
Garage.ui_fault = e["id"]
get_tree().change_scene_to_file("res://src/repair.tscn")
return
if ok:
Sfx.shot_2d("clunk")
_refresh()

8
src/repair.tscn Normal file
View File

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/repair_ui.gd" id="1"]
[node name="Repair" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")

369
src/repair_ui.gd Normal file
View File

@ -0,0 +1,369 @@
extends Control
## The shed repair minigame: fix what you broke with the tools you actually
## own. Diagnose off the dash lights (the CASHIES CODE READER earns its keep
## here), then work through drag / click / twist steps. Owning the right shed
## tool makes a step civilised; not owning it means vice grips and regret.
##
## Which car and which fault come in via Garage.ui_stats_path / Garage.ui_fault
## (set by the garage screen). The input handlers all route through pick_light /
## twist / pump / drop_at, so the smoke suite can play the whole game headless.
const LIGHT_POOL := ["ENGINE TEMP", "DRIVETRAIN", "OIL PRESSURE", "BATTERY", "AIRBAG (ALWAYS ON)"]
const NUT_R := 150.0 # grab radius around the nut, px
var car_id := ""
var fault := ""
var steps: Array = [] # {kind: diagnose|twist|drag|pump, ...}
var step_index := 0
var done := false
var penalty_turns := 0.0 # wrong diagnosis = extra turns on every nut
var twist_prog := 0.0
var wrong_way := 0.0
var pump_count := 0
var dragging := false
var missing_tool := false # any step done the hard way -> bolts left over
var title_label: Label
var step_label: Label
var tool_label: Label
var prog_bar: ProgressBar
var count_label: Label
var stage: Control # per-step widgets live here, cleared each step
var nut: Control = null
var drag_item: Panel = null
var drag_target: Panel = null
var light_btns: Array = []
func _ready() -> void:
car_id = Garage.car_id_of(Garage.ui_stats_path)
fault = Garage.ui_fault
steps = _build_steps()
var bg := ColorRect.new()
bg.color = Color(0.06, 0.06, 0.08)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
# IGNORE or the backdrop eats every click before root._gui_input sees it
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var box := VBoxContainer.new()
box.set_anchors_preset(Control.PRESET_FULL_RECT)
box.offset_left = 60
box.offset_top = 30
box.offset_right = -60
box.offset_bottom = -30
box.add_theme_constant_override("separation", 10)
add_child(box)
title_label = Label.new()
title_label.text = "THE SHED -- %s" % Garage.FAULTS.get(fault, {}).get("name", "???")
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title_label.add_theme_font_size_override("font_size", 36)
title_label.add_theme_color_override("font_color", Color(1, 0.82, 0.35))
box.add_child(title_label)
count_label = Label.new()
count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
count_label.add_theme_font_size_override("font_size", 20)
box.add_child(count_label)
step_label = Label.new()
step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
step_label.add_theme_font_size_override("font_size", 30)
box.add_child(step_label)
tool_label = Label.new()
tool_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
tool_label.add_theme_font_size_override("font_size", 19)
tool_label.add_theme_color_override("font_color", Color(0.65, 0.68, 0.72))
box.add_child(tool_label)
stage = Control.new()
stage.size_flags_vertical = Control.SIZE_EXPAND_FILL
box.add_child(stage)
prog_bar = ProgressBar.new()
prog_bar.custom_minimum_size = Vector2(0, 20)
prog_bar.show_percentage = false
box.add_child(prog_bar)
var back := Button.new()
back.text = "WALK AWAY (Esc)"
back.add_theme_font_size_override("font_size", 20)
back.pressed.connect(_leave)
box.add_child(back)
_show_step()
func _build_steps() -> Array:
## The job sheet. Tools shorten it or soften it; their absence is content.
var out: Array = [{"kind": "diagnose"}]
match fault:
"gasket":
var t := 4.0 if Garage.has_tool("sockets") else 8.0
out.append({"kind": "twist", "label": "UNDO THE HEAD BOLTS", "dir": -1.0, "turns": t})
out.append({"kind": "drag", "label": "OLD GASKET INTO THE BIN", "item": "CRUSTY GASKET", "target": "THE BIN"})
out.append({"kind": "drag", "label": "NEW GASKET ONTO THE BLOCK", "item": "GASKET (EBAY, \"GENUINE\")", "target": "THE BLOCK"})
out.append({"kind": "twist", "label": "TORQUE THE HEAD DOWN", "dir": 1.0, "turns": t})
out.append({"kind": "pump", "label": "TOP UP THE COOLANT", "clicks": 5 if Garage.has_tool("additives") else 8})
"gearbox":
out.append({"kind": "pump", "label": "JACK IT UP", "clicks": 5 if Garage.has_tool("ramps") else 10})
var t := 5.0 if Garage.has_tool("sockets") else 10.0
out.append({"kind": "twist", "label": "UNDO THE BELLHOUSING BOLTS", "dir": -1.0, "turns": t})
if not Garage.has_tool("hoist"):
out.append({"kind": "drag", "label": "DRAG THE DEAD BOX OUT (it's 60kg)", "item": "DEAD GEARBOX", "target": "THE BENCH"})
out.append({"kind": "drag", "label": "CASHIES BOX IN", "item": "GEARBOX (ONE OWNER, HONEST)", "target": "THE TUNNEL"})
out.append({"kind": "twist", "label": "BOLT IT ALL BACK UP", "dir": 1.0, "turns": t})
return out
## ---- step display ---------------------------------------------------------
func _show_step() -> void:
for c in stage.get_children():
c.queue_free()
nut = null
drag_item = null
drag_target = null
light_btns.clear()
twist_prog = 0.0
wrong_way = 0.0
pump_count = 0
if step_index >= steps.size():
_finish()
return
var st: Dictionary = steps[step_index]
count_label.text = "STEP %d / %d" % [step_index + 1, steps.size()]
prog_bar.value = 0
prog_bar.max_value = 1.0
match st["kind"]:
"diagnose":
_show_diagnose()
"twist":
step_label.text = st["label"]
var need := _turns_needed(st)
tool_label.text = ("ALDI SOCKETS: metric-ish, fits mostly" if Garage.has_tool("sockets")
else "VICE GRIPS: this will end badly")
if not Garage.has_tool("sockets"):
missing_tool = true
prog_bar.max_value = need * TAU
nut = _make_nut(st)
"drag":
step_label.text = st["label"]
tool_label.text = "drag it. with the mouse. like your life."
_make_drag(st)
"pump":
step_label.text = st["label"]
tool_label.text = ("SUPERCHEAP RAMPS: rated to \"most cars, probably\""
if fault == "gearbox" and Garage.has_tool("ramps") else "click. click. click.")
prog_bar.max_value = int(st["clicks"])
_make_pump(st)
func _show_diagnose() -> void:
step_label.text = "WHAT'S THAT LIGHT, THEN?"
var correct: String = Garage.FAULTS[fault]["light"]
var pool := LIGHT_POOL.filter(func(l): return l != correct)
# deterministic decoys: seeded off the fault so tests can rely on layout
var rng := RandomNumberGenerator.new()
rng.seed = hash(fault)
pool.shuffle()
var lights: Array = [correct, pool[0], pool[1]]
lights.shuffle()
if Garage.has_tool("reader"):
tool_label.text = "CASHIES READER SAYS: \"%s\" (screen cracked)" % _garble(correct)
else:
tool_label.text = "no code reader. vibes only."
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 26)
row.set_anchors_preset(Control.PRESET_CENTER)
row.grow_horizontal = Control.GROW_DIRECTION_BOTH # centre ON the anchor
stage.add_child(row)
for l in lights:
var b := Button.new()
b.text = "(!) %s" % l
b.add_theme_font_size_override("font_size", 24)
if Garage.has_tool("reader") and l == correct:
b.modulate = Color(1.0, 0.75, 0.35) # the reader points, kind of
b.pressed.connect(func() -> void: pick_light(lights.find(l)))
row.add_child(b)
light_btns.append({"btn": b, "light": l})
func _garble(s: String) -> String:
var out := ""
for i in s.length():
out += ["#", "!", "0"][i % 3] if i % 3 == 1 and s[i] != " " else s[i]
return out
func _make_nut(st: Dictionary) -> Control:
var n := Panel.new()
n.mouse_filter = Control.MOUSE_FILTER_IGNORE # let root handle the twisting
n.custom_minimum_size = Vector2(120, 120)
n.set_anchors_preset(Control.PRESET_CENTER)
n.position = Vector2(-60, -60)
n.pivot_offset = Vector2(60, 60)
n.rotation = 0.0
stage.add_child(n)
var l := Label.new()
l.text = "NUT"
l.set_anchors_preset(Control.PRESET_CENTER)
l.add_theme_font_size_override("font_size", 26)
n.add_child(l)
var hint := Label.new()
hint.text = "hold + drag in circles (%s)" % ("anticlockwise" if st["dir"] < 0 else "clockwise")
hint.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
hint.grow_horizontal = Control.GROW_DIRECTION_BOTH
hint.position.y = -60
hint.add_theme_font_size_override("font_size", 18)
stage.add_child(hint)
return n
func _make_drag(st: Dictionary) -> void:
drag_target = Panel.new()
drag_target.mouse_filter = Control.MOUSE_FILTER_IGNORE
drag_target.custom_minimum_size = Vector2(260, 150)
drag_target.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
drag_target.position += Vector2(-290, -75)
stage.add_child(drag_target)
var tl := Label.new()
tl.text = String(st["target"])
tl.set_anchors_preset(Control.PRESET_CENTER)
tl.add_theme_font_size_override("font_size", 20)
drag_target.add_child(tl)
drag_item = Panel.new()
drag_item.mouse_filter = Control.MOUSE_FILTER_IGNORE
drag_item.custom_minimum_size = Vector2(200, 90)
drag_item.self_modulate = Color(0.8, 0.55, 0.3)
drag_item.set_anchors_preset(Control.PRESET_CENTER_LEFT)
drag_item.position += Vector2(60, -45)
stage.add_child(drag_item)
var il := Label.new()
il.text = String(st["item"])
il.set_anchors_preset(Control.PRESET_CENTER)
il.add_theme_font_size_override("font_size", 17)
drag_item.add_child(il)
func _make_pump(st: Dictionary) -> void:
var b := Button.new()
b.text = "PUMP"
b.custom_minimum_size = Vector2(220, 120)
b.add_theme_font_size_override("font_size", 34)
b.set_anchors_preset(Control.PRESET_CENTER)
b.position = Vector2(-110, -60)
b.pressed.connect(pump)
stage.add_child(b)
## ---- the actual game (also the test API) ----------------------------------
func pick_light(i: int) -> void:
if done or steps[step_index]["kind"] != "diagnose" or i >= light_btns.size():
return
if light_btns[i]["light"] == Garage.FAULTS[fault]["light"]:
Sfx.shot_2d("clunk", -6.0)
_advance()
else:
penalty_turns += 2.0
Sfx.shot_2d("bog", -6.0)
tool_label.text = "nope. pulled the wrong thing apart first. every nut is +2 turns now."
func _turns_needed(st: Dictionary) -> float:
return float(st["turns"]) + penalty_turns
func twist(rad: float) -> void:
## rad > 0 = clockwise on screen. Only the step's direction makes progress;
## a full turn the wrong way earns commentary.
if done or steps[step_index]["kind"] != "twist":
return
var st: Dictionary = steps[step_index]
var toward: float = rad * float(st["dir"])
if toward > 0.0:
twist_prog += toward
wrong_way = 0.0
else:
wrong_way += -toward
if wrong_way > TAU:
wrong_way = 0.0
tool_label.text = "OTHER WAY."
Sfx.shot_2d("bog", -10.0)
if nut:
nut.rotation = twist_prog * float(st["dir"])
prog_bar.value = twist_prog
if twist_prog >= _turns_needed(st) * TAU:
Sfx.shot_2d("clunk", -4.0)
_advance()
func pump() -> void:
if done or steps[step_index]["kind"] != "pump":
return
pump_count += 1
Sfx.shot_2d("clunk", -12.0)
prog_bar.value = pump_count
if pump_count >= int(steps[step_index]["clicks"]):
_advance()
func drop_at(pos: Vector2) -> void:
if done or steps[step_index]["kind"] != "drag" or drag_target == null:
return
if drag_target.get_global_rect().has_point(pos):
Sfx.shot_2d("clunk", -4.0)
_advance()
func _advance() -> void:
step_index += 1
_show_step()
func _finish() -> void:
done = true
Garage.clear_fault(car_id, fault)
step_label.text = "FIXED. PROBABLY."
count_label.text = ""
tool_label.text = ("Three bolts left over. They're spares now." if missing_tool
else "Went back together like it wanted to live.")
prog_bar.value = prog_bar.max_value
Sfx.shot_2d("sting_win", -4.0)
var b := Button.new()
b.text = "BACK TO THE GARAGE"
b.add_theme_font_size_override("font_size", 26)
b.set_anchors_preset(Control.PRESET_CENTER)
b.position = Vector2(-160, -30)
b.custom_minimum_size = Vector2(320, 60)
b.pressed.connect(_leave)
stage.add_child(b)
func _leave() -> void:
get_tree().change_scene_to_file("res://src/garage.tscn")
## ---- mouse plumbing: everything routes to the API above -------------------
var _twisting := false
var _prev_angle := 0.0
func _gui_input(event: InputEvent) -> void:
if done or step_index >= steps.size():
return
var st: Dictionary = steps[step_index]
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
if st["kind"] == "twist" and nut != null \
and event.position.distance_to(nut.get_global_rect().get_center()) < NUT_R:
_twisting = true
_prev_angle = (event.position - nut.get_global_rect().get_center()).angle()
elif st["kind"] == "drag" and drag_item != null \
and drag_item.get_global_rect().has_point(event.position):
dragging = true
else:
_twisting = false
if dragging:
dragging = false
drop_at(event.position)
elif event is InputEventMouseMotion:
if _twisting and nut != null:
# explicit type: event is base InputEvent here, so .position is a
# Variant and := cannot infer through it (the parse error hangs cold)
var a: float = (event.position - nut.get_global_rect().get_center()).angle()
twist(wrapf(a - _prev_angle, -PI, PI))
_prev_angle = a
elif dragging and drag_item != null:
drag_item.global_position = event.position - drag_item.size * 0.5
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_leave()

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

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

View File

@ -59,6 +59,48 @@ func _run() -> void:
_check(not Garage.record_drag_pb("datto_120y", 15.0), "slower ET counted as a PB")
_check(Garage.record_drag_pb("datto_120y", 13.9), "faster ET refused as PB")
_check(absf(Garage.drag_pb("datto_120y") - 13.9) < 0.01, "PB not stored")
# faults: break it, boot wounded, pay the shop for one, DIY the other
print(" .. repair")
Garage.add_fault("datto_120y", "gearbox")
Garage.add_fault("datto_120y", "gasket")
_check(Garage.faults("datto_120y").size() == 2, "faults didn't record")
gg = await _boot("cruise")
_check(gg.car.stats.engine_power < base_power * 0.9, "wounded car boots at full power")
_check(gg.car.leaking, "gearbox-faulted car isn't leaking")
gg.free()
Garage.earn(1000)
_check(Garage.shop_fix("datto_120y", "gasket"), "shop wouldn't take the money")
_check(not Garage.faults("datto_120y").has("gasket"), "shop fix didn't clear the fault")
# DIY the gearbox: play the whole minigame headless through its API
Garage.ui_stats_path = "res://cars/datto_120y/stats.tres"
Garage.ui_fault = "gearbox"
var ui = (load("res://src/repair.tscn") as PackedScene).instantiate()
root.add_child(ui)
await process_frame
_check(ui.steps.size() >= 6, "gearbox job suspiciously short (%d steps)" % ui.steps.size())
var right := -1
for i in ui.light_btns.size():
if ui.light_btns[i]["light"] == Garage.FAULTS["gearbox"]["light"]:
right = i
ui.pick_light((right + 1) % 3)
_check(ui.penalty_turns > 0.0, "wrong diagnosis cost nothing")
ui.pick_light(right)
_check(ui.step_index == 1, "right light didn't advance the job")
var guard := 0
while not ui.done and guard < 12:
guard += 1
var st: Dictionary = ui.steps[ui.step_index]
match st["kind"]:
"twist":
ui.twist(float(st["dir"]) * ((float(st["turns"]) + ui.penalty_turns) * TAU + 0.1))
"pump":
for i in int(st["clicks"]):
ui.pump()
"drag":
ui.drop_at(ui.drag_target.get_global_rect().get_center())
_check(ui.done, "repair never finished (stuck on step %d)" % ui.step_index)
_check(not Garage.faults("datto_120y").has("gearbox"), "DIY fix didn't clear the fault")
ui.free()
Garage._reset_for_test() # later stages must run stock
# cruise: throttle 3s, car must move
@ -235,6 +277,11 @@ func _run() -> void:
game.eng_temp = 1.05
await physics_frame
_check(game.gasket_blown, "overcooked engine kept its head gasket")
# finishing the run sends the carnage home as persistent faults
game._drag_finish(true)
_check(Garage.faults("datto_120y").has("gearbox") and Garage.faults("datto_120y").has("gasket"),
"carnage didn't follow the car home as faults")
Garage._reset_for_test() # don't leave a wounded car for later stages
Input.action_release("throttle")
game.free()
Game.level_path = levels.get("woolies_carpark", levels.values()[0])