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()