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
and +0.55 boost.
Purchases are per-car 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.
**The Shed** is the second path: workshop tools bought once, globally --
SUPERCHEAP WHEEL RAMPS, the CASHIES CODE READER (one previous owner, screen
cracked), an ALDI SOCKET SET, a BUNNINGS AIR COMPRESSOR, the CASHIES CHAIN
HOIST (on wheels, definitely not stolen) and a SERVO ADDITIVE CRATE. Each tool
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

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 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 steer_in := 0.0
var drift_in := false
@ -178,7 +183,7 @@ func _physics_process(delta: float) -> void:
var jolt := linear_velocity - _prev_lv
_prev_lv = linear_velocity
_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(jolt)
@ -190,7 +195,7 @@ func _impact(jolt: Vector3) -> void:
crashed.emit(dv)
Sfx.shot(self, "crash_big" if dv > 8.0 else "crash_small",
-2.0, randf_range(0.9, 1.1))
if dv > WRECK_DV:
if dv > wreck_dv:
Sfx.shot(self, "glass", -4.0)
Fx.sparks(self, clampi(int(dv * 3.0), 10, 44))
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)
for i in 1 + int(dv > 8.0) + int(dv > 12.0):
_shed_panel(side)
if dv > WRECK_DV and not wrecked:
if dv > wreck_dv and not wrecked:
wrecked = true
wreck_started.emit()
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 _loaded := false
@ -64,7 +97,7 @@ static func _load() -> void:
if _loaded:
return
_loaded = true
_data = {"bucks": 0, "cars": {}}
_data = {"bucks": 0, "cars": {}, "tools": [], "tunes": {}}
if FileAccess.file_exists(save_path):
var parsed = JSON.parse_string(FileAccess.get_file_as_string(save_path))
if parsed is Dictionary and parsed.has("bucks"):
@ -106,6 +139,53 @@ static func buy(car_id: String, upgrade: String) -> bool:
_flush()
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:
# res://cars/<id>/stats.tres -> <id>
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.
if not is_player:
return
var ups := owned(car_id_of(stats_path))
if ups.is_empty():
var cid := car_id_of(stats_path)
var ups := owned(cid)
var tns := tunes(cid)
if ups.is_empty() and tns.is_empty():
return
var st = car.get("stats")
if st != null:
@ -134,6 +216,17 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
"spoiler": ga += 0.6
"exhaust": ba += 0.35
"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.grip += ga
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)
static func _reset_for_test() -> void:
_data = {"bucks": 0, "cars": {}}
_data = {"bucks": 0, "cars": {}, "tools": [], "tunes": {}}
_loaded = true
_flush()

View File

@ -8,7 +8,7 @@ var list: ItemList
var bucks_label: Label
var desc_label: Label
var buy_btn: Button
var ids: Array = []
var entries: Array = [] # row -> {"kind": "part"|"tool"|"tune"|"header", "id": ...}
func _ready() -> void:
car_id = Garage.car_id_of(Garage.ui_stats_path)
@ -70,37 +70,98 @@ func _ready() -> void:
row.add_child(back)
_refresh()
if list.item_count > 0:
list.select(0)
_on_select(0)
for i in list.item_count: # row 0 is a section header now
if list.is_item_selectable(i):
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:
var keep := list.get_selected_items()
list.clear()
ids.clear()
entries.clear()
bucks_label.text = "BUCKS: $%d" % Garage.bucks()
_header("--- BOLT-ON PARTS (this car) ---")
for id in Garage.CATALOGUE:
var c: Dictionary = Garage.CATALOGUE[id]
var tag := "[FITTED]" if Garage.has(car_id, id) else "$%d" % c["price"]
list.add_item("%-26s %s" % [c["name"], tag])
ids.append(id)
if not keep.is_empty() and keep[0] < list.item_count:
list.add_item("%-28s %s" % [c["name"], tag])
entries.append({"kind": "part", "id": id})
_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])
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:
var c: Dictionary = Garage.CATALOGUE[ids[i]]
desc_label.text = c["desc"]
buy_btn.disabled = Garage.has(car_id, ids[i]) or Garage.bucks() < int(c["price"])
buy_btn.text = "FITTED" if Garage.has(car_id, ids[i]) else "BUY ($%d)" % c["price"]
var info := _entry_info(i)
if info.is_empty():
return
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:
var sel := list.get_selected_items()
if sel.is_empty():
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")
_refresh()
_on_select(sel[0])
if sel[0] < list.item_count:
_on_select(sel[0])
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):

View File

@ -42,6 +42,15 @@ func _run() -> void:
"wing added no grip")
_check(gg.car.stats.resource_path == "", "upgrades mutated the SHARED stats resource")
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
# cruise: throttle 3s, car must move