Per user direction, the MVP is now simply driving cars on the expressway. - Removed RaceManager and Economy modules entirely - Slimmed Remotes to DataUpdated / SpawnCar / EquipCar - ProfileTemplate now just remembers the last driven car (v2) - Cars: dropped price/rep/upgrade fields; kept identity + driving stats - CarManager: stat-based tuning only; RX-7 allowlist via GameConfig.canDrive - DataManager: assign default car on join; downgrade RX-7 if de-allowlisted - Client: replaced garage/economy UI with a simple car picker + respawn - Docs: GDD/ROADMAP/README rewritten for drive-only; added docs/MESH_SWAP.md - Gates: stylua clean, selene 0/0, rojo builds
This commit is contained in:
@@ -1,25 +1,25 @@
|
|||||||
# Fast & Furious: Tokyo Expressway — MVP
|
# Fast & Furious: Tokyo Expressway — MVP (drive-only)
|
||||||
|
|
||||||
A Roblox street-racing simulator. Start in a 1993 Mazda RX-7 (the owner's real car),
|
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
|
||||||
race the neon Tokyo expressway, earn cash + rep, buy and upgrade faster 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.
|
||||||
|
|
||||||
**Status:** Playable vertical slice. Drive → race → earn → save works end to end.
|
**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a
|
||||||
Built code-first — there is nothing to hand-build in Studio to run it.
|
self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). Once Rokit is
|
Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). With Rokit on
|
||||||
installed and on your `PATH`:
|
your `PATH`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rokit install # installs rojo, wally, selene, stylua at pinned versions
|
rokit install # rojo, wally, selene, stylua at pinned versions
|
||||||
wally install # restores ProfileStore into ServerPackages/
|
wally install # restores ProfileStore into ServerPackages/
|
||||||
```
|
```
|
||||||
|
|
||||||
Installed tools (see `rokit.toml`):
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
| Tool | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| rojo | Sync/build code ↔ Roblox place |
|
| rojo | Sync/build code ↔ Roblox place |
|
||||||
@@ -30,17 +30,16 @@ Installed tools (see `rokit.toml`):
|
|||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build a standalone place file you can open in Studio:
|
rojo build --output build.rbxl # standalone place file to open in Studio
|
||||||
rojo build --output build.rbxl
|
|
||||||
|
|
||||||
# OR live-sync into an open Studio place (install the Rojo Studio plugin):
|
# OR live-sync into an open Studio place (install the Rojo Studio plugin):
|
||||||
rojo serve
|
rojo serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Then in Studio press **Play**. You spawn on the expressway; use the bottom bar:
|
In Studio press **Play**. You spawn on the expressway in your car. Bottom bar:
|
||||||
**GARAGE** (buy/equip/upgrade), **SPAWN CAR**, **RACE**, **DAILY**.
|
**CARS** (switch car) and **RESPAWN** (re-spawn if you flip). The RX-7 only
|
||||||
|
appears for allowlisted accounts.
|
||||||
|
|
||||||
## Quality gates (run before every commit)
|
## Quality gates (CI enforces these on every push)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stylua --check src/ # formatting
|
stylua --check src/ # formatting
|
||||||
@@ -48,41 +47,36 @@ selene src/ # lint
|
|||||||
rojo build --output build.rbxl # must compile
|
rojo build --output build.rbxl # must compile
|
||||||
```
|
```
|
||||||
|
|
||||||
CI runs all three on every push (see `.github/` → mirrored to Gitea Actions).
|
CI runs on a self-hosted **Gitea Actions** runner on UNRAID-1 (Ubuntu 24.04 job
|
||||||
|
container — rojo's binary needs glibc 2.39). Workflow: `.gitea/workflows/ci.yml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture (server-authoritative)
|
## Architecture (server-authoritative)
|
||||||
|
|
||||||
Clients **request**, the server **decides**. Money, rep, and car ownership can
|
Clients **request**, the server **decides**. The RX-7 allowlist and anti-cheat
|
||||||
never be set by the client.
|
are server-side.
|
||||||
|
|
||||||
```
|
```
|
||||||
ReplicatedStorage/Shared GameConfig, Cars, ProfileTemplate, Remotes
|
ReplicatedStorage/Shared GameConfig, Cars, ProfileTemplate, Remotes
|
||||||
ServerScriptService/Server init.server + managers (below)
|
ServerScriptService/Server init.server + managers (below)
|
||||||
ServerScriptService/ServerPackages ProfileStore (Wally)
|
ServerScriptService/ServerPackages ProfileStore (Wally)
|
||||||
StarterPlayerScripts/Client init.client (HUD + Garage UI)
|
StarterPlayerScripts/Client init.client (HUD + car picker)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. The data-safety foundation. |
|
| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. Remembers your last car. |
|
||||||
| `Economy` | The only place cash/rep/ownership change. Buy, upgrade, daily, race payouts. |
|
| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) and enforces the RX-7 allowlist. |
|
||||||
| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) with upgrade-scaled tuning. |
|
| `MapBuilder` | Procedurally builds the expressway, spawn pad, neon pylons, kill walls. |
|
||||||
| `RaceManager` | Solo time-trial; server validates the finish-line touch before paying out. |
|
|
||||||
| `MapBuilder` | Procedurally builds the expressway, spawn pad, finish line, neon pylons, kill walls. |
|
|
||||||
| `AntiCheat` | Movement sanity check (speed/teleport) — a speed bump, not a fortress. |
|
| `AntiCheat` | Movement sanity check (speed/teleport) — a speed bump, not a fortress. |
|
||||||
|
|
||||||
See `docs/GDD.md` for the game design doc and `docs/ROADMAP.md` for the realistic
|
The RX-7 demo-lock: `GameConfig.RX7AllowedUserIds` holds the owner's + son's
|
||||||
solo-dev plan.
|
Roblox UserIds; only they can select/drive the RX-7 (enforced in `CarManager` and
|
||||||
|
the `EquipCar` remote).
|
||||||
|
|
||||||
## Replacing the placeholder RX-7 with the real mesh
|
## Replacing the placeholder RX-7 with the real mesh
|
||||||
|
|
||||||
The car is currently a coloured block + `VehicleSeat` so the game is drivable today.
|
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
||||||
When you import the real RX-7 model:
|
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
||||||
1. Drop the mesh/model into `ReplicatedStorage` (or an assets folder).
|
|
||||||
2. Rewrite only `CarManager.buildCarModel` to clone it and weld a `VehicleSeat`.
|
|
||||||
3. Everything else (economy, upgrades, races, saving) keeps working unchanged.
|
|
||||||
|
|
||||||
Reference photos of the real car 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-fast-n-furious-sim/001-1993 Mazda RX-7/`.
|
||||||
|
|||||||
+29
-43
@@ -1,52 +1,38 @@
|
|||||||
# Game Design Document — Fast & Furious: Tokyo Expressway (MVP)
|
# Game Design Document — Fast & Furious: Tokyo Expressway (MVP)
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
A progression-fantasy street racer. You start in a humble-but-iconic 1993 Mazda
|
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
|
||||||
RX-7 and claw your way up to hypercars on a neon-drenched Tokyo expressway at night.
|
real 1993 Mazda RX-7. The long-term dream is a full street-racing simulator, but
|
||||||
Easy to pick up, satisfying to grind, fair to spend on.
|
the **MVP is deliberately tiny: just drive.**
|
||||||
|
|
||||||
## The one-sentence pitch
|
## MVP scope (what's built) — JUST DRIVING
|
||||||
"Start in the RX-7, own the expressway."
|
- Spawn on a neon Tokyo expressway at night.
|
||||||
|
- Drive a car around the highway (built on Roblox's reliable VehicleSeat).
|
||||||
|
- Switch between cars via a simple "CARS" picker.
|
||||||
|
- Respawn your car if you flip or fall off.
|
||||||
|
- The **RX-7 is demo-locked** to the owner + son (Roblox UserId allowlist,
|
||||||
|
enforced server-side). Everyone else drives the public cars.
|
||||||
|
- Your last car is remembered between sessions (ProfileStore).
|
||||||
|
|
||||||
## Core loop (the addictive 60 seconds)
|
That's it. No racing, no money, no rep, no upgrades, no daily rewards. The point
|
||||||
1. **Spawn** on the expressway in your equipped car.
|
of the MVP is to get the car feeling good on the road and be testable fast.
|
||||||
2. **Race** the time-trial down the highway to the finish line.
|
|
||||||
3. **Earn** cash + reputation for finishing.
|
|
||||||
4. **Spend** in the Garage: upgrade your current car or buy the next tier.
|
|
||||||
5. A faster car / better tune makes the next race feel better → back to step 1.
|
|
||||||
|
|
||||||
Daily rewards + rising rep gates give a reason to come back tomorrow.
|
## Why this cut
|
||||||
|
Driving feel is the hardest, most important part of a car game. Everything else
|
||||||
|
(racing, economy, progression) is worthless if the car isn't fun to drive. So the
|
||||||
|
MVP nails driving first; the rest is layered on later once it feels right.
|
||||||
|
|
||||||
## Pillars
|
## Pillars (for the eventual full game)
|
||||||
- **Progression fantasy** — junker → hypercar, always a visible next goal.
|
- Progression fantasy — junker → hypercar (LATER).
|
||||||
- **Nighttime Tokyo vibe** — neon, speed, style.
|
- Nighttime Tokyo vibe — neon, speed, style (NOW: the map).
|
||||||
- **Fair monetization** — spending accelerates the grind, never buys wins.
|
- Fair monetization (LATER).
|
||||||
- **Server-authoritative** — no exploiting money or unlocking cars.
|
- Server-authoritative — no exploiting (NOW: allowlist + anti-cheat foundations).
|
||||||
|
|
||||||
## MVP feature set (what's built)
|
## Deferred (see ROADMAP)
|
||||||
- 5-tier car catalogue; **RX-7 is the free starter** (Tier 2).
|
Racing, checkpoints, money/economy, reputation, upgrades, customization, daily
|
||||||
- Drivable cars via VehicleSeat (proven, swap-in real mesh later).
|
rewards, traffic AI, leaderboards, monetization, crews/PvP, full city map.
|
||||||
- Procedural expressway map with kill-walls (can't leave the road).
|
|
||||||
- Garage + Dealership UI: view / buy / equip / upgrade.
|
|
||||||
- Upgrade system: Engine / Tires / Nitro, cost-scaled with diminishing returns.
|
|
||||||
- Solo time-trial race with server-validated payouts.
|
|
||||||
- Reputation gates on higher-tier cars.
|
|
||||||
- Daily login reward.
|
|
||||||
- Robust saving via ProfileStore (session-locked, reconciling).
|
|
||||||
- Basic movement anti-cheat.
|
|
||||||
|
|
||||||
## Deliberately deferred (see ROADMAP)
|
## Cars in the picker
|
||||||
Traffic AI, head-to-head/drag racing, car customization cosmetics, crews/PvP,
|
RX-7 (restricted), City Runabout (public default), Silvia S15, Skyline GT-R R34,
|
||||||
full city map, mobile-tuned optimization pass, monetization products.
|
and a stylized hypercar. Stats (Speed/Accel/Handling, 0-100) only affect driving
|
||||||
|
feel — there are no prices or unlocks in the MVP.
|
||||||
## Economy first-pass numbers (tune freely in `GameConfig`)
|
|
||||||
- Start: $1,500, 0 rep, own the RX-7.
|
|
||||||
- Race finish: +$650 (base+win) and +15 rep.
|
|
||||||
- Daily: +$300, +10 rep.
|
|
||||||
- Upgrades: base $500, ×1.6 per level, cap level 5 per part.
|
|
||||||
- Car tiers: Junk $800 → S15 $12k (50 rep) → R34 $45k (180 rep) → Hyper $250k (600 rep).
|
|
||||||
|
|
||||||
## Win condition / retention
|
|
||||||
No hard "end" — the chase up the tiers plus daily rewards and (future) leaderboards
|
|
||||||
supply the long tail. First-session goal: earn enough to feel one upgrade land
|
|
||||||
inside the first 10 minutes.
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Swapping the placeholder RX-7 for the real mesh (M1)
|
||||||
|
|
||||||
|
Right now every car is a coloured block + a `VehicleSeat` so the game is drivable
|
||||||
|
today. This is the **one file** you touch to make the RX-7 a real model:
|
||||||
|
`src/server/CarManager.luau` → the `buildCarModel` function.
|
||||||
|
|
||||||
|
Nothing else (saving, the allowlist, car switching, the map, anti-cheat) needs to
|
||||||
|
change. That's the whole point of isolating model-building in one function.
|
||||||
|
|
||||||
|
## Reference photos
|
||||||
|
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/
|
||||||
|
```
|
||||||
|
|
||||||
|
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
||||||
|
for quick lookups.
|
||||||
|
|
||||||
|
## Step 1 — Get a model into Roblox
|
||||||
|
Options, easiest first:
|
||||||
|
1. **Buy or find a free RX-7 FD model** on the Roblox Creator Marketplace/toolbox,
|
||||||
|
or
|
||||||
|
2. **Model it** in Blender from the reference photos and import the mesh
|
||||||
|
(`.fbx`/`.obj`) via Studio's 3D importer, or
|
||||||
|
3. Commission one.
|
||||||
|
|
||||||
|
You want a single `Model` whose parts are welded together, with a clear "body"
|
||||||
|
part you can drive. Keep the part count low (mobile perf).
|
||||||
|
|
||||||
|
## Step 2 — Store it where Rojo/Studio can clone it
|
||||||
|
Put the finished `Model` under `ReplicatedStorage` (e.g. a `CarModels` folder).
|
||||||
|
Two ways to manage it:
|
||||||
|
- **Simple:** keep it in the Studio place file as a real Instance (not synced by
|
||||||
|
Rojo). Fine for a single hero car.
|
||||||
|
- **Version-controlled:** export the model to a `.rbxm` and reference it from the
|
||||||
|
Rojo project so it lives in git. See Rojo's `$path` docs for `.rbxm` model files.
|
||||||
|
|
||||||
|
## Step 3 — Rewrite `buildCarModel` to clone the real mesh
|
||||||
|
Replace the block-building body of `buildCarModel(car, maxSpeed, torque, turnSpeed)`
|
||||||
|
with something like:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local CAR_MODELS = ReplicatedStorage:WaitForChild("CarModels")
|
||||||
|
|
||||||
|
local function buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||||
|
-- Look up a real model by car id; fall back to the block builder if missing.
|
||||||
|
local template = CAR_MODELS:FindFirstChild(car.Id)
|
||||||
|
if not template then
|
||||||
|
return buildBlockCar(car, maxSpeed, torque, turnSpeed) -- keep the old code as a fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local model = template:Clone()
|
||||||
|
|
||||||
|
-- Ensure it has a VehicleSeat named "DriveSeat" welded to the driving body.
|
||||||
|
local seat = model:FindFirstChild("DriveSeat")
|
||||||
|
if not seat then
|
||||||
|
seat = Instance.new("VehicleSeat")
|
||||||
|
seat.Name = "DriveSeat"
|
||||||
|
-- weld seat to model.PrimaryPart, position it in the cabin...
|
||||||
|
seat.Parent = model
|
||||||
|
end
|
||||||
|
seat.MaxSpeed = maxSpeed
|
||||||
|
seat.Torque = torque
|
||||||
|
seat.TurnSpeed = turnSpeed
|
||||||
|
|
||||||
|
assert(model.PrimaryPart, "Car model needs a PrimaryPart set")
|
||||||
|
return model
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the current block-builder renamed as `buildBlockCar` so any car without a
|
||||||
|
real mesh still works. That way you can import cars one at a time.
|
||||||
|
|
||||||
|
## Step 4 (recommended, M1) — Move to a real chassis
|
||||||
|
The `VehicleSeat` is great for "it drives today" but for real suspension/grip,
|
||||||
|
adopt a proven **constraint chassis** (A-Chassis or a modern equivalent). At that
|
||||||
|
point `buildCarModel` clones the chassis rig and attaches the RX-7 body to it.
|
||||||
|
Still one function; still no changes elsewhere.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
- `rojo build --output build.rbxl` compiles.
|
||||||
|
- In Studio: Play → you spawn in the real RX-7 → it drives → it saves.
|
||||||
|
- Non-allowlisted test account still can't select the RX-7 (server enforced).
|
||||||
+40
-42
@@ -1,62 +1,60 @@
|
|||||||
# Roadmap — realistic solo-dev plan
|
# Roadmap — realistic solo-dev plan
|
||||||
|
|
||||||
This replaces the original doc's optimistic 12-week timeline. It assumes **one
|
Assumes **one part-time developer learning as they go**. The MVP is intentionally
|
||||||
part-time developer learning as they go**. Ship the vertical slice, then layer
|
tiny — *just driving* — so there's a fun, testable thing FAST. Everything else is
|
||||||
polish. Each milestone is independently playable/testable.
|
layered on only after the driving feels good.
|
||||||
|
|
||||||
> Rule of thumb: the original guide's "weeks" are really "milestones." Expect
|
> Expect **months, not weeks** to a polished full game. That's normal. This order
|
||||||
> **4–8 months** of part-time work to a polished, launch-ready MVP. That's normal.
|
> guarantees you always have something that runs.
|
||||||
> The goal of this order is that you always have something that *runs*.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ M0 — Vertical slice (DONE — this repo)
|
## ✅ M0 — Drive-only MVP (DONE — this repo)
|
||||||
Drive → race → earn → buy/upgrade → **save**. Server-authoritative. Placeholder
|
Spawn on the neon expressway, drive a car, switch cars, respawn. RX-7 demo-locked
|
||||||
RX-7 block car, procedural expressway. Proves the whole stack end to end.
|
to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
|
||||||
|
|
||||||
## M1 — Make it feel good (the real work)
|
## M1 — Make driving feel GOOD (the real work, do this next)
|
||||||
- [ ] Import the **real RX-7 mesh**; swap `CarManager.buildCarModel` only.
|
- [ ] Import the **real RX-7 mesh**; swap `CarManager.buildCarModel` only.
|
||||||
- [ ] Adopt a proven **constraint chassis** (A-Chassis or modern equivalent) for
|
See `docs/MESH_SWAP.md` for the exact steps.
|
||||||
real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll physics.
|
- [ ] Adopt a proven **constraint chassis** (A-Chassis or a modern equivalent)
|
||||||
- [ ] Tune driving feel: acceleration curve, grip, camera.
|
for real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll
|
||||||
- [ ] Sound: engine loop, boost, tire screech.
|
physics.
|
||||||
**Exit test:** the RX-7 is genuinely fun to just drive around.
|
- [ ] Tune acceleration curve, grip, and the chase camera.
|
||||||
|
- [ ] Sound: engine loop, tire screech, boost.
|
||||||
|
**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else
|
||||||
|
until this is true.
|
||||||
|
|
||||||
## M2 — Race depth
|
## M2 — More cars + polish (still no racing)
|
||||||
- [ ] Checkpoints + lap/segment timing (extend `RaceManager`).
|
- [ ] Import a few more real car meshes.
|
||||||
- [ ] Personal best times saved to profile.
|
- [ ] Better map: more expressway sections, ramps, tunnels, scenery.
|
||||||
- [ ] A second race type (drag / point-to-point).
|
- [ ] Day/night, weather mood.
|
||||||
- [ ] Countdown + results screen.
|
|
||||||
|
|
||||||
## M3 — Content & progression polish
|
## M3 — Racing (the first "game" layer)
|
||||||
- [ ] Flesh out the 25-car catalogue across tiers.
|
- [ ] Checkpoints + timing (a new RaceManager — the old one was removed in M0).
|
||||||
- [ ] Car customization cosmetics (rims, wraps, neon) — visual only for MVP.
|
- [ ] Personal best times.
|
||||||
- [ ] Daily missions (not just login): "win 3 races", "earn $5k".
|
- [ ] Simple point-to-point and drag modes.
|
||||||
- [ ] Tutorial for the first 10 minutes.
|
|
||||||
|
|
||||||
## M4 — Systems hardening
|
## M4 — Progression & economy (only once racing is fun)
|
||||||
- [ ] Leaderboards (OrderedDataStore: top rep, fastest times).
|
- [ ] Money from races, a garage, buying/unlocking cars, upgrades, reputation.
|
||||||
- [ ] Anti-cheat pass 2 (validate race times against car stats).
|
(These systems existed as scaffolding and were cut for the MVP; reintroduce
|
||||||
- [ ] DataStore stress test / backup-version restore drill.
|
them deliberately when they have a purpose.)
|
||||||
- [ ] Performance pass: StreamingEnabled tuning, part budget, mobile test.
|
|
||||||
|
|
||||||
## M5 — Monetization (only after it's fun)
|
## M5 — Systems hardening & monetization
|
||||||
- [ ] 2× Cash gamepass (the classic first product).
|
- [ ] Leaderboards, anti-cheat pass 2, DataStore backup drill.
|
||||||
- [ ] VIP membership (daily bonus, cosmetic-only exclusives).
|
- [ ] Performance pass (StreamingEnabled, part budget, mobile).
|
||||||
- [ ] Nitro/boost dev-products — **never** pay-to-win on ranked races.
|
- [ ] Fair gamepasses/products — never pay-to-win.
|
||||||
- [ ] Receipt handling via ProfileStore `LastSavedData`.
|
|
||||||
|
|
||||||
## M6 — Traffic AI (deferred hardest system)
|
## M6 — Traffic AI (deferred hardest system)
|
||||||
- [ ] Start with **spline-following** dummy cars on fixed paths (cheap, no lag).
|
- [ ] Spline-following dummy cars first (cheap). True AI only if perf allows.
|
||||||
Only attempt true pathfinding AI if performance allows.
|
|
||||||
- [ ] Spawn/despawn by distance to keep part count sane.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Standing engineering rules
|
## Standing engineering rules
|
||||||
- Every change must pass `stylua --check`, `selene`, and `rojo build` (CI enforces).
|
- Every change must pass `stylua --check`, `selene`, and `rojo build` — CI on the
|
||||||
- Keep the client dumb: it requests, the server decides. No exceptions for money.
|
self-hosted Gitea runner enforces all three on every push.
|
||||||
- Add new tunables to `GameConfig`, not scattered magic numbers.
|
- Keep the client dumb: it requests, the server decides. The RX-7 allowlist and
|
||||||
|
anti-cheat live server-side.
|
||||||
|
- Add tunables to `GameConfig`, not scattered magic numbers.
|
||||||
- Add a save key by adding it to `ProfileTemplate` (auto-reconciles); never remove
|
- Add a save key by adding it to `ProfileTemplate` (auto-reconciles); never remove
|
||||||
a key without a migration.
|
a key without a migration (bump `ProfileStoreName` `_vN` in dev to wipe).
|
||||||
- Test in Studio after every server change (ProfileStore uses a mock store there).
|
- Test in Studio after every server change (ProfileStore uses a mock store there).
|
||||||
|
|||||||
+67
-171
@@ -1,10 +1,12 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
init.client.luau (CLIENT ENTRY POINT)
|
init.client.luua (CLIENT ENTRY POINT)
|
||||||
Builds the HUD (cash / rep / car), a Garage+Dealership panel, and a Daily
|
MVP scope: JUST DRIVING. Minimal UI:
|
||||||
button — all in code so there's nothing to build in Studio. The client is
|
- A small HUD showing the current car name.
|
||||||
purely presentational: it shows server-provided data and sends requests.
|
- A "CARS" button opening a picker to switch cars (RX-7 shown only if you
|
||||||
It never decides money or ownership.
|
can drive it — the server enforces this regardless).
|
||||||
|
- A "RESPAWN CAR" button to re-spawn your car if you flip or fall off.
|
||||||
|
The client is presentational; the server owns everything.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
@@ -17,17 +19,11 @@ local Cars = require(Shared.Cars)
|
|||||||
local player = Players.LocalPlayer
|
local player = Players.LocalPlayer
|
||||||
local playerGui = player:WaitForChild("PlayerGui")
|
local playerGui = player:WaitForChild("PlayerGui")
|
||||||
|
|
||||||
-- Remotes -------------------------------------------------------------------
|
|
||||||
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 rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
|
|
||||||
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
|
|
||||||
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
|
|
||||||
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
|
|
||||||
|
|
||||||
-- Local cache of the latest server snapshot ---------------------------------
|
local currentCarId = ""
|
||||||
local latest: any = nil
|
|
||||||
|
|
||||||
-- UI helpers ----------------------------------------------------------------
|
-- UI helpers ----------------------------------------------------------------
|
||||||
local NEON_PINK = Color3.fromRGB(255, 40, 120)
|
local NEON_PINK = Color3.fromRGB(255, 40, 120)
|
||||||
@@ -55,10 +51,10 @@ local screen = make("ScreenGui", {
|
|||||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||||
}, playerGui)
|
}, playerGui)
|
||||||
|
|
||||||
-- ===== HUD (top-left): cash / rep / car ====================================
|
-- HUD: current car ----------------------------------------------------------
|
||||||
local hud = make("Frame", {
|
local hud = make("Frame", {
|
||||||
Name = "HUD",
|
Name = "HUD",
|
||||||
Size = UDim2.fromOffset(260, 96),
|
Size = UDim2.fromOffset(260, 52),
|
||||||
Position = UDim2.fromOffset(16, 16),
|
Position = UDim2.fromOffset(16, 16),
|
||||||
BackgroundColor3 = PANEL_BG,
|
BackgroundColor3 = PANEL_BG,
|
||||||
BackgroundTransparency = 0.15,
|
BackgroundTransparency = 0.15,
|
||||||
@@ -67,44 +63,22 @@ local hud = make("Frame", {
|
|||||||
corner(hud, 10)
|
corner(hud, 10)
|
||||||
make("UIStroke", { Color = NEON_PINK, Thickness = 1.5, Transparency = 0.3 }, hud)
|
make("UIStroke", { Color = NEON_PINK, Thickness = 1.5, Transparency = 0.3 }, hud)
|
||||||
|
|
||||||
local cashLabel = make("TextLabel", {
|
local carLabel = make("TextLabel", {
|
||||||
Size = UDim2.new(1, -20, 0, 28),
|
Size = UDim2.new(1, -20, 1, 0),
|
||||||
Position = UDim2.fromOffset(12, 8),
|
Position = UDim2.fromOffset(12, 0),
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
Font = Enum.Font.GothamBold,
|
Font = Enum.Font.GothamBold,
|
||||||
TextColor3 = Color3.fromRGB(120, 255, 170),
|
TextColor3 = Color3.fromRGB(230, 230, 240),
|
||||||
TextXAlignment = Enum.TextXAlignment.Left,
|
|
||||||
TextSize = 20,
|
|
||||||
Text = "$ --",
|
|
||||||
}, hud)
|
|
||||||
|
|
||||||
local repLabel = make("TextLabel", {
|
|
||||||
Size = UDim2.new(1, -20, 0, 22),
|
|
||||||
Position = UDim2.fromOffset(12, 38),
|
|
||||||
BackgroundTransparency = 1,
|
|
||||||
Font = Enum.Font.GothamMedium,
|
|
||||||
TextColor3 = NEON_BLUE,
|
|
||||||
TextXAlignment = Enum.TextXAlignment.Left,
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
TextSize = 16,
|
TextSize = 16,
|
||||||
Text = "REP --",
|
|
||||||
}, hud)
|
|
||||||
|
|
||||||
local carLabel = make("TextLabel", {
|
|
||||||
Size = UDim2.new(1, -20, 0, 20),
|
|
||||||
Position = UDim2.fromOffset(12, 64),
|
|
||||||
BackgroundTransparency = 1,
|
|
||||||
Font = Enum.Font.Gotham,
|
|
||||||
TextColor3 = Color3.fromRGB(220, 220, 230),
|
|
||||||
TextXAlignment = Enum.TextXAlignment.Left,
|
|
||||||
TextSize = 14,
|
|
||||||
Text = "Car: --",
|
Text = "Car: --",
|
||||||
}, hud)
|
}, hud)
|
||||||
|
|
||||||
-- ===== Bottom action bar ===================================================
|
-- Bottom action bar ---------------------------------------------------------
|
||||||
local bar = make("Frame", {
|
local bar = make("Frame", {
|
||||||
Name = "ActionBar",
|
Name = "ActionBar",
|
||||||
Size = UDim2.fromOffset(430, 52),
|
Size = UDim2.fromOffset(230, 52),
|
||||||
Position = UDim2.new(0.5, -215, 1, -70),
|
Position = UDim2.new(0.5, -115, 1, -70),
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
}, screen)
|
}, screen)
|
||||||
make("UIListLayout", {
|
make("UIListLayout", {
|
||||||
@@ -116,7 +90,7 @@ make("UIListLayout", {
|
|||||||
|
|
||||||
local function button(text: string, color: Color3, order: number): TextButton
|
local function button(text: string, color: Color3, order: number): TextButton
|
||||||
local b = make("TextButton", {
|
local b = make("TextButton", {
|
||||||
Size = UDim2.fromOffset(100, 44),
|
Size = UDim2.fromOffset(105, 44),
|
||||||
BackgroundColor3 = PANEL_BG,
|
BackgroundColor3 = PANEL_BG,
|
||||||
BackgroundTransparency = 0.1,
|
BackgroundTransparency = 0.1,
|
||||||
Font = Enum.Font.GothamBold,
|
Font = Enum.Font.GothamBold,
|
||||||
@@ -132,12 +106,10 @@ local function button(text: string, color: Color3, order: number): TextButton
|
|||||||
return b
|
return b
|
||||||
end
|
end
|
||||||
|
|
||||||
local garageBtn = button("GARAGE", NEON_BLUE, 1)
|
local carsBtn = button("CARS", NEON_BLUE, 1)
|
||||||
local spawnBtn = button("SPAWN CAR", Color3.fromRGB(120, 255, 170), 2)
|
local respawnBtn = button("RESPAWN", Color3.fromRGB(120, 255, 170), 2)
|
||||||
local raceBtn = button("RACE", NEON_PINK, 3)
|
|
||||||
local dailyBtn = button("DAILY", Color3.fromRGB(255, 210, 80), 4)
|
|
||||||
|
|
||||||
-- ===== Toast (transient messages) ==========================================
|
-- Toast ---------------------------------------------------------------------
|
||||||
local toast = make("TextLabel", {
|
local toast = make("TextLabel", {
|
||||||
Name = "Toast",
|
Name = "Toast",
|
||||||
Size = UDim2.fromOffset(360, 34),
|
Size = UDim2.fromOffset(360, 34),
|
||||||
@@ -159,25 +131,25 @@ local function showToast(msg: string)
|
|||||||
toast.Visible = true
|
toast.Visible = true
|
||||||
toastToken += 1
|
toastToken += 1
|
||||||
local myToken = toastToken
|
local myToken = toastToken
|
||||||
task.delay(2.6, function()
|
task.delay(2.4, function()
|
||||||
if toastToken == myToken then
|
if toastToken == myToken then
|
||||||
toast.Visible = false
|
toast.Visible = false
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ===== Garage / Dealership panel ===========================================
|
-- Car picker panel ----------------------------------------------------------
|
||||||
local garage = make("Frame", {
|
local picker = make("Frame", {
|
||||||
Name = "Garage",
|
Name = "CarPicker",
|
||||||
Size = UDim2.fromOffset(560, 420),
|
Size = UDim2.fromOffset(520, 400),
|
||||||
Position = UDim2.new(0.5, -280, 0.5, -210),
|
Position = UDim2.new(0.5, -260, 0.5, -200),
|
||||||
BackgroundColor3 = PANEL_BG,
|
BackgroundColor3 = PANEL_BG,
|
||||||
BackgroundTransparency = 0.05,
|
BackgroundTransparency = 0.05,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
BorderSizePixel = 0,
|
BorderSizePixel = 0,
|
||||||
}, screen)
|
}, screen)
|
||||||
corner(garage, 12)
|
corner(picker, 12)
|
||||||
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, garage)
|
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, picker)
|
||||||
|
|
||||||
make("TextLabel", {
|
make("TextLabel", {
|
||||||
Size = UDim2.new(1, 0, 0, 40),
|
Size = UDim2.new(1, 0, 0, 40),
|
||||||
@@ -185,8 +157,8 @@ make("TextLabel", {
|
|||||||
Font = Enum.Font.GothamBold,
|
Font = Enum.Font.GothamBold,
|
||||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||||
TextSize = 20,
|
TextSize = 20,
|
||||||
Text = "GARAGE & DEALERSHIP",
|
Text = "PICK A CAR",
|
||||||
}, garage)
|
}, picker)
|
||||||
|
|
||||||
local closeBtn = make("TextButton", {
|
local closeBtn = make("TextButton", {
|
||||||
Size = UDim2.fromOffset(32, 32),
|
Size = UDim2.fromOffset(32, 32),
|
||||||
@@ -197,7 +169,7 @@ local closeBtn = make("TextButton", {
|
|||||||
TextSize = 18,
|
TextSize = 18,
|
||||||
Text = "X",
|
Text = "X",
|
||||||
BorderSizePixel = 0,
|
BorderSizePixel = 0,
|
||||||
}, garage)
|
}, picker)
|
||||||
corner(closeBtn, 8)
|
corner(closeBtn, 8)
|
||||||
|
|
||||||
local list = make("ScrollingFrame", {
|
local list = make("ScrollingFrame", {
|
||||||
@@ -208,36 +180,27 @@ local list = make("ScrollingFrame", {
|
|||||||
ScrollBarThickness = 6,
|
ScrollBarThickness = 6,
|
||||||
CanvasSize = UDim2.new(0, 0, 0, 0),
|
CanvasSize = UDim2.new(0, 0, 0, 0),
|
||||||
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
||||||
}, garage)
|
}, picker)
|
||||||
make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, list)
|
make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, list)
|
||||||
|
|
||||||
-- Build one row per car; content depends on owned/equipped/affordable --------
|
local function rebuildPicker()
|
||||||
local function rebuildGarage()
|
|
||||||
if not latest then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
for _, child in ipairs(list:GetChildren()) do
|
for _, child in ipairs(list:GetChildren()) do
|
||||||
if child:IsA("Frame") then
|
if child:IsA("Frame") then
|
||||||
child:Destroy()
|
child:Destroy()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local ownedIds: { [string]: any } = {}
|
|
||||||
for _, o in ipairs(latest.OwnedCars) do
|
|
||||||
ownedIds[o.Id] = o
|
|
||||||
end
|
|
||||||
|
|
||||||
for i, car in ipairs(Cars.List) do
|
for i, car in ipairs(Cars.List) do
|
||||||
local owned = ownedIds[car.Id]
|
-- Hide the restricted RX-7 unless it's the car we're currently driving
|
||||||
local equipped = latest.EquippedCarId == car.Id
|
-- (i.e. the server already granted it to us). The server enforces this
|
||||||
|
-- regardless of what the client shows.
|
||||||
-- Hide restricted (allowlist-only) cars from anyone who doesn't own them.
|
if car.Restricted and currentCarId ~= car.Id then
|
||||||
if car.Restricted and not owned then
|
|
||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local isCurrent = currentCarId == car.Id
|
||||||
local row = make("Frame", {
|
local row = make("Frame", {
|
||||||
Size = UDim2.new(1, 0, 0, 72),
|
Size = UDim2.new(1, 0, 0, 64),
|
||||||
BackgroundColor3 = Color3.fromRGB(26, 26, 38),
|
BackgroundColor3 = Color3.fromRGB(26, 26, 38),
|
||||||
BorderSizePixel = 0,
|
BorderSizePixel = 0,
|
||||||
LayoutOrder = i,
|
LayoutOrder = i,
|
||||||
@@ -245,7 +208,7 @@ local function rebuildGarage()
|
|||||||
corner(row, 8)
|
corner(row, 8)
|
||||||
|
|
||||||
make("TextLabel", {
|
make("TextLabel", {
|
||||||
Size = UDim2.new(0.6, 0, 0, 22),
|
Size = UDim2.new(0.62, 0, 0, 22),
|
||||||
Position = UDim2.fromOffset(12, 8),
|
Position = UDim2.fromOffset(12, 8),
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
Font = Enum.Font.GothamBold,
|
Font = Enum.Font.GothamBold,
|
||||||
@@ -256,7 +219,7 @@ local function rebuildGarage()
|
|||||||
}, row)
|
}, row)
|
||||||
|
|
||||||
make("TextLabel", {
|
make("TextLabel", {
|
||||||
Size = UDim2.new(0.6, 0, 0, 18),
|
Size = UDim2.new(0.62, 0, 0, 16),
|
||||||
Position = UDim2.fromOffset(12, 32),
|
Position = UDim2.fromOffset(12, 32),
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
Font = Enum.Font.Gotham,
|
Font = Enum.Font.Gotham,
|
||||||
@@ -272,125 +235,58 @@ local function rebuildGarage()
|
|||||||
),
|
),
|
||||||
}, row)
|
}, row)
|
||||||
|
|
||||||
-- Primary action button (Equip / Buy / locked) --------------------------
|
|
||||||
local action = make("TextButton", {
|
local action = make("TextButton", {
|
||||||
Size = UDim2.fromOffset(120, 30),
|
Size = UDim2.fromOffset(120, 34),
|
||||||
Position = UDim2.new(1, -132, 0, 10),
|
Position = UDim2.new(1, -132, 0.5, -17),
|
||||||
Font = Enum.Font.GothamBold,
|
Font = Enum.Font.GothamBold,
|
||||||
TextSize = 13,
|
TextSize = 14,
|
||||||
BorderSizePixel = 0,
|
BorderSizePixel = 0,
|
||||||
}, row)
|
}, row)
|
||||||
corner(action, 6)
|
corner(action, 6)
|
||||||
|
|
||||||
if owned then
|
if isCurrent then
|
||||||
if equipped then
|
action.Text = "DRIVING"
|
||||||
action.Text = "EQUIPPED"
|
|
||||||
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
|
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
|
||||||
action.TextColor3 = Color3.fromRGB(120, 255, 170)
|
action.TextColor3 = Color3.fromRGB(120, 255, 170)
|
||||||
else
|
else
|
||||||
action.Text = "EQUIP"
|
action.Text = "DRIVE"
|
||||||
action.BackgroundColor3 = NEON_BLUE
|
action.BackgroundColor3 = NEON_BLUE
|
||||||
action.TextColor3 = Color3.fromRGB(0, 0, 0)
|
action.TextColor3 = Color3.fromRGB(0, 0, 0)
|
||||||
action.MouseButton1Click:Connect(function()
|
action.MouseButton1Click:Connect(function()
|
||||||
local ok = rEquipCar:InvokeServer(car.Id)
|
local ok = rEquipCar:InvokeServer(car.Id)
|
||||||
if ok then
|
if ok then
|
||||||
showToast("Equipped " .. car.Name)
|
showToast("Now driving " .. car.Name)
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
local affordable = latest.Cash >= car.Price and latest.Rep >= car.RepRequired
|
showToast("Can't drive that one")
|
||||||
action.Text = string.format("BUY $%d", car.Price)
|
|
||||||
action.BackgroundColor3 = affordable and Color3.fromRGB(120, 255, 170) or Color3.fromRGB(60, 40, 48)
|
|
||||||
action.TextColor3 = affordable and Color3.fromRGB(0, 0, 0) or Color3.fromRGB(200, 120, 130)
|
|
||||||
action.MouseButton1Click:Connect(function()
|
|
||||||
local res = rBuyCar:InvokeServer(car.Id)
|
|
||||||
if res and res.ok then
|
|
||||||
showToast("Purchased " .. car.Name .. "!")
|
|
||||||
elseif res then
|
|
||||||
showToast(res.reason or "Cannot buy")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Upgrade button (only for owned cars) ----------------------------------
|
|
||||||
if owned then
|
|
||||||
local upg = make("TextButton", {
|
|
||||||
Size = UDim2.fromOffset(120, 24),
|
|
||||||
Position = UDim2.new(1, -132, 0, 42),
|
|
||||||
Font = Enum.Font.GothamMedium,
|
|
||||||
TextSize = 12,
|
|
||||||
BackgroundColor3 = Color3.fromRGB(45, 45, 60),
|
|
||||||
TextColor3 = Color3.fromRGB(255, 210, 80),
|
|
||||||
BorderSizePixel = 0,
|
|
||||||
Text = string.format(
|
|
||||||
"UPGRADE (E%d/T%d/N%d)",
|
|
||||||
owned.Upgrades.Engine,
|
|
||||||
owned.Upgrades.Tires,
|
|
||||||
owned.Upgrades.Nitro
|
|
||||||
),
|
|
||||||
}, row)
|
|
||||||
corner(upg, 6)
|
|
||||||
upg.MouseButton1Click:Connect(function()
|
|
||||||
-- MVP: cycle Engine -> Tires -> Nitro whichever is lowest.
|
|
||||||
local parts = { "Engine", "Tires", "Nitro" }
|
|
||||||
table.sort(parts, function(a, b)
|
|
||||||
return owned.Upgrades[a] < owned.Upgrades[b]
|
|
||||||
end)
|
|
||||||
local res = rUpgradeCar:InvokeServer(car.Id, parts[1])
|
|
||||||
if res and res.ok then
|
|
||||||
showToast("Upgraded " .. parts[1] .. "!")
|
|
||||||
elseif res then
|
|
||||||
showToast(res.reason or "Cannot upgrade")
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ===== Data sync ===========================================================
|
-- Data sync -----------------------------------------------------------------
|
||||||
local function applyData(data: any)
|
rDataUpdated.OnClientEvent:Connect(function(carId: string)
|
||||||
latest = data
|
currentCarId = carId
|
||||||
cashLabel.Text = string.format("$ %s", tostring(data.Cash))
|
local car = Cars.Get(carId)
|
||||||
repLabel.Text = string.format("REP %s", tostring(data.Rep))
|
carLabel.Text = "Car: " .. (car and car.Name or carId)
|
||||||
local car = Cars.Get(data.EquippedCarId)
|
if picker.Visible then
|
||||||
carLabel.Text = "Car: " .. (car and car.Name or data.EquippedCarId)
|
rebuildPicker()
|
||||||
if garage.Visible then
|
|
||||||
rebuildGarage()
|
|
||||||
end
|
end
|
||||||
end
|
end)
|
||||||
|
|
||||||
rDataUpdated.OnClientEvent:Connect(applyData)
|
-- Buttons -------------------------------------------------------------------
|
||||||
|
carsBtn.MouseButton1Click:Connect(function()
|
||||||
-- ===== Wire buttons ========================================================
|
picker.Visible = not picker.Visible
|
||||||
garageBtn.MouseButton1Click:Connect(function()
|
if picker.Visible then
|
||||||
garage.Visible = not garage.Visible
|
rebuildPicker()
|
||||||
if garage.Visible then
|
|
||||||
rebuildGarage()
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
closeBtn.MouseButton1Click:Connect(function()
|
closeBtn.MouseButton1Click:Connect(function()
|
||||||
garage.Visible = false
|
picker.Visible = false
|
||||||
end)
|
end)
|
||||||
spawnBtn.MouseButton1Click:Connect(function()
|
respawnBtn.MouseButton1Click:Connect(function()
|
||||||
rSpawnCar:FireServer()
|
rSpawnCar:FireServer()
|
||||||
showToast("Spawning your car...")
|
showToast("Respawning your car...")
|
||||||
end)
|
|
||||||
raceBtn.MouseButton1Click:Connect(function()
|
|
||||||
local res = rStartRace:InvokeServer("expressway_timetrial")
|
|
||||||
if res and res.ok then
|
|
||||||
showToast("Race started! Reach the finish line!")
|
|
||||||
elseif res then
|
|
||||||
showToast(res.reason or "Cannot start race")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
dailyBtn.MouseButton1Click:Connect(function()
|
|
||||||
local res = rClaimDaily:InvokeServer()
|
|
||||||
if res and res.ok then
|
|
||||||
showToast(string.format("Daily: +$%d, +%d rep!", res.cash or 0, res.rep or 0))
|
|
||||||
elseif res then
|
|
||||||
showToast(res.reason or "Already claimed")
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
print("[FnF] Client UI ready.")
|
print("[FnF] Client UI ready (drive-only MVP).")
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
the most reliable, least-buggy way to get a car moving on day one. When you
|
the most reliable, least-buggy way to get a car moving on day one. When you
|
||||||
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
|
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
|
||||||
you swap out `buildCarModel` and keep everything else.
|
you swap out `buildCarModel` and keep everything else.
|
||||||
|
|
||||||
Upgrades scale MaxSpeed / Torque / TurnSpeed so the upgrade economy is real.
|
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
@@ -20,10 +18,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|||||||
local Shared = ReplicatedStorage.Shared
|
local Shared = ReplicatedStorage.Shared
|
||||||
local Cars = require(Shared.Cars)
|
local Cars = require(Shared.Cars)
|
||||||
local GameConfig = require(Shared.GameConfig)
|
local GameConfig = require(Shared.GameConfig)
|
||||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
|
||||||
local DataManager = require(script.Parent.DataManager)
|
local DataManager = require(script.Parent.DataManager)
|
||||||
|
|
||||||
type ProfileData = ProfileTemplate.ProfileData
|
|
||||||
type Car = Cars.Car
|
type Car = Cars.Car
|
||||||
|
|
||||||
local CarManager = {}
|
local CarManager = {}
|
||||||
@@ -31,17 +27,11 @@ local CarManager = {}
|
|||||||
local activeCars: { [Player]: Model } = {}
|
local activeCars: { [Player]: Model } = {}
|
||||||
local carsFolder: Folder
|
local carsFolder: Folder
|
||||||
|
|
||||||
-- Convert a car's 0-100 stats + upgrade levels into VehicleSeat tuning --------
|
-- Convert a car's 0-100 stats into VehicleSeat tuning ------------------------
|
||||||
local function computeTuning(car: Car, upgrades: { Engine: number, Tires: number, Nitro: number })
|
local function computeTuning(car: Car)
|
||||||
local engineLvl = upgrades.Engine or 0
|
|
||||||
local tiresLvl = upgrades.Tires or 0
|
|
||||||
-- Base values derived from stats; upgrades add a percentage on top.
|
|
||||||
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
|
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
|
||||||
maxSpeed *= (1 + engineLvl * 0.08) -- +8% top speed per engine level
|
|
||||||
local torque = 8000 + car.Stats.Acceleration * 220
|
local torque = 8000 + car.Stats.Acceleration * 220
|
||||||
torque *= (1 + engineLvl * 0.10)
|
|
||||||
local turnSpeed = 20 + car.Stats.Handling * 0.35
|
local turnSpeed = 20 + car.Stats.Handling * 0.35
|
||||||
turnSpeed *= (1 + tiresLvl * 0.06) -- tires help turn-in
|
|
||||||
return maxSpeed, torque, turnSpeed
|
return maxSpeed, torque, turnSpeed
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,26 +116,14 @@ function CarManager.spawn(player: Player, carId: string?)
|
|||||||
if not car then
|
if not car then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- Defense in depth: never spawn the restricted RX-7 for a non-allowlisted user,
|
-- Restricted car (RX-7) can only be driven by allowlisted UserIds.
|
||||||
-- even if their save somehow contained it.
|
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
|
||||||
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
-- Only spawn cars the player owns (anti-cheat: no spawning unowned exotics).
|
|
||||||
local owned
|
|
||||||
for _, o in ipairs(data.OwnedCars) do
|
|
||||||
if o.Id == id then
|
|
||||||
owned = o
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not owned then
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
CarManager.despawn(player)
|
CarManager.despawn(player)
|
||||||
|
|
||||||
local maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades)
|
local maxSpeed, torque, turnSpeed = computeTuning(car)
|
||||||
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
|
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
|
||||||
model:PivotTo(spawnCFrame())
|
model:PivotTo(spawnCFrame())
|
||||||
model.Parent = carsFolder
|
model.Parent = carsFolder
|
||||||
|
|||||||
@@ -71,20 +71,14 @@ local function onPlayerAdded(player: Player)
|
|||||||
profile:AddUserId(player.UserId) -- GDPR-friendly association
|
profile:AddUserId(player.UserId) -- GDPR-friendly association
|
||||||
profile:Reconcile() -- fill missing template keys
|
profile:Reconcile() -- fill missing template keys
|
||||||
|
|
||||||
-- Grant the correct starter car for THIS player (RX-7 only for allowlisted
|
-- Assign the player's default car if they don't have one yet, and validate:
|
||||||
-- UserIds; everyone else gets the public starter). Idempotent: only grants
|
-- if their saved car is the restricted RX-7 but they're no longer allowlisted,
|
||||||
-- if the player has no cars yet (i.e. genuinely first join).
|
-- fall back to the public default so they can still drive.
|
||||||
local data = profile.Data
|
local data = profile.Data
|
||||||
if #data.OwnedCars == 0 then
|
if data.EquippedCarId == "" then
|
||||||
local starterId = GameConfig.starterCarFor(player.UserId)
|
data.EquippedCarId = GameConfig.defaultCarFor(player.UserId)
|
||||||
table.insert(data.OwnedCars, {
|
elseif data.EquippedCarId == GameConfig.RestrictedCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
||||||
Id = starterId,
|
data.EquippedCarId = GameConfig.PublicDefaultCarId
|
||||||
Upgrades = { Engine = 0, Tires = 0, Nitro = 0 },
|
|
||||||
})
|
|
||||||
data.EquippedCarId = starterId
|
|
||||||
elseif data.EquippedCarId == "" then
|
|
||||||
-- Safety: if somehow they own cars but have nothing equipped, equip the first.
|
|
||||||
data.EquippedCarId = data.OwnedCars[1].Id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
profile.OnSessionEnd:Connect(function()
|
profile.OnSessionEnd:Connect(function()
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
--!strict
|
|
||||||
--[[
|
|
||||||
Economy.luau (SERVER)
|
|
||||||
The ONLY place cash/rep changes. Everything is server-authoritative: clients
|
|
||||||
can request actions but never set their own money. After any change we push a
|
|
||||||
fresh snapshot to the owning client via the DataUpdated remote.
|
|
||||||
|
|
||||||
Also owns: car buying, upgrades, and daily rewards — because those are all
|
|
||||||
economic transactions that must be validated server-side.
|
|
||||||
]]
|
|
||||||
|
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
||||||
|
|
||||||
local Shared = ReplicatedStorage.Shared
|
|
||||||
local GameConfig = require(Shared.GameConfig)
|
|
||||||
local Cars = require(Shared.Cars)
|
|
||||||
local ProfileTemplate = require(Shared.ProfileTemplate)
|
|
||||||
|
|
||||||
local DataManager = require(script.Parent.DataManager)
|
|
||||||
|
|
||||||
type ProfileData = ProfileTemplate.ProfileData
|
|
||||||
type Result = { ok: boolean, reason: string? }
|
|
||||||
|
|
||||||
local Economy = {}
|
|
||||||
|
|
||||||
-- Upgrade tuning: cost scales, effect has diminishing returns -----------------
|
|
||||||
local MAX_UPGRADE_LEVEL = 5
|
|
||||||
local UPGRADE_BASE_COST = 500
|
|
||||||
local UPGRADE_COST_GROWTH = 1.6 -- each level costs 1.6x the previous
|
|
||||||
|
|
||||||
local function upgradeCost(currentLevel: number): number
|
|
||||||
return math.floor(UPGRADE_BASE_COST * (UPGRADE_COST_GROWTH ^ currentLevel))
|
|
||||||
end
|
|
||||||
Economy.upgradeCost = upgradeCost
|
|
||||||
Economy.MAX_UPGRADE_LEVEL = MAX_UPGRADE_LEVEL
|
|
||||||
|
|
||||||
-- Push a snapshot to the client (thin: just the data table) ------------------
|
|
||||||
local dataUpdatedRemote: RemoteEvent? = nil
|
|
||||||
function Economy._setRemote(remote: RemoteEvent)
|
|
||||||
dataUpdatedRemote = remote
|
|
||||||
end
|
|
||||||
|
|
||||||
local function pushUpdate(player: Player, data: ProfileData)
|
|
||||||
if dataUpdatedRemote then
|
|
||||||
dataUpdatedRemote:FireClient(player, data)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Economy.pushUpdate = pushUpdate
|
|
||||||
|
|
||||||
-- Core mutators --------------------------------------------------------------
|
|
||||||
function Economy.addCash(player: Player, amount: number): boolean
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
data.Cash = math.max(0, data.Cash + amount)
|
|
||||||
pushUpdate(player, data)
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
function Economy.addRep(player: Player, amount: number): boolean
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
data.Rep = math.max(0, data.Rep + amount)
|
|
||||||
pushUpdate(player, data)
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
local function ownsCar(data: ProfileData, carId: string): boolean
|
|
||||||
for _, owned in ipairs(data.OwnedCars) do
|
|
||||||
if owned.Id == carId then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
Economy.ownsCar = ownsCar
|
|
||||||
|
|
||||||
-- Buying a car ---------------------------------------------------------------
|
|
||||||
function Economy.buyCar(player: Player, carId: string): Result
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return { ok = false, reason = "Data not loaded" }
|
|
||||||
end
|
|
||||||
local car = Cars.Get(carId)
|
|
||||||
if not car then
|
|
||||||
return { ok = false, reason = "Unknown car" }
|
|
||||||
end
|
|
||||||
-- Restricted demo car: the RX-7 can only ever be owned by allowlisted UserIds.
|
|
||||||
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
|
|
||||||
return { ok = false, reason = "This car is not available" }
|
|
||||||
end
|
|
||||||
if ownsCar(data, carId) then
|
|
||||||
return { ok = false, reason = "You already own this car" }
|
|
||||||
end
|
|
||||||
if data.Rep < car.RepRequired then
|
|
||||||
return { ok = false, reason = string.format("Requires %d rep", car.RepRequired) }
|
|
||||||
end
|
|
||||||
if data.Cash < car.Price then
|
|
||||||
return { ok = false, reason = "Not enough cash" }
|
|
||||||
end
|
|
||||||
|
|
||||||
data.Cash -= car.Price
|
|
||||||
table.insert(data.OwnedCars, { Id = carId, Upgrades = { Engine = 0, Tires = 0, Nitro = 0 } })
|
|
||||||
pushUpdate(player, data)
|
|
||||||
return { ok = true }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Upgrading a car part -------------------------------------------------------
|
|
||||||
local VALID_PARTS = { Engine = true, Tires = true, Nitro = true }
|
|
||||||
|
|
||||||
function Economy.upgradeCar(player: Player, carId: string, part: string): Result
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return { ok = false, reason = "Data not loaded" }
|
|
||||||
end
|
|
||||||
if not VALID_PARTS[part] then
|
|
||||||
return { ok = false, reason = "Invalid part" }
|
|
||||||
end
|
|
||||||
local owned
|
|
||||||
for _, o in ipairs(data.OwnedCars) do
|
|
||||||
if o.Id == carId then
|
|
||||||
owned = o
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not owned then
|
|
||||||
return { ok = false, reason = "You don't own this car" }
|
|
||||||
end
|
|
||||||
local level = owned.Upgrades[part] or 0
|
|
||||||
if level >= MAX_UPGRADE_LEVEL then
|
|
||||||
return { ok = false, reason = "Already maxed" }
|
|
||||||
end
|
|
||||||
local cost = upgradeCost(level)
|
|
||||||
if data.Cash < cost then
|
|
||||||
return { ok = false, reason = "Not enough cash" }
|
|
||||||
end
|
|
||||||
|
|
||||||
data.Cash -= cost
|
|
||||||
owned.Upgrades[part] = level + 1
|
|
||||||
pushUpdate(player, data)
|
|
||||||
return { ok = true }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Daily reward ---------------------------------------------------------------
|
|
||||||
function Economy.claimDaily(player: Player): Result & { cash: number?, rep: number? }
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return { ok = false, reason = "Data not loaded" }
|
|
||||||
end
|
|
||||||
local now = os.time()
|
|
||||||
if now - data.LastDailyClaim < GameConfig.DailyCooldownSeconds then
|
|
||||||
local remaining = GameConfig.DailyCooldownSeconds - (now - data.LastDailyClaim)
|
|
||||||
local hours = math.ceil(remaining / 3600)
|
|
||||||
return { ok = false, reason = string.format("Come back in ~%dh", hours) }
|
|
||||||
end
|
|
||||||
data.LastDailyClaim = now
|
|
||||||
data.Cash += GameConfig.DailyRewardCash
|
|
||||||
data.Rep += GameConfig.DailyRewardRep
|
|
||||||
pushUpdate(player, data)
|
|
||||||
return { ok = true, cash = GameConfig.DailyRewardCash, rep = GameConfig.DailyRewardRep }
|
|
||||||
end
|
|
||||||
|
|
||||||
return Economy
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
--!strict
|
|
||||||
--[[
|
|
||||||
RaceManager.luau (SERVER)
|
|
||||||
MVP race: a solo time-trial down the expressway. Player starts a race, drives
|
|
||||||
to the FinishLine, and the SERVER validates the finish (touch of the real
|
|
||||||
finish part) before paying out. This keeps rewards server-authoritative — the
|
|
||||||
client can SAY it won, but it only gets paid if the server saw the touch.
|
|
||||||
|
|
||||||
This is intentionally the simplest thing that proves the race->reward->save
|
|
||||||
loop. Head-to-head, checkpoints, and drag mode come later.
|
|
||||||
]]
|
|
||||||
|
|
||||||
local Players = game:GetService("Players")
|
|
||||||
local Workspace = game:GetService("Workspace")
|
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
||||||
|
|
||||||
local Shared = ReplicatedStorage.Shared
|
|
||||||
local GameConfig = require(Shared.GameConfig)
|
|
||||||
local Economy = require(script.Parent.Economy)
|
|
||||||
local DataManager = require(script.Parent.DataManager)
|
|
||||||
|
|
||||||
local RaceManager = {}
|
|
||||||
|
|
||||||
type ActiveRace = {
|
|
||||||
startTime: number,
|
|
||||||
connection: RBXScriptConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
local active: { [Player]: ActiveRace } = {}
|
|
||||||
local RACE_TIMEOUT = 180 -- auto-cancel a race after 3 min
|
|
||||||
|
|
||||||
local function finishFor(player: Player, won: boolean)
|
|
||||||
local race = active[player]
|
|
||||||
if not race then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
race.connection:Disconnect()
|
|
||||||
active[player] = nil
|
|
||||||
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if not data then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
data.TotalRaces += 1
|
|
||||||
|
|
||||||
local payout = GameConfig.RaceBaseReward
|
|
||||||
if won then
|
|
||||||
payout += GameConfig.RaceWinBonus
|
|
||||||
data.Wins += 1
|
|
||||||
end
|
|
||||||
Economy.addCash(player, payout)
|
|
||||||
Economy.addRep(player, GameConfig.RaceRepReward)
|
|
||||||
-- addCash/addRep already push updates; TotalRaces/Wins ride along on next push.
|
|
||||||
Economy.pushUpdate(player, data)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Start a race for the player. Returns a Result.
|
|
||||||
function RaceManager.start(player: Player, _raceId: string?): { ok: boolean, reason: string? }
|
|
||||||
if active[player] then
|
|
||||||
return { ok = false, reason = "Already racing" }
|
|
||||||
end
|
|
||||||
local map = Workspace:FindFirstChild("Map")
|
|
||||||
local finish = map and map:FindFirstChild("FinishLine")
|
|
||||||
if not (finish and finish:IsA("BasePart")) then
|
|
||||||
return { ok = false, reason = "No finish line in map" }
|
|
||||||
end
|
|
||||||
|
|
||||||
local conn
|
|
||||||
conn = finish.Touched:Connect(function(hit)
|
|
||||||
-- Was this the racing player's car/character that touched the line?
|
|
||||||
local character = player.Character
|
|
||||||
local carModel = hit:FindFirstAncestorOfClass("Model")
|
|
||||||
local isOwnCar = carModel and carModel:GetAttribute("OwnerUserId") == player.UserId
|
|
||||||
local isSelf = character and hit:IsDescendantOf(character)
|
|
||||||
if isOwnCar or isSelf then
|
|
||||||
finishFor(player, true)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
active[player] = { startTime = os.clock(), connection = conn }
|
|
||||||
|
|
||||||
-- Timeout guard
|
|
||||||
task.delay(RACE_TIMEOUT, function()
|
|
||||||
if active[player] and os.clock() - active[player].startTime >= RACE_TIMEOUT then
|
|
||||||
-- cancel with no reward (won = false but we just clean up)
|
|
||||||
local race = active[player]
|
|
||||||
if race then
|
|
||||||
race.connection:Disconnect()
|
|
||||||
active[player] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
return { ok = true }
|
|
||||||
end
|
|
||||||
|
|
||||||
function RaceManager.init()
|
|
||||||
Players.PlayerRemoving:Connect(function(player)
|
|
||||||
local race = active[player]
|
|
||||||
if race then
|
|
||||||
race.connection:Disconnect()
|
|
||||||
active[player] = nil
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return RaceManager
|
|
||||||
+31
-59
@@ -1,13 +1,14 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
init.server.luau (SERVER ENTRY POINT)
|
init.server.luau (SERVER ENTRY POINT)
|
||||||
The one script Roblox runs on the server. It:
|
MVP scope: JUST DRIVING. The server:
|
||||||
1. Creates the remotes
|
1. Creates the remotes
|
||||||
2. Boots every manager in dependency order
|
2. Boots DataManager (safe saves), CarManager, AntiCheat
|
||||||
3. Builds the map + lighting mood
|
3. Builds the expressway map + lighting mood
|
||||||
4. Wires all remote handlers to server-authoritative logic
|
4. Gives each player their car and lets them switch cars
|
||||||
|
|
||||||
Everything downstream is server-authoritative: clients REQUEST, server DECIDES.
|
Server-authoritative: the client REQUESTS a car, the server DECIDES (and
|
||||||
|
enforces the RX-7 allowlist).
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
@@ -15,11 +16,11 @@ 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 GameConfig = require(Shared.GameConfig)
|
||||||
|
|
||||||
local DataManager = require(script.DataManager)
|
local DataManager = require(script.DataManager)
|
||||||
local Economy = require(script.Economy)
|
|
||||||
local CarManager = require(script.CarManager)
|
local CarManager = require(script.CarManager)
|
||||||
local RaceManager = require(script.RaceManager)
|
|
||||||
local MapBuilder = require(script.MapBuilder)
|
local MapBuilder = require(script.MapBuilder)
|
||||||
local AntiCheat = require(script.AntiCheat)
|
local AntiCheat = require(script.AntiCheat)
|
||||||
|
|
||||||
@@ -28,29 +29,28 @@ 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 rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
|
|
||||||
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
|
|
||||||
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
|
|
||||||
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
|
|
||||||
|
|
||||||
Economy._setRemote(rDataUpdated)
|
local function pushCar(player: Player)
|
||||||
|
local data = DataManager.get(player)
|
||||||
|
if data then
|
||||||
|
rDataUpdated:FireClient(player, data.EquippedCarId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- 2. Boot managers ----------------------------------------------------------
|
-- 2. Boot managers ----------------------------------------------------------
|
||||||
DataManager.init()
|
DataManager.init()
|
||||||
CarManager.init()
|
CarManager.init()
|
||||||
RaceManager.init()
|
|
||||||
AntiCheat.init()
|
AntiCheat.init()
|
||||||
|
|
||||||
-- 3. Build the world --------------------------------------------------------
|
-- 3. Build the world --------------------------------------------------------
|
||||||
MapBuilder.build()
|
MapBuilder.build()
|
||||||
|
|
||||||
-- 4. Player lifecycle: push initial data + auto-spawn their car -------------
|
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||||
Players.PlayerAdded:Connect(function(player)
|
Players.PlayerAdded:Connect(function(player)
|
||||||
DataManager.onReady(player, function(data)
|
DataManager.onReady(player, function()
|
||||||
Economy.pushUpdate(player, data)
|
pushCar(player)
|
||||||
end)
|
end)
|
||||||
player.CharacterAdded:Connect(function()
|
player.CharacterAdded:Connect(function()
|
||||||
-- When the character (re)spawns, give them their equipped car after a beat.
|
|
||||||
DataManager.onReady(player, function(data)
|
DataManager.onReady(player, function(data)
|
||||||
task.wait(1)
|
task.wait(1)
|
||||||
CarManager.spawn(player, data.EquippedCarId)
|
CarManager.spawn(player, data.EquippedCarId)
|
||||||
@@ -58,59 +58,31 @@ Players.PlayerAdded:Connect(function(player)
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- 5. Remote handlers (all validated server-side) ----------------------------
|
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||||
rSpawnCar.OnServerEvent:Connect(function(player, carId)
|
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||||
if carId ~= nil and typeof(carId) ~= "string" then
|
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||||
return
|
|
||||||
end
|
|
||||||
CarManager.spawn(player, carId)
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
rEquipCar.OnServerInvoke = function(player, carId)
|
rEquipCar.OnServerInvoke = function(player, carId)
|
||||||
if typeof(carId) ~= "string" then
|
if typeof(carId) ~= "string" then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
local car = Cars.Get(carId)
|
||||||
|
if not car then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- Enforce the RX-7 allowlist server-side.
|
||||||
|
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
local data = DataManager.get(player)
|
local data = DataManager.get(player)
|
||||||
if not data or not Economy.ownsCar(data, carId) then
|
if not data then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
data.EquippedCarId = carId
|
data.EquippedCarId = carId
|
||||||
Economy.pushUpdate(player, data)
|
pushCar(player)
|
||||||
CarManager.spawn(player, carId)
|
CarManager.spawn(player, carId)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
rBuyCar.OnServerInvoke = function(player, carId)
|
print("[FnF] Server booted (drive-only MVP): map built, car system online.")
|
||||||
if typeof(carId) ~= "string" then
|
|
||||||
return { ok = false, reason = "Bad request" }
|
|
||||||
end
|
|
||||||
return Economy.buyCar(player, carId)
|
|
||||||
end
|
|
||||||
|
|
||||||
rUpgradeCar.OnServerInvoke = function(player, carId, part)
|
|
||||||
if typeof(carId) ~= "string" or typeof(part) ~= "string" then
|
|
||||||
return { ok = false, reason = "Bad request" }
|
|
||||||
end
|
|
||||||
local result = Economy.upgradeCar(player, carId, part)
|
|
||||||
-- Re-spawn so upgraded tuning takes effect immediately if it's the active car.
|
|
||||||
if result.ok then
|
|
||||||
local data = DataManager.get(player)
|
|
||||||
if data and data.EquippedCarId == carId then
|
|
||||||
CarManager.spawn(player, carId)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
rClaimDaily.OnServerInvoke = function(player)
|
|
||||||
return Economy.claimDaily(player)
|
|
||||||
end
|
|
||||||
|
|
||||||
rStartRace.OnServerInvoke = function(player, raceId)
|
|
||||||
if raceId ~= nil and typeof(raceId) ~= "string" then
|
|
||||||
return { ok = false, reason = "Bad request" }
|
|
||||||
end
|
|
||||||
return RaceManager.start(player, raceId)
|
|
||||||
end
|
|
||||||
|
|
||||||
print("[FnF] Server booted: managers online, map built, remotes wired.")
|
|
||||||
|
|||||||
+12
-30
@@ -1,13 +1,11 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
Cars.luau
|
Cars.luau
|
||||||
The car catalogue. Table-driven so adding a car = adding a row.
|
The car catalogue. MVP scope (just driving): a car is an identity + driving
|
||||||
MVP ships with the RX-7 starter + a few tier examples so the dealership
|
feel + look. No prices, no rep gates, no upgrades. Add a car = add a row.
|
||||||
and progression have something to show. Expand freely later.
|
|
||||||
|
|
||||||
Stat scale: 0-100 (relative feel, not real units). Speed maps to top speed,
|
Stat scale 0-100 (relative feel): Speed = top speed, Acceleration = how fast
|
||||||
Acceleration to how fast it gets there, Handling to grip/turn-in.
|
it gets there, Handling = grip/turn-in. These drive the VehicleSeat tuning.
|
||||||
Price is in cash. RepRequired gates purchase behind reputation.
|
|
||||||
]]
|
]]
|
||||||
|
|
||||||
export type CarStats = {
|
export type CarStats = {
|
||||||
@@ -20,31 +18,24 @@ export type Car = {
|
|||||||
Id: string,
|
Id: string,
|
||||||
Name: string,
|
Name: string,
|
||||||
Model: string, -- real-world reference
|
Model: string, -- real-world reference
|
||||||
Tier: number, -- 1 Junk .. 5 Exotic
|
Tier: number, -- 1 Junk .. 5 Exotic (display/flavour only in MVP)
|
||||||
Price: number,
|
|
||||||
RepRequired: number,
|
|
||||||
Stats: CarStats,
|
Stats: CarStats,
|
||||||
Color: { number }, -- {r,g,b} 0-1 for the placeholder body until real mesh exists
|
Color: { number }, -- {r,g,b} 0-1 for the placeholder body until a real mesh exists
|
||||||
Description: string,
|
Description: string,
|
||||||
Starter: boolean?, -- true = granted free on first join
|
Restricted: boolean?, -- true = allowlist-only (the RX-7 demo lock)
|
||||||
Restricted: boolean?, -- true = allowlist-only; hidden from dealership for others
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local Cars = {}
|
local Cars = {}
|
||||||
|
|
||||||
-- Ordered list (dealership browses this in order) ---------------------------
|
|
||||||
Cars.List = {
|
Cars.List = {
|
||||||
{
|
{
|
||||||
Id = "rx7_fc",
|
Id = "rx7_fc",
|
||||||
Name = "Chick3n's RX-7",
|
Name = "Chick3n's RX-7",
|
||||||
Model = "1993 Mazda RX-7 (FD)",
|
Model = "1993 Mazda RX-7 (FD)",
|
||||||
Tier = 2,
|
Tier = 2,
|
||||||
Price = 0,
|
|
||||||
RepRequired = 0,
|
|
||||||
Stats = { Speed = 62, Acceleration = 58, Handling = 70 },
|
Stats = { Speed = 62, Acceleration = 58, Handling = 70 },
|
||||||
Color = { 0.85, 0.15, 0.12 }, -- classic RX-7 red until the real mesh is imported
|
Color = { 0.85, 0.15, 0.12 }, -- classic RX-7 red until the real mesh is imported
|
||||||
Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, and iconic. Your daily driver and first weapon on the expressway.",
|
Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, iconic.",
|
||||||
Starter = true,
|
|
||||||
Restricted = true, -- demo-locked to the owner + son during testing
|
Restricted = true, -- demo-locked to the owner + son during testing
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,48 +43,39 @@ Cars.List = {
|
|||||||
Name = "City Runabout",
|
Name = "City Runabout",
|
||||||
Model = "90s econobox hatchback",
|
Model = "90s econobox hatchback",
|
||||||
Tier = 1,
|
Tier = 1,
|
||||||
Price = 800,
|
|
||||||
RepRequired = 0,
|
|
||||||
Stats = { Speed = 34, Acceleration = 40, Handling = 48 },
|
Stats = { Speed = 34, Acceleration = 40, Handling = 48 },
|
||||||
Color = { 0.5, 0.5, 0.55 },
|
Color = { 0.5, 0.5, 0.55 },
|
||||||
Description = "Cheap, cheerful, and slow. A backup beater for when you want to grind pocket change.",
|
Description = "Cheap, cheerful, and slow. The public default cruiser.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Id = "silv_s15",
|
Id = "silv_s15",
|
||||||
Name = "Night Slider",
|
Name = "Night Slider",
|
||||||
Model = "Nissan Silvia S15",
|
Model = "Nissan Silvia S15",
|
||||||
Tier = 3,
|
Tier = 3,
|
||||||
Price = 12000,
|
|
||||||
RepRequired = 50,
|
|
||||||
Stats = { Speed = 74, Acceleration = 70, Handling = 78 },
|
Stats = { Speed = 74, Acceleration = 70, Handling = 78 },
|
||||||
Color = { 0.1, 0.35, 0.85 },
|
Color = { 0.1, 0.35, 0.85 },
|
||||||
Description = "A drift-bred coupe with serious mid-corner poise. The natural step up from the RX-7.",
|
Description = "A drift-bred coupe with serious mid-corner poise.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Id = "gtr_r34",
|
Id = "gtr_r34",
|
||||||
Name = "Godzilla",
|
Name = "Godzilla",
|
||||||
Model = "Nissan Skyline GT-R R34",
|
Model = "Nissan Skyline GT-R R34",
|
||||||
Tier = 4,
|
Tier = 4,
|
||||||
Price = 45000,
|
|
||||||
RepRequired = 180,
|
|
||||||
Stats = { Speed = 88, Acceleration = 86, Handling = 82 },
|
Stats = { Speed = 88, Acceleration = 86, Handling = 82 },
|
||||||
Color = { 0.15, 0.6, 0.95 },
|
Color = { 0.15, 0.6, 0.95 },
|
||||||
Description = "AWD, twin-turbo, legendary. When you show up in this, people notice.",
|
Description = "AWD, twin-turbo, legendary. People notice when you show up in this.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Id = "hyper_x",
|
Id = "hyper_x",
|
||||||
Name = "Apex Phantom",
|
Name = "Apex Phantom",
|
||||||
Model = "Exotic hypercar (stylized)",
|
Model = "Exotic hypercar (stylized)",
|
||||||
Tier = 5,
|
Tier = 5,
|
||||||
Price = 250000,
|
|
||||||
RepRequired = 600,
|
|
||||||
Stats = { Speed = 100, Acceleration = 98, Handling = 90 },
|
Stats = { Speed = 100, Acceleration = 98, Handling = 90 },
|
||||||
Color = { 0.05, 0.05, 0.06 },
|
Color = { 0.05, 0.05, 0.06 },
|
||||||
Description = "Endgame flex. Millions of yen of carbon fibre and neon. The top of the food chain.",
|
Description = "Top of the food chain. Carbon fibre and neon.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Fast lookup by Id (built once) --------------------------------------------
|
|
||||||
Cars.ById = {} :: { [string]: Car }
|
Cars.ById = {} :: { [string]: Car }
|
||||||
for _, car in ipairs(Cars.List) do
|
for _, car in ipairs(Cars.List) do
|
||||||
Cars.ById[car.Id] = car
|
Cars.ById[car.Id] = car
|
||||||
|
|||||||
+25
-34
@@ -1,63 +1,54 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
GameConfig.luau
|
GameConfig.luau
|
||||||
Central tunable constants. Anything you might want to balance later lives here,
|
Central tunable constants. MVP scope: JUST DRIVING on the expressway.
|
||||||
NOT hard-coded in systems. Single source of truth for numbers.
|
No racing, no economy, no rewards — pick a car and drive it.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local GameConfig = {}
|
local GameConfig = {}
|
||||||
|
|
||||||
-- Economy -------------------------------------------------------------------
|
-- Starter / default cars -----------------------------------------------------
|
||||||
GameConfig.StartingCash = 1500 -- enough to feel, not enough to skip the RX-7
|
|
||||||
GameConfig.StartingRep = 0
|
|
||||||
|
|
||||||
-- Starter cars ---------------------------------------------------------------
|
|
||||||
-- The RX-7 is a RESTRICTED demo car for the owner + son during testing. Only
|
-- The RX-7 is a RESTRICTED demo car for the owner + son during testing. Only
|
||||||
-- UserIds in the allowlist below ever receive / can buy / can spawn it.
|
-- UserIds in the allowlist below can drive it. Everyone else gets the public
|
||||||
-- Everyone else starts in the public starter car instead.
|
-- default and can drive any non-restricted car via the car picker.
|
||||||
GameConfig.PublicStarterCarId = "hatch_junk" -- default car for non-allowlisted players
|
GameConfig.PublicDefaultCarId = "hatch_junk" -- default equipped car for non-allowlisted players
|
||||||
GameConfig.RestrictedStarterCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked)
|
GameConfig.RestrictedCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked)
|
||||||
|
|
||||||
-- >>> FILL THESE IN: Roblox UserIds allowed to own the RX-7 (numbers, not names).
|
-- Roblox UserIds allowed to drive the restricted RX-7 (numbers, not names).
|
||||||
-- Find yours at roblox.com/users/<name>/profile — the number in the URL.
|
-- Find yours at roblox.com/users/<name>/profile — the number in the URL.
|
||||||
GameConfig.RX7AllowedUserIds = {
|
GameConfig.RX7AllowedUserIds = {
|
||||||
[459891257] = true, -- R3AL.CH1CK3N (owner)
|
[459891257] = true, -- R3AL.CH1CK3N (owner)
|
||||||
[4731701178] = true, -- son
|
[4731701178] = true, -- son
|
||||||
}
|
}
|
||||||
|
|
||||||
--- True if this player is allowed to own the restricted RX-7.
|
--- True if this player is allowed to drive the restricted RX-7.
|
||||||
function GameConfig.isRX7Allowed(userId: number): boolean
|
function GameConfig.isRX7Allowed(userId: number): boolean
|
||||||
return GameConfig.RX7AllowedUserIds[userId] == true
|
return GameConfig.RX7AllowedUserIds[userId] == true
|
||||||
end
|
end
|
||||||
|
|
||||||
--- The starter car id this specific player should receive on first join.
|
--- The default car this player should be driving on first join.
|
||||||
function GameConfig.starterCarFor(userId: number): string
|
function GameConfig.defaultCarFor(userId: number): string
|
||||||
if GameConfig.isRX7Allowed(userId) then
|
if GameConfig.isRX7Allowed(userId) then
|
||||||
return GameConfig.RestrictedStarterCarId
|
return GameConfig.RestrictedCarId
|
||||||
end
|
end
|
||||||
return GameConfig.PublicStarterCarId
|
return GameConfig.PublicDefaultCarId
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Race rewards (flat MVP model; tune per-race later) -------------------------
|
--- Can this player drive this car? Restricted cars need the allowlist.
|
||||||
GameConfig.RaceBaseReward = 250
|
--- (carId kept in the signature for call-site clarity / future per-car rules.)
|
||||||
GameConfig.RaceWinBonus = 400
|
function GameConfig.canDrive(userId: number, _carId: string, restricted: boolean?): boolean
|
||||||
GameConfig.RaceRepReward = 15
|
if restricted then
|
||||||
|
return GameConfig.isRX7Allowed(userId)
|
||||||
-- Daily login ----------------------------------------------------------------
|
end
|
||||||
GameConfig.DailyRewardCash = 300
|
return true
|
||||||
GameConfig.DailyRewardRep = 10
|
end
|
||||||
GameConfig.DailyCooldownSeconds = 20 * 60 * 60 -- 20h so a "daily" is reliably claimable each day
|
|
||||||
|
|
||||||
-- Anti-cheat sanity bounds (server-side validation) --------------------------
|
|
||||||
-- Max believable straight-line speed in studs/sec for the FASTEST car + boosts.
|
|
||||||
-- Anything sustained above this => flagged. Generous to avoid false positives in MVP.
|
|
||||||
GameConfig.MaxPlausibleSpeed = 400
|
|
||||||
|
|
||||||
-- DataStore ------------------------------------------------------------------
|
-- DataStore ------------------------------------------------------------------
|
||||||
GameConfig.ProfileStoreName = "PlayerData_v1" -- bump the _vN to wipe/migrate in dev
|
GameConfig.ProfileStoreName = "PlayerData_v1" -- bump the _vN to wipe/migrate in dev
|
||||||
GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio (no live DataStore)
|
GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio
|
||||||
|
|
||||||
-- Autosave interval handled by ProfileStore internally; kept here for reference.
|
-- Anti-cheat sanity bound (server-side): max believable horizontal speed
|
||||||
GameConfig.SessionTag = "fnf-sim-mvp"
|
-- (studs/sec) for the fastest car. Sustained above this => snap back.
|
||||||
|
GameConfig.MaxPlausibleSpeed = 400
|
||||||
|
|
||||||
return GameConfig
|
return GameConfig
|
||||||
|
|||||||
@@ -1,48 +1,19 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
ProfileTemplate.luau
|
ProfileTemplate.luau
|
||||||
The canonical shape of a player's saved data. ProfileStore fills any
|
Saved data shape. MVP scope (just driving): we only remember which car the
|
||||||
missing keys from this template automatically (safe forward-migration:
|
player last drove, so they resume in it. No money, no owned-car list, no rep.
|
||||||
add a key here and existing players get the default on next load).
|
ProfileStore reconciles missing keys from this template automatically.
|
||||||
|
|
||||||
NEVER remove/rename a key without a migration plan — that's how saves break.
|
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local GameConfig = require(script.Parent.GameConfig)
|
|
||||||
|
|
||||||
export type UpgradeLevels = {
|
|
||||||
Engine: number,
|
|
||||||
Tires: number,
|
|
||||||
Nitro: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OwnedCar = {
|
|
||||||
Id: string,
|
|
||||||
Upgrades: UpgradeLevels,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProfileData = {
|
export type ProfileData = {
|
||||||
Cash: number,
|
EquippedCarId: string, -- last car the player was driving ("" = assign default on load)
|
||||||
Rep: number,
|
Version: number,
|
||||||
OwnedCars: { OwnedCar },
|
|
||||||
EquippedCarId: string,
|
|
||||||
LastDailyClaim: number, -- os.time() of last claim; 0 = never
|
|
||||||
TotalRaces: number,
|
|
||||||
Wins: number,
|
|
||||||
Version: number, -- schema version for future migrations
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local ProfileTemplate: ProfileData = {
|
local ProfileTemplate: ProfileData = {
|
||||||
Cash = GameConfig.StartingCash,
|
|
||||||
Rep = GameConfig.StartingRep,
|
|
||||||
-- Starter car is granted per-player by DataManager after load (depends on
|
|
||||||
-- UserId: allowlisted players get the RX-7, everyone else the public car).
|
|
||||||
OwnedCars = {},
|
|
||||||
EquippedCarId = "",
|
EquippedCarId = "",
|
||||||
LastDailyClaim = 0,
|
Version = 2,
|
||||||
TotalRaces = 0,
|
|
||||||
Wins = 0,
|
|
||||||
Version = 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProfileTemplate
|
return ProfileTemplate
|
||||||
|
|||||||
+6
-13
@@ -2,12 +2,10 @@
|
|||||||
--[[
|
--[[
|
||||||
Remotes.luau
|
Remotes.luau
|
||||||
Single source of truth for every RemoteEvent / RemoteFunction.
|
Single source of truth for every RemoteEvent / RemoteFunction.
|
||||||
The SERVER calls Remotes.init() once at boot to create the instances under
|
MVP scope (just driving): the only client->server actions are "give me a car"
|
||||||
ReplicatedStorage/Remotes. The CLIENT just requires and reads them (they're
|
and "switch which car I'm driving". Server creates these at boot; client reads.
|
||||||
guaranteed to exist because the server script runs first / it waits).
|
|
||||||
|
|
||||||
Add a remote = add one line to the DEFS table. No stringly-typed
|
Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere.
|
||||||
FindFirstChild scattered across the codebase.
|
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
@@ -17,17 +15,12 @@ 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 fresh profile snapshot for HUD/garage
|
-- Server -> Client: pushes the player's current car id (for HUD)
|
||||||
DataUpdated = "Event",
|
DataUpdated = "Event",
|
||||||
|
|
||||||
-- Client -> Server requests
|
-- Client -> Server requests
|
||||||
SpawnCar = "Event", -- (carId?) spawn/replace the player's equipped car
|
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
|
||||||
EquipCar = "Function", -- (carId) -> boolean success
|
EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car
|
||||||
BuyCar = "Function", -- (carId) -> {ok:boolean, reason:string?}
|
|
||||||
UpgradeCar = "Function", -- (carId, part) -> {ok:boolean, reason:string?}
|
|
||||||
ClaimDaily = "Function", -- () -> {ok:boolean, cash:number?, rep:number?, reason:string?}
|
|
||||||
StartRace = "Function", -- (raceId) -> {ok:boolean, reason:string?}
|
|
||||||
FinishRace = "Event", -- (raceId, won:boolean) client reports; server validates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local FOLDER_NAME = "Remotes"
|
local FOLDER_NAME = "Remotes"
|
||||||
|
|||||||
Reference in New Issue
Block a user