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:
parent
837015dc64
commit
1753b4c0b0
27
README.md
27
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.
|
||||
|
||||
82
src/callout.gd
Normal file
82
src/callout.gd
Normal 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
1
src/callout.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bepqcn6782bj4
|
||||
@ -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:
|
||||
|
||||
32
src/game.gd
32
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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user