The Garage: shit-tier upgrade economy

Earn bucks from every mode (takedowns, outruns, escapes, derby kills, medals,
a cut of crash damage) and spend them on performance parts of deeply
questionable provenance: K-MART MEGA WING, GO-FASTER STRIPES, SERVO FLAME KIT,
BLINGY MAGS, SERVO NEONS, BUNNINGS DRAINPIPE, TURBO THRU THE BONNET. Every
part gives a real stat bump AND bolts a procedural eyesore onto the car, sized
off the model AABB so the same tat fits a BUBBLA or a Kingswood. Fully kitted:
+35% power, +1.1 grip, +0.55 boost.

Per-car, persistent (user://garage.save), player-only -- stats apply to a
DUPLICATE of the shared CarStats so tuning your Lazza doesn't secretly tune
every Lazza in the city, and the save path is redirectable so the smoke test
can never wipe real progression. Bolt-ons attach after the deformer snapshot:
the wing survives every crash, as is right and proper.

GARAGE button + bucks readout in the menu; shop screen with prices, blurbs and
FITTED states. Smoke covers the earn/buy/duplicate-stats roundtrip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-31 10:50:55 +10:00
parent 9e98d5ba65
commit fd1218621b
14 changed files with 582 additions and 14 deletions

View File

@ -82,6 +82,23 @@ The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and
things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x).
Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo.
## The Garage
Earn **bucks** playing (takedowns $150, outrun wins $400, escapes $500, derby
kills $100, medals up to $1000, plus a cut of crash-mode damage) and spend them
in the GARAGE on performance parts of deeply questionable provenance: the
K-MART MEGA WING, GO-FASTER STRIPES, the SERVO FLAME KIT, BLINGY MAGS, SERVO
NEONS, the BUNNINGS DRAINPIPE and the TURBO THRU THE BONNET. Every part gives a
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.
## Crash deformation and damage
[src/deform.gd](src/deform.gd) implements the control-hull model from the design

