M1/M2: chase camera + expressway map v2
CI / quality (push) Successful in 52s

CameraController (client):
- Scriptable chase cam only while in the DriveSeat; restores default on exit
- Frame-rate independent damped follow with look-ahead aim point
- Flat horizon (ignores car pitch/roll) so bumps don't shake the view
- Speed-based FOV kick (quadratic ramp) for sense of pace
- Cleans up on Died/despawn

MapBuilder v2:
- Highway extended 2400 -> 3200 studs with distinct feature zones
- Gantry arches (seg 2), enclosed neon-lit tunnel (seg 3), jump ramp +
  landing pair on the right lane (seg 4, left lane stays flat)
- Procedural neon skyline towers flanking both sides (deterministic seed,
  same city every server)
- End kill-wall so you can't sail off the far edge
This commit is contained in:
2026-07-22 01:09:44 -04:00
parent b3a1044df6
commit e94bb8fcda
4 changed files with 418 additions and 67 deletions
+136
View File
@@ -0,0 +1,136 @@
--!strict
--[[
CameraController.luau (CLIENT)
Chase camera for driving. Replaces the default Roblox camera ONLY while the
local player sits in a DriveSeat; hands the camera straight back when they
get out.
Feel decisions (tunables at the top):
- The camera chases a point behind/above the car with critically-damped
smoothing (frame-rate independent lerp), so it lags into corners and
catches up on straights — classic arcade racer feel.
- It looks ahead of the car (not at it), which reads speed better.
- FOV widens with speed for a cheap but very effective sense of pace.
- Yaw follows the car's facing, but the position smoothing means drifts
and slides read visually (car rotates inside the frame first).
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Chassis = require(Shared.Chassis)
local player = Players.LocalPlayer
local CameraController = {}
-- Tunables --------------------------------------------------------------------
local CAM_DISTANCE = 24 -- studs behind the car
local CAM_HEIGHT = 9 -- studs above the car
local LOOK_AHEAD = 14 -- aim this far in front of the car
local POSITION_RESPONSIVENESS = 6 -- higher = tighter follow (1/sec)
local BASE_FOV = 70
local MAX_FOV_KICK = 18 -- extra FOV at (chassis) top speed
local FOV_RESPONSIVENESS = 3
-- State -------------------------------------------------------------------------
local renderConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil
local savedCameraType: Enum.CameraType? = nil
local savedFov: number? = nil
-- Frame-rate independent smoothing factor: fraction of remaining distance
-- covered this frame. alpha = 1 - e^(-k*dt).
local function smoothAlpha(k: number, dt: number): number
return 1 - math.exp(-k * dt)
end
local function startChase(seat: VehicleSeat)
local model = seat:FindFirstAncestorOfClass("Model")
local body = model and model.PrimaryPart
if not body then
return
end
local bodyPart = body :: BasePart
local camera = Workspace.CurrentCamera
savedCameraType = camera.CameraType
savedFov = camera.FieldOfView
camera.CameraType = Enum.CameraType.Scriptable
-- Start from wherever the car is so there's no first-frame snap.
local camPos = bodyPart.Position + bodyPart.CFrame.LookVector * -CAM_DISTANCE + Vector3.new(0, CAM_HEIGHT, 0)
renderConn = RunService.RenderStepped:Connect(function(dt)
if not bodyPart.Parent then
return -- car despawned; SeatPart signal will clean us up
end
local look = bodyPart.CFrame.LookVector
local flatLook = Vector3.new(look.X, 0, look.Z)
if flatLook.Magnitude < 0.01 then
flatLook = Vector3.new(0, 0, -1)
else
flatLook = flatLook.Unit
end
-- Where the camera WANTS to be: behind and above, following flat yaw
-- (ignoring car pitch/roll so bumps don't shake the horizon).
local desired = bodyPart.Position - flatLook * CAM_DISTANCE + Vector3.new(0, CAM_HEIGHT, 0)
camPos = camPos:Lerp(desired, smoothAlpha(POSITION_RESPONSIVENESS, dt))
local lookTarget = bodyPart.Position + flatLook * LOOK_AHEAD
camera.CFrame = CFrame.lookAt(camPos, lookTarget)
-- Speed-based FOV kick.
local speed = bodyPart.AssemblyLinearVelocity.Magnitude
-- Normalise against a nominal fast top speed; Chassis gives per-car
-- values but a fixed reference keeps slow cars feeling slow.
local speedFrac = math.clamp(speed / 190, 0, 1)
local targetFov = BASE_FOV + MAX_FOV_KICK * speedFrac * speedFrac
camera.FieldOfView = camera.FieldOfView + (targetFov - camera.FieldOfView) * smoothAlpha(FOV_RESPONSIVENESS, dt)
end)
end
local function stopChase()
if renderConn then
renderConn:Disconnect()
renderConn = nil
end
local camera = Workspace.CurrentCamera
if camera then
camera.CameraType = savedCameraType or Enum.CameraType.Custom
camera.FieldOfView = savedFov or BASE_FOV
end
savedCameraType = nil
savedFov = nil
end
function CameraController.init()
local function onCharacter(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
if seatedConn then
seatedConn:Disconnect()
end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
stopChase()
local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startChase(seatPart)
end
end)
-- Character died/respawned while driving: make sure the camera is back.
humanoid.Died:Connect(stopChase)
end
if player.Character then
onCharacter(player.Character)
end
player.CharacterAdded:Connect(onCharacter)
end
return CameraController
+2
View File
@@ -16,8 +16,10 @@ local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local ChassisController = require(script.ChassisController)
local CameraController = require(script.CameraController)
ChassisController.init()
CameraController.init()
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")