The Shed: workshop tools unlock tune-ups (second progression path)

Six tools, bought once, global: SUPERCHEAP WHEEL RAMPS, CASHIES CODE READER
(one previous owner, screen cracked), ALDI SOCKET SET, BUNNINGS AIR
COMPRESSOR, CASHIES CHAIN HOIST (on wheels, definitely not stolen), SERVO
ADDITIVE CRATE. Each unlocks a per-car tune-up at labour prices: dodgy
alignment (+0.4 grip), check engine delete (+6% power, the light WAS the
problem), 60 PSI in every tyre, head skim & lumpy cam (+12%), octane booster
habit (+0.25 boost), and RETORQUE EVERYTHING -- the one effect parts can't
buy: it raises the car's panel-shed and wreck thresholds, which are now
per-instance vars instead of consts. Parts buy bling; the shed buys labour.

Garage screen grows three sections (parts / shed / tune-ups) with locked
states showing which tool a tune needs. Save schema extends compatibly.
Smoke covers the tool gate, double-sell refusal and panel hardening on a
live car.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-31 11:11:20 +10:00
parent fd1218621b
commit 117d950675
7 changed files with 285 additions and 26 deletions

View File

@ -93,11 +93,22 @@ real stat bump AND bolts a procedural eyesore onto the car -- progression you
can see from across the carpark. Fully kitted is roughly +35% power, +1.1 grip can see from across the carpark. Fully kitted is roughly +35% power, +1.1 grip
and +0.55 boost. and +0.55 boost.
Purchases are per-car and persist in `user://garage.save`. Upgrades apply to a **The Shed** is the second path: workshop tools bought once, globally --
duplicate of the CarStats resource -- the .tres is shared with traffic and SUPERCHEAP WHEEL RAMPS, the CASHIES CODE READER (one previous owner, screen
parked spawns of the same model, and tuning your Lazza must not secretly tune cracked), an ALDI SOCKET SET, a BUNNINGS AIR COMPRESSOR, the CASHIES CHAIN
every Lazza in the city. Bolt-ons attach after the deformer snapshots the HOIST (on wheels, definitely not stolen) and a SERVO ADDITIVE CRATE. Each tool
mesh, so the wing survives every crash, as is right and proper. unlocks a per-car **tune-up** at labour prices: dodgy wheel alignment, check
engine delete ("the light WAS the problem"), 60 PSI in every tyre, a head skim
and lumpy cam, an octane booster habit -- and RETORQUE EVERYTHING, which is the
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.
Purchases are per-car (tools are global) and persist in `user://garage.save`.
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
snapshots the mesh, so the wing survives every crash, as is right and proper.
## Crash deformation and damage ## Crash deformation and damage

