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 the car's shed-a-panel and wreck thresholds). Parts buy bling; the shed buys
labour. 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`, 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 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 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 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?") lines.append("TOP OF THE LADDER. AGAIN?")
if jumped: if jumped:
lines.append("(red light. we saw it. everyone saw it.)") 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: 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: 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) Garage.earn(pay)
lines.append("+$%d bucks | %d clean / %d blown" % [pay, clean_shifts, blown_shifts]) lines.append("+$%d bucks | %d clean / %d blown" % [pay, clean_shifts, blown_shifts])
lines.append("R to retry, Esc for menu") 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. ## REAL path would wipe the player's actual progression.
static var save_path := SAVE static var save_path := SAVE
static var ui_stats_path := "" # menu -> garage screen: which car is selected 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)} ## id -> {name, desc, price, effects, build (visual constructor)}
## Effects: power_mult (multiplies engine_power), grip_add, boost_add (base ## 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() _flush()
return true 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 ---------------------------- ## ---- raceday: the drag ladder + personal bests ----------------------------
## Rung index into game.gd's DRAG_LADDER; beat your current rung to advance. ## 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. ## 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 cid := car_id_of(stats_path)
var ups := owned(cid) var ups := owned(cid)
var tns := tunes(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 return
var st = car.get("stats") var st = car.get("stats")
if st != null: if st != null:
@ -236,6 +292,14 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
var pm := 1.0 var pm := 1.0
var ga := 0.0 var ga := 0.0
var ba := 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: for u in ups:
match u: match u:
"stripes": pm += 0.06 "stripes": pm += 0.06

View File

@ -87,6 +87,15 @@ func _refresh() -> void:
list.clear() list.clear()
entries.clear() entries.clear()
bucks_label.text = "BUCKS: $%d" % Garage.bucks() 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) ---") _header("--- BOLT-ON PARTS (this car) ---")
for id in Garage.CATALOGUE: for id in Garage.CATALOGUE:
var c: Dictionary = Garage.CATALOGUE[id] var c: Dictionary = Garage.CATALOGUE[id]
@ -117,6 +126,14 @@ func _refresh() -> void:
func _entry_info(i: int) -> Dictionary: func _entry_info(i: int) -> Dictionary:
var e: Dictionary = entries[i] var e: Dictionary = entries[i]
match e.get("kind"): 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": "part":
var c: Dictionary = Garage.CATALOGUE[e["id"]] var c: Dictionary = Garage.CATALOGUE[e["id"]]
return {"desc": c["desc"], "price": c["price"], 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.text = "LOCKED (buy the tool)"
buy_btn.disabled = true buy_btn.disabled = true
else: 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"]) buy_btn.disabled = Garage.bucks() < int(info["price"])
func _on_buy() -> void: func _on_buy() -> void:
@ -157,6 +175,12 @@ func _on_buy() -> void:
"part": ok = Garage.buy(car_id, e["id"]) "part": ok = Garage.buy(car_id, e["id"])
"tool": ok = Garage.buy_tool(e["id"]) "tool": ok = Garage.buy_tool(e["id"])
"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"])
"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: if ok:
Sfx.shot_2d("clunk") Sfx.shot_2d("clunk")
_refresh() _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(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(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") _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 Garage._reset_for_test() # later stages must run stock
# cruise: throttle 3s, car must move # cruise: throttle 3s, car must move
@ -235,6 +277,11 @@ func _run() -> void:
game.eng_temp = 1.05 game.eng_temp = 1.05
await physics_frame await physics_frame
_check(game.gasket_blown, "overcooked engine kept its head gasket") _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") Input.action_release("throttle")
game.free() game.free()
Game.level_path = levels.get("woolies_carpark", levels.values()[0]) Game.level_path = levels.get("woolies_carpark", levels.values()[0])