SPOTTO: knock the fuel cap off a yellow one

Burnout's yellow warning diamonds are the game telling you it noticed,
and half of why it feels good. src/callout.gd builds them: diamond,
outlined text, punch-scale in (overshoot then snap, which reads as
IMPACT), stack of three with the older ones drifting up and out.
TAKEDOWN!, NEAR MISS, BIG AIR with the airborne time -- and the new
Australian one.

Roughly one traffic car in six is now painted yellow, deterministically
per car so junction convoys stay learnable puzzles, via a surface
OVERRIDE -- painting the shared fleet material would have turned every
copy of that model yellow across the whole city, the same trap the
garage's stat upgrades hit.

Sideswipe any car hard enough and its FUEL CAP pings off: a real
physics prop, chrome (or yellow), spat off the correct flank behind the
midline with sparks, tumbling into the road for ten seconds. Do it to a
yellow one and it's SPOTTO! -- 0.45 boost, cash or crash-score, a
running session tally, and a sting pitched up 35%. Merely near-missing
a yellow one at speed still earns the shout, because that IS the game
as played in the back of every Falcon on the Bruce Highway.

Sideness is judged in the victim's local frame before it goes dynamic
(|x| vs |z|), so a rear-ender is a shunt, not a sideswipe -- asserted,
along with one-cap-per-car and the cap prop actually existing.

Airtime rides along: car.gd tracks time with no wheel down and emits
landed() past 0.55 s, which pays boost and shouts BIG AIR.

Callout placement note: Control.position is relative to the PARENT, not
the anchor, so anchoring the row and then tweening position:y yanked
every badge to the top of the screen. Screen-space maths instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-03 13:50:54 +10:00
parent 837015dc64
commit 1753b4c0b0
7 changed files with 243 additions and 3 deletions

View File

@ -102,6 +102,33 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C
250 m for 6 s to lose them, or get pinned for 4 s and you're busted. Heat
decays if you behave, so picking a fight is a choice rather than a timer.
## Speed, and SPOTTO
Burnout 3 ran about 110 km/h and felt like 300, so **everything that sells
speed is a lie** ([src/speed_fx.gd](src/speed_fx.gd) + `_speed_cam` in
game.gd): radial blur, Sonic-style speed lines, chromatic aberration folded
into the blur as per-channel sample radii, a tunnel vignette on boost; a
camera that drops and hugs the boot at pace, lags on hard acceleration,
dutch-rolls into the steering and micro-shakes; FOV running 72°→96° with
speed, +12° boosting and a +9° **punch** the instant boost lights; motes
streaming past the lens; and a wind loop riding the same curve. One input
(km/h), squared response, cut in at 55 and maxed at 165 — town speed looks
normal, then it piles on. Boost is deliberately the *visual event*.
**Callouts** ([src/callout.gd](src/callout.gd)) are Burnout's yellow warning
diamonds — the game telling you it noticed, which is half of why Burnout feels
good. Stack of three, punch-scaled in, older ones drifting up: TAKEDOWN!,
NEAR MISS, BIG AIR (with the time), FUEL CAP! and —
**SPOTTO.** Roughly one traffic car in six is painted yellow (a surface
*override*, never the shared fleet material). Sideswipe any car hard enough
and its **fuel cap pings off** into the road with sparks — a real physics prop
that rolls away. Do it to a yellow one and that's the national game: SPOTTO!,
big boost, cash or crash-score, and a running tally for the session. A
rear-ender doesn't count (that's a shunt, and the suite asserts it), and
merely near-missing a yellow one at speed still earns the shout, because
that *is* the game as played in the back of every Falcon on the Bruce Highway.
The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing
things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x).
Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo.

82
src/callout.gd Normal file
View File

