Initial MVP: drivable RX-7 vertical slice + server-authoritative systems
CI / quality (push) Failing after 5m53s
CI / quality (push) Failing after 5m53s
- Rojo + Wally project; ProfileStore (loleris) for safe saves - Shared: GameConfig, Cars (RX-7 free starter), ProfileTemplate, Remotes - Server: DataManager, Economy, CarManager, RaceManager, MapBuilder, AntiCheat - Client: neon HUD + Garage/Dealership UI (buy/equip/upgrade), race/daily - Procedural Tokyo expressway (spawn pad, finish line, neon pylons, kill walls) - CI workflow (Gitea/GitHub), Makefile, selene+stylua clean, rojo builds - Docs: README, GDD, realistic solo ROADMAP
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
--!strict
|
||||
--[[
|
||||
init.client.luau (CLIENT ENTRY POINT)
|
||||
Builds the HUD (cash / rep / car), a Garage+Dealership panel, and a Daily
|
||||
button — all in code so there's nothing to build in Studio. The client is
|
||||
purely presentational: it shows server-provided data and sends requests.
|
||||
It never decides money or ownership.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local Remotes = require(Shared.Remotes)
|
||||
local Cars = require(Shared.Cars)
|
||||
|
||||
local player = Players.LocalPlayer
|
||||
local playerGui = player:WaitForChild("PlayerGui")
|
||||
|
||||
-- Remotes -------------------------------------------------------------------
|
||||
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
||||
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
||||
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
||||
local rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
|
||||
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
|
||||
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
|
||||
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
|
||||
|
||||
-- Local cache of the latest server snapshot ---------------------------------
|
||||
local latest: any = nil
|
||||
|
||||
-- UI helpers ----------------------------------------------------------------
|
||||
local NEON_PINK = Color3.fromRGB(255, 40, 120)
|
||||
local NEON_BLUE = Color3.fromRGB(40, 160, 255)
|
||||
local PANEL_BG = Color3.fromRGB(16, 16, 24)
|
||||
|
||||
local function make(class: string, props: { [string]: any }, parent: Instance?): any
|
||||
local inst = Instance.new(class)
|
||||
for k, v in pairs(props) do
|
||||
(inst :: any)[k] = v
|
||||
end
|
||||
if parent then
|
||||
inst.Parent = parent
|
||||
end
|
||||
return inst
|
||||
end
|
||||
|
||||
local function corner(inst: Instance, radius: number)
|
||||
make("UICorner", { CornerRadius = UDim.new(0, radius) }, inst)
|
||||
end
|
||||
|
||||
local screen = make("ScreenGui", {
|
||||
Name = "FnF_UI",
|
||||
ResetOnSpawn = false,
|
||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
}, playerGui)
|
||||
|
||||
-- ===== HUD (top-left): cash / rep / car ====================================
|
||||
local hud = make("Frame", {
|
||||
Name = "HUD",
|
||||
Size = UDim2.fromOffset(260, 96),
|
||||
Position = UDim2.fromOffset(16, 16),
|
||||
BackgroundColor3 = PANEL_BG,
|
||||
BackgroundTransparency = 0.15,
|
||||
BorderSizePixel = 0,
|
||||
}, screen)
|
||||
corner(hud, 10)
|
||||
make("UIStroke", { Color = NEON_PINK, Thickness = 1.5, Transparency = 0.3 }, hud)
|
||||
|
||||
local cashLabel = make("TextLabel", {
|
||||
Size = UDim2.new(1, -20, 0, 28),
|
||||
Position = UDim2.fromOffset(12, 8),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextColor3 = Color3.fromRGB(120, 255, 170),
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 20,
|
||||
Text = "$ --",
|
||||
}, hud)
|
||||
|
||||
local repLabel = make("TextLabel", {
|
||||
Size = UDim2.new(1, -20, 0, 22),
|
||||
Position = UDim2.fromOffset(12, 38),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextColor3 = NEON_BLUE,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 16,
|
||||
Text = "REP --",
|
||||
}, hud)
|
||||
|
||||
local carLabel = make("TextLabel", {
|
||||
Size = UDim2.new(1, -20, 0, 20),
|
||||
Position = UDim2.fromOffset(12, 64),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.Gotham,
|
||||
TextColor3 = Color3.fromRGB(220, 220, 230),
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 14,
|
||||
Text = "Car: --",
|
||||
}, hud)
|
||||
|
||||
-- ===== Bottom action bar ===================================================
|
||||
local bar = make("Frame", {
|
||||
Name = "ActionBar",
|
||||
Size = UDim2.fromOffset(430, 52),
|
||||
Position = UDim2.new(0.5, -215, 1, -70),
|
||||
BackgroundTransparency = 1,
|
||||
}, screen)
|
||||
make("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
Padding = UDim.new(0, 10),
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
}, bar)
|
||||
|
||||
local function button(text: string, color: Color3, order: number): TextButton
|
||||
local b = make("TextButton", {
|
||||
Size = UDim2.fromOffset(100, 44),
|
||||
BackgroundColor3 = PANEL_BG,
|
||||
BackgroundTransparency = 0.1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextColor3 = color,
|
||||
TextSize = 15,
|
||||
Text = text,
|
||||
AutoButtonColor = true,
|
||||
LayoutOrder = order,
|
||||
BorderSizePixel = 0,
|
||||
}, bar)
|
||||
corner(b, 8)
|
||||
make("UIStroke", { Color = color, Thickness = 1.2, Transparency = 0.4 }, b)
|
||||
return b
|
||||
end
|
||||
|
||||
local garageBtn = button("GARAGE", NEON_BLUE, 1)
|
||||
local spawnBtn = button("SPAWN CAR", Color3.fromRGB(120, 255, 170), 2)
|
||||
local raceBtn = button("RACE", NEON_PINK, 3)
|
||||
local dailyBtn = button("DAILY", Color3.fromRGB(255, 210, 80), 4)
|
||||
|
||||
-- ===== Toast (transient messages) ==========================================
|
||||
local toast = make("TextLabel", {
|
||||
Name = "Toast",
|
||||
Size = UDim2.fromOffset(360, 34),
|
||||
Position = UDim2.new(0.5, -180, 1, -120),
|
||||
BackgroundColor3 = PANEL_BG,
|
||||
BackgroundTransparency = 0.1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
TextSize = 15,
|
||||
Text = "",
|
||||
Visible = false,
|
||||
BorderSizePixel = 0,
|
||||
}, screen)
|
||||
corner(toast, 8)
|
||||
|
||||
local toastToken = 0
|
||||
local function showToast(msg: string)
|
||||
toast.Text = msg
|
||||
toast.Visible = true
|
||||
toastToken += 1
|
||||
local myToken = toastToken
|
||||
task.delay(2.6, function()
|
||||
if toastToken == myToken then
|
||||
toast.Visible = false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ===== Garage / Dealership panel ===========================================
|
||||
local garage = make("Frame", {
|
||||
Name = "Garage",
|
||||
Size = UDim2.fromOffset(560, 420),
|
||||
Position = UDim2.new(0.5, -280, 0.5, -210),
|
||||
BackgroundColor3 = PANEL_BG,
|
||||
BackgroundTransparency = 0.05,
|
||||
Visible = false,
|
||||
BorderSizePixel = 0,
|
||||
}, screen)
|
||||
corner(garage, 12)
|
||||
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, garage)
|
||||
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 0, 40),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
TextSize = 20,
|
||||
Text = "GARAGE & DEALERSHIP",
|
||||
}, garage)
|
||||
|
||||
local closeBtn = make("TextButton", {
|
||||
Size = UDim2.fromOffset(32, 32),
|
||||
Position = UDim2.new(1, -40, 0, 6),
|
||||
BackgroundColor3 = NEON_PINK,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
TextSize = 18,
|
||||
Text = "X",
|
||||
BorderSizePixel = 0,
|
||||
}, garage)
|
||||
corner(closeBtn, 8)
|
||||
|
||||
local list = make("ScrollingFrame", {
|
||||
Size = UDim2.new(1, -24, 1, -56),
|
||||
Position = UDim2.fromOffset(12, 46),
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
ScrollBarThickness = 6,
|
||||
CanvasSize = UDim2.new(0, 0, 0, 0),
|
||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||
}, garage)
|
||||
make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, list)
|
||||
|
||||
-- Build one row per car; content depends on owned/equipped/affordable --------
|
||||
local function rebuildGarage()
|
||||
if not latest then
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(list:GetChildren()) do
|
||||
if child:IsA("Frame") then
|
||||
child:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
local ownedIds: { [string]: any } = {}
|
||||
for _, o in ipairs(latest.OwnedCars) do
|
||||
ownedIds[o.Id] = o
|
||||
end
|
||||
|
||||
for i, car in ipairs(Cars.List) do
|
||||
local owned = ownedIds[car.Id]
|
||||
local equipped = latest.EquippedCarId == car.Id
|
||||
|
||||
local row = make("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 72),
|
||||
BackgroundColor3 = Color3.fromRGB(26, 26, 38),
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = i,
|
||||
}, list)
|
||||
corner(row, 8)
|
||||
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(0.6, 0, 0, 22),
|
||||
Position = UDim2.fromOffset(12, 8),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 16,
|
||||
Text = string.format("T%d %s", car.Tier, car.Name),
|
||||
}, row)
|
||||
|
||||
make("TextLabel", {
|
||||
Size = UDim2.new(0.6, 0, 0, 18),
|
||||
Position = UDim2.fromOffset(12, 32),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.Gotham,
|
||||
TextColor3 = Color3.fromRGB(170, 170, 185),
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 12,
|
||||
Text = string.format(
|
||||
"%s | SPD %d ACC %d HND %d",
|
||||
car.Model,
|
||||
car.Stats.Speed,
|
||||
car.Stats.Acceleration,
|
||||
car.Stats.Handling
|
||||
),
|
||||
}, row)
|
||||
|
||||
-- Primary action button (Equip / Buy / locked) --------------------------
|
||||
local action = make("TextButton", {
|
||||
Size = UDim2.fromOffset(120, 30),
|
||||
Position = UDim2.new(1, -132, 0, 10),
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextSize = 13,
|
||||
BorderSizePixel = 0,
|
||||
}, row)
|
||||
corner(action, 6)
|
||||
|
||||
if owned then
|
||||
if equipped then
|
||||
action.Text = "EQUIPPED"
|
||||
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
|
||||
action.TextColor3 = Color3.fromRGB(120, 255, 170)
|
||||
else
|
||||
action.Text = "EQUIP"
|
||||
action.BackgroundColor3 = NEON_BLUE
|
||||
action.TextColor3 = Color3.fromRGB(0, 0, 0)
|
||||
action.MouseButton1Click:Connect(function()
|
||||
local ok = rEquipCar:InvokeServer(car.Id)
|
||||
if ok then
|
||||
showToast("Equipped " .. car.Name)
|
||||
end
|
||||
end)
|
||||
end
|
||||
else
|
||||
local affordable = latest.Cash >= car.Price and latest.Rep >= car.RepRequired
|
||||
action.Text = string.format("BUY $%d", car.Price)
|
||||
action.BackgroundColor3 = affordable and Color3.fromRGB(120, 255, 170) or Color3.fromRGB(60, 40, 48)
|
||||
action.TextColor3 = affordable and Color3.fromRGB(0, 0, 0) or Color3.fromRGB(200, 120, 130)
|
||||
action.MouseButton1Click:Connect(function()
|
||||
local res = rBuyCar:InvokeServer(car.Id)
|
||||
if res and res.ok then
|
||||
showToast("Purchased " .. car.Name .. "!")
|
||||
elseif res then
|
||||
showToast(res.reason or "Cannot buy")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Upgrade button (only for owned cars) ----------------------------------
|
||||
if owned then
|
||||
local upg = make("TextButton", {
|
||||
Size = UDim2.fromOffset(120, 24),
|
||||
Position = UDim2.new(1, -132, 0, 42),
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 12,
|
||||
BackgroundColor3 = Color3.fromRGB(45, 45, 60),
|
||||
TextColor3 = Color3.fromRGB(255, 210, 80),
|
||||
BorderSizePixel = 0,
|
||||
Text = string.format(
|
||||
"UPGRADE (E%d/T%d/N%d)",
|
||||
owned.Upgrades.Engine,
|
||||
owned.Upgrades.Tires,
|
||||
owned.Upgrades.Nitro
|
||||
),
|
||||
}, row)
|
||||
corner(upg, 6)
|
||||
upg.MouseButton1Click:Connect(function()
|
||||
-- MVP: cycle Engine -> Tires -> Nitro whichever is lowest.
|
||||
local parts = { "Engine", "Tires", "Nitro" }
|
||||
table.sort(parts, function(a, b)
|
||||
return owned.Upgrades[a] < owned.Upgrades[b]
|
||||
end)
|
||||
local res = rUpgradeCar:InvokeServer(car.Id, parts[1])
|
||||
if res and res.ok then
|
||||
showToast("Upgraded " .. parts[1] .. "!")
|
||||
elseif res then
|
||||
showToast(res.reason or "Cannot upgrade")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== Data sync ===========================================================
|
||||
local function applyData(data: any)
|
||||
latest = data
|
||||
cashLabel.Text = string.format("$ %s", tostring(data.Cash))
|
||||
repLabel.Text = string.format("REP %s", tostring(data.Rep))
|
||||
local car = Cars.Get(data.EquippedCarId)
|
||||
carLabel.Text = "Car: " .. (car and car.Name or data.EquippedCarId)
|
||||
if garage.Visible then
|
||||
rebuildGarage()
|
||||
end
|
||||
end
|
||||
|
||||
rDataUpdated.OnClientEvent:Connect(applyData)
|
||||
|
||||
-- ===== Wire buttons ========================================================
|
||||
garageBtn.MouseButton1Click:Connect(function()
|
||||
garage.Visible = not garage.Visible
|
||||
if garage.Visible then
|
||||
rebuildGarage()
|
||||
end
|
||||
end)
|
||||
closeBtn.MouseButton1Click:Connect(function()
|
||||
garage.Visible = false
|
||||
end)
|
||||
spawnBtn.MouseButton1Click:Connect(function()
|
||||
rSpawnCar:FireServer()
|
||||
showToast("Spawning your car...")
|
||||
end)
|
||||
raceBtn.MouseButton1Click:Connect(function()
|
||||
local res = rStartRace:InvokeServer("expressway_timetrial")
|
||||
if res and res.ok then
|
||||
showToast("Race started! Reach the finish line!")
|
||||
elseif res then
|
||||
showToast(res.reason or "Cannot start race")
|
||||
end
|
||||
end)
|
||||
dailyBtn.MouseButton1Click:Connect(function()
|
||||
local res = rClaimDaily:InvokeServer()
|
||||
if res and res.ok then
|
||||
showToast(string.format("Daily: +$%d, +%d rep!", res.cash or 0, res.rep or 0))
|
||||
elseif res then
|
||||
showToast(res.reason or "Already claimed")
|
||||
end
|
||||
end)
|
||||
|
||||
print("[FnF] Client UI ready.")
|
||||
@@ -0,0 +1,81 @@
|
||||
--!strict
|
||||
--[[
|
||||
AntiCheat.luau (SERVER)
|
||||
MVP-level movement sanity check. Not a fortress — a speed bump that catches
|
||||
obvious speed/teleport hacks. Because money & car ownership are ALREADY
|
||||
server-authoritative (Economy validates everything), the main client attack
|
||||
surface left is movement, so we watch that.
|
||||
|
||||
Approach: sample each player's HRP position periodically; if they cover
|
||||
ground implying a sustained speed above MaxPlausibleSpeed, snap them back
|
||||
to their last valid position. Generous threshold to avoid false positives
|
||||
from legit fast cars.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local RunService = game:GetService("RunService")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local GameConfig = require(ReplicatedStorage.Shared.GameConfig)
|
||||
|
||||
local AntiCheat = {}
|
||||
|
||||
type Track = { lastPos: Vector3, lastValid: CFrame }
|
||||
|
||||
local tracked: { [Player]: Track } = {}
|
||||
local SAMPLE_INTERVAL = 0.5
|
||||
|
||||
local function hrpOf(player: Player): BasePart?
|
||||
local char = player.Character
|
||||
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
|
||||
end
|
||||
|
||||
function AntiCheat.init()
|
||||
local function watch(player: Player)
|
||||
player.CharacterAdded:Connect(function(char)
|
||||
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
|
||||
if hrp then
|
||||
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
|
||||
end
|
||||
end)
|
||||
end
|
||||
for _, p in ipairs(Players:GetPlayers()) do
|
||||
watch(p)
|
||||
end
|
||||
Players.PlayerAdded:Connect(watch)
|
||||
Players.PlayerRemoving:Connect(function(p)
|
||||
tracked[p] = nil
|
||||
end)
|
||||
|
||||
local accum = 0
|
||||
RunService.Heartbeat:Connect(function(dt)
|
||||
accum += dt
|
||||
if accum < SAMPLE_INTERVAL then
|
||||
return
|
||||
end
|
||||
local interval = accum
|
||||
accum = 0
|
||||
|
||||
for player, track in pairs(tracked) do
|
||||
local hrp = hrpOf(player)
|
||||
if not hrp then
|
||||
continue
|
||||
end
|
||||
local pos = hrp.Position
|
||||
-- Falling doesn't count (avoid flagging big drops). Only guard XZ plane.
|
||||
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
|
||||
local hSpeed = horizontal / interval
|
||||
|
||||
if hSpeed > GameConfig.MaxPlausibleSpeed then
|
||||
-- Snap back: likely a speed/teleport hack.
|
||||
hrp.CFrame = track.lastValid
|
||||
hrp.AssemblyLinearVelocity = Vector3.zero
|
||||
else
|
||||
track.lastValid = hrp.CFrame
|
||||
end
|
||||
track.lastPos = hrp.Position
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return AntiCheat
|
||||
@@ -0,0 +1,178 @@
|
||||
--!strict
|
||||
--[[
|
||||
CarManager.luau (SERVER)
|
||||
Spawns the player's equipped car as a simple, PROVEN drivable vehicle.
|
||||
|
||||
Design choice (per "don't reinvent the wheel"): instead of a hand-rolled
|
||||
physics chassis, the MVP car is a single driving Part + a VehicleSeat.
|
||||
Roblox's VehicleSeat drives the assembly via its built-in Throttle/Steer —
|
||||
the most reliable, least-buggy way to get a car moving on day one. When you
|
||||
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
|
||||
you swap out `buildCarModel` and keep everything else.
|
||||
|
||||
Upgrades scale MaxSpeed / Torque / TurnSpeed so the upgrade economy is real.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local Cars = require(Shared.Cars)
|
||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
type ProfileData = ProfileTemplate.ProfileData
|
||||
type Car = Cars.Car
|
||||
|
||||
local CarManager = {}
|
||||
|
||||
local activeCars: { [Player]: Model } = {}
|
||||
local carsFolder: Folder
|
||||
|
||||
-- Convert a car's 0-100 stats + upgrade levels into VehicleSeat tuning --------
|
||||
local function computeTuning(car: Car, upgrades: { Engine: number, Tires: number, Nitro: number })
|
||||
local engineLvl = upgrades.Engine or 0
|
||||
local tiresLvl = upgrades.Tires or 0
|
||||
-- Base values derived from stats; upgrades add a percentage on top.
|
||||
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
|
||||
maxSpeed *= (1 + engineLvl * 0.08) -- +8% top speed per engine level
|
||||
local torque = 8000 + car.Stats.Acceleration * 220
|
||||
torque *= (1 + engineLvl * 0.10)
|
||||
local turnSpeed = 20 + car.Stats.Handling * 0.35
|
||||
turnSpeed *= (1 + tiresLvl * 0.06) -- tires help turn-in
|
||||
return maxSpeed, torque, turnSpeed
|
||||
end
|
||||
|
||||
-- Build the placeholder car model. Returns the Model with a VehicleSeat.
|
||||
local function buildCarModel(car: Car, maxSpeed: number, torque: number, turnSpeed: number): Model
|
||||
local model = Instance.new("Model")
|
||||
model.Name = "Car_" .. car.Id
|
||||
|
||||
-- Body (the driving part) --------------------------------------------------
|
||||
local body = Instance.new("Part")
|
||||
body.Name = "Body"
|
||||
body.Size = Vector3.new(6, 2, 12)
|
||||
body.Color = Color3.new(car.Color[1], car.Color[2], car.Color[3])
|
||||
body.Material = Enum.Material.SmoothPlastic
|
||||
body.CanCollide = true
|
||||
body.Parent = model
|
||||
|
||||
-- A cabin block so it reads as a car, welded to the body -------------------
|
||||
local cabin = Instance.new("Part")
|
||||
cabin.Name = "Cabin"
|
||||
cabin.Size = Vector3.new(5, 1.6, 5)
|
||||
cabin.Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7)
|
||||
cabin.Material = Enum.Material.Glass
|
||||
cabin.CanCollide = false
|
||||
cabin.Parent = model
|
||||
local cabinWeld = Instance.new("WeldConstraint")
|
||||
cabinWeld.Part0 = body
|
||||
cabinWeld.Part1 = cabin
|
||||
cabinWeld.Parent = body
|
||||
cabin.CFrame = body.CFrame * CFrame.new(0, 1.8, 0.5)
|
||||
|
||||
-- VehicleSeat: Roblox's built-in, reliable driver ------------------------
|
||||
local seat = Instance.new("VehicleSeat")
|
||||
seat.Name = "DriveSeat"
|
||||
seat.Size = Vector3.new(4, 0.5, 3)
|
||||
seat.MaxSpeed = maxSpeed
|
||||
seat.Torque = torque
|
||||
seat.TurnSpeed = turnSpeed
|
||||
seat.HeadsUpDisplay = false
|
||||
seat.CanCollide = false
|
||||
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
||||
seat.Parent = model
|
||||
local seatWeld = Instance.new("WeldConstraint")
|
||||
seatWeld.Part0 = body
|
||||
seatWeld.Part1 = seat
|
||||
seatWeld.Parent = body
|
||||
seat.CFrame = body.CFrame * CFrame.new(0, 1.6, -1)
|
||||
|
||||
model.PrimaryPart = body
|
||||
return model
|
||||
end
|
||||
|
||||
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
|
||||
local function spawnCFrame(): CFrame
|
||||
local map = Workspace:FindFirstChild("Map")
|
||||
if map then
|
||||
local pad = map:FindFirstChild("SpawnPad", true)
|
||||
if pad and pad:IsA("BasePart") then
|
||||
return pad.CFrame * CFrame.new(0, 5, 0)
|
||||
end
|
||||
end
|
||||
return CFrame.new(0, 8, 0)
|
||||
end
|
||||
|
||||
--- Despawn the player's current car if any.
|
||||
function CarManager.despawn(player: Player)
|
||||
local existing = activeCars[player]
|
||||
if existing then
|
||||
existing:Destroy()
|
||||
activeCars[player] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Spawn (or replace) the player's equipped car and seat them in it.
|
||||
function CarManager.spawn(player: Player, carId: string?)
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return
|
||||
end
|
||||
local id = carId or data.EquippedCarId
|
||||
local car = Cars.Get(id)
|
||||
if not car then
|
||||
return
|
||||
end
|
||||
-- Only spawn cars the player owns (anti-cheat: no spawning unowned exotics).
|
||||
local owned
|
||||
for _, o in ipairs(data.OwnedCars) do
|
||||
if o.Id == id then
|
||||
owned = o
|
||||
break
|
||||
end
|
||||
end
|
||||
if not owned then
|
||||
return
|
||||
end
|
||||
|
||||
CarManager.despawn(player)
|
||||
|
||||
local maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades)
|
||||
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||
model:PivotTo(spawnCFrame())
|
||||
model.Parent = carsFolder
|
||||
model:SetAttribute("OwnerUserId", player.UserId)
|
||||
model:SetAttribute("CarId", id)
|
||||
|
||||
-- Sit the player down. Give the assembly network ownership to the driver so
|
||||
-- physics are smooth on their client (standard Roblox vehicle practice).
|
||||
local character = player.Character
|
||||
if character then
|
||||
local seat = model:FindFirstChild("DriveSeat") :: VehicleSeat
|
||||
local body = model.PrimaryPart
|
||||
if body then
|
||||
body:SetNetworkOwner(player)
|
||||
end
|
||||
if seat then
|
||||
task.defer(function()
|
||||
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
activeCars[player] = model
|
||||
end
|
||||
|
||||
function CarManager.init()
|
||||
carsFolder = Instance.new("Folder")
|
||||
carsFolder.Name = "ActiveCars"
|
||||
carsFolder.Parent = Workspace
|
||||
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
CarManager.despawn(player)
|
||||
end)
|
||||
end
|
||||
|
||||
return CarManager
|
||||
@@ -0,0 +1,151 @@
|
||||
--!strict
|
||||
--[[
|
||||
DataManager.luau (SERVER)
|
||||
Wraps loleris's ProfileStore (battle-tested) so the rest of the game never
|
||||
touches DataStores directly. This is deliberately built FIRST — data safety
|
||||
is the foundation everything else stands on.
|
||||
|
||||
Public API:
|
||||
DataManager.init()
|
||||
DataManager.get(player) -> ProfileData? (nil if still loading / kicked)
|
||||
DataManager.onReady(player, fn) -> runs fn(data) now or when loaded
|
||||
DataManager.forEachLoaded(fn)
|
||||
|
||||
Guarantees:
|
||||
- Session locking (no data loss from double-load across servers)
|
||||
- Reconcile fills new template keys onto old saves (forward migration)
|
||||
- Studio uses ProfileStore.Mock so you can test without a live DataStore
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local RunService = game:GetService("RunService")
|
||||
local ServerScriptService = game:GetService("ServerScriptService")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local ProfileStore = require(ServerScriptService.ServerPackages.ProfileStore)
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
||||
|
||||
type ProfileData = ProfileTemplate.ProfileData
|
||||
|
||||
local DataManager = {}
|
||||
|
||||
-- Profiles keyed by player. Value is the ProfileStore profile object.
|
||||
local profiles: { [Player]: any } = {}
|
||||
-- Callbacks queued before a profile finished loading.
|
||||
local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
|
||||
|
||||
local store: any = nil
|
||||
|
||||
local function keyFor(player: Player): string
|
||||
return "Player_" .. tostring(player.UserId)
|
||||
end
|
||||
|
||||
local function flushReady(player: Player, data: ProfileData)
|
||||
local queue = readyQueue[player]
|
||||
if not queue then
|
||||
return
|
||||
end
|
||||
readyQueue[player] = nil
|
||||
for _, fn in ipairs(queue) do
|
||||
task.spawn(fn, data)
|
||||
end
|
||||
end
|
||||
|
||||
local function onPlayerAdded(player: Player)
|
||||
local profile = store:StartSessionAsync(keyFor(player), {
|
||||
Cancel = function()
|
||||
-- Give up starting the session if the player already left.
|
||||
return player.Parent ~= Players
|
||||
end,
|
||||
})
|
||||
|
||||
if profile == nil then
|
||||
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
||||
player:Kick("[FnF] Your data could not be loaded. Please rejoin.")
|
||||
return
|
||||
end
|
||||
|
||||
profile:AddUserId(player.UserId) -- GDPR-friendly association
|
||||
profile:Reconcile() -- fill missing template keys
|
||||
|
||||
profile.OnSessionEnd:Connect(function()
|
||||
-- The session was lost (another server stole it, or we released it).
|
||||
profiles[player] = nil
|
||||
if player.Parent == Players then
|
||||
player:Kick("[FnF] Your session ended (data loaded elsewhere). Please rejoin.")
|
||||
end
|
||||
end)
|
||||
|
||||
if player.Parent ~= Players then
|
||||
-- Player left during the yield above; release immediately.
|
||||
profile:EndSession()
|
||||
return
|
||||
end
|
||||
|
||||
profiles[player] = profile
|
||||
flushReady(player, profile.Data)
|
||||
end
|
||||
|
||||
local function onPlayerRemoving(player: Player)
|
||||
local profile = profiles[player]
|
||||
if profile then
|
||||
profile:EndSession() -- saves + releases the lock
|
||||
end
|
||||
profiles[player] = nil
|
||||
readyQueue[player] = nil
|
||||
end
|
||||
|
||||
--- Returns the live, mutable data table (changes autosave). nil while loading.
|
||||
function DataManager.get(player: Player): ProfileData?
|
||||
local profile = profiles[player]
|
||||
return profile and profile.Data or nil
|
||||
end
|
||||
|
||||
--- Run fn with the player's data now (if loaded) or as soon as it loads.
|
||||
function DataManager.onReady(player: Player, fn: (ProfileData) -> ())
|
||||
local profile = profiles[player]
|
||||
if profile then
|
||||
task.spawn(fn, profile.Data)
|
||||
return
|
||||
end
|
||||
readyQueue[player] = readyQueue[player] or {}
|
||||
table.insert(readyQueue[player], fn)
|
||||
end
|
||||
|
||||
--- Iterate every currently-loaded player's data.
|
||||
function DataManager.forEachLoaded(fn: (Player, ProfileData) -> ())
|
||||
for player, profile in pairs(profiles) do
|
||||
fn(player, profile.Data)
|
||||
end
|
||||
end
|
||||
|
||||
function DataManager.init()
|
||||
assert(RunService:IsServer(), "DataManager is server-only")
|
||||
|
||||
local base = ProfileStore.New(GameConfig.ProfileStoreName, ProfileTemplate)
|
||||
-- In Studio, use the in-memory Mock store so testing never touches live data.
|
||||
if GameConfig.UseMockInStudio and RunService:IsStudio() then
|
||||
store = base.Mock
|
||||
else
|
||||
store = base
|
||||
end
|
||||
|
||||
for _, player in ipairs(Players:GetPlayers()) do
|
||||
task.spawn(onPlayerAdded, player)
|
||||
end
|
||||
Players.PlayerAdded:Connect(onPlayerAdded)
|
||||
Players.PlayerRemoving:Connect(onPlayerRemoving)
|
||||
|
||||
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
|
||||
-- but ending sessions explicitly is tidy for the last frame).
|
||||
game:BindToClose(function()
|
||||
for _, player in ipairs(Players:GetPlayers()) do
|
||||
onPlayerRemoving(player)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return DataManager
|
||||
@@ -0,0 +1,162 @@
|
||||
--!strict
|
||||
--[[
|
||||
Economy.luau (SERVER)
|
||||
The ONLY place cash/rep changes. Everything is server-authoritative: clients
|
||||
can request actions but never set their own money. After any change we push a
|
||||
fresh snapshot to the owning client via the DataUpdated remote.
|
||||
|
||||
Also owns: car buying, upgrades, and daily rewards — because those are all
|
||||
economic transactions that must be validated server-side.
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local Cars = require(Shared.Cars)
|
||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
||||
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
type ProfileData = ProfileTemplate.ProfileData
|
||||
type Result = { ok: boolean, reason: string? }
|
||||
|
||||
local Economy = {}
|
||||
|
||||
-- Upgrade tuning: cost scales, effect has diminishing returns -----------------
|
||||
local MAX_UPGRADE_LEVEL = 5
|
||||
local UPGRADE_BASE_COST = 500
|
||||
local UPGRADE_COST_GROWTH = 1.6 -- each level costs 1.6x the previous
|
||||
|
||||
local function upgradeCost(currentLevel: number): number
|
||||
return math.floor(UPGRADE_BASE_COST * (UPGRADE_COST_GROWTH ^ currentLevel))
|
||||
end
|
||||
Economy.upgradeCost = upgradeCost
|
||||
Economy.MAX_UPGRADE_LEVEL = MAX_UPGRADE_LEVEL
|
||||
|
||||
-- Push a snapshot to the client (thin: just the data table) ------------------
|
||||
local dataUpdatedRemote: RemoteEvent? = nil
|
||||
function Economy._setRemote(remote: RemoteEvent)
|
||||
dataUpdatedRemote = remote
|
||||
end
|
||||
|
||||
local function pushUpdate(player: Player, data: ProfileData)
|
||||
if dataUpdatedRemote then
|
||||
dataUpdatedRemote:FireClient(player, data)
|
||||
end
|
||||
end
|
||||
Economy.pushUpdate = pushUpdate
|
||||
|
||||
-- Core mutators --------------------------------------------------------------
|
||||
function Economy.addCash(player: Player, amount: number): boolean
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return false
|
||||
end
|
||||
data.Cash = math.max(0, data.Cash + amount)
|
||||
pushUpdate(player, data)
|
||||
return true
|
||||
end
|
||||
|
||||
function Economy.addRep(player: Player, amount: number): boolean
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return false
|
||||
end
|
||||
data.Rep = math.max(0, data.Rep + amount)
|
||||
pushUpdate(player, data)
|
||||
return true
|
||||
end
|
||||
|
||||
local function ownsCar(data: ProfileData, carId: string): boolean
|
||||
for _, owned in ipairs(data.OwnedCars) do
|
||||
if owned.Id == carId then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
Economy.ownsCar = ownsCar
|
||||
|
||||
-- Buying a car ---------------------------------------------------------------
|
||||
function Economy.buyCar(player: Player, carId: string): Result
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
local car = Cars.Get(carId)
|
||||
if not car then
|
||||
return { ok = false, reason = "Unknown car" }
|
||||
end
|
||||
if ownsCar(data, carId) then
|
||||
return { ok = false, reason = "You already own this car" }
|
||||
end
|
||||
if data.Rep < car.RepRequired then
|
||||
return { ok = false, reason = string.format("Requires %d rep", car.RepRequired) }
|
||||
end
|
||||
if data.Cash < car.Price then
|
||||
return { ok = false, reason = "Not enough cash" }
|
||||
end
|
||||
|
||||
data.Cash -= car.Price
|
||||
table.insert(data.OwnedCars, { Id = carId, Upgrades = { Engine = 0, Tires = 0, Nitro = 0 } })
|
||||
pushUpdate(player, data)
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
-- Upgrading a car part -------------------------------------------------------
|
||||
local VALID_PARTS = { Engine = true, Tires = true, Nitro = true }
|
||||
|
||||
function Economy.upgradeCar(player: Player, carId: string, part: string): Result
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
if not VALID_PARTS[part] then
|
||||
return { ok = false, reason = "Invalid part" }
|
||||
end
|
||||
local owned
|
||||
for _, o in ipairs(data.OwnedCars) do
|
||||
if o.Id == carId then
|
||||
owned = o
|
||||
break
|
||||
end
|
||||
end
|
||||
if not owned then
|
||||
return { ok = false, reason = "You don't own this car" }
|
||||
end
|
||||
local level = owned.Upgrades[part] or 0
|
||||
if level >= MAX_UPGRADE_LEVEL then
|
||||
return { ok = false, reason = "Already maxed" }
|
||||
end
|
||||
local cost = upgradeCost(level)
|
||||
if data.Cash < cost then
|
||||
return { ok = false, reason = "Not enough cash" }
|
||||
end
|
||||
|
||||
data.Cash -= cost
|
||||
owned.Upgrades[part] = level + 1
|
||||
pushUpdate(player, data)
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
-- Daily reward ---------------------------------------------------------------
|
||||
function Economy.claimDaily(player: Player): Result & { cash: number?, rep: number? }
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return { ok = false, reason = "Data not loaded" }
|
||||
end
|
||||
local now = os.time()
|
||||
if now - data.LastDailyClaim < GameConfig.DailyCooldownSeconds then
|
||||
local remaining = GameConfig.DailyCooldownSeconds - (now - data.LastDailyClaim)
|
||||
local hours = math.ceil(remaining / 3600)
|
||||
return { ok = false, reason = string.format("Come back in ~%dh", hours) }
|
||||
end
|
||||
data.LastDailyClaim = now
|
||||
data.Cash += GameConfig.DailyRewardCash
|
||||
data.Rep += GameConfig.DailyRewardRep
|
||||
pushUpdate(player, data)
|
||||
return { ok = true, cash = GameConfig.DailyRewardCash, rep = GameConfig.DailyRewardRep }
|
||||
end
|
||||
|
||||
return Economy
|
||||
@@ -0,0 +1,142 @@
|
||||
--!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.
|
||||
|
||||
Later you replace this with a proper modeled map; the rest of the game
|
||||
(spawns, races) reads named parts so it keeps working.
|
||||
]]
|
||||
|
||||
local Workspace = game:GetService("Workspace")
|
||||
|
||||
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 function part(props: { [string]: any }): Part
|
||||
local p = Instance.new("Part")
|
||||
p.Anchored = true
|
||||
p.TopSurface = Enum.SurfaceType.Smooth
|
||||
p.BottomSurface = Enum.SurfaceType.Smooth
|
||||
for k, v in pairs(props) do
|
||||
(p :: any)[k] = v
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
function MapBuilder.build(): Folder
|
||||
local existing = Workspace:FindFirstChild("Map")
|
||||
if existing then
|
||||
existing:Destroy()
|
||||
end
|
||||
|
||||
local map = Instance.new("Folder")
|
||||
map.Name = "Map"
|
||||
|
||||
local roadFolder = Instance.new("Folder")
|
||||
roadFolder.Name = "Expressway"
|
||||
roadFolder.Parent = map
|
||||
|
||||
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
|
||||
|
||||
-- Spawn pad (where cars appear) at the start ------------------------------
|
||||
local pad = part({
|
||||
Name = "SpawnPad",
|
||||
Size = Vector3.new(ROAD_WIDTH - 6, 1, 40),
|
||||
Position = Vector3.new(0, 1.5, -20),
|
||||
Color = Color3.fromRGB(20, 220, 120),
|
||||
Material = Enum.Material.Neon,
|
||||
Transparency = 0.4,
|
||||
})
|
||||
pad.Parent = map
|
||||
|
||||
-- Finish line at the far end ----------------------------------------------
|
||||
local finish = part({
|
||||
Name = "FinishLine",
|
||||
Size = Vector3.new(ROAD_WIDTH, 1, 6),
|
||||
Position = Vector3.new(0, 1.5, -totalLength + 30),
|
||||
Color = Color3.fromRGB(240, 240, 240),
|
||||
Material = Enum.Material.Neon,
|
||||
Transparency = 0.3,
|
||||
})
|
||||
finish.Parent = map
|
||||
|
||||
-- A floor far below to catch anything that clips through (safety net) ------
|
||||
local floor = part({
|
||||
Name = "SafetyFloor",
|
||||
Size = Vector3.new(ROAD_WIDTH * 4, 2, totalLength + 400),
|
||||
Position = Vector3.new(0, -60, -totalLength / 2),
|
||||
Transparency = 1,
|
||||
CanCollide = true,
|
||||
})
|
||||
floor.Parent = map
|
||||
|
||||
map.Parent = Workspace
|
||||
return map
|
||||
end
|
||||
|
||||
return MapBuilder
|
||||
@@ -0,0 +1,107 @@
|
||||
--!strict
|
||||
--[[
|
||||
RaceManager.luau (SERVER)
|
||||
MVP race: a solo time-trial down the expressway. Player starts a race, drives
|
||||
to the FinishLine, and the SERVER validates the finish (touch of the real
|
||||
finish part) before paying out. This keeps rewards server-authoritative — the
|
||||
client can SAY it won, but it only gets paid if the server saw the touch.
|
||||
|
||||
This is intentionally the simplest thing that proves the race->reward->save
|
||||
loop. Head-to-head, checkpoints, and drag mode come later.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local Workspace = game:GetService("Workspace")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local GameConfig = require(Shared.GameConfig)
|
||||
local Economy = require(script.Parent.Economy)
|
||||
local DataManager = require(script.Parent.DataManager)
|
||||
|
||||
local RaceManager = {}
|
||||
|
||||
type ActiveRace = {
|
||||
startTime: number,
|
||||
connection: RBXScriptConnection,
|
||||
}
|
||||
|
||||
local active: { [Player]: ActiveRace } = {}
|
||||
local RACE_TIMEOUT = 180 -- auto-cancel a race after 3 min
|
||||
|
||||
local function finishFor(player: Player, won: boolean)
|
||||
local race = active[player]
|
||||
if not race then
|
||||
return
|
||||
end
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
|
||||
local data = DataManager.get(player)
|
||||
if not data then
|
||||
return
|
||||
end
|
||||
data.TotalRaces += 1
|
||||
|
||||
local payout = GameConfig.RaceBaseReward
|
||||
if won then
|
||||
payout += GameConfig.RaceWinBonus
|
||||
data.Wins += 1
|
||||
end
|
||||
Economy.addCash(player, payout)
|
||||
Economy.addRep(player, GameConfig.RaceRepReward)
|
||||
-- addCash/addRep already push updates; TotalRaces/Wins ride along on next push.
|
||||
Economy.pushUpdate(player, data)
|
||||
end
|
||||
|
||||
--- Start a race for the player. Returns a Result.
|
||||
function RaceManager.start(player: Player, _raceId: string?): { ok: boolean, reason: string? }
|
||||
if active[player] then
|
||||
return { ok = false, reason = "Already racing" }
|
||||
end
|
||||
local map = Workspace:FindFirstChild("Map")
|
||||
local finish = map and map:FindFirstChild("FinishLine")
|
||||
if not (finish and finish:IsA("BasePart")) then
|
||||
return { ok = false, reason = "No finish line in map" }
|
||||
end
|
||||
|
||||
local conn
|
||||
conn = finish.Touched:Connect(function(hit)
|
||||
-- Was this the racing player's car/character that touched the line?
|
||||
local character = player.Character
|
||||
local carModel = hit:FindFirstAncestorOfClass("Model")
|
||||
local isOwnCar = carModel and carModel:GetAttribute("OwnerUserId") == player.UserId
|
||||
local isSelf = character and hit:IsDescendantOf(character)
|
||||
if isOwnCar or isSelf then
|
||||
finishFor(player, true)
|
||||
end
|
||||
end)
|
||||
|
||||
active[player] = { startTime = os.clock(), connection = conn }
|
||||
|
||||
-- Timeout guard
|
||||
task.delay(RACE_TIMEOUT, function()
|
||||
if active[player] and os.clock() - active[player].startTime >= RACE_TIMEOUT then
|
||||
-- cancel with no reward (won = false but we just clean up)
|
||||
local race = active[player]
|
||||
if race then
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return { ok = true }
|
||||
end
|
||||
|
||||
function RaceManager.init()
|
||||
Players.PlayerRemoving:Connect(function(player)
|
||||
local race = active[player]
|
||||
if race then
|
||||
race.connection:Disconnect()
|
||||
active[player] = nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return RaceManager
|
||||
@@ -0,0 +1,116 @@
|
||||
--!strict
|
||||
--[[
|
||||
init.server.luau (SERVER ENTRY POINT)
|
||||
The one script Roblox runs on the server. It:
|
||||
1. Creates the remotes
|
||||
2. Boots every manager in dependency order
|
||||
3. Builds the map + lighting mood
|
||||
4. Wires all remote handlers to server-authoritative logic
|
||||
|
||||
Everything downstream is server-authoritative: clients REQUEST, server DECIDES.
|
||||
]]
|
||||
|
||||
local Players = game:GetService("Players")
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local Shared = ReplicatedStorage.Shared
|
||||
local Remotes = require(Shared.Remotes)
|
||||
|
||||
local DataManager = require(script.DataManager)
|
||||
local Economy = require(script.Economy)
|
||||
local CarManager = require(script.CarManager)
|
||||
local RaceManager = require(script.RaceManager)
|
||||
local MapBuilder = require(script.MapBuilder)
|
||||
local AntiCheat = require(script.AntiCheat)
|
||||
|
||||
-- 1. Remotes ----------------------------------------------------------------
|
||||
Remotes.init()
|
||||
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
||||
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
||||
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
||||
local rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
|
||||
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
|
||||
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
|
||||
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
|
||||
|
||||
Economy._setRemote(rDataUpdated)
|
||||
|
||||
-- 2. Boot managers ----------------------------------------------------------
|
||||
DataManager.init()
|
||||
CarManager.init()
|
||||
RaceManager.init()
|
||||
AntiCheat.init()
|
||||
|
||||
-- 3. Build the world --------------------------------------------------------
|
||||
MapBuilder.build()
|
||||
|
||||
-- 4. Player lifecycle: push initial data + auto-spawn their car -------------
|
||||
Players.PlayerAdded:Connect(function(player)
|
||||
DataManager.onReady(player, function(data)
|
||||
Economy.pushUpdate(player, data)
|
||||
end)
|
||||
player.CharacterAdded:Connect(function()
|
||||
-- When the character (re)spawns, give them their equipped car after a beat.
|
||||
DataManager.onReady(player, function(data)
|
||||
task.wait(1)
|
||||
CarManager.spawn(player, data.EquippedCarId)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- 5. Remote handlers (all validated server-side) ----------------------------
|
||||
rSpawnCar.OnServerEvent:Connect(function(player, carId)
|
||||
if carId ~= nil and typeof(carId) ~= "string" then
|
||||
return
|
||||
end
|
||||
CarManager.spawn(player, carId)
|
||||
end)
|
||||
|
||||
rEquipCar.OnServerInvoke = function(player, carId)
|
||||
if typeof(carId) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local data = DataManager.get(player)
|
||||
if not data or not Economy.ownsCar(data, carId) then
|
||||
return false
|
||||
end
|
||||
data.EquippedCarId = carId
|
||||
Economy.pushUpdate(player, data)
|
||||
CarManager.spawn(player, carId)
|
||||
return true
|
||||
end
|
||||
|
||||
rBuyCar.OnServerInvoke = function(player, carId)
|
||||
if typeof(carId) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
return Economy.buyCar(player, carId)
|
||||
end
|
||||
|
||||
rUpgradeCar.OnServerInvoke = function(player, carId, part)
|
||||
if typeof(carId) ~= "string" or typeof(part) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
local result = Economy.upgradeCar(player, carId, part)
|
||||
-- Re-spawn so upgraded tuning takes effect immediately if it's the active car.
|
||||
if result.ok then
|
||||
local data = DataManager.get(player)
|
||||
if data and data.EquippedCarId == carId then
|
||||
CarManager.spawn(player, carId)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
rClaimDaily.OnServerInvoke = function(player)
|
||||
return Economy.claimDaily(player)
|
||||
end
|
||||
|
||||
rStartRace.OnServerInvoke = function(player, raceId)
|
||||
if raceId ~= nil and typeof(raceId) ~= "string" then
|
||||
return { ok = false, reason = "Bad request" }
|
||||
end
|
||||
return RaceManager.start(player, raceId)
|
||||
end
|
||||
|
||||
print("[FnF] Server booted: managers online, map built, remotes wired.")
|
||||
@@ -0,0 +1,104 @@
|
||||
--!strict
|
||||
--[[
|
||||
Cars.luau
|
||||
The car catalogue. Table-driven so adding a car = adding a row.
|
||||
MVP ships with the RX-7 starter + a few tier examples so the dealership
|
||||
and progression have something to show. Expand freely later.
|
||||
|
||||
Stat scale: 0-100 (relative feel, not real units). Speed maps to top speed,
|
||||
Acceleration to how fast it gets there, Handling to grip/turn-in.
|
||||
Price is in cash. RepRequired gates purchase behind reputation.
|
||||
]]
|
||||
|
||||
export type CarStats = {
|
||||
Speed: number,
|
||||
Acceleration: number,
|
||||
Handling: number,
|
||||
}
|
||||
|
||||
export type Car = {
|
||||
Id: string,
|
||||
Name: string,
|
||||
Model: string, -- real-world reference
|
||||
Tier: number, -- 1 Junk .. 5 Exotic
|
||||
Price: number,
|
||||
RepRequired: number,
|
||||
Stats: CarStats,
|
||||
Color: { number }, -- {r,g,b} 0-1 for the placeholder body until real mesh exists
|
||||
Description: string,
|
||||
Starter: boolean?, -- true = granted free on first join
|
||||
}
|
||||
|
||||
local Cars = {}
|
||||
|
||||
-- Ordered list (dealership browses this in order) ---------------------------
|
||||
Cars.List = {
|
||||
{
|
||||
Id = "rx7_fc",
|
||||
Name = "Chick3n's RX-7",
|
||||
Model = "1993 Mazda RX-7 (FD)",
|
||||
Tier = 2,
|
||||
Price = 0,
|
||||
RepRequired = 0,
|
||||
Stats = { Speed = 62, Acceleration = 58, Handling = 70 },
|
||||
Color = { 0.85, 0.15, 0.12 }, -- classic RX-7 red until the real mesh is imported
|
||||
Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, and iconic. Your daily driver and first weapon on the expressway.",
|
||||
Starter = true,
|
||||
},
|
||||
{
|
||||
Id = "hatch_junk",
|
||||
Name = "City Runabout",
|
||||
Model = "90s econobox hatchback",
|
||||
Tier = 1,
|
||||
Price = 800,
|
||||
RepRequired = 0,
|
||||
Stats = { Speed = 34, Acceleration = 40, Handling = 48 },
|
||||
Color = { 0.5, 0.5, 0.55 },
|
||||
Description = "Cheap, cheerful, and slow. A backup beater for when you want to grind pocket change.",
|
||||
},
|
||||
{
|
||||
Id = "silv_s15",
|
||||
Name = "Night Slider",
|
||||
Model = "Nissan Silvia S15",
|
||||
Tier = 3,
|
||||
Price = 12000,
|
||||
RepRequired = 50,
|
||||
Stats = { Speed = 74, Acceleration = 70, Handling = 78 },
|
||||
Color = { 0.1, 0.35, 0.85 },
|
||||
Description = "A drift-bred coupe with serious mid-corner poise. The natural step up from the RX-7.",
|
||||
},
|
||||
{
|
||||
Id = "gtr_r34",
|
||||
Name = "Godzilla",
|
||||
Model = "Nissan Skyline GT-R R34",
|
||||
Tier = 4,
|
||||
Price = 45000,
|
||||
RepRequired = 180,
|
||||
Stats = { Speed = 88, Acceleration = 86, Handling = 82 },
|
||||
Color = { 0.15, 0.6, 0.95 },
|
||||
Description = "AWD, twin-turbo, legendary. When you show up in this, people notice.",
|
||||
},
|
||||
{
|
||||
Id = "hyper_x",
|
||||
Name = "Apex Phantom",
|
||||
Model = "Exotic hypercar (stylized)",
|
||||
Tier = 5,
|
||||
Price = 250000,
|
||||
RepRequired = 600,
|
||||
Stats = { Speed = 100, Acceleration = 98, Handling = 90 },
|
||||
Color = { 0.05, 0.05, 0.06 },
|
||||
Description = "Endgame flex. Millions of yen of carbon fibre and neon. The top of the food chain.",
|
||||
},
|
||||
}
|
||||
|
||||
-- Fast lookup by Id (built once) --------------------------------------------
|
||||
Cars.ById = {} :: { [string]: Car }
|
||||
for _, car in ipairs(Cars.List) do
|
||||
Cars.ById[car.Id] = car
|
||||
end
|
||||
|
||||
function Cars.Get(id: string): Car?
|
||||
return Cars.ById[id]
|
||||
end
|
||||
|
||||
return Cars
|
||||
@@ -0,0 +1,37 @@
|
||||
--!strict
|
||||
--[[
|
||||
GameConfig.luau
|
||||
Central tunable constants. Anything you might want to balance later lives here,
|
||||
NOT hard-coded in systems. Single source of truth for numbers.
|
||||
]]
|
||||
|
||||
local GameConfig = {}
|
||||
|
||||
-- Economy -------------------------------------------------------------------
|
||||
GameConfig.StartingCash = 1500 -- enough to feel, not enough to skip the RX-7
|
||||
GameConfig.StartingRep = 0
|
||||
GameConfig.StartingCarId = "rx7_fc" -- the player's real 1993 Mazda RX-7
|
||||
|
||||
-- Race rewards (flat MVP model; tune per-race later) -------------------------
|
||||
GameConfig.RaceBaseReward = 250
|
||||
GameConfig.RaceWinBonus = 400
|
||||
GameConfig.RaceRepReward = 15
|
||||
|
||||
-- Daily login ----------------------------------------------------------------
|
||||
GameConfig.DailyRewardCash = 300
|
||||
GameConfig.DailyRewardRep = 10
|
||||
GameConfig.DailyCooldownSeconds = 20 * 60 * 60 -- 20h so a "daily" is reliably claimable each day
|
||||
|
||||
-- Anti-cheat sanity bounds (server-side validation) --------------------------
|
||||
-- Max believable straight-line speed in studs/sec for the FASTEST car + boosts.
|
||||
-- Anything sustained above this => flagged. Generous to avoid false positives in MVP.
|
||||
GameConfig.MaxPlausibleSpeed = 400
|
||||
|
||||
-- DataStore ------------------------------------------------------------------
|
||||
GameConfig.ProfileStoreName = "PlayerData_v1" -- bump the _vN to wipe/migrate in dev
|
||||
GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio (no live DataStore)
|
||||
|
||||
-- Autosave interval handled by ProfileStore internally; kept here for reference.
|
||||
GameConfig.SessionTag = "fnf-sim-mvp"
|
||||
|
||||
return GameConfig
|
||||
@@ -0,0 +1,51 @@
|
||||
--!strict
|
||||
--[[
|
||||
ProfileTemplate.luau
|
||||
The canonical shape of a player's saved data. ProfileStore fills any
|
||||
missing keys from this template automatically (safe forward-migration:
|
||||
add a key here and existing players get the default on next load).
|
||||
|
||||
NEVER remove/rename a key without a migration plan — that's how saves break.
|
||||
]]
|
||||
|
||||
local GameConfig = require(script.Parent.GameConfig)
|
||||
|
||||
export type UpgradeLevels = {
|
||||
Engine: number,
|
||||
Tires: number,
|
||||
Nitro: number,
|
||||
}
|
||||
|
||||
export type OwnedCar = {
|
||||
Id: string,
|
||||
Upgrades: UpgradeLevels,
|
||||
}
|
||||
|
||||
export type ProfileData = {
|
||||
Cash: number,
|
||||
Rep: number,
|
||||
OwnedCars: { OwnedCar },
|
||||
EquippedCarId: string,
|
||||
LastDailyClaim: number, -- os.time() of last claim; 0 = never
|
||||
TotalRaces: number,
|
||||
Wins: number,
|
||||
Version: number, -- schema version for future migrations
|
||||
}
|
||||
|
||||
local ProfileTemplate: ProfileData = {
|
||||
Cash = GameConfig.StartingCash,
|
||||
Rep = GameConfig.StartingRep,
|
||||
OwnedCars = {
|
||||
{
|
||||
Id = GameConfig.StartingCarId,
|
||||
Upgrades = { Engine = 0, Tires = 0, Nitro = 0 },
|
||||
},
|
||||
},
|
||||
EquippedCarId = GameConfig.StartingCarId,
|
||||
LastDailyClaim = 0,
|
||||
TotalRaces = 0,
|
||||
Wins = 0,
|
||||
Version = 1,
|
||||
}
|
||||
|
||||
return ProfileTemplate
|
||||
@@ -0,0 +1,63 @@
|
||||
--!strict
|
||||
--[[
|
||||
Remotes.luau
|
||||
Single source of truth for every RemoteEvent / RemoteFunction.
|
||||
The SERVER calls Remotes.init() once at boot to create the instances under
|
||||
ReplicatedStorage/Remotes. The CLIENT just requires and reads them (they're
|
||||
guaranteed to exist because the server script runs first / it waits).
|
||||
|
||||
Add a remote = add one line to the DEFS table. No stringly-typed
|
||||
FindFirstChild scattered across the codebase.
|
||||
]]
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
local Remotes = {}
|
||||
|
||||
-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ----------
|
||||
local DEFS: { [string]: string } = {
|
||||
-- Server -> Client: pushes fresh profile snapshot for HUD/garage
|
||||
DataUpdated = "Event",
|
||||
|
||||
-- Client -> Server requests
|
||||
SpawnCar = "Event", -- (carId?) spawn/replace the player's equipped car
|
||||
EquipCar = "Function", -- (carId) -> boolean success
|
||||
BuyCar = "Function", -- (carId) -> {ok:boolean, reason:string?}
|
||||
UpgradeCar = "Function", -- (carId, part) -> {ok:boolean, reason:string?}
|
||||
ClaimDaily = "Function", -- () -> {ok:boolean, cash:number?, rep:number?, reason:string?}
|
||||
StartRace = "Function", -- (raceId) -> {ok:boolean, reason:string?}
|
||||
FinishRace = "Event", -- (raceId, won:boolean) client reports; server validates
|
||||
}
|
||||
|
||||
local FOLDER_NAME = "Remotes"
|
||||
|
||||
-- Server-only: build the folder + instances.
|
||||
function Remotes.init(): Folder
|
||||
assert(RunService:IsServer(), "Remotes.init() must run on the server")
|
||||
local existing = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
|
||||
if existing then
|
||||
return existing :: Folder
|
||||
end
|
||||
local folder = Instance.new("Folder")
|
||||
folder.Name = FOLDER_NAME
|
||||
for name, kind in pairs(DEFS) do
|
||||
local inst = Instance.new(kind == "Event" and "RemoteEvent" or "RemoteFunction")
|
||||
inst.Name = name
|
||||
inst.Parent = folder
|
||||
end
|
||||
folder.Parent = ReplicatedStorage
|
||||
return folder
|
||||
end
|
||||
|
||||
-- Both realms: fetch a remote by name (waits, so client-safe).
|
||||
function Remotes.get(name: string): Instance
|
||||
assert(DEFS[name], "Unknown remote: " .. tostring(name))
|
||||
local folder = ReplicatedStorage:WaitForChild(FOLDER_NAME, 30)
|
||||
assert(folder, "Remotes folder never appeared")
|
||||
local inst = folder:WaitForChild(name, 30)
|
||||
assert(inst, "Remote never appeared: " .. name)
|
||||
return inst
|
||||
end
|
||||
|
||||
return Remotes
|
||||
Reference in New Issue
Block a user