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:
parent
9e98d5ba65
commit
fd1218621b
17
README.md
17
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
|
||||
|
||||
40
shot_facade_f.png.import
Normal file
40
shot_facade_f.png.import
Normal 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
40
shot_facade_r.png.import
Normal 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
|
||||
@ -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:
|
||||
|
||||
43
src/game.gd
43
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
|
||||
|
||||
271
src/garage.gd
Normal file
271
src/garage.gd
Normal 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
1
src/garage.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://juoh4wagvdxs
|
||||
8
src/garage.tscn
Normal file
8
src/garage.tscn
Normal 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
107
src/garage_ui.gd
Normal 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
1
src/garage_ui.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ce38qk1pa20xq
|
||||
10
src/main.gd
10
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():
|
||||
|
||||
@ -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
|
||||
|
||||
15
src/sfx.gd
15
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)
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user