ShitboxInfinity/src/game.gd
m3ultra a8fed95d42 Mechanical carnage: burnouts, grenaded gearboxes, blown head gaskets
The drag strip now has consequences on both sides of the green.

Burnouts: DRIFT+GAS in the staging box lights up the rears -- fat white
FLUX-free tyre smoke (new Fx.burnout emitter), fake rear-wheel spin via
car.spin_override, the skid loop singing while stationary -- and builds
TYRE TEMP. Warm tyres hook up off the line, cold ones spin the launch
away: a traction factor (0.78 cold to 1.20 hot) applies for the first
150 m. The burnout holds the car with power_scale 0, so it can never
red-light you; releasing into a creep still can, as is right.

Gearbox: blown shifts are now strikes. Three in one run and the box
grenades -- a panel bangs off the back (car.mech_pop), the lever goes
dead (stuck in whatever gear you murdered it into, 55% drive), and the
car starts LEAKING: oil patches spawn behind it every 7 m of travel and
sit on the strip for 30 s. Everyone can see what you did.

Coolant: riding the limiter cooks ENG TEMP at 0.30/s (boost adds a
little; driving clean cools). At 100% the head gasket lets go: a steam
jet out of the bonnet (Fx.steamer), 72% power for the rest of the run,
and COOLANT EVERYWHERE. Sitting on the redline refusing to shift is now
a self-solving problem.

HUD shows tyre temp in staging and OVERHEAT/BOX GRENADED/GASKET
STEAMING flags in the run; the result screen commemorates both failures.
Smoke suite: burnout warms without tripping the beams, three panic
shifts grenade the box and lock the gear, the leak flag sets, and a
forced overtemp pops the gasket.

Next: these failures become persistent per-car faults you repair in the
shed with a drag/click/twist minigame and the Cashies code reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:26:57 +10:00