40
shot_facade_f.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://3n2kvgojbgwf"
path="res://.godot/imported/shot_facade_f.png-9fa96bfde3dcbd65751693978b15004c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_facade_f.png"
dest_files=["res://.godot/imported/shot_facade_f.png-9fa96bfde3dcbd65751693978b15004c.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_facade_r.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cjfi57xn0na3h"
path="res://.godot/imported/shot_facade_r.png-2e6fbc4bb08a15dde0b9e54f90566d31.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_facade_r.png"
dest_files=["res://.godot/imported/shot_facade_r.png-2e6fbc4bb08a15dde0b9e54f90566d31.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

@ -92,6 +92,9 @@ func _ready() -> void:
_snd_boost = Sfx.looper(self, "boost", -8.0)
_smoke = Fx.smoker(self, Vector3(0, 0.6, -stats.length * 0.32))
_dust = Fx.duster(self, Vector3(0, 0.1, stats.length * 0.42))
# bolt on the player's garage tat last, so the deformer never snapshots it:
# the K-Mart wing survives every crash, as is right and proper
Garage.apply(self, stats.resource_path, is_player)
func _physics_process(delta: float) -> void:
if is_player:

View File

@ -693,7 +693,8 @@ func _derby_down(d: Cop) -> void:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("WRECKED 'EM! x%d" % derby_kills, 46)
Garage.earn(100)
_msg("WRECKED 'EM! x%d +$100" % derby_kills, 46)
func _derby_tick(delta: float) -> void:
var alive: Array[Cop] = []
@ -727,7 +728,8 @@ func _derby_tick(delta: float) -> void:
_msg("RAN AWAY\nR to retry, Esc for menu", 44, true)
elif alive.is_empty():
over = true
_msg("LAST ONE RUNNING\n%d wrecked\nR to retry, Esc for menu" % derby_kills, 48, true)
Garage.earn(800)
_msg("LAST ONE RUNNING\n%d wrecked +$800\nR to retry, Esc for menu" % derby_kills, 48, true)
func _drag_tick(delta: float) -> void:
if drag_len <= 0.0:
@ -775,8 +777,10 @@ func _drag_tick(delta: float) -> void:
over = true
car.power_scale = 1.0 # _drag_tick stops on `over`, so reset it here
var won := left <= rival_left
_msg("%s %.2fs\n%d clean / %d blown\nR to retry, Esc for menu"
% ["WON" if won else "LOST", drag_t, clean_shifts, blown_shifts], 44, true)
var drag_pay := 400 if won else 0
Garage.earn(drag_pay)
_msg("%s %.2fs +$%d bucks\n%d clean / %d blown\nR to retry, Esc for menu"
% ["WON" if won else "LOST", drag_t, drag_pay, clean_shifts, blown_shifts], 44, true)
func add_heat(amount: float) -> void:
if mode != "cruise" or heat_cd > 0.0 or not cops.is_empty():
@ -814,7 +818,8 @@ func _cop_wrecked(c: Cop) -> void:
if not car.wrecked and car.global_position.distance_to(c.global_position) < 25.0:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
_msg("COP DOWN!", 48)
Garage.earn(200)
_msg("COP DOWN! +$200", 48)
func _end_pursuit(escaped: bool) -> void:
for c in cops:
@ -829,7 +834,8 @@ func _end_pursuit(escaped: bool) -> void:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("LOST 'EM! x%d" % escapes, 52)
Garage.earn(500)
_msg("LOST 'EM! x%d +$500" % escapes, 52)
else:
busts += 1
Sfx.shot(car, "sting_lose", -4.0)
@ -974,7 +980,8 @@ func _outrun_end(won: bool, why: String) -> void:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) # takedown-tier reward
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("OUTRUN WON! x%d%s" % [outruns_won, tail], 52)
Garage.earn(400)
_msg("OUTRUN WON! x%d +$400%s" % [outruns_won, tail], 52)
else:
Sfx.shot(car, "sting_lose", -4.0)
_msg("OUTRUN LOST%s" % tail, 44)
@ -1040,11 +1047,12 @@ func _rival_wrecked(r: Rival) -> void:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
Garage.earn(150)
if mode == "rage" and not over:
rage_downs += 1
_msg("TAKEDOWN! x%d" % rage_downs, 56)
_msg("TAKEDOWN! x%d +$150" % rage_downs, 56)
else:
_msg("TAKEDOWN!", 56)
_msg("TAKEDOWN! +$150", 56)
func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
if is_instance_valid(t):
@ -1110,13 +1118,18 @@ func _jct_tally() -> void:
over = true
var t: Array = junction.get("targets", [15000, 35000, 60000])
var medal := "NO MEDAL, MATE"
var payout := int(score / 50.0)
if score >= float(t[2]):
medal = "GOLD!"
payout += 1000
elif score >= float(t[1]):
medal = "SILVER"
payout += 600
elif score >= float(t[0]):
medal = "BRONZE"
_msg("DAMAGE: $%d\n%s\nR to retry, Esc for menu" % [int(score), medal], 48, true)
payout += 300
Garage.earn(payout)
_msg("DAMAGE: $%d\n%s +$%d bucks\nR to retry, Esc for menu" % [int(score), medal, payout], 48, true)
func _msg(text: String, size: int, sticky := false) -> void:
# a sticky message is a result screen; don't let a stray "NEAR MISS" from a
@ -1144,7 +1157,9 @@ func _process(delta: float) -> void:
info_label.text = "$%d %d s" % [int(score), ceili(time_left)]
if time_left <= 0.0:
over = true
_msg("DAMAGE: $%d\nR to retry, Esc for menu" % int(score), 48, true)
var crash_pay := int(score / 50.0)
Garage.earn(crash_pay)
_msg("DAMAGE: $%d +$%d bucks\nR to retry, Esc for menu" % [int(score), crash_pay], 48, true)
if mode == "junction" and not over:
time_left -= delta
info_label.text = "$%d %d s" % [int(score), ceili(time_left)]
@ -1165,7 +1180,11 @@ func _process(delta: float) -> void:
elif rage_downs >= RAGE_TARGETS[0]:
medal = "BRONZE"
Sfx.shot(car, "sting_win" if rage_downs >= RAGE_TARGETS[0] else "sting_lose", -4.0)
_msg("%d TAKEDOWNS\n%s\nR to retry, Esc for menu" % [rage_downs, medal], 48, true)
var rage_pay := 300 if rage_downs >= RAGE_TARGETS[0] else 0
rage_pay = 600 if rage_downs >= RAGE_TARGETS[1] else rage_pay
rage_pay = 1000 if rage_downs >= RAGE_TARGETS[2] else rage_pay
Garage.earn(rage_pay)
_msg("%d TAKEDOWNS\n%s +$%d bucks\nR to retry, Esc for menu" % [rage_downs, medal, rage_pay], 48, true)
# drag and derby both tell you "R to retry", so they have to honour it too
if mode == "race" and Input.is_action_just_pressed("reset"):
# car.gd teleports the player home on R; without resyncing the lap

271
src/garage.gd Normal file
View File

@ -0,0 +1,271 @@
class_name Garage
## The shit-tier upgrade economy. Earn bucks wrecking things, spend them on
## "performance" parts of deeply questionable provenance. Every part gives a
## real stat bump AND bolts a procedural eyesore onto the car -- progression
## you can see from across the carpark.
##
## Persistent per-car in user://garage.save (JSON): {"bucks": N, "cars":
## {car_id: [upgrade ids]}}. Stats apply to a DUPLICATE of the CarStats
## resource -- the .tres is shared by traffic/parked spawns of the same model,
## and upgrading your Lazza must not secretly tune every Lazza in the city.
const SAVE := "user://garage.save"
## Redirectable so tests point at a scratch file -- _reset_for_test() on the
## 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
## id -> {name, desc, price, effects, build (visual constructor)}
## Effects: power_mult (multiplies engine_power), grip_add, boost_add (base
## boost_max). Priced roughly by shame-to-speed ratio.
const CATALOGUE := {
"spoiler": {
"name": "K-MART MEGA WING",
"desc": "Towering plastic. Adds downforce, mostly spiritually. +0.6 grip",
"price": 500,
},
"stripes": {
"name": "GO-FASTER STRIPES",
"desc": "Twin racing stripes. Scientifically proven by blokes. +6% power",
"price": 750,
},
"flames": {
"name": "SERVO FLAME KIT",
"desc": "Stick-on flames. The car THINKS it's fast now. +8% power",
"price": 1000,
},
"neons": {
"name": "SERVO NEONS",
"desc": "Underglow. Illegal in several ways. +0.2 boost",
"price": 1200,
},
"mags": {
"name": "BLINGY MAGS",
"desc": "Chrome-look mags, two sizes too shiny. +0.5 grip, +3% power",
"price": 1500,
},
"exhaust": {
"name": "BUNNINGS DRAINPIPE",
"desc": "100mm of pure fury. The neighbours WILL know. +0.35 boost",
"price": 2000,
},
"turbo": {
"name": "TURBO THRU THE BONNET",
"desc": "A turbo. Through the bonnet. Warranty extremely void. +18% power",
"price": 3000,
},
}
static var _data := {}
static var _loaded := false
static func _load() -> void:
if _loaded:
return
_loaded = true
_data = {"bucks": 0, "cars": {}}
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"):
_data = parsed
static func _flush() -> void:
var f := FileAccess.open(save_path, FileAccess.WRITE)
f.store_string(JSON.stringify(_data))
f.close()
static func bucks() -> int:
_load()
return int(_data["bucks"])
static func earn(amount: int) -> int:
_load()
_data["bucks"] = int(_data["bucks"]) + maxi(amount, 0)
_flush()
return amount
static func owned(car_id: String) -> Array:
_load()
return _data["cars"].get(car_id, [])
static func has(car_id: String, upgrade: String) -> bool:
return upgrade in owned(car_id)
static func buy(car_id: String, upgrade: String) -> bool:
_load()
if not CATALOGUE.has(upgrade) or has(car_id, upgrade):
return false
var price: int = CATALOGUE[upgrade]["price"]
if int(_data["bucks"]) < price:
return false
_data["bucks"] = int(_data["bucks"]) - price
var lst: Array = _data["cars"].get(car_id, [])
lst.append(upgrade)
_data["cars"][car_id] = lst
_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()
## ---- application to a live car -------------------------------------------
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():
return
var st = car.get("stats")
if st != null:
st = st.duplicate()
var pm := 1.0
var ga := 0.0
var ba := 0.0
for u in ups:
match u:
"stripes": pm += 0.06
"flames": pm += 0.08
"mags": pm += 0.03; ga += 0.5
"turbo": pm += 0.18
"spoiler": ga += 0.6
"exhaust": ba += 0.35
"neons": ba += 0.2
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)
_bolt_visuals(car, ups)
static func _mat(rgba: Color, rough := 0.5, metal := 0.0, emit := Color.BLACK) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = rgba
m.roughness = rough
m.metallic = metal
if emit != Color.BLACK:
m.emission_enabled = true
m.emission = emit
m.emission_energy_multiplier = 1.4
return m
static func _box(parent: Node3D, pos: Vector3, size: Vector3, mat: Material, rot := Vector3.ZERO) -> void:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
bm.material = mat
mi.mesh = bm
mi.position = pos
mi.rotation_degrees = rot
parent.add_child(mi)
static func _bolt_visuals(car: Node3D, ups: Array) -> void:
## Everything is sized off the model's AABB so the same tat fits a BUBBLA
## or a Kingswood. Origin is the ride height, ~0.75 above ground.
var aabb := AABB()
var first := true
for mi in car.find_children("*", "MeshInstance3D", true, false):
var b: AABB = (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb()
var local := AABB(car.to_local(b.position), b.size)
aabb = local if first else aabb.merge(local)
first = false
if first:
return
var top := aabb.position.y + aabb.size.y
var rear := aabb.position.z + aabb.size.z
var front := aabb.position.z
var hw := aabb.size.x * 0.5
var plastic := _mat(Color(0.10, 0.10, 0.11), 0.35)
var chrome := _mat(Color(0.85, 0.86, 0.88), 0.15, 1.0)
var gold := _mat(Color(0.95, 0.78, 0.25), 0.2, 1.0)
if "spoiler" in ups:
# double-deck plastic monument, one size fits none
for sx in [-hw * 0.55, hw * 0.55]:
_box(car, Vector3(sx, top - 0.35, rear - 0.28), Vector3(0.07, 0.42, 0.07), plastic)
_box(car, Vector3(0, top + 0.07, rear - 0.30), Vector3(hw * 1.7, 0.05, 0.34), plastic, Vector3(-8, 0, 0))
_box(car, Vector3(0, top - 0.12, rear - 0.22), Vector3(hw * 1.5, 0.04, 0.26), plastic, Vector3(-8, 0, 0))
if "turbo" in ups:
# the promised turbo, through the promised bonnet
var bz := front + aabb.size.z * 0.22
var by := top - 0.28
var pipe := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.09
cm.bottom_radius = 0.11
cm.height = 0.34
cm.material = chrome
pipe.mesh = cm
pipe.position = Vector3(-aabb.size.x * 0.18, by + 0.17, bz)
car.add_child(pipe)
var body := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 0.13
sm.height = 0.2
sm.material = chrome
body.mesh = sm
body.position = Vector3(-aabb.size.x * 0.18, by + 0.02, bz + 0.16)
car.add_child(body)
if "stripes" in ups:
var stripe := _mat(Color(0.95, 0.95, 0.92), 0.6)
for sx in [-hw * 0.22, hw * 0.22]:
# roof run + bonnet run, floating a whisker proud of the panels
_box(car, Vector3(sx, top + 0.015, (front + rear) * 0.5 + aabb.size.z * 0.05),
Vector3(0.14, 0.012, aabb.size.z * 0.44), stripe)
_box(car, Vector3(sx, top - 0.26, front + aabb.size.z * 0.16),
Vector3(0.14, 0.012, aabb.size.z * 0.26), stripe, Vector3(7, 0, 0))
if "flames" in ups:
var org := _mat(Color(0.95, 0.45, 0.05), 0.5, 0.0, Color(0.6, 0.2, 0.0))
var yel := _mat(Color(0.98, 0.85, 0.2), 0.5)
for sx in [-1.0, 1.0]:
# explicit type: sx is Variant out of an untyped array literal, and
# := cannot infer through it -- this parse error hung the whole suite
var x: float = sx * (hw + 0.015)
var zbase := front + aabb.size.z * 0.18
for k in 4:
var l := 0.55 - k * 0.11
_box(car, Vector3(x, -0.12 + (k % 2) * 0.10, zbase + k * 0.34),
Vector3(0.02, 0.16 - k * 0.02, l), org, Vector3(0, 0, 0))
_box(car, Vector3(x, -0.10, zbase + 0.10), Vector3(0.022, 0.09, 0.4), yel)
if "mags" in ups:
for w in car.find_children("wheel_*", "MeshInstance3D", true, false):
var mi := w as MeshInstance3D
if mi.mesh and mi.mesh.get_surface_count() > 1:
mi.set_surface_override_material(1, gold)
if "exhaust" in ups:
var pipe := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.09
cm.bottom_radius = 0.09
cm.height = 0.5
cm.material = chrome
pipe.mesh = cm
pipe.rotation_degrees = Vector3(90, 0, 0)
pipe.position = Vector3(-aabb.size.x * 0.28, aabb.position.y + 0.10, rear - 0.05)
car.add_child(pipe)
if "neons" in ups:
var glow := _mat(Color(0.2, 0.6, 1.0), 0.4, 0.0, Color(0.15, 0.55, 1.0))
for sx in [-hw * 0.8, hw * 0.8]:
_box(car, Vector3(sx, aabb.position.y + 0.05, (front + rear) * 0.5),
Vector3(0.04, 0.02, aabb.size.z * 0.55), glow)
var ol := OmniLight3D.new()
ol.light_color = Color(0.25, 0.55, 1.0)
ol.light_energy = 1.4
ol.omni_range = 3.5
ol.position = Vector3(0, aabb.position.y - 0.1, (front + rear) * 0.5)
car.add_child(ol)
## test hook: wipe state (used by the smoke suite, never the game)
static func _reset_for_test() -> void:
_data = {"bucks": 0, "cars": {}}
_loaded = true
_flush()

1
src/garage.gd.uid Normal file
View File

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

8
src/garage.tscn Normal file
View File

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/garage_ui.gd" id="1"]
[node name="GarageUI" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")

107
src/garage_ui.gd Normal file
View File

@ -0,0 +1,107 @@
extends Control
## The shop. Pick a part, read its lie, hand over the bucks.
## Which car you're outfitting comes in via Garage.ui_stats_path (set by the menu).
var car_id := ""
var car_name := ""
var list: ItemList
var bucks_label: Label
var desc_label: Label
var buy_btn: Button
var ids: Array = []
func _ready() -> void:
car_id = Garage.car_id_of(Garage.ui_stats_path)
var st: CarStats = load(Garage.ui_stats_path)
car_name = st.display_name if st else car_id
var bg := ColorRect.new()
bg.color = Color(0.07, 0.06, 0.09)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
var box := VBoxContainer.new()
box.set_anchors_preset(Control.PRESET_FULL_RECT)
box.offset_left = 60
box.offset_top = 40
box.offset_right = -60
box.offset_bottom = -40
box.add_theme_constant_override("separation", 14)
add_child(box)
var title := Label.new()
title.text = "THE GARAGE -- %s" % car_name
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 40)
title.add_theme_color_override("font_color", Color(1, 0.82, 0.35))
box.add_child(title)
bucks_label = Label.new()
bucks_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
bucks_label.add_theme_font_size_override("font_size", 26)
box.add_child(bucks_label)
list = ItemList.new()
list.size_flags_vertical = Control.SIZE_EXPAND_FILL
list.add_theme_font_size_override("font_size", 22)
list.item_selected.connect(_on_select)
box.add_child(list)
desc_label = Label.new()
desc_label.add_theme_font_size_override("font_size", 20)
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
box.add_child(desc_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 20)
box.add_child(row)
buy_btn = Button.new()
buy_btn.text = "BUY"
buy_btn.add_theme_font_size_override("font_size", 28)
buy_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
buy_btn.pressed.connect(_on_buy)
row.add_child(buy_btn)
var back := Button.new()
back.text = "BACK"
back.add_theme_font_size_override("font_size", 28)
back.size_flags_horizontal = Control.SIZE_EXPAND_FILL
back.pressed.connect(func() -> void:
get_tree().change_scene_to_file("res://src/main.tscn"))
row.add_child(back)
_refresh()
if list.item_count > 0:
list.select(0)
_on_select(0)
func _refresh() -> void:
var keep := list.get_selected_items()
list.clear()
ids.clear()
bucks_label.text = "BUCKS: $%d" % Garage.bucks()
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.select(keep[0])
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"]
func _on_buy() -> void:
var sel := list.get_selected_items()
if sel.is_empty():
return
if Garage.buy(car_id, ids[sel[0]]):
Sfx.shot_2d("clunk")
_refresh()
_on_select(sel[0])
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file("res://src/main.tscn")

1
src/garage_ui.gd.uid Normal file
View File

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

View File

@ -39,7 +39,15 @@ func _ready() -> void:
if not level_paths.is_empty():
level_list.select(0)
mode_list.select(0)
$VBox/Start.pressed.connect(_start)
$VBox/Bucks.text = "BUCKS: $%d" % Garage.bucks()
$VBox/Buttons/Start.pressed.connect(_start)
$VBox/Buttons/Garage.pressed.connect(_garage)
func _garage() -> void:
if car_list.get_selected_items().is_empty():
return
Garage.ui_stats_path = car_paths[car_list.get_selected_items()[0]]
get_tree().change_scene_to_file("res://src/garage.tscn")
func _start() -> void:
if car_list.get_selected_items().is_empty() or level_list.get_selected_items().is_empty():

View File

@ -55,6 +55,22 @@ size_flags_horizontal = 3
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.5
[node name="Start" type="Button" parent="VBox"]
[node name="Bucks" type="Label" parent="VBox"]
text = "BUCKS: $0"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 24
theme_override_colors/font_color = Color(1, 0.82, 0.35, 1)
[node name="Buttons" type="HBoxContainer" parent="VBox"]
theme_override_constants/separation = 20
[node name="Start" type="Button" parent="VBox/Buttons"]
text = "RACE"
size_flags_horizontal = 3
size_flags_stretch_ratio = 2.0
theme_override_font_sizes/font_size = 32
[node name="Garage" type="Button" parent="VBox/Buttons"]
text = "GARAGE"
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 32

View File

@ -36,6 +36,21 @@ static func looper(parent: Node, name: String, db: float) -> AudioStreamPlayer3D
parent.add_child(p)
return p
static func shot_2d(name: String, db := -4.0) -> void:
## UI one-shot: no position, plays on the scene tree root.
var s := stream(name)
if s == null:
return
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
var p := AudioStreamPlayer.new()
p.stream = s
p.volume_db = db
tree.root.add_child(p)
p.play()
p.finished.connect(p.queue_free)
static func shot(parent: Node, name: String, db := 0.0, pitch := 1.0) -> void:
## Fire-and-forget one-shot at the parent's position.
var s := stream(name)

View File

@ -22,6 +22,28 @@ func _run() -> void:
# the carpark is the controlled arena; city levels have walls that complicate the ram test
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
# garage economy: scratch save (NEVER the real one), earn/buy roundtrip,
# stats + bolt-ons applied to the player, then reset so later stages run stock
print(" .. garage")
Garage.save_path = "user://garage_smoketest.save"
Garage._reset_for_test()
_check(Garage.bucks() == 0, "scratch garage not empty")
Garage.earn(4000)
_check(Garage.bucks() == 4000, "earn didn't bank")
_check(Garage.buy("datto_120y", "spoiler"), "couldn't buy the wing")
_check(Garage.buy("datto_120y", "turbo"), "couldn't buy the turbo")
_check(not Garage.buy("datto_120y", "spoiler"), "bought the same wing twice")
_check(not Garage.buy("datto_120y", "exhaust"), "bought a $2000 pipe with $500")
_check(Garage.bucks() == 500, "change is wrong (%d)" % Garage.bucks())
var base_power: float = (load("res://cars/datto_120y/stats.tres") as CarStats).engine_power
var gg: Game = await _boot("cruise")
_check(gg.car.stats.engine_power > base_power * 1.15, "turbo didn't boost power")
_check(gg.car.stats.grip > (load("res://cars/datto_120y/stats.tres") as CarStats).grip + 0.5,
"wing added no grip")
_check(gg.car.stats.resource_path == "", "upgrades mutated the SHARED stats resource")
gg.free()
Garage._reset_for_test() # later stages must run stock
# cruise: throttle 3s, car must move
print(" .. cruise")
var game: Game = await _boot("cruise")