Road Rage, oncoming boost, a real Crashbreaker fireball, title screen

The last two design-doc gameplay items land:
- Road Rage: 90 s, wreck as many rivals as you can. They recover on their own
  (no hold_wreck) so the supply never dries up, and a 220 m leash teleports
  strays back to the road ahead of the player. Bronze/silver/gold at 4/8/12.
- Oncoming: driving against the RacePath tangent while actually on the road
  (within 7 m of the line, above 12 m/s, heading dot < -0.55) trickles boost
  with a periodic toast. Runs in cruise, race and rage.

Crashbreaker finally looks the part: layered Fx.explosion (70-particle additive
fire bloom, rising smoke, 60-spark shower) inside a thin additive shockwave
shell -- the old opaque orange bubble drowned the fire out completely, so it
became a fast-fading front-culled dome instead. Plus a 0.8 camera shake.

Menu is a title screen: FLUX-generated key art (battered sedan, flying hubcap,
bridge, jacarandas -- the hubcap is canon now) behind a scrim, outlined golden
title. Art prompt asks for no lettering; the plate gibberish is FLUX being FLUX.

Smoke coverage for both mechanics. The oncoming test taught a harness lesson:
setting global_position on a continuous_cd body SWEEPS it there, wrecking the
car before the test began -- teleport via PhysicsServer3D.body_set_state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-30 11:39:00 +10:00
parent 3070a96b41
commit 63c32928ee
10 changed files with 333 additions and 12 deletions

View File

@ -59,6 +59,11 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C
sit on curved approaches where the launch street bends away from the intersection.
Worth hand-picking better crossings for those two.
- **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`.
- **Road Rage** — Burnout 3's headline mode: 90 seconds, wreck as many rivals
as you can. They recover and keep driving so the supply never dries up, and a
leash teleports strays back into play. Bronze/silver/gold at 4/8/12.
- **Oncoming** — in Cruise, Race and Road Rage, driving against the traffic
flow while actually on the road earns boost. The oldest deal in Burnout.
- **Drag** — NFSU2's event, our code: a straight-line run down the level's longest
genuinely straight stretch of wide road (the builder finds it and drops
`DragStart`/`DragEnd` markers, so no level needs hand-authored coordinates).
@ -289,5 +294,6 @@ forever and NC-licensed audio doesn't belong in this repo at all.
## Design doc
`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. Still to come:
oncoming-lane boost, Road Rage mode, a DEM elevation pass (so the Story Bridge deck
actually rises), and hand-picked crossings for the two scenery-crash junctions.
a DEM elevation pass (so the Story Bridge deck actually rises), hand-picked crossings
for the two scenery-crash junctions, and splitting wheel meshes out of the 13 imported
cars so they spin like the procedural fleet's do.