@ -0,0 +1,82 @@
class_name Callout
extends CanvasLayer
## Burnout 3's yellow warning-diamond callouts -- NUDGE! RUBBIN'! RIGHTSIDE
## SLIDE! -- which are the game telling you it noticed. Half of Burnout's feel
## is that nothing you do goes unremarked; the badge IS the reward.
##
## Ours are Australian: SPOTTO! when you knock the fuel cap off a yellow car.
## Stack of three, newest at the bottom, older ones drifting up and out.
const HOLD := 1.05
const MAX_STACK := 3
var _stack: Array[Control] = []
func _init() -> void:
layer = 2 # above the HUD; a callout that loses to the speedo is useless
func say(text: String, gold := true) -> void:
# Explicit screen-space placement, no anchors: Control.position is relative
# to the PARENT, not to the anchor, so the stack tween below would yank an
# anchored row to the top of the screen the moment it ran.
var vp := Vector2(1600, 900)
if get_viewport():
vp = get_viewport().get_visible_rect().size
var base_y := vp.y * 0.44
var row := Control.new()
row.position = Vector2(vp.x * 0.5, base_y)
row.set_meta("base_y", base_y)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(row)
# the diamond: a square on its corner, dark rim then bright fill
for spec in [[34.0, Color(0.12, 0.10, 0.06)], [27.0, Color(1.0, 0.78, 0.10) if gold
else Color(0.95, 0.35, 0.15)]]:
var d := ColorRect.new()
d.color = spec[1]
d.size = Vector2(spec[0], spec[0])
d.pivot_offset = d.size * 0.5
d.position = Vector2(-d.size.x * 0.5 - 150.0, -d.size.y * 0.5)
d.rotation = PI * 0.25
d.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(d)
var bang := Label.new()
bang.text = "!"
bang.add_theme_font_size_override("font_size", 26)
bang.add_theme_color_override("font_color", Color(0.15, 0.12, 0.05))
bang.position = Vector2(-156.0, -20.0)
row.add_child(bang)
var lbl := Label.new()
lbl.text = text
lbl.add_theme_font_size_override("font_size", 40)
lbl.add_theme_color_override("font_color", Color(1.0, 0.86, 0.25) if gold
else Color(1.0, 0.55, 0.25))
lbl.add_theme_color_override("font_outline_color", Color(0.08, 0.06, 0.03))
lbl.add_theme_constant_override("outline_size", 10)
lbl.position = Vector2(-110.0, -28.0)
row.add_child(lbl)
# shove the older ones up the screen; drop anything past the stack limit
_stack.append(row)
while _stack.size() > MAX_STACK:
var old: Control = _stack.pop_front()
if is_instance_valid(old):
old.queue_free()
for i in _stack.size():
var c := _stack[i]
if is_instance_valid(c):
var lift: float = float(c.get_meta("base_y")) - 52.0 * (_stack.size() - 1 - i)
create_tween().tween_property(c, "position:y", lift, 0.18) \
.set_trans(Tween.TRANS_CUBIC)
# punch in: overshoot the scale, snap back. Cheap, and it reads as IMPACT.
row.scale = Vector2(1.7, 0.6)
var tw := create_tween()
tw.tween_property(row, "scale", Vector2.ONE, 0.16).set_trans(Tween.TRANS_BACK) \
.set_ease(Tween.EASE_OUT)
tw.tween_interval(HOLD)
tw.tween_property(row, "modulate:a", 0.0, 0.35)
tw.tween_callback(func() -> void:
_stack.erase(row)
row.queue_free())

1
src/callout.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://bepqcn6782bj4

View File

@ -8,6 +8,7 @@ extends RigidBody3D
signal crashed(dv: float)
signal wreck_started
signal landed(air_time: float) # all four off the deck, then back down
@export var stats: CarStats
@export var is_player := true
@ -43,6 +44,7 @@ var boosting := false
var slip := 0.0 # sideways m/s
var wrecked := false
var spawn_xform: Transform3D
var air_t := 0.0 # seconds since the last wheel left the ground
var spin_override := 0.0 # extra rear-wheel spin (rad/s) for burnouts
var burnout_on := false # keeps the skid loop singing while stationary
var leaking := false # a grenaded gearbox drips oil patches behind
@ -188,6 +190,13 @@ func _physics_process(delta: float) -> void:
if _leak_accum > 7.0:
_leak_accum = 0.0
_drip()
# airtime: only counts once it's a real jump, and only reports on landing
if grounded == 0:
air_t += delta
elif air_t > 0.0:
if air_t > 0.55 and not wrecked:
landed.emit(air_t)
air_t = 0.0
if _smoke:
_smoke.emitting = wrecked or (_deform != null and _deform.damage > 0.7)
if _dust:

View File