40
shot_garage_f.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dr7aglv20utx3"
path="res://.godot/imported/shot_garage_f.png-aa2f56aea17c124e5ede0f7b9bc1c349.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_garage_f.png"
dest_files=["res://.godot/imported/shot_garage_f.png-aa2f56aea17c124e5ede0f7b9bc1c349.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

40
shot_garage_r.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bmlq6d63wenbp"
path="res://.godot/imported/shot_garage_r.png-524c5e48bfb09463cc90db7e4ba2f22b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_garage_r.png"
dest_files=["res://.godot/imported/shot_garage_r.png-524c5e48bfb09463cc90db7e4ba2f22b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -24,6 +24,11 @@ const BOOST_CAP := 4.0 # takedowns grow the meter up to 4x (Burnout 3 rul
const PART_DV := 4.0 # velocity jolt (m/s in one tick) that sheds a panel const PART_DV := 4.0 # velocity jolt (m/s in one tick) that sheds a panel
const WRECK_DV := 9.0 # jolt that wrecks you const WRECK_DV := 9.0 # jolt that wrecks you
# per-instance copies: the Garage's RETORQUE EVERYTHING tune raises them, so a
# properly spannered shitbox holds its panels on through bigger hits
var part_dv := PART_DV
var wreck_dv := WRECK_DV
var throttle_in := 0.0 var throttle_in := 0.0
var steer_in := 0.0 var steer_in := 0.0
var drift_in := false var drift_in := false
@ -178,7 +183,7 @@ func _physics_process(delta: float) -> void:
var jolt := linear_velocity - _prev_lv var jolt := linear_velocity - _prev_lv
_prev_lv = linear_velocity _prev_lv = linear_velocity
_impact_cd = maxf(_impact_cd - delta, 0.0) _impact_cd = maxf(_impact_cd - delta, 0.0)
if jolt.length() > PART_DV and _impact_cd == 0.0: if jolt.length() > part_dv and _impact_cd == 0.0:
_impact_cd = 0.4 # one collision = one crash event, not one per tick _impact_cd = 0.4 # one collision = one crash event, not one per tick
_impact(jolt) _impact(jolt)
@ -190,7 +195,7 @@ func _impact(jolt: Vector3) -> void:
crashed.emit(dv) crashed.emit(dv)
Sfx.shot(self, "crash_big" if dv > 8.0 else "crash_small", Sfx.shot(self, "crash_big" if dv > 8.0 else "crash_small",
-2.0, randf_range(0.9, 1.1)) -2.0, randf_range(0.9, 1.1))
if dv > WRECK_DV: if dv > wreck_dv:
Sfx.shot(self, "glass", -4.0) Sfx.shot(self, "glass", -4.0)
Fx.sparks(self, clampi(int(dv * 3.0), 10, 44)) Fx.sparks(self, clampi(int(dv * 3.0), 10, 44))
var side := (global_basis.inverse() * -jolt).normalized() var side := (global_basis.inverse() * -jolt).normalized()
@ -202,7 +207,7 @@ func _impact(jolt: Vector3) -> void:
_deform.impact(reach, -side, minf(dv, 26.0) * 0.048) _deform.impact(reach, -side, minf(dv, 26.0) * 0.048)
for i in 1 + int(dv > 8.0) + int(dv > 12.0): for i in 1 + int(dv > 8.0) + int(dv > 12.0):
_shed_panel(side) _shed_panel(side)
if dv > WRECK_DV and not wrecked: if dv > wreck_dv and not wrecked:
wrecked = true wrecked = true
wreck_started.emit() wreck_started.emit()
get_tree().create_timer(2.2, true, false, true).timeout.connect(_recover) get_tree().create_timer(2.2, true, false, true).timeout.connect(_recover)

View File

@ -57,6 +57,39 @@ 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.
const TOOLS := {
"ramps": {"name": "SUPERCHEAP WHEEL RAMPS", "price": 800,
"desc": "Plastic. Rated to \"most cars, probably\"."},
"reader": {"name": "CASHIES CODE READER", "price": 1200,
"desc": "One previous owner. Screen cracked. Works if you squint."},
"sockets": {"name": "ALDI SOCKET SET", "price": 600,
"desc": "Sunday special. Metric-ish."},
"compressor": {"name": "BUNNINGS AIR COMPRESSOR", "price": 1500,
"desc": "Came with three fittings, none of them the right one."},
"hoist": {"name": "CASHIES CHAIN HOIST (ON WHEELS)", "price": 2500,
"desc": "Definitely not stolen. Rolls beautifully."},
"additives": {"name": "SERVO ADDITIVE CRATE", "price": 400,
"desc": "A crate of bottles that all claim +30 horsepower."},
}
## tune id -> {name, desc, price (labour), tool (required)}
const TUNES := {
"align": {"name": "DODGY WHEEL ALIGNMENT", "price": 300, "tool": "ramps",
"desc": "Eyeballed against the fence. Straight-ish. +0.4 grip"},
"cel": {"name": "CHECK ENGINE DELETE", "price": 300, "tool": "reader",
"desc": "The light WAS the problem. +6% power"},
"retorque": {"name": "RETORQUE EVERYTHING", "price": 300, "tool": "sockets",
"desc": "Every bolt. Three left over, but panels HANG ON now. +panel grit"},
"psi": {"name": "60 PSI IN EVERY TYRE", "price": 300, "tool": "compressor",
"desc": "More PSI is more grip, that's just science. +0.3 grip, +2% power"},
"headskim": {"name": "HEAD SKIM & LUMPY CAM", "price": 500, "tool": "hoist",
"desc": "Engine out on the driveway. Neighbours notified by sound. +12% power"},
"octane": {"name": "OCTANE BOOSTER HABIT", "price": 250, "tool": "additives",
"desc": "Two bottles per tank, minimum. +0.25 boost"},
}
static var _data := {} static var _data := {}
static var _loaded := false static var _loaded := false
@ -64,7 +97,7 @@ static func _load() -> void:
if _loaded: if _loaded:
return return
_loaded = true _loaded = true
_data = {"bucks": 0, "cars": {}} _data = {"bucks": 0, "cars": {}, "tools": [], "tunes": {}}
if FileAccess.file_exists(save_path): if FileAccess.file_exists(save_path):
var parsed = JSON.parse_string(FileAccess.get_file_as_string(save_path)) var parsed = JSON.parse_string(FileAccess.get_file_as_string(save_path))
if parsed is Dictionary and parsed.has("bucks"): if parsed is Dictionary and parsed.has("bucks"):
@ -106,6 +139,53 @@ static func buy(car_id: String, upgrade: String) -> bool:
_flush() _flush()
return true return true
static func tools() -> Array:
_load()
return _data.get("tools", [])
static func has_tool(tool: String) -> bool:
return tool in tools()
static func buy_tool(tool: String) -> bool:
_load()
if not TOOLS.has(tool) or has_tool(tool):
return false
var price: int = TOOLS[tool]["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
_flush()
return true
static func tunes(car_id: String) -> Array:
_load()
return _data.get("tunes", {}).get(car_id, [])
static func has_tune(car_id: String, tune: String) -> bool:
return tune in tunes(car_id)
static func buy_tune(car_id: String, tune: String) -> bool:
## The skill-tree gate: no tool, no tune. The shed IS the prerequisite.
_load()
if not TUNES.has(tune) or has_tune(car_id, tune):
return false
if not has_tool(TUNES[tune]["tool"]):
return false
var price: int = TUNES[tune]["price"]
if int(_data["bucks"]) < price:
return false
_data["bucks"] = int(_data["bucks"]) - price
var t: Dictionary = _data.get("tunes", {})
var lst: Array = t.get(car_id, [])
lst.append(tune)
t[car_id] = lst
_data["tunes"] = t
_flush()
return true
static func car_id_of(stats_path: String) -> String: static func car_id_of(stats_path: String) -> String:
# res://cars/<id>/stats.tres -> <id> # res://cars/<id>/stats.tres -> <id>
return stats_path.get_base_dir().get_file() return stats_path.get_base_dir().get_file()
@ -116,8 +196,10 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
## Player only: traffic and parked cars stay stock -- your mods are YOURS. ## Player only: traffic and parked cars stay stock -- your mods are YOURS.
if not is_player: if not is_player:
return return
var ups := owned(car_id_of(stats_path)) var cid := car_id_of(stats_path)
if ups.is_empty(): var ups := owned(cid)
var tns := tunes(cid)
if ups.is_empty() and tns.is_empty():
return return
var st = car.get("stats") var st = car.get("stats")
if st != null: if st != null:
@ -134,6 +216,17 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
"spoiler": ga += 0.6 "spoiler": ga += 0.6
"exhaust": ba += 0.35 "exhaust": ba += 0.35
"neons": ba += 0.2 "neons": ba += 0.2
for t in tns:
match t:
"align": ga += 0.4
"cel": pm += 0.06
"psi": ga += 0.3; pm += 0.02
"headskim": pm += 0.12
"octane": ba += 0.25
"retorque":
# panels cling on: thresholds live on the car, not the stats
car.set("part_dv", float(car.get("part_dv")) + 2.0)
car.set("wreck_dv", float(car.get("wreck_dv")) + 1.5)
st.engine_power *= pm st.engine_power *= pm
st.grip += ga st.grip += ga
car.set("stats", st) car.set("stats", st)
@ -266,6 +359,6 @@ static func _bolt_visuals(car: Node3D, ups: Array) -> void:
## test hook: wipe state (used by the smoke suite, never the game) ## test hook: wipe state (used by the smoke suite, never the game)
static func _reset_for_test() -> void: static func _reset_for_test() -> void:
_data = {"bucks": 0, "cars": {}} _data = {"bucks": 0, "cars": {}, "tools": [], "tunes": {}}
_loaded = true _loaded = true
_flush() _flush()

View File

@ -8,7 +8,7 @@ var list: ItemList
var bucks_label: Label var bucks_label: Label
var desc_label: Label var desc_label: Label
var buy_btn: Button var buy_btn: Button
var ids: Array = [] var entries: Array = [] # row -> {"kind": "part"|"tool"|"tune"|"header", "id": ...}
func _ready() -> void: func _ready() -> void:
car_id = Garage.car_id_of(Garage.ui_stats_path) car_id = Garage.car_id_of(Garage.ui_stats_path)
@ -70,37 +70,98 @@ func _ready() -> void:
row.add_child(back) row.add_child(back)
_refresh() _refresh()
if list.item_count > 0: for i in list.item_count: # row 0 is a section header now
list.select(0) if list.is_item_selectable(i):
_on_select(0) list.select(i)
_on_select(i)
break
func _header(text: String) -> void:
var i := list.add_item(text)
list.set_item_selectable(i, false)
list.set_item_custom_fg_color(i, Color(1, 0.82, 0.35))
entries.append({"kind": "header"})
func _refresh() -> void: func _refresh() -> void:
var keep := list.get_selected_items() var keep := list.get_selected_items()
list.clear() list.clear()
ids.clear() entries.clear()
bucks_label.text = "BUCKS: $%d" % Garage.bucks() bucks_label.text = "BUCKS: $%d" % Garage.bucks()
_header("--- BOLT-ON PARTS (this car) ---")
for id in Garage.CATALOGUE: for id in Garage.CATALOGUE:
var c: Dictionary = Garage.CATALOGUE[id] var c: Dictionary = Garage.CATALOGUE[id]
var tag := "[FITTED]" if Garage.has(car_id, id) else "$%d" % c["price"] var tag := "[FITTED]" if Garage.has(car_id, id) else "$%d" % c["price"]
list.add_item("%-26s %s" % [c["name"], tag]) list.add_item("%-28s %s" % [c["name"], tag])
ids.append(id) entries.append({"kind": "part", "id": id})
if not keep.is_empty() and keep[0] < list.item_count: _header("--- THE SHED (tools, buy once, unlock tune-ups) ---")
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])
entries.append({"kind": "tool", "id": id})
_header("--- TUNE-UPS (this car, needs the tool) ---")
for id in Garage.TUNES:
var c: Dictionary = Garage.TUNES[id]
var tag: String
if Garage.has_tune(car_id, id):
tag = "[DONE]"
elif not Garage.has_tool(c["tool"]):
tag = "[NEEDS %s]" % Garage.TOOLS[c["tool"]]["name"]
else:
tag = "$%d" % c["price"]
list.add_item("%-28s %s" % [c["name"], tag])
entries.append({"kind": "tune", "id": id})
if not keep.is_empty() and keep[0] < list.item_count and list.is_item_selectable(keep[0]):
list.select(keep[0]) list.select(keep[0])
func _entry_info(i: int) -> Dictionary:
var e: Dictionary = entries[i]
match e.get("kind"):
"part":
var c: Dictionary = Garage.CATALOGUE[e["id"]]
return {"desc": c["desc"], "price": c["price"],
"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}
"tune":
var c: Dictionary = Garage.TUNES[e["id"]]
return {"desc": c["desc"], "price": c["price"],
"done": Garage.has_tune(car_id, e["id"]),
"locked": not Garage.has_tool(c["tool"])}
return {}
func _on_select(i: int) -> void: func _on_select(i: int) -> void:
var c: Dictionary = Garage.CATALOGUE[ids[i]] var info := _entry_info(i)
desc_label.text = c["desc"] if info.is_empty():
buy_btn.disabled = Garage.has(car_id, ids[i]) or Garage.bucks() < int(c["price"]) return
buy_btn.text = "FITTED" if Garage.has(car_id, ids[i]) else "BUY ($%d)" % c["price"] desc_label.text = info["desc"]
if info["done"]:
buy_btn.text = "DONE"
buy_btn.disabled = true
elif info["locked"]:
buy_btn.text = "LOCKED (buy the tool)"
buy_btn.disabled = true
else:
buy_btn.text = "BUY ($%d)" % int(info["price"])
buy_btn.disabled = Garage.bucks() < int(info["price"])
func _on_buy() -> void: func _on_buy() -> void:
var sel := list.get_selected_items() var sel := list.get_selected_items()
if sel.is_empty(): if sel.is_empty():
return return
if Garage.buy(car_id, ids[sel[0]]): var e: Dictionary = entries[sel[0]]
var ok := false
match e.get("kind"):
"part": ok = Garage.buy(car_id, e["id"])
"tool": ok = Garage.buy_tool(e["id"])
"tune": ok = Garage.buy_tune(car_id, e["id"])
if ok:
Sfx.shot_2d("clunk") Sfx.shot_2d("clunk")
_refresh() _refresh()
_on_select(sel[0]) if sel[0] < list.item_count:
_on_select(sel[0])
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):

View File

@ -42,6 +42,15 @@ func _run() -> void:
"wing added no grip") "wing added no grip")
_check(gg.car.stats.resource_path == "", "upgrades mutated the SHARED stats resource") _check(gg.car.stats.resource_path == "", "upgrades mutated the SHARED stats resource")
gg.free() gg.free()
# the shed: tunes are gated on tools, and retorque hardens the panels
Garage.earn(2000)
_check(not Garage.buy_tune("datto_120y", "retorque"), "tuned without owning the tool")
_check(Garage.buy_tool("sockets"), "couldn't buy the socket set")
_check(Garage.buy_tune("datto_120y", "retorque"), "tool owned but tune refused")
_check(not Garage.buy_tune("datto_120y", "retorque"), "same tune sold twice")
gg = await _boot("cruise")
_check(gg.car.part_dv > Car.PART_DV + 1.0, "retorque didn't harden panels (%.1f)" % gg.car.part_dv)
gg.free()
Garage._reset_for_test() # later stages must run stock Garage._reset_for_test() # later stages must run stock
# cruise: throttle 3s, car must move # cruise: throttle 3s, car must move