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:
parent
1c0b7efb32
commit
eb7c189c9f
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
.DS_Store
|
||||
*.blend1
|
||||
screenshot.png
|
||||
shot_*.png
|
||||
|
||||
64
README.md
64
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/<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
BIN
cars/datto_120y/car.glb
Normal file
Binary file not shown.
42
cars/datto_120y/car.glb.import
Normal file
42
cars/datto_120y/car.glb.import
Normal 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
|
||||
13
cars/datto_120y/stats.tres
Normal file
13
cars/datto_120y/stats.tres
Normal 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
BIN
cars/excel_x3/car.glb
Normal file
Binary file not shown.
42
cars/excel_x3/car.glb.import
Normal file
42
cars/excel_x3/car.glb.import
Normal 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
13
cars/excel_x3/stats.tres
Normal 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
BIN
cars/gemmy_tx/car.glb
Normal file
Binary file not shown.
42
cars/gemmy_tx/car.glb.import
Normal file
42
cars/gemmy_tx/car.glb.import
Normal 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
|
||||
@ -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
BIN
cars/kingswood_hq/car.glb
Normal file
Binary file not shown.
42
cars/kingswood_hq/car.glb.import
Normal file
42
cars/kingswood_hq/car.glb.import
Normal 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
|
||||
13
cars/kingswood_hq/stats.tres
Normal file
13
cars/kingswood_hq/stats.tres
Normal 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
BIN
cars/vl_terbo/car.glb
Normal file
Binary file not shown.
42
cars/vl_terbo/car.glb.import
Normal file
42
cars/vl_terbo/car.glb.import
Normal 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
|
||||
@ -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
BIN
cars/xf_falcon/car.glb
Normal file
Binary file not shown.
42
cars/xf_falcon/car.glb.import
Normal file
42
cars/xf_falcon/car.glb.import
Normal 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
13
cars/xf_falcon/stats.tres
Normal 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
|
||||
@ -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
40
screenshot.png.import
Normal 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
|
||||
124
src/car.gd
124
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
|
||||
|
||||
@ -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
|
||||
|
||||
184
src/game.gd
184
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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
52
src/rival.gd
Normal 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
1
src/rival.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b8s7pvlla46kh
|
||||
8
src/rival.tscn
Normal file
8
src/rival.tscn
Normal 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
73
src/traffic.gd
Normal 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
1
src/traffic.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://cch61cwxv7g81
|
||||
@ -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
1
tests/screenshot.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ci411yia72g6m
|
||||
@ -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
217
tools/build_cars.py
Normal 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))
|
||||
Loading…
Reference in New Issue
Block a user