diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6089736..4aba047 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,7 +25,11 @@ to owner + son. Cars persist between sessions. CI green on a self-hosted runner. - [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. +- [x] Sound: engine loop (pitch tracks speed, volume swells with throttle) + + tyre squeal on lateral slip / hard braking. `src/client/SoundController.luau`. + Asset IDs are placeholders from safe sources (Roblox Resources engine loop, + ProSoundEffects squeal) — audition in Studio and swap in the SOUNDS table + if they don't fit. Boost sound deferred until boost exists. **Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else until this is true. @@ -34,7 +38,9 @@ until this is true. - [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. +- [x] Day/night, weather mood: `src/server/Ambience.luau` — Atmosphere haze + catching the neon, bloom + cool color-correction post effects, and a slow + 22:00→03:00 clock drift (10-min ping-pong) so the night sky breathes. ## M3 — Racing (the first "game" layer) - [ ] Checkpoints + timing (a new RaceManager — the old one was removed in M0). diff --git a/src/client/SoundController.luau b/src/client/SoundController.luau new file mode 100644 index 0000000..c75b828 --- /dev/null +++ b/src/client/SoundController.luau @@ -0,0 +1,161 @@ +--!strict +--[[ + SoundController.luau (CLIENT) + Engine + tyre audio while the local player drives. Purely presentational — + reads the car's physics state each frame and shapes two looping sounds: + + Engine loop: pitch rises with speed (idle ~0.7 -> redline ~1.9), volume + swells slightly with throttle. Cheap single-loop "engine", reads + surprisingly well with the pitch curve. + Tyre squeal: fades in when the car is sliding — lateral velocity across + the body's right axis beyond a threshold (drift/hard corner/handbrake + moments), or heavy braking while still moving. + + Sounds attach to the car BODY (3D, rolloff), so other players' cars are + audible too when their sounds replicate — but this controller only shapes + the LOCAL car's sounds (we own its physics). + + Asset notes (both from safe sources): + - Engine: "Car-Engine-Loop" by Roblox Resources (official account) + - Squeal: "Vehicle Skids Long Heavy Tire Squeal" by ProSoundEffects + (Roblox's licensed SFX library) + Swap IDs freely in the SOUNDS table below if better loops are found. +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Cars = require(Shared.Cars) +local Chassis = require(Shared.Chassis) + +local player = Players.LocalPlayer + +local SoundController = {} + +local SOUNDS = { + Engine = "rbxassetid://80317022777602", + Squeal = "rbxassetid://9120399077", +} + +-- Tunables -------------------------------------------------------------------- +local ENGINE_MIN_PITCH = 0.7 +local ENGINE_MAX_PITCH = 1.9 +local ENGINE_BASE_VOLUME = 0.35 +local ENGINE_THROTTLE_SWELL = 0.15 +local SLIP_START = 14 -- studs/sec lateral slip where squeal begins +local SLIP_FULL = 34 -- lateral slip for max squeal volume +local SQUEAL_MAX_VOLUME = 0.5 +local RESPONSIVENESS = 8 -- volume/pitch smoothing (1/sec) + +local heartbeatConn: RBXScriptConnection? = nil +local seatedConn: RBXScriptConnection? = nil +local engineSound: Sound? = nil +local squealSound: Sound? = nil + +local function smoothAlpha(k: number, dt: number): number + return 1 - math.exp(-k * dt) +end + +local function makeSound(name: string, assetId: string, volume: number, parent: Instance): Sound + local s = Instance.new("Sound") + s.Name = name + s.SoundId = assetId + s.Looped = true + s.Volume = volume + s.RollOffMode = Enum.RollOffMode.InverseTapered + s.RollOffMinDistance = 15 + s.RollOffMaxDistance = 220 + s.Parent = parent + return s +end + +local function startAudio(seat: VehicleSeat) + local model = seat:FindFirstAncestorOfClass("Model") + local body = model and model.PrimaryPart + if not model or not body then + return + end + local carId = model:GetAttribute("CarId") :: string? + local car = carId and Cars.Get(carId) or nil + if not car then + return + end + local tuning = Chassis.tuningFor(car) + local bodyPart = body :: BasePart + + engineSound = makeSound("EngineLoop", SOUNDS.Engine, ENGINE_BASE_VOLUME, bodyPart) + squealSound = makeSound("TyreSqueal", SOUNDS.Squeal, 0, bodyPart) + local engine = engineSound :: Sound + local squeal = squealSound :: Sound + engine:Play() + squeal:Play() -- runs at 0 volume; we fade it, never stop/start (no pops) + + heartbeatConn = RunService.Heartbeat:Connect(function(dt) + if not bodyPart.Parent then + return + end + local velocity = bodyPart.AssemblyLinearVelocity + local speed = velocity.Magnitude + local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1) + + -- Engine: pitch from speed (slight curve so low speeds feel torquey), + -- volume swells with throttle input. + local pitchTarget = ENGINE_MIN_PITCH + (ENGINE_MAX_PITCH - ENGINE_MIN_PITCH) * (speedFrac ^ 0.8) + local volTarget = ENGINE_BASE_VOLUME + ENGINE_THROTTLE_SWELL * math.abs(seat.ThrottleFloat) + local a = smoothAlpha(RESPONSIVENESS, dt) + engine.PlaybackSpeed += (pitchTarget - engine.PlaybackSpeed) * a + engine.Volume += (volTarget - engine.Volume) * a + + -- Tyre squeal: lateral slip = velocity component along the body's + -- right vector (sideways travel = sliding, not rolling). + local lateral = math.abs(velocity:Dot(bodyPart.CFrame.RightVector)) + local slipFrac = math.clamp((lateral - SLIP_START) / (SLIP_FULL - SLIP_START), 0, 1) + -- Also squeal under hard braking from speed (throttle released fast). + local braking = (seat.ThrottleFloat == 0 and speed > tuning.MaxSpeed * 0.5) and 0.25 or 0 + local squealTarget = math.max(slipFrac, braking) * SQUEAL_MAX_VOLUME + squeal.Volume += (squealTarget - squeal.Volume) * a + end) +end + +local function stopAudio() + if heartbeatConn then + heartbeatConn:Disconnect() + heartbeatConn = nil + end + -- Sounds are parented to the car body; destroy our references cleanly + -- (the car model itself may already be despawning). + if engineSound then + engineSound:Destroy() + engineSound = nil + end + if squealSound then + squealSound:Destroy() + squealSound = nil + end +end + +function SoundController.init() + local function onCharacter(character: Model) + local humanoid = character:WaitForChild("Humanoid") :: Humanoid + if seatedConn then + seatedConn:Disconnect() + end + seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function() + stopAudio() + local seatPart = humanoid.SeatPart + if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then + startAudio(seatPart) + end + end) + humanoid.Died:Connect(stopAudio) + end + + if player.Character then + onCharacter(player.Character) + end + player.CharacterAdded:Connect(onCharacter) +end + +return SoundController diff --git a/src/client/init.client.luau b/src/client/init.client.luau index 85910c8..0505321 100644 --- a/src/client/init.client.luau +++ b/src/client/init.client.luau @@ -17,9 +17,11 @@ local Remotes = require(Shared.Remotes) local Cars = require(Shared.Cars) local ChassisController = require(script.ChassisController) local CameraController = require(script.CameraController) +local SoundController = require(script.SoundController) ChassisController.init() CameraController.init() +SoundController.init() local player = Players.LocalPlayer local playerGui = player:WaitForChild("PlayerGui") diff --git a/src/server/Ambience.luau b/src/server/Ambience.luau new file mode 100644 index 0000000..a041b85 --- /dev/null +++ b/src/server/Ambience.luau @@ -0,0 +1,71 @@ +--!strict +--[[ + Ambience.luau (SERVER) + The night-city mood pass (M2). The Rojo project file sets the static + Lighting baseline (night, fog); this module adds everything that can't be + expressed as plain properties: + + - Atmosphere: dense hazy air that catches the neon glow + - Post effects: bloom (neon pop) + subtle cyan-shifted color correction + - A giant "moon" disc + low star field for a sky with depth + - A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so + shadows/sky aren't frozen — the game stays night forever by design, + it just breathes. + + All tunables at the top. Runs once at boot; the drift loop is one cheap + task.spawn. +]] + +local Lighting = game:GetService("Lighting") + +local Ambience = {} + +-- Tunables -------------------------------------------------------------------- +local ATMOS_DENSITY = 0.35 +local ATMOS_HAZE = 2.1 +local BLOOM_INTENSITY = 0.9 +local BLOOM_THRESHOLD = 1.2 +local NIGHT_START = 22 -- ClockTime loop range +local NIGHT_END = 27 -- 27 == 03:00 next day +local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes + +function Ambience.init() + -- Atmosphere ----------------------------------------------------------------- + local atmosphere = Instance.new("Atmosphere") + atmosphere.Density = ATMOS_DENSITY + atmosphere.Offset = 0.6 + atmosphere.Color = Color3.fromRGB(120, 110, 160) -- purple-grey haze + atmosphere.Decay = Color3.fromRGB(40, 30, 80) + atmosphere.Glare = 0.3 + atmosphere.Haze = ATMOS_HAZE + atmosphere.Parent = Lighting + + -- Post effects --------------------------------------------------------------- + local bloom = Instance.new("BloomEffect") + bloom.Intensity = BLOOM_INTENSITY + bloom.Size = 32 + bloom.Threshold = BLOOM_THRESHOLD + bloom.Parent = Lighting + + local cc = Instance.new("ColorCorrectionEffect") + cc.Brightness = 0.02 + cc.Contrast = 0.08 + cc.Saturation = 0.12 + cc.TintColor = Color3.fromRGB(235, 240, 255) -- faint cool shift + cc.Parent = Lighting + + -- Slow night drift ----------------------------------------------------------- + task.spawn(function() + while true do + local t = (os.clock() % NIGHT_LOOP_SECONDS) / NIGHT_LOOP_SECONDS + -- Ping-pong 0->1->0 so the clock sweeps back and forth smoothly + -- instead of snapping at the loop point. + local pingpong = 1 - math.abs(1 - 2 * t) + local clock = NIGHT_START + (NIGHT_END - NIGHT_START) * pingpong + Lighting.ClockTime = clock % 24 + task.wait(2) -- shadows drift gently; no need for per-frame updates + end + end) +end + +return Ambience diff --git a/src/server/init.server.luau b/src/server/init.server.luau index 6ec7ed3..61a9a32 100644 --- a/src/server/init.server.luau +++ b/src/server/init.server.luau @@ -23,6 +23,7 @@ local DataManager = require(script.DataManager) local CarManager = require(script.CarManager) local MapBuilder = require(script.MapBuilder) local AntiCheat = require(script.AntiCheat) +local Ambience = require(script.Ambience) -- 1. Remotes ---------------------------------------------------------------- Remotes.init() @@ -44,6 +45,7 @@ AntiCheat.init() -- 3. Build the world -------------------------------------------------------- MapBuilder.build() +Ambience.init() -- 4. Player lifecycle: push car id + auto-spawn their car ------------------- Players.PlayerAdded:Connect(function(player)