From e94bb8fcda1e11cfa6423ea6c15aea904e45a135 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Wed, 22 Jul 2026 01:09:44 -0400 Subject: [PATCH] M1/M2: chase camera + expressway map v2 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 --- docs/ROADMAP.md | 9 +- src/client/CameraController.luau | 136 +++++++++++++ src/client/init.client.luau | 2 + src/server/MapBuilder.luau | 338 +++++++++++++++++++++++++------ 4 files changed, 418 insertions(+), 67 deletions(-) create mode 100644 src/client/CameraController.luau diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1d08a1e..6089736 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,14 +21,19 @@ to owner + son. Cars persist between sessions. CI green on a self-hosted runner. (driver owns network physics). **Needs a Studio playtest to tune feel.** - [ ] Import the **real RX-7 mesh**; weld the shell onto the chassis `Body`. See `docs/MESH_SWAP.md` for the exact steps (needs Studio for asset upload). -- [ ] Tune acceleration curve, grip, and the chase camera (after first playtest). +- [ ] Tune acceleration curve and grip (after first playtest). +- [x] **Chase camera**: scriptable follow cam while seated — damped chase with + look-ahead, flat horizon (ignores car pitch/roll), speed-based FOV kick. + `src/client/CameraController.luau`. Tune constants after playtest. - [ ] Sound: engine loop, tire screech, boost. **Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else until this is true. ## M2 — More cars + polish (still no racing) - [ ] Import a few more real car meshes. -- [ ] Better map: more expressway sections, ramps, tunnels, scenery. +- [x] Better map v2: 3200-stud expressway with gantry arches, a neon-lit + tunnel, jump ramps (right lane; left stays flat), end wall, and a + procedural neon skyline flanking the route. - [ ] Day/night, weather mood. ## M3 — Racing (the first "game" layer) diff --git a/src/client/CameraController.luau b/src/client/CameraController.luau new file mode 100644 index 0000000..51c2fb0 --- /dev/null +++ b/src/client/CameraController.luau @@ -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 diff --git a/src/client/init.client.luau b/src/client/init.client.luau index 8c7e37d..85910c8 100644 --- a/src/client/init.client.luau +++ b/src/client/init.client.luau @@ -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") diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index f5bce50..ec0a9c5 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -1,13 +1,22 @@ --!strict --[[ MapBuilder.luau (SERVER) - Builds the MVP "Tokyo expressway" vertical slice entirely from code so the - game is playable with ZERO manual Studio building. This is a straight, - banked-guardrail highway segment with a start pad, a finish line, neon-ish - side pylons, and invisible kill walls so you can't drive off the edge. + Builds the "Tokyo expressway" map entirely from code — ZERO manual Studio + building. v2 (M2 groundwork): the straight highway now runs through a + sequence of FEATURES so driving it is actually interesting: - Later you replace this with a proper modeled map; the rest of the game - (spawns, races) reads named parts so it keeps working. + seg 0-1 open start (spawn pad) + seg 2 overhead gantry arches + seg 3 TUNNEL (enclosed, neon-striped ceiling) + seg 4 jump ramps (side-by-side launch + landing, optional — road + continues flat around them) + seg 5-6 open run to the finish line + seg 7 finish + runoff + + Neon skyline blocks flank the whole route outside the guardrails for the + night-city mood. Everything is still named parts (SpawnPad, FinishLine, + KillWall_*) so gameplay code keeps working when this is replaced by a + modeled map later. ]] local Workspace = game:GetService("Workspace") @@ -16,8 +25,12 @@ local MapBuilder = {} local ROAD_WIDTH = 60 local SEGMENT_LENGTH = 400 -local NUM_SEGMENTS = 6 -- total length = 2400 studs of highway -local WALL_HEIGHT = 20 +local NUM_SEGMENTS = 8 -- total length = 3200 studs of highway +local WALL_HEIGHT = 26 + +local NEON_PINK = Color3.fromRGB(255, 40, 120) +local NEON_BLUE = Color3.fromRGB(40, 160, 255) +local NEON_TEAL = Color3.fromRGB(40, 230, 200) local function part(props: { [string]: any }): Part local p = Instance.new("Part") @@ -30,6 +43,242 @@ local function part(props: { [string]: any }): Part return p end +-- Deterministic pseudo-random for scenery (same city every server) ------------ +local function det(seed: number): number + local x = math.sin(seed * 12.9898) * 43758.5453 + return x - math.floor(x) +end + +-- Road deck ------------------------------------------------------------------ +local function buildRoad(parent: Folder) + for i = 0, NUM_SEGMENTS - 1 do + local z = -(i * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2 + local road = part({ + Name = "Road_" .. i, + Size = Vector3.new(ROAD_WIDTH, 2, SEGMENT_LENGTH), + Position = Vector3.new(0, 0, z), + Color = Color3.fromRGB(28, 28, 34), + Material = Enum.Material.Asphalt, + }) + road.Parent = parent + + local line = part({ + Name = "Lane_" .. i, + Size = Vector3.new(1, 2.1, SEGMENT_LENGTH), + Position = Vector3.new(0, 0.1, z), + Color = Color3.fromRGB(230, 200, 60), + Material = Enum.Material.Neon, + }) + line.Parent = parent + end +end + +-- Guardrails, kill walls, pylons ---------------------------------------------- +local function buildBarriers(map: Folder, totalLength: number) + for _, side in ipairs({ -1, 1 }) do + local x = side * (ROAD_WIDTH / 2) + local rail = part({ + Name = "Guardrail_" .. (side == -1 and "L" or "R"), + Size = Vector3.new(1.5, 4, totalLength), + Position = Vector3.new(x, 2, -totalLength / 2), + Color = Color3.fromRGB(180, 180, 190), + Material = Enum.Material.Metal, + }) + rail.Parent = map + + local wall = part({ + Name = "KillWall_" .. (side == -1 and "L" or "R"), + Size = Vector3.new(2, WALL_HEIGHT, totalLength), + Position = Vector3.new(x + side * 2, WALL_HEIGHT / 2, -totalLength / 2), + Transparency = 1, + CanCollide = true, + }) + wall.Parent = map + + for i = 0, math.floor(totalLength / 200) do + local pylon = part({ + Name = "Pylon", + Size = Vector3.new(1, 14, 1), + Position = Vector3.new(x + side * 3, 7, -(i * 200)), + Color = side == -1 and NEON_PINK or NEON_BLUE, + Material = Enum.Material.Neon, + }) + pylon.Parent = map + end + end +end + +-- Overhead gantry arches (segment 2) ------------------------------------------- +local function buildGantries(map: Folder) + local folder = Instance.new("Folder") + folder.Name = "Gantries" + folder.Parent = map + + local segStart = 2 * SEGMENT_LENGTH + for g = 0, 3 do + local z = -(segStart + 60 + g * 90) + for _, side in ipairs({ -1, 1 }) do + local post = part({ + Name = "GantryPost", + Size = Vector3.new(2, 24, 2), + Position = Vector3.new(side * (ROAD_WIDTH / 2 - 2), 12, z), + Color = Color3.fromRGB(60, 60, 70), + Material = Enum.Material.DiamondPlate, + }) + post.Parent = folder + end + local beam = part({ + Name = "GantryBeam", + Size = Vector3.new(ROAD_WIDTH, 2.5, 2.5), + Position = Vector3.new(0, 24, z), + Color = g % 2 == 0 and NEON_TEAL or NEON_PINK, + Material = Enum.Material.Neon, + }) + beam.Parent = folder + end +end + +-- Tunnel (segment 3): walls + ceiling with neon strips --------------------------- +local function buildTunnel(map: Folder) + local folder = Instance.new("Folder") + folder.Name = "Tunnel" + folder.Parent = map + + local zCenter = -(3 * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2 + local tunnelHeight = 22 + + for _, side in ipairs({ -1, 1 }) do + local wall = part({ + Name = "TunnelWall_" .. (side == -1 and "L" or "R"), + Size = Vector3.new(3, tunnelHeight, SEGMENT_LENGTH), + Position = Vector3.new(side * (ROAD_WIDTH / 2 + 1.5), tunnelHeight / 2, zCenter), + Color = Color3.fromRGB(38, 38, 46), + Material = Enum.Material.Concrete, + }) + wall.Parent = folder + end + + local ceiling = part({ + Name = "TunnelCeiling", + Size = Vector3.new(ROAD_WIDTH + 6, 2, SEGMENT_LENGTH), + Position = Vector3.new(0, tunnelHeight + 1, zCenter), + Color = Color3.fromRGB(30, 30, 38), + Material = Enum.Material.Concrete, + }) + ceiling.Parent = folder + + -- Neon ceiling strips light the tunnel (one every 50 studs, alternating). + for i = 0, math.floor(SEGMENT_LENGTH / 50) do + local z = -(3 * SEGMENT_LENGTH) - i * 50 + local strip = part({ + Name = "TunnelLight", + Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, 2), + Position = Vector3.new(0, tunnelHeight - 0.5, z), + Color = i % 2 == 0 and NEON_BLUE or NEON_TEAL, + Material = Enum.Material.Neon, + }) + strip.Parent = folder + end +end + +-- Jump ramps (segment 4): launch + landing pair on the right half --------------- +local function buildRamps(map: Folder) + local folder = Instance.new("Folder") + folder.Name = "Ramps" + folder.Parent = map + + local zStart = 4 * SEGMENT_LENGTH + 100 + local rampX = ROAD_WIDTH / 4 -- right half of the road; left half stays flat + + -- Launch ramp: a wedge sloping up in the direction of travel (-Z). + local launch = Instance.new("WedgePart") + launch.Anchored = true + launch.Name = "RampLaunch" + launch.Size = Vector3.new(ROAD_WIDTH / 2 - 4, 10, 48) + -- WedgePart slopes from its -Z (tall) to +Z (thin edge); car travels -Z, + -- so face the thin edge toward the approaching car (+Z toward car = yaw 180). + launch.CFrame = CFrame.new(rampX, 5, -zStart) * CFrame.Angles(0, math.rad(180), 0) + launch.Color = Color3.fromRGB(50, 50, 62) + launch.Material = Enum.Material.Metal + launch.TopSurface = Enum.SurfaceType.Smooth + launch.Parent = folder + + local edge = part({ + Name = "RampEdgeGlow", + Size = Vector3.new(ROAD_WIDTH / 2 - 4, 0.8, 1), + Position = Vector3.new(rampX, 10.4, -zStart - 24), + Color = NEON_PINK, + Material = Enum.Material.Neon, + }) + edge.Parent = folder + + -- Landing ramp: mirrored wedge further down so clean jumps land smooth. + local landing = Instance.new("WedgePart") + landing.Anchored = true + landing.Name = "RampLanding" + landing.Size = Vector3.new(ROAD_WIDTH / 2 - 4, 10, 60) + landing.CFrame = CFrame.new(rampX, 5, -(zStart + 150)) + landing.Color = Color3.fromRGB(50, 50, 62) + landing.Material = Enum.Material.Metal + landing.TopSurface = Enum.SurfaceType.Smooth + landing.Parent = folder +end + +-- Neon skyline: procedural building blocks flanking the route ------------------- +local function buildSkyline(map: Folder, totalLength: number) + local folder = Instance.new("Folder") + folder.Name = "Skyline" + folder.Parent = map + + local accents = { NEON_PINK, NEON_BLUE, NEON_TEAL, Color3.fromRGB(200, 120, 255) } + + for _, side in ipairs({ -1, 1 }) do + local n = 0 + local z = -80 + while z > -totalLength do + n += 1 + local seed = side * 1000 + n + local width = 40 + det(seed) * 60 + local depth = 40 + det(seed + 1) * 60 + local height = 40 + det(seed + 2) * 160 + local gap = 30 + det(seed + 3) * 70 + local x = side * (ROAD_WIDTH / 2 + 50 + det(seed + 4) * 90 + width / 2) + + local tower = part({ + Name = "Tower", + Size = Vector3.new(width, height, depth), + Position = Vector3.new(x, height / 2 - 10, z - depth / 2), + Color = Color3.fromRGB(16, 16, 24), + Material = Enum.Material.Slate, + }) + tower.Parent = folder + + -- Lit window band + rooftop accent (2 parts per tower, cheap). + local accent = accents[(n % #accents) + 1] + local band = part({ + Name = "TowerBand", + Size = Vector3.new(width + 0.4, 2 + det(seed + 5) * 4, depth + 0.4), + Position = Vector3.new(x, height * (0.4 + det(seed + 6) * 0.4), z - depth / 2), + Color = accent, + Material = Enum.Material.Neon, + Transparency = 0.2, + }) + band.Parent = folder + + local roof = part({ + Name = "TowerRoof", + Size = Vector3.new(width * 0.3, 1.5, depth * 0.3), + Position = Vector3.new(x, height - 9, z - depth / 2), + Color = accent, + Material = Enum.Material.Neon, + }) + roof.Parent = folder + + z = z - depth - gap + end + end +end + function MapBuilder.build(): Folder local existing = Workspace:FindFirstChild("Map") if existing then @@ -45,63 +294,12 @@ function MapBuilder.build(): Folder local totalLength = SEGMENT_LENGTH * NUM_SEGMENTS - -- Road deck (segmented so you can later bank/curve individual pieces) ------ - for i = 0, NUM_SEGMENTS - 1 do - local z = -(i * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2 - local road = part({ - Name = "Road_" .. i, - Size = Vector3.new(ROAD_WIDTH, 2, SEGMENT_LENGTH), - Position = Vector3.new(0, 0, z), - Color = Color3.fromRGB(28, 28, 34), - Material = Enum.Material.Asphalt, - }) - road.Parent = roadFolder - - -- Center lane line - local line = part({ - Name = "Lane_" .. i, - Size = Vector3.new(1, 2.1, SEGMENT_LENGTH), - Position = Vector3.new(0, 0.1, z), - Color = Color3.fromRGB(230, 200, 60), - Material = Enum.Material.Neon, - }) - line.Parent = roadFolder - end - - -- Guardrails + invisible kill walls on both sides ------------------------- - for _, side in ipairs({ -1, 1 }) do - local x = side * (ROAD_WIDTH / 2) - local rail = part({ - Name = "Guardrail_" .. (side == -1 and "L" or "R"), - Size = Vector3.new(1.5, 4, totalLength), - Position = Vector3.new(x, 2, -totalLength / 2), - Color = Color3.fromRGB(180, 180, 190), - Material = Enum.Material.Metal, - }) - rail.Parent = map - - -- Invisible wall: solid, tall, non-rendered — the "can't leave" barrier. - local wall = part({ - Name = "KillWall_" .. (side == -1 and "L" or "R"), - Size = Vector3.new(2, WALL_HEIGHT, totalLength), - Position = Vector3.new(x + side * 2, WALL_HEIGHT / 2, -totalLength / 2), - Transparency = 1, - CanCollide = true, - }) - wall.Parent = map - - -- Neon pylons for that cyberpunk Tokyo glow (cheap: one every 200 studs) - for i = 0, NUM_SEGMENTS * 2 do - local pylon = part({ - Name = "Pylon", - Size = Vector3.new(1, 14, 1), - Position = Vector3.new(x + side * 3, 7, -(i * 200)), - Color = side == -1 and Color3.fromRGB(255, 40, 120) or Color3.fromRGB(40, 160, 255), - Material = Enum.Material.Neon, - }) - pylon.Parent = map - end - end + buildRoad(roadFolder) + buildBarriers(map, totalLength) + buildGantries(map) + buildTunnel(map) + buildRamps(map) + buildSkyline(map, totalLength) -- Spawn pad (where cars appear) at the start ------------------------------ local pad = part({ @@ -125,6 +323,16 @@ function MapBuilder.build(): Folder }) finish.Parent = map + -- End wall so you can't sail off the far edge ------------------------------ + local endWall = part({ + Name = "KillWall_End", + Size = Vector3.new(ROAD_WIDTH + 8, WALL_HEIGHT, 2), + Position = Vector3.new(0, WALL_HEIGHT / 2, -totalLength - 1), + Transparency = 1, + CanCollide = true, + }) + endWall.Parent = map + -- A floor far below to catch anything that clips through (safety net) ------ local floor = part({ Name = "SafetyFloor",