From 1753b4c0b0f676e577154bdd4d31e592209c1f50 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Mon, 3 Aug 2026 13:50:54 +1000 Subject: [PATCH] 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 --- README.md | 27 +++++++++++++++ src/callout.gd | 82 ++++++++++++++++++++++++++++++++++++++++++++++ src/callout.gd.uid | 1 + src/car.gd | 9 +++++ src/game.gd | 32 ++++++++++++++++-- src/traffic.gd | 75 ++++++++++++++++++++++++++++++++++++++++++ tests/smoke.gd | 20 +++++++++++ 7 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 src/callout.gd create mode 100644 src/callout.gd.uid diff --git a/README.md b/README.md index c610901..a8c3805 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/callout.gd b/src/callout.gd new file mode 100644 index 0000000..03431ac --- /dev/null +++ b/src/callout.gd @@ -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()) diff --git a/src/callout.gd.uid b/src/callout.gd.uid new file mode 100644 index 0000000..918bba5 --- /dev/null +++ b/src/callout.gd.uid @@ -0,0 +1 @@ +uid://bepqcn6782bj4 diff --git a/src/car.gd b/src/car.gd index 32c716d..a9f56a3 100644 --- a/src/car.gd +++ b/src/car.gd @@ -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: diff --git a/src/game.gd b/src/game.gd index 78f3f6f..3c99272 100644 --- a/src/game.gd +++ b/src/game.gd @@ -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): diff --git a/src/traffic.gd b/src/traffic.gd index 3003b09..2495bf2 100644 --- a/src/traffic.gd +++ b/src/traffic.gd @@ -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 diff --git a/tests/smoke.gd b/tests/smoke.gd index d06db4c..e5c21e3 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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()