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