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).")
+23
View File
@@ -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
+17
View File
@@ -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 -----------------------------------
+475
View File
@@ -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
+47 -9
View File
@@ -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
)
)
+174
View File
@@ -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
+87
View File
@@ -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
+16 -5
View File
@@ -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
+8 -4
View File
@@ -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"