@ -382,6 +382,7 @@ func _ready() -> void:
midx += 1
add_child(t)
t.smashed.connect(_traffic_smashed.bind(t))
t.sideswiped.connect(_sideswiped)
traffic.append(t)
elif race_path:
var n: int = TRAFFIC_COUNT.get(mode, 4)
@ -392,6 +393,7 @@ func _ready() -> void:
t.speed = randf_range(9.0, 14.0)
add_child(t)
t.smashed.connect(_traffic_smashed.bind(t))
t.sideswiped.connect(_sideswiped)
traffic.append(t)
cam = Camera3D.new()
@ -401,6 +403,11 @@ func _ready() -> void:
# the speed kit: screen pass under the HUD, motes on the lens, wind in the ears
speed_fx = SpeedFx.new()
add_child(speed_fx)
callout = Callout.new()
add_child(callout)
car.landed.connect(func(t: float) -> void:
callout.say("BIG AIR %.1fs" % t if t > 1.1 else "AIRBORNE")
car.earn_boost(0.10 + 0.12 * t))
_rush_p = Fx.rusher(cam)
_snd_wind = Sfx.looper(car, "wind", -26.0)
@ -456,6 +463,8 @@ const FOV_PUNCH := 9.0 # instant kick the moment boost starts
const SPEED_FULL := 165.0 # km/h at which the effects max out
var speed_fx: SpeedFx
var callout: Callout
var spottos := 0 # yellow cars de-capped this run. It counts.
var _rush_p: GPUParticles3D
var _snd_wind: AudioStreamPlayer3D
var _fov_punch := 0.0
@ -814,7 +823,8 @@ func _physics_process(delta: float) -> void:
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)
# a yellow one you merely SAW still counts -- that IS the game
callout.say("SPOTTO! (no contact)" if t.yellow else "NEAR MISS", t.yellow)
if mode == "race" and race_path and not over:
_race_tick()
if mode == "cruise" and race_path:
@ -1385,9 +1395,25 @@ func _rival_wrecked(r: Rival) -> void:
Garage.earn(150)
if mode == "rage" and not over:
rage_downs += 1
_msg("TAKEDOWN! x%d +$150" % rage_downs, 56)
callout.say("TAKEDOWN! x%d" % rage_downs)
else:
_msg("TAKEDOWN! +$150", 56)
callout.say("TAKEDOWN!")
func _sideswiped(yellow: bool) -> void:
## The fuel cap has left the vehicle. If it was a yellow one, that is the
## national game and the whole car should know about it.
if yellow:
spottos += 1
callout.say("SPOTTO! x%d" % spottos if spottos > 1 else "SPOTTO!")
Sfx.shot(car, "sting_win", -6.0, 1.35)
car.earn_boost(0.45)
if mode in ["crash", "junction"] and not over:
score += 750.0
else:
Garage.earn(40)
else:
callout.say("FUEL CAP!", false)
car.earn_boost(0.15)
func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
if is_instance_valid(t):

View File

