From e47fecf2a84f34048ba04b3d70cc4ad7eea89e86 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 31 Jul 2026 12:46:56 +1000 Subject: [PATCH] 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 --- README.md | 22 ++- push17.log | 10 ++ src/game.gd | 8 +- src/garage.gd | 66 +++++++- src/garage_ui.gd | 26 ++- src/repair.tscn | 8 + src/repair_ui.gd | 369 +++++++++++++++++++++++++++++++++++++++++++ src/repair_ui.gd.uid | 1 + tests/smoke.gd | 47 ++++++ 9 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 push17.log create mode 100644 src/repair.tscn create mode 100644 src/repair_ui.gd create mode 100644 src/repair_ui.gd.uid diff --git a/README.md b/README.md index 76a807f..b2699d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/push17.log b/push17.log new file mode 100644 index 0000000..1f430b1 --- /dev/null +++ b/push17.log @@ -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 diff --git a/src/game.gd b/src/game.gd index c628940..e68f78c 100644 --- a/src/game.gd +++ b/src/game.gd @@ -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") diff --git a/src/garage.gd b/src/garage.gd index 2187511..e7abfa3 100644 --- a/src/garage.gd +++ b/src/garage.gd @@ -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 diff --git a/src/garage_ui.gd b/src/garage_ui.gd index 07c58ad..8160cdd 100644 --- a/src/garage_ui.gd +++ b/src/garage_ui.gd @@ -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() diff --git a/src/repair.tscn b/src/repair.tscn new file mode 100644 index 0000000..4b03a46 --- /dev/null +++ b/src/repair.tscn @@ -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") diff --git a/src/repair_ui.gd b/src/repair_ui.gd new file mode 100644 index 0000000..2961292 --- /dev/null +++ b/src/repair_ui.gd @@ -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() diff --git a/src/repair_ui.gd.uid b/src/repair_ui.gd.uid new file mode 100644 index 0000000..f726672 --- /dev/null +++ b/src/repair_ui.gd.uid @@ -0,0 +1 @@ +uid://bray7xq7dem20 diff --git a/tests/smoke.gd b/tests/smoke.gd index 941a88c..b330521 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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])