From fd1218621b1c30004ac27620809fab940d7e9e76 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 31 Jul 2026 10:50:55 +1000 Subject: [PATCH] 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 --- README.md | 17 +++ shot_facade_f.png.import | 40 ++++++ shot_facade_r.png.import | 40 ++++++ src/car.gd | 3 + src/game.gd | 43 +++++-- src/garage.gd | 271 +++++++++++++++++++++++++++++++++++++++ src/garage.gd.uid | 1 + src/garage.tscn | 8 ++ src/garage_ui.gd | 107 ++++++++++++++++ src/garage_ui.gd.uid | 1 + src/main.gd | 10 +- src/main.tscn | 18 ++- src/sfx.gd | 15 +++ tests/smoke.gd | 22 ++++ 14 files changed, 582 insertions(+), 14 deletions(-) create mode 100644 shot_facade_f.png.import create mode 100644 shot_facade_r.png.import create mode 100644 src/garage.gd create mode 100644 src/garage.gd.uid create mode 100644 src/garage.tscn create mode 100644 src/garage_ui.gd create mode 100644 src/garage_ui.gd.uid diff --git a/README.md b/README.md index 3b83892..e309ba5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/shot_facade_f.png.import b/shot_facade_f.png.import new file mode 100644 index 0000000..e3534dd --- /dev/null +++ b/shot_facade_f.png.import @@ -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 diff --git a/shot_facade_r.png.import b/shot_facade_r.png.import new file mode 100644 index 0000000..e2c072c --- /dev/null +++ b/shot_facade_r.png.import @@ -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 diff --git a/src/car.gd b/src/car.gd index 1a0e76c..91aa1ab 100644 --- a/src/car.gd +++ b/src/car.gd @@ -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: diff --git a/src/game.gd b/src/game.gd index 7e0f0ec..bcc7440 100644 --- a/src/game.gd +++ b/src/game.gd @@ -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 diff --git a/src/garage.gd b/src/garage.gd new file mode 100644 index 0000000..42a53ae --- /dev/null +++ b/src/garage.gd @@ -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//stats.tres -> + 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() diff --git a/src/garage.gd.uid b/src/garage.gd.uid new file mode 100644 index 0000000..3ea0543 --- /dev/null +++ b/src/garage.gd.uid @@ -0,0 +1 @@ +uid://juoh4wagvdxs diff --git a/src/garage.tscn b/src/garage.tscn new file mode 100644 index 0000000..07b0db8 --- /dev/null +++ b/src/garage.tscn @@ -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") diff --git a/src/garage_ui.gd b/src/garage_ui.gd new file mode 100644 index 0000000..95ee276 --- /dev/null +++ b/src/garage_ui.gd @@ -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") diff --git a/src/garage_ui.gd.uid b/src/garage_ui.gd.uid new file mode 100644 index 0000000..23d34f5 --- /dev/null +++ b/src/garage_ui.gd.uid @@ -0,0 +1 @@ +uid://ce38qk1pa20xq diff --git a/src/main.gd b/src/main.gd index 1c263d2..6171a5a 100644 --- a/src/main.gd +++ b/src/main.gd @@ -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(): diff --git a/src/main.tscn b/src/main.tscn index 5eca3d2..7cb55c7 100644 --- a/src/main.tscn +++ b/src/main.tscn @@ -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 diff --git a/src/sfx.gd b/src/sfx.gd index 60933e6..c56ff47 100644 --- a/src/sfx.gd +++ b/src/sfx.gd @@ -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) diff --git a/tests/smoke.gd b/tests/smoke.gd index dd9170e..676429c 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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")