BIN
assets/ui/title.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dfj2nx15yao4b"
path="res://.godot/imported/title.jpg-92335cdada3b990384c48ae100e0394f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/title.jpg"
dest_files=["res://.godot/imported/title.jpg-92335cdada3b990384c48ae100e0394f.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_boom.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bsi3itd11kct2"
path="res://.godot/imported/shot_boom.png-f4ed7c2b8b65bee8e8d626deb1966c57.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_boom.png"
dest_files=["res://.godot/imported/shot_boom.png-f4ed7c2b8b65bee8e8d626deb1966c57.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_menu.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btgywefwig6wi"
path="res://.godot/imported/shot_menu.png-c61344a3ec1e3de2e32ce9670e847ca3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_menu.png"
dest_files=["res://.godot/imported/shot_menu.png-c61344a3ec1e3de2e32ce9670e847ca3.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

View File

@ -88,6 +88,51 @@ static func _init_shared() -> void:
_dust_mat.scale_max = 1.2
_dust_mat.color_ramp = _ramp([Color(0.75, 0.73, 0.70, 0.0), Color(0.72, 0.70, 0.66, 0.4), Color(0.7, 0.7, 0.7, 0.0)])
static var _fire_mat: ParticleProcessMaterial
static var _fire_draw: QuadMesh
static func explosion(parent: Node3D) -> void:
## Crashbreaker-grade fireball: additive fire core, rising smoke, spark
## shower. Layered one-shots, all self-freeing.
_init_shared()
if _fire_mat == null:
_fire_mat = ParticleProcessMaterial.new()
_fire_mat.direction = Vector3(0, 1, 0)
_fire_mat.spread = 180.0
_fire_mat.initial_velocity_min = 6.0
_fire_mat.initial_velocity_max = 17.0
_fire_mat.gravity = Vector3(0, 3.0, 0)
_fire_mat.scale_min = 0.8
_fire_mat.scale_max = 2.2
_fire_mat.damping_min = 4.0
_fire_mat.damping_max = 7.0
_fire_mat.color_ramp = _ramp([Color(1.0, 0.95, 0.6), Color(1.0, 0.55, 0.08),
Color(0.85, 0.2, 0.02, 0.6), Color(0.2, 0.05, 0.02, 0.0)])
_fire_draw = _draw_quad(2.6, Color(1, 0.6, 0.15), true)
var fire := GPUParticles3D.new()
fire.process_material = _fire_mat
fire.draw_pass_1 = _fire_draw
fire.amount = 70
fire.lifetime = 0.85
fire.one_shot = true
fire.explosiveness = 0.95
parent.add_child(fire)
fire.emitting = true
var smoke := GPUParticles3D.new()
smoke.process_material = _smoke_mat
smoke.draw_pass_1 = _smoke_draw
smoke.amount = 30
smoke.lifetime = 2.0
smoke.one_shot = true
smoke.explosiveness = 0.6
smoke.position = Vector3(0, 1.0, 0)
parent.add_child(smoke)
smoke.emitting = true
sparks(parent, 60)
parent.get_tree().create_timer(3.0).timeout.connect(func() -> void:
fire.queue_free()
smoke.queue_free())
static func sparks(parent: Node3D, amount := 24) -> void:
## One-shot burst at the parent's position, self-freeing.
_init_shared()

View File

@ -55,6 +55,17 @@ const DERBY_OUT := 8.0
const OUTRUN_GAP := 250.0 # escape distance that decides an outrun (NFSU2 rule)
const OUTRUN_TIME := 90.0
const OUTRUN_START_D := 12.0 # pull alongside within this, at speed, to trigger
## Road Rage (Burnout 3's headline mode): 90 s, wreck as many rivals as you can.
## They recover and keep driving, so the supply never dries up.
const RAGE_TIME := 90.0
const RAGE_RIVALS := 4
const RAGE_TARGETS := [4, 8, 12] # bronze / silver / gold takedowns
const RAGE_LEASH := 220.0 # rivals further than this teleport back into play
## Oncoming: driving against the traffic flow on the road earns boost -- the
## canon Burnout risk/reward loop.
const ONCOMING_EARN := 0.22 # boost per second while in the oncoming lane
const ONCOMING_LANE_D := 7.0 # how close to the road line counts as "on the road"
const ONCOMING_SPEED := 12.0
var jstate := "" # junction: "run" -> "settle" -> "done"
var breaker_used := false
@ -86,6 +97,11 @@ var outrun_t := 0.0
var outrun_cd := 0.0 # cooldown so a finished outrun doesn't instantly re-arm
var outruns_won := 0
var rage_downs := 0
var _road_off := 0.0 # player's tracked offset along the road line
var _oncoming_run := 0.0
var _oncoming_toast := 0.0
var car: Car
var cam: Camera3D
var race_path: Path3D
@ -242,6 +258,19 @@ func _ready() -> void:
car.global_position + lane)
r.progress = 0.0
rivals.append(r)
elif mode == "rage" and race_path:
time_left = RAGE_TIME
var pool := Registry.cars().values()
for i in RAGE_RIVALS:
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
r.stats = load(pool[randi() % pool.size()])
r.path = race_path
var off := race_path.curve.get_baked_length() * (i + 1) / float(RAGE_RIVALS + 1)
r.transform = _grid_xform(off)
r.progress = off
add_child(r)
r.wreck_started.connect(_rival_wrecked.bind(r))
rivals.append(r)
elif mode == "cruise" and race_path:
# roaming challengers: they lap the circuit until you pick a fight
var pool := Registry.cars().values()
@ -575,6 +604,10 @@ func _physics_process(delta: float) -> void:
_pursuit_tick(delta)
if mode == "drag" and not over:
_drag_tick(delta)
if mode == "rage" and not over:
_rage_tick()
if mode in ["cruise", "race", "rage"] and race_path and not car.wrecked:
_oncoming_tick(delta)
if mode == "derby" and not over:
_derby_tick(delta)
if mode == "junction" and jstate == "settle":
@ -782,6 +815,45 @@ func _pursuit_tick(delta: float) -> void:
elif escape_t >= ESCAPE_TIME:
_end_pursuit(true)
func _rage_tick() -> void:
# rivals recover on their own (no hold_wreck), so the supply of takedowns
# never dries up; the leash keeps the brawl within reach
var L := race_path.curve.get_baked_length()
for r in rivals:
if r.wrecked:
continue
if car.global_position.distance_to(r.global_position) > RAGE_LEASH:
var off := fposmod(_road_off + randf_range(60.0, 120.0), L)
r.global_transform = _grid_xform(off)
r.progress = off
r.linear_velocity = Vector3.ZERO
r.angular_velocity = Vector3.ZERO
func _oncoming_tick(delta: float) -> void:
## Burnout's oldest deal: drive against the flow, get paid in boost.
## Traffic runs the RacePath forward, so "oncoming" = moving against the
## local tangent while actually on the road.
var L := race_path.curve.get_baked_length()
_road_off = _nearest_off(car.global_position, _road_off, L)
var p := race_path.to_global(race_path.curve.sample_baked(_road_off))
var tangent := (race_path.to_global(race_path.curve.sample_baked(fposmod(_road_off + 2.0, L))) - p)
tangent.y = 0.0
var flat_v := car.linear_velocity
flat_v.y = 0.0
var on_road := Vector2(car.global_position.x - p.x, car.global_position.z - p.z).length() < ONCOMING_LANE_D
if on_road and flat_v.length() > ONCOMING_SPEED \
and tangent.length_squared() > 0.01 \
and flat_v.normalized().dot(tangent.normalized()) < -0.55:
car.earn_boost(ONCOMING_EARN * delta)
_oncoming_run += delta
_oncoming_toast -= delta
if _oncoming_toast <= 0.0:
_oncoming_toast = 2.0
_msg("ONCOMING +boost", 30)
else:
_oncoming_run = 0.0
_oncoming_toast = 0.0
func _outrun_tick(delta: float) -> void:
## NFSU2-style impromptu 1v1: pull alongside a roaming rival at speed and it's
## on. Straight-line distance decides WHEN it ends (so ducking down a side
@ -905,7 +977,11 @@ 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)
_msg("TAKEDOWN!", 56)
if mode == "rage" and not over:
rage_downs += 1
_msg("TAKEDOWN! x%d" % rage_downs, 56)
else:
_msg("TAKEDOWN!", 56)
func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
if is_instance_valid(t):
@ -941,23 +1017,29 @@ func _crashbreaker() -> void:
(b as RigidBody3D).apply_central_impulse(dir * strength * (b as RigidBody3D).mass)
car.apply_central_impulse(Vector3.UP * car.mass * 7.0)
Sfx.shot(car, "boom", 0.0)
Fx.explosion(car)
_shake = 0.8
_msg("CRASHBREAKER!", 52)
var boom := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 1.0
sm.height = 2.0
# thin fast shockwave shell -- the fire itself is Fx.explosion's particles,
# and the old opaque orange bubble used to drown them out completely
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.1, 0.7)
mat.albedo_color = Color(1.0, 0.55, 0.15, 0.20)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.05)
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_FRONT # inside face: reads as a wavefront
sm.material = mat
boom.mesh = sm
add_child(boom)
boom.global_position = origin
var tw := create_tween()
tw.tween_property(boom, "scale", Vector3.ONE * 14.0, 0.45)
tw.parallel().tween_property(boom, "transparency", 1.0, 0.45)
tw.tween_property(boom, "scale", Vector3.ONE * 15.0, 0.30) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(boom, "transparency", 1.0, 0.30)
tw.tween_callback(boom.queue_free)
func _jct_tally() -> void:
@ -1007,6 +1089,20 @@ func _process(delta: float) -> void:
if jstate == "run":
_msg("Didn't even crash. Cooked.", 40)
_jct_tally()
if mode == "rage" and not over:
time_left -= delta
info_label.text = "%d TAKEDOWNS %d s" % [rage_downs, ceili(time_left)]
if time_left <= 0.0:
over = true
var medal := "NO MEDAL, MATE"
if rage_downs >= RAGE_TARGETS[2]:
medal = "GOLD!"
elif rage_downs >= RAGE_TARGETS[1]:
medal = "SILVER"
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)
# 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
@ -1015,7 +1111,7 @@ func _process(delta: float) -> void:
for c in laps:
prev_off[c] = 0.0 if c == car else (c as Rival).progress
laps[car] = 0
if over and mode in ["crash", "junction", "drag", "derby"] \
if over and mode in ["crash", "junction", "drag", "derby", "rage"] \
and Input.is_action_just_pressed("reset"):
get_tree().reload_current_scene()
return

