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")