From be52290093250c0d1cd7de3321e98ca241fce093 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Thu, 30 Jul 2026 20:16:04 -0400 Subject: [PATCH] Add three-mode Wangan loop; rename project to Tokyo Wangan Racing Daikoku Futo PA becomes the hub (Tokyo Xtreme Racer structure): park, pick a mode, drive out the exit gate to start the run, pull back into the PA to end it. Modes: - CRUISE free roam, banks session + lifetime kilometres - DUEL gap-based battle: hold 350 studs for 3s to win - TIME ATTACK solo flying lap, 4 sector splits, saved personal best New shared modules: - Circuit.luau one source of truth for loop geometry. progressOf(pos) gives lap distance, which drives lap detection, sectors and duel gaps alike, so the map and gameplay can never drift apart. - Modes.luau mode definitions + duel/time-attack tuning constants Server-authoritative throughout: ModeManager samples the car real position on Heartbeat and the client never reports distance, lap time or gap. Teleport and respawn jumps are rejected from distance accumulation. ProfileTemplate v3 adds DistanceStuds, BestLapTime, BestSectors, DuelsWon and DuelsLost; ProfileStore Reconcile migrates existing saves. Rename: repo, wally package, local dir and stale NAS asset paths all move off the old fnf-sim name. NAS folder was already 0-tokyo-wangan-racing, so those doc paths were broken before this. --- README.md | 37 ++- docs/MESH_SWAP.md | 2 +- docs/ROTARY_SOUND.md | 2 +- src/client/ModeUI.luau | 479 ++++++++++++++++++++++++++++++++ src/client/init.client.luau | 14 +- src/server/CarManager.luau | 23 ++ src/server/MapBuilder.luau | 17 ++ src/server/ModeManager.luau | 475 +++++++++++++++++++++++++++++++ src/server/init.server.luau | 56 +++- src/shared/Circuit.luau | 174 ++++++++++++ src/shared/Modes.luau | 87 ++++++ src/shared/ProfileTemplate.luau | 21 +- src/shared/Remotes.luau | 12 +- wally.toml | 2 +- 14 files changed, 1371 insertions(+), 30 deletions(-) create mode 100644 src/client/ModeUI.luau create mode 100644 src/server/ModeManager.luau create mode 100644 src/shared/Circuit.luau create mode 100644 src/shared/Modes.luau 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"