Add three-mode Wangan loop; rename project to Tokyo Wangan Racing
CI / quality (push) Successful in 1m18s
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:
@@ -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
|
||||
@@ -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).")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user