@ -5,11 +5,18 @@ extends RigidBody3D
## ponytail: no lane logic, no avoidance -- canon Burnout traffic is dumb.
signal smashed(impulse: float)
## SPOTTO: sideswipe a car hard enough to knock its fuel cap off. If it's a
## yellow one, that's the national game, and the callout is mandatory.
signal sideswiped(yellow: bool)
const YELLOW_ODDS := 0.17 # roughly one car in six, same as the real road
var path: Path3D
var offset := 0.0
var speed := 11.0
var live := true
var yellow := false
var cap_gone := false
static var _model_pool: Array[String] = []
static var _pool_built := false
@ -41,11 +48,33 @@ func _init() -> void:
add_child(cs)
body_entered.connect(_hit)
static var _yellow_mat: StandardMaterial3D
static func yellow_paint() -> StandardMaterial3D:
if _yellow_mat == null:
_yellow_mat = StandardMaterial3D.new()
_yellow_mat.albedo_color = Color(0.96, 0.76, 0.05)
_yellow_mat.roughness = 0.45
_yellow_mat.metallic = 0.25
return _yellow_mat
func _ready() -> void:
# visuals here, not _init, so model_idx set by the spawner is respected
var mp := pick_model(model_idx)
if mp != "":
add_child((load(mp) as PackedScene).instantiate())
# deterministic per car, so a junction convoy is still a learnable puzzle
var rid := model_idx if model_idx >= 0 else int(get_instance_id())
yellow = fposmod(sin(float(rid) * 12.9898) * 43758.5453, 1.0) < YELLOW_ODDS
if yellow:
# surface OVERRIDE, never the mesh's own material: the fleet GLBs are
# shared, and painting one taxi yellow must not paint the whole city
for w in find_children("*", "MeshInstance3D", true, false):
var mi := w as MeshInstance3D
var n := String(mi.name).to_lower()
if n.begins_with("wheel") or n.contains("glass") or n.contains("light"):
continue
mi.set_surface_override_material(0, yellow_paint())
else:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
@ -73,6 +102,51 @@ func blast(impulse: Vector3) -> void:
smashed.emit(clampf(impulse.length(), 3.0, 30.0))
apply_central_impulse(impulse * mass)
func flank_hit(rel: Vector3) -> bool:
## Side-on contact? Then the fuel cap goes. Returns true if it flew, so the
## caller can decide whether that was merely a sideswipe or a SPOTTO.
if cap_gone:
return false
var local := global_basis.inverse() * rel
if absf(local.x) < absf(local.z) * 0.85 or rel.length() < 4.0:
return false # nose-to-tail: that's a shunt, not a sideswipe
cap_gone = true
var side: float = signf(local.x)
var cap := RigidBody3D.new()
cap.mass = 0.4
var cs := CollisionShape3D.new()
var cyl := CylinderShape3D.new()
cyl.radius = 0.07
cyl.height = 0.03
cs.shape = cyl
cap.add_child(cs)
var mi := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.07
cm.bottom_radius = 0.07
cm.height = 0.03
cm.radial_segments = 10
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.75, 0.76, 0.78) if not yellow else Color(0.9, 0.72, 0.1)
m.metallic = 0.7
m.roughness = 0.35
cm.material = m
mi.mesh = cm
mi.rotation_degrees = Vector3(0, 0, 90)
cap.add_child(mi)
get_parent().add_child(cap)
# the flank, behind the middle, where a filler actually lives
cap.global_position = global_position + global_basis.x * side * 0.9 \
+ global_basis.z * 1.1 + Vector3.UP * 0.1
cap.linear_velocity = linear_velocity + global_basis.x * side * randf_range(3.0, 6.0) \
+ Vector3.UP * randf_range(2.0, 4.0)
cap.angular_velocity = Vector3(randf_range(-14, 14), randf_range(-14, 14),
randf_range(-14, 14))
Fx.sparks(self, 16)
get_tree().create_timer(10.0).timeout.connect(cap.queue_free)
sideswiped.emit(yellow)
return true
func _hit(body: Node) -> void:
if not live or not body is RigidBody3D:
return
@ -82,6 +156,7 @@ func _hit(body: Node) -> void:
var rel := ((body as RigidBody3D).linear_velocity - linear_velocity).limit_length(30.0)
if rel.length() < 3.0:
return
flank_hit(rel) # before going dynamic, while the basis is still the car's
live = false
freeze = false
linear_velocity = rel * 0.7 + Vector3.UP * 2.5

View File

@ -262,6 +262,26 @@ func _run() -> void:
Input.action_release("throttle")
_check(not victim.live, "rammed traffic never went dynamic")
_check(game.score > 0.0, "crash mode scored nothing")
# SPOTTO: a side-on hit knocks the fuel cap off, and a YELLOW one is the
# national game. Nose-to-tail must NOT count -- that's a shunt.
var vic2: TrafficCar = game.traffic[1]
vic2.yellow = true
var spot0: int = game.spottos
_check(not vic2.flank_hit(-vic2.global_basis.z * 12.0), "rear-ender counted as a sideswipe")
_check(vic2.flank_hit(vic2.global_basis.x * 12.0), "side hit didn't shed the cap")
_check(game.spottos == spot0 + 1, "yellow sideswipe didn't score a SPOTTO")
_check(not vic2.flank_hit(vic2.global_basis.x * 12.0), "car shed two fuel caps")
var caps := 0
for n in game.get_children():
if n is RigidBody3D and n.get_child_count() == 2 and not (n is TrafficCar) \
and not (n is Car):
caps += 1
_check(caps >= 1, "no fuel cap prop in the world")
var yellows := 0
for t2 in game.traffic:
if t2.yellow:
yellows += 1
_check(yellows >= 1, "no yellow cars on the road at all (%d)" % game.traffic.size())
var crash_score := game.score
game.free()