Compare commits
2
Commits
e0bee80795
...
be52290093
| 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 —
|
||||
starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just
|
||||
driving.** No racing, money, or upgrades yet — the goal is to nail the driving
|
||||
feel and be testable fast. See `docs/ROADMAP.md` for what comes next.
|
||||
starting with a real 1993 Mazda RX-7. The driving feel comes first; everything
|
||||
else layers on top of it. See `docs/ROADMAP.md` for what comes next.
|
||||
|
||||
**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a
|
||||
self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio.
|
||||
**Status:** Playable. Spawn → drive → switch cars → respawn, plus the full
|
||||
three-mode Wangan loop. CI green on a self-hosted Gitea runner. Built code-first
|
||||
— nothing to hand-build in Studio.
|
||||
|
||||
## The Daikoku loop (how you play)
|
||||
|
||||
Daikoku Futo PA is the hub, exactly like Tokyo Xtreme Racer. You are never
|
||||
"joining a match" — you park, pick how you want to run, and drive out.
|
||||
|
||||
```
|
||||
IN THE PA ──► pick a mode ──► cross the exit gate ──► RUNNING
|
||||
▲ │
|
||||
└──────────────── pull back into the PA ─────────────────┘
|
||||
```
|
||||
|
||||
| Mode | 日本語 | What it is |
|
||||
|---|---|---|
|
||||
| **CRUISE** | 流し | Free roam. Bank kilometres, meet other drivers. Rivals can pull alongside and challenge you. |
|
||||
| **DUEL** | バトル | Hunt rivals. First to open a ~350-stud gap and *hold* it for 3s wins. No laps, no checkpoints — who breaks first. |
|
||||
| **TIME ATTACK** | タイムアタック | Solo flying lap with 4 sector splits and a saved personal best. Rivals can't interrupt you. |
|
||||
|
||||
Cruise and Duel both allow challenges, so a cruise can turn into a battle
|
||||
organically — pull alongside someone and the prompt appears for both of you.
|
||||
Time Attack deliberately opts out so a hot lap is never ruined.
|
||||
|
||||
Kilometres, lap times and duel records all persist via ProfileStore.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,4 +102,4 @@ the `EquipCar` remote).
|
||||
|
||||
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
||||
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
||||
`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`.
|
||||
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "fnf-sim",
|
||||
"name": "TokyoWanganRacing",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"Lighting": {
|
||||
"$className": "Lighting",
|
||||
"$properties": {
|
||||
"Technology": "Future",
|
||||
"ClockTime": 0.0,
|
||||
"Brightness": 1.0,
|
||||
"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
|
||||
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):
|
||||
|
||||
```
|
||||
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`
|
||||
|
||||
@@ -52,7 +52,7 @@ videos of the car are fine — audio gets extracted. Best results:
|
||||
|
||||
Drop the files on the NAS next to the reference photos:
|
||||
```
|
||||
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/sound/
|
||||
unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/sound/
|
||||
```
|
||||
|
||||
## Step 2 — Process into seamless loops (this workstation)
|
||||
|
||||
@@ -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()
|
||||
@@ -53,7 +54,7 @@ local function corner(inst: Instance, radius: number)
|
||||
end
|
||||
|
||||
local screen = make("ScreenGui", {
|
||||
Name = "FnF_UI",
|
||||
Name = "Wangan_UI",
|
||||
ResetOnSpawn = false,
|
||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
}, playerGui)
|
||||
@@ -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("[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 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
|
||||
|
||||
-- Windshield: raked wedge (slope toward front)
|
||||
vis({
|
||||
Name = "Windshield",
|
||||
Size = Vector3.new(W - 1.0, 1.3, 1.9),
|
||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
||||
Size = Vector3.new(W - 1.0, 1.7, 2.1),
|
||||
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
||||
Color = glass,
|
||||
Material = Enum.Material.Glass,
|
||||
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
|
||||
vis({
|
||||
Name = "RearGlass",
|
||||
Size = Vector3.new(W - 1.2, 1.3, rearGlassLen),
|
||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, L * 0.235),
|
||||
Size = Vector3.new(W - 1.2, 1.7, rearGlassLen),
|
||||
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, L * 0.235),
|
||||
Color = glass,
|
||||
Material = Enum.Material.Glass,
|
||||
Transparency = 0.25,
|
||||
|
||||
@@ -223,7 +223,10 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
||||
seat.HeadsUpDisplay = false
|
||||
seat.CanCollide = false
|
||||
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
|
||||
local seatWeld = Instance.new("WeldConstraint")
|
||||
seatWeld.Part0 = body
|
||||
@@ -323,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
|
||||
|
||||
@@ -64,7 +64,7 @@ local function onPlayerAdded(player: Player)
|
||||
|
||||
if profile == nil then
|
||||
-- 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
|
||||
end
|
||||
|
||||
@@ -85,7 +85,7 @@ local function onPlayerAdded(player: Player)
|
||||
-- The session was lost (another server stole it, or we released it).
|
||||
profiles[player] = nil
|
||||
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)
|
||||
|
||||
|
||||
@@ -347,17 +347,36 @@ local function buildDaikoku(map: Folder)
|
||||
end
|
||||
|
||||
-- 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({
|
||||
Name = "SpawnPad",
|
||||
Size = Vector3.new(120, 1, 60),
|
||||
CFrame = CFrame.new(0, 1.2, z0 + 130),
|
||||
Color = Color3.fromRGB(20, 220, 120),
|
||||
Material = Enum.Material.Neon,
|
||||
Transparency = 0.4,
|
||||
Color = Color3.fromRGB(45, 60, 55),
|
||||
Material = Enum.Material.Asphalt,
|
||||
Transparency = 0,
|
||||
})
|
||||
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("[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
|
||||
--[[
|
||||
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"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "r3alchick3n/fnf-sim"
|
||||
name = "r3alchick3n/tokyo-wangan-racing"
|
||||
version = "0.1.0"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
|
||||
Reference in New Issue
Block a user