View File

@ -2,7 +2,7 @@ extends Control
## Menu: pick a car, a level and a mode from whatever exists, hit RACE.
const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"],
["drag", "Drag"], ["derby", "Derby"]]
["rage", "Road Rage"], ["drag", "Drag"], ["derby", "Derby"]]
@onready var car_list: ItemList = $VBox/HBox/Cars
@onready var level_list: ItemList = $VBox/HBox/Levels

View File

@ -1,12 +1,26 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://src/main.gd" id="1"]
[ext_resource type="Texture2D" path="res://assets/ui/title.jpg" id="2"]
[node name="Main" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
[node name="BG" type="TextureRect" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
texture = ExtResource("2")
expand_mode = 1
stretch_mode = 6
modulate = Color(0.62, 0.62, 0.68, 1)
[node name="Scrim" type="ColorRect" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.05, 0.05, 0.09, 0.42)
[node name="VBox" type="VBoxContainer" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
@ -19,7 +33,10 @@ theme_override_constants/separation = 20
[node name="Title" type="Label" parent="VBox"]
text = "BURNOUT INFINITY: SHITBOX"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 44
theme_override_font_sizes/font_size = 52
theme_override_colors/font_color = Color(1, 0.82, 0.35, 1)
theme_override_colors/font_outline_color = Color(0.12, 0.05, 0.02, 1)
theme_override_constants/outline_size = 10
[node name="HBox" type="HBoxContainer" parent="VBox"]
size_flags_vertical = 3

View File

@ -162,6 +162,43 @@ func _run() -> void:
_check(d.target != null and d.target != d, "derby car has no/self target")
game.free()
# road rage: rivals spawn, a takedown near the player counts and pays
print(" .. rage")
game = await _boot("rage")
_check(game.rivals.size() == Game.RAGE_RIVALS, "rage field wrong size")
var prey: Rival = game.rivals[0]
await physics_frame
game.car.global_position = prey.global_position + Vector3(3, 0.2, 0)
await physics_frame
prey.wreck_started.emit()
_check(game.rage_downs == 1, "rage takedown didn't count (%d)" % game.rage_downs)
game.time_left = 0.05
for i in 10:
await physics_frame
_check(game.over, "rage never tallied")
game.free()
# oncoming: rolling against the road tangent at speed earns boost
game = await _boot("cruise")
await physics_frame
var curve := game.race_path.curve
var road_pt := game.race_path.to_global(curve.sample_baked(20.0))
var tan := (game.race_path.to_global(curve.sample_baked(24.0)) - road_pt).normalized()
# Teleport via the physics server: setting global_position on a
# continuous_cd body SWEEPS it there, slamming through everything en route
# -- the first version of this test wrecked the car before it could earn.
var xf := Transform3D(Basis.looking_at(-tan, Vector3.UP), road_pt + Vector3.UP * 0.75)
PhysicsServer3D.body_set_state(game.car.get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, xf)
game.car.wrecked = false
game.car.boost = 0.0
game.car._prev_lv = -tan * 16.0 # don't let the velocity jump read as a crash
game.car.linear_velocity = -tan * 16.0
for i in 45:
await physics_frame
game.car.linear_velocity = -tan * 16.0 # hold course against drag
_check(game.car.boost > 0.05, "oncoming earned no boost (%.3f)" % game.car.boost)
game.free()
# race: rivals spawn and make progress along the path
print(" .. race")
game = await _boot("race")