diff --git a/README.md b/README.md index 2a0adc7..3b83892 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/ui/title.jpg b/assets/ui/title.jpg new file mode 100644 index 0000000..96b4deb Binary files /dev/null and b/assets/ui/title.jpg differ diff --git a/assets/ui/title.jpg.import b/assets/ui/title.jpg.import new file mode 100644 index 0000000..349cd18 --- /dev/null +++ b/assets/ui/title.jpg.import @@ -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 diff --git a/shot_boom.png.import b/shot_boom.png.import new file mode 100644 index 0000000..cfd1148 --- /dev/null +++ b/shot_boom.png.import @@ -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 diff --git a/shot_menu.png.import b/shot_menu.png.import new file mode 100644 index 0000000..d9b5d15 --- /dev/null +++ b/shot_menu.png.import @@ -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 diff --git a/src/fx.gd b/src/fx.gd index 589c696..c4cf105 100644 --- a/src/fx.gd +++ b/src/fx.gd @@ -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() diff --git a/src/game.gd b/src/game.gd index e907ae6..3a5f19b 100644 --- a/src/game.gd +++ b/src/game.gd @@ -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 diff --git a/src/main.gd b/src/main.gd index 3c464da..2020fc6 100644 --- a/src/main.gd +++ b/src/main.gd @@ -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 diff --git a/src/main.tscn b/src/main.tscn index 7032f47..c1f3c0b 100644 --- a/src/main.tscn +++ b/src/main.tscn @@ -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 diff --git a/tests/smoke.gd b/tests/smoke.gd index 9c6717a..dd9170e 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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")