diff --git a/assets/sounds/bog.wav b/assets/sounds/bog.wav index c8aa7ae..04f086c 100644 Binary files a/assets/sounds/bog.wav and b/assets/sounds/bog.wav differ diff --git a/assets/sounds/boom.wav b/assets/sounds/boom.wav index 825a45a..fbbab37 100644 Binary files a/assets/sounds/boom.wav and b/assets/sounds/boom.wav differ diff --git a/assets/sounds/clunk.wav b/assets/sounds/clunk.wav index 1bd8a6f..965c31b 100644 Binary files a/assets/sounds/clunk.wav and b/assets/sounds/clunk.wav differ diff --git a/assets/sounds/crash_big.wav b/assets/sounds/crash_big.wav index e8a5f34..b289f23 100644 Binary files a/assets/sounds/crash_big.wav and b/assets/sounds/crash_big.wav differ diff --git a/assets/sounds/crash_small.wav b/assets/sounds/crash_small.wav index 1d22895..df414f3 100644 Binary files a/assets/sounds/crash_small.wav and b/assets/sounds/crash_small.wav differ diff --git a/assets/sounds/glass.wav b/assets/sounds/glass.wav index 54fc400..2367332 100644 Binary files a/assets/sounds/glass.wav and b/assets/sounds/glass.wav differ diff --git a/assets/sounds/wind.wav b/assets/sounds/wind.wav new file mode 100644 index 0000000..804d5ed Binary files /dev/null and b/assets/sounds/wind.wav differ diff --git a/assets/sounds/wind.wav.import b/assets/sounds/wind.wav.import new file mode 100644 index 0000000..15ed39f --- /dev/null +++ b/assets/sounds/wind.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://miy805g8f178" +path="res://.godot/imported/wind.wav-ec283f4d84f3cfd6c4eb231e441b3d63.sample" + +[deps] + +source_file="res://assets/sounds/wind.wav" +dest_files=["res://.godot/imported/wind.wav-ec283f4d84f3cfd6c4eb231e441b3d63.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/src/fx.gd b/src/fx.gd index 86a08d8..0f5539e 100644 --- a/src/fx.gd +++ b/src/fx.gd @@ -175,6 +175,40 @@ static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D: static var _burnout_mat: ParticleProcessMaterial static var _steam_mat: ParticleProcessMaterial +static var _rush_mat: ParticleProcessMaterial +static var _rush_draw: QuadMesh + +static func rusher(cam: Node3D) -> GPUParticles3D: + ## Motes streaming past the lens -- the oldest speed trick there is (Sonic + ## did it in 2D, every racer since does it in 3D). Parented to the CAMERA in + ## local space, so they fly straight past the viewer wherever it points, and + ## SpeedFx's radial blur smears each speck into a streak for free. Caller + ## drives .speed_scale and .emitting. + _init_shared() + if _rush_mat == null: + _rush_mat = ParticleProcessMaterial.new() + _rush_mat.direction = Vector3(0, 0, 1) # camera-space: toward the viewer + _rush_mat.spread = 4.0 + _rush_mat.initial_velocity_min = 55.0 + _rush_mat.initial_velocity_max = 95.0 + _rush_mat.gravity = Vector3.ZERO + _rush_mat.scale_min = 0.5 + _rush_mat.scale_max = 1.4 + _rush_mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + _rush_mat.emission_box_extents = Vector3(15.0, 8.0, 22.0) + _rush_mat.color_ramp = _ramp([Color(1, 1, 1, 0.0), Color(1, 1, 1, 0.55), + Color(1, 1, 1, 0.0)]) + _rush_draw = _draw_quad(0.075, Color(1, 1, 1), true) + var p := GPUParticles3D.new() + p.process_material = _rush_mat + p.draw_pass_1 = _rush_draw + p.amount = 90 + p.lifetime = 0.75 + p.local_coords = true + p.position = Vector3(0, 0, -24.0) # spawn volume sits ahead of the lens + p.emitting = false + cam.add_child(p) + return p static func burnout(parent: Node3D, offset: Vector3) -> GPUParticles3D: ## The good stuff: fat white tyre smoke for the burnout box. Caller toggles. diff --git a/src/game.gd b/src/game.gd index 685cc08..78f3f6f 100644 --- a/src/game.gd +++ b/src/game.gd @@ -395,9 +395,14 @@ func _ready() -> void: traffic.append(t) cam = Camera3D.new() - cam.fov = 72.0 + cam.fov = FOV_HOME add_child(cam) cam.global_position = car.global_position + Vector3(0, 3, 8) + # the speed kit: screen pass under the HUD, motes on the lens, wind in the ears + speed_fx = SpeedFx.new() + add_child(speed_fx) + _rush_p = Fx.rusher(cam) + _snd_wind = Sfx.looper(car, "wind", -26.0) _build_environment() @@ -439,6 +444,89 @@ func _ready() -> void: msg_label.add_theme_font_size_override("font_size", 48) hud.add_child(msg_label) +## ---- the sensation of speed ------------------------------------------------ +## None of this makes the car faster. Burnout 3 ran ~110 km/h in a 72 deg lens +## and felt like 300 because six lies fired at once; these are those lies. +const CAM_HOME := Vector3(0, 2.4, 6.0) # rest pose, behind and above +const CAM_FAST := Vector3(0, 1.75, 5.1) # flat out: drops and hugs the boot +const FOV_HOME := 72.0 +const FOV_FAST := 96.0 # speed alone +const FOV_BOOST := 12.0 # added while boosting +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 _rush_p: GPUParticles3D +var _snd_wind: AudioStreamPlayer3D +var _fov_punch := 0.0 +var _was_boosting := false +var _cam_roll := 0.0 +var _prev_kmh := 0.0 + +func _speed_cam(delta: float) -> void: + var kmh := car.speed_kmh() + var f: float = clampf(kmh / SPEED_FULL, 0.0, 1.0) + var fe := f * f # squared: nothing happens at town speed, then it piles on + + # 1. the camera drops and closes in as speed rises -- low and near reads + # fast even at a walking pace, which is why every arcade racer does it + var offset := CAM_HOME.lerp(CAM_FAST, fe) + # 2. lag: hard acceleration lets the car pull AWAY from the lens for a beat + var accel: float = (kmh - _prev_kmh) / maxf(delta, 0.001) / 3.6 + _prev_kmh = kmh + offset.z += clampf(accel * 0.08, -0.5, 1.4) + var target := car.global_position + car.global_basis * offset + # faster follow at speed so the car doesn't swim around the frame + cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-(5.0 + 3.0 * fe) * delta)) + # 3. aim slightly ahead of the car at speed -- more road, less bonnet + cam.look_at(car.global_position + Vector3.UP * 1.2 + - car.global_basis.z * (3.0 + 9.0 * fe)) + + # 4. dutch roll into the steering, doubled while drifting: the horizon + # tipping is a cheap, powerful "this is violent" signal + var want_roll: float = -car.steer * (0.05 + 0.10 * fe) * (2.0 if car.drift_in else 1.0) + _cam_roll = lerpf(_cam_roll, want_roll, 1.0 - exp(-6.0 * delta)) + cam.rotation.z += _cam_roll + + # 5. micro-shake that grows with speed (plus the big impact shake) + if fe > 0.05: + var m := fe * 0.035 + cam.global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1), + randf_range(-1, 1)) * m + 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) + + # 6. FOV: the big one. Widens with speed, wider again on boost, and PUNCHES + # the instant boost lights -- the punch is what sells the shove. + if car.boosting and not _was_boosting: + _fov_punch = FOV_PUNCH + _was_boosting = car.boosting + _fov_punch *= exp(-4.5 * delta) + var want_fov := FOV_HOME + (FOV_FAST - FOV_HOME) * fe \ + + (FOV_BOOST if car.boosting else 0.0) + _fov_punch + cam.fov = lerpf(cam.fov, want_fov, 1.0 - exp(-7.0 * delta)) + + # 7. screen-space: radial blur, speed lines, aberration, tunnel vignette + if speed_fx: + speed_fx.drive(kmh, car.boosting, delta) + # 8. motes past the lens (the blur smears them into streaks) + if _rush_p: + _rush_p.emitting = fe > 0.06 + _rush_p.speed_scale = 0.7 + 1.6 * fe + # 9. wind: rises and sharpens with speed, so the ears agree with the eyes + if _snd_wind: + if fe > 0.02 and not _snd_wind.playing: + _snd_wind.play() + elif fe <= 0.02 and _snd_wind.playing: + _snd_wind.stop() + if _snd_wind.playing: + _snd_wind.volume_db = -26.0 + 20.0 * fe + (3.0 if car.boosting else 0.0) + _snd_wind.pitch_scale = 0.75 + 0.7 * f + func _drag_markers(level: Node) -> Array[Node3D]: var out: Array[Node3D] = [] var s := level.find_child("DragStart", true, false) as Node3D @@ -1446,16 +1534,7 @@ func _process(delta: float) -> void: 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_cam(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 diff --git a/src/sfx.gd b/src/sfx.gd index c56ff47..02f2428 100644 --- a/src/sfx.gd +++ b/src/sfx.gd @@ -4,7 +4,7 @@ class_name Sfx ## regenerated by the tool never silently loses its loop flag. const DIR := "res://assets/sounds/%s.wav" -const LOOPS := ["engine", "skid", "boost", "siren"] +const LOOPS := ["engine", "skid", "boost", "siren", "wind"] static var _cache := {} diff --git a/src/speed_fx.gd b/src/speed_fx.gd new file mode 100644 index 0000000..233b952 --- /dev/null +++ b/src/speed_fx.gd @@ -0,0 +1,106 @@ +class_name SpeedFx +extends CanvasLayer +## The screen-space half of the speed lie. Burnout 3 never actually went that +## fast -- it bent the lens. Four effects on one fullscreen pass, all driven by +## two numbers (rush = general speed, boost = the good stuff): +## +## RADIAL BLUR smears the world outward from the crosshair. The single +## biggest contributor; it also turns the 3D rush motes into +## streaks for free, since the blur is radial and they are +## small bright specks. +## SPEED LINES the Sonic trick: bright radial lanes streaming outward, +## randomised per lane and scrolling, so the eye reads +## "impossible velocity" even at a modest 90 km/h. +## CHROMATIC ABER. red/blue pulled apart at the frame edge. Sells "the lens +## can't cope" -- pure fiction, entirely convincing. +## TUNNEL VIGNETTE darkens the edges under boost so the centre punches out. +## +## Lives on layer 0 so the 3D world is captured and smeared while the HUD +## (layer 1) stays razor sharp -- blurring your own speedo reads as a bug. + +const CODE := """ +shader_type canvas_item; +render_mode unshaded; + +uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_linear; +uniform float rush = 0.0; +uniform float boost = 0.0; + +float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453123); } + +void fragment() { + vec2 c = SCREEN_UV - vec2(0.5); + float r = clamp(length(c) * 2.0, 0.0, 1.5); + float edge = smoothstep(0.12, 1.0, r); + + // Radial blur, with the chromatic aberration folded INTO it as a per-channel + // radius offset. Sampling sharp R/B against a blurred G (the obvious way to + // bolt CA on afterwards) fringes every high-contrast edge in magenta and + // green -- it looks like a broken display, not a fast car. + float amt = (rush * 0.042 + boost * 0.022) * edge; + float ca = (rush * 0.0025 + boost * 0.006) * edge; + vec3 col = vec3(0.0); + float wsum = 0.0; + for (int i = 0; i < 8; i++) { + float f = float(i) / 7.0; + float w = 1.0 - f * 0.55; + float s = 1.0 - amt * f; + col.r += texture(screen_tex, vec2(0.5) + c * (s + ca)).r * w; + col.g += texture(screen_tex, vec2(0.5) + c * s).g * w; + col.b += texture(screen_tex, vec2(0.5) + c * (s - ca)).b * w; + wsum += w; + } + col /= wsum; + + // speed lines: radial lanes, each with its own phase, streaming outward + float lanes = 300.0; + float ai = (atan(c.y, c.x) / 6.2831853 + 0.5) * lanes; + float lane = floor(ai); + float h1 = hash(lane); + float h2 = hash(lane + 41.3); + float lanew = smoothstep(1.0, 0.45, abs(fract(ai) - 0.5) * 2.0); + float start = fract(h1 + TIME * (1.1 + h2 * 1.4)); + float inner = mix(0.42, 1.05, start); + float line = smoothstep(inner, inner + 0.10, r) * (1.0 - smoothstep(inner + 0.22, inner + 0.45, r)); + // sparse while merely quick, a storm while boosting -- boost should read as + // an EVENT, not as "the same picture but more" + float gate = step(h2, 0.25 + boost * 0.45); + float clear = smoothstep(0.30, 0.72, r); // never draw over the car itself + col += vec3(line * lanew * gate * clear * (rush * 0.13 + boost * 0.38)); + + // tunnel vignette: boost squeezes the frame + col *= 1.0 - 0.42 * boost * edge; + COLOR = vec4(col, 1.0); +} +""" + +var rect: ColorRect +var mat: ShaderMaterial +var rush := 0.0 # 0..1 smoothed speed term (also the test's read-out) +var boost := 0.0 + +func _init() -> void: + layer = 0 + var sh := Shader.new() + sh.code = CODE + mat = ShaderMaterial.new() + mat.shader = sh + rect = ColorRect.new() + rect.set_anchors_preset(Control.PRESET_FULL_RECT) + rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + rect.material = mat + rect.visible = false # nothing to smear while stationary + add_child(rect) + +func drive(speed_kmh: float, boosting: bool, delta: float) -> void: + ## Effects start at CUT_IN and reach full at FULL -- below that a car + ## crawling around the shed shouldn't look like a warp drive. + const CUT_IN := 55.0 + const FULL := 165.0 + var want: float = clampf((speed_kmh - CUT_IN) / (FULL - CUT_IN), 0.0, 1.0) + rush = lerpf(rush, want, 1.0 - exp(-3.5 * delta)) + boost = lerpf(boost, 1.0 if boosting else 0.0, 1.0 - exp(-5.0 * delta)) + rect.visible = rush > 0.01 or boost > 0.01 + if rect.visible: + mat.set_shader_parameter("rush", rush) + mat.set_shader_parameter("boost", boost) diff --git a/src/speed_fx.gd.uid b/src/speed_fx.gd.uid new file mode 100644 index 0000000..589841a --- /dev/null +++ b/src/speed_fx.gd.uid @@ -0,0 +1 @@ +uid://b0um3w2ex8356 diff --git a/tests/smoke.gd b/tests/smoke.gd index 75cc83e..d06db4c 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -220,6 +220,26 @@ func _run() -> void: Input.action_release("throttle") game.free() + # speed kit: at pace the lens must widen, the screen pass must engage, the + # motes must fly and the wind must blow -- all off ONE number (km/h) + print(" .. speed") + game = await _boot("cruise") + _check(game.speed_fx != null and game._rush_p != null, "speed kit not built") + var fov0: float = game.cam.fov + for i in 30: + await physics_frame + game.car.linear_velocity = -game.car.global_basis.z * 45.0 # ~160 km/h + await process_frame + _check(game.cam.fov > fov0 + 8.0, "FOV never opened up (%.1f -> %.1f)" % [fov0, game.cam.fov]) + _check(game.speed_fx.rush > 0.25, "screen rush never engaged (%.2f)" % game.speed_fx.rush) + _check(game.speed_fx.rect.visible, "screen pass stayed hidden at speed") + _check(game._rush_p.emitting, "rush motes not emitting at speed") + var fast_fov: float = game.cam.fov + game.car.boosting = true + await process_frame + _check(game.cam.fov > fast_fov, "boost punch didn't kick the lens") + game.free() + # crash mode: traffic spawned, ram the nearest one, score must rise print(" .. crash") game = await _boot("crash") diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py index 6d33b80..a0c4a99 100644 --- a/tools/gen_sounds.py +++ b/tools/gen_sounds.py @@ -91,6 +91,14 @@ bo = band(noise(1.0), 250, 1600) + 0.5 * band(noise(1.0), 2500, 6000) bo *= 1.0 + 0.25 * np.sin(2 * math.pi * 6 * t(1.0)) write("boost", norm(seam(bo), 0.55)) +# wind: the rush past the windows. Low banded noise with a slow swell -- kept +# dull on purpose so the pitch/volume ramp in car.gd does the talking rather +# than the timbre. Seam-crossfaded like the other noise beds. +wd = band(noise(1.0), 90, 1100, peaks=((260, 120, 2.2),)) +wd += 0.35 * band(noise(1.0), 1200, 4200) +wd *= 1.0 + 0.18 * np.sin(2 * math.pi * 3 * t(1.0)) +write("wind", norm(seam(wd), 0.5)) + # siren: Aussie two-tone, 620/470 Hz halves with soft crossfades, odd harmonics tt = t(1.0) gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square