diff --git a/.gitignore b/.gitignore index 628385f..eb31a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store *.blend1 screenshot.png +shot_*.png diff --git a/README.md b/README.md index 10b5e79..c6c0e7f 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,49 @@ Burnout 3-style arcade racer, Aussie shitboxes, Woolies carparks. Godot 4.6 + Bl /Applications/Godot.app/Contents/MacOS/Godot --path . ``` -Controls: WASD/arrows drive, Shift drift, Space boost (earn it by drifting), R reset, Esc menu. +Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after Crash Mode), Esc menu. -## Add a car +## Modes -Make a folder `cars//` containing `stats.tres`: +- **Cruise** — free roam with traffic. +- **Crash Mode** — 60 seconds, rack up damage dollars ramming traffic and parked cars. +- **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`. + +The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing +things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x). +Hard crashes shed body panels and trigger Impact Time slow-mo. + +## Cars + +A car is a folder: `cars//stats.tres` + optional `car.glb`. Models are built +procedurally — specs live in [tools/build_cars.py](tools/build_cars.py), one dict per car +(silhouette profile, dimensions, paint). Rebuild all GLBs with: ``` -[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3] - -[ext_resource type="Script" path="res://src/car_stats.gd" id="1"] - -[resource] -script = ExtResource("1") -display_name = "Datto 120Y" -model_path = "res://cars/datto_120y/car.glb" -engine_power = 12.0 -mass = 900.0 -grip = 6.5 +/Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_cars.py ``` -Model is optional — no `car.glb` means you drive the placeholder box. Export the GLB from -Blender with the car facing **-Y** (Blender forward), roughly 1.8 x 1.4 x 4.2 m, origin at the -centre, wheels-bottom around z = -0.5. +Model convention (any GLB works, hand-modelled or generated): + +- Nose faces **+Y** in Blender (imports as Godot -Z forward). +- Node origin 0.75 m above ground level (the controller's ride height). +- Objects named `det_*` are detachable — the game rips them off on impact + (doors, mirrors, bumpers, bonnet, boot). +- Current fleet: Datto 120Y, Gemmy TX, Kingswood HQ, VL Terbo, XF Fungoon, Excel Hilton. + Piss-take names on purpose: real model names and badges are trademarks. + +To improve a silhouette: supply a side-on photo + front three-quarter photo, tweak the +profile points in the spec, rebuild, screenshot. Kerb weight and power figures go in +stats.tres if you want realism; otherwise vibes. ## Add a level Make a folder `levels//` containing `level.glb` (or `level.tscn`). Blender convention: -- Objects that should be solid: suffix the object name with `-col` (Godot generates collision on import). -- Add an Empty named `Spawn` where the car starts (its -Y/forward axis = drive direction). -- Export scene as glTF Binary to `levels//level.glb`. Done — it appears in the menu. +- Solid objects: suffix the name with `-col` (Godot generates collision on import). +- An Empty named `Spawn` where the car starts. +- A curve/path named `RacePath` (loop) enables Race mode and traffic. +- Export glTF Binary to `levels//level.glb`. It appears in the menu. ## Tests @@ -47,8 +59,14 @@ Make a folder `levels//` containing `level.glb` (or `level.tscn`). Blender c /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd ``` +Visual check (opens a window, saves a PNG): + +``` +/Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd -- car=datto_120y mode=crash frames=280 out=shot.png +``` + ## Design doc -`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. The build order the -report implies: 1) handling + boost loop (done, tune forever), 2) crash/damage + takedowns, -3) traffic, 4) Crash Mode in the carpark, 5) race modes + AI. +`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. Still to come: +crash deformation (control-hull vertex displacement, equations in the PDF), oncoming-lane +boost, Road Rage mode, sound, real level GLBs from Blender. diff --git a/cars/datto_120y/car.glb b/cars/datto_120y/car.glb new file mode 100644 index 0000000..fdc0e20 Binary files /dev/null and b/cars/datto_120y/car.glb differ diff --git a/cars/datto_120y/car.glb.import b/cars/datto_120y/car.glb.import new file mode 100644 index 0000000..02a2488 --- /dev/null +++ b/cars/datto_120y/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://ce7ggjcjxdext" +path="res://.godot/imported/car.glb-e378097fb9e4310b2b03d3bb742810f8.scn" + +[deps] + +source_file="res://cars/datto_120y/car.glb" +dest_files=["res://.godot/imported/car.glb-e378097fb9e4310b2b03d3bb742810f8.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/datto_120y/stats.tres b/cars/datto_120y/stats.tres new file mode 100644 index 0000000..ba81a7d --- /dev/null +++ b/cars/datto_120y/stats.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/car_stats.gd" id="1"] + +[resource] +script = ExtResource("1") +display_name = "Datto 120Y" +model_path = "res://cars/datto_120y/car.glb" +engine_power = 11.0 +mass = 850.0 +grip = 6.5 +length = 3.92 +width = 1.50 diff --git a/cars/excel_x3/car.glb b/cars/excel_x3/car.glb new file mode 100644 index 0000000..8a66afc Binary files /dev/null and b/cars/excel_x3/car.glb differ diff --git a/cars/excel_x3/car.glb.import b/cars/excel_x3/car.glb.import new file mode 100644 index 0000000..327e707 --- /dev/null +++ b/cars/excel_x3/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://g3b1wxkn6fwk" +path="res://.godot/imported/car.glb-a57f742564ace5ed237ebc4564dfb8c3.scn" + +[deps] + +source_file="res://cars/excel_x3/car.glb" +dest_files=["res://.godot/imported/car.glb-a57f742564ace5ed237ebc4564dfb8c3.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/excel_x3/stats.tres b/cars/excel_x3/stats.tres new file mode 100644 index 0000000..6832e61 --- /dev/null +++ b/cars/excel_x3/stats.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/car_stats.gd" id="1"] + +[resource] +script = ExtResource("1") +display_name = "Excel Hilton" +model_path = "res://cars/excel_x3/car.glb" +engine_power = 12.0 +mass = 980.0 +grip = 6.8 +length = 4.10 +width = 1.62 diff --git a/cars/gemmy_tx/car.glb b/cars/gemmy_tx/car.glb new file mode 100644 index 0000000..073ad19 Binary files /dev/null and b/cars/gemmy_tx/car.glb differ diff --git a/cars/gemmy_tx/car.glb.import b/cars/gemmy_tx/car.glb.import new file mode 100644 index 0000000..08711fe --- /dev/null +++ b/cars/gemmy_tx/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dqken2gji8e23" +path="res://.godot/imported/car.glb-5e5ac440118a817d281119d908a29b56.scn" + +[deps] + +source_file="res://cars/gemmy_tx/car.glb" +dest_files=["res://.godot/imported/car.glb-5e5ac440118a817d281119d908a29b56.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/gemmy_tx/stats.tres b/cars/gemmy_tx/stats.tres index b13925e..1294461 100644 --- a/cars/gemmy_tx/stats.tres +++ b/cars/gemmy_tx/stats.tres @@ -5,7 +5,9 @@ [resource] script = ExtResource("1") display_name = "Gemmy TX" -model_path = "" -engine_power = 14.0 +model_path = "res://cars/gemmy_tx/car.glb" +engine_power = 13.0 mass = 950.0 grip = 7.0 +length = 4.13 +width = 1.57 diff --git a/cars/kingswood_hq/car.glb b/cars/kingswood_hq/car.glb new file mode 100644 index 0000000..0099516 Binary files /dev/null and b/cars/kingswood_hq/car.glb differ diff --git a/cars/kingswood_hq/car.glb.import b/cars/kingswood_hq/car.glb.import new file mode 100644 index 0000000..67bf016 --- /dev/null +++ b/cars/kingswood_hq/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cipr4m5ik2j0q" +path="res://.godot/imported/car.glb-6977f92ee10a58fcde53200be4b3a9fb.scn" + +[deps] + +source_file="res://cars/kingswood_hq/car.glb" +dest_files=["res://.godot/imported/car.glb-6977f92ee10a58fcde53200be4b3a9fb.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/kingswood_hq/stats.tres b/cars/kingswood_hq/stats.tres new file mode 100644 index 0000000..ccf3e75 --- /dev/null +++ b/cars/kingswood_hq/stats.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/car_stats.gd" id="1"] + +[resource] +script = ExtResource("1") +display_name = "Kingswood HQ" +model_path = "res://cars/kingswood_hq/car.glb" +engine_power = 15.0 +mass = 1400.0 +grip = 5.5 +length = 4.81 +width = 1.88 diff --git a/cars/vl_terbo/car.glb b/cars/vl_terbo/car.glb new file mode 100644 index 0000000..e1dd0fe Binary files /dev/null and b/cars/vl_terbo/car.glb differ diff --git a/cars/vl_terbo/car.glb.import b/cars/vl_terbo/car.glb.import new file mode 100644 index 0000000..ec58ed1 --- /dev/null +++ b/cars/vl_terbo/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bbi1igmm0lvko" +path="res://.godot/imported/car.glb-d562177d050e8bc9348a99036622f776.scn" + +[deps] + +source_file="res://cars/vl_terbo/car.glb" +dest_files=["res://.godot/imported/car.glb-d562177d050e8bc9348a99036622f776.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/vl_terbo/stats.tres b/cars/vl_terbo/stats.tres index d0fb006..edc4476 100644 --- a/cars/vl_terbo/stats.tres +++ b/cars/vl_terbo/stats.tres @@ -5,7 +5,9 @@ [resource] script = ExtResource("1") display_name = "VL Terbo" -model_path = "" -engine_power = 18.0 -mass = 1300.0 +model_path = "res://cars/vl_terbo/car.glb" +engine_power = 19.0 +mass = 1250.0 grip = 5.0 +length = 4.72 +width = 1.72 diff --git a/cars/xf_falcon/car.glb b/cars/xf_falcon/car.glb new file mode 100644 index 0000000..f2d854d Binary files /dev/null and b/cars/xf_falcon/car.glb differ diff --git a/cars/xf_falcon/car.glb.import b/cars/xf_falcon/car.glb.import new file mode 100644 index 0000000..03bd420 --- /dev/null +++ b/cars/xf_falcon/car.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://c1eahenja3f15" +path="res://.godot/imported/car.glb-43049e0c6f8686c1178cc0743ec34159.scn" + +[deps] + +source_file="res://cars/xf_falcon/car.glb" +dest_files=["res://.godot/imported/car.glb-43049e0c6f8686c1178cc0743ec34159.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/cars/xf_falcon/stats.tres b/cars/xf_falcon/stats.tres new file mode 100644 index 0000000..d161284 --- /dev/null +++ b/cars/xf_falcon/stats.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/car_stats.gd" id="1"] + +[resource] +script = ExtResource("1") +display_name = "XF Fungoon" +model_path = "res://cars/xf_falcon/car.glb" +engine_power = 15.0 +mass = 1350.0 +grip = 5.5 +length = 4.91 +width = 1.86 diff --git a/levels/woolies_carpark/carpark.gd b/levels/woolies_carpark/carpark.gd index f436a91..e194575 100644 --- a/levels/woolies_carpark/carpark.gd +++ b/levels/woolies_carpark/carpark.gd @@ -17,6 +17,21 @@ func _ready() -> void: _box(pos, Vector3(1.9, 1.4, 4.4), Color.from_hsv(rng.randf(), 0.5, rng.randf_range(0.4, 0.9)), true) for i in 6: # light poles _box(Vector3(-30.0 + i * 12.0, 3.0, 8.0), Vector3(0.4, 6.0, 0.4), Color(0.3, 0.3, 0.3)) + _race_path() + +func _race_path() -> void: + # perimeter loop road around the parking rows -- race line + traffic route + var path := Path3D.new() + path.name = "RacePath" + var c := Curve3D.new() + var pts := [Vector3(45, 0, 55), Vector3(45, 0, -55), Vector3(-45, 0, -55), Vector3(-45, 0, 55)] + for i in pts.size() + 1: + var p: Vector3 = pts[i % pts.size()] + var to_prev: Vector3 = (pts[(i - 1 + pts.size()) % pts.size()] - p).normalized() * 12.0 + var to_next: Vector3 = (pts[(i + 1) % pts.size()] - p).normalized() * 12.0 + c.add_point(p, to_prev, to_next) + path.curve = c + add_child(path) func _box(pos: Vector3, size: Vector3, color: Color, dynamic := false) -> void: var body: PhysicsBody3D = RigidBody3D.new() if dynamic else StaticBody3D.new() diff --git a/screenshot.png.import b/screenshot.png.import new file mode 100644 index 0000000..1865695 --- /dev/null +++ b/screenshot.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dti2g41yp6bom" +path="res://.godot/imported/screenshot.png-024a21af5d37bf0f0dd0e2bccdd149d0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://screenshot.png" +dest_files=["res://.godot/imported/screenshot.png-024a21af5d37bf0f0dd0e2bccdd149d0.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/car.gd b/src/car.gd index 1ecd26b..d3ed3d5 100644 --- a/src/car.gd +++ b/src/car.gd @@ -1,9 +1,16 @@ class_name Car extends RigidBody3D ## Arcade raycast car: 4 suspension rays, body-level tyre model. +## Player (is_player) reads keys; AI sets the *_in fields (see rival.gd). +## Crashes are detected as velocity jolts -- big jolt sheds det_* panels off the +## model, bigger jolt = full wreck (game.gd adds slow-mo for the player). ## ponytail: body-level grip/drive forces -- go per-wheel only if the feel demands it. +signal crashed(dv: float) +signal wreck_started + @export var stats: CarStats +@export var is_player := true const REST := 0.55 # suspension rest length, m const STEER_ANGLE := 0.6 @@ -13,13 +20,28 @@ const BOOST_MULT := 1.8 const BOOST_DRAIN := 0.35 # meter/sec while boosting const DRIFT_EARN := 0.3 # meter/sec while sliding const DRAG := 0.012 # quadratic drag -> top speed = sqrt(power/DRAG) +const BOOST_CAP := 4.0 # takedowns grow the meter up to 4x (Burnout 3 rule) +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 + +var throttle_in := 0.0 +var steer_in := 0.0 +var drift_in := false +var boost_in := false +var power_scale := 1.0 # rubber-band hook, set by game.gd var steer := 0.0 -var boost := 0.0 # 0..1 +var boost := 0.0 +var boost_max := 1.0 var boosting := false var slip := 0.0 # sideways m/s +var wrecked := false var spawn_xform: Transform3D +var _prev_lv := Vector3.ZERO +var _impact_cd := 0.0 +var _parts: Array[Node] = [] + @onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR] func _ready() -> void: @@ -28,20 +50,41 @@ func _ready() -> void: center_of_mass = Vector3(0, -0.3, 0) angular_damp = 3.0 spawn_xform = global_transform + var cs: CollisionShape3D = $Shape + cs.shape = cs.shape.duplicate() + (cs.shape as BoxShape3D).size = Vector3(stats.width, 1.0, stats.length) + for w: Node3D in wheels: + w.position.x = signf(w.position.x) * (stats.width / 2.0 - 0.15) + w.position.z = signf(w.position.z) * (stats.length / 2.0 - 0.55) if stats.model_path != "" and ResourceLoader.exists(stats.model_path): $Body.visible = false - add_child((load(stats.model_path) as PackedScene).instantiate()) + var model: Node3D = (load(stats.model_path) as PackedScene).instantiate() + add_child(model) + _parts = model.find_children("det_*", "Node3D", true, false) + else: + var bm := BoxMesh.new() + bm.size = Vector3(stats.width, 1.0, stats.length) + $Body.mesh = bm func _physics_process(delta: float) -> void: - if Input.is_action_just_pressed("reset"): - global_transform = spawn_xform - linear_velocity = Vector3.ZERO - angular_velocity = Vector3.ZERO + if is_player: + throttle_in = Input.get_axis("brake", "throttle") + steer_in = Input.get_axis("steer_right", "steer_left") + drift_in = Input.is_action_pressed("drift") + boost_in = Input.is_action_pressed("boost") + if Input.is_action_just_pressed("reset"): + global_transform = spawn_xform + linear_velocity = Vector3.ZERO + angular_velocity = Vector3.ZERO + _prev_lv = Vector3.ZERO + if wrecked: + throttle_in = 0.0 + steer_in = 0.0 + drift_in = false + boost_in = false - var throttle := Input.get_axis("brake", "throttle") - steer = lerpf(steer, Input.get_axis("steer_right", "steer_left") * STEER_ANGLE, STEER_SPEED * delta) - var drifting := Input.is_action_pressed("drift") - boosting = Input.is_action_pressed("boost") and boost > 0.0 + steer = lerpf(steer, steer_in * STEER_ANGLE, STEER_SPEED * delta) + boosting = boost_in and boost > 0.0 and not wrecked var space := get_world_3d().direct_space_state var grounded := 0 @@ -61,18 +104,71 @@ func _physics_process(delta: float) -> void: if grounded >= 2: var fwd := -global_basis.z var speed := fwd.dot(linear_velocity) - var power := stats.engine_power * (BOOST_MULT if boosting else 1.0) - apply_central_force(fwd * throttle * power * mass) + var power := stats.engine_power * power_scale * (BOOST_MULT if boosting else 1.0) + apply_central_force(fwd * throttle_in * power * mass) apply_torque(global_basis.y * steer * clampf(speed / 12.0, -1.0, 1.0) * mass * 14.0) slip = global_basis.x.dot(linear_velocity) - var grip := stats.grip * (DRIFT_GRIP_MULT if drifting else 1.0) + var grip := stats.grip * (DRIFT_GRIP_MULT if drift_in else 1.0) apply_central_force(-global_basis.x * slip * grip * mass) apply_central_force(-linear_velocity * linear_velocity.length() * mass * DRAG) if absf(slip) > 4.0 and speed > 8.0: - boost = minf(boost + DRIFT_EARN * delta, 1.0) + boost = minf(boost + DRIFT_EARN * delta, boost_max) if boosting: boost = maxf(boost - BOOST_DRAIN * delta, 0.0) + 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: + _impact_cd = 0.4 # one collision = one crash event, not one per tick + _impact(jolt) + +func earn_boost(amount: float) -> void: + boost = minf(boost + amount, boost_max) + +func _impact(jolt: Vector3) -> void: + var dv := jolt.length() + crashed.emit(dv) + var side := (global_basis.inverse() * -jolt).normalized() + for i in 1 + int(dv > 8.0) + int(dv > 12.0): + _shed_panel(side) + if dv > WRECK_DV and not wrecked: + wrecked = true + wreck_started.emit() + get_tree().create_timer(2.2, true, false, true).timeout.connect(_recover) + +func _recover() -> void: + wrecked = false + if is_player: + boost = boost_max # Burnout rule: recovery refills the meter + +func _shed_panel(side: Vector3) -> void: + if _parts.is_empty(): + return + var best: Node3D = _parts[0] + var best_d := -INF + for p: Node3D in _parts: + var d := to_local(p.global_position).normalized().dot(side) + if d > best_d: + best_d = d + best = p + _parts.erase(best) + var rb := RigidBody3D.new() + rb.mass = 8.0 + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + var aabb := (best as MeshInstance3D).get_aabb() if best is MeshInstance3D else AABB(Vector3.ONE * -0.15, Vector3.ONE * 0.3) + box.size = aabb.size.clamp(Vector3.ONE * 0.06, Vector3.ONE * 3.0) + cs.shape = box + cs.position = aabb.get_center() + rb.add_child(cs) + get_parent().add_child(rb) + rb.global_transform = best.global_transform + best.reparent(rb) + rb.linear_velocity = linear_velocity + rb.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8)) + get_tree().create_timer(8.0).timeout.connect(rb.queue_free) + func speed_kmh() -> float: return linear_velocity.length() * 3.6 diff --git a/src/car_stats.gd b/src/car_stats.gd index 6ddce8b..79c1438 100644 --- a/src/car_stats.gd +++ b/src/car_stats.gd @@ -8,3 +8,5 @@ extends Resource @export var engine_power := 14.0 # forward accel, m/s^2-ish @export var mass := 1100.0 @export var grip := 6.0 # lateral grip; lower = slidier +@export var length := 4.2 # collision footprint, m +@export var width := 1.8 diff --git a/src/game.gd b/src/game.gd index 86d0b3f..33b6679 100644 --- a/src/game.gd +++ b/src/game.gd @@ -1,19 +1,45 @@ class_name Game extends Node3D -## Loads the chosen level, spawns the chosen car at the level's "Spawn" node, -## runs chase cam + HUD. Everything built in code -- game.tscn is just this script. +## Loads the chosen level, spawns cars, runs the chosen mode: +## cruise -- free roam with traffic +## crash -- 60s damage-dollar rampage (Crash Mode v0) +## race -- 3 laps vs rivals on the level's RacePath, rubber-banded +## Takedowns refill the boost meter and grow it (up to 4x, per Burnout 3). static var car_path := "" static var level_path := "" +static var mode := "cruise" + +const LAPS := 3 +const CRASH_TIME := 60.0 +const RIVAL_COUNT := 3 +const TRAFFIC_COUNT := {"cruise": 6, "crash": 10, "race": 4} var car: Car var cam: Camera3D +var race_path: Path3D +var rivals: Array[Rival] = [] +var traffic: Array[TrafficCar] = [] +var score := 0.0 +var time_left := CRASH_TIME +var over := false +var laps := {} # racer -> lap count +var prev_off := {} # racer -> last path offset +var near_cd := {} # traffic -> near-miss cooldown + var speed_label: Label var boost_bar: ProgressBar +var info_label: Label +var msg_label: Label +var msg_timer: SceneTreeTimer func _ready() -> void: var level: Node = (load(level_path) as PackedScene).instantiate() add_child(level) + for p in level.find_children("*", "Path3D", true, false): + race_path = p + if String(p.name).begins_with("Race"): + break car = (load("res://src/car.tscn") as PackedScene).instantiate() car.stats = load(car_path) @@ -22,6 +48,38 @@ func _ready() -> void: if spawn is Node3D: car.global_transform = spawn.global_transform car.global_position += Vector3.UP * 0.6 + car.crashed.connect(_player_crash) + car.wreck_started.connect(_player_wreck) + + if mode == "race" and race_path: + _grid(car, 0.0) + laps[car] = 0 + prev_off[car] = 0.0 + var pool := Registry.cars().values() + for i in RIVAL_COUNT: + var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate() + r.stats = load(pool[randi() % pool.size()]) + r.path = race_path + add_child(r) + var off := race_path.curve.get_baked_length() - 8.0 * (i + 1) + _grid(r, off) + r.progress = off + laps[r] = 0 + prev_off[r] = off + r.wreck_started.connect(_rival_wrecked.bind(r)) + rivals.append(r) + car.spawn_xform = car.global_transform + + if race_path: + var n: int = TRAFFIC_COUNT.get(mode, 4) + for i in n: + var t := TrafficCar.new() + t.path = race_path + t.offset = race_path.curve.get_baked_length() * (i + 0.5) / n + t.speed = randf_range(9.0, 14.0) + add_child(t) + t.smashed.connect(_traffic_smashed) + traffic.append(t) cam = Camera3D.new() cam.fov = 72.0 @@ -48,17 +106,137 @@ func _ready() -> void: hud.add_child(speed_label) boost_bar = ProgressBar.new() boost_bar.position = Vector2(24, 72) - boost_bar.size = Vector2(220, 18) + boost_bar.size = Vector2(160, 18) boost_bar.show_percentage = false hud.add_child(boost_bar) + info_label = Label.new() + info_label.anchor_left = 1.0 + info_label.anchor_right = 1.0 + info_label.offset_left = -420.0 + info_label.offset_top = 24.0 + info_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + info_label.add_theme_font_size_override("font_size", 30) + hud.add_child(info_label) + msg_label = Label.new() + msg_label.anchor_left = 0.5 + msg_label.anchor_right = 0.5 + msg_label.anchor_top = 0.25 + msg_label.grow_horizontal = Control.GROW_DIRECTION_BOTH + msg_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + msg_label.add_theme_font_size_override("font_size", 48) + hud.add_child(msg_label) + +func _exit_tree() -> void: + Engine.time_scale = 1.0 + +func _grid(c: Car, off: float) -> void: + var L := race_path.curve.get_baked_length() + var pos := race_path.to_global(race_path.curve.sample_baked(fposmod(off, L))) + Vector3.UP * 0.8 + var ahead := race_path.to_global(race_path.curve.sample_baked(fposmod(off + 4.0, L))) + Vector3.UP * 0.8 + c.look_at_from_position(pos, ahead, Vector3.UP) + +func _physics_process(delta: float) -> void: + # near misses: close pass on live traffic at speed + for t in traffic: + near_cd[t] = maxf(near_cd.get(t, 0.0) - delta, 0.0) + if t.live and near_cd[t] == 0.0 and car.linear_velocity.length() > 15.0 \ + and car.global_position.distance_to(t.global_position) < 4.5: + near_cd[t] = 4.0 + car.earn_boost(0.12) + _msg("NEAR MISS", 32) + if mode == "race" and race_path and not over: + _race_tick() + +func _race_tick() -> void: + var L := race_path.curve.get_baked_length() + prev_off[car] = _lap_step(car, _nearest_off(car.global_position, prev_off[car], L), L) + for r in rivals: + prev_off[r] = _lap_step(r, r.progress, L) + var my_total: float = laps[car] * L + prev_off[car] + var pos := 1 + for r in rivals: + var rt: float = laps[r] * L + prev_off[r] + if rt > my_total: + pos += 1 + r.power_scale = clampf(1.0 + (my_total - rt) / 300.0, 0.8, 1.35) + info_label.text = "P%d/%d LAP %d/%d" % [pos, rivals.size() + 1, mini(laps[car] + 1, LAPS), LAPS] + if laps[car] >= LAPS: + over = true + _msg("FINISHED P%d\nEsc for menu" % pos, 48, true) + +func _lap_step(c: Car, off: float, L: float) -> float: + if prev_off[c] > 0.8 * L and off < 0.2 * L: + laps[c] += 1 + elif off > 0.8 * L and prev_off[c] < 0.2 * L: + laps[c] -= 1 + return off + +func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float: + var best := seed_off + var best_d := INF + for i in 25: + var o := fposmod(seed_off - 4.0 + i, L) + var d := race_path.to_global(race_path.curve.sample_baked(o)).distance_squared_to(pos) + if d < best_d: + best_d = d + best = o + return best + +func _player_crash(dv: float) -> void: + if mode == "crash" and not over: + score += minf(dv, 30.0) * 120.0 + if dv > 6.0: + _msg("SMASH", 40) + +func _player_wreck() -> void: + Engine.time_scale = 0.35 # Impact Time + get_tree().create_timer(1.0, true, false, true).timeout.connect( + func() -> void: Engine.time_scale = 1.0) + _msg("WRECKED", 48) + +func _rival_wrecked(r: Rival) -> void: + if car.wrecked or car.global_position.distance_to(r.global_position) > 8.0: + return + car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) + car.boost = car.boost_max + _msg("TAKEDOWN!", 56) + +func _traffic_smashed(impulse: float) -> void: + if mode == "crash" and not over: + score += impulse * 400.0 + _msg("$%d" % int(impulse * 400.0), 40) + car.earn_boost(0.2) + +func _msg(text: String, size: int, sticky := false) -> void: + msg_label.text = text + msg_label.add_theme_font_size_override("font_size", size) + if sticky: + msg_timer = null + return + var t := get_tree().create_timer(1.2, true, false, true) + msg_timer = t + t.timeout.connect(func() -> void: + if msg_timer == t: + msg_label.text = "") func _process(delta: float) -> void: if Input.is_action_just_pressed("ui_cancel"): get_tree().change_scene_to_file("res://src/main.tscn") return + if mode == "crash" and not over: + time_left -= delta + 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) + if over and mode == "crash" and Input.is_action_just_pressed("reset"): + get_tree().reload_current_scene() + return var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0) cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-5.0 * delta)) cam.look_at(car.global_position + Vector3.UP * 1.2) cam.fov = lerpf(cam.fov, 84.0 if car.boosting else 72.0, 6.0 * delta) speed_label.text = "%d km/h" % roundf(car.speed_kmh()) + boost_bar.size.x = 160.0 * car.boost_max + boost_bar.max_value = 100.0 * car.boost_max boost_bar.value = car.boost * 100.0 diff --git a/src/main.gd b/src/main.gd index f631fce..62306bb 100644 --- a/src/main.gd +++ b/src/main.gd @@ -1,8 +1,11 @@ extends Control -## Menu: pick a car and a level from whatever folders exist, hit RACE. +## Menu: pick a car, a level and a mode from whatever exists, hit RACE. + +const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"]] @onready var car_list: ItemList = $VBox/HBox/Cars @onready var level_list: ItemList = $VBox/HBox/Levels +@onready var mode_list: ItemList = $VBox/HBox/Modes var car_paths: Array = [] var level_paths: Array = [] @@ -17,10 +20,13 @@ func _ready() -> void: for id in levels: level_list.add_item(String(id).capitalize()) level_paths.append(levels[id]) + for m in MODES: + mode_list.add_item(m[1]) if not car_paths.is_empty(): car_list.select(0) if not level_paths.is_empty(): level_list.select(0) + mode_list.select(0) $VBox/Start.pressed.connect(_start) func _start() -> void: @@ -28,4 +34,5 @@ func _start() -> void: return Game.car_path = car_paths[car_list.get_selected_items()[0]] Game.level_path = level_paths[level_list.get_selected_items()[0]] + Game.mode = MODES[mode_list.get_selected_items()[0]][0] get_tree().change_scene_to_file("res://src/game.tscn") diff --git a/src/main.tscn b/src/main.tscn index 2fb7ba2..7032f47 100644 --- a/src/main.tscn +++ b/src/main.tscn @@ -31,6 +31,9 @@ size_flags_horizontal = 3 [node name="Levels" type="ItemList" parent="VBox/HBox"] size_flags_horizontal = 3 +[node name="Modes" type="ItemList" parent="VBox/HBox"] +size_flags_horizontal = 3 + [node name="Start" type="Button" parent="VBox"] text = "RACE" theme_override_font_sizes/font_size = 32 diff --git a/src/rival.gd b/src/rival.gd new file mode 100644 index 0000000..f66e4f8 --- /dev/null +++ b/src/rival.gd @@ -0,0 +1,52 @@ +class_name Rival +extends Car +## AI opponent: chases a lookahead point on the race path. Rubber-banding comes +## from game.gd setting power_scale. ponytail: no hostility index yet -- add when +## Road Rage mode lands. + +var path: Path3D +var progress := 0.0 + +var _stuck := 0.0 + +func _physics_process(delta: float) -> void: + if path: + _drive(delta) + super(delta) + +func _drive(delta: float) -> void: + var curve := path.curve + var L := curve.get_baked_length() + progress = _nearest(curve, L) + var look := 6.0 + linear_velocity.length() * 0.6 + var target := path.to_global(curve.sample_baked(fmod(progress + look, L))) + var local := to_local(target) + var angle := atan2(-local.x, -local.z) + steer_in = clampf(angle * 1.6, -1.0, 1.0) + throttle_in = 0.35 if absf(angle) > 1.2 else 1.0 + drift_in = absf(angle) > 0.7 and linear_velocity.length() > 14.0 + boost_in = boost > 0.4 and absf(angle) < 0.15 + + # wedged against something: put it back on the path + if linear_velocity.length() < 1.5 and not wrecked: + _stuck += delta + if _stuck > 3.0: + _stuck = 0.0 + var pos := path.to_global(curve.sample_baked(progress)) + Vector3.UP * 0.8 + var ahead := path.to_global(curve.sample_baked(fmod(progress + 4.0, L))) + Vector3.UP * 0.8 + look_at_from_position(pos, ahead, Vector3.UP) + linear_velocity = Vector3.ZERO + angular_velocity = Vector3.ZERO + else: + _stuck = 0.0 + +func _nearest(curve: Curve3D, L: float) -> float: + var best := progress + var best_d := INF + for i in 25: + var o := fposmod(progress - 4.0 + i, L) + var d := path.to_global(curve.sample_baked(o)).distance_squared_to(global_position) + if d < best_d: + best_d = d + best = o + return best diff --git a/src/rival.gd.uid b/src/rival.gd.uid new file mode 100644 index 0000000..8d93ebe --- /dev/null +++ b/src/rival.gd.uid @@ -0,0 +1 @@ +uid://b8s7pvlla46kh diff --git a/src/rival.tscn b/src/rival.tscn new file mode 100644 index 0000000..ea9b668 --- /dev/null +++ b/src/rival.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://src/car.tscn" id="1"] +[ext_resource type="Script" path="res://src/rival.gd" id="2"] + +[node name="Rival" instance=ExtResource("1")] +script = ExtResource("2") +is_player = false diff --git a/src/traffic.gd b/src/traffic.gd new file mode 100644 index 0000000..4a32352 --- /dev/null +++ b/src/traffic.gd @@ -0,0 +1,73 @@ +class_name TrafficCar +extends RigidBody3D +## Civilian traffic: frozen-kinematic body gliding along a Path3D loop; goes fully +## dynamic (and flies) when whacked hard enough. +## ponytail: no lane logic, no avoidance -- canon Burnout traffic is dumb. + +signal smashed(impulse: float) + +var path: Path3D +var offset := 0.0 +var speed := 11.0 +var live := true + +static var _model_pool: Array[String] = [] +static var _pool_built := false + +static func pick_model() -> String: + if not _pool_built: + _pool_built = true + for p in Registry.cars().values(): + var s: CarStats = load(p) + if s.model_path != "" and ResourceLoader.exists(s.model_path): + _model_pool.append(s.model_path) + return _model_pool.pick_random() if not _model_pool.is_empty() else "" + +func _init() -> void: + mass = 900.0 + freeze = true + freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + contact_monitor = true + max_contacts_reported = 8 + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(1.7, 1.2, 4.0) + cs.shape = box + add_child(cs) + var mp := pick_model() + if mp != "": + add_child((load(mp) as PackedScene).instantiate()) + else: + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = box.size + var mat := StandardMaterial3D.new() + mat.albedo_color = Color.from_hsv(randf(), 0.4, 0.8) + bm.material = mat + mi.mesh = bm + add_child(mi) + body_entered.connect(_hit) + +func _physics_process(delta: float) -> void: + if not live or path == null: + return + var L := path.curve.get_baked_length() + offset = fmod(offset + speed * delta, L) + var pos := path.to_global(path.curve.sample_baked(offset)) + Vector3.UP * 0.75 + var ahead := path.to_global(path.curve.sample_baked(fmod(offset + 2.0, L))) + Vector3.UP * 0.75 + look_at_from_position(pos, ahead, Vector3.UP) + +func _hit(body: Node) -> void: + if not live or not body is RigidBody3D: + return + if body is TrafficCar and (body as TrafficCar).live: + return # live traffic rear-ending live traffic is not a crash, it's commuting + # limit_length: penetration ejections report silly velocities, cap the punt + var rel := ((body as RigidBody3D).linear_velocity - linear_velocity).limit_length(30.0) + if rel.length() < 3.0: + return + live = false + freeze = false + linear_velocity = rel * 0.7 + Vector3.UP * 2.5 + angular_velocity = Vector3(randf_range(-3, 3), randf_range(-3, 3), randf_range(-3, 3)) + smashed.emit(rel.length()) diff --git a/src/traffic.gd.uid b/src/traffic.gd.uid new file mode 100644 index 0000000..f0c0551 --- /dev/null +++ b/src/traffic.gd.uid @@ -0,0 +1 @@ +uid://cch61cwxv7g81 diff --git a/tests/screenshot.gd b/tests/screenshot.gd index e655027..6649fad 100644 --- a/tests/screenshot.gd +++ b/tests/screenshot.gd @@ -1,19 +1,27 @@ extends SceneTree ## Boot straight into the game and save a viewport screenshot (needs a window, not --headless): -## /Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd -## Writes screenshot.png to the project dir. Used to eyeball the build without a human. +## Godot --path . --script tests/screenshot.gd -- car=datto_120y mode=cruise out=shot.png frames=90 throttle=1 +## Defaults: first car, cruise, screenshot.png, 90 physics frames, throttle held. func _initialize() -> void: _run() func _run() -> void: - Game.car_path = Registry.cars().values()[0] + var opts := {} + for a in OS.get_cmdline_user_args(): + var kv := a.split("=") + if kv.size() == 2: + opts[kv[0]] = kv[1] + var cars := Registry.cars() + Game.car_path = cars.get(opts.get("car", ""), cars.values()[0]) Game.level_path = Registry.levels().values()[0] + Game.mode = opts.get("mode", "cruise") root.add_child((load("res://src/game.tscn") as PackedScene).instantiate()) await process_frame - Input.action_press("throttle") - for i in 90: + if opts.get("throttle", "1") == "1": + Input.action_press("throttle") + for i in int(opts.get("frames", "90")): await physics_frame - root.get_texture().get_image().save_png("res://screenshot.png") + root.get_texture().get_image().save_png("res://" + opts.get("out", "screenshot.png")) print("screenshot saved") quit(0) diff --git a/tests/screenshot.gd.uid b/tests/screenshot.gd.uid new file mode 100644 index 0000000..94044a2 --- /dev/null +++ b/tests/screenshot.gd.uid @@ -0,0 +1 @@ +uid://ci411yia72g6m diff --git a/tests/smoke.gd b/tests/smoke.gd index 3dcb1ae..2a6c333 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -1,7 +1,8 @@ extends SceneTree ## Headless smoke test: ## /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd -## Registry finds content, game boots, car drives forward under throttle. +## Registry finds content; each mode boots and simulates; the car drives; traffic +## gets smashed when driven through. func _initialize() -> void: _run() @@ -14,16 +15,62 @@ func _run() -> void: for p in cars.values(): var s: CarStats = load(p) assert(s != null and s.engine_power > 0.0, "bad stats: %s" % p) + if s.model_path != "": + assert(load(s.model_path) is PackedScene, "bad model: %s" % s.model_path) Game.car_path = cars.values()[0] Game.level_path = levels.values()[0] - var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate() - root.add_child(game) - await process_frame # _ready doesn't run until the main loop starts + + # cruise: throttle 3s, car must move + var game: Game = await _boot("cruise") + if game.car.stats.model_path != "": + assert(game.car._parts.size() >= 8, "model has no detachable det_* parts") var start: Vector3 = game.car.global_position Input.action_press("throttle") for i in 180: await physics_frame var dist := start.distance_to(game.car.global_position) assert(dist > 5.0, "car didn't move (%.1f m)" % dist) - print("smoke ok: %d cars, %d levels, drove %.1f m, %.0f km/h" % [cars.size(), levels.size(), dist, game.car.speed_kmh()]) + Input.action_release("throttle") + game.free() + + # crash mode: traffic spawned, ram the nearest one, score must rise + game = await _boot("crash") + assert(game.traffic.size() == Game.TRAFFIC_COUNT["crash"], "traffic missing") + var victim: TrafficCar = game.traffic[0] + await physics_frame # traffic takes position on its first physics tick + game.car.global_position = victim.global_position - victim.global_basis.z * 15.0 + game.car.look_at(victim.global_position) + Input.action_press("throttle") + for i in 240: + await physics_frame + if not victim.live: + break + Input.action_release("throttle") + assert(not victim.live, "rammed traffic never went dynamic") + assert(game.score > 0.0, "crash mode scored nothing") + var crash_score := game.score + game.free() + + # race: rivals spawn and make progress along the path + game = await _boot("race") + assert(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing") + var p0: float = game.rivals[0].progress + for i in 240: + await physics_frame + var moved := false + for r in game.rivals: + if absf(r.progress - p0) > 3.0 or r.linear_velocity.length() > 3.0: + moved = true + assert(moved, "no rival is driving") + game.free() + + print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | race AI driving" % + [cars.size(), levels.size(), dist, int(crash_score)]) quit(0) + +func _boot(mode: String) -> Game: + Game.mode = mode + var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate() + root.add_child(game) + await process_frame # _ready is deferred until the main loop runs + return game diff --git a/tools/build_cars.py b/tools/build_cars.py new file mode 100644 index 0000000..2f1d525 --- /dev/null +++ b/tools/build_cars.py @@ -0,0 +1,217 @@ +"""Procedural Aussie shitbox factory. + +Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_cars.py + +Each car: side-silhouette profile extruded across the width (body + greenhouse), +cylinder wheels, and separate det_* panel objects (doors, mirrors, bumpers, +bonnet, boot) that the game rips off on impact. Exports cars//car.glb. + +Convention: +Y is the nose in Blender (imports facing -Z, Godot-forward). +Ground is z=0 while building; the body root is dropped -0.75 at the end to +match the car controller's ride height. + +ponytail: silhouettes are hand-tuned point lists, not reference-traced. Feed me +side-on photos per car when a shape needs to get closer. +""" +import os +import math +import bpy +import bmesh +from mathutils import Matrix + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cars") + +GLASS = (0.12, 0.16, 0.20, 1) +TYRE = (0.08, 0.08, 0.08, 1) +CHROME = (0.75, 0.76, 0.78, 1) +DARK = (0.15, 0.15, 0.15, 1) +LIGHT_F = (0.95, 0.92, 0.75, 1) +LIGHT_R = (0.7, 0.1, 0.08, 1) + +# L, W, heights: nose/belt/tail, lengths: bonnet/boot, roof height + rear roof end, +# axle overhangs, wheel radius, paint, chrome bumpers, hatch rear +CARS = { + "datto_120y": dict(L=3.92, W=1.50, nose=0.62, belt=0.78, tail=0.72, + bonnet=1.00, boot=0.85, roof=1.32, roof_rear=0.75, + ohang_f=0.72, ohang_r=0.80, wheel=0.27, + paint=(0.78, 0.62, 0.16, 1), chrome_bumpers=True, hatch=False), + "gemmy_tx": dict(L=4.13, W=1.57, nose=0.60, belt=0.76, tail=0.70, + bonnet=1.15, boot=0.95, roof=1.28, roof_rear=0.80, + ohang_f=0.80, ohang_r=0.85, wheel=0.28, + paint=(0.72, 0.30, 0.10, 1), chrome_bumpers=True, hatch=False), + "kingswood_hq": dict(L=4.81, W=1.88, nose=0.68, belt=0.85, tail=0.78, + bonnet=1.50, boot=1.30, roof=1.36, roof_rear=1.05, + ohang_f=0.90, ohang_r=1.05, wheel=0.31, + paint=(0.66, 0.74, 0.55, 1), chrome_bumpers=True, hatch=False), + "vl_terbo": dict(L=4.72, W=1.72, nose=0.56, belt=0.80, tail=0.82, + bonnet=1.30, boot=1.05, roof=1.33, roof_rear=0.90, + ohang_f=0.85, ohang_r=0.95, wheel=0.30, + paint=(0.92, 0.92, 0.90, 1), chrome_bumpers=False, hatch=False), + "xf_falcon": dict(L=4.91, W=1.86, nose=0.66, belt=0.84, tail=0.80, + bonnet=1.45, boot=1.25, roof=1.37, roof_rear=1.00, + ohang_f=0.90, ohang_r=1.00, wheel=0.30, + paint=(0.45, 0.55, 0.68, 1), chrome_bumpers=True, hatch=False), + "excel_x3": dict(L=4.10, W=1.62, nose=0.58, belt=0.80, tail=0.96, + bonnet=0.95, boot=0.30, roof=1.36, roof_rear=0.95, + ohang_f=0.78, ohang_r=0.70, wheel=0.27, + paint=(0.10, 0.55, 0.52, 1), chrome_bumpers=False, hatch=True), +} + + +def wipe(): + for obj in list(bpy.data.objects): + bpy.data.objects.remove(obj, do_unlink=True) + for coll in (bpy.data.meshes, bpy.data.materials): + for block in list(coll): + if block.users == 0: + coll.remove(block) + + +def material(name, rgba, metallic=0.0, rough=0.7): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED") + bsdf.inputs["Base Color"].default_value = rgba + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = rough + return mat + + +def add_obj(name, mesh, mat): + obj = bpy.data.objects.new(name, mesh) + obj.data.materials.append(mat) + bpy.context.collection.objects.link(obj) + return obj + + +def extrude_profile(name, profile, half_w, mat): + """Closed (y,z) polygon extruded from -half_w to +half_w along x.""" + n = len(profile) + verts = [(-half_w, y, z) for y, z in profile] + [(half_w, y, z) for y, z in profile] + faces = [list(range(n)), [2 * n - 1 - i for i in range(n)]] + for i in range(n): + j = (i + 1) % n + faces.append([i, j, n + j, n + i]) + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(verts, [], faces) + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(mesh) + bm.free() + mesh.update() + return add_obj(name, mesh, mat) + + +def box(name, center, size, mat, rot_x=0.0): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bmesh.ops.scale(bm, vec=size, verts=bm.verts) + if rot_x: + bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(rot_x, 3, "X"), verts=bm.verts) + bmesh.ops.translate(bm, vec=center, verts=bm.verts) + bm.to_mesh(mesh) + bm.free() + return add_obj(name, mesh, mat) + + +def wheel(name, x, y, r, mat): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24) + bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts) + bmesh.ops.translate(bm, vec=(x, y, r), verts=bm.verts) + bm.to_mesh(mesh) + bm.free() + return add_obj(name, mesh, mat) + + +def build(car_id, s): + wipe() + paint = material("paint", s["paint"], rough=0.45) + paint_dk = material("paint_dark", tuple(c * 0.8 for c in s["paint"][:3]) + (1,), rough=0.5) + glass = material("glass", GLASS, rough=0.2) + tyre = material("tyre", TYRE, rough=0.9) + chrome = material("chrome", CHROME, metallic=1.0, rough=0.25) + dark = material("dark", DARK, rough=0.8) + bumper_mat = chrome if s["chrome_bumpers"] else paint_dk + + L, W = s["L"], s["W"] + f, r, hw = L / 2, -L / 2, W / 2 + bottom = 0.22 + ws_base = f - s["bonnet"] # windscreen base y + boot_y = r + s["boot"] # rear window base y + nose2 = s["nose"] + 0.02 + + body_profile = [ + (f, bottom + 0.15), (f, s["nose"]), (f - 0.3, nose2), (ws_base, s["belt"]), + (boot_y, s["belt"]), (r + 0.05, s["tail"]), (r, s["tail"] - 0.05), + (r, bottom + 0.15), (r + 0.2, bottom), (f - 0.2, bottom), + ] + body = extrude_profile("body", body_profile, hw, paint) + + rr_y = r + 0.12 if s["hatch"] else boot_y + rr_z = s["tail"] - 0.03 if s["hatch"] else s["belt"] + gh_profile = [ + (ws_base, s["belt"] - 0.02), + (ws_base - 0.55, s["roof"]), + (boot_y + s["roof_rear"], s["roof"]), + (rr_y, rr_z), + ] + parts = [extrude_profile("glass", gh_profile, hw * 0.84, glass)] + + for side, sx in (("l", -1), ("r", 1)): + door_z = (bottom + 0.3 + s["belt"]) / 2 + door_h = s["belt"] - bottom - 0.32 + span = (ws_base - 0.05) - (boot_y + 0.05) + half = span / 2 + parts.append(box("det_door_f%s" % side, (sx * hw, ws_base - 0.05 - half / 2, door_z), + (0.06, half - 0.06, door_h), paint_dk)) + parts.append(box("det_door_r%s" % side, (sx * hw, boot_y + 0.05 + half / 2, door_z), + (0.06, half - 0.06, door_h), paint_dk)) + parts.append(box("det_mirror_%s" % side, (sx * (hw + 0.09), ws_base + 0.05, s["belt"] + 0.10), + (0.14, 0.09, 0.12), dark)) + parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat)) + parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat)) + + # bonnet plate follows the bonnet slope + by1, bz1, by2, bz2 = f - 0.35, nose2, ws_base + 0.03, s["belt"] + ang = math.atan2(bz2 - bz1, by2 - by1) + parts.append(box("det_bonnet", ((0, (by1 + by2) / 2, (bz1 + bz2) / 2 + 0.02)), + (W * 0.7, math.hypot(by2 - by1, bz2 - bz1) - 0.1, 0.04), paint_dk, rot_x=-ang)) + if not s["hatch"]: + ty1, tz1, ty2, tz2 = boot_y - 0.03, s["belt"], r + 0.1, s["tail"] + ang = math.atan2(tz2 - tz1, ty2 - ty1) + parts.append(box("det_boot", ((0, (ty1 + ty2) / 2, (tz1 + tz2) / 2 + 0.02)), + (W * 0.7, math.hypot(ty2 - ty1, tz2 - tz1) - 0.1, 0.04), paint_dk, rot_x=-ang)) + + trim = [ + box("trim_grille", (0, f + 0.01, s["nose"] - 0.14), (W * 0.5, 0.05, 0.12), dark), + box("trim_light_fl", (-W * 0.33, f + 0.02, s["nose"] - 0.13), (0.24, 0.05, 0.13), material("lf", LIGHT_F)), + box("trim_light_fr", (W * 0.33, f + 0.02, s["nose"] - 0.13), (0.24, 0.05, 0.13), material("lf2", LIGHT_F)), + box("trim_light_rl", (-W * 0.32, r - 0.02, s["tail"] - 0.14), (0.26, 0.05, 0.12), material("lr", LIGHT_R)), + box("trim_light_rr", (W * 0.32, r - 0.02, s["tail"] - 0.14), (0.26, 0.05, 0.12), material("lr2", LIGHT_R)), + ] + + ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"] + wx = hw - 0.10 + wheels = [wheel("wheel_fl", -wx, ax_f, s["wheel"], tyre), wheel("wheel_fr", wx, ax_f, s["wheel"], tyre), + wheel("wheel_rl", -wx, ax_r, s["wheel"], tyre), wheel("wheel_rr", wx, ax_r, s["wheel"], tyre)] + + for obj in parts + trim + wheels: + obj.parent = body + body.location.z = -0.75 # node origin sits at the controller's ride height + + out_dir = os.path.join(OUT, car_id) + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, "car.glb") + for obj in bpy.data.objects: + obj.select_set(True) + bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True) + print("built", car_id, "->", path) + + +for car_id, spec in CARS.items(): + build(car_id, spec) +print("done: %d cars" % len(CARS))