diff --git a/README.md b/README.md index 75ea86b..fa64bba 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,35 @@ -# Tokyo Wangan Racing — MVP (drive-only) +# Tokyo Wangan Racing A Roblox game where you cruise a neon Tokyo expressway at night in cool cars — -starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just -driving.** No racing, money, or upgrades yet — the goal is to nail the driving -feel and be testable fast. See `docs/ROADMAP.md` for what comes next. +starting with a real 1993 Mazda RX-7. The driving feel comes first; everything +else layers on top of it. See `docs/ROADMAP.md` for what comes next. -**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a -self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio. +**Status:** Playable. Spawn → drive → switch cars → respawn, plus the full +three-mode Wangan loop. CI green on a self-hosted Gitea runner. Built code-first +— nothing to hand-build in Studio. + +## The Daikoku loop (how you play) + +Daikoku Futo PA is the hub, exactly like Tokyo Xtreme Racer. You are never +"joining a match" — you park, pick how you want to run, and drive out. + +``` + IN THE PA ──► pick a mode ──► cross the exit gate ──► RUNNING + ▲ │ + └──────────────── pull back into the PA ─────────────────┘ +``` + +| Mode | 日本語 | What it is | +|---|---|---| +| **CRUISE** | 流し | Free roam. Bank kilometres, meet other drivers. Rivals can pull alongside and challenge you. | +| **DUEL** | バトル | Hunt rivals. First to open a ~350-stud gap and *hold* it for 3s wins. No laps, no checkpoints — who breaks first. | +| **TIME ATTACK** | タイムアタック | Solo flying lap with 4 sector splits and a saved personal best. Rivals can't interrupt you. | + +Cruise and Duel both allow challenges, so a cruise can turn into a battle +organically — pull alongside someone and the prompt appears for both of you. +Time Attack deliberately opts out so a hot lap is never ruined. + +Kilometres, lap times and duel records all persist via ProfileStore. --- @@ -79,4 +102,4 @@ the `EquipCar` remote). One function changes: `CarManager.buildCarModel`. Full step-by-step in `docs/MESH_SWAP.md`. Reference photos live on the NAS at -`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`. +`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/`. diff --git a/docs/MESH_SWAP.md b/docs/MESH_SWAP.md index 28889ad..64c688e 100644 --- a/docs/MESH_SWAP.md +++ b/docs/MESH_SWAP.md @@ -13,7 +13,7 @@ anti-cheat) needs to change. The real car's reference photos live on the NAS (too large to commit to git): ``` -unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/ +unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/ ``` A single downscaled reference is included at `assets/reference/rx7-reference.png` diff --git a/docs/ROTARY_SOUND.md b/docs/ROTARY_SOUND.md index 24138f9..0f0edb6 100644 --- a/docs/ROTARY_SOUND.md +++ b/docs/ROTARY_SOUND.md @@ -52,7 +52,7 @@ videos of the car are fine — audio gets extracted. Best results: Drop the files on the NAS next to the reference photos: ``` -unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/sound/ +unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/sound/ ``` ## Step 2 — Process into seamless loops (this workstation) diff --git a/src/client/ModeUI.luau b/src/client/ModeUI.luau new file mode 100644 index 0000000..52acd36 --- /dev/null +++ b/src/client/ModeUI.luau @@ -0,0 +1,479 @@ +--!strict +--[[ + ModeUI.luau (CLIENT) + + Everything the three-mode loop shows the player: + + * PA panel — the mode picker. Only visible while parked at Daikoku. + Pick CRUISE / DUEL / TIME ATTACK, then drive out the gate. + * Run HUD — mode-specific live readout while out on the Wangan: + CRUISE -> session + lifetime kilometres + TIME ATTACK -> lap clock, sector splits, best lap + DUEL -> opponent, gap bar, who's breaking away + * Duel prompt — a rival pulled alongside: RUN IT / NOT NOW. + * Result card — duel won/lost, lap set. + + Purely presentational. Every number here came from the server; this module + never decides anything. +]] + +local TweenService = game:GetService("TweenService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Modes = require(Shared.Modes) +local Remotes = require(Shared.Remotes) + +local ModeUI = {} + +local NEON_PINK = Color3.fromRGB(255, 40, 120) +local NEON_BLUE = Color3.fromRGB(40, 160, 255) +local NEON_GREEN = Color3.fromRGB(120, 255, 170) +local PANEL_BG = Color3.fromRGB(16, 16, 24) +local DIM = Color3.fromRGB(150, 150, 165) + +local rSelectMode = Remotes.get("SelectMode") :: RemoteFunction +local rRespondDuel = Remotes.get("RespondDuel") :: RemoteEvent +local rAbandonRun = Remotes.get("AbandonRun") :: RemoteEvent +local rModeUpdated = Remotes.get("ModeUpdated") :: RemoteEvent +local rDuelOffer = Remotes.get("DuelOffer") :: RemoteEvent +local rDuelResult = Remotes.get("DuelResult") :: RemoteEvent + +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 + +-- Format seconds as M:SS.mmm — the racing-timer convention. +local function fmtTime(t: number?): string + if not t or t <= 0 then + return "--:--.---" + end + local m = math.floor(t / 60) + local s = t - m * 60 + return string.format("%d:%06.3f", m, s) +end + +local screen: ScreenGui +local paPanel: Frame +local runHud: Frame +local hudTitle: TextLabel +local hudMain: TextLabel +local hudSub: TextLabel +local gapBar: Frame +local gapFill: Frame +local gapLabel: TextLabel +local duelPrompt: Frame +local duelPromptLabel: TextLabel +local resultCard: Frame +local resultTitle: TextLabel +local resultSub: TextLabel + +local lastState: string? = nil + +-- Build ----------------------------------------------------------------------- + +function ModeUI.build(parentGui: ScreenGui) + screen = parentGui + + -- PA panel: the mode picker ---------------------------------------------- + paPanel = make("Frame", { + Name = "PAPanel", + AnchorPoint = Vector2.new(0.5, 1), + Position = UDim2.new(0.5, 0, 1, -90), + Size = UDim2.new(0, 720, 0, 210), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.08, + BorderSizePixel = 0, + Visible = false, + }, screen) + corner(paPanel, 12) + make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.4 }, paPanel) + + make("TextLabel", { + Text = "大黒PA — CHOOSE YOUR RUN", + Font = Enum.Font.GothamBold, + TextSize = 15, + TextColor3 = Color3.fromRGB(235, 235, 245), + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 10), + Size = UDim2.new(1, 0, 0, 20), + }, paPanel) + + make("TextLabel", { + Name = "Hint", + Text = "Pick a mode, then drive out the yellow gate onto the Wangan.", + Font = Enum.Font.Gotham, + TextSize = 12, + TextColor3 = DIM, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 1, -26), + Size = UDim2.new(1, 0, 0, 18), + }, paPanel) + + local row = make("Frame", { + BackgroundTransparency = 1, + Position = UDim2.new(0, 16, 0, 40), + Size = UDim2.new(1, -32, 0, 130), + }, paPanel) + make("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 12), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + }, row) + + for i, mode in ipairs(Modes.List) do + local card = make("TextButton", { + Name = mode.Id, + Text = "", + AutoButtonColor = false, + BackgroundColor3 = Color3.fromRGB(24, 24, 34), + Size = UDim2.new(0, 224, 1, 0), + LayoutOrder = i, + BorderSizePixel = 0, + }, row) + corner(card, 10) + local stroke = make("UIStroke", { Color = mode.Color, Thickness = 1.5, Transparency = 0.55 }, card) + + make("TextLabel", { + Text = mode.NameJP, + Font = Enum.Font.GothamBold, + TextSize = 13, + TextColor3 = mode.Color, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 10), + Size = UDim2.new(1, 0, 0, 16), + }, card) + make("TextLabel", { + Text = mode.Name, + Font = Enum.Font.GothamBold, + TextSize = 19, + TextColor3 = Color3.fromRGB(240, 240, 250), + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 28), + Size = UDim2.new(1, 0, 0, 22), + }, card) + make("TextLabel", { + Text = mode.Blurb, + Font = Enum.Font.Gotham, + TextSize = 11, + TextColor3 = DIM, + TextWrapped = true, + TextYAlignment = Enum.TextYAlignment.Top, + BackgroundTransparency = 1, + Position = UDim2.new(0, 12, 0, 56), + Size = UDim2.new(1, -24, 1, -66), + }, card) + + card.MouseEnter:Connect(function() + TweenService:Create(stroke, TweenInfo.new(0.15), { Transparency = 0 }):Play() + end) + card.MouseLeave:Connect(function() + TweenService:Create(stroke, TweenInfo.new(0.15), { Transparency = 0.55 }):Play() + end) + card.MouseButton1Click:Connect(function() + local ok = rSelectMode:InvokeServer(mode.Id) + if ok then + ModeUI.showArmed(mode) + end + end) + end + + -- Run HUD ---------------------------------------------------------------- + runHud = make("Frame", { + Name = "RunHUD", + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.new(0.5, 0, 0, 16), + Size = UDim2.new(0, 340, 0, 104), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.15, + BorderSizePixel = 0, + Visible = false, + }, screen) + corner(runHud, 10) + make("UIStroke", { Color = NEON_PINK, Thickness = 1.2, Transparency = 0.5 }, runHud) + + hudTitle = make("TextLabel", { + Text = "", + Font = Enum.Font.GothamBold, + TextSize = 12, + TextColor3 = NEON_PINK, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 8), + Size = UDim2.new(1, 0, 0, 14), + }, runHud) + + hudMain = make("TextLabel", { + Text = "", + Font = Enum.Font.GothamBold, + TextSize = 30, + TextColor3 = Color3.fromRGB(245, 245, 255), + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 24), + Size = UDim2.new(1, 0, 0, 36), + }, runHud) + + hudSub = make("TextLabel", { + Text = "", + Font = Enum.Font.Gotham, + TextSize = 12, + TextColor3 = DIM, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 60), + Size = UDim2.new(1, 0, 0, 16), + }, runHud) + + -- Duel gap bar (hidden unless duelling) + gapBar = make("Frame", { + Name = "GapBar", + BackgroundColor3 = Color3.fromRGB(35, 35, 48), + BorderSizePixel = 0, + Position = UDim2.new(0, 20, 0, 80), + Size = UDim2.new(1, -40, 0, 8), + Visible = false, + }, runHud) + corner(gapBar, 4) + gapFill = make("Frame", { + BackgroundColor3 = NEON_PINK, + BorderSizePixel = 0, + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.new(0.5, 0, 0, 0), + Size = UDim2.new(0, 4, 1, 0), + }, gapBar) + corner(gapFill, 4) + gapLabel = make("TextLabel", { + Text = "", + Font = Enum.Font.GothamBold, + TextSize = 11, + TextColor3 = DIM, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 88), + Size = UDim2.new(1, 0, 0, 14), + Visible = false, + }, runHud) + + -- Duel prompt ------------------------------------------------------------ + duelPrompt = make("Frame", { + Name = "DuelPrompt", + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.new(0.5, 0, 0.62, 0), + Size = UDim2.new(0, 380, 0, 120), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.05, + BorderSizePixel = 0, + Visible = false, + }, screen) + corner(duelPrompt, 12) + make("UIStroke", { Color = NEON_PINK, Thickness = 2, Transparency = 0.1 }, duelPrompt) + + duelPromptLabel = make("TextLabel", { + Text = "", + Font = Enum.Font.GothamBold, + TextSize = 16, + TextColor3 = Color3.fromRGB(245, 245, 255), + TextWrapped = true, + BackgroundTransparency = 1, + Position = UDim2.new(0, 16, 0, 14), + Size = UDim2.new(1, -32, 0, 46), + }, duelPrompt) + + local btnRow = make("Frame", { + BackgroundTransparency = 1, + Position = UDim2.new(0, 16, 1, -50), + Size = UDim2.new(1, -32, 0, 36), + }, duelPrompt) + make("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + Padding = UDim.new(0, 10), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + }, btnRow) + + local function promptBtn(text: string, color: Color3, accept: boolean) + local b = make("TextButton", { + Text = text, + Font = Enum.Font.GothamBold, + TextSize = 14, + TextColor3 = Color3.fromRGB(10, 10, 14), + BackgroundColor3 = color, + Size = UDim2.new(0, 160, 1, 0), + BorderSizePixel = 0, + }, btnRow) + corner(b, 8) + b.MouseButton1Click:Connect(function() + rRespondDuel:FireServer(accept) + duelPrompt.Visible = false + end) + end + promptBtn("RUN IT", NEON_PINK, true) + promptBtn("NOT NOW", Color3.fromRGB(90, 90, 110), false) + + -- Result card ------------------------------------------------------------ + resultCard = make("Frame", { + Name = "ResultCard", + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.new(0.5, 0, 0.42, 0), + Size = UDim2.new(0, 420, 0, 110), + BackgroundColor3 = PANEL_BG, + BackgroundTransparency = 0.05, + BorderSizePixel = 0, + Visible = false, + }, screen) + corner(resultCard, 12) + make("UIStroke", { Color = NEON_GREEN, Thickness = 2, Transparency = 0.15 }, resultCard) + resultTitle = make("TextLabel", { + Text = "", + Font = Enum.Font.GothamBold, + TextSize = 26, + TextColor3 = NEON_GREEN, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 20), + Size = UDim2.new(1, 0, 0, 32), + }, resultCard) + resultSub = make("TextLabel", { + Text = "", + Font = Enum.Font.Gotham, + TextSize = 14, + TextColor3 = DIM, + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 58), + Size = UDim2.new(1, 0, 0, 20), + }, resultCard) + + ModeUI.connect() +end + +-- Behaviour ------------------------------------------------------------------- + +function ModeUI.showArmed(mode: Modes.ModeDef) + local hint = paPanel:FindFirstChild("Hint") :: TextLabel? + if hint then + hint.Text = string.format("%s armed — drive out the yellow gate to start.", mode.Name) + hint.TextColor3 = mode.Color + end +end + +local function showResult(title: string, sub: string, color: Color3) + resultTitle.Text = title + resultTitle.TextColor3 = color + resultSub.Text = sub + resultCard.Visible = true + task.delay(5, function() + resultCard.Visible = false + end) +end + +function ModeUI.connect() + rModeUpdated.OnClientEvent:Connect(function(state: any) + if typeof(state) ~= "table" then + return + end + + local inPA = state.state == "IN_PA" or state.state == "ARMED" + paPanel.Visible = inPA + runHud.Visible = state.state == "RUNNING" + + -- Reset the picker hint when we newly arrive back in the PA. + if state.state == "IN_PA" and lastState ~= "IN_PA" then + local hint = paPanel:FindFirstChild("Hint") :: TextLabel? + if hint then + hint.Text = "Pick a mode, then drive out the yellow gate onto the Wangan." + hint.TextColor3 = DIM + end + end + lastState = state.state + + if state.state ~= "RUNNING" then + return + end + + local mode = state.mode and Modes.Get(state.mode) + if not mode then + return + end + hudTitle.Text = mode.NameJP .. " " .. mode.Name + hudTitle.TextColor3 = mode.Color + + if state.duel then + -- DUEL readout takes over the HUD. + local gap = state.duel.gap or 0 + hudMain.Text = string.format("%+.0f", gap) + hudMain.TextColor3 = gap >= 0 and NEON_GREEN or NEON_PINK + hudSub.Text = + string.format("vs %s %s", state.duel.opponent, gap >= 0 and "you're ahead" or "you're behind") + gapBar.Visible = true + gapLabel.Visible = true + local frac = math.clamp(gap / (state.duel.winGap or 350), -1, 1) + gapFill.Position = UDim2.new(0.5 + frac * 0.5, 0, 0, 0) + gapFill.BackgroundColor3 = gap >= 0 and NEON_GREEN or NEON_PINK + gapLabel.Text = string.format("hold %d studs to win", state.duel.winGap or 350) + elseif mode.Timed then + gapBar.Visible = false + gapLabel.Visible = false + hudMain.Text = fmtTime(state.lapTime) + hudMain.TextColor3 = Color3.fromRGB(245, 245, 255) + local best = state.bestLapTime or 0 + local parts = { string.format("SECTOR %d/4", state.sector or 1) } + if best > 0 then + table.insert(parts, "BEST " .. fmtTime(best)) + end + if state.lastLapTime then + table.insert(parts, "LAST " .. fmtTime(state.lastLapTime)) + end + hudSub.Text = table.concat(parts, " ") + else + gapBar.Visible = false + gapLabel.Visible = false + hudMain.Text = string.format("%.2f km", state.sessionKm or 0) + hudMain.TextColor3 = Color3.fromRGB(245, 245, 255) + hudSub.Text = string.format("this run · %.1f km lifetime", state.totalKm or 0) + end + end) + + rDuelOffer.OnClientEvent:Connect(function(info: any) + if typeof(info) ~= "table" then + return + end + duelPromptLabel.Text = string.format("%s pulled alongside.\nRun it?", tostring(info.opponent)) + duelPrompt.Visible = true + task.delay(10, function() + if duelPrompt.Visible then + duelPrompt.Visible = false + rRespondDuel:FireServer(false) + end + end) + end) + + rDuelResult.OnClientEvent:Connect(function(info: any) + if typeof(info) ~= "table" then + return + end + duelPrompt.Visible = false + if info.expired then + showResult("NO CONTEST", tostring(info.reason or ""), Color3.fromRGB(200, 200, 210)) + elseif info.won then + showResult( + "WIN", + string.format("beat %s — %s", tostring(info.opponent), tostring(info.reason)), + NEON_GREEN + ) + else + showResult("LOSS", string.format("%s got away", tostring(info.opponent)), NEON_PINK) + end + end) +end + +function ModeUI.abandon() + rAbandonRun:FireServer() +end + +return ModeUI diff --git a/src/client/init.client.luau b/src/client/init.client.luau index 1e1a198..c59943e 100644 --- a/src/client/init.client.luau +++ b/src/client/init.client.luau @@ -18,6 +18,7 @@ local Cars = require(Shared.Cars) local ChassisController = require(script.ChassisController) local CameraController = require(script.CameraController) local SoundController = require(script.SoundController) +local ModeUI = require(script.ModeUI) ChassisController.init() CameraController.init() @@ -272,7 +273,7 @@ local function rebuildPicker() end -- Data sync ----------------------------------------------------------------- -rDataUpdated.OnClientEvent:Connect(function(carId: string) +rDataUpdated.OnClientEvent:Connect(function(carId: string, _stats: any) currentCarId = carId local car = Cars.Get(carId) carLabel.Text = "Car: " .. (car and car.Name or carId) @@ -296,4 +297,13 @@ respawnBtn.MouseButton1Click:Connect(function() showToast("Respawning your car...") end) -print("[Wangan] Client UI ready (drive-only MVP).") +-- Mode system UI (PA picker, run HUD, duel prompts) ------------------------- +ModeUI.build(screen) + +local giveUpBtn = button("GIVE UP", Color3.fromRGB(255, 140, 60), 3) +giveUpBtn.MouseButton1Click:Connect(function() + ModeUI.abandon() + showToast("Run abandoned — head back to Daikoku PA.") +end) + +print("[Wangan] Client UI ready (3 modes: cruise / duel / time attack).") diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index 9228fdd..2d2349b 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -326,4 +326,27 @@ function CarManager.init() end) end +-- The player's live car model, or nil if they have none right now. +function CarManager.getCar(player: Player): Model? + local model = activeCars[player] + if model and model.Parent then + return model + end + return nil +end + +-- The physics root of the player's car (the chassis Body). ModeManager samples +-- this to measure real, server-side position — never client-reported. +function CarManager.getRoot(player: Player): BasePart? + local model = CarManager.getCar(player) + if not model then + return nil + end + local body = model:FindFirstChild("Body") + if body and body:IsA("BasePart") then + return body + end + return model.PrimaryPart +end + return CarManager diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index a480307..b23558f 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -360,6 +360,23 @@ local function buildDaikoku(map: Folder) spawn.Parent = folder makeSign("大黒PA DAIKOKU PA", CFrame.new(0, 12, z0 + 4) * CFrame.Angles(0, math.pi, 0), folder) + + -- Mode gate --------------------------------------------------------------- + -- The exit slip road out of the PA. Crossing this line is what ARMS your + -- chosen mode into a live run (ModeManager watches position, not this part, + -- but the marker tells the player exactly where the commitment happens). + local gate = part({ + Name = "ExitGate", + Size = Vector3.new(220, 1, 4), + CFrame = CFrame.new(0, 1.4, z0 + 6), + Color = Color3.fromRGB(255, 200, 60), + Material = Enum.Material.Neon, + Transparency = 0.35, + CanCollide = false, + }) + gate.Parent = folder + + makeSign("出口 EXIT -> WANGAN", CFrame.new(0, 20, z0 + 30) * CFrame.Angles(0, math.pi, 0), folder) end -- Rainbow Bridge towers over the east straight ----------------------------------- diff --git a/src/server/ModeManager.luau b/src/server/ModeManager.luau new file mode 100644 index 0000000..8f133f6 --- /dev/null +++ b/src/server/ModeManager.luau @@ -0,0 +1,475 @@ +--!strict +--[[ + ModeManager.luau (SERVER) + + The heart of the mode system. Tokyo Extreme Racer structure: + + IN THE PA -> select a mode -> drive out the gate -> RUNNING + ^ | + +---------------- pull back into the PA ----------------+ + + States per player: + IN_PA — parked at Daikoku. Mode picker is open. Nothing is timed. + ARMED — mode chosen, still in the PA. Crossing the exit gate starts it. + RUNNING — out on the Wangan. Mode rules are live. + + Every mode shares ONE progress primitive (Circuit.progressOf) so distance, + lap timing and duel gaps are all the same measurement. Server-authoritative: + the client never reports its own position, distance, or lap time — the + server samples the car's real position on a Heartbeat. +]] + +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Shared = ReplicatedStorage.Shared +local Circuit = require(Shared.Circuit) +local Modes = require(Shared.Modes) +local Remotes = require(Shared.Remotes) + +local DataManager = require(script.Parent.DataManager) +local CarManager = require(script.Parent.CarManager) + +local ModeManager = {} + +export type State = "IN_PA" | "ARMED" | "RUNNING" + +type Session = { + player: Player, + state: State, + mode: string?, -- selected ModeId + hint: number, -- last nearest-sample index (search accelerator) + progress: number, -- studs around the lap + lastProgress: number, + -- distance + sessionStuds: number, + -- time attack + lapStart: number?, + lapStartProgress: number?, + sectorTimes: { number }, + sectorIndex: number, + lastLapTime: number?, + -- duel + duelId: number?, + -- housekeeping + lastPos: Vector3?, + offerCooldown: number, +} + +local sessions: { [Player]: Session } = {} + +-- Duels ---------------------------------------------------------------------- +type Duel = { + id: number, + a: Player, + b: Player, + startedAt: number, + leadSince: number?, -- when the current leader first hit WIN_GAP + leader: Player?, +} +local duels: { [number]: Duel } = {} +local nextDuelId = 1 + +local rModeUpdated: RemoteEvent +local rDuelOffer: RemoteEvent +local rDuelResult: RemoteEvent + +-- Helpers -------------------------------------------------------------------- + +local function carRoot(player: Player): BasePart? + return CarManager.getRoot(player) +end + +local function sectorOf(progress: number): number + local n = Modes.TIME_ATTACK.SECTORS + local idx = math.floor((progress / Circuit.LAP_LENGTH) * n) + 1 + return math.clamp(idx, 1, n) +end + +-- Push the full HUD snapshot for one player. +local function push(session: Session) + local player = session.player + local data = DataManager.get(player) + local duel = session.duelId and duels[session.duelId] or nil + + local duelInfo = nil + if duel then + local other = duel.a == player and duel.b or duel.a + local otherSession = sessions[other] + local gap = 0 + if otherSession then + gap = Circuit.gap(session.progress, otherSession.progress) + end + duelInfo = { + opponent = other.DisplayName, + gap = gap, + elapsed = os.clock() - duel.startedAt, + winGap = Modes.DUEL.WIN_GAP, + } + end + + rModeUpdated:FireClient(player, { + state = session.state, + mode = session.mode, + inPA = session.state == "IN_PA", + lapTime = session.lapStart and (os.clock() - session.lapStart) or nil, + lastLapTime = session.lastLapTime, + bestLapTime = data and data.BestLapTime or 0, + sector = session.sectorIndex, + sectorTimes = session.sectorTimes, + sessionKm = session.sessionStuds * Circuit.STUDS_TO_KM, + totalKm = data and (data.DistanceStuds * Circuit.STUDS_TO_KM) or 0, + duel = duelInfo, + }) +end + +local function resetRun(session: Session) + session.lapStart = nil + session.lapStartProgress = nil + session.sectorTimes = { 0, 0, 0, 0 } + session.sectorIndex = 1 +end + +local function endDuel(duel: Duel, winner: Player?, reason: string) + duels[duel.id] = nil + for _, p in ipairs({ duel.a, duel.b }) do + local s = sessions[p] + if s then + s.duelId = nil + end + local data = DataManager.get(p) + local won = winner == p + if data and winner then + if won then + data.DuelsWon += 1 + else + data.DuelsLost += 1 + end + end + rDuelResult:FireClient(p, { + won = won, + expired = winner == nil, + reason = reason, + opponent = (duel.a == p and duel.b or duel.a).DisplayName, + }) + if s then + push(s) + end + end +end + +-- Public API ----------------------------------------------------------------- + +function ModeManager.onPlayerAdded(player: Player) + sessions[player] = { + player = player, + state = "IN_PA", + mode = nil, + hint = 1, + progress = 0, + lastProgress = 0, + sessionStuds = 0, + sectorTimes = { 0, 0, 0, 0 }, + sectorIndex = 1, + offerCooldown = 0, + } +end + +function ModeManager.onPlayerRemoving(player: Player) + local session = sessions[player] + if session and session.duelId then + local duel = duels[session.duelId] + if duel then + local other = duel.a == player and duel.b or duel.a + endDuel(duel, other, "opponent left") + end + end + sessions[player] = nil +end + +-- Client asks to arm a mode. Only legal while parked in the PA. +function ModeManager.selectMode(player: Player, modeId: any): boolean + if not Modes.IsValid(modeId) then + return false + end + local session = sessions[player] + if not session then + return false + end + if session.state == "RUNNING" then + return false -- must return to the PA first + end + session.mode = modeId + session.state = "ARMED" + resetRun(session) + push(session) + return true +end + +function ModeManager.abandon(player: Player) + local session = sessions[player] + if not session then + return + end + if session.duelId then + local duel = duels[session.duelId] + if duel then + local other = duel.a == player and duel.b or duel.a + endDuel(duel, other, "opponent backed out") + end + end + session.state = "IN_PA" + resetRun(session) + push(session) +end + +function ModeManager.respondDuel(player: Player, accept: any) + local session = sessions[player] + if not session or typeof(accept) ~= "boolean" then + return + end + local pendingId = session.duelId + if not pendingId then + return + end + local duel = duels[pendingId] + if not duel then + session.duelId = nil + return + end + if not accept then + endDuel(duel, nil, "declined") + return + end + -- Accepting confirms it; the challenger already had it armed. + duel.startedAt = os.clock() + for _, p in ipairs({ duel.a, duel.b }) do + local s = sessions[p] + if s then + push(s) + end + end +end + +-- Try to pair two nearby players into a duel. +local function tryOfferDuel(session: Session) + if session.duelId or session.state ~= "RUNNING" then + return + end + local mode = session.mode and Modes.Get(session.mode) + if not mode or not mode.AllowChallenges then + return + end + if os.clock() < session.offerCooldown then + return + end + + local myRoot = carRoot(session.player) + if not myRoot then + return + end + + for other, otherSession in pairs(sessions) do + if other ~= session.player and not otherSession.duelId and otherSession.state == "RUNNING" then + local otherMode = otherSession.mode and Modes.Get(otherSession.mode) + if otherMode and otherMode.AllowChallenges then + local otherRoot = carRoot(other) + if otherRoot and (otherRoot.Position - myRoot.Position).Magnitude <= Modes.DUEL.CHALLENGE_RANGE then + local duel: Duel = { + id = nextDuelId, + a = session.player, + b = other, + startedAt = os.clock(), + } + duels[nextDuelId] = duel + nextDuelId += 1 + session.duelId = duel.id + otherSession.duelId = duel.id + session.offerCooldown = os.clock() + 30 + otherSession.offerCooldown = os.clock() + 30 + rDuelOffer:FireClient(session.player, { opponent = other.DisplayName }) + rDuelOffer:FireClient(other, { opponent = session.player.DisplayName }) + return + end + end + end + end +end + +-- Per-player tick ------------------------------------------------------------ +local function stepPlayer(session: Session, dt: number) + local root = carRoot(session.player) + if not root then + return + end + local pos = root.Position + + local progress, hint = Circuit.progressOf(pos, session.hint) + session.hint = hint + session.lastProgress = session.progress + session.progress = progress + + local inPA = Circuit.inPA(pos) + + -- Distance travelled (real movement, server-measured) --------------------- + if session.lastPos then + local moved = (pos - session.lastPos).Magnitude + -- Ignore teleports/respawns: a car can't legitimately move 500 studs/frame. + if moved < 500 * dt + 5 then + session.sessionStuds += moved + local data = DataManager.get(session.player) + if data then + data.DistanceStuds += moved + end + end + end + session.lastPos = pos + + -- State transitions ------------------------------------------------------- + if session.state == "ARMED" and not inPA then + -- Crossed the gate onto the Wangan: the run is live. + session.state = "RUNNING" + local mode = session.mode and Modes.Get(session.mode) + if mode and mode.Timed then + session.lapStart = os.clock() + session.lapStartProgress = progress + session.sectorIndex = sectorOf(progress) + session.sectorTimes = { 0, 0, 0, 0 } + end + push(session) + elseif session.state == "RUNNING" and inPA then + -- Pulled back into the PA: run over, back to the hub. + if session.duelId then + local duel = duels[session.duelId] + if duel then + local other = duel.a == session.player and duel.b or duel.a + endDuel(duel, other, "returned to PA") + end + end + session.state = "IN_PA" + resetRun(session) + push(session) + return + end + + if session.state ~= "RUNNING" then + return + end + + local mode = session.mode and Modes.Get(session.mode) + if not mode then + return + end + + -- TIME ATTACK: sector splits + lap completion ---------------------------- + if mode.Timed and session.lapStart then + local sec = sectorOf(progress) + if sec ~= session.sectorIndex then + -- crossed a sector boundary (forward only) + local delta = Circuit.gap(progress, session.lastProgress) + if delta > 0 then + session.sectorTimes[session.sectorIndex] = os.clock() - session.lapStart + session.sectorIndex = sec + push(session) + end + end + + -- Lap completion: progress wrapped past the start point going forward. + local startP = session.lapStartProgress or 0 + local wrapped = session.lastProgress > startP and progress < startP + local passed = session.lastProgress < startP and progress >= startP + local movedFar = math.abs(Circuit.gap(progress, session.lastProgress)) < Circuit.LAP_LENGTH / 4 + if (wrapped or passed) and movedFar and (os.clock() - session.lapStart) > 20 then + local lap = os.clock() - session.lapStart + session.lastLapTime = lap + local data = DataManager.get(session.player) + if data then + if data.BestLapTime == 0 or lap < data.BestLapTime then + data.BestLapTime = lap + end + for i, t in ipairs(session.sectorTimes) do + if t > 0 and (data.BestSectors[i] == 0 or t < data.BestSectors[i]) then + data.BestSectors[i] = t + end + end + end + -- Roll straight into the next flying lap. + session.lapStart = os.clock() + session.lapStartProgress = progress + session.sectorTimes = { 0, 0, 0, 0 } + session.sectorIndex = sectorOf(progress) + push(session) + end + end + + -- DUEL: gap-based win condition ------------------------------------------ + if session.duelId then + local duel = duels[session.duelId] + if duel and duel.a == session.player then -- evaluate once per duel + local sa, sb = sessions[duel.a], sessions[duel.b] + if sa and sb then + local gap = Circuit.gap(sa.progress, sb.progress) + local leader = gap > 0 and duel.a or duel.b + local absGap = math.abs(gap) + + if absGap >= Modes.DUEL.WIN_GAP then + if duel.leader ~= leader then + duel.leader = leader + duel.leadSince = os.clock() + elseif duel.leadSince and (os.clock() - duel.leadSince) >= Modes.DUEL.HOLD_TIME then + endDuel(duel, leader, "broke away") + return + end + else + duel.leader = nil + duel.leadSince = nil + end + + if (os.clock() - duel.startedAt) > Modes.DUEL.MAX_DURATION then + endDuel(duel, nil, "time expired") + return + end + end + end + else + tryOfferDuel(session) + end +end + +function ModeManager.init() + rModeUpdated = Remotes.get("ModeUpdated") :: RemoteEvent + rDuelOffer = Remotes.get("DuelOffer") :: RemoteEvent + rDuelResult = Remotes.get("DuelResult") :: RemoteEvent + + for _, player in ipairs(Players:GetPlayers()) do + ModeManager.onPlayerAdded(player) + end + + -- HUD push throttle: state changes push immediately, this is the steady + -- 5 Hz refresh for timers/gaps. + local pushAccum = 0 + RunService.Heartbeat:Connect(function(dt) + for _, session in pairs(sessions) do + stepPlayer(session, dt) + end + pushAccum += dt + if pushAccum >= 0.2 then + pushAccum = 0 + for _, session in pairs(sessions) do + if session.state == "RUNNING" then + push(session) + end + end + end + end) +end + +function ModeManager.pushTo(player: Player) + local session = sessions[player] + if session then + push(session) + end +end + +return ModeManager diff --git a/src/server/init.server.luau b/src/server/init.server.luau index a02904a..9c4e833 100644 --- a/src/server/init.server.luau +++ b/src/server/init.server.luau @@ -1,14 +1,17 @@ --!strict --[[ init.server.luau (SERVER ENTRY POINT) - MVP scope: JUST DRIVING. The server: - 1. Creates the remotes - 2. Boots DataManager (safe saves), CarManager, AntiCheat - 3. Builds the expressway map + lighting mood - 4. Gives each player their car and lets them switch cars - Server-authoritative: the client REQUESTS a car, the server DECIDES (and - enforces the RX-7 allowlist). + Scope: driving + the three-mode Wangan loop. The server: + 1. Creates the remotes + 2. Boots DataManager (safe saves), CarManager, ModeManager, AntiCheat + 3. Builds the expressway map + lighting mood + 4. Gives each player their car, lets them switch cars, and runs the + PA -> mode select -> exit gate -> run -> back to PA loop + + Server-authoritative: the client REQUESTS a car or a mode, the server + DECIDES. Distance, lap times and duel gaps are measured from the car's real + server-side position — the client never reports its own numbers. ]] local Players = game:GetService("Players") @@ -17,10 +20,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Shared = ReplicatedStorage.Shared local Remotes = require(Shared.Remotes) local Cars = require(Shared.Cars) +local Circuit = require(Shared.Circuit) local GameConfig = require(Shared.GameConfig) local DataManager = require(script.DataManager) local CarManager = require(script.CarManager) +local ModeManager = require(script.ModeManager) local MapBuilder = require(script.MapBuilder) local AntiCheat = require(script.AntiCheat) local Ambience = require(script.Ambience) @@ -30,17 +35,26 @@ Remotes.init() local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction +local rSelectMode = Remotes.get("SelectMode") :: RemoteFunction +local rRespondDuel = Remotes.get("RespondDuel") :: RemoteEvent +local rAbandonRun = Remotes.get("AbandonRun") :: RemoteEvent local function pushCar(player: Player) local data = DataManager.get(player) if data then - rDataUpdated:FireClient(player, data.EquippedCarId) + rDataUpdated:FireClient(player, data.EquippedCarId, { + totalKm = data.DistanceStuds * Circuit.STUDS_TO_KM, + bestLapTime = data.BestLapTime, + duelsWon = data.DuelsWon, + duelsLost = data.DuelsLost, + }) end end -- 2. Boot managers ---------------------------------------------------------- DataManager.init() CarManager.init() +ModeManager.init() AntiCheat.init() -- 3. Build the world -------------------------------------------------------- @@ -49,8 +63,10 @@ Ambience.init() -- 4. Player lifecycle: push car id + auto-spawn their car ------------------- Players.PlayerAdded:Connect(function(player) + ModeManager.onPlayerAdded(player) DataManager.onReady(player, function() pushCar(player) + ModeManager.pushTo(player) end) player.CharacterAdded:Connect(function() DataManager.onReady(player, function(data) @@ -60,6 +76,10 @@ Players.PlayerAdded:Connect(function(player) end) end) +Players.PlayerRemoving:Connect(function(player) + ModeManager.onPlayerRemoving(player) +end) + -- 5. Remote handlers (validated server-side) -------------------------------- rSpawnCar.OnServerEvent:Connect(function(player) CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it) @@ -87,4 +107,22 @@ rEquipCar.OnServerInvoke = function(player, carId) return true end -print("[Wangan] Server booted (drive-only MVP): map built, car system online.") +rSelectMode.OnServerInvoke = function(player, modeId) + return ModeManager.selectMode(player, modeId) +end + +rRespondDuel.OnServerEvent:Connect(function(player, accept) + ModeManager.respondDuel(player, accept) +end) + +rAbandonRun.OnServerEvent:Connect(function(player) + ModeManager.abandon(player) +end) + +print( + string.format( + "[Wangan] Server booted: map built, car system online, 3 modes armed (lap = %.0f studs / %.2f km).", + Circuit.LAP_LENGTH, + Circuit.LAP_LENGTH * Circuit.STUDS_TO_KM + ) +) diff --git a/src/shared/Circuit.luau b/src/shared/Circuit.luau new file mode 100644 index 0000000..eeb6b7f --- /dev/null +++ b/src/shared/Circuit.luau @@ -0,0 +1,174 @@ +--!strict +--[[ + Circuit.luau (SHARED) + Single source of truth for the Wangan Loop centreline geometry. + + MapBuilder builds the road from these samples; gameplay (time attack, duel + gaps, cruise distance) measures progress against the SAME samples. If the + map shape ever changes, both move together — no drift. + + The key primitive is `progressOf(pos)`: how far around the lap a world + position is, in studs. From that one number you get: + - lap detection (progress wraps high -> low) + - sector splits (progress crosses a quarter boundary) + - duel gap (difference of two players' progress, wrap-aware) +]] + +local Circuit = {} + +-- Loop geometry (MUST match MapBuilder's road extrusion) ---------------------- +Circuit.ROAD_WIDTH = 60 +Circuit.HX = 1200 -- half-extent X +Circuit.HZ = 800 -- half-extent Z +Circuit.CORNER_R = 300 +Circuit.STEP = 60 -- straight sample spacing +Circuit.ARC_STEPS = 12 -- samples per 90-degree corner + +-- Daikoku PA occupies the outer side of the south straight. +Circuit.PA_GAP_HALF = 270 +Circuit.PA_ZONE_X = 400 +Circuit.PA_ZONE_Z = Circuit.HZ + 35 -- just outside the road's outer edge + +export type Sample = { pos: Vector3, piece: string } + +local HX, HZ = Circuit.HX, Circuit.HZ +local CORNER_R, STEP, ARC_STEPS = Circuit.CORNER_R, Circuit.STEP, Circuit.ARC_STEPS + +local function genSamples(): { Sample } + local samples: { Sample } = {} + local sx = HX - CORNER_R + local sz = HZ - CORNER_R + + local function push(pos: Vector3, piece: string) + table.insert(samples, { pos = pos, piece = piece }) + end + local function arc(cx: number, cz: number, a0: number, piece: string) + for i = 1, ARC_STEPS do + local a = a0 + (i / ARC_STEPS) * (math.pi / 2) + push(Vector3.new(cx + CORNER_R * math.sin(a), 0, cz + CORNER_R * math.cos(a)), piece) + end + end + + -- South straight (Daikoku side), heading +X + local x = -sx + while x <= sx do + push(Vector3.new(x, 0, HZ), "south") + x += STEP + end + arc(sx, sz, 0, "corner_se") + -- East straight (Rainbow Bridge), heading -Z + local z = sz - STEP + while z >= -sz do + push(Vector3.new(HX, 0, z), "east") + z -= STEP + end + arc(sx, -sz, math.pi / 2, "corner_ne") + -- North straight (Yamate tunnel), heading -X + x = sx - STEP + while x >= -sx do + push(Vector3.new(x, 0, -HZ), "north") + x -= STEP + end + arc(-sx, -sz, math.pi, "corner_nw") + -- West straight (Tokyo Tower run), heading +Z + z = -sz + STEP + while z <= sz do + push(Vector3.new(-HX, 0, z), "west") + z += STEP + end + arc(-sx, sz, 3 * math.pi / 2, "corner_sw") + + return samples +end + +Circuit.samples = genSamples() + +-- Cumulative arc length at each sample, and total lap length ------------------ +local cumulative: { number } = {} +do + local total = 0 + for i, s in ipairs(Circuit.samples) do + if i == 1 then + cumulative[i] = 0 + else + total += (s.pos - Circuit.samples[i - 1].pos).Magnitude + cumulative[i] = total + end + end + -- close the loop back to sample 1 + local last = Circuit.samples[#Circuit.samples] + total += (Circuit.samples[1].pos - last.pos).Magnitude + Circuit.LAP_LENGTH = total +end + +Circuit.cumulative = cumulative +Circuit.SAMPLE_COUNT = #Circuit.samples + +-- Nearest sample index. `hint` = last known index; we search locally around it +-- first (cars move a little per tick) and only fall back to a full scan when +-- the local window misses (fresh spawn, teleport, respawn). +function Circuit.nearestIndex(pos: Vector3, hint: number?): number + local n = Circuit.SAMPLE_COUNT + local best, bestDist = 1, math.huge + + local function consider(i: number) + local idx = ((i - 1) % n) + 1 + local d = (Circuit.samples[idx].pos - Vector3.new(pos.X, 0, pos.Z)).Magnitude + if d < bestDist then + best, bestDist = idx, d + end + end + + if hint then + for i = hint - 6, hint + 6 do + consider(i) + end + -- A straight sample is STEP apart; anything under 2 steps is a solid lock. + if bestDist < STEP * 2 then + return best + end + best, bestDist = 1, math.huge + end + for i = 1, n do + consider(i) + end + return best +end + +-- Distance travelled around the lap, in studs (0 .. LAP_LENGTH). +function Circuit.progressOf(pos: Vector3, hint: number?): (number, number) + local idx = Circuit.nearestIndex(pos, hint) + return cumulative[idx], idx +end + +-- Wrap-aware signed gap: how far `a` is AHEAD of `b`, in studs. +-- Result is in (-LAP/2, +LAP/2]; positive = a leads. +function Circuit.gap(progressA: number, progressB: number): number + local lap = Circuit.LAP_LENGTH + local d = (progressA - progressB) % lap + if d > lap / 2 then + d -= lap + end + return d +end + +-- Is this world position inside the Daikoku PA (off the racing surface)? +function Circuit.inPA(pos: Vector3): boolean + return pos.Z > Circuit.PA_ZONE_Z and math.abs(pos.X) < Circuit.PA_ZONE_X +end + +-- Forward direction (unit) of the racing line at a sample index. +function Circuit.forwardAt(idx: number): Vector3 + local n = Circuit.SAMPLE_COUNT + local nextIdx = (idx % n) + 1 + local d = Circuit.samples[nextIdx].pos - Circuit.samples[idx].pos + if d.Magnitude < 0.01 then + return Vector3.xAxis + end + return d.Unit +end + +-- Roblox studs -> kilometres. 1 stud ~= 0.28 m (Roblox's own rule of thumb). +Circuit.STUDS_TO_KM = 0.28 / 1000 + +return Circuit diff --git a/src/shared/Modes.luau b/src/shared/Modes.luau new file mode 100644 index 0000000..06e1585 --- /dev/null +++ b/src/shared/Modes.luau @@ -0,0 +1,87 @@ +--!strict +--[[ + Modes.luau (SHARED) + The three ways to leave Daikoku Futo PA. Selected in the PA, armed when you + cross the exit gate onto the Wangan, ended when you pull back into the PA. + + This is the Tokyo Extreme Racer structure: the parking area is the hub, the + exit ramp is the mode gate. You never "join a match" — you just drive out. +]] + +local Modes = {} + +export type ModeId = "CRUISE" | "DUEL" | "TIME_ATTACK" + +export type ModeDef = { + Id: ModeId, + Name: string, + NameJP: string, + Blurb: string, + Color: Color3, + -- Can rivals pull alongside and challenge you in this mode? + AllowChallenges: boolean, + -- Is the lap timer running? + Timed: boolean, +} + +Modes.List = { + { + Id = "CRUISE" :: ModeId, + Name = "CRUISE", + NameJP = "流し", + Blurb = "Free roam the Wangan. Bank kilometres, meet other drivers.\nRivals may pull up alongside — flash them to accept.", + Color = Color3.fromRGB(40, 160, 255), + AllowChallenges = true, + Timed = false, + }, + { + Id = "DUEL" :: ModeId, + Name = "DUEL", + NameJP = "バトル", + Blurb = "Hunt rivals. First to pull a car-length gap and hold it wins.\nNo laps, no checkpoints — just who breaks first.", + Color = Color3.fromRGB(255, 40, 120), + AllowChallenges = true, + Timed = false, + }, + { + Id = "TIME_ATTACK" :: ModeId, + Name = "TIME ATTACK", + NameJP = "タイムアタック", + Blurb = "Solo flying lap of the full loop with sector splits.\nBeat your ghost. No rivals will interrupt you.", + Color = Color3.fromRGB(120, 255, 170), + AllowChallenges = false, + Timed = true, + }, +} + +local byId: { [string]: ModeDef } = {} +for _, m in ipairs(Modes.List) do + byId[m.Id] = m +end + +function Modes.Get(id: string): ModeDef? + return byId[id] +end + +function Modes.IsValid(id: any): boolean + return typeof(id) == "string" and byId[id] ~= nil +end + +-- Duel tuning ---------------------------------------------------------------- +Modes.DUEL = { + -- Gap (studs) you must hold to win. ~350 studs is roughly a long straight's + -- worth of daylight — the Wangan "he's gone" moment. + WIN_GAP = 350, + -- ...and you must hold it this long, so a slipstream pass doesn't insta-win. + HOLD_TIME = 3.0, + -- Duel auto-expires (nobody broke away). + MAX_DURATION = 180, + -- How close a rival must be to offer/accept a challenge. + CHALLENGE_RANGE = 120, +} + +Modes.TIME_ATTACK = { + SECTORS = 4, +} + +return Modes diff --git a/src/shared/ProfileTemplate.luau b/src/shared/ProfileTemplate.luau index bb67e7f..7fadbb5 100644 --- a/src/shared/ProfileTemplate.luau +++ b/src/shared/ProfileTemplate.luau @@ -1,19 +1,30 @@ --!strict --[[ ProfileTemplate.luau - Saved data shape. MVP scope (just driving): we only remember which car the - player last drove, so they resume in it. No money, no owned-car list, no rep. - ProfileStore reconciles missing keys from this template automatically. + Saved data shape. v3 adds the mode-system career stats: kilometres cruised, + best lap, duel record. ProfileStore reconciles missing keys automatically, + so existing v2 saves gain these with the defaults below. ]] export type ProfileData = { - EquippedCarId: string, -- last car the player was driving ("" = assign default on load) + EquippedCarId: string, -- last car the player was driving ("" = assign default) + -- Career stats ----------------------------------------------------------- + DistanceStuds: number, -- lifetime distance driven, in studs (display as km) + BestLapTime: number, -- best TIME ATTACK lap, seconds (0 = none set) + BestSectors: { number }, -- best individual sector times, seconds (0 = none) + DuelsWon: number, + DuelsLost: number, Version: number, } local ProfileTemplate: ProfileData = { EquippedCarId = "", - Version = 2, + DistanceStuds = 0, + BestLapTime = 0, + BestSectors = { 0, 0, 0, 0 }, + DuelsWon = 0, + DuelsLost = 0, + Version = 3, } return ProfileTemplate diff --git a/src/shared/Remotes.luau b/src/shared/Remotes.luau index 1eca6e5..ade36b9 100644 --- a/src/shared/Remotes.luau +++ b/src/shared/Remotes.luau @@ -2,8 +2,6 @@ --[[ Remotes.luau Single source of truth for every RemoteEvent / RemoteFunction. - MVP scope (just driving): the only client->server actions are "give me a car" - and "switch which car I'm driving". Server creates these at boot; client reads. Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere. ]] @@ -15,12 +13,18 @@ local Remotes = {} -- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ---------- local DEFS: { [string]: string } = { - -- Server -> Client: pushes the player's current car id (for HUD) - DataUpdated = "Event", + -- Server -> Client + DataUpdated = "Event", -- (carId, stats) player's car + career stats for HUD + ModeUpdated = "Event", -- (state) full mode/HUD snapshot (see ModeManager) + DuelOffer = "Event", -- (offerInfo) a rival is alongside and wants a run + DuelResult = "Event", -- (resultInfo) duel ended: won/lost/expired -- Client -> Server requests SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip) EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car + SelectMode = "Function", -- (modeId) -> boolean: arm a mode (must be in the PA) + RespondDuel = "Event", -- (accept: boolean) answer a DuelOffer + AbandonRun = "Event", -- () give up the current run / return to PA } local FOLDER_NAME = "Remotes" diff --git a/wally.toml b/wally.toml index fde7178..ab59762 100644 --- a/wally.toml +++ b/wally.toml @@ -1,5 +1,5 @@ [package] -name = "r3alchick3n/fnf-sim" +name = "r3alchick3n/tokyo-wangan-racing" version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared"