Wrench phase 2: gearbox and gasket in 3D, the crane ceremony, tool tiers

The 2D repair panel retires -- every fault is a wrench job on the real
car in the real shed now. Gearbox and gasket open with DIAGNOSIS: three
lit warning lights, one honest, the code reader pointing at it through
its cracked screen ("D!IV!TR!IN"); a wrong pick costs quality and makes
every nut in the job run a turn longer (penalty_turns feeds _need() and
the HUD torque arc).

The gearbox is the hero job. Without a hoist you drag the 60 kg box out
by hand and hold the steady ring so it doesn't land on you; with the
Cashies crane it's the ceremony -- hook the chain, pump it out -- and
the tier-3 gantry winch hooks itself up. The gasket job is the full
rite: bonnet, head bolts, old gasket out (mind the mayonnaise), seat
the new one square, torque the head IN the window, coolant back in.

Tool tiers: every line goes Cashies -> Supercheap -> SNAP-OFF
PROFESSIONAL (buy again to upgrade, garage shows [T2] UP $N). Tiers are
felt, not read: sockets scale turns 1.6x down to 0.5x and widen the
torque window 0.8 to 1.3 rad; the tier-2 compressor's RATTLE GUN
converts non-torque nut twists into BRRRT clicks (windows keep the hand
skill -- guns strip); ramps cut jacking 10 clicks to 3; the fluids
shelf speeds pours; the SNAP-OFF tablet skips diagnosis outright.
Pre-tier saves read as tier 1.

Workmanship reaches the strip: an 85+ gearbox rebuild raises box_limit
to 4 blown shifts, an 85+ gasket job lifts the overheat ceiling to
1.18. Both wired into the drag setup and asserted in the suite, which
now also covers tier upgrade/cap flow, wrong-then-right diagnosis, the
toolless 60 kg gearbox path, and a clean gasket run -- via one shared
driver that handles every verb including diagnose.

The := inference trap, visit four: indexing untyped const Arrays.
Typed the tables (Array[float]/Array[int]) so every index site infers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-31 18:04:15 +10:00
parent a5158bf6ec
commit 89b9106f88
14 changed files with 386 additions and 522 deletions

View File

