The Shed: workshop tools unlock tune-ups (second progression path)
Six tools, bought once, global: SUPERCHEAP WHEEL RAMPS, CASHIES CODE READER (one previous owner, screen cracked), ALDI SOCKET SET, BUNNINGS AIR COMPRESSOR, CASHIES CHAIN HOIST (on wheels, definitely not stolen), SERVO ADDITIVE CRATE. Each unlocks a per-car tune-up at labour prices: dodgy alignment (+0.4 grip), check engine delete (+6% power, the light WAS the problem), 60 PSI in every tyre, head skim & lumpy cam (+12%), octane booster habit (+0.25 boost), and RETORQUE EVERYTHING -- the one effect parts can't buy: it raises the car's panel-shed and wreck thresholds, which are now per-instance vars instead of consts. Parts buy bling; the shed buys labour. Garage screen grows three sections (parts / shed / tune-ups) with locked states showing which tool a tune needs. Save schema extends compatibly. Smoke covers the tool gate, double-sell refusal and panel hardening on a live car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fd1218621b
commit
117d950675
21
README.md
21
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
|
||||
|
||||
|
||||
40
shot_garage_f.png.import
Normal file
40
shot_garage_f.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dr7aglv20utx3"
|
||||
path="res://.godot/imported/shot_garage_f.png-aa2f56aea17c124e5ede0f7b9bc1c349.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_garage_f.png"
|
||||
dest_files=["res://.godot/imported/shot_garage_f.png-aa2f56aea17c124e5ede0f7b9bc1c349.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
40
shot_garage_r.png.import
Normal file
40
shot_garage_r.png.import
Normal file
@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bmlq6d63wenbp"
|
||||
path="res://.godot/imported/shot_garage_r.png-524c5e48bfb09463cc90db7e4ba2f22b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://shot_garage_r.png"
|
||||
dest_files=["res://.godot/imported/shot_garage_r.png-524c5e48bfb09463cc90db7e4ba2f22b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
11
src/car.gd
11
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)
|
||||
|
||||
101
src/garage.gd
101
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/<id>/stats.tres -> <id>
|
||||
return stats_path.get_base_dir().get_file()
|
||||
@ -116,8 +196,10 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
|
||||
## Player only: traffic and parked cars stay stock -- your mods are YOURS.
|
||||
if not is_player:
|
||||
return
|
||||
var ups := owned(car_id_of(stats_path))
|
||||
if ups.is_empty():
|
||||
var cid := car_id_of(stats_path)
|
||||
var ups := owned(cid)
|
||||
var tns := tunes(cid)
|
||||
if ups.is_empty() and tns.is_empty():
|
||||
return
|
||||
var st = car.get("stats")
|
||||
if st != null:
|
||||
@ -134,6 +216,17 @@ static func apply(car: Node3D, stats_path: String, is_player: bool) -> void:
|
||||
"spoiler": ga += 0.6
|
||||
"exhaust": ba += 0.35
|
||||
"neons": ba += 0.2
|
||||
for t in tns:
|
||||
match t:
|
||||
"align": ga += 0.4
|
||||
"cel": pm += 0.06
|
||||
"psi": ga += 0.3; pm += 0.02
|
||||
"headskim": pm += 0.12
|
||||
"octane": ba += 0.25
|
||||
"retorque":
|
||||
# panels cling on: thresholds live on the car, not the stats
|
||||
car.set("part_dv", float(car.get("part_dv")) + 2.0)
|
||||
car.set("wreck_dv", float(car.get("wreck_dv")) + 1.5)
|
||||
st.engine_power *= pm
|
||||
st.grip += ga
|
||||
car.set("stats", st)
|
||||
@ -266,6 +359,6 @@ static func _bolt_visuals(car: Node3D, ups: Array) -> void:
|
||||
|
||||
## test hook: wipe state (used by the smoke suite, never the game)
|
||||
static func _reset_for_test() -> void:
|
||||
_data = {"bucks": 0, "cars": {}}
|
||||
_data = {"bucks": 0, "cars": {}, "tools": [], "tunes": {}}
|
||||
_loaded = true
|
||||
_flush()
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user