Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be52290093 | ||
|
|
681c3462c6 |
@@ -1,12 +1,35 @@
|
|||||||
# Fast & Furious: Tokyo Expressway — MVP (drive-only)
|
# Tokyo Wangan Racing
|
||||||
|
|
||||||
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
|
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
|
||||||
starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just
|
starting with a real 1993 Mazda RX-7. The driving feel comes first; everything
|
||||||
driving.** No racing, money, or upgrades yet — the goal is to nail the driving
|
else layers on top of it. See `docs/ROADMAP.md` for what comes next.
|
||||||
feel and be testable fast. See `docs/ROADMAP.md` for what comes next.
|
|
||||||
|
|
||||||
**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a
|
**Status:** Playable. Spawn → drive → switch cars → respawn, plus the full
|
||||||
self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio.
|
three-mode Wangan loop. CI green on a self-hosted Gitea runner. Built code-first
|
||||||
|
— nothing to hand-build in Studio.
|
||||||
|
|
||||||
|
## The Daikoku loop (how you play)
|
||||||
|
|
||||||
|
Daikoku Futo PA is the hub, exactly like Tokyo Xtreme Racer. You are never
|
||||||
|
"joining a match" — you park, pick how you want to run, and drive out.
|
||||||
|
|
||||||
|
```
|
||||||
|
IN THE PA ──► pick a mode ──► cross the exit gate ──► RUNNING
|
||||||
|
▲ │
|
||||||
|
└──────────────── pull back into the PA ─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mode | 日本語 | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| **CRUISE** | 流し | Free roam. Bank kilometres, meet other drivers. Rivals can pull alongside and challenge you. |
|
||||||
|
| **DUEL** | バトル | Hunt rivals. First to open a ~350-stud gap and *hold* it for 3s wins. No laps, no checkpoints — who breaks first. |
|
||||||
|
| **TIME ATTACK** | タイムアタック | Solo flying lap with 4 sector splits and a saved personal best. Rivals can't interrupt you. |
|
||||||
|
|
||||||
|
Cruise and Duel both allow challenges, so a cruise can turn into a battle
|
||||||
|
organically — pull alongside someone and the prompt appears for both of you.
|
||||||
|
Time Attack deliberately opts out so a hot lap is never ruined.
|
||||||
|
|
||||||
|
Kilometres, lap times and duel records all persist via ProfileStore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,4 +102,4 @@ the `EquipCar` remote).
|
|||||||
|
|
||||||
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
||||||
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
||||||
`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`.
|
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fnf-sim",
|
"name": "TokyoWanganRacing",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$className": "DataModel",
|
"$className": "DataModel",
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"Lighting": {
|
"Lighting": {
|
||||||
"$className": "Lighting",
|
"$className": "Lighting",
|
||||||
"$properties": {
|
"$properties": {
|
||||||
|
"Technology": "Future",
|
||||||
"ClockTime": 0.0,
|
"ClockTime": 0.0,
|
||||||
"Brightness": 1.0,
|
"Brightness": 1.0,
|
||||||
"Ambient": [0.05, 0.05, 0.1],
|
"Ambient": [0.05, 0.05, 0.1],
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Game Design Document — Fast & Furious: Tokyo Expressway (MVP)
|
# Game Design Document — Tokyo Wangan Racing (MVP)
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
|
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ anti-cheat) needs to change.
|
|||||||
The real car's reference photos live on the NAS (too large to commit to git):
|
The real car's reference photos live on the NAS (too large to commit to git):
|
||||||
|
|
||||||
```
|
```
|
||||||
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/
|
unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/
|
||||||
```
|
```
|
||||||
|
|
||||||
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ videos of the car are fine — audio gets extracted. Best results:
|
|||||||
|
|
||||||
Drop the files on the NAS next to the reference photos:
|
Drop the files on the NAS next to the reference photos:
|
||||||
```
|
```
|
||||||
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/sound/
|
unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/sound/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 2 — Process into seamless loops (this workstation)
|
## Step 2 — Process into seamless loops (this workstation)
|
||||||
|
|||||||
@@ -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 ChassisController = require(script.ChassisController)
|
||||||
local CameraController = require(script.CameraController)
|
local CameraController = require(script.CameraController)
|
||||||
local SoundController = require(script.SoundController)
|
local SoundController = require(script.SoundController)
|
||||||
|
local ModeUI = require(script.ModeUI)
|
||||||
|
|
||||||
ChassisController.init()
|
ChassisController.init()
|
||||||
CameraController.init()
|
CameraController.init()
|
||||||
@@ -53,7 +54,7 @@ local function corner(inst: Instance, radius: number)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local screen = make("ScreenGui", {
|
local screen = make("ScreenGui", {
|
||||||
Name = "FnF_UI",
|
Name = "Wangan_UI",
|
||||||
ResetOnSpawn = false,
|
ResetOnSpawn = false,
|
||||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||||
}, playerGui)
|
}, playerGui)
|
||||||
@@ -272,7 +273,7 @@ local function rebuildPicker()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Data sync -----------------------------------------------------------------
|
-- Data sync -----------------------------------------------------------------
|
||||||
rDataUpdated.OnClientEvent:Connect(function(carId: string)
|
rDataUpdated.OnClientEvent:Connect(function(carId: string, _stats: any)
|
||||||
currentCarId = carId
|
currentCarId = carId
|
||||||
local car = Cars.Get(carId)
|
local car = Cars.Get(carId)
|
||||||
carLabel.Text = "Car: " .. (car and car.Name or carId)
|
carLabel.Text = "Car: " .. (car and car.Name or carId)
|
||||||
@@ -296,4 +297,13 @@ respawnBtn.MouseButton1Click:Connect(function()
|
|||||||
showToast("Respawning your car...")
|
showToast("Respawning your car...")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
print("[FnF] 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).")
|
||||||
|
|||||||
@@ -224,14 +224,14 @@ end
|
|||||||
|
|
||||||
local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, glass: Color3, style: Style)
|
local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, glass: Color3, style: Style)
|
||||||
local origin = body.CFrame
|
local origin = body.CFrame
|
||||||
local roofY = H / 2 + 1.5
|
local roofY = H / 2 + 1.9 -- tall enough that a seated avatar fits under it
|
||||||
local roofLen = L * 0.26
|
local roofLen = L * 0.26
|
||||||
|
|
||||||
-- Windshield: raked wedge (slope toward front)
|
-- Windshield: raked wedge (slope toward front)
|
||||||
vis({
|
vis({
|
||||||
Name = "Windshield",
|
Name = "Windshield",
|
||||||
Size = Vector3.new(W - 1.0, 1.3, 1.9),
|
Size = Vector3.new(W - 1.0, 1.7, 2.1),
|
||||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
||||||
Color = glass,
|
Color = glass,
|
||||||
Material = Enum.Material.Glass,
|
Material = Enum.Material.Glass,
|
||||||
Transparency = 0.25,
|
Transparency = 0.25,
|
||||||
@@ -262,8 +262,8 @@ local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, gl
|
|||||||
local rearGlassLen = if style.TailKind == "hatch" then 2.4 else 1.7
|
local rearGlassLen = if style.TailKind == "hatch" then 2.4 else 1.7
|
||||||
vis({
|
vis({
|
||||||
Name = "RearGlass",
|
Name = "RearGlass",
|
||||||
Size = Vector3.new(W - 1.2, 1.3, rearGlassLen),
|
Size = Vector3.new(W - 1.2, 1.7, rearGlassLen),
|
||||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, L * 0.235),
|
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, L * 0.235),
|
||||||
Color = glass,
|
Color = glass,
|
||||||
Material = Enum.Material.Glass,
|
Material = Enum.Material.Glass,
|
||||||
Transparency = 0.25,
|
Transparency = 0.25,
|
||||||
|
|||||||
@@ -223,7 +223,10 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
seat.HeadsUpDisplay = false
|
seat.HeadsUpDisplay = false
|
||||||
seat.CanCollide = false
|
seat.CanCollide = false
|
||||||
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
||||||
seat.CFrame = origin * CFrame.new(0, 1.2, -0.5)
|
-- Seat pan just above the body slab (top = H/2): low enough to look tucked
|
||||||
|
-- in the cabin, high enough that the avatar's legs never interpenetrate
|
||||||
|
-- the slab (which causes physics launches).
|
||||||
|
seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2)
|
||||||
seat.Parent = model
|
seat.Parent = model
|
||||||
local seatWeld = Instance.new("WeldConstraint")
|
local seatWeld = Instance.new("WeldConstraint")
|
||||||
seatWeld.Part0 = body
|
seatWeld.Part0 = body
|
||||||
@@ -323,4 +326,27 @@ function CarManager.init()
|
|||||||
end)
|
end)
|
||||||
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
|
return CarManager
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ local function onPlayerAdded(player: Player)
|
|||||||
|
|
||||||
if profile == nil then
|
if profile == nil then
|
||||||
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
||||||
player:Kick("[FnF] Your data could not be loaded. Please rejoin.")
|
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ local function onPlayerAdded(player: Player)
|
|||||||
-- The session was lost (another server stole it, or we released it).
|
-- The session was lost (another server stole it, or we released it).
|
||||||
profiles[player] = nil
|
profiles[player] = nil
|
||||||
if player.Parent == Players then
|
if player.Parent == Players then
|
||||||
player:Kick("[FnF] Your session ended (data loaded elsewhere). Please rejoin.")
|
player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -347,17 +347,36 @@ local function buildDaikoku(map: Folder)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Spawn pad (cars appear here — Daikoku is the meet) -----------------------
|
-- Spawn pad (cars appear here — Daikoku is the meet) -----------------------
|
||||||
|
-- Subtle painted zone, NOT neon: the cars are the stars of the meet, the
|
||||||
|
-- floor must never outshine them.
|
||||||
local spawn = part({
|
local spawn = part({
|
||||||
Name = "SpawnPad",
|
Name = "SpawnPad",
|
||||||
Size = Vector3.new(120, 1, 60),
|
Size = Vector3.new(120, 1, 60),
|
||||||
CFrame = CFrame.new(0, 1.2, z0 + 130),
|
CFrame = CFrame.new(0, 1.2, z0 + 130),
|
||||||
Color = Color3.fromRGB(20, 220, 120),
|
Color = Color3.fromRGB(45, 60, 55),
|
||||||
Material = Enum.Material.Neon,
|
Material = Enum.Material.Asphalt,
|
||||||
Transparency = 0.4,
|
Transparency = 0,
|
||||||
})
|
})
|
||||||
spawn.Parent = folder
|
spawn.Parent = folder
|
||||||
|
|
||||||
makeSign("大黒PA DAIKOKU PA", CFrame.new(0, 12, z0 + 4) * CFrame.Angles(0, math.pi, 0), 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
|
end
|
||||||
|
|
||||||
-- Rainbow Bridge towers over the east straight -----------------------------------
|
-- 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
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
init.server.luau (SERVER ENTRY POINT)
|
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
|
Scope: driving + the three-mode Wangan loop. The server:
|
||||||
enforces the RX-7 allowlist).
|
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")
|
local Players = game:GetService("Players")
|
||||||
@@ -17,10 +20,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|||||||
local Shared = ReplicatedStorage.Shared
|
local Shared = ReplicatedStorage.Shared
|
||||||
local Remotes = require(Shared.Remotes)
|
local Remotes = require(Shared.Remotes)
|
||||||
local Cars = require(Shared.Cars)
|
local Cars = require(Shared.Cars)
|
||||||
|
local Circuit = require(Shared.Circuit)
|
||||||
local GameConfig = require(Shared.GameConfig)
|
local GameConfig = require(Shared.GameConfig)
|
||||||
|
|
||||||
local DataManager = require(script.DataManager)
|
local DataManager = require(script.DataManager)
|
||||||
local CarManager = require(script.CarManager)
|
local CarManager = require(script.CarManager)
|
||||||
|
local ModeManager = require(script.ModeManager)
|
||||||
local MapBuilder = require(script.MapBuilder)
|
local MapBuilder = require(script.MapBuilder)
|
||||||
local AntiCheat = require(script.AntiCheat)
|
local AntiCheat = require(script.AntiCheat)
|
||||||
local Ambience = require(script.Ambience)
|
local Ambience = require(script.Ambience)
|
||||||
@@ -30,17 +35,26 @@ Remotes.init()
|
|||||||
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
||||||
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
||||||
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
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 function pushCar(player: Player)
|
||||||
local data = DataManager.get(player)
|
local data = DataManager.get(player)
|
||||||
if data then
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 2. Boot managers ----------------------------------------------------------
|
-- 2. Boot managers ----------------------------------------------------------
|
||||||
DataManager.init()
|
DataManager.init()
|
||||||
CarManager.init()
|
CarManager.init()
|
||||||
|
ModeManager.init()
|
||||||
AntiCheat.init()
|
AntiCheat.init()
|
||||||
|
|
||||||
-- 3. Build the world --------------------------------------------------------
|
-- 3. Build the world --------------------------------------------------------
|
||||||
@@ -49,8 +63,10 @@ Ambience.init()
|
|||||||
|
|
||||||
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||||
Players.PlayerAdded:Connect(function(player)
|
Players.PlayerAdded:Connect(function(player)
|
||||||
|
ModeManager.onPlayerAdded(player)
|
||||||
DataManager.onReady(player, function()
|
DataManager.onReady(player, function()
|
||||||
pushCar(player)
|
pushCar(player)
|
||||||
|
ModeManager.pushTo(player)
|
||||||
end)
|
end)
|
||||||
player.CharacterAdded:Connect(function()
|
player.CharacterAdded:Connect(function()
|
||||||
DataManager.onReady(player, function(data)
|
DataManager.onReady(player, function(data)
|
||||||
@@ -60,6 +76,10 @@ Players.PlayerAdded:Connect(function(player)
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
Players.PlayerRemoving:Connect(function(player)
|
||||||
|
ModeManager.onPlayerRemoving(player)
|
||||||
|
end)
|
||||||
|
|
||||||
-- 5. Remote handlers (validated server-side) --------------------------------
|
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||||
rSpawnCar.OnServerEvent:Connect(function(player)
|
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||||
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||||
@@ -87,4 +107,22 @@ rEquipCar.OnServerInvoke = function(player, carId)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
print("[FnF] 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
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
ProfileTemplate.luau
|
ProfileTemplate.luau
|
||||||
Saved data shape. MVP scope (just driving): we only remember which car the
|
Saved data shape. v3 adds the mode-system career stats: kilometres cruised,
|
||||||
player last drove, so they resume in it. No money, no owned-car list, no rep.
|
best lap, duel record. ProfileStore reconciles missing keys automatically,
|
||||||
ProfileStore reconciles missing keys from this template automatically.
|
so existing v2 saves gain these with the defaults below.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
export type ProfileData = {
|
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,
|
Version: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
local ProfileTemplate: ProfileData = {
|
local ProfileTemplate: ProfileData = {
|
||||||
EquippedCarId = "",
|
EquippedCarId = "",
|
||||||
Version = 2,
|
DistanceStuds = 0,
|
||||||
|
BestLapTime = 0,
|
||||||
|
BestSectors = { 0, 0, 0, 0 },
|
||||||
|
DuelsWon = 0,
|
||||||
|
DuelsLost = 0,
|
||||||
|
Version = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProfileTemplate
|
return ProfileTemplate
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
--[[
|
--[[
|
||||||
Remotes.luau
|
Remotes.luau
|
||||||
Single source of truth for every RemoteEvent / RemoteFunction.
|
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.
|
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 ----------
|
-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ----------
|
||||||
local DEFS: { [string]: string } = {
|
local DEFS: { [string]: string } = {
|
||||||
-- Server -> Client: pushes the player's current car id (for HUD)
|
-- Server -> Client
|
||||||
DataUpdated = "Event",
|
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
|
-- Client -> Server requests
|
||||||
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
|
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
|
||||||
EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car
|
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"
|
local FOLDER_NAME = "Remotes"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "r3alchick3n/fnf-sim"
|
name = "r3alchick3n/tokyo-wangan-racing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
registry = "https://github.com/UpliftGames/wally-index"
|
registry = "https://github.com/UpliftGames/wally-index"
|
||||||
realm = "shared"
|
realm = "shared"
|
||||||
|
|||||||
Reference in New Issue
Block a user