@ -164,11 +164,26 @@ 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.
steady_tick/clamp_on/pick_light`), 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.
Phase 2 finishes the port: **gearbox and gasket are wrench jobs now** (the 2D
panel is gone) and both open with **diagnosis** -- three lit warning lights,
one honest, the code reader pointing at it through its cracked screen; a wrong
pick makes every nut in the job run a turn longer. The gearbox job is the
hero: no hoist means dragging 60 kg out by hand and holding the steady ring so
it doesn't land on you, while owning the crane turns it into the ceremony
(hook the chain, pump it out -- and at tier 3 the winch hooks itself). Every
tool line now has **three tiers** (Cashies -> Supercheap -> SNAP-OFF
PROFESSIONAL, buy again to upgrade): better sockets mean fewer turns and a
wider torque window, the tier-2 compressor's **rattle gun turns wheel nuts
into BRRRT clicks** (torque windows keep the hand skill -- guns strip), ramps
speed the jacking, the fluids shelf speeds pours, and the tier-3 scan tool
skips diagnosis entirely. Workmanship reaches the strip: an 85+ gearbox
rebuild takes an **extra blown shift** before grenading, an 85+ gasket job
**raises the overheat ceiling** -- both asserted in the suite. Phase 3 is
driveway customers.
**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,

View File

@ -1,10 +0,0 @@
Enumerating objects: 37, done.
Counting objects: 2% (1/37) Counting objects: 5% (2/37) Counting objects: 8% (3/37) Counting objects: 10% (4/37) Counting objects: 13% (5/37) Counting objects: 16% (6/37) Counting objects: 18% (7/37) Counting objects: 21% (8/37) Counting objects: 24% (9/37) Counting objects: 27% (10/37) Counting objects: 29% (11/37) Counting objects: 32% (12/37) Counting objects: 35% (13/37) Counting objects: 37% (14/37) Counting objects: 40% (15/37) Counting objects: 43% (16/37) Counting objects: 45% (17/37) Counting objects: 48% (18/37) Counting objects: 51% (19/37) Counting objects: 54% (20/37) Counting objects: 56% (21/37) Counting objects: 59% (22/37) Counting objects: 62% (23/37) Counting objects: 64% (24/37) Counting objects: 67% (25/37) Counting objects: 70% (26/37) Counting objects: 72% (27/37) Counting objects: 75% (28/37) Counting objects: 78% (29/37) Counting objects: 81% (30/37) Counting objects: 83% (31/37) Counting objects: 86% (32/37) Counting objects: 89% (33/37) Counting objects: 91% (34/37) Counting objects: 94% (35/37) Counting objects: 97% (36/37) Counting objects: 100% (37/37) Counting objects: 100% (37/37), done.
Delta compression using up to 32 threads
Compressing objects: 4% (1/24) Compressing objects: 8% (2/24) Compressing objects: 12% (3/24) Compressing objects: 16% (4/24) Compressing objects: 20% (5/24) Compressing objects: 25% (6/24) Compressing objects: 29% (7/24) Compressing objects: 33% (8/24) Compressing objects: 37% (9/24) Compressing objects: 41% (10/24) Compressing objects: 45% (11/24) Compressing objects: 50% (12/24) Compressing objects: 54% (13/24) Compressing objects: 58% (14/24) Compressing objects: 62% (15/24) Compressing objects: 66% (16/24) Compressing objects: 70% (17/24) Compressing objects: 75% (18/24) Compressing objects: 79% (19/24) Compressing objects: 83% (20/24) Compressing objects: 87% (21/24) Compressing objects: 91% (22/24) Compressing objects: 95% (23/24) Compressing objects: 100% (24/24) Compressing objects: 100% (24/24), done.
Writing objects: 3% (1/26) Writing objects: 7% (2/26) Writing objects: 11% (3/26) Writing objects: 15% (4/26) Writing objects: 19% (5/26) Writing objects: 23% (6/26) Writing objects: 26% (7/26) Writing objects: 34% (9/26) Writing objects: 38% (10/26) Writing objects: 42% (11/26) Writing objects: 46% (12/26) Writing objects: 50% (13/26) Writing objects: 53% (14/26) Writing objects: 61% (16/26) Writing objects: 65% (17/26) Writing objects: 69% (18/26) Writing objects: 73% (19/26) Writing objects: 76% (20/26) Writing objects: 80% (21/26) Writing objects: 84% (22/26) Writing objects: 88% (23/26) Writing objects: 92% (24/26) Writing objects: 96% (25/26) Writing objects: 100% (26/26) Writing objects: 100% (26/26), 312.96 KiB | 39.12 MiB/s, done.
Total 26 (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
fdbb921..17aabd7 main -> main

View File

@ -1,10 +0,0 @@
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

@ -1,10 +0,0 @@
Enumerating objects: 21, done.
Counting objects: 4% (1/21) Counting objects: 9% (2/21) Counting objects: 14% (3/21) Counting objects: 19% (4/21) Counting objects: 23% (5/21) Counting objects: 28% (6/21) Counting objects: 33% (7/21) Counting objects: 38% (8/21) Counting objects: 42% (9/21) Counting objects: 47% (10/21) Counting objects: 52% (11/21) Counting objects: 57% (12/21) Counting objects: 61% (13/21) Counting objects: 66% (14/21) Counting objects: 71% (15/21) Counting objects: 76% (16/21) Counting objects: 80% (17/21) Counting objects: 85% (18/21) Counting objects: 90% (19/21) Counting objects: 95% (20/21) Counting objects: 100% (21/21) Counting objects: 100% (21/21), done.
Delta compression using up to 32 threads
Compressing objects: 8% (1/12) Compressing objects: 16% (2/12) Compressing objects: 25% (3/12) Compressing objects: 33% (4/12) Compressing objects: 41% (5/12) Compressing objects: 50% (6/12) Compressing objects: 58% (7/12) Compressing objects: 66% (8/12) Compressing objects: 75% (9/12) Compressing objects: 83% (10/12) Compressing objects: 91% (11/12) Compressing objects: 100% (12/12) Compressing objects: 100% (12/12), done.
Writing objects: 7% (1/13) Writing objects: 15% (2/13) Writing objects: 23% (3/13) Writing objects: 30% (4/13) Writing objects: 38% (5/13) Writing objects: 46% (6/13) Writing objects: 53% (7/13) Writing objects: 61% (8/13) Writing objects: 69% (9/13) Writing objects: 76% (10/13) Writing objects: 84% (11/13) Writing objects: 92% (12/13) Writing objects: 100% (13/13) Writing objects: 100% (13/13), 10.48 KiB | 10.48 MiB/s, done.
Total 13 (delta 8), 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
927b3a9..4f91b34 main -> main

File diff suppressed because one or more lines are too long

View File

@ -1,10 +0,0 @@
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

@ -117,9 +117,11 @@ var _launch_pos := Vector3.ZERO
## Mechanical sympathy: none of these reset until the scene does.
var tyre_temp := 0.0 # 0 cold .. 1 hot, built in the burnout box
var eng_temp := 0.0 # limiter abuse; at 1.0 the head gasket lets go
var box_strikes := 0 # blown shifts this run; 3 grenades the gearbox
var box_strikes := 0 # blown shifts this run; box_limit grenades it
var box_limit := 3 # 4 if your last gearbox rebuild scored 85+
var box_blown := false
var gasket_blown := false
var gasket_cap := 1.0 # 1.18 if your last gasket job scored 85+
var derby: Array[Cop] = [] # arena opponents; Cop AI already hunts a target
var derby_centre: Vector3
@ -290,6 +292,11 @@ func _ready() -> void:
sc.add_point(drag_end + Vector3.UP * (drag_end_y + 0.7) + lane + fwd * 120.0)
strip.curve = sc
add_child(strip)
# workmanship pays here: an 85+ gearbox rebuild takes an extra blown
# shift, an 85+ gasket job raises the overheat ceiling
var wcid := Garage.car_id_of(car_path)
box_limit = 3 + (1 if Garage.quality(wcid, "gearbox") >= 85.0 else 0)
gasket_cap = 1.0 + (0.18 if Garage.quality(wcid, "gasket") >= 85.0 else 0.0)
# the ladder: your current rung decides who's in the other lane
drag_rung_now = mini(Garage.drag_rung(), DRAG_LADDER.size() - 1)
var rung: Dictionary = DRAG_LADDER[drag_rung_now]
@ -826,7 +833,7 @@ func _drag_tick(delta: float) -> void:
box_strikes += 1
bog = 0.9
Sfx.shot(car, "bog", -2.0)
if box_strikes >= 3:
if box_strikes >= box_limit:
# third mistreated shift: the box keeps what's in it
box_blown = true
car.mech_pop()
@ -843,10 +850,10 @@ func _drag_tick(delta: float) -> void:
if gear <= DRAG_GEARS and revs >= REDLINE:
eng_temp += delta * 0.30
elif car.boosting:
eng_temp = minf(eng_temp + delta * 0.06, 0.99 if not gasket_blown else 1.0)
eng_temp = minf(eng_temp + delta * 0.06, gasket_cap - 0.01 if not gasket_blown else gasket_cap)
else:
eng_temp = maxf(eng_temp - delta * 0.04, 0.0)
if eng_temp >= 1.0 and not gasket_blown:
if eng_temp >= gasket_cap and not gasket_blown:
gasket_blown = true
car.set_steam(true)
Sfx.shot(car, "bog", -2.0, 0.55)

View File

@ -61,19 +61,65 @@ const CATALOGUE := {
## THE SHED: workshop tools are global, bought once, and each unlocks a
## per-car TUNE-UP. Parts buy bling; the shed buys labour. Provenance of the
## hoist is not to be discussed.
## Each tool line has THREE tiers: Cashies-grade, Supercheap-grade, and
## SNAP-OFF PROFESSIONAL. Higher tiers change how jobs FEEL in the wrench
## minigame (fewer turns, wider torque windows, the rattle gun, a crane that
## does the lifting). Top-level name/price/desc mirror tier 1 for old code.
const TOOLS := {
"ramps": {"name": "SUPERCHEAP WHEEL RAMPS", "price": 800,
"desc": "Plastic. Rated to \"most cars, probably\"."},
"desc": "Plastic. Rated to \"most cars, probably\".",
"tiers": [
{"name": "SUPERCHEAP WHEEL RAMPS", "price": 800,
"desc": "Plastic. Rated to \"most cars, probably\". Jacking: slow."},
{"name": "STEEL RAMPS + CHOCKS", "price": 2000,
"desc": "Actual metal. The car goes up like it means it."},
{"name": "QUICK-JACK (KNOCKOFF)", "price": 4000,
"desc": "Whoosh. Three clicks and it's in the air."}]},
"reader": {"name": "CASHIES CODE READER", "price": 1200,
"desc": "One previous owner. Screen cracked. Works if you squint."},
"desc": "One previous owner. Screen cracked. Works if you squint.",
"tiers": [
{"name": "CASHIES CODE READER", "price": 1200,
"desc": "Screen cracked. Points at the right light, roughly."},
{"name": "SUPERCHEAP SCAN TOOL", "price": 3000,
"desc": "Whole screen works. Names the fault outright."},
{"name": "SNAP-OFF DIAGNOSTICS TABLET", "price": 6000,
"desc": "Knows what's wrong before you park. Skips diagnosis."}]},
"sockets": {"name": "ALDI SOCKET SET", "price": 600,
"desc": "Sunday special. Metric-ish."},
"desc": "Sunday special. Metric-ish.",
"tiers": [
{"name": "ALDI SOCKET SET", "price": 600,
"desc": "Sunday special. Metric-ish."},
{"name": "SUPERCHEAP 200-PIECE", "price": 1500,
"desc": "A socket for everything, 30% fewer turns."},
{"name": "SNAP-OFF PROFESSIONAL", "price": 3000,
"desc": "Fell off a van. Half the turns, forgiving torque."}]},
"compressor": {"name": "BUNNINGS AIR COMPRESSOR", "price": 1500,
"desc": "Came with three fittings, none of them the right one."},
"desc": "Came with three fittings, none of them the right one.",
"tiers": [
{"name": "BUNNINGS AIR COMPRESSOR", "price": 1500,
"desc": "Came with three fittings, none of them the right one."},
{"name": "COMPRESSOR + RATTLE GUN", "price": 3500,
"desc": "BRRRT. Wheel nuts become a clicking matter."},
{"name": "SHOP AIR, PLUMBED", "price": 7000,
"desc": "BRRRT but faster, and the neighbours hate you."}]},
"hoist": {"name": "CASHIES CHAIN HOIST (ON WHEELS)", "price": 2500,
"desc": "Definitely not stolen. Rolls beautifully."},
"desc": "Definitely not stolen. Rolls beautifully.",
"tiers": [
{"name": "CASHIES CHAIN HOIST (ON WHEELS)", "price": 2500,
"desc": "Definitely not stolen. Does the 60 kg lifting."},
{"name": "2-TONNE CRANE, NEW CHAINS", "price": 5000,
"desc": "Half the pumping, none of the creaking."},
{"name": "GANTRY + ELECTRIC WINCH", "price": 9000,
"desc": "Hooks itself up. You just point."}]},
"additives": {"name": "SERVO ADDITIVE CRATE", "price": 400,
"desc": "A crate of bottles that all claim +30 horsepower."},
"desc": "A crate of bottles that all claim +30 horsepower.",
"tiers": [
{"name": "SERVO ADDITIVE CRATE", "price": 400,
"desc": "A crate of bottles that all claim +30 horsepower."},
{"name": "PROPER FLUIDS SHELF", "price": 1000,
"desc": "Funnels that fit. Pours nearly pour themselves."},
{"name": "BULK DRUMS + PUMP", "price": 2000,
"desc": "Tap the drum, walk away, come back to full."}]},
}
## tune id -> {name, desc, price (labour), tool (required)}
const TUNES := {
@ -147,17 +193,39 @@ static func tools() -> Array:
static func has_tool(tool: String) -> bool:
return tool in tools()
static func buy_tool(tool: String) -> bool:
static func tool_tier(tool: String) -> int:
## 0 = not owned; 1..3 = owned tier. Pre-tier saves count as tier 1.
_load()
if not TOOLS.has(tool) or has_tool(tool):
if not has_tool(tool):
return 0
return int(_data.get("tool_tiers", {}).get(tool, 1))
static func tool_upgrade_price(tool: String) -> int:
## Price of the NEXT tier, or 0 when maxed.
var t := tool_tier(tool)
var tiers: Array = TOOLS[tool]["tiers"]
return int(tiers[t]["price"]) if t < tiers.size() else 0
static func buy_tool(tool: String) -> bool:
## First purchase = tier 1; buying again upgrades a tier, up to 3.
_load()
if not TOOLS.has(tool):
return false
var price: int = TOOLS[tool]["price"]
var t := tool_tier(tool)
var tiers: Array = TOOLS[tool]["tiers"]
if t >= tiers.size():
return false
var price: int = tiers[t]["price"]
if int(_data["bucks"]) < price:
return false
_data["bucks"] = int(_data["bucks"]) - price
var lst: Array = _data.get("tools", [])
lst.append(tool)
_data["tools"] = lst
if t == 0:
var lst: Array = _data.get("tools", [])
lst.append(tool)
_data["tools"] = lst
var tt: Dictionary = _data.get("tool_tiers", {})
tt[tool] = t + 1
_data["tool_tiers"] = tt
_flush()
return true
@ -197,10 +265,10 @@ static func buy_tune(car_id: String, tune: String) -> bool:
## scene: which minigame fixes it -- "repair" (2D panel) or "wrench" (3D shed)
const FAULTS := {
"gearbox": {"name": "GRENADED GEARBOX", "light": "DRIVETRAIN", "shop": 900,
"tool": "hoist", "scene": "repair",
"tool": "hoist", "scene": "wrench",
"desc": "Third gear is a maraca now. Leaks on everything it loves."},
"gasket": {"name": "BLOWN HEAD GASKET", "light": "ENGINE TEMP", "shop": 700,
"tool": "sockets", "scene": "repair",
"tool": "sockets", "scene": "wrench",
"desc": "Mayonnaise under the oil cap. Not the good kind."},
"flat": {"name": "FLAT-SPOTTED TYRES", "light": "TYRE PRESSURE", "shop": 250,
"tool": "sockets", "scene": "wrench",

View File

@ -102,11 +102,19 @@ func _refresh() -> void:
var tag := "[FITTED]" if Garage.has(car_id, id) else "$%d" % c["price"]
list.add_item("%-28s %s" % [c["name"], tag])
entries.append({"kind": "part", "id": id})
_header("--- THE SHED (tools, buy once, unlock tune-ups) ---")
_header("--- THE SHED (tools; buy again to upgrade the tier) ---")
for id in Garage.TOOLS:
var c: Dictionary = Garage.TOOLS[id]
var tag := "[OWNED]" if Garage.has_tool(id) else "$%d" % c["price"]
list.add_item("%-28s %s" % [c["name"], tag])
var tier := Garage.tool_tier(id)
var shown: String = c["tiers"][maxi(tier - 1, 0)]["name"]
var tag: String
if tier == 0:
tag = "$%d" % int(c["price"])
elif tier < 3:
tag = "[T%d] UP $%d" % [tier, Garage.tool_upgrade_price(id)]
else:
tag = "[T3 MAXED]"
list.add_item("%-28s %s" % [shown, tag])
entries.append({"kind": "tool", "id": id})
_header("--- TUNE-UPS (this car, needs the tool) ---")
for id in Garage.TUNES:
@ -140,8 +148,12 @@ func _entry_info(i: int) -> Dictionary:
"done": Garage.has(car_id, e["id"]), "locked": false}
"tool":
var c: Dictionary = Garage.TOOLS[e["id"]]
return {"desc": c["desc"], "price": c["price"],
"done": Garage.has_tool(e["id"]), "locked": false}
var tier := Garage.tool_tier(e["id"])
var d: String = c["tiers"][mini(tier, 2)]["desc"]
if tier > 0 and tier < 3:
d = "NEXT: " + String(c["tiers"][tier]["name"]) + " -- " + d
return {"desc": d, "price": Garage.tool_upgrade_price(e["id"]),
"done": tier >= 3, "locked": false}
"tune":
var c: Dictionary = Garage.TUNES[e["id"]]
return {"desc": c["desc"], "price": c["price"],
@ -177,11 +189,10 @@ 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 whichever minigame owns this fault (repair = 2D panel,
# wrench = 3D shed); it clears the fault on completion
# DIY: off to the wrench minigame; it clears the fault on completion
Garage.ui_fault = e["id"]
get_tree().change_scene_to_file("res://src/%s.tscn"
% String(Garage.FAULTS[e["id"]].get("scene", "repair")))
% String(Garage.FAULTS[e["id"]].get("scene", "wrench")))
return
if ok:
Sfx.shot_2d("clunk")

View File

@ -1,8 +0,0 @@
[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")

View File

@ -1,369 +0,0 @@
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()

View File

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

View File

@ -14,8 +14,16 @@ extends Node3D
## 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
## Tool tiers change how jobs FEEL. Tables indexed by Garage.tool_tier() 0..3.
## typed so indexing them stays inference-friendly (the := trap, 4th visit)
const TURN_MULT: Array[float] = [1.6, 1.0, 0.7, 0.5] # sockets tier
const WINDOW_W: Array[float] = [0.8, 0.8, 1.0, 1.3] # torque forgiveness, rad
const JACK_CLICKS: Array[int] = [10, 8, 5, 3] # ramps
const POUR_MULT: Array[float] = [1.3, 1.0, 0.6, 0.4] # additives
const RATTLE_CLICKS: Array[int] = [0, 0, 4, 2] # compressor >=2: BRRRT
const CRANE_CLICKS: Array[int] = [0, 10, 5, 4] # hoist: crane pumping
const LIGHT_POOL := ["ENGINE TEMP", "DRIVETRAIN", "OIL PRESSURE", "BATTERY", "AIRBAG (ALWAYS ON)"]
var car_id := ""
var fault := ""
@ -31,6 +39,10 @@ var pump_count := 0
var steady_need := 0.0
var wait_left := 0.0
var clamps_done: Array = []
var penalty_turns := 0.0 # wrong diagnosis: every nut gets this much longer
var light_options: Array = []
var _light_btns: Array = []
var _diag_layer: CanvasLayer = null
var props := {} # name -> Node3D (spare wheel, battery, tray...)
var model: Node3D
var cam: Camera3D
@ -134,48 +146,77 @@ func _anchor_engine() -> Vector3:
func _anchor_sump() -> Vector3:
return CAR_AT + Vector3(0.2, 0.28, -stats.length * 0.18)
func _t(base: float) -> float:
return base * TURN_MULT[Garage.tool_tier("sockets")]
func _win() -> float:
return WINDOW_W[Garage.tool_tier("sockets")]
func _sockets_note() -> String:
return ["VICE GRIPS: this will end badly", "ALDI SOCKETS: fits, mostly",
"SUPERCHEAP 200-PIECE: there IS a socket for that",
"SNAP-OFF PROFESSIONAL: it just... undoes"][Garage.tool_tier("sockets")]
func _apply_tiers(sheet: Array) -> Array:
## Post-pass: the rattle gun (compressor t2+) turns non-torque nut twists
## into BRRRT clicks. Torque windows keep the hand skill -- guns strip.
var rattle := RATTLE_CLICKS[Garage.tool_tier("compressor")]
if rattle > 0:
for i in sheet.size():
var st: Dictionary = sheet[i]
if st["verb"] == "twist" and bool(st.get("nut", false)) and not bool(st.get("window", false)):
sheet[i] = {"verb": "pump", "clicks": rattle, "cam": st["cam"],
"label": st["label"] + " (BRRRT)", "note": "the rattle gun eats this step"}
# gearbox/gasket aren't obvious from the driver's seat: diagnose first,
# unless the SNAP-OFF tablet already told you on the way in
if fault in ["gearbox", "gasket"] and Garage.tool_tier("reader") < 3:
sheet.push_front({"verb": "diagnose", "cam": "bonnet",
"label": "WHAT'S THAT LIGHT, THEN?", "note": ""})
return sheet
func _job() -> Array:
var have_sockets := Garage.has_tool("sockets")
var t := 3.0 if have_sockets else 6.0
var pour := POUR_MULT[Garage.tool_tier("additives")]
var jack := JACK_CLICKS[Garage.tool_tier("ramps")]
var hoist := Garage.tool_tier("hoist")
match fault:
"flat":
return [
{"verb": "pump", "clicks": 8, "cam": "wheel", "label": "JACK IT UP",
return _apply_tiers([
{"verb": "pump", "clicks": jack, "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": "twist", "dir": -1.0, "turns": _t(3.0), "window": false, "nut": true,
"cam": "wheel", "label": "CRACK THE NUTS OFF", "note": _sockets_note()},
{"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": "twist", "dir": 1.0, "turns": _t(3.0), "window": true, "nut": 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 [
return _apply_tiers([
{"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",
{"verb": "twist", "dir": -1.0, "turns": _t(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",
{"verb": "twist", "dir": 1.0, "turns": _t(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",
{"verb": "steady", "secs": 2.5 * pour, "cam": "bonnet", "label": "POUR IT IN, STEADY",
"note": "outside the ring = on the manifold"},
]
])
"battery":
return [
return _apply_tiers([
{"verb": "yank", "cam": "bonnet", "label": "BONNET UP", "note": "flick it open"},
{"verb": "twist", "dir": -1.0, "turns": 1.0, "window": false, "cam": "bonnet",
{"verb": "twist", "dir": -1.0, "turns": _t(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"},
@ -185,7 +226,52 @@ func _job() -> Array:
{"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"},
])
"gasket":
return _apply_tiers([
{"verb": "yank", "cam": "bonnet", "label": "BONNET UP", "note": "flick it open"},
{"verb": "twist", "dir": -1.0, "turns": _t(4.0), "window": false, "nut": true,
"cam": "bonnet", "label": "UNDO THE HEAD BOLTS", "note": _sockets_note()},
{"verb": "yank", "cam": "bonnet", "label": "OLD GASKET OUT",
"note": "flick it. mind the mayonnaise."},
{"verb": "grab", "item": "gasket", "at": "engine", "cam": "bonnet",
"label": "NEW GASKET ON", "note": "EBAY, \"GENUINE\". sure."},
{"verb": "steady", "secs": 2.0, "cam": "bonnet", "label": "SEAT IT SQUARE",
"note": "keep it in the ring or it weeps forever"},
{"verb": "twist", "dir": 1.0, "turns": _t(4.0), "window": true, "nut": true,
"cam": "bonnet", "label": "TORQUE THE HEAD DOWN",
"note": "release IN the green -- head bolts do not forgive"},
{"verb": "steady", "secs": 2.5 * pour, "cam": "bonnet", "label": "COOLANT BACK IN",
"note": "steady pour. the radiator's been through enough"},
{"verb": "yank", "cam": "bonnet", "label": "BONNET DOWN", "note": "one dignified slam"},
])
"gearbox":
var sheet: Array = [
{"verb": "pump", "clicks": jack, "cam": "under", "label": "JACK IT UP",
"note": "click it like you mean it"},
{"verb": "twist", "dir": -1.0, "turns": _t(5.0), "window": false, "nut": true,
"cam": "under", "label": "UNDO THE BELLHOUSING BOLTS", "note": _sockets_note()},
]
if hoist == 0:
sheet.append({"verb": "grab", "item": "dead_box", "at": "bench", "cam": "under",
"label": "DRAG THE DEAD BOX OUT (it's 60 kg)", "note": "no crane. just you and regret"})
sheet.append({"verb": "steady", "secs": 2.0, "cam": "under",
"label": "DON'T DROP IT ON YOURSELF", "note": "hold the ring. HOLD IT."})
else:
if hoist < 3:
sheet.append({"verb": "grab", "item": "chain", "at": "box", "cam": "under",
"label": "HOOK THE CRANE UP", "note": "chain to the box. the crane does the rest"})
sheet.append({"verb": "pump", "clicks": CRANE_CLICKS[hoist], "cam": "under",
"label": "CRANE IT OUT", "note": ["", "the Cashies crane creaks but lifts",
"new chains. barely any creak", "the winch just... does it"][hoist]})
sheet.append({"verb": "grab", "item": "cashies_box", "at": "tunnel", "cam": "under",
"label": "CASHIES BOX IN", "note": "GEARBOX -- ONE OWNER, HONEST"})
sheet.append({"verb": "twist", "dir": 1.0, "turns": _t(5.0), "window": true, "nut": true,
"cam": "under", "label": "BOLT IT ALL BACK UP",
"note": "release IN the green -- you KNOW what happens"})
sheet.append({"verb": "pump", "clicks": 4, "cam": "under", "label": "LET IT DOWN",
"note": "and breathe"})
return _apply_tiers(sheet)
return []
## ---- step lifecycle --------------------------------------------------------
@ -200,6 +286,9 @@ func _show_step() -> void:
if step_index >= steps.size():
_finish()
return
if _diag_layer:
_diag_layer.queue_free()
_diag_layer = null
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", ""))
@ -213,13 +302,65 @@ func _show_step() -> void:
steady_need = float(st["secs"])
"grab":
_spawn_prop(String(st["item"]))
"yank":
if fault == "battery" and step_index == 0:
pass # bonnet handled on success
"diagnose":
_show_diagnose()
ring.anchor_world = _step_anchor()
ring.cam = cam
ring.mode = String(st["verb"])
func _show_diagnose() -> void:
## Three lit warning lights, one honest. The reader points (t1 garbled,
## t2 outright); no reader means vibes. Wrong pick = every nut runs longer.
var correct: String = Garage.FAULTS[fault]["light"]
var pool := LIGHT_POOL.filter(func(l): return l != correct)
pool.shuffle()
light_options = [correct, pool[0], pool[1]]
light_options.shuffle()
var rt := Garage.tool_tier("reader")
if rt >= 2:
note_label.text = "SCAN TOOL SAYS: \"%s\". no drama." % correct
elif rt == 1:
note_label.text = "CASHIES READER SAYS: \"%s\" (screen cracked)" % _garble(correct)
else:
note_label.text = "no reader. vibes only."
_diag_layer = CanvasLayer.new()
add_child(_diag_layer)
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
_diag_layer.add_child(row)
_light_btns.clear()
for i in light_options.size():
var b := Button.new()
b.text = "(!) %s" % light_options[i]
b.add_theme_font_size_override("font_size", 24)
if rt >= 1 and light_options[i] == correct:
b.modulate = Color(1.0, 0.75, 0.35)
var idx := i
b.pressed.connect(func() -> void: pick_light(idx))
row.add_child(b)
_light_btns.append(b)
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 pick_light(i: int) -> void:
if done or steps[step_index]["verb"] != "diagnose" or i >= light_options.size():
return
if light_options[i] == Garage.FAULTS[fault]["light"]:
Sfx.shot_2d("clunk", -6.0)
_advance()
else:
penalty_turns += 1.0
quality -= 5.0
Sfx.shot_2d("bog", -6.0)
note_label.text = "nope. pulled the wrong thing apart first -- every nut runs a turn longer now."
qual_label.text = "QUALITY %d%%" % int(quality)
func _step_anchor() -> Vector3:
var st: Dictionary = steps[step_index]
match String(st.get("cam", "wheel")):
@ -275,6 +416,25 @@ func _spawn_prop(item: String) -> void:
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)
"gasket":
var bm := BoxMesh.new()
bm.size = Vector3(0.45, 0.02, 0.3)
bm.material = _dark(Color(0.55, 0.5, 0.4))
mi.mesh = bm
n.position = Vector3(10.5, 1.0, -19.8) # on the bench
"dead_box", "cashies_box":
var bm := BoxMesh.new()
bm.size = Vector3(0.35, 0.4, 0.7)
bm.material = _dark(Color(0.10, 0.10, 0.11) if item == "dead_box" else Color(0.16, 0.28, 0.16))
mi.mesh = bm
n.position = _anchor_sump() - Vector3(0, 0.15, 0) if item == "dead_box" \
else Vector3(10.5, 1.15, -19.8)
"chain":
var bm := BoxMesh.new()
bm.size = Vector3(0.05, 0.9, 0.05)
bm.material = _dark(Color(0.35, 0.36, 0.38))
mi.mesh = bm
n.position = Vector3(15.2, 1.4, -17.0) # hanging off the crane
n.add_child(mi)
n.name = item
add_child(n)
@ -305,6 +465,10 @@ func _process(delta: float) -> void:
## ---- the verbs (test API) --------------------------------------------------
func _need() -> float:
# wrong diagnosis makes every nut longer; better sockets never change THAT
return (float(steps[step_index]["turns"]) + penalty_turns) * TAU
func twist(rad: float) -> void:
if done or steps[step_index]["verb"] != "twist":
return
@ -313,14 +477,14 @@ func twist(rad: float) -> void:
if toward <= 0.0:
return
twist_prog += toward
var need: float = float(st["turns"]) * TAU
var need := _need()
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:
if twist_prog > need + _win() and not _stripped:
_stripped = true
quality -= 10.0
Sfx.shot_2d("bog", -4.0)
@ -334,8 +498,8 @@ 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:
var need := _need()
if twist_prog >= need and twist_prog <= need + _win():
Sfx.shot_2d("clunk", -4.0)
_advance()
elif twist_prog > 0.5:
@ -354,11 +518,11 @@ func yank_ok() -> void:
if done or steps[step_index]["verb"] != "yank":
return
Sfx.shot_2d("clunk", -6.0)
var label := String(steps[step_index]["label"])
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"
if _bonnet and label.begins_with("BONNET"):
var opening: bool = label == "BONNET UP"
var tw := create_tween()
tw.tween_property(_bonnet, "rotation:x", -0.9 if opening else 0.0, 0.4)
_advance()
@ -369,17 +533,23 @@ func grab(item: String) -> void:
if steps[step_index]["item"] == item:
carried = item
func _drop_target(at: String) -> Vector3:
match at:
"wheel": return _anchor_wheel()
"sump": return _anchor_sump() - Vector3(0, 0.2, 0)
"engine": return _anchor_engine()
"bench": return Vector3(11.5, 1.0, -19.8)
"box": # the dead gearbox under the car, waiting for the chain
return _anchor_sump() - Vector3(0, 0.15, 0)
"tunnel":
return _anchor_sump() - Vector3(0, 0.1, 0)
return _anchor_wheel()
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()
var target := _drop_target(String(st["at"]))
if world.distance_to(target) < 1.2:
var p: Node3D = props.get(carried)
if p:
@ -558,7 +728,7 @@ class RingDraw extends Control:
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 need: float = (float(w["turns"]) + float(parent.get("penalty_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)

View File

@ -59,7 +59,15 @@ 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
# tool tiers: buying again upgrades; effects are exercised by wrench jobs
Garage.earn(1500 + 3000)
_check(Garage.tool_tier("sockets") == 1, "owned sockets not tier 1")
_check(Garage.buy_tool("sockets"), "tier 2 upgrade refused")
_check(Garage.buy_tool("sockets"), "tier 3 upgrade refused")
_check(Garage.tool_tier("sockets") == 3 and not Garage.buy_tool("sockets"),
"tier cap broken (tier %d)" % Garage.tool_tier("sockets"))
_check(Garage.has_tool("sockets"), "tiered tool lost has_tool")
# faults: break it, boot wounded, pay the shop for one, wrench the other
print(" .. repair")
Garage.add_fault("datto_120y", "gearbox")
Garage.add_fault("datto_120y", "gasket")
@ -71,36 +79,45 @@ func _run() -> void:
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
# the gearbox goes through the 3D wrench: diagnose (wrong first, penalty),
# then the toolless 60 kg drag path -- no hoist owned on this save
Garage.ui_stats_path = "res://cars/datto_120y/stats.tres"
Garage.ui_fault = "gearbox"
var ui = (load("res://src/repair.tscn") as PackedScene).instantiate()
var ui = (load("res://src/wrench.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())
_check(ui.steps[0]["verb"] == "diagnose", "gearbox job skipped diagnosis")
_check(ui.steps.size() == 8, "toolless gearbox sheet wrong (%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"]:
for i in ui.light_options.size():
if ui.light_options[i] == Garage.FAULTS["gearbox"]["light"]:
right = i
ui.pick_light((right + 1) % 3)
_check(ui.penalty_turns > 0.0, "wrong diagnosis cost nothing")
_check(ui.penalty_turns > 0.0 and ui.quality < 100.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")
await _drive_wrench(ui, false)
_check(ui.done, "gearbox job never finished (step %d)" % ui.step_index)
_check(not Garage.faults("datto_120y").has("gearbox"), "wrench fix didn't clear the fault")
_check(Garage.quality("datto_120y", "gearbox") >= 85.0, "clean-ish gearbox scored %d"
% int(ui.quality))
ui.free()
# a gasket job at 85+ raises the overheat ceiling; gearbox 85+ adds a strike
Garage.add_fault("datto_120y", "gasket")
Garage.ui_fault = "gasket"
ui = (load("res://src/wrench.tscn") as PackedScene).instantiate()
root.add_child(ui)
await process_frame
await _drive_wrench(ui, false)
_check(ui.done and Garage.quality("datto_120y", "gasket") >= 85.0,
"gasket job didn't finish clean (%d)" % int(ui.quality))
ui.free()
Game.level_path = levels["willowbank_dragway"]
gg = await _boot("drag")
_check(gg.box_limit == 4, "85+ gearbox rebuild paid no extra strike (%d)" % gg.box_limit)
_check(gg.gasket_cap > 1.1, "85+ gasket job raised no ceiling (%.2f)" % gg.gasket_cap)
gg.free()
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
# THE SHED home base: owned tools materialise (sockets were bought above,
# ramps were not), and rolling into the bay and holding still opens the garage
print(" .. shed")
@ -428,7 +445,13 @@ func _drive_wrench(wr, sabotage: bool) -> void:
while not wr.done and guard < 30:
guard += 1
var st: Dictionary = wr.steps[wr.step_index]
var need: float = (float(st.get("turns", 0.0)) + wr.penalty_turns) * TAU
match String(st["verb"]):
"diagnose":
for i in wr.light_options.size():
if wr.light_options[i] == Garage.FAULTS[wr.fault]["light"]:
wr.pick_light(i)
break
"pump":
for i in int(st["clicks"]):
wr.pump()
@ -436,18 +459,17 @@ func _drive_wrench(wr, sabotage: bool) -> void:
if bool(st.get("window", false)):
if sabotage and not sabotaged:
sabotaged = true
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 2.0))
wr.twist(float(st["dir"]) * (need + 4.0))
else:
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.3))
wr.twist(float(st["dir"]) * (need + 0.3))
wr.twist_release()
else:
wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.2))
wr.twist(float(st["dir"]) * (need + 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())
wr.drop_at(wr._drop_target(String(st["at"])))
"steady":
while not wr.done and wr.steps[wr.step_index]["verb"] == "steady":
wr.steady_tick(true, 0.5)