1392 lines
53 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 time_of_day := "day" # day | dusk | night
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
## The Willowbank ladder: preset rivals, weakest first. power is their
## power_scale once they launch, reaction is how long after the green they sit
## there blinking, purse is what beating them pays. Beat the rung you're on to
## climb; the top dog repeats and keeps paying.
const DRAG_LADDER := [
{"name": "NANNA'S SHOPPING RUN", "car": "morry", "power": 0.55, "reaction": 0.85, "purse": 250},
{"name": "P-PLATE PEPPER", "car": "excel_x3", "power": 0.78, "reaction": 0.55, "purse": 400},
{"name": "MULLET MERV", "car": "kingswood_hq", "power": 0.98, "reaction": 0.40, "purse": 600},
{"name": "TORQUE'N TEZZA", "car": "vl_terbo", "power": 1.12, "reaction": 0.30, "purse": 850},
{"name": "SELF-DESCRIBED BATHURST LEGEND", "car": "floored_xd", "power": 1.26, "reaction": 0.22, "purse": 1200},
{"name": "THE WILLOWBANK WIDOWMAKER", "car": "falcodore_gtho", "power": 1.45, "reaction": 0.15, "purse": 1600},
]
const TREE_AMBERS := [1.2, 1.7, 2.2] # seconds into staging, sportsman tree
const TREE_GREEN := 2.7
const DRAG_FINISH_PAY := 50 # crossing the line at all
const DRAG_PB_PAY := 150 # beating your own best
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
## Road Rage (Burnout 3's headline mode): 90 s, wreck as many rivals as you can.
## They recover and keep driving, so the supply never dries up.
const RAGE_TIME := 90.0
const RAGE_RIVALS := 4
const RAGE_TARGETS := [4, 8, 12] # bronze / silver / gold takedowns
const RAGE_LEASH := 220.0 # rivals further than this teleport back into play
## Oncoming: driving against the traffic flow on the road earns boost -- the
## canon Burnout risk/reward loop.
const ONCOMING_EARN := 0.22 # boost per second while in the oncoming lane
const ONCOMING_LANE_D := 7.0 # how close to the road line counts as "on the road"
const ONCOMING_SPEED := 12.0
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_end_y := 0.0
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 # elapsed time, counted from the GREEN
var drag_state := "" # "stage" (at the tree) -> "run" (green light)
var stage_t := 0.0
var jumped := false # rolled out of the beams before the green
var drag_rung_now := 0 # index into DRAG_LADDER for this event
var rival_reaction := 0.0
var rival_et := 0.0 # rival's ET once it crosses; 0 = hasn't yet
var rival_wait := 0.0 # grace timer after the rival crosses first
var _tree_bulbs := {} # "TreeGreen" etc -> MeshInstance3D (dragway only)
var _launch_pos := Vector3.ZERO
## Mechanical sympathy: none of these reset until the scene does.
var tyre_temp := 0.0 # 0 cold .. 1 hot, built in the burnout box
var eng_temp := 0.0 # limiter abuse; at 1.0 the head gasket lets go
var box_strikes := 0 # blown shifts this run; 3 grenades the gearbox
var box_blown := false
var gasket_blown := false
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 rage_downs := 0
var _road_off := 0.0 # player's tracked offset along the road line
var _oncoming_run := 0.0
var _oncoming_toast := 0.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"], float(sp.get("y", 0.0)) + 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)
drag_end_y = mk[1].global_position.y
var from := Vector3(mk[0].global_position.x, mk[0].global_position.y + 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, t.origin.y + 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 * (drag_end_y + 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 * (drag_end_y + 0.7) + lane + fwd * 120.0)
strip.curve = sc
add_child(strip)
# the ladder: your current rung decides who's in the other lane
drag_rung_now = mini(Garage.drag_rung(), DRAG_LADDER.size() - 1)
var rung: Dictionary = DRAG_LADDER[drag_rung_now]
rival_reaction = float(rung["reaction"])
var cars_dict := Registry.cars()
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
r.stats = load(cars_dict[rung["car"]]) if cars_dict.has(rung["car"]) \
else load(cars_dict.values()[randi() % cars_dict.size()])
r.path = strip
r.power_scale = 0.0 # held at the tree until green + reaction time
add_child(r)
r.global_transform = Transform3D(Basis.looking_at(fwd, Vector3.UP),
car.global_position + lane)
r.progress = 0.0
rivals.append(r)
drag_state = "stage"
_launch_pos = car.global_position
# the Willowbank tree, if this level has one: bulbs are MeshInstance3Ds
# with an emission material_override that _bulb() switches on and off
for bn in ["TreeStage", "TreeAmber1", "TreeAmber2", "TreeAmber3", "TreeGreen", "TreeRed"]:
var b := level.find_child(bn, true, false)
if b is MeshInstance3D:
_tree_bulbs[bn] = b
elif mode == "rage" and race_path:
time_left = RAGE_TIME
var pool := Registry.cars().values()
for i in RAGE_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 := race_path.curve.get_baked_length() * (i + 1) / float(RAGE_RIVALS + 1)
r.transform = _grid_xform(off)
r.progress = off
add_child(r)
r.wreck_started.connect(_rival_wrecked.bind(r))
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"]:
# third component (when baked) is terrain height
curve.add_point(Vector3(p[0], float(p[2]) if p.size() > 2 else 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, t.origin.y + 0.72, 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 if time_of_day != "night" else 3.5
hm.material = hmat
head.mesh = hm
head.position = Vector3(1.45, size.y * 0.5 - 0.36, 0)
b.add_child(head)
if time_of_day == "night":
# a real light under the lantern -- and because it's parented
# to the physics body, toppling the lamp takes its pool of
# light with it, which is deeply satisfying
var ol := OmniLight3D.new()
ol.light_color = Color(1.0, 0.87, 0.55)
ol.light_energy = 1.6
ol.omni_range = 13.0
ol.omni_attenuation = 1.4
ol.position = head.position - Vector3(0, 0.5, 0)
b.add_child(ol)
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, t.origin.y + size.y * 0.5 + 0.02, t.origin.z))
b.sleeping = true
n += 1
func _build_environment() -> void:
## Three Brisbanes: midday (hard high sun), dusk (low orange sun under a FLUX
## panorama) and night (moonlight, working street lamps, headlights).
var sun := DirectionalLight3D.new()
match time_of_day:
"dusk":
sun.rotation_degrees = Vector3(-11, 55, 0)
sun.light_color = Color(1.0, 0.62, 0.32)
sun.light_energy = 0.85
"night":
sun.rotation_degrees = Vector3(-48, -20, 0)
sun.light_color = Color(0.55, 0.65, 0.90) # moon
sun.light_energy = 0.14
_:
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)
# dusk/night ride FLUX panoramas; day keeps the procedural gradient
var sky_mat: Material
var pano := "res://assets/textures/sky_%s.jpg" % time_of_day
if time_of_day != "day" and ResourceLoader.exists(pano):
var pm := PanoramaSkyMaterial.new()
pm.panorama = load(pano)
pm.energy_multiplier = 1.3 if time_of_day == "dusk" else 0.9
sky_mat = pm
else:
var gm := ProceduralSkyMaterial.new()
gm.sky_top_color = Color(0.27, 0.46, 0.74)
gm.sky_horizon_color = Color(0.80, 0.86, 0.92)
gm.sky_energy_multiplier = 1.0
gm.ground_bottom_color = Color(0.28, 0.28, 0.30)
gm.ground_horizon_color = Color(0.66, 0.72, 0.78)
gm.sun_angle_max = 4.0
gm.sun_curve = 0.08
sky_mat = gm
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
match time_of_day:
"dusk":
e.ambient_light_color = Color(0.80, 0.62, 0.55)
e.ambient_light_energy = 0.38
"night":
e.ambient_light_color = Color(0.35, 0.40, 0.55)
e.ambient_light_energy = 0.16
_:
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
if time_of_day == "night":
e.glow_intensity = 0.8 # lamp heads and tail lights bloom
e.fog_light_color = Color(0.10, 0.11, 0.16)
elif time_of_day == "dusk":
e.fog_light_color = Color(0.55, 0.38, 0.30)
var env := WorldEnvironment.new()
env.environment = e
add_child(env)
if time_of_day == "night":
# headlights on the player: two warm spots raking the road ahead
for sx in [-0.55, 0.55]:
var hl := SpotLight3D.new()
hl.light_color = Color(1.0, 0.94, 0.78)
hl.light_energy = 4.0
hl.spot_range = 42.0
hl.spot_angle = 28.0
hl.spot_angle_attenuation = 1.6
hl.position = Vector3(sx, 0.15, -car.stats.length * 0.48)
hl.rotation_degrees = Vector3(-4.0, 180.0, 0)
car.add_child(hl)
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 == "rage" and not over:
_rage_tick()
if mode in ["cruise", "race", "rage"] and race_path and not car.wrecked:
_oncoming_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)
Garage.earn(100)
_msg("WRECKED 'EM! x%d +$100" % 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
Garage.earn(800)
_msg("LAST ONE RUNNING\n%d wrecked +$800\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
if drag_state == "stage":
_stage_tick(delta)
return
drag_t += delta
bog = maxf(bog - delta, 0.0)
# the rival launches when its reaction time is up -- Nanna sits at the green
# for most of a second; the Widowmaker leaves with you
if not rivals.is_empty():
rivals[0].power_scale = 0.0 if drag_t < rival_reaction \
else float(DRAG_LADDER[drag_rung_now]["power"])
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") and not box_blown:
if revs >= SHIFT_LO and revs < REDLINE:
clean_shifts += 1
Sfx.shot(car, "clunk", -2.0)
_msg("CLEAN SHIFT", 34)
else:
blown_shifts += 1
box_strikes += 1
bog = 0.9
Sfx.shot(car, "bog", -2.0)
if box_strikes >= 3:
# third mistreated shift: the box keeps what's in it
box_blown = true
car.mech_pop()
car.leaking = true
_msg("GEARBOX GRENADED!\nSTUCK IN %d, LEAKING" % mini(gear + 1, DRAG_GEARS), 44)
else:
_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
# coolant: riding the limiter cooks the engine; at 1.0 the head gasket goes
if gear <= DRAG_GEARS and revs >= REDLINE:
eng_temp += delta * 0.30
elif car.boosting:
eng_temp = minf(eng_temp + delta * 0.06, 0.99 if not gasket_blown else 1.0)
else:
eng_temp = maxf(eng_temp - delta * 0.04, 0.0)
if eng_temp >= 1.0 and not gasket_blown:
gasket_blown = true
car.set_steam(true)
Sfx.shot(car, "bog", -2.0, 0.55)
_msg("HEAD GASKET!\nCOOLANT EVERYWHERE", 44)
# 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
# launch traction: warm tyres hook up, cold ones spin it away off the line
var gone := Vector3(car.global_position.x, 0, car.global_position.z).distance_to(
Vector3(_launch_pos.x, 0, _launch_pos.z))
var traction := (0.78 + 0.42 * tyre_temp) if gone < 150.0 else 1.0
# 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)) \
* traction * (0.55 if box_blown else 1.0) * (0.72 if gasket_blown else 1.0)
var flat := Vector3(car.global_position.x, 0.0, car.global_position.z)
var left := flat.distance_to(drag_end)
if rival_et == 0.0 and not rivals.is_empty():
var rf := Vector3(rivals[0].global_position.x, 0.0, rivals[0].global_position.z)
if rf.distance_to(drag_end) < 12.0:
rival_et = drag_t
var pb := Garage.drag_pb(Garage.car_id_of(car_path))
var mech := ""
if box_blown:
mech += " BOX GRENADED"
if gasket_blown:
mech += " GASKET STEAMING"
elif eng_temp > 0.6:
mech += " ENG %d%%!" % int(eng_temp * 100)
info_label.text = "VS %s%s\nGEAR %d %s %dm %.1fs%s" % [
DRAG_LADDER[drag_rung_now]["name"],
(" | PB %.2f" % pb) if pb > 0.0 else "",
mini(gear, DRAG_GEARS),
("BOGGED" if bog > 0.0 else ("SHIFT!" if revs >= SHIFT_LO else "%d%%" % int(revs * 100))),
int(left), drag_t, mech]
# the event ends when YOU cross: losing to the rival mustn't rob you of your
# ET and PB. If the rival's crossed and you dawdle 10 s, it gets called.
if rival_et > 0.0:
rival_wait += delta
if left < 12.0:
_drag_finish(true)
elif rival_wait > 10.0:
_drag_finish(false)
func _stage_tick(delta: float) -> void:
## At the tree. The RIVAL is held by the electronics; you're held by nothing
## but your own discipline, which is the whole point of a christmas tree.
var was := stage_t
stage_t += delta
if not rivals.is_empty():
rivals[0].power_scale = 0.0
# the burnout box: DRIFT+GAS lights up the rears -- smoke pours, tyres warm,
# and the handbrake holds you off the beams so it can't red-light you
var burning := Input.is_action_pressed("drift") \
and Input.get_axis("brake", "throttle") > 0.2 and not car.wrecked
car.set_burnout(burning)
if burning:
car.power_scale = 0.0
tyre_temp = minf(tyre_temp + delta * 0.30, 1.0)
else:
car.power_scale = 0.8
if was <= 0.0:
_bulb("TreeStage", true)
for i in TREE_AMBERS.size():
if was < float(TREE_AMBERS[i]) and stage_t >= float(TREE_AMBERS[i]):
_bulb("TreeAmber%d" % (i + 1), true)
Sfx.shot_2d("clunk", -10.0)
if not jumped and car.global_position.distance_to(_launch_pos) > 2.0:
jumped = true
_bulb("TreeRed", true)
Sfx.shot(car, "bog", -2.0)
_msg("RED LIGHT", 44)
var rung: Dictionary = DRAG_LADDER[drag_rung_now]
var pb := Garage.drag_pb(Garage.car_id_of(car_path))
info_label.text = "VS %s%s\nTYRES %d%% (DRIFT+GAS to burn out)\nHOLD FOR THE GREEN..." % [
rung["name"], (" | PB %.2fs" % pb) if pb > 0.0 else "", int(tyre_temp * 100)]
if stage_t >= TREE_GREEN:
drag_state = "run"
car.set_burnout(false)
for i in 3:
_bulb("TreeAmber%d" % (i + 1), false)
_bulb("TreeGreen", true)
Sfx.shot_2d("clunk", -2.0)
if jumped:
bog = 1.6 # you launched at a light that wasn't green
_msg("GO! (BOGGED: RED LIGHT)", 44)
else:
_msg("GO!", 52)
func _drag_finish(crossed: bool) -> void:
over = true
car.power_scale = 1.0 # _drag_tick stops on `over`, so reset it here
var rung: Dictionary = DRAG_LADDER[drag_rung_now]
var won := crossed and (rival_et == 0.0 or drag_t <= rival_et)
var pay := DRAG_FINISH_PAY if crossed else 0
var lines: Array[String] = []
if crossed:
lines.append("%s vs %s -- %.2fs" % ["WON" if won else "LOST", rung["name"], drag_t])
if Garage.record_drag_pb(Garage.car_id_of(car_path), drag_t):
pay += DRAG_PB_PAY
lines.append("NEW PERSONAL BEST +$%d" % DRAG_PB_PAY)
else:
lines.append("LOST vs %s (never crossed)" % rung["name"])
if won:
pay += int(rung["purse"])
lines.append("PURSE +$%d" % int(rung["purse"]))
if Garage.drag_rung() == drag_rung_now and drag_rung_now < DRAG_LADDER.size() - 1:
Garage.drag_advance()
lines.append("UP THE LADDER: next is %s" % DRAG_LADDER[drag_rung_now + 1]["name"])
elif drag_rung_now == DRAG_LADDER.size() - 1:
lines.append("TOP OF THE LADDER. AGAIN?")
if jumped:
lines.append("(red light. we saw it. everyone saw it.)")
if box_blown:
lines.append("(the gearbox is on the strip somewhere behind you)")
if gasket_blown:
lines.append("(and the radiator's boiled itself dry)")
Garage.earn(pay)
lines.append("+$%d bucks | %d clean / %d blown" % [pay, clean_shifts, blown_shifts])
lines.append("R to retry, Esc for menu")
Sfx.shot_2d("sting_win" if won else "bog")
_msg("\n".join(lines), 40, true)
func _bulb(bn: String, on: bool) -> void:
if not _tree_bulbs.has(bn):
return # street drags have no tree hardware; the HUD carries the count
var m := (_tree_bulbs[bn] as MeshInstance3D).material_override as StandardMaterial3D
if m:
m.emission_enabled = on
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
Garage.earn(200)
_msg("COP DOWN! +$200", 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)
Garage.earn(500)
_msg("LOST 'EM! x%d +$500" % 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 _rage_tick() -> void:
# rivals recover on their own (no hold_wreck), so the supply of takedowns
# never dries up; the leash keeps the brawl within reach
var L := race_path.curve.get_baked_length()
for r in rivals:
if r.wrecked:
continue
if car.global_position.distance_to(r.global_position) > RAGE_LEASH:
var off := fposmod(_road_off + randf_range(60.0, 120.0), L)
r.global_transform = _grid_xform(off)
r.progress = off
r.linear_velocity = Vector3.ZERO
r.angular_velocity = Vector3.ZERO
func _oncoming_tick(delta: float) -> void:
## Burnout's oldest deal: drive against the flow, get paid in boost.
## Traffic runs the RacePath forward, so "oncoming" = moving against the
## local tangent while actually on the road.
var L := race_path.curve.get_baked_length()
_road_off = _nearest_off(car.global_position, _road_off, L)
var p := race_path.to_global(race_path.curve.sample_baked(_road_off))
var tangent := (race_path.to_global(race_path.curve.sample_baked(fposmod(_road_off + 2.0, L))) - p)
tangent.y = 0.0
var flat_v := car.linear_velocity
flat_v.y = 0.0
var on_road := Vector2(car.global_position.x - p.x, car.global_position.z - p.z).length() < ONCOMING_LANE_D
if on_road and flat_v.length() > ONCOMING_SPEED \
and tangent.length_squared() > 0.01 \
and flat_v.normalized().dot(tangent.normalized()) < -0.55:
car.earn_boost(ONCOMING_EARN * delta)
_oncoming_run += delta
_oncoming_toast -= delta
if _oncoming_toast <= 0.0:
_oncoming_toast = 2.0
_msg("ONCOMING +boost", 30)
else:
_oncoming_run = 0.0
_oncoming_toast = 0.0
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)
Garage.earn(400)
_msg("OUTRUN WON! x%d +$400%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)
Garage.earn(150)
if mode == "rage" and not over:
rage_downs += 1
_msg("TAKEDOWN! x%d +$150" % rage_downs, 56)
else:
_msg("TAKEDOWN! +$150", 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)
Fx.explosion(car)
_shake = 0.8
_msg("CRASHBREAKER!", 52)
var boom := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 1.0
sm.height = 2.0
# thin fast shockwave shell -- the fire itself is Fx.explosion's particles,
# and the old opaque orange bubble used to drown them out completely
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.55, 0.15, 0.20)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_FRONT # inside face: reads as a wavefront
sm.material = mat
boom.mesh = sm
add_child(boom)
boom.global_position = origin
var tw := create_tween()
tw.tween_property(boom, "scale", Vector3.ONE * 15.0, 0.30) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(boom, "transparency", 1.0, 0.30)
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"
var payout := int(score / 50.0)
if score >= float(t[2]):
medal = "GOLD!"
payout += 1000
elif score >= float(t[1]):
medal = "SILVER"
payout += 600
elif score >= float(t[0]):
medal = "BRONZE"
payout += 300
Garage.earn(payout)
_msg("DAMAGE: $%d\n%s +$%d bucks\nR to retry, Esc for menu" % [int(score), medal, payout], 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
var crash_pay := int(score / 50.0)
Garage.earn(crash_pay)
_msg("DAMAGE: $%d +$%d bucks\nR to retry, Esc for menu" % [int(score), crash_pay], 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()
if mode == "rage" and not over:
time_left -= delta
info_label.text = "%d TAKEDOWNS %d s" % [rage_downs, ceili(time_left)]
if time_left <= 0.0:
over = true
var medal := "NO MEDAL, MATE"
if rage_downs >= RAGE_TARGETS[2]:
medal = "GOLD!"
elif rage_downs >= RAGE_TARGETS[1]:
medal = "SILVER"
elif rage_downs >= RAGE_TARGETS[0]:
medal = "BRONZE"
Sfx.shot(car, "sting_win" if rage_downs >= RAGE_TARGETS[0] else "sting_lose", -4.0)
var rage_pay := 300 if rage_downs >= RAGE_TARGETS[0] else 0
rage_pay = 600 if rage_downs >= RAGE_TARGETS[1] else rage_pay
rage_pay = 1000 if rage_downs >= RAGE_TARGETS[2] else rage_pay
Garage.earn(rage_pay)
_msg("%d TAKEDOWNS\n%s +$%d bucks\nR to retry, Esc for menu" % [rage_downs, medal, rage_pay], 48, true)
# 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", "rage"] \
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