ShitboxInfinity/src/game.gd
m3ultra 3070a96b41 Car bodies sculpted, wheels that spin, destruction that reads
Skin pass (build_cars.py, whole procedural fleet rebuilt):
- warp_obj() kills the slab: plan taper (nose/tail pull in), tumblehome (the
  glasshouse leans inward above the belt) and rocker tuck, applied to
  world-space verts so det_ panels stay flush with the body.
- Beveled body edges catch light instead of vanishing into flat shading.
- Wheels are tyre + rim with the mesh centred on the hub and the OBJECT at the
  hub -- the old wheels baked position into their verts, so a spinning wheel
  would have orbited the car's origin. Chrome det_hub hubcaps on every corner,
  dark arch shrouds so wheels sit in wells rather than under a slab.
- B-pillars over the one-piece glasshouse, licence plates, exhaust, door
  handles. Mirrors anchored to where the tapered flank actually ends up.

Destruction feel:
- Visual wheels spin with road speed and the fronts steer (rotation order YXZ:
  yaw then roll, so steered wheels roll about the steered axle).
- Shed panels burst outward with lift instead of just detaching; light hits
  ping a hubcap off first, saving doors for crashes that deserve them.
- Sparks on every impact, soft-puff smoke on wrecks and heavy damage, tyre
  smoke while drifting (src/fx.gd -- procedural particles, radial-gradient
  billboards, no textures). Camera shake on player crashes, applied after
  look_at so it reads as the camera being knocked, not re-aimed.
- Crumple strength up ~40% now that scratches and dulling ride on it.

Traffic and parked cars inherit all of it via the shared fleet GLBs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:06:45 +10:00

1040 lines
38 KiB
GDScript

