From a5158bf6ec43455ef30849db3ff4ee2089681730 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 31 Jul 2026 15:36:05 +1000 Subject: [PATCH] Wrench phase 1: 3D jobs on your car in your shed -- your hands are the tune The repair game grows its 3D half (src/wrench.gd): flat tyre, oil change and dead battery are done on the player's actual car model, parked in the actual shed level, through five mouse verbs on projected 3D anchor points: GRAB part follows the cursor on a ray-plane, drop at the target TWIST circle the mouse around the anchor; torque steps have a seat window (release inside = clean) and blowing past it STRIPS the thread -- quality hit plus an extraction step inserted PUMP rhythm clicks (jack up, jack down) YANK press and flick (pop the wheel, bonnet up) STEADY keep the cursor inside a wandering ring (line up the studs, pour the oil) Camera presets per step (wheel / under-car / bonnet), a screen-space ring overlay with a live torque arc, and props spawned per job (spare, tray, bottle, batteries). The battery job enforces RED THEN BLACK -- backwards earns a spark shower and commentary. Workmanship is a stat: every job scores 0-100, and an 85+ fix BUFFS the car through Garage.apply (tyres +0.2 grip, oil +3% power, battery +0.15 boost) until that fault recurs -- add_fault clears its quality entry, so your last good fix is exactly as durable as your driving. Faults now have gameplay sources: a red light at the tree flat-spots the fronts, every 4th wreck rattles the sump loose (oil light, not in junctions where wrecking is the game), and getting busted kills the battery. All three are suite-inert by construction. New faults route to the wrench scene via FAULTS[id].scene; gearbox/gasket keep the 2D panel until phase 2. Wounds: flat -0.35 grip, oil -7% power, battery -0.3 boost (boost floor clamped at 0.4 -- the old ba>0 guard never subtracted). Smoke plays a clean flat-tyre job through the API (7 steps, quality 90+, grip buff verified on the next boot) and a sabotaged oil job (blown torque window -> stripped thread -> extraction step appears). The same Variant-inference parse trap struck a third time (Dictionary index this time); the suite caught it before it cost anything. Co-Authored-By: Claude Opus 5 --- README.md | 19 ++ push20.log | 10 + src/game.gd | 14 +- src/garage.gd | 68 +++++- src/garage_ui.gd | 6 +- src/wrench.gd | 568 ++++++++++++++++++++++++++++++++++++++++++++++ src/wrench.gd.uid | 1 + src/wrench.tscn | 6 + tests/smoke.gd | 70 ++++++ 9 files changed, 752 insertions(+), 10 deletions(-) create mode 100644 push20.log create mode 100644 src/wrench.gd create mode 100644 src/wrench.gd.uid create mode 100644 src/wrench.tscn diff --git a/README.md b/README.md index 99b0c11..c627cfc 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,25 @@ Purchases are per-car (tools are global) and persist in `user://garage.save`, which also carries the drag **ladder rung**, per-car **quarter-mile PBs** and outstanding **faults**. +**The wrench minigame** ([src/wrench.gd](src/wrench.gd)) is the 3D half of +repairs, phase 1: flat tyre, oil change and dead battery happen on YOUR car, +parked in YOUR shed, through five mouse verbs on 3D anchor points -- GRAB +(part follows the cursor, drop it at the target), TWIST (circle the mouse; +torque steps have a seat window and blowing past it STRIPS the thread, adding +an extraction step), PUMP (rhythm clicks), YANK (flick), and STEADY (keep the +cursor in a wandering ring). Every job scores 0-100 for workmanship; finish at +85+ and the fix is a live buff via Garage.apply (grip for tyres, power for +oil, boost for battery) until that fault recurs -- your hands are the tune. +Faults have gameplay sources: a red light at the tree flat-spots the fronts, +every 4th wreck rattles the sump loose (oil light), and getting busted kills +the battery, because the divvy van jump-starts it wrong out of spite. The +whole thing routes through an API (`twist/pump/grab/drop_at/yank_ok/ +steady_tick/clamp_on`), so the smoke suite plays a clean flat-tyre job +(quality 90+, buff verified on next boot) and a sabotaged oil job (stripped +thread, extraction step) headless. Gearbox and gasket still use the 2D panel +(`FAULTS[id].scene` routes each fault to its minigame) -- porting them to 3D +plus tool tiers and driveway customers is phases 2 and 3. + **The Shed is also a place** (`levels/the_shed`): a drivable suburban home base — brick house, paling fence, hills hoist mid-spin with the washing still on it, wheelie bins at the gate, and the corrugated double shed on its slab. The tools diff --git a/push20.log b/push20.log new file mode 100644 index 0000000..6c070d9 --- /dev/null +++ b/push20.log @@ -0,0 +1,10 @@ +Enumerating objects: 38, done. +Counting objects: 2% (1/38) Counting objects: 5% (2/38) Counting objects: 7% (3/38) Counting objects: 10% (4/38) Counting objects: 13% (5/38) Counting objects: 15% (6/38) Counting objects: 18% (7/38) Counting objects: 21% (8/38) Counting objects: 23% (9/38) Counting objects: 26% (10/38) Counting objects: 28% (11/38) Counting objects: 31% (12/38) Counting objects: 34% (13/38) Counting objects: 36% (14/38) Counting objects: 39% (15/38) Counting objects: 42% (16/38) Counting objects: 44% (17/38) Counting objects: 47% (18/38) Counting objects: 50% (19/38) Counting objects: 52% (20/38) Counting objects: 55% (21/38) Counting objects: 57% (22/38) Counting objects: 60% (23/38) Counting objects: 63% (24/38) Counting objects: 65% (25/38) Counting objects: 68% (26/38) Counting objects: 71% (27/38) Counting objects: 73% (28/38) Counting objects: 76% (29/38) Counting objects: 78% (30/38) Counting objects: 81% (31/38) Counting objects: 84% (32/38) Counting objects: 86% (33/38) Counting objects: 89% (34/38) Counting objects: 92% (35/38) Counting objects: 94% (36/38) Counting objects: 97% (37/38) Counting objects: 100% (38/38) Counting objects: 100% (38/38), done. +Delta compression using up to 32 threads +Compressing objects: 4% (1/23) Compressing objects: 8% (2/23) Compressing objects: 13% (3/23) Compressing objects: 17% (4/23) Compressing objects: 21% (5/23) Compressing objects: 26% (6/23) Compressing objects: 30% (7/23) Compressing objects: 34% (8/23) Compressing objects: 39% (9/23) Compressing objects: 43% (10/23) Compressing objects: 47% (11/23) Compressing objects: 52% (12/23) Compressing objects: 56% (13/23) Compressing objects: 60% (14/23) Compressing objects: 65% (15/23) Compressing objects: 69% (16/23) Compressing objects: 73% (17/23) Compressing objects: 78% (18/23) Compressing objects: 82% (19/23) Compressing objects: 86% (20/23) Compressing objects: 91% (21/23) Compressing objects: 95% (22/23) Compressing objects: 100% (23/23) Compressing objects: 100% (23/23), done. +Writing objects: 4% (1/25) Writing objects: 8% (2/25) Writing objects: 12% (3/25) Writing objects: 16% (4/25) Writing objects: 20% (5/25) Writing objects: 24% (6/25) Writing objects: 32% (8/25) Writing objects: 36% (9/25) Writing objects: 40% (10/25) Writing objects: 44% (11/25) Writing objects: 48% (12/25) Writing objects: 52% (13/25) Writing objects: 56% (14/25) Writing objects: 60% (15/25) Writing objects: 64% (16/25) Writing objects: 68% (17/25) Writing objects: 72% (18/25) Writing objects: 76% (19/25) Writing objects: 80% (20/25) Writing objects: 84% (21/25) Writing objects: 88% (22/25) Writing objects: 92% (23/25) Writing objects: 96% (24/25) Writing objects: 100% (25/25) Writing objects: 100% (25/25), 211.82 KiB | 30.26 MiB/s, done. +Total 25 (delta 13), reused 0 (delta 0), pack-reused 0 (from 0) +remote: . Processing 1 references +remote: Processed 1 references in total +To ssh://100.71.119.27:222/monster/burnoutinfinityshitbox.git + 8967972..6d740ad main -> main diff --git a/src/game.gd b/src/game.gd index 4517aca..dff99a0 100644 --- a/src/game.gd +++ b/src/game.gd @@ -960,10 +960,11 @@ func _drag_finish(crossed: bool) -> void: lines.append("UP THE LADDER: next is %s" % DRAG_LADDER[drag_rung_now + 1]["name"]) elif drag_rung_now == DRAG_LADDER.size() - 1: lines.append("TOP OF THE LADDER. AGAIN?") - if jumped: - lines.append("(red light. we saw it. everyone saw it.)") # what you broke follows the car home: faults persist until repaired var cid := Garage.car_id_of(car_path) + if jumped: + Garage.add_fault(cid, "flat") + lines.append("(red light. we saw it. and you've flat-spotted the fronts -- FIX IT)") if box_blown: Garage.add_fault(cid, "gearbox") lines.append("(the gearbox is on the strip somewhere behind you -- FIX IT IN THE GARAGE)") @@ -1040,7 +1041,9 @@ func _end_pursuit(escaped: bool) -> void: else: busts += 1 Sfx.shot(car, "sting_lose", -4.0) - _msg("BUSTED", 52) + # impound treatment: the divvy van jump-starts it wrong, out of spite + Garage.add_fault(Garage.car_id_of(car_path), "battery") + _msg("BUSTED\n(and now the battery's dead. coincidence?)", 48) func _pursuit_tick(delta: float) -> void: heat_cd = maxf(heat_cd - delta, 0.0) @@ -1241,6 +1244,11 @@ func _player_wreck() -> void: get_tree().create_timer(1.0, true, false, true).timeout.connect( func() -> void: Engine.time_scale = 1.0) _msg("WRECKED", 48) + # wrecks wear the car: every 4th rattles the sump loose (not in junctions, + # where wrecking IS the game and shouldn't bill you for playing it) + if mode != "junction" and Garage.bump_wreck(Garage.car_id_of(car_path)): + get_tree().create_timer(2.0, true, false, true).timeout.connect( + func() -> void: _msg("SOMETHING'S RATTLED LOOSE -- OIL LIGHT'S ON", 32)) func _rival_wrecked(r: Rival) -> void: if car.wrecked or car.global_position.distance_to(r.global_position) > 8.0: diff --git a/src/garage.gd b/src/garage.gd index e7abfa3..b38ad08 100644 --- a/src/garage.gd +++ b/src/garage.gd @@ -194,13 +194,23 @@ static func buy_tune(car_id: String, tune: String) -> bool: ## "tool" is the shed tool that makes the DIY fix civilised; without it the ## minigame is longer and the commentary crueller. +## scene: which minigame fixes it -- "repair" (2D panel) or "wrench" (3D shed) const FAULTS := { "gearbox": {"name": "GRENADED GEARBOX", "light": "DRIVETRAIN", "shop": 900, - "tool": "hoist", + "tool": "hoist", "scene": "repair", "desc": "Third gear is a maraca now. Leaks on everything it loves."}, "gasket": {"name": "BLOWN HEAD GASKET", "light": "ENGINE TEMP", "shop": 700, - "tool": "sockets", + "tool": "sockets", "scene": "repair", "desc": "Mayonnaise under the oil cap. Not the good kind."}, + "flat": {"name": "FLAT-SPOTTED TYRES", "light": "TYRE PRESSURE", "shop": 250, + "tool": "sockets", "scene": "wrench", + "desc": "That red-light launch took the fronts with it. Grip is a memory."}, + "oil": {"name": "OIL LIGHT'S ON", "light": "OIL PRESSURE", "shop": 180, + "tool": "ramps", "scene": "wrench", + "desc": "Service due. The sump remembers every wreck you've walked away from."}, + "battery": {"name": "DEAD BATTERY", "light": "BATTERY", "shop": 220, + "tool": "reader", "scene": "wrench", + "desc": "The divvy van jump-started it wrong out of spite."}, } static func faults(car_id: String) -> Array: @@ -211,6 +221,11 @@ static func add_fault(car_id: String, fault: String) -> void: _load() if not FAULTS.has(fault): return + # the fault recurring takes its quality buff with it: your last good fix + # is exactly as durable as your driving + var qd: Dictionary = _data.get("quality", {}) + if qd.has(car_id): + qd[car_id].erase(fault) var f: Dictionary = _data.get("faults", {}) var lst: Array = f.get(car_id, []) if fault in lst: @@ -220,6 +235,33 @@ static func add_fault(car_id: String, fault: String) -> void: _data["faults"] = f _flush() +## ---- workmanship: 0-100 per fixed system, 85+ is a buff -------------------- + +static func quality(car_id: String, sys: String) -> float: + _load() + return float(_data.get("quality", {}).get(car_id, {}).get(sys, 0.0)) + +static func set_quality(car_id: String, sys: String, score: float) -> void: + _load() + var q: Dictionary = _data.get("quality", {}) + var c: Dictionary = q.get(car_id, {}) + c[sys] = score + q[car_id] = c + _data["quality"] = q + _flush() + +static func bump_wreck(car_id: String) -> bool: + ## Wrecks wear the sump loose: every 4th one turns the oil light on. + _load() + var w: Dictionary = _data.get("wrecks", {}) + w[car_id] = int(w.get(car_id, 0)) + 1 + _data["wrecks"] = w + _flush() + if int(w[car_id]) % 4 == 0 and not "oil" in faults(car_id): + add_fault(car_id, "oil") + return true + return false + static func clear_fault(car_id: String, fault: String) -> void: _load() var f: Dictionary = _data.get("faults", {}) @@ -284,7 +326,9 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void: var ups := owned(cid) var tns := tunes(cid) var flt := faults(cid) - if ups.is_empty() and tns.is_empty() and flt.is_empty(): + _load() + var qual: Dictionary = _data.get("quality", {}).get(cid, {}) + if ups.is_empty() and tns.is_empty() and flt.is_empty() and qual.is_empty(): return var st = car.get("stats") if st != null: @@ -300,6 +344,20 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void: pm *= 0.85 if car.has_method("set_steam"): car.call("set_steam", true) + if "flat" in flt: + ga -= 0.35 + if "oil" in flt: + pm *= 0.93 + if "battery" in flt: + ba -= 0.3 + # workmanship buffs: an 85+ fix drives BETTER than stock, until you + # break that system again (add_fault clears its quality entry) + if float(qual.get("flat", 0.0)) >= 85.0: + ga += 0.2 + if float(qual.get("oil", 0.0)) >= 85.0: + pm *= 1.03 + if float(qual.get("battery", 0.0)) >= 85.0: + ba += 0.15 for u in ups: match u: "stripes": pm += 0.06 @@ -323,8 +381,8 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void: st.engine_power *= pm st.grip += ga car.set("stats", st) - if ba > 0.0 and car.get("boost_max") != null: - car.set("boost_max", float(car.get("boost_max")) + ba) + if ba != 0.0 and car.get("boost_max") != null: + car.set("boost_max", maxf(float(car.get("boost_max")) + ba, 0.4)) _bolt_visuals(car, ups) static func _mat(rgba: Color, rough := 0.5, metal := 0.0, emit := Color.BLACK) -> StandardMaterial3D: diff --git a/src/garage_ui.gd b/src/garage_ui.gd index 8160cdd..9643f71 100644 --- a/src/garage_ui.gd +++ b/src/garage_ui.gd @@ -177,9 +177,11 @@ func _on_buy() -> void: "tune": ok = Garage.buy_tune(car_id, e["id"]) "shopfix": ok = Garage.shop_fix(car_id, e["id"]) "fault": - # DIY: off to the shed minigame; it clears the fault on completion + # DIY: off to whichever minigame owns this fault (repair = 2D panel, + # wrench = 3D shed); it clears the fault on completion Garage.ui_fault = e["id"] - get_tree().change_scene_to_file("res://src/repair.tscn") + get_tree().change_scene_to_file("res://src/%s.tscn" + % String(Garage.FAULTS[e["id"]].get("scene", "repair"))) return if ok: Sfx.shot_2d("clunk") diff --git a/src/wrench.gd b/src/wrench.gd new file mode 100644 index 0000000..73db1fd --- /dev/null +++ b/src/wrench.gd @@ -0,0 +1,568 @@ +extends Node3D +## The 3D wrenching minigame, Phase 1: flat tyre, oil change, dead battery. +## Your ACTUAL car in your ACTUAL shed, fixed by hand with five verbs: +## GRAB -- click a loose part/tool, it follows the cursor, drop at the target +## TWIST -- hold near the bolt and circle the mouse; torque jobs have a seat +## window -- release inside it for a clean torque, blow past it and +## the thread STRIPS (quality hit + an extraction step appears) +## PUMP -- click the anchor repeatedly (jack, pump) +## YANK -- press the anchor and flick the mouse hard +## STEADY- keep the cursor inside a wandering ring (lining up studs, pouring) +## Every job scores 0-100. Finish at 85+ and the fix is a BUFF (Garage.apply) +## until that fault recurs. Which car/fault come via Garage.ui_stats_path / +## Garage.ui_fault. All input routes through the API methods (twist, pump, +## grab, drop, yank_ok, steady_tick, clamp, twist_release, skip_wait) so the +## smoke suite plays entire jobs headless. + +const WINDOW := 0.8 # rad of "seated" travel after the turns are in +const CAR_AT := Vector3(12, 0.0, -14.2) # the shed's painted bay + +var car_id := "" +var fault := "" +var stats: CarStats +var steps: Array = [] +var step_index := 0 +var quality := 100.0 +var done := false +var carried := "" # GRAB: name of the prop on the cursor +var twist_prog := 0.0 +var _stripped := false # current twist step already stripped +var pump_count := 0 +var steady_need := 0.0 +var wait_left := 0.0 +var clamps_done: Array = [] +var props := {} # name -> Node3D (spare wheel, battery, tray...) +var model: Node3D +var cam: Camera3D +var step_label: Label +var note_label: Label +var qual_label: Label +var ring: Control # screen-space anchor/steady indicator +var _wheel_mesh: MeshInstance3D = null +var _bonnet: Node3D = null +var _oil_fx: GPUParticles3D = null + +func _ready() -> void: + car_id = Garage.car_id_of(Garage.ui_stats_path) + fault = Garage.ui_fault + stats = load(Garage.ui_stats_path) + + add_child((load("res://levels/the_shed/level.tscn") as PackedScene).instantiate()) + _environment() + # the patient: model only, no physics -- it isn't going anywhere on stands + if stats.model_path != "" and ResourceLoader.exists(stats.model_path): + model = (load(stats.model_path) as PackedScene).instantiate() + else: + model = Node3D.new() + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = Vector3(stats.width, 1.0, stats.length) + mi.mesh = bm + model.add_child(mi) + add_child(model) + model.position = CAR_AT + Vector3.UP * 0.72 + for w in model.find_children("wheel_*", "MeshInstance3D", true, false): + var mi := w as MeshInstance3D + if mi.position.x < 0.0 and mi.position.z < 0.0: + _wheel_mesh = mi # front-left is the patient's patient + var b := model.find_children("det_bonnet*", "Node3D", true, false) + _bonnet = b[0] if not b.is_empty() else null + + cam = Camera3D.new() + cam.fov = 60.0 + add_child(cam) + _hud() + steps = _job() + _show_step() + +func _environment() -> void: + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-55, 40, 0) + sun.light_energy = 1.1 + sun.shadow_enabled = true + add_child(sun) + var e := Environment.new() + e.background_mode = Environment.BG_SKY + e.sky = Sky.new() + var sm := ProceduralSkyMaterial.new() + sm.sky_horizon_color = Color(0.8, 0.85, 0.9) + e.sky.sky_material = sm + e.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + e.ambient_light_energy = 0.6 + e.tonemap_mode = Environment.TONE_MAPPER_ACES + var env := WorldEnvironment.new() + env.environment = e + add_child(env) + +func _hud() -> void: + var hud := CanvasLayer.new() + add_child(hud) + step_label = Label.new() + step_label.anchor_left = 0.5 + step_label.anchor_right = 0.5 + step_label.grow_horizontal = Control.GROW_DIRECTION_BOTH + step_label.anchor_top = 0.04 + step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + step_label.add_theme_font_size_override("font_size", 34) + hud.add_child(step_label) + note_label = Label.new() + note_label.anchor_left = 0.5 + note_label.anchor_right = 0.5 + note_label.grow_horizontal = Control.GROW_DIRECTION_BOTH + note_label.anchor_top = 0.11 + note_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + note_label.add_theme_font_size_override("font_size", 19) + note_label.add_theme_color_override("font_color", Color(0.7, 0.73, 0.78)) + hud.add_child(note_label) + qual_label = Label.new() + qual_label.position = Vector2(24, 24) + qual_label.add_theme_font_size_override("font_size", 26) + hud.add_child(qual_label) + ring = RingDraw.new() + ring.set_anchors_preset(Control.PRESET_FULL_RECT) + ring.mouse_filter = Control.MOUSE_FILTER_IGNORE + hud.add_child(ring) + +## ---- job sheets ------------------------------------------------------------ + +func _anchor_wheel() -> Vector3: + return CAR_AT + Vector3(-stats.width * 0.5, 0.45, -(stats.length * 0.5 - 0.55)) + +func _anchor_engine() -> Vector3: + return CAR_AT + Vector3(0, 1.1, -stats.length * 0.30) + +func _anchor_sump() -> Vector3: + return CAR_AT + Vector3(0.2, 0.28, -stats.length * 0.18) + +func _job() -> Array: + var have_sockets := Garage.has_tool("sockets") + var t := 3.0 if have_sockets else 6.0 + match fault: + "flat": + return [ + {"verb": "pump", "clicks": 8, "cam": "wheel", "label": "JACK IT UP", + "note": "click it like you mean it"}, + {"verb": "twist", "dir": -1.0, "turns": t, "window": false, "cam": "wheel", + "label": "CRACK THE NUTS OFF", + "note": "ALDI SOCKETS: fits, mostly" if have_sockets else "VICE GRIPS: this will end badly"}, + {"verb": "yank", "cam": "wheel", "label": "POP THE FLAT OFF", + "note": "press on it and FLICK"}, + {"verb": "grab", "item": "spare", "at": "wheel", "cam": "wheel", + "label": "SPARE ON", "note": "grab it, hang it on the studs"}, + {"verb": "steady", "secs": 2.0, "cam": "wheel", "label": "LINE UP THE STUDS", + "note": "keep the cursor in the ring"}, + {"verb": "twist", "dir": 1.0, "turns": t, "window": true, "cam": "wheel", + "label": "TORQUE THE NUTS", "note": "release IN the green -- past it strips"}, + {"verb": "pump", "clicks": 4, "cam": "wheel", "label": "LET IT DOWN", + "note": "gently. gently."}, + ] + "oil": + return [ + {"verb": "grab", "item": "tray", "at": "sump", "cam": "under", + "label": "TRAY UNDER THE SUMP", "note": "future-you says thanks"}, + {"verb": "twist", "dir": -1.0, "turns": 1.5, "window": false, "cam": "under", + "label": "CRACK THE DRAIN PLUG", "note": "it's tighter than it needs to be"}, + {"verb": "yank", "cam": "under", "label": "PLUG OUT", "note": "flick -- and stand back"}, + {"verb": "wait", "secs": 2.0, "cam": "under", "label": "LET HER DRAIN", + "note": "black gold. worthless black gold."}, + {"verb": "twist", "dir": 1.0, "turns": 1.5, "window": true, "cam": "under", + "label": "PLUG BACK IN", "note": "release in the green: sump threads are soft"}, + {"verb": "grab", "item": "bottle", "at": "engine", "cam": "bonnet", + "label": "FRESH OIL UP TOP", "note": "the good stuff ($12/5L)"}, + {"verb": "steady", "secs": 2.5, "cam": "bonnet", "label": "POUR IT IN, STEADY", + "note": "outside the ring = on the manifold"}, + ] + "battery": + return [ + {"verb": "yank", "cam": "bonnet", "label": "BONNET UP", "note": "flick it open"}, + {"verb": "twist", "dir": -1.0, "turns": 1.0, "window": false, "cam": "bonnet", + "label": "CLAMPS OFF", "note": "10mm. it's always 10mm. where's the 10mm"}, + {"verb": "grab", "item": "old_batt", "at": "bench", "cam": "bonnet", + "label": "DEAD ONE OUT", "note": "heavier than it looks"}, + {"verb": "grab", "item": "new_batt", "at": "engine", "cam": "bonnet", + "label": "CASHIES BATTERY IN", + "note": "charged overnight" if Garage.has_tool("reader") else "\"probably holds charge\" -- the label"}, + {"verb": "clamps", "cam": "bonnet", "label": "CLAMP IT UP: RED THEN BLACK", + "note": "get it backwards and you'll know"}, + {"verb": "yank", "cam": "bonnet", "label": "BONNET DOWN", "note": "one dignified slam"}, + ] + return [] + +## ---- step lifecycle -------------------------------------------------------- + +func _show_step() -> void: + twist_prog = 0.0 + _stripped = false + pump_count = 0 + steady_need = 0.0 + clamps_done = [] + carried = "" + if step_index >= steps.size(): + _finish() + return + var st: Dictionary = steps[step_index] + step_label.text = "%s (%d/%d)" % [st["label"], step_index + 1, steps.size()] + note_label.text = String(st.get("note", "")) + qual_label.text = "QUALITY %d%%" % int(quality) + _camera(String(st.get("cam", "wheel"))) + match st["verb"]: + "wait": + wait_left = float(st["secs"]) + _oil_start() + "steady": + steady_need = float(st["secs"]) + "grab": + _spawn_prop(String(st["item"])) + "yank": + if fault == "battery" and step_index == 0: + pass # bonnet handled on success + ring.anchor_world = _step_anchor() + ring.cam = cam + ring.mode = String(st["verb"]) + +func _step_anchor() -> Vector3: + var st: Dictionary = steps[step_index] + match String(st.get("cam", "wheel")): + "under": + return _anchor_sump() + "bonnet": + return _anchor_engine() + return _anchor_wheel() + +func _camera(preset: String) -> void: + match preset: + "under": + cam.global_position = CAR_AT + Vector3(0.4, 0.5, -stats.length * 0.18 + 2.6) + cam.look_at(_anchor_sump()) + "bonnet": + cam.global_position = CAR_AT + Vector3(-1.6, 2.3, -stats.length * 0.30 + 2.4) + cam.look_at(_anchor_engine()) + _: + cam.global_position = CAR_AT + Vector3(-stats.width * 0.5 - 2.6, 1.4, + -(stats.length * 0.5 - 0.55) + 2.2) + cam.look_at(_anchor_wheel()) + +func _spawn_prop(item: String) -> void: + if props.has(item): + return + var n := Node3D.new() + var mi := MeshInstance3D.new() + match item: + "spare": + var cm := CylinderMesh.new() + cm.top_radius = 0.31 + cm.bottom_radius = 0.31 + cm.height = 0.22 + cm.material = _dark(Color(0.09, 0.09, 0.1)) + mi.mesh = cm + mi.rotation_degrees = Vector3(0, 0, 90) + n.position = CAR_AT + Vector3(-2.6, 0.31, 2.2) + "tray": + var bm := BoxMesh.new() + bm.size = Vector3(0.5, 0.12, 0.5) + bm.material = _dark(Color(0.15, 0.15, 0.17)) + mi.mesh = bm + n.position = CAR_AT + Vector3(-2.0, 0.06, 0.5) + "bottle": + var bm := BoxMesh.new() + bm.size = Vector3(0.18, 0.32, 0.14) + bm.material = _dark(Color(0.75, 0.7, 0.2)) + mi.mesh = bm + n.position = Vector3(11.5, 1.06, -19.8) # on the bench + "old_batt", "new_batt": + var bm := BoxMesh.new() + bm.size = Vector3(0.32, 0.22, 0.19) + bm.material = _dark(Color(0.08, 0.08, 0.09) if item == "old_batt" else Color(0.12, 0.3, 0.12)) + mi.mesh = bm + n.position = _anchor_engine() if item == "old_batt" else Vector3(10.5, 1.08, -19.8) + n.add_child(mi) + n.name = item + add_child(n) + props[item] = n + +func _dark(c: Color) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.albedo_color = c + m.roughness = 0.7 + return m + +func _oil_start() -> void: + if _oil_fx == null: + _oil_fx = Fx.smoker(self, Vector3.ZERO) + _oil_fx.position = _anchor_sump() + _oil_fx.amount = 12 + _oil_fx.emitting = true + +func _process(delta: float) -> void: + if done: + return + if step_index < steps.size() and steps[step_index]["verb"] == "wait": + wait_left -= delta + if wait_left <= 0.0: + if _oil_fx: + _oil_fx.emitting = false + _advance() + +## ---- the verbs (test API) -------------------------------------------------- + +func twist(rad: float) -> void: + if done or steps[step_index]["verb"] != "twist": + return + var st: Dictionary = steps[step_index] + var toward: float = rad * float(st["dir"]) + if toward <= 0.0: + return + twist_prog += toward + var need: float = float(st["turns"]) * TAU + if not bool(st["window"]): + if twist_prog >= need: + Sfx.shot_2d("clunk", -6.0) + _advance() + return + # torque window: overshoot past the seat and the thread strips + if twist_prog > need + WINDOW and not _stripped: + _stripped = true + quality -= 10.0 + Sfx.shot_2d("bog", -4.0) + note_label.text = "STRIPPED IT. that's going in the jar." + steps.insert(step_index + 1, {"verb": "twist", "dir": -1.0, "turns": 2.0, + "window": false, "cam": st.get("cam", "wheel"), + "label": "EXTRACT THE STRIPPED BOLT", "note": "(you did this)"}) + _advance() + +func twist_release() -> void: + ## releasing the mouse ends a torque attempt: clean if inside the window + if done or steps[step_index]["verb"] != "twist" or not bool(steps[step_index]["window"]): + return + var need: float = float(steps[step_index]["turns"]) * TAU + if twist_prog >= need and twist_prog <= need + WINDOW: + Sfx.shot_2d("clunk", -4.0) + _advance() + elif twist_prog > 0.5: + quality -= 1.0 # fumbled attempt, re-grip + twist_prog = maxf(twist_prog - 1.0, 0.0) + +func pump() -> void: + if done or steps[step_index]["verb"] != "pump": + return + pump_count += 1 + Sfx.shot_2d("clunk", -12.0) + if pump_count >= int(steps[step_index]["clicks"]): + _advance() + +func yank_ok() -> void: + if done or steps[step_index]["verb"] != "yank": + return + Sfx.shot_2d("clunk", -6.0) + if fault == "flat" and _wheel_mesh: + _wheel_mesh.visible = false + if fault == "battery" and _bonnet: + # explicit bool: a Dictionary index is Variant and := can't see through it + var opening: bool = steps[step_index]["label"] == "BONNET UP" + var tw := create_tween() + tw.tween_property(_bonnet, "rotation:x", -0.9 if opening else 0.0, 0.4) + _advance() + +func grab(item: String) -> void: + if done or steps[step_index]["verb"] != "grab": + return + if steps[step_index]["item"] == item: + carried = item + +func drop_at(world: Vector3) -> void: + if done or carried == "" or steps[step_index]["verb"] != "grab": + return + var st: Dictionary = steps[step_index] + var target: Vector3 + match String(st["at"]): + "wheel": target = _anchor_wheel() + "sump": target = _anchor_sump() - Vector3(0, 0.2, 0) + "engine": target = _anchor_engine() + "bench": target = Vector3(11.5, 1.0, -19.8) + _: target = _anchor_wheel() + if world.distance_to(target) < 1.2: + var p: Node3D = props.get(carried) + if p: + p.position = target + if carried == "spare": + p.get_child(0).rotation_degrees = Vector3(0, 0, 90) + Sfx.shot_2d("clunk", -6.0) + carried = "" + _advance() + +func steady_tick(inside: bool, delta: float) -> void: + if done or steps[step_index]["verb"] != "steady": + return + if inside: + steady_need -= delta + if steady_need <= 0.0: + Sfx.shot_2d("clunk", -6.0) + if fault == "flat" and props.has("spare") and _wheel_mesh: + (props["spare"] as Node3D).visible = false + _wheel_mesh.visible = true # the spare becomes the wheel + _advance() + else: + quality -= 2.0 * delta * 10.0 * 0.1 # excursions cost, gently + qual_label.text = "QUALITY %d%%" % int(quality) + +func clamp_on(colour: String) -> void: + if done or steps[step_index]["verb"] != "clamps": + return + if colour in clamps_done: + return + if clamps_done.is_empty() and colour != "red": + quality -= 10.0 + Fx.sparks(self, 30) + Sfx.shot_2d("glass", -6.0) + note_label.text = "WRONG ORDER. smell that? that's the smell of learning." + clamps_done.append(colour) + Sfx.shot_2d("clunk", -8.0) + if clamps_done.size() >= 2: + _advance() + +func skip_wait() -> void: + if not done and step_index < steps.size() and steps[step_index]["verb"] == "wait": + wait_left = 0.0 + +func _advance() -> void: + step_index += 1 + _show_step() + +func _finish() -> void: + done = true + quality = clampf(quality, 0.0, 100.0) + Garage.clear_fault(car_id, fault) + Garage.set_quality(car_id, fault, quality) + step_label.text = "DONE. QUALITY %d%%" % int(quality) + note_label.text = ("that's a PROPER fix -- it'll drive better than stock" if quality >= 85.0 + else "it'll hold. probably. no buff at that standard, mate") + Sfx.shot_2d("sting_win" if quality >= 85.0 else "clunk", -4.0) + var b := Button.new() + b.text = "BACK TO THE GARAGE" + b.add_theme_font_size_override("font_size", 26) + b.anchor_left = 0.5 + b.anchor_right = 0.5 + b.anchor_top = 0.8 + b.grow_horizontal = Control.GROW_DIRECTION_BOTH + b.pressed.connect(func() -> void: + get_tree().change_scene_to_file("res://src/garage.tscn")) + var hud := CanvasLayer.new() + add_child(hud) + hud.add_child(b) + +## ---- mouse plumbing -------------------------------------------------------- + +var _pressing := false +var _prev_angle := 0.0 +var _press_pos := Vector2.ZERO +var _press_t := 0.0 + +func _anchor_screen() -> Vector2: + return cam.unproject_position(_step_anchor()) + +func _unhandled_input(event: InputEvent) -> void: + if done or step_index >= steps.size(): + return + var st: Dictionary = steps[step_index] + var verb := String(st["verb"]) + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _pressing = true + _press_pos = event.position + _press_t = Time.get_ticks_msec() / 1000.0 + var d: float = event.position.distance_to(_anchor_screen()) + match verb: + "twist": + _prev_angle = (event.position - _anchor_screen()).angle() + "pump": + if d < 160.0: + pump() + "grab": + if carried == "": + # grab the prop if clicked near it, else it's a miss + var p: Node3D = props.get(String(st["item"])) + if p and event.position.distance_to(cam.unproject_position(p.global_position)) < 120.0: + grab(String(st["item"])) + "clamps": + # left half of the anchor = red clamp, right half = black + clamp_on("red" if event.position.x < _anchor_screen().x else "black") + else: + _pressing = false + match verb: + "twist": + twist_release() + "yank": + var dt := Time.get_ticks_msec() / 1000.0 - _press_t + if dt < 0.35 and event.position.distance_to(_press_pos) > 120.0: + yank_ok() + "grab": + if carried != "": + # drop: project the cursor onto the anchor's height plane + var from := cam.project_ray_origin(event.position) + var dir := cam.project_ray_normal(event.position) + var plane := Plane(Vector3.UP, _step_anchor().y) + var hit = plane.intersects_ray(from, dir) + if hit != null: + drop_at(hit) + elif event is InputEventMouseMotion: + if _pressing and verb == "twist": + var a: float = (event.position - _anchor_screen()).angle() + twist(wrapf(a - _prev_angle, -PI, PI)) + _prev_angle = a + elif carried != "": + var p: Node3D = props.get(carried) + if p: + var from := cam.project_ray_origin(event.position) + var dir := cam.project_ray_normal(event.position) + var plane := Plane(Vector3.UP, _step_anchor().y) + var hit = plane.intersects_ray(from, dir) + if hit != null: + p.global_position = hit + +func _physics_process(delta: float) -> void: + if done or step_index >= steps.size(): + return + if steps[step_index]["verb"] == "steady": + var inside: bool = get_viewport().get_mouse_position().distance_to( + ring.steady_centre()) < 46.0 + steady_tick(inside, delta) + +func _input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + get_tree().change_scene_to_file("res://src/garage.tscn") + + +class RingDraw extends Control: + ## Screen-space indicator: anchor ring, twist progress arc, wandering + ## steady target. Pure decoration -- the verbs live on the parent. + var anchor_world := Vector3.ZERO + var cam: Camera3D = null + var mode := "" + var _t := 0.0 + + func steady_centre() -> Vector2: + var base := cam.unproject_position(anchor_world) if cam else size * 0.5 + return base + Vector2(sin(_t * 1.3), cos(_t * 0.9)) * 34.0 + + func _process(delta: float) -> void: + _t += delta + queue_redraw() + + func _draw() -> void: + if cam == null: + return + var p := cam.unproject_position(anchor_world) + var parent := get_parent().get_parent() as Node3D # CanvasLayer -> wrench + match mode: + "twist": + draw_arc(p, 42.0, 0, TAU, 40, Color(1, 0.82, 0.35, 0.8), 3.0) + var w = parent.get("steps")[parent.get("step_index")] + if bool(w.get("window", false)): + var need: float = float(w["turns"]) * TAU + var frac: float = clampf(float(parent.get("twist_prog")) / need, 0.0, 1.3) + draw_arc(p, 52.0, -PI / 2, -PI / 2 + TAU * minf(frac, 1.0), 40, + Color(0.3, 0.9, 0.4, 0.9) if frac >= 1.0 else Color(0.9, 0.9, 0.9, 0.7), 4.0) + "steady": + draw_arc(steady_centre(), 46.0, 0, TAU, 40, Color(0.3, 0.9, 0.4, 0.85), 3.0) + "pump", "yank", "grab", "clamps": + draw_arc(p, 40.0 + sin(_t * 5.0) * 5.0, 0, TAU, 40, Color(1, 0.82, 0.35, 0.7), 3.0) diff --git a/src/wrench.gd.uid b/src/wrench.gd.uid new file mode 100644 index 0000000..add7c8c --- /dev/null +++ b/src/wrench.gd.uid @@ -0,0 +1 @@ +uid://dfyx45imad8y6 diff --git a/src/wrench.tscn b/src/wrench.tscn new file mode 100644 index 0000000..4831ad1 --- /dev/null +++ b/src/wrench.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/wrench.gd" id="1"] + +[node name="Wrench" type="Node3D"] +script = ExtResource("1") diff --git a/tests/smoke.gd b/tests/smoke.gd index b4bcb4a..f6eff5d 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -124,6 +124,37 @@ func _run() -> void: _check(gg.garage_requested, "rolling into the bay never opened the garage") gg.free() Game.level_path = levels.get("woolies_carpark", levels.values()[0]) + # wrench phase 1: the 3D shed jobs. A clean flat-tyre run scores 90+ and + # buffs the car; a sabotaged torque window strips a thread on the oil job. + print(" .. wrench") + Garage.add_fault("datto_120y", "flat") + Garage.ui_stats_path = "res://cars/datto_120y/stats.tres" + Garage.ui_fault = "flat" + var wr = (load("res://src/wrench.tscn") as PackedScene).instantiate() + root.add_child(wr) + await process_frame + _check(wr.steps.size() == 7, "flat-tyre job sheet wrong (%d steps)" % wr.steps.size()) + await _drive_wrench(wr, false) + _check(wr.done, "flat-tyre job never finished (step %d)" % wr.step_index) + _check(wr.quality >= 90.0, "clean flat run scored %d" % int(wr.quality)) + _check(not Garage.faults("datto_120y").has("flat"), "flat fault not cleared") + _check(Garage.quality("datto_120y", "flat") >= 85.0, "workmanship not recorded") + wr.free() + gg = await _boot("cruise") # 85+ fix = grip buff on the next boot + var dstats: CarStats = load("res://cars/datto_120y/stats.tres") + _check(gg.car.stats.grip > dstats.grip + 0.7, "85+ fix paid no grip buff (%.2f vs %.2f)" + % [gg.car.stats.grip, dstats.grip]) + gg.free() + Garage.add_fault("datto_120y", "oil") + Garage.ui_fault = "oil" + wr = (load("res://src/wrench.tscn") as PackedScene).instantiate() + root.add_child(wr) + await process_frame + await _drive_wrench(wr, true) + _check(wr.done, "oil job never finished (step %d)" % wr.step_index) + _check(wr.quality <= 90.0, "stripped thread cost nothing (%d)" % int(wr.quality)) + _check(wr.steps.size() == 8, "extraction step never appeared (%d steps)" % wr.steps.size()) + wr.free() Garage._reset_for_test() # later stages must run stock # cruise: throttle 3s, car must move @@ -389,6 +420,45 @@ func _run() -> void: [cars.size(), levels.size(), dist, int(crash_score), int(jct_score), Game.DERBY_CARS]) quit(0) +func _drive_wrench(wr, sabotage: bool) -> void: + ## Plays any wrench job through its API. sabotage blows the first torque + ## window wide open to prove that threads strip. + var guard := 0 + var sabotaged := false + while not wr.done and guard < 30: + guard += 1 + var st: Dictionary = wr.steps[wr.step_index] + match String(st["verb"]): + "pump": + for i in int(st["clicks"]): + wr.pump() + "twist": + if bool(st.get("window", false)): + if sabotage and not sabotaged: + sabotaged = true + wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 2.0)) + else: + wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.3)) + wr.twist_release() + else: + wr.twist(float(st["dir"]) * (float(st["turns"]) * TAU + 0.2)) + "yank": + wr.yank_ok() + "grab": + wr.grab(String(st["item"])) + wr.drop_at(Vector3(11.5, 1.0, -19.8) if String(st["at"]) == "bench" + else wr._step_anchor()) + "steady": + while not wr.done and wr.steps[wr.step_index]["verb"] == "steady": + wr.steady_tick(true, 0.5) + "clamps": + wr.clamp_on("red") + wr.clamp_on("black") + "wait": + wr.skip_wait() + await physics_frame + await physics_frame + func _check(cond: bool, msg: String) -> void: ## NOT assert(): a failed assert in a headless SceneTree script halts the ## script but leaves the tree running, so the process spins forever instead