From a8fed95d42ea0b5f4a54a1a89f915dfe12628391 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 31 Jul 2026 12:26:57 +1000 Subject: [PATCH] Mechanical carnage: burnouts, grenaded gearboxes, blown head gaskets The drag strip now has consequences on both sides of the green. Burnouts: DRIFT+GAS in the staging box lights up the rears -- fat white FLUX-free tyre smoke (new Fx.burnout emitter), fake rear-wheel spin via car.spin_override, the skid loop singing while stationary -- and builds TYRE TEMP. Warm tyres hook up off the line, cold ones spin the launch away: a traction factor (0.78 cold to 1.20 hot) applies for the first 150 m. The burnout holds the car with power_scale 0, so it can never red-light you; releasing into a creep still can, as is right. Gearbox: blown shifts are now strikes. Three in one run and the box grenades -- a panel bangs off the back (car.mech_pop), the lever goes dead (stuck in whatever gear you murdered it into, 55% drive), and the car starts LEAKING: oil patches spawn behind it every 7 m of travel and sit on the strip for 30 s. Everyone can see what you did. Coolant: riding the limiter cooks ENG TEMP at 0.30/s (boost adds a little; driving clean cools). At 100% the head gasket lets go: a steam jet out of the bonnet (Fx.steamer), 72% power for the rest of the run, and COOLANT EVERYWHERE. Sitting on the redline refusing to shift is now a self-solving problem. HUD shows tyre temp in staging and OVERHEAT/BOX GRENADED/GASKET STEAMING flags in the run; the result screen commemorates both failures. Smoke suite: burnout warms without tripping the beams, three panic shifts grenade the box and lock the gear, the leak flag sets, and a forced overtemp pops the gasket. Next: these failures become persistent per-car faults you repair in the shed with a drag/click/twist minigame and the Cashies code reader. Co-Authored-By: Claude Opus 5 --- push16.log | 10 ++++++++ src/car.gd | 56 ++++++++++++++++++++++++++++++++++++++-- src/fx.gd | 51 +++++++++++++++++++++++++++++++++++++ src/game.gd | 69 ++++++++++++++++++++++++++++++++++++++++++++------ tests/smoke.gd | 31 +++++++++++++++++++++++ 5 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 push16.log diff --git a/push16.log b/push16.log new file mode 100644 index 0000000..3d0b8a7 --- /dev/null +++ b/push16.log @@ -0,0 +1,10 @@ +Enumerating objects: 37, done. +Counting objects: 2% (1/37) Counting objects: 5% (2/37) Counting objects: 8% (3/37) Counting objects: 10% (4/37) Counting objects: 13% (5/37) Counting objects: 16% (6/37) Counting objects: 18% (7/37) Counting objects: 21% (8/37) Counting objects: 24% (9/37) Counting objects: 27% (10/37) Counting objects: 29% (11/37) Counting objects: 32% (12/37) Counting objects: 35% (13/37) Counting objects: 37% (14/37) Counting objects: 40% (15/37) Counting objects: 43% (16/37) Counting objects: 45% (17/37) Counting objects: 48% (18/37) Counting objects: 51% (19/37) Counting objects: 54% (20/37) Counting objects: 56% (21/37) Counting objects: 59% (22/37) Counting objects: 62% (23/37) Counting objects: 64% (24/37) Counting objects: 67% (25/37) Counting objects: 70% (26/37) Counting objects: 72% (27/37) Counting objects: 75% (28/37) Counting objects: 78% (29/37) Counting objects: 81% (30/37) Counting objects: 83% (31/37) Counting objects: 86% (32/37) Counting objects: 89% (33/37) Counting objects: 91% (34/37) Counting objects: 94% (35/37) Counting objects: 97% (36/37) Counting objects: 100% (37/37) Counting objects: 100% (37/37), done. +Delta compression using up to 32 threads +Compressing objects: 4% (1/24) Compressing objects: 8% (2/24) Compressing objects: 12% (3/24) Compressing objects: 16% (4/24) Compressing objects: 20% (5/24) Compressing objects: 25% (6/24) Compressing objects: 29% (7/24) Compressing objects: 33% (8/24) Compressing objects: 37% (9/24) Compressing objects: 41% (10/24) Compressing objects: 45% (11/24) Compressing objects: 50% (12/24) Compressing objects: 54% (13/24) Compressing objects: 58% (14/24) Compressing objects: 62% (15/24) Compressing objects: 66% (16/24) Compressing objects: 70% (17/24) Compressing objects: 75% (18/24) Compressing objects: 79% (19/24) Compressing objects: 83% (20/24) Compressing objects: 87% (21/24) Compressing objects: 91% (22/24) Compressing objects: 95% (23/24) Compressing objects: 100% (24/24) Compressing objects: 100% (24/24), done. +Writing objects: 3% (1/26) Writing objects: 7% (2/26) Writing objects: 11% (3/26) Writing objects: 15% (4/26) Writing objects: 19% (5/26) Writing objects: 23% (6/26) Writing objects: 26% (7/26) Writing objects: 34% (9/26) Writing objects: 38% (10/26) Writing objects: 42% (11/26) Writing objects: 46% (12/26) Writing objects: 50% (13/26) Writing objects: 53% (14/26) Writing objects: 61% (16/26) Writing objects: 65% (17/26) Writing objects: 69% (18/26) Writing objects: 73% (19/26) Writing objects: 76% (20/26) Writing objects: 80% (21/26) Writing objects: 84% (22/26) Writing objects: 88% (23/26) Writing objects: 92% (24/26) Writing objects: 96% (25/26) Writing objects: 100% (26/26) Writing objects: 100% (26/26), 312.96 KiB | 39.12 MiB/s, done. +Total 26 (delta 13), reused 0 (delta 0), pack-reused 0 (from 0) +remote: . Processing 1 references +remote: Processed 1 references in total +To ssh://100.71.119.27:222/monster/burnoutinfinityshitbox.git + fdbb921..17aabd7 main -> main diff --git a/src/car.gd b/src/car.gd index 21381c9..32c716d 100644 --- a/src/car.gd +++ b/src/car.gd @@ -43,6 +43,9 @@ var boosting := false var slip := 0.0 # sideways m/s var wrecked := false var spawn_xform: Transform3D +var spin_override := 0.0 # extra rear-wheel spin (rad/s) for burnouts +var burnout_on := false # keeps the skid loop singing while stationary +var leaking := false # a grenaded gearbox drips oil patches behind var _prev_lv := Vector3.ZERO var _impact_cd := 0.0 @@ -54,6 +57,9 @@ var _snd_boost: AudioStreamPlayer3D var _vis_wheels: Array = [] # {node, front, radius, spin} var _smoke: GPUParticles3D var _dust: GPUParticles3D +var _burn: GPUParticles3D # burnout tyre smoke, made lazily on first use +var _steam: GPUParticles3D # head-gasket steam, likewise +var _leak_accum := 0.0 @onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR] @@ -158,7 +164,8 @@ func _physics_process(delta: float) -> void: + (0.25 if boosting else 0.0) _snd_eng.volume_db = (-9.0 if is_player else -17.0) + absf(throttle_in) * 4.0 if _snd_skid: - var skidding := grounded >= 2 and absf(slip) > 4.0 and linear_velocity.length() > 8.0 + var skidding := (grounded >= 2 and absf(slip) > 4.0 and linear_velocity.length() > 8.0) \ + or burnout_on if skidding and not _snd_skid.playing: _snd_skid.play() elif not skidding and _snd_skid.playing: @@ -173,8 +180,14 @@ func _physics_process(delta: float) -> void: var fwd_speed := -global_basis.z.dot(linear_velocity) for w in _vis_wheels: - w["spin"] = fposmod(w["spin"] + fwd_speed / w["radius"] * delta, TAU) + var rate: float = fwd_speed / w["radius"] + (0.0 if w["front"] else spin_override) + w["spin"] = fposmod(w["spin"] + rate * delta, TAU) (w["node"] as Node3D).rotation = Vector3(w["spin"], steer if w["front"] else 0.0, 0.0) + if leaking: + _leak_accum += linear_velocity.length() * delta + if _leak_accum > 7.0: + _leak_accum = 0.0 + _drip() if _smoke: _smoke.emitting = wrecked or (_deform != null and _deform.damage > 0.7) if _dust: @@ -190,6 +203,45 @@ func _physics_process(delta: float) -> void: func earn_boost(amount: float) -> void: boost = minf(boost + amount, boost_max) +func set_burnout(on: bool) -> void: + ## Lighting up the rears in the box: fake wheel spin, white smoke, skid loop. + burnout_on = on + spin_override = 34.0 if on else 0.0 + if on and _burn == null: + _burn = Fx.burnout(self, Vector3(0, 0.15, stats.length * 0.42)) + if _burn: + _burn.emitting = on + +func set_steam(on: bool) -> void: + ## The head gasket has left the building; the bonnet announces it. + if on and _steam == null: + _steam = Fx.steamer(self, Vector3(0, 0.55, -stats.length * 0.38)) + if _steam: + _steam.emitting = on + +func mech_pop() -> void: + ## A mechanical letting-go (grenaded gearbox and friends): one panel off + ## the back, sparks, a low clunk -- damage without a wreck. + Fx.sparks(self, 30) + _shed_panel(Vector3(0, 0, 1)) + Sfx.shot(self, "clunk", 0.0, 0.6) + +func _drip() -> void: + # an oil patch on the road behind us; the strip remembers what you did + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = Vector3(0.55, 0.012, 0.95) + var m := StandardMaterial3D.new() + m.albedo_color = Color(0.05, 0.045, 0.03) + m.roughness = 0.25 + m.metallic = 0.4 + bm.material = m + mi.mesh = bm + get_parent().add_child(mi) + mi.global_position = global_position + Vector3(0, -0.52, 0) + mi.rotation.y = rotation.y + get_tree().create_timer(30.0).timeout.connect(mi.queue_free) + func _impact(jolt: Vector3) -> void: var dv := jolt.length() crashed.emit(dv) diff --git a/src/fx.gd b/src/fx.gd index c4cf105..86a08d8 100644 --- a/src/fx.gd +++ b/src/fx.gd @@ -172,3 +172,54 @@ static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D: p.position = offset parent.add_child(p) return p + +static var _burnout_mat: ParticleProcessMaterial +static var _steam_mat: ParticleProcessMaterial + +static func burnout(parent: Node3D, offset: Vector3) -> GPUParticles3D: + ## The good stuff: fat white tyre smoke for the burnout box. Caller toggles. + _init_shared() + if _burnout_mat == null: + _burnout_mat = ParticleProcessMaterial.new() + _burnout_mat.direction = Vector3(0, 1, 0) + _burnout_mat.spread = 70.0 + _burnout_mat.initial_velocity_min = 1.5 + _burnout_mat.initial_velocity_max = 3.6 + _burnout_mat.gravity = Vector3(0, 1.4, 0) + _burnout_mat.scale_min = 1.2 + _burnout_mat.scale_max = 2.6 + _burnout_mat.color_ramp = _ramp([Color(0.92, 0.92, 0.92, 0.0), + Color(0.88, 0.88, 0.88, 0.6), Color(0.80, 0.80, 0.80, 0.0)]) + var p := GPUParticles3D.new() + p.process_material = _burnout_mat + p.draw_pass_1 = _smoke_draw + p.amount = 60 + p.lifetime = 1.7 + p.emitting = false + p.position = offset + parent.add_child(p) + return p + +static func steamer(parent: Node3D, offset: Vector3) -> GPUParticles3D: + ## Head-gasket steam: a thin hot jet out of the bonnet. Caller toggles. + _init_shared() + if _steam_mat == null: + _steam_mat = ParticleProcessMaterial.new() + _steam_mat.direction = Vector3(0, 1, 0) + _steam_mat.spread = 12.0 + _steam_mat.initial_velocity_min = 3.5 + _steam_mat.initial_velocity_max = 5.5 + _steam_mat.gravity = Vector3(0, 2.5, 0) + _steam_mat.scale_min = 0.5 + _steam_mat.scale_max = 1.1 + _steam_mat.color_ramp = _ramp([Color(0.95, 0.96, 0.98, 0.0), + Color(0.93, 0.94, 0.96, 0.55), Color(0.9, 0.9, 0.92, 0.0)]) + var p := GPUParticles3D.new() + p.process_material = _steam_mat + p.draw_pass_1 = _smoke_draw + p.amount = 34 + p.lifetime = 1.1 + p.emitting = false + p.position = offset + parent.add_child(p) + return p diff --git a/src/game.gd b/src/game.gd index 82d97bb..c628940 100644 --- a/src/game.gd +++ b/src/game.gd @@ -114,6 +114,12 @@ var rival_et := 0.0 # rival's ET once it crosses; 0 = hasn't yet var rival_wait := 0.0 # grace timer after the rival crosses first var _tree_bulbs := {} # "TreeGreen" etc -> MeshInstance3D (dragway only) var _launch_pos := Vector3.ZERO +## Mechanical sympathy: none of these reset until the scene does. +var tyre_temp := 0.0 # 0 cold .. 1 hot, built in the burnout box +var eng_temp := 0.0 # limiter abuse; at 1.0 the head gasket lets go +var box_strikes := 0 # blown shifts this run; 3 grenades the gearbox +var box_blown := false +var gasket_blown := false var derby: Array[Cop] = [] # arena opponents; Cop AI already hunts a target var derby_centre: Vector3 @@ -787,27 +793,53 @@ func _drag_tick(delta: float) -> void: if gear <= DRAG_GEARS: revs = minf(revs + DRAG_REV_RATE[gear] * delta * (1.0 if bog == 0.0 else 0.25), 1.0) - if Input.is_action_just_pressed("boost"): + if Input.is_action_just_pressed("boost") and not box_blown: if revs >= SHIFT_LO and revs < REDLINE: clean_shifts += 1 Sfx.shot(car, "clunk", -2.0) _msg("CLEAN SHIFT", 34) else: blown_shifts += 1 + box_strikes += 1 bog = 0.9 Sfx.shot(car, "bog", -2.0) - _msg("MISSED IT", 34) + if box_strikes >= 3: + # third mistreated shift: the box keeps what's in it + box_blown = true + car.mech_pop() + car.leaking = true + _msg("GEARBOX GRENADED!\nSTUCK IN %d, LEAKING" % mini(gear + 1, DRAG_GEARS), 44) + else: + _msg("MISSED IT", 34) gear += 1 revs = 0.28 if gear <= DRAG_GEARS else 1.0 elif revs >= REDLINE: bog = maxf(bog, 0.35) # sitting on the limiter goes nowhere + # coolant: riding the limiter cooks the engine; at 1.0 the head gasket goes + if gear <= DRAG_GEARS and revs >= REDLINE: + eng_temp += delta * 0.30 + elif car.boosting: + eng_temp = minf(eng_temp + delta * 0.06, 0.99 if not gasket_blown else 1.0) + else: + eng_temp = maxf(eng_temp - delta * 0.04, 0.0) + if eng_temp >= 1.0 and not gasket_blown: + gasket_blown = true + car.set_steam(true) + Sfx.shot(car, "bog", -2.0, 0.55) + _msg("HEAD GASKET!\nCOOLANT EVERYWHERE", 44) + # drive comes from the box, not the throttle: clean shifts pay, bogging hurts var tune := 1.0 + clean_shifts * 0.07 - blown_shifts * 0.05 + # launch traction: warm tyres hook up, cold ones spin it away off the line + var gone := Vector3(car.global_position.x, 0, car.global_position.z).distance_to( + Vector3(_launch_pos.x, 0, _launch_pos.z)) + var traction := (0.78 + 0.42 * tyre_temp) if gone < 150.0 else 1.0 # clamp the gear term: gear runs to DRAG_GEARS+1 as the "past top" sentinel, # and un-clamped that paid 6/5 -- more grunt than top gear for shifting into nothing car.power_scale = (0.35 if bog > 0.0 else tune) \ - * (0.75 + 0.25 * mini(gear, DRAG_GEARS) / float(DRAG_GEARS)) + * (0.75 + 0.25 * mini(gear, DRAG_GEARS) / float(DRAG_GEARS)) \ + * traction * (0.55 if box_blown else 1.0) * (0.72 if gasket_blown else 1.0) var flat := Vector3(car.global_position.x, 0.0, car.global_position.z) var left := flat.distance_to(drag_end) @@ -816,12 +848,19 @@ func _drag_tick(delta: float) -> void: if rf.distance_to(drag_end) < 12.0: rival_et = drag_t var pb := Garage.drag_pb(Garage.car_id_of(car_path)) - info_label.text = "VS %s%s\nGEAR %d %s %dm %.1fs" % [ + var mech := "" + if box_blown: + mech += " BOX GRENADED" + if gasket_blown: + mech += " GASKET STEAMING" + elif eng_temp > 0.6: + mech += " ENG %d%%!" % int(eng_temp * 100) + info_label.text = "VS %s%s\nGEAR %d %s %dm %.1fs%s" % [ DRAG_LADDER[drag_rung_now]["name"], (" | PB %.2f" % pb) if pb > 0.0 else "", mini(gear, DRAG_GEARS), ("BOGGED" if bog > 0.0 else ("SHIFT!" if revs >= SHIFT_LO else "%d%%" % int(revs * 100))), - int(left), drag_t] + int(left), drag_t, mech] # the event ends when YOU cross: losing to the rival mustn't rob you of your # ET and PB. If the rival's crossed and you dawdle 10 s, it gets called. if rival_et > 0.0: @@ -838,7 +877,16 @@ func _stage_tick(delta: float) -> void: stage_t += delta if not rivals.is_empty(): rivals[0].power_scale = 0.0 - car.power_scale = 0.8 + # the burnout box: DRIFT+GAS lights up the rears -- smoke pours, tyres warm, + # and the handbrake holds you off the beams so it can't red-light you + var burning := Input.is_action_pressed("drift") \ + and Input.get_axis("brake", "throttle") > 0.2 and not car.wrecked + car.set_burnout(burning) + if burning: + car.power_scale = 0.0 + tyre_temp = minf(tyre_temp + delta * 0.30, 1.0) + else: + car.power_scale = 0.8 if was <= 0.0: _bulb("TreeStage", true) for i in TREE_AMBERS.size(): @@ -852,10 +900,11 @@ func _stage_tick(delta: float) -> void: _msg("RED LIGHT", 44) var rung: Dictionary = DRAG_LADDER[drag_rung_now] var pb := Garage.drag_pb(Garage.car_id_of(car_path)) - info_label.text = "VS %s%s\nHOLD FOR THE GREEN..." % [rung["name"], - (" | PB %.2fs" % pb) if pb > 0.0 else ""] + info_label.text = "VS %s%s\nTYRES %d%% (DRIFT+GAS to burn out)\nHOLD FOR THE GREEN..." % [ + rung["name"], (" | PB %.2fs" % pb) if pb > 0.0 else "", int(tyre_temp * 100)] if stage_t >= TREE_GREEN: drag_state = "run" + car.set_burnout(false) for i in 3: _bulb("TreeAmber%d" % (i + 1), false) _bulb("TreeGreen", true) @@ -890,6 +939,10 @@ func _drag_finish(crossed: bool) -> void: lines.append("TOP OF THE LADDER. AGAIN?") if jumped: lines.append("(red light. we saw it. everyone saw it.)") + if box_blown: + lines.append("(the gearbox is on the strip somewhere behind you)") + if gasket_blown: + lines.append("(and the radiator's boiled itself dry)") Garage.earn(pay) lines.append("+$%d bucks | %d clean / %d blown" % [pay, clean_shifts, blown_shifts]) lines.append("R to retry, Esc for menu") diff --git a/tests/smoke.gd b/tests/smoke.gd index 34a2d0f..941a88c 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -175,6 +175,18 @@ func _run() -> void: _check(game._tree_bulbs.size() == 6, "christmas tree bulbs missing (%d)" % game._tree_bulbs.size()) await physics_frame _check(game.rivals[0].power_scale == 0.0, "rival not held at the tree") + # burnout in the box: DRIFT+GAS warms the tyres and must NOT roll the beams + Input.action_press("drift") + Input.action_press("throttle") + for i in 80: + await physics_frame + if game.drag_state != "stage": + break + _check(game.tyre_temp > 0.25, "burnout warmed nothing (%.2f)" % game.tyre_temp) + _check(not game.jumped, "burnout rolled the beams (red light)") + _check(game.car.burnout_on, "burnout fx never engaged") + Input.action_release("drift") + Input.action_release("throttle") # hold still like a disciplined launch: no red light, wait out the ambers var tree_spin := 0 while game.drag_state != "run" and tree_spin < 400: @@ -204,6 +216,25 @@ func _run() -> void: _check(game.gear == g0 + 1, "shift didn't change gear") _check(game.clean_shifts == 1 and game.blown_shifts == 0, "in-window shift scored as blown (%d/%d)" % [game.clean_shifts, game.blown_shifts]) + # grenade the box: three panic shifts nowhere near the window + for i in 3: + game.revs = 0.10 + Input.action_press("boost") + await physics_frame + Input.action_release("boost") + await physics_frame + _check(game.box_blown, "gearbox survived three blown shifts (%d strikes)" % game.box_strikes) + _check(game.car.leaking, "grenaded box isn't leaking oil") + var locked: int = game.gear + Input.action_press("boost") + await physics_frame + Input.action_release("boost") + await physics_frame + _check(game.gear == locked, "blown box still shifts") + # and cook the coolant: past 1.0 the head gasket lets go + game.eng_temp = 1.05 + await physics_frame + _check(game.gasket_blown, "overcooked engine kept its head gasket") Input.action_release("throttle") game.free() Game.level_path = levels.get("woolies_carpark", levels.values()[0])