commit 1c0b7efb32db62af49522f1be57829b9235193b1 Author: type-two Date: Mon Jul 27 21:03:45 2026 +1000 Engine v0: Godot 4.6 data-driven arcade racer scaffold Drop-in content: cars//stats.tres (+optional car.glb), levels//level.glb or .tscn with a Spawn marker. Raycast arcade car (boost, drift, drift-earns-boost), chase cam, HUD, menu built from registry scan. Placeholder Woolies carpark with shuntable parked cars. Headless smoke test + windowed screenshot harness. Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..628385f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.godot/ +.DS_Store +*.blend1 +screenshot.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..10b5e79 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Burnout Infinity: Shitbox + +Burnout 3-style arcade racer, Aussie shitboxes, Woolies carparks. Godot 4.6 + Blender, native Apple Silicon. + +## Run + +``` +/Applications/Godot.app/Contents/MacOS/Godot --path . +``` + +Controls: WASD/arrows drive, Shift drift, Space boost (earn it by drifting), R reset, Esc menu. + +## Add a car + +Make a folder `cars//` containing `stats.tres`: + +``` +[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 +``` + +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. + +## Add a level + +Make a folder `levels//` containing `level.glb` (or `level.tscn`). Blender convention: + +- Objects that should be solid: suffix the object name with `-col` (Godot generates collision on import). +- Add an Empty named `Spawn` where the car starts (its -Y/forward axis = drive direction). +- Export scene as glTF Binary to `levels//level.glb`. Done — it appears in the menu. + +## Tests + +``` +/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --import +/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd +``` + +## 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. diff --git a/cars/gemmy_tx/stats.tres b/cars/gemmy_tx/stats.tres new file mode 100644 index 0000000..b13925e --- /dev/null +++ b/cars/gemmy_tx/stats.tres @@ -0,0 +1,11 @@ +[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 = "Gemmy TX" +model_path = "" +engine_power = 14.0 +mass = 950.0 +grip = 7.0 diff --git a/cars/vl_terbo/stats.tres b/cars/vl_terbo/stats.tres new file mode 100644 index 0000000..d0fb006 --- /dev/null +++ b/cars/vl_terbo/stats.tres @@ -0,0 +1,11 @@ +[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 = "VL Terbo" +model_path = "" +engine_power = 18.0 +mass = 1300.0 +grip = 5.0 diff --git a/docs/BurnoutInfinityShitbox.pdf b/docs/BurnoutInfinityShitbox.pdf new file mode 100644 index 0000000..6ae206d Binary files /dev/null and b/docs/BurnoutInfinityShitbox.pdf differ diff --git a/levels/woolies_carpark/carpark.gd b/levels/woolies_carpark/carpark.gd new file mode 100644 index 0000000..f436a91 --- /dev/null +++ b/levels/woolies_carpark/carpark.gd @@ -0,0 +1,39 @@ +extends Node3D +## ponytail: placeholder carpark built in code -- real levels arrive as level.glb from Blender +## and this whole folder becomes just the GLB + nothing else. + +func _ready() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 8 + _box(Vector3(0, -0.5, 0), Vector3(240, 1, 160), Color(0.35, 0.35, 0.37)) # tarmac + for side in [-1, 1]: # kerb walls + _box(Vector3(0, 0.4, side * 79.0), Vector3(240, 0.8, 2), Color(0.6, 0.6, 0.6)) + _box(Vector3(side * 119.0, 0.4, 0), Vector3(2, 0.8, 160), Color(0.6, 0.6, 0.6)) + for row in 4: # rows of parked shitboxes, shuntable + for bay in 10: + if rng.randf() < 0.35: + continue + var pos := Vector3(-27.0 + bay * 6.0, 0.8, -30.0 + row * 18.0) + _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)) + +func _box(pos: Vector3, size: Vector3, color: Color, dynamic := false) -> void: + var body: PhysicsBody3D = RigidBody3D.new() if dynamic else StaticBody3D.new() + if dynamic: + body.mass = 300.0 + var shape := CollisionShape3D.new() + var bs := BoxShape3D.new() + bs.size = size + shape.shape = bs + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = size + var mat := StandardMaterial3D.new() + mat.albedo_color = color + bm.material = mat + mi.mesh = bm + body.add_child(shape) + body.add_child(mi) + body.position = pos + add_child(body) diff --git a/levels/woolies_carpark/carpark.gd.uid b/levels/woolies_carpark/carpark.gd.uid new file mode 100644 index 0000000..228d62a --- /dev/null +++ b/levels/woolies_carpark/carpark.gd.uid @@ -0,0 +1 @@ +uid://baogh32opqndt diff --git a/levels/woolies_carpark/level.tscn b/levels/woolies_carpark/level.tscn new file mode 100644 index 0000000..9ce67e1 --- /dev/null +++ b/levels/woolies_carpark/level.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://levels/woolies_carpark/carpark.gd" id="1"] + +[node name="WooliesCarpark" type="Node3D"] +script = ExtResource("1") + +[node name="Spawn" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 55) diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..61a3f8d --- /dev/null +++ b/project.godot @@ -0,0 +1,43 @@ +config_version=5 + +[application] + +config/name="Burnout Infinity: Shitbox" +run/main_scene="res://src/main.tscn" +config/features=PackedStringArray("4.6", "Forward Plus") + +[display] + +window/size/viewport_width=1600 +window/size/viewport_height=900 + +[input] + +throttle={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":87,"device":-1), Object(InputEventKey,"physical_keycode":4194320,"device":-1)] +} +brake={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":83,"device":-1), Object(InputEventKey,"physical_keycode":4194322,"device":-1)] +} +steer_left={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":65,"device":-1), Object(InputEventKey,"physical_keycode":4194319,"device":-1)] +} +steer_right={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":68,"device":-1), Object(InputEventKey,"physical_keycode":4194321,"device":-1)] +} +drift={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":4194325,"device":-1)] +} +boost={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":32,"device":-1)] +} +reset={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"physical_keycode":82,"device":-1)] +} diff --git a/src/car.gd b/src/car.gd new file mode 100644 index 0000000..1ecd26b --- /dev/null +++ b/src/car.gd @@ -0,0 +1,78 @@ +class_name Car +extends RigidBody3D +## Arcade raycast car: 4 suspension rays, body-level tyre model. +## ponytail: body-level grip/drive forces -- go per-wheel only if the feel demands it. + +@export var stats: CarStats + +const REST := 0.55 # suspension rest length, m +const STEER_ANGLE := 0.6 +const STEER_SPEED := 10.0 +const DRIFT_GRIP_MULT := 0.35 +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) + +var steer := 0.0 +var boost := 0.0 # 0..1 +var boosting := false +var slip := 0.0 # sideways m/s +var spawn_xform: Transform3D + +@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR] + +func _ready() -> void: + mass = stats.mass + center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM + center_of_mass = Vector3(0, -0.3, 0) + angular_damp = 3.0 + spawn_xform = global_transform + if stats.model_path != "" and ResourceLoader.exists(stats.model_path): + $Body.visible = false + add_child((load(stats.model_path) as PackedScene).instantiate()) + +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 + + 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 + + var space := get_world_3d().direct_space_state + var grounded := 0 + for w: Node3D in wheels: + var from := w.global_position + var q := PhysicsRayQueryParameters3D.create(from, from - global_basis.y * REST) + q.exclude = [get_rid()] + var hit := space.intersect_ray(q) + if hit: + grounded += 1 + var offset := REST - from.distance_to(hit.position) + var point_vel := linear_velocity + angular_velocity.cross(from - global_position) + var spring := offset * mass * 25.0 - global_basis.y.dot(point_vel) * mass * 2.0 + if spring > 0.0: + apply_force(global_basis.y * spring, from - global_position) + + 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) + 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) + 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) + + if boosting: + boost = maxf(boost - BOOST_DRAIN * delta, 0.0) + +func speed_kmh() -> float: + return linear_velocity.length() * 3.6 diff --git a/src/car.gd.uid b/src/car.gd.uid new file mode 100644 index 0000000..666bedc --- /dev/null +++ b/src/car.gd.uid @@ -0,0 +1 @@ +uid://brt5bxt4u8k23 diff --git a/src/car.tscn b/src/car.tscn new file mode 100644 index 0000000..7f8ce9a --- /dev/null +++ b/src/car.tscn @@ -0,0 +1,30 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://src/car.gd" id="1"] + +[sub_resource type="BoxShape3D" id="shape"] +size = Vector3(1.8, 1.0, 4.2) + +[sub_resource type="BoxMesh" id="mesh"] +size = Vector3(1.8, 1.0, 4.2) + +[node name="Car" type="RigidBody3D"] +script = ExtResource("1") + +[node name="Shape" type="CollisionShape3D" parent="."] +shape = SubResource("shape") + +[node name="Body" type="MeshInstance3D" parent="."] +mesh = SubResource("mesh") + +[node name="FL" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.8, -0.3, -1.5) + +[node name="FR" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.8, -0.3, -1.5) + +[node name="RL" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.8, -0.3, 1.5) + +[node name="RR" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.8, -0.3, 1.5) diff --git a/src/car_stats.gd b/src/car_stats.gd new file mode 100644 index 0000000..6ddce8b --- /dev/null +++ b/src/car_stats.gd @@ -0,0 +1,10 @@ +class_name CarStats +extends Resource +## One car = one folder under res://cars/ with a stats.tres (this resource). +## Optional car.glb next to it, referenced via model_path. No model = placeholder box. + +@export var display_name := "Unnamed Shitbox" +@export var model_path := "" +@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 diff --git a/src/car_stats.gd.uid b/src/car_stats.gd.uid new file mode 100644 index 0000000..f4d0962 --- /dev/null +++ b/src/car_stats.gd.uid @@ -0,0 +1 @@ +uid://dtrpw74k6hge1 diff --git a/src/game.gd b/src/game.gd new file mode 100644 index 0000000..86d0b3f --- /dev/null +++ b/src/game.gd @@ -0,0 +1,64 @@ +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. + +static var car_path := "" +static var level_path := "" + +var car: Car +var cam: Camera3D +var speed_label: Label +var boost_bar: ProgressBar + +func _ready() -> void: + var level: Node = (load(level_path) as PackedScene).instantiate() + add_child(level) + + car = (load("res://src/car.tscn") as PackedScene).instantiate() + car.stats = load(car_path) + add_child(car) + var spawn := level.find_child("Spawn", true, false) + if spawn is Node3D: + car.global_transform = spawn.global_transform + car.global_position += Vector3.UP * 0.6 + + cam = Camera3D.new() + cam.fov = 72.0 + add_child(cam) + cam.global_position = car.global_position + Vector3(0, 3, 8) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-55, 30, 0) + sun.shadow_enabled = true + add_child(sun) + var env := WorldEnvironment.new() + var e := Environment.new() + e.background_mode = Environment.BG_SKY + e.sky = Sky.new() + e.sky.sky_material = ProceduralSkyMaterial.new() + env.environment = e + add_child(env) + + var hud := CanvasLayer.new() + add_child(hud) + speed_label = Label.new() + speed_label.position = Vector2(24, 24) + speed_label.add_theme_font_size_override("font_size", 32) + hud.add_child(speed_label) + boost_bar = ProgressBar.new() + boost_bar.position = Vector2(24, 72) + boost_bar.size = Vector2(220, 18) + boost_bar.show_percentage = false + hud.add_child(boost_bar) + +func _process(delta: float) -> void: + if Input.is_action_just_pressed("ui_cancel"): + get_tree().change_scene_to_file("res://src/main.tscn") + 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.value = car.boost * 100.0 diff --git a/src/game.gd.uid b/src/game.gd.uid new file mode 100644 index 0000000..b9b69a5 --- /dev/null +++ b/src/game.gd.uid @@ -0,0 +1 @@ +uid://ci8smv5mvb4fb diff --git a/src/game.tscn b/src/game.tscn new file mode 100644 index 0000000..117e646 --- /dev/null +++ b/src/game.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/game.gd" id="1"] + +[node name="Game" type="Node3D"] +script = ExtResource("1") diff --git a/src/main.gd b/src/main.gd new file mode 100644 index 0000000..f631fce --- /dev/null +++ b/src/main.gd @@ -0,0 +1,31 @@ +extends Control +## Menu: pick a car and a level from whatever folders exist, hit RACE. + +@onready var car_list: ItemList = $VBox/HBox/Cars +@onready var level_list: ItemList = $VBox/HBox/Levels + +var car_paths: Array = [] +var level_paths: Array = [] + +func _ready() -> void: + var cars := Registry.cars() + for id in cars: + var stats: CarStats = load(cars[id]) + car_list.add_item(stats.display_name) + car_paths.append(cars[id]) + var levels := Registry.levels() + for id in levels: + level_list.add_item(String(id).capitalize()) + level_paths.append(levels[id]) + if not car_paths.is_empty(): + car_list.select(0) + if not level_paths.is_empty(): + level_list.select(0) + $VBox/Start.pressed.connect(_start) + +func _start() -> void: + if car_list.get_selected_items().is_empty() or level_list.get_selected_items().is_empty(): + return + Game.car_path = car_paths[car_list.get_selected_items()[0]] + Game.level_path = level_paths[level_list.get_selected_items()[0]] + get_tree().change_scene_to_file("res://src/game.tscn") diff --git a/src/main.gd.uid b/src/main.gd.uid new file mode 100644 index 0000000..0618df2 --- /dev/null +++ b/src/main.gd.uid @@ -0,0 +1 @@ +uid://fsusisaqg5nu diff --git a/src/main.tscn b/src/main.tscn new file mode 100644 index 0000000..2fb7ba2 --- /dev/null +++ b/src/main.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/main.gd" id="1"] + +[node name="Main" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="VBox" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 40.0 +offset_top = 40.0 +offset_right = -40.0 +offset_bottom = -40.0 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="VBox"] +text = "BURNOUT INFINITY: SHITBOX" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 44 + +[node name="HBox" type="HBoxContainer" parent="VBox"] +size_flags_vertical = 3 +theme_override_constants/separation = 20 + +[node name="Cars" type="ItemList" parent="VBox/HBox"] +size_flags_horizontal = 3 + +[node name="Levels" type="ItemList" parent="VBox/HBox"] +size_flags_horizontal = 3 + +[node name="Start" type="Button" parent="VBox"] +text = "RACE" +theme_override_font_sizes/font_size = 32 diff --git a/src/registry.gd b/src/registry.gd new file mode 100644 index 0000000..d9a510c --- /dev/null +++ b/src/registry.gd @@ -0,0 +1,21 @@ +class_name Registry +## Content discovery: a car is cars//stats.tres, a level is levels//level.glb (or .tscn). +## ponytail: DirAccess scan works in dev; revisit when we ship an exported .app (pck listing differs). + +static func cars() -> Dictionary: + var out := {} + for dir in DirAccess.get_directories_at("res://cars"): + var p := "res://cars/%s/stats.tres" % dir + if ResourceLoader.exists(p): + out[dir] = p + return out + +static func levels() -> Dictionary: + var out := {} + for dir in DirAccess.get_directories_at("res://levels"): + for f in ["level.tscn", "level.glb"]: + var p := "res://levels/%s/%s" % [dir, f] + if ResourceLoader.exists(p): + out[dir] = p + break + return out diff --git a/src/registry.gd.uid b/src/registry.gd.uid new file mode 100644 index 0000000..29ed530 --- /dev/null +++ b/src/registry.gd.uid @@ -0,0 +1 @@ +uid://dmhb80pfe8b1e diff --git a/tests/screenshot.gd b/tests/screenshot.gd new file mode 100644 index 0000000..e655027 --- /dev/null +++ b/tests/screenshot.gd @@ -0,0 +1,19 @@ +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. + +func _initialize() -> void: + _run() + +func _run() -> void: + Game.car_path = Registry.cars().values()[0] + Game.level_path = Registry.levels().values()[0] + root.add_child((load("res://src/game.tscn") as PackedScene).instantiate()) + await process_frame + Input.action_press("throttle") + for i in 90: + await physics_frame + root.get_texture().get_image().save_png("res://screenshot.png") + print("screenshot saved") + quit(0) diff --git a/tests/smoke.gd b/tests/smoke.gd new file mode 100644 index 0000000..3dcb1ae --- /dev/null +++ b/tests/smoke.gd @@ -0,0 +1,29 @@ +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. + +func _initialize() -> void: + _run() + +func _run() -> void: + var cars := Registry.cars() + var levels := Registry.levels() + assert(not cars.is_empty(), "no cars found") + assert(not levels.is_empty(), "no levels found") + for p in cars.values(): + var s: CarStats = load(p) + assert(s != null and s.engine_power > 0.0, "bad stats: %s" % p) + 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 + 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()]) + quit(0) diff --git a/tests/smoke.gd.uid b/tests/smoke.gd.uid new file mode 100644 index 0000000..8c5e091 --- /dev/null +++ b/tests/smoke.gd.uid @@ -0,0 +1 @@ +uid://cgx3wtstk4snq