Gameplay systems + procedural Blender car fleet

Modes: Cruise / Crash Mode (60s damage dollars) / Race (3 laps, rubber-banded
rivals). Burnout loop: boost earned by drift, near miss, smash; takedowns refill
and grow the meter to 4x; hard crashes shed det_* panels and trigger Impact Time
slow-mo. Traffic follows the level RacePath frozen-kinematic and goes dynamic
when whacked. tools/build_cars.py builds six Aussie shitbox GLBs (profile
extrusion + detachable doors/mirrors/bumpers/bonnet/boot). Smoke test covers
all three modes headless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 21:29:42 +10:00
parent 1c0b7efb32
commit eb7c189c9f
36 changed files with 1133 additions and 57 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.DS_Store
*.blend1
screenshot.png
shot_*.png

View File

@ -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/<id>/` 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/<id>/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/<id>/` 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/<id>/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/<id>/level.glb`. It appears in the menu.
## Tests
@ -47,8 +59,14 @@ Make a folder `levels/<id>/` 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.

BIN
cars/datto_120y/car.glb Normal file

Binary file not shown.

View File

@ -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

View File

@ -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

BIN
cars/excel_x3/car.glb Normal file

Binary file not shown.

View File

@ -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

13
cars/excel_x3/stats.tres Normal file
View File

@ -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

BIN
cars/gemmy_tx/car.glb Normal file

Binary file not shown.

View File

@ -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

View File

@ -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

BIN
cars/kingswood_hq/car.glb Normal file

Binary file not shown.

View File

@ -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

View File

@ -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

BIN
cars/vl_terbo/car.glb Normal file

Binary file not shown.

View File

@ -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

View File

@ -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

BIN
cars/xf_falcon/car.glb Normal file

Binary file not shown.

View File

@ -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

13
cars/xf_falcon/stats.tres Normal file
View File

@ -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

View File

@ -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()

40
screenshot.png.import Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

52
src/rival.gd Normal file
View File

@ -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

1
src/rival.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://b8s7pvlla46kh

8
src/rival.tscn Normal file
View File

@ -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

73
src/traffic.gd Normal file
View File

@ -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())

1
src/traffic.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://cch61cwxv7g81

View File

@ -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)

1
tests/screenshot.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://ci411yia72g6m

View File

@ -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

217
tools/build_cars.py Normal file
View File

@ -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/<id>/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))