Add three-mode Wangan loop; rename project to Tokyo Wangan Racing
CI / quality (push) Successful in 1m18s

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.
This commit is contained in:
2026-07-30 20:16:04 -04:00
parent 681c3462c6
commit be52290093
14 changed files with 1371 additions and 30 deletions
+479
View File
@@ -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
+12 -2
View File
@@ -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).")