class_name Game
extends Node3D
## Loads the chosen level, spawns cars, runs the chosen mode:
## cruise -- free roam with traffic + roaming rivals (drive up close to one
## at speed to trigger an OUTRUN: first to gap the other wins)
## 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"
static var junction: Dictionary = {}
const LAPS := 3
const CRASH_TIME := 60.0
const RIVAL_COUNT := 3
## drag and derby are explicitly 0: both fall through to the RacePath traffic
## branch otherwise (every level GLB has a RacePath), littering a drag strip and
## a derby ring with loop traffic that belongs to neither event.
const TRAFFIC_COUNT := {"cruise": 8, "crash": 12, "race": 4, "drag": 0, "derby": 0}
const PARKED_MAX := 50 # PP_ markers filled with shuntable fleet cars
## Street furniture that topples. Marker prefix -> size, mass, colour.
## Deliberately physics, not scenery: clipping a lamp post at 120 and watching
## it go over is the whole point of the genre.
const SMASHABLE := {
"WB": {"size": Vector3(0.55, 1.0, 0.6), "mass": 35.0, "col": Color(0.14, 0.30, 0.13), "max": 45},
"BO": {"size": Vector3(0.22, 0.95, 0.22), "mass": 45.0, "col": Color(0.20, 0.21, 0.23), "max": 60},
"SL": {"size": Vector3(0.26, 7.4, 0.26), "mass": 190.0, "col": Color(0.24, 0.25, 0.27), "max": 60},
}
const JCT_RUN_TIME := 30.0 # crash into something before this or the run ends
const JCT_SETTLE := 9.0 # aftertouch/crashbreaker window after the crash
const ROAM_RIVALS := 2 # challengers circling the RacePath in cruise
## Pursuit (Simpsons Hit & Run's meter, our code): wreck enough street furniture
## and the law turns up. Heat decays if you behave, so it's a choice not a timer.
const HEAT_PER_SMASH := 0.14
const HEAT_DECAY := 0.055 # per second while not smashing anything
const HEAT_COOLDOWN := 20.0 # grace after an escape before heat can build again
const COPS_MIN := 2
const COPS_MAX := 4
const BUST_RANGE := 7.0 # a cop this close, this long, and you're done
const BUST_TIME := 4.0
const ESCAPE_RANGE := 220.0
const ESCAPE_TIME := 6.0
## Drag (NFSU2's, our code): the whole event is the gearbox. Revs climb, you
## shift in the window, and a blown shift costs you the race in a straight line
## where there's nothing else to get wrong.
const DRAG_GEARS := 5
const SHIFT_LO := 0.74 # revs below this = shifted early, lose drive
const REDLINE := 0.99 # hit it without shifting and the engine bogs
const DRAG_REV_RATE := [0.0, 0.62, 0.50, 0.40, 0.32, 0.26] # per gear, revs/sec
const DERBY_CARS := 5 # opponents in the arena, last one running wins
const DERBY_ARENA := 90.0 # leave the ring this long and you forfeit
const DERBY_OUT := 8.0
const OUTRUN_GAP := 250.0 # escape distance that decides an outrun (NFSU2 rule)
const OUTRUN_TIME := 90.0
const OUTRUN_START_D := 12.0 # pull alongside within this, at speed, to trigger
var jstate := "" # junction: "run" -> "settle" -> "done"
var breaker_used := false
var spinners: Array[Node3D] = [] # ferris wheels etc, turned in _process
var heat := 0.0
var heat_cd := 0.0
var cops: Array[Cop] = []
var bust_t := 0.0
var escape_t := 0.0
var busts := 0
var escapes := 0
var drag_end: Vector3
var drag_len := 0.0
var gear := 1
var revs := 0.0
var bog := 0.0 # seconds of lost drive from a blown shift
var clean_shifts := 0
var blown_shifts := 0
var drag_t := 0.0
var derby: Array[Cop] = [] # arena opponents; Cop AI already hunts a target
var derby_centre: Vector3
var derby_out_t := 0.0
var derby_kills := 0
var outrun_rival: Rival = null
var outrun_t := 0.0
var outrun_cd := 0.0 # cooldown so a finished outrun doesn't instantly re-arm
var outruns_won := 0
var 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 heat_bar: ProgressBar
var info_label: Label
var msg_label: Label
var msg_timer: SceneTreeTimer
var _sticky := false # a result screen is up; transient messages must not clobber it
var _shake := 0.0 # camera jolt on impacts, decays exponentially
func _ready() -> void:
var level: Node = (load(level_path) as PackedScene).instantiate()
add_child(level)
if mode != "junction": # junctions are deterministic puzzles: keep them clear
_spawn_smashables(level)
for w in level.find_children("wheel_of_brisbane*", "MeshInstance3D", true, false):
# The GLB bakes world coords into the mesh, so the node origin is the
# level's, not the wheel's. Hang it off a pivot at its own AABB centre.
var mi := w as MeshInstance3D
var pivot := Node3D.new()
level.add_child(pivot)
pivot.global_position = mi.global_transform * mi.get_aabb().get_center()
var keep := mi.global_transform
mi.reparent(pivot, false)
mi.global_transform = keep
spinners.append(pivot)
for p in level.find_children("*", "Path3D", true, false):
race_path = p
if String(p.name).begins_with("Race"):
break
if race_path == null:
# GLB levels can't carry Path3D: build one from a "RacePath" node of empties
var rp := level.find_child("RacePath", true, false)
if rp != null and rp.get_child_count() >= 3:
var path := Path3D.new()
var curve := Curve3D.new()
for ch in rp.get_children():
curve.add_point((ch as Node3D).global_position)
curve.add_point((rp.get_child(0) as Node3D).global_position)
path.curve = curve
level.add_child(path)
race_path = path
car = (load("res://src/car.tscn") as PackedScene).instantiate()
car.stats = load(car_path)
# place BEFORE add_child: teleporting a continuous_cd body after it enters
# the physics world sweeps the jump as motion and hits everything en route
if mode == "junction":
var sp: Dictionary = junction["spawn"]
var jf := Vector3(sp["dx"], 0, sp["dz"]).normalized()
car.transform = Transform3D(Basis.looking_at(jf, Vector3.UP),
_clear_spawn(Vector3(sp["x"], 0.7, sp["z"]), jf))
car.hold_wreck = true
# The marker test lives in the branch condition, not inside it: as an `elif`
# with the test inside, a level without a drag strip (the code-built carpark)
# matched the branch, set no transform, and left the car at the world origin
# half-sunk in the ground slab with no way to end the run.
elif mode == "drag" and _drag_markers(level).size() == 2:
var mk := _drag_markers(level)
drag_end = Vector3(mk[1].global_position.x, 0.0, mk[1].global_position.z)
var from := Vector3(mk[0].global_position.x, 0.7, mk[0].global_position.z)
var dir := (drag_end - Vector3(from.x, 0, from.z)).normalized()
drag_len = Vector3(from.x, 0, from.z).distance_to(drag_end)
car.transform = Transform3D(Basis.looking_at(dir, Vector3.UP),
_clear_spawn(from, dir) - dir.cross(Vector3.UP) * 2.0)
elif mode == "race" and race_path:
car.transform = _grid_xform(0.0)
else:
var spawn := level.find_child("Spawn", true, false)
if spawn is Node3D:
# trust only origin + yaw: DCC-exported empties arrive with mangled bases
var t: Transform3D = (spawn as Node3D).global_transform
var f := -t.basis.z
f.y = 0.0
f = f.normalized() if f.length_squared() > 0.001 else Vector3.FORWARD
car.transform = Transform3D(Basis.looking_at(f, Vector3.UP), Vector3(t.origin.x, 0.7, t.origin.z))
else:
car.position = Vector3.UP * 0.6
add_child(car)
car.crashed.connect(_player_crash)
car.wreck_started.connect(_player_wreck)
if mode == "race" and race_path:
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
var off := race_path.curve.get_baked_length() - 8.0 * (i + 1)
r.transform = _grid_xform(off)
r.progress = off
add_child(r)
laps[r] = 0
prev_off[r] = off
r.wreck_started.connect(_rival_wrecked.bind(r))
rivals.append(r)
elif mode == "derby":
# Cop AI is exactly what a derby wants: hunts a target, reverses out of
# wedges. Each one chases a different car so it's a brawl, not a mob.
derby_centre = Vector3(car.global_position.x, 0.0, car.global_position.z)
var pool := Registry.cars().values()
for i in DERBY_CARS:
var d: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate()
d.stats = load(pool[randi() % pool.size()])
d.power_boost = 1.0
d.siren = false # brawlers, not police
# a wreck is an elimination: without this car.gd's 2.2 s _recover
# timer stands them back up and the field never empties
d.hold_wreck = true
add_child(d)
var a := TAU * i / DERBY_CARS
var at := derby_centre + Vector3(cos(a), 0, sin(a)) * 26.0 + Vector3.UP * 0.75
d.global_transform = Transform3D(
Basis.looking_at((derby_centre - at).normalized() * Vector3(1, 0, 1), Vector3.UP), at)
d.wreck_started.connect(_derby_down.bind(d))
derby.append(d)
for i in derby.size():
derby[i].target = car if i % 2 == 0 else derby[(i + 1) % derby.size()]
elif mode == "drag" and drag_len > 0.0:
# one opponent, one straight -- a Path3D of two points is all the AI needs
# Run the rival down its OWN lane. Anchoring the strip on the player put
# the AI's racing line on top of the player and they simply collided.
var fwd := -car.global_basis.z
fwd = Vector3(fwd.x, 0.0, fwd.z).normalized()
var lane := fwd.cross(Vector3.UP).normalized() * 4.0
var strip := Path3D.new()
var sc := Curve3D.new()
sc.add_point(car.global_position + lane)
sc.add_point(drag_end + Vector3.UP * 0.7 + lane)
# a 2-point curve makes rival.gd's fmod() lookahead wrap to the start once
# it nears the end, spinning the car around -- extend past the line instead
sc.add_point(drag_end + Vector3.UP * 0.7 + lane + fwd * 120.0)
strip.curve = sc
add_child(strip)
var pool := Registry.cars().values()
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
r.stats = load(pool[randi() % pool.size()])
r.path = strip
add_child(r)
r.global_transform = Transform3D(Basis.looking_at(fwd, Vector3.UP),
car.global_position + lane)
r.progress = 0.0
rivals.append(r)
elif mode == "cruise" and race_path:
# roaming challengers: they lap the circuit until you pick a fight
var pool := Registry.cars().values()
var L := race_path.curve.get_baked_length()
for i in ROAM_RIVALS:
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
r.stats = load(pool[randi() % pool.size()])
r.path = race_path
var off := L * (i + 1) / float(ROAM_RIVALS + 1)
r.transform = _grid_xform(off)
r.progress = off
add_child(r)
r.wreck_started.connect(_rival_wrecked.bind(r))
rivals.append(r)
car.spawn_xform = car.global_transform
if mode == "junction":
jstate = "run"
time_left = JCT_RUN_TIME
var midx := 0
for stream in junction["streams"]:
var path := Path3D.new()
var curve := Curve3D.new()
for p in stream["pts"]:
curve.add_point(Vector3(p[0], 0, p[1]))
path.curve = curve
add_child(path)
var L := curve.get_baked_length()
var headway: float = stream["speed"] * stream["interval"]
# At least 3 per stream, spaced to fit. int(L / headway) floors to 1
# on a short clipped stream, which is how Grey x Glenelg ended up
# with 9 convoy cars against the usual 20-25 and gaps wide enough to
# drive clean through -- it scored nothing in 12 of 12 tuning runs.
var n: int = clampi(int(L / headway), 3, 14)
var spacing := L / float(n)
for i in n:
var t := TrafficCar.new()
t.path = path
t.offset = fposmod(i * spacing, L)
t.speed = stream["speed"]
t.model_idx = midx
midx += 1
add_child(t)
t.smashed.connect(_traffic_smashed.bind(t))
traffic.append(t)
elif 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.bind(t))
traffic.append(t)
cam = Camera3D.new()
cam.fov = 72.0
add_child(cam)
cam.global_position = car.global_position + Vector3(0, 3, 8)
_build_environment()
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(160, 18)
boost_bar.show_percentage = false
hud.add_child(boost_bar)
heat_bar = ProgressBar.new()
heat_bar.position = Vector2(24, 96)
heat_bar.size = Vector2(160, 10)
heat_bar.show_percentage = false
heat_bar.max_value = 1.0
heat_bar.visible = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(0.85, 0.16, 0.12)
heat_bar.add_theme_stylebox_override("fill", fill)
hud.add_child(heat_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 _drag_markers(level: Node) -> Array[Node3D]:
var out: Array[Node3D] = []
var s := level.find_child("DragStart", true, false) as Node3D
var e := level.find_child("DragEnd", true, false) as Node3D
if s and e:
out = [s, e]
return out
func _clear_spawn(pos: Vector3, fwd: Vector3) -> Vector3:
## The generator places launch points from street centrelines alone, so one
## can land inside a building or a mall canopy leg -- Albert x Adelaide was
## wedged at its start line, 1 m driven in 25 s. Back off along the approach
## until the car actually fits.
var space := get_world_3d().direct_space_state
var q := PhysicsShapeQueryParameters3D.new()
var box := BoxShape3D.new()
# must clear the ground slab: its top face is y=0 and the spawn sits at 0.7,
# so a 1.4-tall box centred there touches the ground at every candidate and
# the whole search silently fails
box.size = Vector3(2.2, 0.9, 4.8)
q.shape = box
var lift := Vector3(0, 0.25, 0)
var side := Vector3.UP.cross(fwd).normalized()
# nearest-first: try the exact spot, then widening back/side offsets. A clear
# box isn't enough -- Albert x Adelaide fitted fine but faced a wall 2.7 m
# on, so the launch corridor has to be clear too.
var tries: Array[Vector3] = [Vector3.ZERO]
# nearest-first, and allow sliding forward as well as back: Albert x Adelaide's
# generated launch sits inside a CBD building block, where nothing behind it
# is clear either. A shortened run-up beats an unplayable event.
for along in [0.0, -6.0, 6.0, -12.0, 12.0, -20.0, 20.0, -30.0, 30.0,
45.0, 60.0, 75.0, 90.0, 105.0]:
for lat in [0.0, 4.0, -4.0, 8.0, -8.0, 13.0, -13.0]:
tries.append(fwd * along + side * lat)
for off in tries:
var at := pos + off
q.transform = Transform3D(Basis.IDENTITY, at + lift)
if not space.intersect_shape(q, 1).is_empty():
continue
var ray := PhysicsRayQueryParameters3D.create(at + lift, at + lift + fwd * 22.0)
if space.intersect_ray(ray).is_empty():
if off.length() > 0.5:
push_warning("junction spawn blocked; moved %.0f m to clear the run-up"
% off.length())
return at
push_warning("junction spawn has no clear launch corridor")
return pos
func _spawn_smashables(level: Node) -> void:
## Fill the level's PP_/WB_ markers with shuntable parked cars and wheelie
## bins. Real fleet models, asleep until rammed.
var pool: Array[String] = []
var cars_dict := Registry.cars()
for id in cars_dict:
var st: CarStats = load(cars_dict[id])
if st.model_path != "" and ResourceLoader.exists(st.model_path):
pool.append(st.model_path)
pool.sort()
var n := 0
for m in level.find_children("PP_*", "Node3D", true, false):
if n >= PARKED_MAX:
break
var b := RigidBody3D.new()
b.mass = 300.0
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(1.8, 1.2, 4.3)
cs.shape = bs
b.add_child(cs)
if not pool.is_empty():
b.add_child((load(pool[randi() % pool.size()]) as PackedScene).instantiate())
add_child(b)
var t: Transform3D = (m as Node3D).global_transform
b.global_transform = Transform3D(
Basis(Vector3.UP, t.basis.get_euler().y), Vector3(t.origin.x, 0.75, t.origin.z))
b.sleeping = true
n += 1
for prefix in SMASHABLE:
var spec: Dictionary = SMASHABLE[prefix]
var size: Vector3 = spec["size"]
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = spec["col"]
mat.roughness = 0.8
mat.metallic = 0.3
mesh.material = mat
n = 0
for m in level.find_children("%s_*" % prefix, "Node3D", true, false):
if n >= int(spec["max"]):
break
var b := RigidBody3D.new()
b.mass = spec["mass"]
# low centre of mass so a clipped lamp post topples rather than
# helicoptering off into the skybox
b.center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM
b.center_of_mass = Vector3(0, -size.y * 0.3, 0)
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = size
cs.shape = bs
b.add_child(cs)
var mi := MeshInstance3D.new()
mi.mesh = mesh
b.add_child(mi)
if prefix == "SL":
# arm + lantern, visual only -- collision stays the plain pole box
var arm := MeshInstance3D.new()
var am := BoxMesh.new()
am.size = Vector3(1.5, 0.12, 0.12)
am.material = mat
arm.mesh = am
arm.position = Vector3(0.75, size.y * 0.5 - 0.25, 0)
b.add_child(arm)
var head := MeshInstance3D.new()
var hm := BoxMesh.new()
hm.size = Vector3(0.8, 0.14, 0.4)
var hmat := StandardMaterial3D.new()
hmat.albedo_color = Color(0.86, 0.84, 0.72)
hmat.emission_enabled = true
hmat.emission = Color(0.9, 0.85, 0.6)
hmat.emission_energy_multiplier = 0.35
hm.material = hmat
head.mesh = hm
head.position = Vector3(1.45, size.y * 0.5 - 0.36, 0)
b.add_child(head)
add_child(b)
var t: Transform3D = (m as Node3D).global_transform
b.global_transform = Transform3D(
Basis(Vector3.UP, t.basis.get_euler().y),
Vector3(t.origin.x, size.y * 0.5 + 0.02, t.origin.z))
b.sleeping = true
n += 1
func _build_environment() -> void:
## Brisbane midday: hard high sun, deep blue zenith, everything a bit blown out.
## The bare default (no tonemap, grey ambient) rendered the city flat and dead.
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-62, 38, 0)
sun.light_color = Color(1.0, 0.96, 0.88)
sun.light_energy = 1.25
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 220.0
sun.directional_shadow_split_1 = 0.05
sun.directional_shadow_split_2 = 0.15
sun.directional_shadow_split_3 = 0.45
sun.directional_shadow_blend_splits = true
sun.shadow_bias = 0.035
sun.shadow_normal_bias = 1.4
add_child(sun)
# a dim unshadowed fill from the opposite side so shadowed facades keep their
# texture instead of crushing to black
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-28, -145, 0)
fill.light_color = Color(0.86, 0.89, 0.96)
fill.light_energy = 0.22
fill.shadow_enabled = false
add_child(fill)
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = Color(0.27, 0.46, 0.74)
sky_mat.sky_horizon_color = Color(0.80, 0.86, 0.92)
sky_mat.sky_energy_multiplier = 1.0
sky_mat.ground_bottom_color = Color(0.28, 0.28, 0.30)
sky_mat.ground_horizon_color = Color(0.66, 0.72, 0.78)
sky_mat.sun_angle_max = 4.0
sky_mat.sun_curve = 0.08
var e := Environment.new()
e.background_mode = Environment.BG_SKY
e.sky = Sky.new()
e.sky.sky_material = sky_mat
e.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
# a street canyon is lit almost entirely by ambient, so pure sky ambient
# painted the whole city blue -- blend it half-and-half with neutral
e.ambient_light_sky_contribution = 0.5
e.ambient_light_color = Color(0.88, 0.87, 0.84)
e.ambient_light_energy = 0.55
e.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
e.tonemap_mode = Environment.TONE_MAPPER_ACES
e.tonemap_exposure = 0.6
e.tonemap_white = 5.0
e.ssao_enabled = true # contact shade is what makes the massing read
e.ssao_radius = 3.0
e.ssao_intensity = 1.6
e.ssao_power = 1.4
e.ssao_light_affect = 0.25
e.glow_enabled = true # sun off glass, and the Crashbreaker fireball
e.glow_intensity = 0.5
e.glow_bloom = 0.08
e.glow_hdr_threshold = 1.3
e.fog_enabled = true # hides the level's hard edge, gives the CBD depth
e.fog_mode = Environment.FOG_MODE_DEPTH
e.fog_light_color = Color(0.74, 0.82, 0.92)
e.fog_density = 0.0
e.fog_depth_begin = 190.0
e.fog_depth_end = 900.0
e.fog_depth_curve = 0.7
e.fog_sun_scatter = 0.15
var env := WorldEnvironment.new()
env.environment = e
add_child(env)
func _exit_tree() -> void:
Engine.time_scale = 1.0
func _grid_xform(off: float) -> Transform3D:
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
var dir := (ahead - pos).normalized()
return Transform3D(Basis.looking_at(dir if dir.length_squared() > 0.001 else Vector3.FORWARD, Vector3.UP), pos)
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()
if mode == "cruise" and race_path:
_outrun_tick(delta)
if mode == "cruise":
_pursuit_tick(delta)
if mode == "drag" and not over:
_drag_tick(delta)
if mode == "derby" and not over:
_derby_tick(delta)
if mode == "junction" and jstate == "settle":
if Input.is_action_just_pressed("boost") and not breaker_used:
_crashbreaker()
if car.wrecked: # aftertouch: nudge the tumbling wreck, camera-relative
var right := cam.global_basis.x
right.y = 0
var fwd := -cam.global_basis.z
fwd.y = 0
var push := right.normalized() * Input.get_axis("steer_left", "steer_right") \
+ fwd.normalized() * Input.get_axis("brake", "throttle")
car.apply_central_force(push * car.mass * 9.0)
func _derby_down(d: Cop) -> void:
if over:
return # no bounties after the result screen is up
if not car.wrecked and car.global_position.distance_to(d.global_position) < 22.0:
derby_kills += 1
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("WRECKED 'EM! x%d" % derby_kills, 46)
func _derby_tick(delta: float) -> void:
var alive: Array[Cop] = []
for d in derby:
if is_instance_valid(d) and not d.wrecked:
alive.append(d)
# retarget anyone hunting a car that's already out, or they circle a corpse
for d in alive:
if d.target == null or not is_instance_valid(d.target) \
or (d.target is Cop and (d.target as Cop).wrecked):
d.target = car if alive.size() < 2 else alive[randi() % alive.size()]
if d.target == d:
d.target = car
var flat := Vector3(car.global_position.x, 0.0, car.global_position.z)
var from_centre := flat.distance_to(derby_centre)
if from_centre > DERBY_ARENA:
derby_out_t += delta
else:
derby_out_t = maxf(derby_out_t - delta, 0.0)
info_label.text = "DERBY %d left %s" % [
alive.size(),
"GET BACK IN %ds" % ceili(DERBY_OUT - derby_out_t) if derby_out_t > 0.5 else "%d wrecked" % derby_kills]
if car.wrecked:
over = true
_msg("WRECKED\n%d of %d taken out\nR to retry, Esc for menu"
% [derby_kills, DERBY_CARS], 44, true)
elif derby_out_t >= DERBY_OUT:
over = true
_msg("RAN AWAY\nR to retry, Esc for menu", 44, true)
elif alive.is_empty():
over = true
_msg("LAST ONE RUNNING\n%d wrecked\nR to retry, Esc for menu" % derby_kills, 48, true)
func _drag_tick(delta: float) -> void:
if drag_len <= 0.0:
info_label.text = "no drag strip in this level"
return
drag_t += delta
bog = maxf(bog - delta, 0.0)
if gear <= DRAG_GEARS:
revs = minf(revs + DRAG_REV_RATE[gear] * delta * (1.0 if bog == 0.0 else 0.25), 1.0)
if Input.is_action_just_pressed("boost"):
if revs >= SHIFT_LO and revs < REDLINE:
clean_shifts += 1
Sfx.shot(car, "clunk", -2.0)
_msg("CLEAN SHIFT", 34)
else:
blown_shifts += 1
bog = 0.9
Sfx.shot(car, "bog", -2.0)
_msg("MISSED IT", 34)
gear += 1
revs = 0.28 if gear <= DRAG_GEARS else 1.0
elif revs >= REDLINE:
bog = maxf(bog, 0.35) # sitting on the limiter goes nowhere
# drive comes from the box, not the throttle: clean shifts pay, bogging hurts
var tune := 1.0 + clean_shifts * 0.07 - blown_shifts * 0.05
# clamp the gear term: gear runs to DRAG_GEARS+1 as the "past top" sentinel,
# and un-clamped that paid 6/5 -- more grunt than top gear for shifting into nothing
car.power_scale = (0.35 if bog > 0.0 else tune) \
* (0.75 + 0.25 * mini(gear, DRAG_GEARS) / float(DRAG_GEARS))
var flat := Vector3(car.global_position.x, 0.0, car.global_position.z)
var left := flat.distance_to(drag_end)
var rival_left := INF
if not rivals.is_empty():
var rf := Vector3(rivals[0].global_position.x, 0.0, rivals[0].global_position.z)
rival_left = rf.distance_to(drag_end)
info_label.text = "GEAR %d %s %dm %.1fs" % [
mini(gear, DRAG_GEARS),
("BOGGED" if bog > 0.0 else ("SHIFT!" if revs >= SHIFT_LO else "%d%%" % int(revs * 100))),
int(left), drag_t]
# the rival crossing has to end it too, or losing means the run never stops
if left < 12.0 or rival_left < 12.0:
over = true
car.power_scale = 1.0 # _drag_tick stops on `over`, so reset it here
var won := left <= rival_left
_msg("%s %.2fs\n%d clean / %d blown\nR to retry, Esc for menu"
% ["WON" if won else "LOST", drag_t, clean_shifts, blown_shifts], 44, true)
func add_heat(amount: float) -> void:
if mode != "cruise" or heat_cd > 0.0 or not cops.is_empty():
return
heat = minf(heat + amount, 1.0)
if heat >= 1.0:
_start_pursuit()
func _start_pursuit() -> void:
var pool := Registry.cars().values()
var n := COPS_MIN + (busts + escapes) / 2 # they escalate as the night goes on
for i in mini(n, COPS_MAX):
var c: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate()
c.stats = load(pool[randi() % pool.size()])
c.target = car
c.power_boost = 1.12
c.hold_wreck = true # wrecked cop stays out of the chase
add_child(c)
# Drop them in behind, spread across the road, clear of the player.
# Flattened: multiplying by the full basis while the player is pitched
# (mid-jump, up a kerb) spawns cops in the air or under the road.
var flat_b := Basis.looking_at(
Vector3(-car.global_basis.z.x, 0.0, -car.global_basis.z.z).normalized(), Vector3.UP)
var back := flat_b * Vector3((i - 0.5) * 4.0, 0.0, 22.0 + i * 5.0)
c.global_transform = Transform3D(
Basis.looking_at(-back.normalized(), Vector3.UP), car.global_position + back)
c.wreck_started.connect(_cop_wrecked.bind(c))
cops.append(c)
bust_t = 0.0
escape_t = 0.0
_msg("WANTED!\nlose them", 48)
func _cop_wrecked(c: Cop) -> void:
# taking one out is worth the same as any takedown
if not car.wrecked and car.global_position.distance_to(c.global_position) < 25.0:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
_msg("COP DOWN!", 48)
func _end_pursuit(escaped: bool) -> void:
for c in cops:
if is_instance_valid(c):
c.queue_free()
cops.clear()
heat = 0.0
heat_cd = HEAT_COOLDOWN
info_label.text = "" # nothing else writes this in cruise, so "WANTED" would stick
if escaped:
escapes += 1
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("LOST 'EM! x%d" % escapes, 52)
else:
busts += 1
Sfx.shot(car, "sting_lose", -4.0)
_msg("BUSTED", 52)
func _pursuit_tick(delta: float) -> void:
heat_cd = maxf(heat_cd - delta, 0.0)
if cops.is_empty():
if heat > 0.0:
heat = maxf(heat - HEAT_DECAY * delta, 0.0)
return
cops = cops.filter(func(c): return is_instance_valid(c))
if cops.is_empty():
_end_pursuit(true)
return
var nearest := INF
for c in cops:
if not c.wrecked:
nearest = minf(nearest, car.global_position.distance_to(c.global_position))
if nearest == INF: # every cop wrecked = you won
_end_pursuit(true)
return
if nearest < BUST_RANGE and not car.wrecked:
bust_t += delta
escape_t = 0.0
else:
bust_t = maxf(bust_t - delta * 0.5, 0.0)
if nearest > ESCAPE_RANGE:
escape_t += delta
else:
escape_t = 0.0
info_label.text = "WANTED %dm %s" % [
int(nearest),
("BUSTED IN %ds" % ceili(BUST_TIME - bust_t)) if bust_t > 1.0
else ("LOSING THEM %ds" % ceili(ESCAPE_TIME - escape_t)) if escape_t > 0.5
else "%d cars" % cops.size()]
if car.wrecked and nearest < 40.0:
_end_pursuit(false)
elif bust_t >= BUST_TIME:
_end_pursuit(false)
elif escape_t >= ESCAPE_TIME:
_end_pursuit(true)
func _outrun_tick(delta: float) -> void:
## NFSU2-style impromptu 1v1: pull alongside a roaming rival at speed and it's
## on. Straight-line distance decides WHEN it ends (so ducking down a side
## street is a real escape); path order decides WHO won (so reversing away
## from the circuit is a loss, not a win).
outrun_cd = maxf(outrun_cd - delta, 0.0)
var L := race_path.curve.get_baked_length()
if outrun_rival == null:
if outrun_cd > 0.0 or car.wrecked:
return
for r in rivals:
if not r.wrecked and car.linear_velocity.length() > 8.0 \
and car.global_position.distance_to(r.global_position) < OUTRUN_START_D:
outrun_rival = r
outrun_t = OUTRUN_TIME
laps[car] = 0
laps[r] = 0
prev_off[car] = _nearest_off(car.global_position, r.progress, L)
prev_off[r] = r.progress
_msg("OUTRUN!\ngap them by %dm" % int(OUTRUN_GAP), 44)
return
return
var r := outrun_rival
outrun_t -= delta
if Input.is_action_just_pressed("reset"): # R teleports home: that's a forfeit
_outrun_end(false, "bailed")
return
prev_off[car] = _lap_step(car, _nearest_off(car.global_position, prev_off[car], L), L)
prev_off[r] = _lap_step(r, r.progress, L)
var my_total: float = laps[car] * L + prev_off[car]
var rt: float = laps[r] * L + prev_off[r]
var ahead := my_total >= rt
# rubber-band: the rival hunts when losing, coasts a touch when winning
r.power_scale = clampf(1.0 + (my_total - rt) / 250.0, 0.85, 1.4)
var d := car.global_position.distance_to(r.global_position)
info_label.text = "OUTRUN %s %dm / %dm %ds" % ["LEAD" if ahead else "CHASE", int(d), int(OUTRUN_GAP), ceili(outrun_t)]
if car.wrecked:
_outrun_end(false, "wrecked")
elif r.wrecked and d < 60.0:
_outrun_end(true, "rival wrecked")
elif d > OUTRUN_GAP:
_outrun_end(ahead, "")
elif outrun_t <= 0.0:
_outrun_end(false, "time up")
func _outrun_end(won: bool, why: String) -> void:
outrun_rival.power_scale = 1.0
outrun_rival = null
outrun_cd = 8.0
info_label.text = ""
var tail := ("\n(%s)" % why) if why != "" else ""
if won:
outruns_won += 1
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) # takedown-tier reward
car.boost = car.boost_max
Sfx.shot(car, "sting_win", -4.0)
_msg("OUTRUN WON! x%d%s" % [outruns_won, tail], 52)
else:
Sfx.shot(car, "sting_lose", -4.0)
_msg("OUTRUN LOST%s" % tail, 44)
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:
_shake = maxf(_shake, clampf(dv / 16.0, 0.12, 0.65))
if mode in ["crash", "junction"] and not over:
score += minf(dv, 30.0) * 120.0
if dv > 3.0:
add_heat(HEAT_PER_SMASH * minf(dv / 6.0, 2.0))
if mode == "junction" and jstate == "run" and dv > 5.0:
jstate = "settle"
time_left = JCT_SETTLE
_msg("CRASH!\narrows = aftertouch space = crashbreaker", 36)
return
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
Sfx.shot(car, "sting_win", -4.0)
_msg("TAKEDOWN!", 56)
func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
if is_instance_valid(t):
Fx.sparks(t, clampi(int(impulse * 2.0), 12, 40))
if mode in ["crash", "junction"] and not over:
score += impulse * 400.0
_msg("$%d" % int(impulse * 400.0), 40)
car.earn_boost(0.2)
add_heat(HEAT_PER_SMASH)
func _crashbreaker() -> void:
breaker_used = true
score += 2000.0
var origin := car.global_position
var q := PhysicsShapeQueryParameters3D.new()
var sphere := SphereShape3D.new()
sphere.radius = 14.0
q.shape = sphere
q.transform = Transform3D(Basis.IDENTITY, origin)
var hits := get_world_3d().direct_space_state.intersect_shape(q, 64)
for h in hits:
var b: Object = h.collider
if b == car or not b is RigidBody3D:
continue
var dir: Vector3 = (b as RigidBody3D).global_position - origin
var dist := dir.length()
dir = (dir / maxf(dist, 0.5) + Vector3.UP * 0.6).normalized()
var strength := 16.0 / (1.0 + dist * 0.25)
if b is TrafficCar:
(b as TrafficCar).blast(dir * strength)
score += 500.0
else:
(b as RigidBody3D).apply_central_impulse(dir * strength * (b as RigidBody3D).mass)
car.apply_central_impulse(Vector3.UP * car.mass * 7.0)
Sfx.shot(car, "boom", 0.0)
_msg("CRASHBREAKER!", 52)
var boom := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 1.0
sm.height = 2.0
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.1, 0.7)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.05)
sm.material = mat
boom.mesh = sm
add_child(boom)
boom.global_position = origin
var tw := create_tween()
tw.tween_property(boom, "scale", Vector3.ONE * 14.0, 0.45)
tw.parallel().tween_property(boom, "transparency", 1.0, 0.45)
tw.tween_callback(boom.queue_free)
func _jct_tally() -> void:
jstate = "done"
over = true
var t: Array = junction.get("targets", [15000, 35000, 60000])
var medal := "NO MEDAL, MATE"
if score >= float(t[2]):
medal = "GOLD!"
elif score >= float(t[1]):
medal = "SILVER"
elif score >= float(t[0]):
medal = "BRONZE"
_msg("DAMAGE: $%d\n%s\nR to retry, Esc for menu" % [int(score), medal], 48, true)
func _msg(text: String, size: int, sticky := false) -> void:
# a sticky message is a result screen; don't let a stray "NEAR MISS" from a
# still-rolling wreck overwrite it a moment later
if _sticky and not sticky:
return
_sticky = sticky
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 mode == "junction" and not over:
time_left -= delta
info_label.text = "$%d %d s" % [int(score), ceili(time_left)]
if time_left <= 0.0:
if jstate == "run":
_msg("Didn't even crash. Cooked.", 40)
_jct_tally()
# drag and derby both tell you "R to retry", so they have to honour it too
if mode == "race" and Input.is_action_just_pressed("reset"):
# car.gd teleports the player home on R; without resyncing the lap
# tracker, _nearest_off's 25 m local window walks the offset around the
# loop and silently books phantom laps
for c in laps:
prev_off[c] = 0.0 if c == car else (c as Rival).progress
laps[car] = 0
if over and mode in ["crash", "junction", "drag", "derby"] \
and Input.is_action_just_pressed("reset"):
get_tree().reload_current_scene()
return
for sp in spinners:
sp.rotate_x(delta * 0.09) # a full turn takes ~70 s, same as the real one
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)
if _shake > 0.005:
# post-look_at, so the jolt reads as the camera being knocked, not re-aimed
cam.global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1),
randf_range(-1, 1)) * _shake * 0.35
cam.rotation.z += randf_range(-1, 1) * _shake * 0.03
_shake *= exp(-6.0 * delta)
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
heat_bar.visible = mode == "cruise" and heat > 0.02 and cops.is_empty()
heat_bar.value = heat