diff --git a/README.md b/README.md index e309ba5..91b832b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/shot_garage_f.png.import b/shot_garage_f.png.import new file mode 100644 index 0000000..1cc1d71 --- /dev/null +++ b/shot_garage_f.png.import @@ -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 diff --git a/shot_garage_r.png.import b/shot_garage_r.png.import new file mode 100644 index 0000000..4642482 --- /dev/null +++ b/shot_garage_r.png.import @@ -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 diff --git a/src/car.gd b/src/car.gd index 91aa1ab..21381c9 100644 --- a/src/car.gd +++ b/src/car.gd @@ -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) diff --git a/src/garage.gd b/src/garage.gd index 42a53ae..2bcc50a 100644 --- a/src/garage.gd +++ b/src/garage.gd @@ -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//stats.tres -> 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() diff --git a/src/garage_ui.gd b/src/garage_ui.gd index 95ee276..07c58ad 100644 --- a/src/garage_ui.gd +++ b/src/garage_ui.gd @@ -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"): diff --git a/tests/smoke.gd b/tests/smoke.gd index 676429c..c41a564 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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