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>
1392 lines
53 KiB
GDScript
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
|