Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5b26b3eb | ||
|
|
25d1e37750 | ||
|
|
83b3bf80c3 | ||
|
|
74899a7197 | ||
|
|
dc0df9122e | ||
|
|
6e50f9718d | ||
|
|
c297b20aed | ||
|
|
1d23c187ad | ||
|
|
badba0f7a4 | ||
|
|
6c753505f0 | ||
|
|
f4f95ec9cc | ||
|
|
4a702a4fdb | ||
|
|
16cba4ac32 | ||
|
|
3a74e15f6e | ||
|
|
fed0c0ed20 | ||
|
|
e997f0f73d | ||
|
|
1e41de92fd | ||
|
|
6b0e3b75e4 | ||
|
|
c8dabea53a | ||
|
|
8808b919c3 | ||
|
|
7d368d5444 | ||
|
|
967385261d | ||
|
|
85730f0038 | ||
|
|
37a83e0e8f | ||
|
|
0c5ecd2487 | ||
|
|
f70a0b06bb | ||
|
|
c8809efa7b | ||
|
|
3ae71f9dd3 | ||
|
|
45069188be | ||
|
|
d45f35736a | ||
|
|
766fce5088 | ||
|
|
0dbd7420d4 | ||
|
|
1f888b3693 | ||
|
|
be52290093 | ||
|
|
681c3462c6 |
@@ -15,3 +15,4 @@ wally.lock
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
audio-work/
|
audio-work/
|
||||||
|
tools/shots/
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# CLAUDE.md -- Tokyo Wangan Racing
|
||||||
|
|
||||||
|
Guidance for Claude Code running **natively on `windows-gpu`**, the same machine
|
||||||
|
as Roblox Studio. Pure ASCII on purpose (PowerShell 5.1 reads non-ASCII as ANSI
|
||||||
|
and desyncs the parser).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Read this before touching MapBuilder
|
||||||
|
|
||||||
|
`src/server/MapBuilder.luau` generates the map procedurally. The user also edits
|
||||||
|
geometry **by hand in Studio**. Those two facts collide, and that collision is
|
||||||
|
the single most important thing about this repo:
|
||||||
|
|
||||||
|
> **A rebuild regenerates anything the source still describes.**
|
||||||
|
> If the user deleted geometry by hand and you rebuild without teaching the
|
||||||
|
> source about that deletion, you silently put it back.
|
||||||
|
|
||||||
|
The user WILL notice, immediately, and will say so ("you just added back the X
|
||||||
|
that I deleted"). When that happens: **stop editing, run a read-only probe,
|
||||||
|
report exact numbers.** Do not "fix" it with another rebuild.
|
||||||
|
|
||||||
|
### The live invariant: aux-lane suppression
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local AUX_SUPPRESS_FROM = 525 -- samples 525..550 have NO aux lane
|
||||||
|
local AUX_SUPPRESS_TO = 550 -- because the user deleted that run by hand
|
||||||
|
local AUX_BLEND = 5 -- smoothstep width ramp, do not hard-cut
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Do not remove or widen this range.** Re-enabling it resurrects deleted geometry.
|
||||||
|
- `AUX_BLEND = 5` is not cosmetic. A hard cut produced a **26-stud lateral fence
|
||||||
|
jog**. The smoothstep removes it.
|
||||||
|
- The parapet is force-closed across the same range (~line 1031). Normally the
|
||||||
|
parapet OPENS along a speed-change lane because the aux lane re-fences the
|
||||||
|
outer edge -- with no aux lane there is nothing to re-fence with, leaving the
|
||||||
|
deck edge bare. That was the measured barrier gap at samples 527..548.
|
||||||
|
|
||||||
|
Any change touching aux lanes, parapets, or barriers must re-run
|
||||||
|
`tools/probe_regression.luau` and match the baseline below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verified baseline (commit 6e50f97)
|
||||||
|
|
||||||
|
Measured in the live datamodel, not inferred from source:
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| total parts | 16788 |
|
||||||
|
| PAWestSlip | 60 segs, 34.4 deg total bend, worst 1.4, 0 kinks >5 |
|
||||||
|
| PAEastSlip | 60 segs, 30.7 deg total bend, worst 1.3, 0 kinks >5 |
|
||||||
|
| AuxLane parts | 30 (samples 525-550 deliberately absent) |
|
||||||
|
| barrier coverage | 823 samples, 0 unguarded |
|
||||||
|
| fence jogs >10 studs | 0 |
|
||||||
|
|
||||||
|
`AuxLane = 30` is the **user's hand-edited count**, not the source's original 52.
|
||||||
|
If a probe reports 52, a rebuild has undone the suppression -- that is a
|
||||||
|
regression, not progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Two channels, different jobs
|
||||||
|
|
||||||
|
```
|
||||||
|
[A] MCP bridge (roblox_studio) -> READ run Luau in Studio, measure, screenshot
|
||||||
|
[B] Rojo -> WRITE push .luau source into Studio (~1 s)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use [A] to verify what [B] did. Never assume a sync landed.**
|
||||||
|
|
||||||
|
Rojo syncs **scripts, not built geometry**. Editing MapBuilder replaces the
|
||||||
|
ModuleScript instantly, but `workspace.Map` still holds parts from a PREVIOUS
|
||||||
|
`build()` run. The map looks stale until you re-run the build. Do not tell the
|
||||||
|
user "you'll see it live" about map geometry.
|
||||||
|
|
||||||
|
**Rojo version must match the Studio plugin.** The plugin auto-updates from the
|
||||||
|
Marketplace without asking. Server older than plugin fails the handshake with a
|
||||||
|
cryptic `attempt to index number with 'protocolVersion'`. Both are pinned at
|
||||||
|
**7.7.0** (`rokit.toml`). If the user reports a connect error, check this first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # stylua --check + selene + rojo build
|
||||||
|
make build # rojo build --output build.rbxl
|
||||||
|
make serve # rojo serve (Studio: Rojo -> Connect)
|
||||||
|
make fmt # stylua src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe scripts in `tools/` are **read-only** -- they measure the live datamodel
|
||||||
|
and never build. Run them through the MCP bridge.
|
||||||
|
|
||||||
|
| Script | Answers |
|
||||||
|
|---|---|
|
||||||
|
| `probe_regression.luau` | full baseline check vs the table above |
|
||||||
|
| `verify_auxlane.luau` | is the 525-550 suppression intact? |
|
||||||
|
| `verify_slips.luau` | slip-road bend angles / kink count |
|
||||||
|
| `probe_barriergap.luau` | any unguarded deck edge? |
|
||||||
|
| `probe_jog.luau` | lateral fence jogs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hard-won rules
|
||||||
|
|
||||||
|
- **Measure, then fix.** Do not infer a bug from symptoms. One inverted
|
||||||
|
convention can produce three unrelated-looking symptoms; fixing each symptom
|
||||||
|
leaves the root cause intact.
|
||||||
|
- **A metric derived from a constant cannot fail.** Ask: would this number
|
||||||
|
differ between the broken and fixed build? If not, it is not a test.
|
||||||
|
- **An empty result set must FAIL, never pass.** Count what you actually
|
||||||
|
exercised; `if tested == 0 then return "NOTHING TESTED -- FAILURE" end`.
|
||||||
|
- **Verification in Play mode proves nothing.** Play is a disposable sandbox
|
||||||
|
discarded on stop. Verify in Edit mode.
|
||||||
|
- **require() caches stale bytecode after a Rojo edit.** The cache still holds
|
||||||
|
old bytecode even though `.Source` is new -- no error, no warning. Clone the
|
||||||
|
module and require the clone.
|
||||||
|
- **Enumerate part names, don't just count.** A raw total hides partial removals
|
||||||
|
(deleted the tall barrier, missed the kerb). Tally what should be gone vs
|
||||||
|
what should remain, and report both.
|
||||||
|
- **AutoRecovery places are silently stale.** After a crash Studio reopens
|
||||||
|
`*_AutoRecovery_1.rbxl` holding pre-crash state. Check the title bar.
|
||||||
|
- **Creator Store assets: 50+ scripts or 1M+ tris is zero-tolerance.** Get exact
|
||||||
|
counts before inserting anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shell
|
||||||
|
|
||||||
|
Git Bash (`C:\Program Files\Git\bin\bash.exe`) is present -- prefer it.
|
||||||
|
PowerShell over a non-interactive pipe mangles quoting. `&` is not a statement
|
||||||
|
separator in PowerShell. When PowerShell is unavoidable non-interactively, use
|
||||||
|
`-EncodedCommand` with UTF-16LE base64.
|
||||||
|
|
||||||
|
`StudioMCP.exe` lives inside the versioned Studio install and **moves on every
|
||||||
|
update**. Never hardcode it; `~/claude-tools/studio-mcp.bat` resolves it
|
||||||
|
dynamically.
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# PROJECT-BRIEF.md — What we're building and where everything is
|
||||||
|
|
||||||
|
> **Read this first, before `CLAUDE.md`.**
|
||||||
|
> `CLAUDE.md` tells you *how to operate the tools safely*.
|
||||||
|
> This file tells you *what the game is, why it's built this way, and where the
|
||||||
|
> resources live*. You need both.
|
||||||
|
>
|
||||||
|
> Verified against the live repo, the NAS, and Gitea on **2026-08-06**. Every
|
||||||
|
> path and number below was checked, not recalled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The game in one paragraph
|
||||||
|
|
||||||
|
**Tokyo Wangan Racing** is a Roblox night-driving game set on a neon Tokyo
|
||||||
|
expressway, built around a real 1993 Mazda RX-7 the owner actually owns. It is
|
||||||
|
modelled on **Tokyo Xtreme Racer**, not on lap-racing games: **Daikoku Futo PA
|
||||||
|
(the parking area) is the hub.** You park, choose how you want to run, and drive
|
||||||
|
out onto the Wangan. You never "join a match."
|
||||||
|
|
||||||
|
The core bet: **driving feel is the whole game.** Racing, economy, and
|
||||||
|
progression are all deliberately deferred until the car is genuinely fun to just
|
||||||
|
drive around. Do not add game layers on top of a car that doesn't feel good yet.
|
||||||
|
|
||||||
|
### The three modes
|
||||||
|
|
||||||
|
Selected in the PA, armed when you cross the exit gate, ended when you pull back
|
||||||
|
into the PA. Defined in `src/shared/Modes.luau`.
|
||||||
|
|
||||||
|
| Mode | 日本語 | What it is | Challenges? | Timed? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **CRUISE** | 流し | Free roam, bank kilometres, meet drivers | yes | no |
|
||||||
|
| **DUEL** | バトル | Open a gap and *hold* it — who breaks first | yes | no |
|
||||||
|
| **TIME ATTACK** | タイムアタック | Solo flying lap, sector splits, personal best | **no** | yes |
|
||||||
|
|
||||||
|
Cruise and Duel both allow challenges, so a cruise turns into a battle
|
||||||
|
organically. **Time Attack opts out on purpose** so a hot lap is never ruined —
|
||||||
|
that's a design decision, not an oversight. Don't "fix" it.
|
||||||
|
|
||||||
|
### The circuit
|
||||||
|
|
||||||
|
Closed loop, **~49,400 studs (13.8 km)**, samples evenly spaced 60 studs apart so
|
||||||
|
the loop closes seamlessly. Roughly a 2:46 lap at 300 km/h. Scale is
|
||||||
|
**1 stud = 0.28 m**. Coordinates: `+X = east`, `+Z = north`.
|
||||||
|
|
||||||
|
Landmark zones (themed, **not** geographically accurate): Daikoku Futo PA,
|
||||||
|
Rainbow Bridge, Odaiba (Unicorn Gundam + ferris wheel), Yamate-style tunnel,
|
||||||
|
Tokyo Tower replica, Tatsumi PA, bilingual green signage (大黒PA, レインボーブリッジ,
|
||||||
|
お台場, 辰巳PA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Where you are in the plan
|
||||||
|
|
||||||
|
Source of truth: `docs/ROADMAP.md` and `docs/GDD.md`. Current position:
|
||||||
|
|
||||||
|
- **M0 — drive-only MVP: DONE.** Spawn, drive, switch cars, respawn, persistence.
|
||||||
|
- **M1 — make driving feel GOOD: IN PROGRESS. This is the current work.**
|
||||||
|
- done: constraint chassis (springs, prismatic suspension, servo steering,
|
||||||
|
motor wheels, engine braking), chase camera, two-layer engine sound
|
||||||
|
- **not done: import the real RX-7 mesh** (`docs/MESH_SWAP.md`)
|
||||||
|
- **not done: real 13B rotary audio** from the owner's actual car
|
||||||
|
(`docs/ROTARY_SOUND.md`) — current loops are stand-ins
|
||||||
|
- **not done: tune accel curve + grip** — needs a Studio playtest
|
||||||
|
- **M2 — more cars + map polish:** map v3 and ambience done; more meshes pending.
|
||||||
|
- **M3+ — racing, economy, hardening, traffic AI:** deliberately not started.
|
||||||
|
|
||||||
|
> **M1's exit test:** *the RX-7 is genuinely fun to just drive around.*
|
||||||
|
> Ship nothing else until that's true. If asked to build a racing/economy
|
||||||
|
> feature, flag that it's M3/M4 and confirm before starting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Repo layout and architecture
|
||||||
|
|
||||||
|
Repo: `~/roblox/tokyo-wangan-racing` (Git Bash path on this box).
|
||||||
|
|
||||||
|
**Server-authoritative: clients request, the server decides.** The RX-7
|
||||||
|
allowlist and anti-cheat are server-side. Keep the client dumb.
|
||||||
|
|
||||||
|
```
|
||||||
|
ReplicatedStorage/Shared src/shared GameConfig, Cars, Circuit, Modes,
|
||||||
|
Chassis, Remotes, ProfileTemplate
|
||||||
|
ServerScriptService/Server src/server init.server + managers
|
||||||
|
ServerScriptService/ServerPackages ServerPackages ProfileStore (Wally)
|
||||||
|
StarterPlayerScripts/Client src/client HUD, car picker, controllers
|
||||||
|
```
|
||||||
|
|
||||||
|
Mapping is declared in `default.project.json` (Rojo).
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `src/server/MapBuilder.luau` | **3,479 lines — the big one.** Procedurally builds the entire expressway, PA, ramps, barriers, signage. |
|
||||||
|
| `src/server/CarManager.luau` | Spawns the equipped car; enforces the RX-7 allowlist |
|
||||||
|
| `src/server/ModeManager.luau` | Mode selection, exit-gate arming, mode end |
|
||||||
|
| `src/server/DataManager.luau` | ProfileStore wrapper — session lock, reconcile, Studio mock |
|
||||||
|
| `src/server/AntiCheat.luau` | Speed/teleport sanity check — a speed bump, not a fortress |
|
||||||
|
| `src/server/Ambience.luau` | Atmosphere haze, bloom, 22:00→03:00 clock drift |
|
||||||
|
| `src/shared/Circuit.luau` | The 1,050-line circuit spline — sample positions, PA geometry |
|
||||||
|
| `src/shared/Chassis.luau` | Shared chassis tuning constants |
|
||||||
|
| `src/client/ChassisController.luau` | Drives the chassis client-side (driver owns network physics) |
|
||||||
|
| `src/client/CameraController.luau` | Damped chase cam, flat horizon, speed FOV kick |
|
||||||
|
| `src/client/SoundController.luau` | Two-layer engine crossfade + tyre squeal |
|
||||||
|
|
||||||
|
**Cars:** RX-7 (restricted), City Runabout (public default), Silvia S15, Skyline
|
||||||
|
GT-R R34, stylized hypercar. The **RX-7 is demo-locked** to the owner + his son
|
||||||
|
via `GameConfig.RX7AllowedUserIds`, enforced in `CarManager` and the `EquipCar`
|
||||||
|
remote. Stats only affect feel — no prices, no unlocks in the MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resources you can reach — and how
|
||||||
|
|
||||||
|
### 4a. Live Roblox Studio (this machine, local)
|
||||||
|
|
||||||
|
Two channels, different jobs. **Use [A] to verify what [B] did.**
|
||||||
|
|
||||||
|
```
|
||||||
|
[A] MCP bridge -> READ run Luau in the datamodel, screenshot the viewport
|
||||||
|
[B] Rojo -> WRITE push .luau source into Studio (~1 s)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mcp__roblox_studio__execute_luau` — `datamodel_type` **must be `"Edit"`**,
|
||||||
|
never `"Server"` (Server edits a throwaway Play copy that dies on stop).
|
||||||
|
- `mcp__roblox_studio__screen_capture` — Studio's **3D viewport only**. Not the
|
||||||
|
desktop, not Explorer, not Blender.
|
||||||
|
- Rojo: `rojo serve default.project.json --port 34872`, then **somebody must
|
||||||
|
click Rojo → Connect in Studio.** It never auto-connects, and an ESTABLISHED
|
||||||
|
socket on 34872 is idle heartbeat, *not* a sync session.
|
||||||
|
|
||||||
|
Full operational detail, version pins, and the failure decoder: **`CLAUDE.md`**.
|
||||||
|
|
||||||
|
#### Rojo must run natively on Windows — never over an SSH tunnel
|
||||||
|
|
||||||
|
**Rojo runs on the Windows box, in the Windows checkout.** Studio, Rojo and the
|
||||||
|
repo it serves all live on the same machine. Start it with:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd /d %USERPROFILE%\roblox\tokyo-wangan-racing
|
||||||
|
%USERPROFILE%\.local\bin\rojo.exe serve default.project.json --port 34872
|
||||||
|
```
|
||||||
|
|
||||||
|
A scheduled task `RojoTWR` does exactly this — `schtasks /Run /TN "RojoTWR"`.
|
||||||
|
(Launching via `start` over SSH does **not** work: the process dies when the SSH
|
||||||
|
session exits. Use the task.)
|
||||||
|
|
||||||
|
**Rojo does not stay up on its own.** The root cause was found on 2026-08-06:
|
||||||
|
**Windows OpenSSH kills the entire process tree when the SSH session exits.**
|
||||||
|
Anything started over SSH with `Start-Process` — including Rojo — dies seconds
|
||||||
|
later. This presents as "Rojo was running a minute ago and now the plugin won't
|
||||||
|
connect," and it is *not* a plugin fault.
|
||||||
|
|
||||||
|
Proof: `rojo procs: 1` inside the SSH session, `rojo procs: 0` from a new
|
||||||
|
session moments later.
|
||||||
|
|
||||||
|
The fix is to hand ownership to **Task Scheduler**, which is not part of the SSH
|
||||||
|
process tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
schtasks /Run /TN "RojoTWR" start rojo detached (survives SSH exit)
|
||||||
|
%USERPROFILE%\rojo-guard.ps1 idempotent watchdog
|
||||||
|
schtasks /Query /TN "RojoTWR-Guard" runs the watchdog every 3 minutes
|
||||||
|
%USERPROFILE%\rojo-guard.log only logs real events, silent when healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
Repo copy of the watchdog: `tools/win/rojo-guard.ps1`.
|
||||||
|
|
||||||
|
> **Never start Rojo with `Start-Process`/`start` over SSH.** Always go through
|
||||||
|
> `schtasks /Run /TN "RojoTWR"`. The guard itself follows this rule internally.
|
||||||
|
|
||||||
|
The guard is deliberately conservative:
|
||||||
|
- if `rojo` already owns 34872 it exits 0 and does nothing (safe mid-session —
|
||||||
|
it will not disturb a live Studio connection)
|
||||||
|
- if the port is held by **anything else** (e.g. an `sshd` tunnel) it refuses to
|
||||||
|
start and logs `PORT HIJACKED`, rather than masking the bug
|
||||||
|
- if `ServerPackages` is missing it logs that instead of letting Rojo silently
|
||||||
|
exit on startup
|
||||||
|
|
||||||
|
Verified end-to-end: killed `rojo`, waited, and the scheduler restored it
|
||||||
|
unattended (`Last Result: 0`, port owned by `rojo`).
|
||||||
|
|
||||||
|
**Clicking Rojo → Connect in Studio is still manual and always will be.** The
|
||||||
|
guard keeps the *server* alive; it cannot press the button. `Rojo_autoReconnect`
|
||||||
|
is `false` in the plugin settings, so after a drop you must reconnect by hand
|
||||||
|
(or flip that setting in the plugin).
|
||||||
|
|
||||||
|
> ⚠️ **Do not reverse-tunnel a Linux Rojo to port 34872.** It was tried
|
||||||
|
> (`ssh -N -R 34872:127.0.0.1:34872`) and it is actively harmful: the port
|
||||||
|
> answers, `/api/rojo` returns valid JSON, and everything *looks* healthy — but
|
||||||
|
> it is serving the **Linux** working tree. Studio then syncs files that are not
|
||||||
|
> the ones being edited on Windows, and `netstat` shows the port owned by
|
||||||
|
> **sshd** rather than `rojo`. If port 34872 is owned by anything other than a
|
||||||
|
> process literally named `rojo`, that is the bug.
|
||||||
|
>
|
||||||
|
> Diagnostic that settles it in one line:
|
||||||
|
> ```powershell
|
||||||
|
> $l=(netstat -ano | Select-String ":34872.*LISTENING"); (Get-Process -Id (($l[0] -split "\s+")[-1])).ProcessName
|
||||||
|
> ```
|
||||||
|
> Expect `rojo`. If you get `sshd`, kill the tunnel.
|
||||||
|
|
||||||
|
#### `ServerPackages` is gitignored — Rojo will not start without it
|
||||||
|
|
||||||
|
`/Packages/`, `/ServerPackages/` and `wally.lock` are **gitignored**, so a fresh
|
||||||
|
clone has no dependencies and Rojo **refuses to start**, exiting immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR rojo] Rojo project referred to a file using $path that could not be
|
||||||
|
turned into a Roblox Instance by Rojo. File $path: ServerPackages
|
||||||
|
```
|
||||||
|
|
||||||
|
That error means *missing Wally deps*, not a broken project file. There is only
|
||||||
|
one dependency (`ProfileStore`, 19 files / 316 KB). Fix with `wally install`, or
|
||||||
|
copy the folder from a machine that already has it. **Rojo exiting on startup is
|
||||||
|
silent from Studio's side — it just looks like Rojo "won't connect."**
|
||||||
|
|
||||||
|
### 4b. Reference material on the NAS (1.5 GB, verified present)
|
||||||
|
|
||||||
|
Host `unraid-1` (10.10.10.43), reachable over SSH. Base path:
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Folder | Contents (verified counts) |
|
||||||
|
|---|---|
|
||||||
|
| `001-original-images/001-1993-Mazda-RX-7/` | **12 files** — the real car, for the mesh swap |
|
||||||
|
| `001-original-images/002-1993-Nissan-Skyline-GT-R/` | **38 files** |
|
||||||
|
| `003-google-earth-images/` | **12 files** — `daikoku-futo-01..12.png`, aerial geometry |
|
||||||
|
| `004-video-reference/` | `aerial/`, `contact-sheets/`, `docs/`, `entrance-routes/` |
|
||||||
|
| `002-completed-models/` | empty — finished models land here |
|
||||||
|
|
||||||
|
> ⚠️ **The README currently prints a stale path** for the RX-7 reference
|
||||||
|
> (`001-1993 Mazda RX-7/` with spaces, at the top level). The real path is
|
||||||
|
> `001-original-images/001-1993-Mazda-RX-7/` with hyphens. Use the real one.
|
||||||
|
|
||||||
|
Copy a file over rather than working blind:
|
||||||
|
```bash
|
||||||
|
scp unraid-1:'/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/003-google-earth-images/daikoku-futo-01.png' /tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. Reference material committed in-repo
|
||||||
|
|
||||||
|
- `reference/ramps/` — 12 integrity-checked photos of Daikoku/Oi/Heiwajima/
|
||||||
|
Shibaura PAs, plus `INDEX.md`, `BUILD_SPEC.md`, and OSM/satellite scripts.
|
||||||
|
**All CC BY / CC BY-SA — reference-only for shape study. Do not trace directly
|
||||||
|
into shipped assets without attribution.**
|
||||||
|
- `docs/daikoku-reference-spec.md` — measured spec for the PA entrance geometry
|
||||||
|
- `docs/japanese-road-standards.md` — real road-geometry standards
|
||||||
|
- `docs/CAR_PHYSICS_REFERENCE.txt`, `docs/MESH_SWAP.md`, `docs/ROTARY_SOUND.md`
|
||||||
|
- `assets/audio/` — current stand-in RX-7 loops (idle / rev / whistle)
|
||||||
|
- `audio-work/` — raw recordings from real car meets, for the rotary audio work
|
||||||
|
- `c1_georeference.json` — georeferencing data behind the circuit
|
||||||
|
|
||||||
|
### 4d. Git / CI
|
||||||
|
|
||||||
|
Remote: `ssh://[email protected]:2222/jesse/tokyo-wangan-racing.git`
|
||||||
|
(self-hosted Gitea on UNRAID-1, SSH key auth already working from this box).
|
||||||
|
|
||||||
|
CI runs on a **self-hosted Gitea Actions runner** (`.gitea/workflows/ci.yml`),
|
||||||
|
Ubuntu 24.04 job container — rojo's binary needs glibc 2.39.
|
||||||
|
|
||||||
|
### 4e. Existing tooling — check before writing new probes
|
||||||
|
|
||||||
|
`tools/` already holds **109 files, 58 of them diagnostic/verification scripts**
|
||||||
|
(`diag_*.luau`, `verify_*.luau`, `capture_*.py`, `check_*.luau`). There is a
|
||||||
|
very high chance a probe already exists for what
|
||||||
|
you need — `verify_auxlane.luau`, `verify_shared_ramps.luau`,
|
||||||
|
`check_live_source.luau`, `scripts/verify_map.luau`, and capture helpers for
|
||||||
|
flyovers/ramps/defects. **Look there first.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Quality gates — CI enforces all three
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stylua --check src/ # formatting
|
||||||
|
selene src/ # lint
|
||||||
|
rojo build --output build.rbxl # must compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
- Tunables go in `GameConfig`, not scattered magic numbers.
|
||||||
|
- Add a save key by adding it to `ProfileTemplate` (auto-reconciles). Never
|
||||||
|
remove a key without a migration.
|
||||||
|
- Test in Studio after every server change (ProfileStore uses a mock in Studio).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The things that will bite you
|
||||||
|
|
||||||
|
These are compressed from hard-won experience. The long versions live in
|
||||||
|
`CLAUDE.md` — but these three matter most:
|
||||||
|
|
||||||
|
### Never regenerate hand-deleted work
|
||||||
|
**The single most important rule on this project.** If the user deleted
|
||||||
|
something in Studio, the *source* must honor it or the next rebuild silently
|
||||||
|
resurrects it — and they will catch it immediately.
|
||||||
|
|
||||||
|
Live example, currently in `MapBuilder.luau`:
|
||||||
|
```lua
|
||||||
|
AUX_SUPPRESS_FROM = 525 -- user deleted this aux-lane stretch
|
||||||
|
AUX_SUPPRESS_TO = 550
|
||||||
|
AUX_BLEND = 5 -- smoothstep; a hard cut caused a 26-stud fence jog
|
||||||
|
```
|
||||||
|
**Do not remove that suppression.** Hand-placed assets live in
|
||||||
|
`workspace.PAProps`, which the builder never touches. Before any rebuild, ask:
|
||||||
|
*does this regenerate something removed by hand?*
|
||||||
|
|
||||||
|
### Measure before reasoning, and re-measure after
|
||||||
|
Write a **read-only probe**, get real numbers, *then* form a theory. A code
|
||||||
|
change is not proof — verify against the bug's signature. Enumerate names, not
|
||||||
|
just totals: a raw count hides a partial removal.
|
||||||
|
|
||||||
|
### Beat the `require()` cache
|
||||||
|
After a Rojo sync the datamodel still holds **old bytecode** — no error, no
|
||||||
|
warning, and you will measure the previous build and believe it:
|
||||||
|
```lua
|
||||||
|
local tmp = game.ServerScriptService.Server.MapBuilder:Clone()
|
||||||
|
tmp.Parent = game.ServerScriptService
|
||||||
|
local MapBuilder = require(tmp) -- fresh bytecode, guaranteed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verified-good baseline (2026-08-06) — regression-check against these
|
||||||
|
|
||||||
|
| metric | value |
|
||||||
|
|---|---|
|
||||||
|
| total parts | 16,788 |
|
||||||
|
| PAWestSlip | 60 segs, 34.4° total bend, worst 1.4°, 0 kinks >5° |
|
||||||
|
| PAEastSlip | 60 segs, 30.7° total bend, worst 1.3°, 0 kinks >5° |
|
||||||
|
| AuxLane parts | 30 — **samples 525–550 deliberately absent (user-deleted)** |
|
||||||
|
| barrier coverage | 823 samples, 0 unguarded |
|
||||||
|
| fence jogs >10 studs | 0 |
|
||||||
|
|
||||||
|
### Never fabricate
|
||||||
|
If Studio is closed, say "Studio is closed." Never invent a screenshot, a part
|
||||||
|
count, or a measurement you didn't obtain. `{'error': '', 'isError': True}` on
|
||||||
|
every MCP call means **Studio is closed** — not that your script is broken.
|
||||||
|
Reporting a blocker honestly always beats a plausible-looking fake.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. First five minutes in a new session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tasklist | findstr /I "Roblox" # 1. is Studio even running?
|
||||||
|
claude mcp list # 2. bridge registered + connected?
|
||||||
|
curl -s http://127.0.0.1:34872/api/rojo # 3. rojo serving?
|
||||||
|
cd ~/roblox/tokyo-wangan-racing && git status --short && git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then read `docs/ROADMAP.md` to confirm what milestone is active, and check
|
||||||
|
`tools/` for an existing probe before writing a new one.
|
||||||
|
|
||||||
|
**Working state as of 2026-08-06:** clean tree, 0 uncommitted files, HEAD at
|
||||||
|
`dc0df91`. (An older note in `START-HERE.md` warns about "36 uncommitted files"
|
||||||
|
on the Linux box — that has since been committed and pushed. Trust `git status`
|
||||||
|
over that note.)
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
# Fast & Furious: Tokyo Expressway — MVP (drive-only)
|
# Tokyo Wangan Racing
|
||||||
|
|
||||||
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
|
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
|
||||||
starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just
|
starting with a real 1993 Mazda RX-7. The driving feel comes first; everything
|
||||||
driving.** No racing, money, or upgrades yet — the goal is to nail the driving
|
else layers on top of it. See `docs/ROADMAP.md` for what comes next.
|
||||||
feel and be testable fast. See `docs/ROADMAP.md` for what comes next.
|
|
||||||
|
|
||||||
**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a
|
**Status:** Playable. Spawn → drive → switch cars → respawn, plus the full
|
||||||
self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio.
|
three-mode Wangan loop. CI green on a self-hosted Gitea runner. Built code-first
|
||||||
|
— nothing to hand-build in Studio.
|
||||||
|
|
||||||
|
## The Daikoku loop (how you play)
|
||||||
|
|
||||||
|
Daikoku Futo PA is the hub, exactly like Tokyo Xtreme Racer. You are never
|
||||||
|
"joining a match" — you park, pick how you want to run, and drive out.
|
||||||
|
|
||||||
|
```
|
||||||
|
IN THE PA ──► pick a mode ──► cross the exit gate ──► RUNNING
|
||||||
|
▲ │
|
||||||
|
└──────────────── pull back into the PA ─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mode | 日本語 | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| **CRUISE** | 流し | Free roam. Bank kilometres, meet other drivers. Rivals can pull alongside and challenge you. |
|
||||||
|
| **DUEL** | バトル | Hunt rivals. First to open a ~350-stud gap and *hold* it for 3s wins. No laps, no checkpoints — who breaks first. |
|
||||||
|
| **TIME ATTACK** | タイムアタック | Solo flying lap with 4 sector splits and a saved personal best. Rivals can't interrupt you. |
|
||||||
|
|
||||||
|
Cruise and Duel both allow challenges, so a cruise can turn into a battle
|
||||||
|
organically — pull alongside someone and the prompt appears for both of you.
|
||||||
|
Time Attack deliberately opts out so a hot lap is never ruined.
|
||||||
|
|
||||||
|
Kilometres, lap times and duel records all persist via ProfileStore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,4 +102,5 @@ the `EquipCar` remote).
|
|||||||
|
|
||||||
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
One function changes: `CarManager.buildCarModel`. Full step-by-step in
|
||||||
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
|
||||||
`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`.
|
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-original-images/001-1993-Mazda-RX-7/`
|
||||||
|
(12 files). See `PROJECT-BRIEF.md` for the full reference-asset inventory.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"note": "C1 world<->real Tokyo georeference. X IS NEGATED vs source data.",
|
||||||
|
"world_to_real": {
|
||||||
|
"lon": "139.75654 + 2.528e-06*(-X)",
|
||||||
|
"lat": "35.67327 + 2.117e-06*Z"
|
||||||
|
},
|
||||||
|
"real_to_world": {
|
||||||
|
"X": "-(lon-139.75654)/2.528e-06",
|
||||||
|
"Z": "(lat-35.67327)/2.117e-06"
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"+Z": "NORTH",
|
||||||
|
"-Z": "SOUTH",
|
||||||
|
"-X": "EAST",
|
||||||
|
"+X": "WEST"
|
||||||
|
},
|
||||||
|
"scale_studs_per_meter": 4.3,
|
||||||
|
"median_error_m": 366,
|
||||||
|
"loop_center_world": [
|
||||||
|
180,
|
||||||
|
-180
|
||||||
|
],
|
||||||
|
"verified": [
|
||||||
|
"Nihonbashi east of Palace",
|
||||||
|
"Tokyo Tower SW outside loop",
|
||||||
|
"Ginza/Tsukiji east",
|
||||||
|
"Nagatacho/Kasumigaseki west"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fnf-sim",
|
"name": "TokyoWanganRacing",
|
||||||
"tree": {
|
"tree": {
|
||||||
"$className": "DataModel",
|
"$className": "DataModel",
|
||||||
|
|
||||||
@@ -41,13 +41,15 @@
|
|||||||
"Lighting": {
|
"Lighting": {
|
||||||
"$className": "Lighting",
|
"$className": "Lighting",
|
||||||
"$properties": {
|
"$properties": {
|
||||||
"ClockTime": 0.0,
|
"Technology": "Future",
|
||||||
"Brightness": 1.0,
|
"ClockTime": 14.0,
|
||||||
"Ambient": [0.05, 0.05, 0.1],
|
"Brightness": 2.0,
|
||||||
"OutdoorAmbient": [0.1, 0.1, 0.18],
|
"Ambient": [0.35, 0.35, 0.38],
|
||||||
"FogEnd": 900.0,
|
"OutdoorAmbient": [0.45, 0.46, 0.5],
|
||||||
"FogColor": [0.03, 0.03, 0.08],
|
"FogEnd": 6000.0,
|
||||||
"EnvironmentDiffuseScale": 0.4,
|
"FogColor": [0.55, 0.6, 0.68],
|
||||||
|
"EnvironmentDiffuseScale": 1.0,
|
||||||
|
"EnvironmentSpecularScale": 1.0,
|
||||||
"GlobalShadows": true
|
"GlobalShadows": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Game Design Document — Fast & Furious: Tokyo Expressway (MVP)
|
# Game Design Document — Tokyo Wangan Racing (MVP)
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
|
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ anti-cheat) needs to change.
|
|||||||
The real car's reference photos live on the NAS (too large to commit to git):
|
The real car's reference photos live on the NAS (too large to commit to git):
|
||||||
|
|
||||||
```
|
```
|
||||||
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/
|
unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/
|
||||||
```
|
```
|
||||||
|
|
||||||
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
A single downscaled reference is included at `assets/reference/rx7-reference.png`
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ videos of the car are fine — audio gets extracted. Best results:
|
|||||||
|
|
||||||
Drop the files on the NAS next to the reference photos:
|
Drop the files on the NAS next to the reference photos:
|
||||||
```
|
```
|
||||||
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/sound/
|
unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/001-1993 Mazda RX-7/sound/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 2 — Process into seamless loops (this workstation)
|
## Step 2 — Process into seamless loops (this workstation)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Daikoku Futo PA — Measured Reference Spec
|
||||||
|
|
||||||
|
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
|
||||||
|
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
|
||||||
|
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
|
||||||
|
frame-sampled onboard video.
|
||||||
|
|
||||||
|
Generated 2026-08-03.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scale
|
||||||
|
|
||||||
|
| Quantity | Value |
|
||||||
|
|---|---|
|
||||||
|
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
|
||||||
|
| Reciprocal | 1 stud = 0.28 m |
|
||||||
|
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
|
||||||
|
|
||||||
|
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
|
||||||
|
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
|
||||||
|
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
|
||||||
|
> geometry. PA work uses 3.571.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The real PA footprint (OSM)
|
||||||
|
|
||||||
|
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
|
||||||
|
`35.46246657, 139.68149001` (user's Google Earth point).
|
||||||
|
|
||||||
|
| OSM way | Type | Extent (m) | Perimeter | Name |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
|
||||||
|
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
|
||||||
|
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
|
||||||
|
|
||||||
|
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
|
||||||
|
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
|
||||||
|
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
|
||||||
|
apron extent is not the problem.
|
||||||
|
|
||||||
|
The *shape* is the divergence: the build is an axis-aligned square; the real
|
||||||
|
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Loop ramp radii — MEASURED
|
||||||
|
|
||||||
|
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
|
||||||
|
`rms` = fit residual (low = genuinely circular).
|
||||||
|
|
||||||
|
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
|
||||||
|
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
|
||||||
|
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
|
||||||
|
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
|
||||||
|
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
|
||||||
|
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
|
||||||
|
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
|
||||||
|
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
|
||||||
|
|
||||||
|
**Headline numbers**
|
||||||
|
|
||||||
|
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
|
||||||
|
- The two cleanest circular fits (rms < 1.5) are the true helixes:
|
||||||
|
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
|
||||||
|
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
|
||||||
|
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
|
||||||
|
`右、右と覚えておくとPAに着きます` — "remember right, right".
|
||||||
|
|
||||||
|
**Recommended build values**
|
||||||
|
|
||||||
|
| Ramp | Radius (m) | Radius (studs) | Turn |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Main entry helix | 147 | **525** | ~220° CW |
|
||||||
|
| Secondary helix | 124 | **440** | ~160° CCW |
|
||||||
|
| Tight connector | 71 | **255** | ~270° CW |
|
||||||
|
|
||||||
|
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
|
||||||
|
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deck layers — the vertical stack
|
||||||
|
|
||||||
|
OSM `layer` tags on bridge ways in the interchange:
|
||||||
|
|
||||||
|
| Layer | Bridge ways | Total length |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 11 | 17,696 m |
|
||||||
|
| 2 | 7 | 5,637 m |
|
||||||
|
| 3 | 8 | 5,368 m |
|
||||||
|
| 4 | 10 | 10,096 m |
|
||||||
|
| 5 | 6 | 1,417 m |
|
||||||
|
|
||||||
|
**Five distinct deck levels.** This is the single most important structural
|
||||||
|
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
|
||||||
|
|
||||||
|
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
|
||||||
|
that is one level. A faithful build wants at least 3 visible levels overhead.
|
||||||
|
|
||||||
|
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
|
||||||
|
that is **36–43 studs per level**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Video-derived detail
|
||||||
|
|
||||||
|
Sources archived at
|
||||||
|
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
|
||||||
|
|
||||||
|
| Route | Video ID | Len | Light | Value |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
|
||||||
|
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
|
||||||
|
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
|
||||||
|
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
|
||||||
|
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
|
||||||
|
|
||||||
|
### Confirmed visual details
|
||||||
|
|
||||||
|
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
|
||||||
|
curve. Present in all five videos, day and night. Highest-value cheap
|
||||||
|
detail available.
|
||||||
|
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
|
||||||
|
underside (clearest at `yokohane_down` t=218–242s).
|
||||||
|
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
|
||||||
|
low concrete jersey base.
|
||||||
|
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
|
||||||
|
`50` km/h limit discs on ramps.
|
||||||
|
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
|
||||||
|
t=104s).
|
||||||
|
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
|
||||||
|
visible**, painted directional arrows, low guardrail perimeter.
|
||||||
|
7. Arrival reads as **descending into a canyon** — decks close in overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gaps / not yet measured
|
||||||
|
|
||||||
|
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
|
||||||
|
Needs either a DEM lookup or estimation from video pier counts.
|
||||||
|
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
|
||||||
|
are captured in full, the Honmoku run covers the approach only.
|
||||||
|
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
|
||||||
|
sampled.
|
||||||
|
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
|
||||||
|
not yet fitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
|
||||||
|
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
|
||||||
|
python3 /tmp/sat.py # Esri aerial tiles
|
||||||
|
```
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Japanese expressway road-standard reference (verified)
|
||||||
|
|
||||||
|
Compiled 2026-08-01 for the Tokyo Wangan Racing circuit. Every figure here was
|
||||||
|
read from a primary standard or an official sign artwork file, **not** from
|
||||||
|
recollection. Where a commonly-repeated number turned out to be wrong, that is
|
||||||
|
called out explicitly, because the wrong versions are widespread online.
|
||||||
|
|
||||||
|
## Road markings — 全標協 (Japan Road Marking Association) 2012 / 2018
|
||||||
|
|
||||||
|
| Element | Spec | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 車線境界線 lane boundary (dashed) | line length **3–10 m**; gap **1×–2× the line length**; width **10–15 cm** (15 cm "desirable") | 全標協 2012 p.42 |
|
||||||
|
| Lane boundary where overtaking undesirable (屈曲部/curves) | becomes **solid** instead of dashed | 全標協 2012 p.42 |
|
||||||
|
| Boundary vs 変速車線 (accel/decel lane at ramps) | length **2 m** (3 m on decel lanes), width **45 cm** | 全標協 2012 p.42 |
|
||||||
|
| 導流帯 gore / zebra hatching | **45 cm** white solid bars repeated every **1.0 m**; bounded by a 15–20 cm edge line | 全標協 2018 pp.97–98 |
|
||||||
|
| Zebra around an obstruction | 45 cm bars at 1.0 m, guided by arrows upstream | 全標協 2018 |
|
||||||
|
| Dashed extension of edge line through intersections | width 15–30 cm, line+gap every 0.5–2.0 m | 警察庁 2014 p.11 様式4 |
|
||||||
|
|
||||||
|
**Correction to a widely-repeated claim.** The "45 cm wide lines on expressways"
|
||||||
|
figure that circulates online is *not* the lane boundary — 全標協 2012 p.42 gives
|
||||||
|
45 cm only for the boundary between a through lane and a **変速車線**
|
||||||
|
(acceleration/deceleration lane). Applying 45 cm to ordinary lane dividers makes
|
||||||
|
the road look like it is all ramp. Lane dividers are 15 cm.
|
||||||
|
|
||||||
|
**On 8 m / 12 m.** The standard specifies a *range* (3–10 m line, gap 1–2×), not a
|
||||||
|
single value. 8 m line / 12 m gap sits inside that range at the high-speed end
|
||||||
|
(gap = 1.5× line) and is the configuration used at expressway speeds; a 5 m/5 m
|
||||||
|
rhythm reads as a surface street. Treat 8/12 as "a legal expressway
|
||||||
|
configuration", not as "the one legally mandated number".
|
||||||
|
|
||||||
|
## Colour values — sampled from official sign artwork, not estimated
|
||||||
|
|
||||||
|
| Use | Value |
|
||||||
|
|---|---|
|
||||||
|
| Guide sign (案内標識) green | **#005D35** |
|
||||||
|
| Shutoko route badge green | **#007F1F** |
|
||||||
|
| Sign lettering | white #FFFFFF |
|
||||||
|
|
||||||
|
Japanese **expressway** guide signs are green; ordinary-road guide signs are
|
||||||
|
blue. Shutoko uses green.
|
||||||
|
|
||||||
|
## Speed limits (from the route articles' own signage tables)
|
||||||
|
|
||||||
|
| Route | Posted |
|
||||||
|
|---|---|
|
||||||
|
| C1 都心環状線 (Inner Circular) | **50 km/h** mainline, **40 km/h** inside the junction sections — 江戸橋JCT内, 浜崎橋JCT内, and 京橋JCT–旧白魚橋乗継所 are each posted 40 in the route's own 車線・最高速度 table |
|
||||||
|
| B 湾岸線 (Bayshore/Wangan) | **80 km/h** (60–70 in places) |
|
||||||
|
|
||||||
|
The C1 and the Wangan are **not** the same limit — a C1-shaped circuit should be
|
||||||
|
posted 50, not 80.
|
||||||
|
|
||||||
|
## Caveats / not verified
|
||||||
|
|
||||||
|
- Lamp pole spacing (~35 m) and parapet heights are reasonable engineering
|
||||||
|
values but were **not** confirmed against a primary source; they are tuned by
|
||||||
|
eye for gameplay readability.
|
||||||
|
- Asphalt/concrete RGB values in MapBuilder are art direction, not measured
|
||||||
|
photometry. Porous asphalt (排水性舗装) does read darker and coarser than
|
||||||
|
ordinary asphalt, which the palette reflects.
|
||||||
|
|
||||||
|
## Structural sections of the C1 (drives MapBuilder geometry)
|
||||||
|
|
||||||
|
The C1 is not a uniform elevated tube — it changes structural type along its
|
||||||
|
length, and the circuit now renders each type differently.
|
||||||
|
|
||||||
|
| Section | Real-world form | In-game |
|
||||||
|
|---|---|---|
|
||||||
|
| Elevated viaduct (most of the loop) | Deck on concrete columns | `Pillar` every 3rd sample above Y=18 |
|
||||||
|
| Bridge spans (474 flagged samples) | Steel box girder 鋼箱桁橋 over the old canals | `BridgeGirder` + `GirderRib` under deck; `PierCap` + round `PierColumn` on a 6-sample pitch |
|
||||||
|
| Ginza 銀座 | Runs **below** street level in the filled-in 楓川・築地川 river beds; surviving city bridges cross **over** it, piers landing in the median | `GinzaOverpass` + `OverpassParapet`, `OverpassPier` in the median |
|
||||||
|
| Tunnel sections | Bored/cut-and-cover | Tunnel bore, ceiling, no sky lighting |
|
||||||
|
|
||||||
|
Girder depth is set from the span/depth rule of thumb for steel box girders
|
||||||
|
(≈1/20 of span): at ~35 m spans that is ~1.8 m ≈ 6.4 studs (`GIRDER_H`).
|
||||||
|
|
||||||
|
**Why Ginza bans lane changes.** The no-lane-change marking through Ginza is not
|
||||||
|
arbitrary styling — the crossing bridges' piers physically occupy the median
|
||||||
|
between the carriageways, so the lanes are separated by structure. The yellow
|
||||||
|
line and the `OverpassPier` geometry are two expressions of the same fact.
|
||||||
|
|
||||||
|
## Lane counts
|
||||||
|
|
||||||
|
| Section | Lanes |
|
||||||
|
|---|---|
|
||||||
|
| Most of C1 | 2 + 2 |
|
||||||
|
| Shiodome JCT → Hamazakibashi JCT | **3 + 3** (widened; carries the merge from the Haneda line) |
|
||||||
|
|
||||||
|
Rendered by `widthAt(i)`: 60 studs baseline, 82 studs in `WIDE_ZONES`, with a
|
||||||
|
taper between so the widening never steps more than ~1.7 studs per sample.
|
||||||
|
|
||||||
|
## Method note
|
||||||
|
|
||||||
|
Wikipedia article *extracts* (`prop=extracts`) silently drop the infobox and
|
||||||
|
table markup that carry these figures — the measurements only appear in the raw
|
||||||
|
wikitext (`prop=revisions&rvslots=main`). Query the wikitext when hunting for
|
||||||
|
specs.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# PA Ramp Build Spec — reconciled
|
||||||
|
|
||||||
|
Written 2026-08-03 after research + measurement. Supersedes ad-hoc azimuth
|
||||||
|
tuning in `MapBuilder.luau`.
|
||||||
|
|
||||||
|
## Source conflict — READ THIS FIRST
|
||||||
|
|
||||||
|
Two sources disagree, and the disagreement matters:
|
||||||
|
|
||||||
|
| | Generic standard (AASHTO/FHWA/JP, via research) | Measured Daikoku (OSM circle fits) |
|
||||||
|
|---|---|---|
|
||||||
|
| Ramp form | short shallow **parallel slip road** | **loop helix**, 150–220° of turn |
|
||||||
|
| Radius | 80–120 m (285–430 studs) | 71–204 m, mean 131 m (**469 studs**) |
|
||||||
|
| Arc | 30–45° | 217° (main helix, rms 1.2 = truly circular) |
|
||||||
|
|
||||||
|
**Resolution: measured Daikoku wins for THIS build.** The generic spec describes
|
||||||
|
a typical roadside PA on a flat ground-level expressway. Daikoku PA is not that
|
||||||
|
— it sits at the bottom of a **five-level junction stack** on reclaimed land, so
|
||||||
|
its ramps are genuinely helical loops, not slip roads. The satellite plan view
|
||||||
|
(`aerial/daikoku_z19.jpg`) shows one large-radius arc sweeping around the lot.
|
||||||
|
|
||||||
|
The generic spec is still authoritative for the **merge detail** (below), which
|
||||||
|
is not Daikoku-specific.
|
||||||
|
|
||||||
|
## Numbers to build to
|
||||||
|
|
||||||
|
| Quantity | Value | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Scale | 3.571 studs/m (1 stud = 0.28 m) | project |
|
||||||
|
| Main entry helix radius | **525 studs** (147 m) | OSM way 32403901, rms 1.2 |
|
||||||
|
| Secondary helix radius | **440 studs** (124 m) | OSM way 779045459, rms 0.7 |
|
||||||
|
| Tight connector radius | 255 studs (71 m) | OSM way 32355896 |
|
||||||
|
| Turn direction | **clockwise (right) — BOTH ramps** | 6 of 8 OSM ways; video 「右、右」 |
|
||||||
|
| Divergence at merge | 2–5° (current build: 0.1–0.2°, tangent) | AASHTO/FHWA |
|
||||||
|
| Deceleration lane | 150–200 m before gore | AASHTO |
|
||||||
|
| Acceleration lane | 200–300 m after gore | AASHTO |
|
||||||
|
| Gore apex angle | < 5°, chevron-painted over 30–50 m | AASHTO |
|
||||||
|
| Superelevation | 4–8% bank on curves | AASHTO |
|
||||||
|
| Deck-to-deck spacing | 36–43 studs/level (10–12 m) | JP standard |
|
||||||
|
|
||||||
|
## Hard constraints (checkable in a probe)
|
||||||
|
|
||||||
|
1. **Entry and exit ramps must NEVER intersect.** Current build fails this:
|
||||||
|
2547 point-pairs within 30 studs planar at < 12 studs dY, worst case
|
||||||
|
**0.5 studs apart at dY = 0.0**. This is the dominant visual defect —
|
||||||
|
it reads as a bowtie tangle. Probe must assert 0 such pairs.
|
||||||
|
2. Both ramps curve **the same direction** (clockwise). They are at opposite
|
||||||
|
ends of the lot and wrap the same way; they do not mirror into each other.
|
||||||
|
3. Ramp arc wraps **outside** the parking apron, forming its boundary — it does
|
||||||
|
not cut across the lot interior.
|
||||||
|
4. Merge is tangent (already achieved: 0.1–0.2°).
|
||||||
|
5. No ramp part intersects any Workspace-level building model (obstacle scope
|
||||||
|
must be INVERTED, not a name whitelist — see verification skill).
|
||||||
|
|
||||||
|
## Edge / structure treatment (from onboard video + Commons photos)
|
||||||
|
|
||||||
|
- Edge = **low concrete jersey base + see-through chain-link mesh fence above**.
|
||||||
|
NOT a tall solid parapet. Current build's heavy kerb chain is why the ramps
|
||||||
|
read as thick white rails.
|
||||||
|
- **Red/orange chevron shoulder paint** through every ramp curve — present in
|
||||||
|
all five reference videos, day and night. Cheapest high-value detail.
|
||||||
|
- Piers: **large cylindrical columns**, widely spaced, under a **trapezoidal
|
||||||
|
box-girder** deck underside. White/pale concrete.
|
||||||
|
- White zebra hatching fills the gore wedge.
|
||||||
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
# PA Ramp Reference Images
|
||||||
|
|
||||||
|
Downloaded 2026-08-03. All files verified present on disk and integrity-checked
|
||||||
|
with PIL (12/12 valid, 0 corrupt).
|
||||||
|
|
||||||
|
## Wikimedia Commons (fetched by research agent)
|
||||||
|
|
||||||
|
| File | Px | Shows | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Daikoku-PA.JPG` | 2487x1797 | Daikoku PA overview | CC BY 3.0 |
|
||||||
|
| `Daikoku-JCT.JPG` | 2560x1920 | Daikoku JCT elevated stack | CC BY 3.0 |
|
||||||
|
| `DaikokuJCT-Bnishi.JPG` | 640x480 | Daikoku JCT bayside west | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_daikokuPA.jpg` | 1280x960 | Daikoku PA elevated structure | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_oiPA_east.jpg` | 1280x960 | Oi PA east | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_oiPA_west.jpg` | 1280x960 | Oi PA west (ground level, facility bldg) | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_heiwajimaPA_up.jpg` | 1280x960 | Heiwajima PA inbound | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_heiwajimaPA_down.jpg` | 1280x960 | Heiwajima PA outbound | CC BY-SA 3.0 |
|
||||||
|
| `Shutoko_shibauraPA.jpg` | 1280x960 | Shibaura PA | CC BY-SA 3.0 |
|
||||||
|
| `Shuto_expressway_shibaura_pa.jpg` | 1000x680 | Shibaura PA / expressway | CC BY-SA 3.0 |
|
||||||
|
|
||||||
|
License note: all CC BY / CC BY-SA. Reference-only for shape study. Do NOT
|
||||||
|
trace directly into shipped assets without attribution (see
|
||||||
|
`reference-image-research` skill, license matrix).
|
||||||
|
|
||||||
|
## From project NAS archive (higher value for geometry)
|
||||||
|
|
||||||
|
| File | Px | Shows |
|
||||||
|
|---|---|---|
|
||||||
|
| `aerial/daikoku_z19.jpg` | Esri z19 | **Best plan view.** Satellite of Daikoku PA: single large-radius arc wrapping the lot |
|
||||||
|
| `aerial/daikoku_bldg_big.jpg` | | Daikoku facility building aerial |
|
||||||
|
| `daikoku_jct_sheet2.jpg` | 1728x972 | Onboard contact sheet, Daikoku JCT |
|
||||||
|
| `exit_to_baybridge_sheet2.jpg` | 1728x1296 | **Best ground view.** Off-ramp divergence, gore hatching, jersey+mesh edge, box girder |
|
||||||
|
|
||||||
|
## Measured source of truth
|
||||||
|
|
||||||
|
`docs/daikoku-reference-spec.md` — OSM Overpass geometry with Kasa circle fits
|
||||||
|
of the actual ramp ways. This is measured, not estimated, and outranks generic
|
||||||
|
standards for Daikoku-specific radii.
|
||||||
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,160 @@
|
|||||||
|
# Daikoku Futo PA — Measured Reference Spec
|
||||||
|
|
||||||
|
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
|
||||||
|
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
|
||||||
|
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
|
||||||
|
frame-sampled onboard video.
|
||||||
|
|
||||||
|
Generated 2026-08-03.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scale
|
||||||
|
|
||||||
|
| Quantity | Value |
|
||||||
|
|---|---|
|
||||||
|
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
|
||||||
|
| Reciprocal | 1 stud = 0.28 m |
|
||||||
|
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
|
||||||
|
|
||||||
|
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
|
||||||
|
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
|
||||||
|
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
|
||||||
|
> geometry. PA work uses 3.571.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The real PA footprint (OSM)
|
||||||
|
|
||||||
|
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
|
||||||
|
`35.46246657, 139.68149001` (user's Google Earth point).
|
||||||
|
|
||||||
|
| OSM way | Type | Extent (m) | Perimeter | Name |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
|
||||||
|
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
|
||||||
|
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
|
||||||
|
|
||||||
|
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
|
||||||
|
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
|
||||||
|
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
|
||||||
|
apron extent is not the problem.
|
||||||
|
|
||||||
|
The *shape* is the divergence: the build is an axis-aligned square; the real
|
||||||
|
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Loop ramp radii — MEASURED
|
||||||
|
|
||||||
|
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
|
||||||
|
`rms` = fit residual (low = genuinely circular).
|
||||||
|
|
||||||
|
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
|
||||||
|
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
|
||||||
|
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
|
||||||
|
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
|
||||||
|
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
|
||||||
|
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
|
||||||
|
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
|
||||||
|
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
|
||||||
|
|
||||||
|
**Headline numbers**
|
||||||
|
|
||||||
|
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
|
||||||
|
- The two cleanest circular fits (rms < 1.5) are the true helixes:
|
||||||
|
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
|
||||||
|
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
|
||||||
|
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
|
||||||
|
`右、右と覚えておくとPAに着きます` — "remember right, right".
|
||||||
|
|
||||||
|
**Recommended build values**
|
||||||
|
|
||||||
|
| Ramp | Radius (m) | Radius (studs) | Turn |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Main entry helix | 147 | **525** | ~220° CW |
|
||||||
|
| Secondary helix | 124 | **440** | ~160° CCW |
|
||||||
|
| Tight connector | 71 | **255** | ~270° CW |
|
||||||
|
|
||||||
|
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
|
||||||
|
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deck layers — the vertical stack
|
||||||
|
|
||||||
|
OSM `layer` tags on bridge ways in the interchange:
|
||||||
|
|
||||||
|
| Layer | Bridge ways | Total length |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 11 | 17,696 m |
|
||||||
|
| 2 | 7 | 5,637 m |
|
||||||
|
| 3 | 8 | 5,368 m |
|
||||||
|
| 4 | 10 | 10,096 m |
|
||||||
|
| 5 | 6 | 1,417 m |
|
||||||
|
|
||||||
|
**Five distinct deck levels.** This is the single most important structural
|
||||||
|
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
|
||||||
|
|
||||||
|
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
|
||||||
|
that is one level. A faithful build wants at least 3 visible levels overhead.
|
||||||
|
|
||||||
|
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
|
||||||
|
that is **36–43 studs per level**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Video-derived detail
|
||||||
|
|
||||||
|
Sources archived at
|
||||||
|
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
|
||||||
|
|
||||||
|
| Route | Video ID | Len | Light | Value |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
|
||||||
|
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
|
||||||
|
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
|
||||||
|
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
|
||||||
|
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
|
||||||
|
|
||||||
|
### Confirmed visual details
|
||||||
|
|
||||||
|
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
|
||||||
|
curve. Present in all five videos, day and night. Highest-value cheap
|
||||||
|
detail available.
|
||||||
|
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
|
||||||
|
underside (clearest at `yokohane_down` t=218–242s).
|
||||||
|
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
|
||||||
|
low concrete jersey base.
|
||||||
|
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
|
||||||
|
`50` km/h limit discs on ramps.
|
||||||
|
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
|
||||||
|
t=104s).
|
||||||
|
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
|
||||||
|
visible**, painted directional arrows, low guardrail perimeter.
|
||||||
|
7. Arrival reads as **descending into a canyon** — decks close in overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gaps / not yet measured
|
||||||
|
|
||||||
|
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
|
||||||
|
Needs either a DEM lookup or estimation from video pier counts.
|
||||||
|
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
|
||||||
|
are captured in full, the Honmoku run covers the approach only.
|
||||||
|
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
|
||||||
|
sampled.
|
||||||
|
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
|
||||||
|
not yet fitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
|
||||||
|
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
|
||||||
|
python3 /tmp/sat.py # Esri aerial tiles
|
||||||
|
```
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pull real Daikoku JCT / PA geometry from OpenStreetMap via Overpass.
|
||||||
|
|
||||||
|
Outputs measured, real-world numbers:
|
||||||
|
- PA service-area polygon (extent, area)
|
||||||
|
- Each ramp/link way: length, turn direction, fitted circle radius
|
||||||
|
- Bridge levels present
|
||||||
|
|
||||||
|
All distances in metres. Local ENU projection about the PA centre so we can
|
||||||
|
fit circles in a flat plane without map-projection distortion.
|
||||||
|
"""
|
||||||
|
import json, math, urllib.request, urllib.parse, sys
|
||||||
|
|
||||||
|
LAT0, LON0 = 35.46246657, 139.68149001 # user's Google Earth point
|
||||||
|
R_EARTH = 6378137.0
|
||||||
|
|
||||||
|
# Generous bbox around the whole interchange
|
||||||
|
S, W, N, E = 35.4545, 139.6700, 35.4705, 139.6935
|
||||||
|
|
||||||
|
Q = f"""
|
||||||
|
[out:json][timeout:120];
|
||||||
|
(
|
||||||
|
way["highway"]({S},{W},{N},{E});
|
||||||
|
way["amenity"="parking"]({S},{W},{N},{E});
|
||||||
|
way["highway"="services"]({S},{W},{N},{E});
|
||||||
|
way["highway"="rest_area"]({S},{W},{N},{E});
|
||||||
|
);
|
||||||
|
out body geom;
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENDPOINTS = [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch():
|
||||||
|
last = None
|
||||||
|
for ep in ENDPOINTS:
|
||||||
|
try:
|
||||||
|
data = urllib.parse.urlencode({"data": Q}).encode()
|
||||||
|
req = urllib.request.Request(ep, data=data,
|
||||||
|
headers={"User-Agent": "daikoku-ref/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=150) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{ep}: {e}"
|
||||||
|
print(f" endpoint failed {last}", file=sys.stderr)
|
||||||
|
raise SystemExit(f"all overpass endpoints failed: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def enu(lat, lon):
|
||||||
|
"""Local east/north metres relative to (LAT0, LON0)."""
|
||||||
|
x = math.radians(lon - LON0) * R_EARTH * math.cos(math.radians(LAT0))
|
||||||
|
y = math.radians(lat - LAT0) * R_EARTH
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def polyline_len(pts):
|
||||||
|
return sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def fit_circle(pts):
|
||||||
|
"""Algebraic (Kasa) circle fit. Returns (cx, cy, R, rms_residual)."""
|
||||||
|
n = len(pts)
|
||||||
|
if n < 3:
|
||||||
|
return None
|
||||||
|
sx = sum(p[0] for p in pts) / n
|
||||||
|
sy = sum(p[1] for p in pts) / n
|
||||||
|
u = [p[0] - sx for p in pts]
|
||||||
|
v = [p[1] - sy for p in pts]
|
||||||
|
Suu = sum(a * a for a in u); Svv = sum(b * b for b in v)
|
||||||
|
Suv = sum(u[i] * v[i] for i in range(n))
|
||||||
|
Suuu = sum(a ** 3 for a in u); Svvv = sum(b ** 3 for b in v)
|
||||||
|
Suvv = sum(u[i] * v[i] * v[i] for i in range(n))
|
||||||
|
Svuu = sum(v[i] * u[i] * u[i] for i in range(n))
|
||||||
|
d = 2 * (Suu * Svv - Suv * Suv)
|
||||||
|
if abs(d) < 1e-9:
|
||||||
|
return None
|
||||||
|
uc = (Svv * (Suuu + Suvv) - Suv * (Svvv + Svuu)) / d
|
||||||
|
vc = (Suu * (Svvv + Svuu) - Suv * (Suuu + Suvv)) / d
|
||||||
|
cx, cy = uc + sx, vc + sy
|
||||||
|
rs = [math.dist(p, (cx, cy)) for p in pts]
|
||||||
|
R = sum(rs) / len(rs)
|
||||||
|
rms = math.sqrt(sum((r - R) ** 2 for r in rs) / len(rs))
|
||||||
|
return cx, cy, R, rms
|
||||||
|
|
||||||
|
|
||||||
|
def total_turn(pts):
|
||||||
|
"""Signed cumulative heading change in degrees. +ccw / -cw."""
|
||||||
|
tot = 0.0
|
||||||
|
for i in range(1, len(pts) - 1):
|
||||||
|
a1 = math.atan2(pts[i][1] - pts[i - 1][1], pts[i][0] - pts[i - 1][0])
|
||||||
|
a2 = math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0])
|
||||||
|
d = math.degrees(a2 - a1)
|
||||||
|
while d > 180: d -= 360
|
||||||
|
while d < -180: d += 360
|
||||||
|
tot += d
|
||||||
|
return tot
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = fetch()
|
||||||
|
els = data.get("elements", [])
|
||||||
|
print(f"overpass returned {len(els)} ways\n")
|
||||||
|
|
||||||
|
ramps, areas = [], []
|
||||||
|
for el in els:
|
||||||
|
if el.get("type") != "way" or "geometry" not in el:
|
||||||
|
continue
|
||||||
|
t = el.get("tags", {})
|
||||||
|
pts = [enu(g["lat"], g["lon"]) for g in el["geometry"]]
|
||||||
|
if len(pts) < 2:
|
||||||
|
continue
|
||||||
|
rec = {"id": el["id"], "tags": t, "pts": pts,
|
||||||
|
"len": polyline_len(pts)}
|
||||||
|
hw = t.get("highway", "")
|
||||||
|
if hw in ("services", "rest_area") or t.get("amenity") == "parking":
|
||||||
|
areas.append(rec)
|
||||||
|
elif hw.endswith("_link") or t.get("oneway") == "yes":
|
||||||
|
ramps.append(rec)
|
||||||
|
|
||||||
|
# ---- PA area ----
|
||||||
|
print("=" * 70)
|
||||||
|
print("PARKING / SERVICE AREA POLYGONS")
|
||||||
|
print("=" * 70)
|
||||||
|
for a in sorted(areas, key=lambda r: -r["len"])[:6]:
|
||||||
|
xs = [p[0] for p in a["pts"]]; ys = [p[1] for p in a["pts"]]
|
||||||
|
w = max(xs) - min(xs); h = max(ys) - min(ys)
|
||||||
|
nm = a["tags"].get("name", a["tags"].get("name:en", "-"))
|
||||||
|
print(f" way {a['id']:>12} {a['tags'].get('highway') or a['tags'].get('amenity'):<10}"
|
||||||
|
f" extent {w:6.0f} x {h:6.0f} m perim {a['len']:7.0f} m {nm}")
|
||||||
|
print(f" E {min(xs):7.0f}..{max(xs):7.0f} N {min(ys):7.0f}..{max(ys):7.0f}")
|
||||||
|
|
||||||
|
# ---- curved ramps: the loops ----
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("CURVED RAMPS (|turn| >= 120 deg) -- the helix loops")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" {'way id':>12} {'len(m)':>7} {'turn':>8} {'radius(m)':>10} {'rms':>6} layer/bridge")
|
||||||
|
loops = []
|
||||||
|
for r in ramps:
|
||||||
|
turn = total_turn(r["pts"])
|
||||||
|
if abs(turn) < 120 or r["len"] < 60:
|
||||||
|
continue
|
||||||
|
fc = fit_circle(r["pts"])
|
||||||
|
if not fc:
|
||||||
|
continue
|
||||||
|
cx, cy, R, rms = fc
|
||||||
|
if R > 400: # not a tight loop
|
||||||
|
continue
|
||||||
|
loops.append((r, turn, cx, cy, R, rms))
|
||||||
|
for r, turn, cx, cy, R, rms in sorted(loops, key=lambda z: -abs(z[1])):
|
||||||
|
t = r["tags"]
|
||||||
|
lb = f"layer={t.get('layer','-')} bridge={t.get('bridge','-')}"
|
||||||
|
print(f" {r['id']:>12} {r['len']:7.0f} {turn:7.0f}\u00b0 {R:10.1f} {rms:6.1f} {lb}")
|
||||||
|
print(f" centre E {cx:7.0f} N {cy:7.0f} "
|
||||||
|
f"{'CCW/left' if turn>0 else 'CW/right'}")
|
||||||
|
|
||||||
|
if loops:
|
||||||
|
rr = [z[4] for z in loops]
|
||||||
|
print(f"\n loop radius: min {min(rr):.0f} m max {max(rr):.0f} m "
|
||||||
|
f"mean {sum(rr)/len(rr):.0f} m")
|
||||||
|
print(f" in studs (1 stud = 0.28 m): mean {sum(rr)/len(rr)/0.28:.0f} studs")
|
||||||
|
|
||||||
|
# ---- layers present ----
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("DECK LAYERS (bridge ways)")
|
||||||
|
print("=" * 70)
|
||||||
|
lay = {}
|
||||||
|
for r in ramps:
|
||||||
|
if r["tags"].get("bridge"):
|
||||||
|
L = r["tags"].get("layer", "0")
|
||||||
|
lay.setdefault(L, []).append(r["len"])
|
||||||
|
for L in sorted(lay, key=lambda s: int(s) if s.lstrip("-").isdigit() else 0):
|
||||||
|
v = lay[L]
|
||||||
|
print(f" layer {L:>3}: {len(v):3d} bridge ways, total {sum(v):8.0f} m")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download a route video, sample frames every N seconds, and build labelled
|
||||||
|
contact sheets. Built for Daikoku entrance/exit reference gathering.
|
||||||
|
|
||||||
|
Usage: route.py <videoid> <shortname> [step_seconds]
|
||||||
|
"""
|
||||||
|
import os, re, subprocess, sys, glob, json
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
YTDLP = os.path.expanduser("~/.local/bin/yt-dlp")
|
||||||
|
ROOT = "/tmp/routes"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kw):
|
||||||
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def meta(vid):
|
||||||
|
r = run(f'{YTDLP} --no-warnings -J --skip-download "https://www.youtube.com/watch?v={vid}" 2>/dev/null')
|
||||||
|
try:
|
||||||
|
d = json.loads(r.stdout)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {k: d.get(k) for k in ("title", "uploader", "duration", "upload_date", "width", "height")}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(vid, name):
|
||||||
|
d = f"{ROOT}/{name}"
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
mp4 = f"{d}/{name}.mp4"
|
||||||
|
if not os.path.exists(mp4):
|
||||||
|
r = run(f'{YTDLP} -f "bv*[height<=1080][ext=mp4]/bv*[height<=1080]/b" '
|
||||||
|
f'--no-warnings -o "{mp4}" "https://www.youtube.com/watch?v={vid}"')
|
||||||
|
if not os.path.exists(mp4):
|
||||||
|
print(f" DOWNLOAD FAILED: {r.stderr[-300:]}")
|
||||||
|
return None
|
||||||
|
return mp4
|
||||||
|
|
||||||
|
|
||||||
|
def sample(mp4, d, step):
|
||||||
|
dur = float(run(f'ffprobe -v error -show_entries format=duration '
|
||||||
|
f'-of default=nw=1:nk=1 "{mp4}"').stdout.strip() or 0)
|
||||||
|
for f in glob.glob(f"{d}/fr_*.jpg"):
|
||||||
|
os.remove(f)
|
||||||
|
ts = list(range(2, int(dur), step))
|
||||||
|
for t in ts:
|
||||||
|
run(f'ffmpeg -v error -ss {t} -i "{mp4}" -frames:v 1 -q:v 2 '
|
||||||
|
f'-vf scale=960:-1 "{d}/fr_{t:04d}.jpg" -y')
|
||||||
|
return dur, ts
|
||||||
|
|
||||||
|
|
||||||
|
def sheets(d, name, cols=3, per=9):
|
||||||
|
fs = sorted(glob.glob(f"{d}/fr_*.jpg"))
|
||||||
|
out = []
|
||||||
|
for page in range((len(fs) + per - 1) // per):
|
||||||
|
sub = fs[page * per:(page + 1) * per]
|
||||||
|
ims = [Image.open(f).convert("RGB") for f in sub]
|
||||||
|
w, h = ims[0].size
|
||||||
|
rows = (len(sub) + cols - 1) // cols
|
||||||
|
s = Image.new("RGB", (w * cols, h * rows), (10, 10, 10))
|
||||||
|
for i, im in enumerate(ims):
|
||||||
|
s.paste(im, ((i % cols) * w, (i // cols) * h))
|
||||||
|
dr = ImageDraw.Draw(s)
|
||||||
|
for i, f in enumerate(sub):
|
||||||
|
t = int(re.search(r"fr_(\d+)", f).group(1))
|
||||||
|
x, y = (i % cols) * w, (i // cols) * h
|
||||||
|
dr.rectangle([x + 6, y + 6, x + 190, y + 54], fill=(0, 0, 0))
|
||||||
|
dr.text((x + 20, y + 22), f"t={t}s", fill=(255, 225, 0))
|
||||||
|
s = s.resize((int(s.width * 0.60), int(s.height * 0.60)), Image.LANCZOS)
|
||||||
|
o = f"{d}/{name}_sheet{page+1}.jpg"
|
||||||
|
s.save(o, quality=90)
|
||||||
|
out.append(o)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
vid, name = sys.argv[1], sys.argv[2]
|
||||||
|
step = int(sys.argv[3]) if len(sys.argv) > 3 else 8
|
||||||
|
m = meta(vid)
|
||||||
|
print(f"=== {name} [{vid}] ===")
|
||||||
|
print(f" title: {m.get('title')}")
|
||||||
|
print(f" dur : {m.get('duration')}s {m.get('width')}x{m.get('height')}")
|
||||||
|
mp4 = fetch(vid, name)
|
||||||
|
if not mp4:
|
||||||
|
sys.exit(1)
|
||||||
|
d = f"{ROOT}/{name}"
|
||||||
|
dur, ts = sample(mp4, d, step)
|
||||||
|
sh = sheets(d, name)
|
||||||
|
print(f" frames: {len(ts)} @ {step}s sheets: {len(sh)}")
|
||||||
|
for s in sh:
|
||||||
|
print(f" {s}")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch + stitch Esri World Imagery tiles (no API key) around a lat/lon.
|
||||||
|
|
||||||
|
Esri World Imagery over Japan is high-res aerial. Tile scheme is standard
|
||||||
|
Web Mercator XYZ, same as OSM.
|
||||||
|
"""
|
||||||
|
import math, sys, urllib.request, io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
URL = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) research/1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def deg2tile(lat, lon, z):
|
||||||
|
lat_r = math.radians(lat)
|
||||||
|
n = 2.0 ** z
|
||||||
|
xt = (lon + 180.0) / 360.0 * n
|
||||||
|
yt = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
|
||||||
|
return xt, yt
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(z, x, y):
|
||||||
|
u = URL.format(z=z, x=x, y=y)
|
||||||
|
req = urllib.request.Request(u, headers=UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def build(lat, lon, z, half, out):
|
||||||
|
xt, yt = deg2tile(lat, lon, z)
|
||||||
|
cx, cy = int(xt), int(yt)
|
||||||
|
n = half * 2 + 1
|
||||||
|
canvas = Image.new("RGB", (256 * n, 256 * n))
|
||||||
|
ok = 0
|
||||||
|
for i, x in enumerate(range(cx - half, cx + half + 1)):
|
||||||
|
for j, y in enumerate(range(cy - half, cy + half + 1)):
|
||||||
|
try:
|
||||||
|
canvas.paste(fetch(z, x, y), (i * 256, j * 256))
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" tile {z}/{x}/{y} FAIL {e}", file=sys.stderr)
|
||||||
|
canvas.save(out, quality=92)
|
||||||
|
# metres per pixel at this latitude
|
||||||
|
mpp = 156543.03392 * math.cos(math.radians(lat)) / (2 ** z)
|
||||||
|
span = mpp * 256 * n
|
||||||
|
print(f"{out} z={z} tiles={ok}/{n*n} size={canvas.size} "
|
||||||
|
f"mpp={mpp:.3f} span={span:.0f}m")
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
LAT, LON = 35.46246657, 139.68149001
|
||||||
|
build(LAT, LON, 17, 2, "/tmp/daikoku_z17.jpg") # wide: whole loop
|
||||||
|
build(LAT, LON, 18, 2, "/tmp/daikoku_z18.jpg") # closer
|
||||||
|
build(LAT, LON, 19, 2, "/tmp/daikoku_z19.jpg") # detail: stalls
|
||||||
|
After Width: | Height: | Size: 303 KiB |
@@ -1,5 +1,5 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rojo = "rojo-rbx/rojo@7.5.1"
|
rojo = "rojo-rbx/rojo@7.7.0"
|
||||||
wally = "UpliftGames/[email protected]"
|
wally = "UpliftGames/[email protected]"
|
||||||
selene = "Kampfkarren/[email protected]"
|
selene = "Kampfkarren/[email protected]"
|
||||||
stylua = "JohnnyMorganz/[email protected]"
|
stylua = "JohnnyMorganz/[email protected]"
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
--!strict
|
||||||
|
-- verify_map.luau — automated map regression test.
|
||||||
|
--
|
||||||
|
-- Run from Linux:
|
||||||
|
-- python3 -c "import sys;sys.path.insert(0,'/tmp');import rbx;
|
||||||
|
-- print(rbx.call('execute_luau',
|
||||||
|
-- {'code':open('scripts/verify_map.luau').read(),'datamodel_type':'Edit'},
|
||||||
|
-- wait=90,timeout=600))"
|
||||||
|
--
|
||||||
|
-- Exits with a PASS/FAIL summary. Every check is a real geometric assertion
|
||||||
|
-- against the built map -- no check trusts that a build "ran".
|
||||||
|
--
|
||||||
|
-- IMPORTANT: this must be run against a map built from CURRENT source. Studio
|
||||||
|
-- caches ModuleScripts, so a stale Circuit will silently pass the shape checks
|
||||||
|
-- while describing the wrong circuit. Check 1 guards exactly that.
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local EXPECT_SAMPLES = 823
|
||||||
|
local EXPECT_TUNNEL = 200
|
||||||
|
local EXPECT_BRIDGE = 474
|
||||||
|
local ROAD_HALF = 24 -- ROAD_WIDTH 48 / 2
|
||||||
|
local CAR_HALF_W = 5 -- 10-stud car
|
||||||
|
local CAR_HEIGHT = 5.5
|
||||||
|
|
||||||
|
local results: { string } = {}
|
||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function check(name: string, ok: boolean, detail: string)
|
||||||
|
if ok then
|
||||||
|
table.insert(results, (" PASS %-34s %s"):format(name, detail))
|
||||||
|
else
|
||||||
|
failures += 1
|
||||||
|
table.insert(results, (" FAIL %-34s %s"):format(name, detail))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fresh-require the circuit so a cached stale module can't mask a bad build.
|
||||||
|
local orig = RS.Shared:FindFirstChild("Circuit")
|
||||||
|
if not orig then
|
||||||
|
return "FATAL: ReplicatedStorage.Shared.Circuit missing"
|
||||||
|
end
|
||||||
|
orig.Name = "_CircuitOld"
|
||||||
|
local fresh = orig:Clone()
|
||||||
|
fresh.Name = "Circuit"
|
||||||
|
fresh.Parent = RS.Shared
|
||||||
|
local ok, C = pcall(require, fresh)
|
||||||
|
local function cleanup()
|
||||||
|
fresh:Destroy()
|
||||||
|
orig.Name = "Circuit"
|
||||||
|
end
|
||||||
|
if not ok then
|
||||||
|
cleanup()
|
||||||
|
return "FATAL: Circuit require failed: " .. tostring(C)
|
||||||
|
end
|
||||||
|
|
||||||
|
local s = C.samples
|
||||||
|
local n = #s
|
||||||
|
|
||||||
|
-- 1. Source freshness ------------------------------------------------------
|
||||||
|
local nTunnel, nBridge = 0, 0
|
||||||
|
for i = 1, n do
|
||||||
|
if s[i].tunnel then
|
||||||
|
nTunnel += 1
|
||||||
|
end
|
||||||
|
if s[i].bridge then
|
||||||
|
nBridge += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(
|
||||||
|
"circuit is current source",
|
||||||
|
n == EXPECT_SAMPLES and nTunnel == EXPECT_TUNNEL and nBridge == EXPECT_BRIDGE,
|
||||||
|
("samples=%d tunnel=%d bridge=%d"):format(n, nTunnel, nBridge)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 2. Loop closure + spacing ------------------------------------------------
|
||||||
|
local minGap, maxGap = math.huge, 0
|
||||||
|
for i = 1, n do
|
||||||
|
local d = (s[(i % n) + 1].pos - s[i].pos).Magnitude
|
||||||
|
minGap = math.min(minGap, d)
|
||||||
|
maxGap = math.max(maxGap, d)
|
||||||
|
end
|
||||||
|
check("loop closed & evenly spaced", maxGap - minGap < 5, ("gap %.1f..%.1f studs"):format(minGap, maxGap))
|
||||||
|
|
||||||
|
-- 3. Grade is drivable -----------------------------------------------------
|
||||||
|
local maxGrade, gradeAt = 0, 0
|
||||||
|
for i = 1, n do
|
||||||
|
local a, b = s[i].pos, s[(i % n) + 1].pos
|
||||||
|
local flat = (Vector3.new(b.X, 0, b.Z) - Vector3.new(a.X, 0, a.Z)).Magnitude
|
||||||
|
if flat > 0.1 then
|
||||||
|
local g = math.abs(b.Y - a.Y) / flat
|
||||||
|
if g > maxGrade then
|
||||||
|
maxGrade, gradeAt = g, i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check("max grade drivable (<15%)", maxGrade < 0.15, ("%.1f%% at sample %d"):format(maxGrade * 100, gradeAt))
|
||||||
|
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
cleanup()
|
||||||
|
return "FATAL: workspace.Map missing -- run MapBuilder first"
|
||||||
|
end
|
||||||
|
local exp = map:FindFirstChild("Expressway")
|
||||||
|
|
||||||
|
-- 4. Continuous driving surface -------------------------------------------
|
||||||
|
local rp = RaycastParams.new()
|
||||||
|
rp.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
rp.FilterDescendantsInstances = { exp }
|
||||||
|
local holes, worstOffset = 0, 0
|
||||||
|
for i = 1, n do
|
||||||
|
local hit = workspace:Raycast(s[i].pos + Vector3.new(0, 12, 0), Vector3.new(0, -40, 0), rp)
|
||||||
|
if not hit then
|
||||||
|
holes += 1
|
||||||
|
else
|
||||||
|
worstOffset = math.max(worstOffset, math.abs(s[i].pos.Y - hit.Position.Y))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check("no holes in road surface", holes == 0, ("%d gaps, worst offset %.2f studs"):format(holes, worstOffset))
|
||||||
|
|
||||||
|
-- 5. Tunnel bore count matches flags --------------------------------------
|
||||||
|
local walls, ceilings = 0, 0
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d.Name == "TunnelWall" then
|
||||||
|
walls += 1
|
||||||
|
elseif d.Name == "TunnelCeiling" then
|
||||||
|
ceilings += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(
|
||||||
|
"tunnel bore matches flags",
|
||||||
|
walls == nTunnel * 2 and ceilings == nTunnel,
|
||||||
|
("%d walls (want %d), %d ceilings"):format(walls, nTunnel * 2, nTunnel)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 6. Nothing intrudes into the car envelope -------------------------------
|
||||||
|
-- Probe sits ON the deck: centre lifted by half the car height, so the box
|
||||||
|
-- never dips below the road and false-positives on the pillars beneath it.
|
||||||
|
local probe = Instance.new("Part")
|
||||||
|
probe.Size = Vector3.new(CAR_HALF_W * 2, CAR_HEIGHT, 20)
|
||||||
|
probe.Anchored = true
|
||||||
|
probe.CanCollide = false
|
||||||
|
probe.Transparency = 1
|
||||||
|
probe.Parent = workspace
|
||||||
|
|
||||||
|
local op = OverlapParams.new()
|
||||||
|
op.FilterType = Enum.RaycastFilterType.Exclude
|
||||||
|
op.FilterDescendantsInstances = { probe, exp }
|
||||||
|
op.MaxParts = 8
|
||||||
|
|
||||||
|
local expected = {
|
||||||
|
KillWall = true,
|
||||||
|
Guardrail = true,
|
||||||
|
TunnelCeiling = true,
|
||||||
|
TunnelLight = true,
|
||||||
|
RoadLight = true,
|
||||||
|
BoxGirder = true,
|
||||||
|
PierCap = true,
|
||||||
|
Cable = true,
|
||||||
|
Baseplate = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local intrusions = 0
|
||||||
|
local firstHit = ""
|
||||||
|
for i = 1, n do
|
||||||
|
local a, b = s[i].pos, s[(i % n) + 1].pos
|
||||||
|
local dir = b - a
|
||||||
|
if dir.Magnitude > 0.1 then
|
||||||
|
-- Pillar tops are flush with the deck (0.00 clearance by design), so the
|
||||||
|
-- probe must sit fully ABOVE the road surface or it clips its own
|
||||||
|
-- supports. Lift a further 1.5 studs of margin.
|
||||||
|
local mid = (a + b) / 2 + Vector3.new(0, CAR_HEIGHT / 2 + 2.0, 0)
|
||||||
|
probe.CFrame = CFrame.lookAt(mid, mid + dir.Unit)
|
||||||
|
for _, h in ipairs(workspace:GetPartsInPart(probe, op)) do
|
||||||
|
-- only judge the NEW map; legacy C1/City trees are separate content
|
||||||
|
local root = h
|
||||||
|
while root.Parent and root.Parent ~= workspace do
|
||||||
|
root = root.Parent
|
||||||
|
end
|
||||||
|
if root == map and not expected[h.Name] then
|
||||||
|
intrusions += 1
|
||||||
|
if firstHit == "" then
|
||||||
|
firstHit = h:GetFullName() .. " @sample" .. i
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
probe:Destroy()
|
||||||
|
check("car envelope clear", intrusions == 0, ("%d intrusions %s"):format(intrusions, firstHit))
|
||||||
|
|
||||||
|
-- 7. Supports stay under the deck -----------------------------------------
|
||||||
|
local badPillars, minGapUnder = 0, math.huge
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d.Name == "Pillar" and d:IsA("BasePart") then
|
||||||
|
local top = d.Position.Y + d.Size.Y / 2
|
||||||
|
local best = math.huge
|
||||||
|
for i = 1, n do
|
||||||
|
local flat = (Vector3.new(s[i].pos.X, 0, s[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude
|
||||||
|
if flat < best then
|
||||||
|
best = flat
|
||||||
|
if flat < ROAD_HALF then
|
||||||
|
local gap = s[i].pos.Y - top
|
||||||
|
minGapUnder = math.min(minGapUnder, gap)
|
||||||
|
if gap < 0 then
|
||||||
|
badPillars += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(
|
||||||
|
"pillars stay below deck",
|
||||||
|
badPillars == 0,
|
||||||
|
("%d protruding, min clearance %.2f studs"):format(badPillars, minGapUnder == math.huge and 0 or minGapUnder)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 8. Asset hygiene ---------------------------------------------------------
|
||||||
|
local badSounds = 0
|
||||||
|
for _, svc in ipairs({ game:GetService("ServerStorage"), RS, workspace }) do
|
||||||
|
for _, d in ipairs(svc:GetDescendants()) do
|
||||||
|
if d:IsA("Sound") then
|
||||||
|
local _, count = d.SoundId:gsub("rbxassetid://", "")
|
||||||
|
if count > 1 then
|
||||||
|
badSounds += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check("no malformed sound ids", badSounds == 0, ("%d double-prefixed"):format(badSounds))
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
local header = failures == 0 and ("MAP VERIFY: ALL %d CHECKS PASSED"):format(#results)
|
||||||
|
or ("MAP VERIFY: %d/%d CHECKS FAILED"):format(failures, #results)
|
||||||
|
return header .. "\n" .. table.concat(results, "\n")
|
||||||
@@ -29,6 +29,11 @@ local ChassisController = {}
|
|||||||
|
|
||||||
local heartbeatConn: RBXScriptConnection? = nil
|
local heartbeatConn: RBXScriptConnection? = nil
|
||||||
local seatedConn: RBXScriptConnection? = nil
|
local seatedConn: RBXScriptConnection? = nil
|
||||||
|
-- The seat the live heartbeatConn is bound to. On respawn the old car is
|
||||||
|
-- destroyed and a new one spawned; the connection stays alive but drives the
|
||||||
|
-- DESTROYED car's motors. Tracking the seat lets the watchdog notice that the
|
||||||
|
-- loop is stale and re-arm against the new car.
|
||||||
|
local drivingSeat: VehicleSeat? = nil
|
||||||
|
|
||||||
local function findCarModel(seat: VehicleSeat): Model?
|
local function findCarModel(seat: VehicleSeat): Model?
|
||||||
local model = seat:FindFirstAncestorOfClass("Model")
|
local model = seat:FindFirstAncestorOfClass("Model")
|
||||||
@@ -70,6 +75,7 @@ local function startDriving(seat: VehicleSeat)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
drivingSeat = seat
|
||||||
heartbeatConn = RunService.Heartbeat:Connect(function()
|
heartbeatConn = RunService.Heartbeat:Connect(function()
|
||||||
local throttle = seat.ThrottleFloat
|
local throttle = seat.ThrottleFloat
|
||||||
local steer = seat.SteerFloat
|
local steer = seat.SteerFloat
|
||||||
@@ -106,6 +112,7 @@ local function stopDriving()
|
|||||||
heartbeatConn:Disconnect()
|
heartbeatConn:Disconnect()
|
||||||
heartbeatConn = nil
|
heartbeatConn = nil
|
||||||
end
|
end
|
||||||
|
drivingSeat = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChassisController.init()
|
function ChassisController.init()
|
||||||
@@ -115,13 +122,70 @@ function ChassisController.init()
|
|||||||
if seatedConn then
|
if seatedConn then
|
||||||
seatedConn:Disconnect()
|
seatedConn:Disconnect()
|
||||||
end
|
end
|
||||||
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
|
local function applySeat()
|
||||||
stopDriving()
|
stopDriving()
|
||||||
local seatPart = humanoid.SeatPart
|
local seatPart = humanoid.SeatPart
|
||||||
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
|
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
|
||||||
startDriving(seatPart)
|
startDriving(seatPart)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(applySeat)
|
||||||
|
|
||||||
|
-- WATCHDOG: the SeatPart signal alone is NOT reliable enough to arm the
|
||||||
|
-- chassis. Observed on cold boot: humanoid is Seated in a valid DriveSeat,
|
||||||
|
-- the car is network-owned by us, and the catch-up applySeat() below has
|
||||||
|
-- already run — yet the Heartbeat loop is not connected, so every motor
|
||||||
|
-- keeps MotorMaxTorque = 0 and the car is completely undrivable. The
|
||||||
|
-- signal fires during client bootstrap while modules are still being
|
||||||
|
-- required, and the edge is simply lost; re-running applySeat() once at
|
||||||
|
-- init doesn't help when the seat is assigned a moment AFTER that call.
|
||||||
|
-- A level-triggered check is immune to every ordering of these events:
|
||||||
|
-- if we are seated in a DriveSeat but have no live loop, arm it.
|
||||||
|
task.spawn(function()
|
||||||
|
while humanoid.Parent do
|
||||||
|
-- The whole body is pcall-wrapped: this watchdog is the ONLY
|
||||||
|
-- self-healing path. If it ever errors the thread dies silently
|
||||||
|
-- and the car becomes permanently undrivable, with the failure
|
||||||
|
-- appearing intermittent because the edge-triggered seat signal
|
||||||
|
-- still handles the easy nil -> seat case. Never let it die.
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local seatPart = humanoid.SeatPart
|
||||||
|
local seated = seatPart ~= nil
|
||||||
|
and seatPart:IsA("VehicleSeat")
|
||||||
|
and seatPart.Name == Chassis.SEAT_NAME
|
||||||
|
-- A live connection bound to a different seat (or to a car
|
||||||
|
-- that has since been destroyed) is STALE: it keeps driving
|
||||||
|
-- the old car's motors, so heartbeatConn stays non-nil and
|
||||||
|
-- the new car never gets armed. Re-arm whenever the bound
|
||||||
|
-- seat isn't the one we're sitting in, or it left the world.
|
||||||
|
-- NOTE: seatPart is nil for a frame whenever the old car is
|
||||||
|
-- destroyed on respawn, so it MUST be nil-checked before it
|
||||||
|
-- is indexed -- that crash is what killed this loop before.
|
||||||
|
local stale = heartbeatConn ~= nil
|
||||||
|
and (drivingSeat ~= seatPart or seatPart == nil or not seatPart:IsDescendantOf(workspace))
|
||||||
|
if seated and (not heartbeatConn or stale) then
|
||||||
|
stopDriving()
|
||||||
|
startDriving(seatPart :: VehicleSeat)
|
||||||
|
elseif not seated and heartbeatConn then
|
||||||
|
stopDriving()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
warn("[ChassisController] watchdog iteration failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
task.wait(0.5)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- RACE FIX: the server can seat the player in a spawned car BEFORE this
|
||||||
|
-- listener exists (CarManager sits them during spawn, which may complete
|
||||||
|
-- before the client finishes requiring its modules). In that case the
|
||||||
|
-- SeatPart signal already fired with nobody listening, so startDriving
|
||||||
|
-- never ran and the motors kept MotorMaxTorque = 0 — the car looked
|
||||||
|
-- completely dead to the player. Evaluate the CURRENT seat state once at
|
||||||
|
-- init instead of only reacting to future changes.
|
||||||
|
applySeat()
|
||||||
end
|
end
|
||||||
|
|
||||||
if player.Character then
|
if player.Character then
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
--!strict
|
||||||
|
--[[
|
||||||
|
ModeUI.luau (CLIENT)
|
||||||
|
|
||||||
|
Everything the three-mode loop shows the player:
|
||||||
|
|
||||||
|
* PA panel — the mode picker. Only visible while parked at Daikoku.
|
||||||
|
Pick CRUISE / DUEL / TIME ATTACK, then drive out the gate.
|
||||||
|
* Run HUD — mode-specific live readout while out on the Wangan:
|
||||||
|
CRUISE -> session + lifetime kilometres
|
||||||
|
TIME ATTACK -> lap clock, sector splits, best lap
|
||||||
|
DUEL -> opponent, gap bar, who's breaking away
|
||||||
|
* Duel prompt — a rival pulled alongside: RUN IT / NOT NOW.
|
||||||
|
* Result card — duel won/lost, lap set.
|
||||||
|
|
||||||
|
Purely presentational. Every number here came from the server; this module
|
||||||
|
never decides anything.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local TweenService = game:GetService("TweenService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage.Shared
|
||||||
|
local Modes = require(Shared.Modes)
|
||||||
|
local Remotes = require(Shared.Remotes)
|
||||||
|
|
||||||
|
local ModeUI = {}
|
||||||
|
|
||||||
|
local NEON_PINK = Color3.fromRGB(255, 40, 120)
|
||||||
|
local NEON_BLUE = Color3.fromRGB(40, 160, 255)
|
||||||
|
local NEON_GREEN = Color3.fromRGB(120, 255, 170)
|
||||||
|
local PANEL_BG = Color3.fromRGB(16, 16, 24)
|
||||||
|
local DIM = Color3.fromRGB(150, 150, 165)
|
||||||
|
|
||||||
|
local rSelectMode = Remotes.get("SelectMode") :: RemoteFunction
|
||||||
|
local rRespondDuel = Remotes.get("RespondDuel") :: RemoteEvent
|
||||||
|
local rAbandonRun = Remotes.get("AbandonRun") :: RemoteEvent
|
||||||
|
local rModeUpdated = Remotes.get("ModeUpdated") :: RemoteEvent
|
||||||
|
local rDuelOffer = Remotes.get("DuelOffer") :: RemoteEvent
|
||||||
|
local rDuelResult = Remotes.get("DuelResult") :: RemoteEvent
|
||||||
|
|
||||||
|
local function make(class: string, props: { [string]: any }, parent: Instance?): any
|
||||||
|
local inst = Instance.new(class)
|
||||||
|
for k, v in pairs(props) do
|
||||||
|
(inst :: any)[k] = v
|
||||||
|
end
|
||||||
|
if parent then
|
||||||
|
inst.Parent = parent
|
||||||
|
end
|
||||||
|
return inst
|
||||||
|
end
|
||||||
|
|
||||||
|
local function corner(inst: Instance, radius: number)
|
||||||
|
make("UICorner", { CornerRadius = UDim.new(0, radius) }, inst)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Format seconds as M:SS.mmm — the racing-timer convention.
|
||||||
|
local function fmtTime(t: number?): string
|
||||||
|
if not t or t <= 0 then
|
||||||
|
return "--:--.---"
|
||||||
|
end
|
||||||
|
local m = math.floor(t / 60)
|
||||||
|
local s = t - m * 60
|
||||||
|
return string.format("%d:%06.3f", m, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local screen: ScreenGui
|
||||||
|
local paPanel: Frame
|
||||||
|
local runHud: Frame
|
||||||
|
local hudTitle: TextLabel
|
||||||
|
local hudMain: TextLabel
|
||||||
|
local hudSub: TextLabel
|
||||||
|
local gapBar: Frame
|
||||||
|
local gapFill: Frame
|
||||||
|
local gapLabel: TextLabel
|
||||||
|
local duelPrompt: Frame
|
||||||
|
local duelPromptLabel: TextLabel
|
||||||
|
local resultCard: Frame
|
||||||
|
local resultTitle: TextLabel
|
||||||
|
local resultSub: TextLabel
|
||||||
|
|
||||||
|
local lastState: string? = nil
|
||||||
|
|
||||||
|
-- Build -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ModeUI.build(parentGui: ScreenGui)
|
||||||
|
screen = parentGui
|
||||||
|
|
||||||
|
-- PA panel: the mode picker ----------------------------------------------
|
||||||
|
paPanel = make("Frame", {
|
||||||
|
Name = "PAPanel",
|
||||||
|
AnchorPoint = Vector2.new(0.5, 1),
|
||||||
|
Position = UDim2.new(0.5, 0, 1, -90),
|
||||||
|
Size = UDim2.new(0, 720, 0, 210),
|
||||||
|
BackgroundColor3 = PANEL_BG,
|
||||||
|
BackgroundTransparency = 0.08,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
Visible = false,
|
||||||
|
}, screen)
|
||||||
|
corner(paPanel, 12)
|
||||||
|
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.4 }, paPanel)
|
||||||
|
|
||||||
|
make("TextLabel", {
|
||||||
|
Text = "大黒PA — CHOOSE YOUR RUN",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 15,
|
||||||
|
TextColor3 = Color3.fromRGB(235, 235, 245),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 10),
|
||||||
|
Size = UDim2.new(1, 0, 0, 20),
|
||||||
|
}, paPanel)
|
||||||
|
|
||||||
|
make("TextLabel", {
|
||||||
|
Name = "Hint",
|
||||||
|
Text = "Pick a mode, then drive out the yellow gate onto the Wangan.",
|
||||||
|
Font = Enum.Font.Gotham,
|
||||||
|
TextSize = 12,
|
||||||
|
TextColor3 = DIM,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 1, -26),
|
||||||
|
Size = UDim2.new(1, 0, 0, 18),
|
||||||
|
}, paPanel)
|
||||||
|
|
||||||
|
local row = make("Frame", {
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 16, 0, 40),
|
||||||
|
Size = UDim2.new(1, -32, 0, 130),
|
||||||
|
}, paPanel)
|
||||||
|
make("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
Padding = UDim.new(0, 12),
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||||
|
}, row)
|
||||||
|
|
||||||
|
for i, mode in ipairs(Modes.List) do
|
||||||
|
local card = make("TextButton", {
|
||||||
|
Name = mode.Id,
|
||||||
|
Text = "",
|
||||||
|
AutoButtonColor = false,
|
||||||
|
BackgroundColor3 = Color3.fromRGB(24, 24, 34),
|
||||||
|
Size = UDim2.new(0, 224, 1, 0),
|
||||||
|
LayoutOrder = i,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
}, row)
|
||||||
|
corner(card, 10)
|
||||||
|
local stroke = make("UIStroke", { Color = mode.Color, Thickness = 1.5, Transparency = 0.55 }, card)
|
||||||
|
|
||||||
|
make("TextLabel", {
|
||||||
|
Text = mode.NameJP,
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 13,
|
||||||
|
TextColor3 = mode.Color,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 10),
|
||||||
|
Size = UDim2.new(1, 0, 0, 16),
|
||||||
|
}, card)
|
||||||
|
make("TextLabel", {
|
||||||
|
Text = mode.Name,
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 19,
|
||||||
|
TextColor3 = Color3.fromRGB(240, 240, 250),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 28),
|
||||||
|
Size = UDim2.new(1, 0, 0, 22),
|
||||||
|
}, card)
|
||||||
|
make("TextLabel", {
|
||||||
|
Text = mode.Blurb,
|
||||||
|
Font = Enum.Font.Gotham,
|
||||||
|
TextSize = 11,
|
||||||
|
TextColor3 = DIM,
|
||||||
|
TextWrapped = true,
|
||||||
|
TextYAlignment = Enum.TextYAlignment.Top,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 12, 0, 56),
|
||||||
|
Size = UDim2.new(1, -24, 1, -66),
|
||||||
|
}, card)
|
||||||
|
|
||||||
|
card.MouseEnter:Connect(function()
|
||||||
|
TweenService:Create(stroke, TweenInfo.new(0.15), { Transparency = 0 }):Play()
|
||||||
|
end)
|
||||||
|
card.MouseLeave:Connect(function()
|
||||||
|
TweenService:Create(stroke, TweenInfo.new(0.15), { Transparency = 0.55 }):Play()
|
||||||
|
end)
|
||||||
|
card.MouseButton1Click:Connect(function()
|
||||||
|
local ok = rSelectMode:InvokeServer(mode.Id)
|
||||||
|
if ok then
|
||||||
|
ModeUI.showArmed(mode)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run HUD ----------------------------------------------------------------
|
||||||
|
runHud = make("Frame", {
|
||||||
|
Name = "RunHUD",
|
||||||
|
AnchorPoint = Vector2.new(0.5, 0),
|
||||||
|
Position = UDim2.new(0.5, 0, 0, 16),
|
||||||
|
Size = UDim2.new(0, 340, 0, 104),
|
||||||
|
BackgroundColor3 = PANEL_BG,
|
||||||
|
BackgroundTransparency = 0.15,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
Visible = false,
|
||||||
|
}, screen)
|
||||||
|
corner(runHud, 10)
|
||||||
|
make("UIStroke", { Color = NEON_PINK, Thickness = 1.2, Transparency = 0.5 }, runHud)
|
||||||
|
|
||||||
|
hudTitle = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 12,
|
||||||
|
TextColor3 = NEON_PINK,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 8),
|
||||||
|
Size = UDim2.new(1, 0, 0, 14),
|
||||||
|
}, runHud)
|
||||||
|
|
||||||
|
hudMain = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 30,
|
||||||
|
TextColor3 = Color3.fromRGB(245, 245, 255),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 24),
|
||||||
|
Size = UDim2.new(1, 0, 0, 36),
|
||||||
|
}, runHud)
|
||||||
|
|
||||||
|
hudSub = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.Gotham,
|
||||||
|
TextSize = 12,
|
||||||
|
TextColor3 = DIM,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 60),
|
||||||
|
Size = UDim2.new(1, 0, 0, 16),
|
||||||
|
}, runHud)
|
||||||
|
|
||||||
|
-- Duel gap bar (hidden unless duelling)
|
||||||
|
gapBar = make("Frame", {
|
||||||
|
Name = "GapBar",
|
||||||
|
BackgroundColor3 = Color3.fromRGB(35, 35, 48),
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
Position = UDim2.new(0, 20, 0, 80),
|
||||||
|
Size = UDim2.new(1, -40, 0, 8),
|
||||||
|
Visible = false,
|
||||||
|
}, runHud)
|
||||||
|
corner(gapBar, 4)
|
||||||
|
gapFill = make("Frame", {
|
||||||
|
BackgroundColor3 = NEON_PINK,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
AnchorPoint = Vector2.new(0.5, 0),
|
||||||
|
Position = UDim2.new(0.5, 0, 0, 0),
|
||||||
|
Size = UDim2.new(0, 4, 1, 0),
|
||||||
|
}, gapBar)
|
||||||
|
corner(gapFill, 4)
|
||||||
|
gapLabel = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 11,
|
||||||
|
TextColor3 = DIM,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 88),
|
||||||
|
Size = UDim2.new(1, 0, 0, 14),
|
||||||
|
Visible = false,
|
||||||
|
}, runHud)
|
||||||
|
|
||||||
|
-- Duel prompt ------------------------------------------------------------
|
||||||
|
duelPrompt = make("Frame", {
|
||||||
|
Name = "DuelPrompt",
|
||||||
|
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||||
|
Position = UDim2.new(0.5, 0, 0.62, 0),
|
||||||
|
Size = UDim2.new(0, 380, 0, 120),
|
||||||
|
BackgroundColor3 = PANEL_BG,
|
||||||
|
BackgroundTransparency = 0.05,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
Visible = false,
|
||||||
|
}, screen)
|
||||||
|
corner(duelPrompt, 12)
|
||||||
|
make("UIStroke", { Color = NEON_PINK, Thickness = 2, Transparency = 0.1 }, duelPrompt)
|
||||||
|
|
||||||
|
duelPromptLabel = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 16,
|
||||||
|
TextColor3 = Color3.fromRGB(245, 245, 255),
|
||||||
|
TextWrapped = true,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 16, 0, 14),
|
||||||
|
Size = UDim2.new(1, -32, 0, 46),
|
||||||
|
}, duelPrompt)
|
||||||
|
|
||||||
|
local btnRow = make("Frame", {
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 16, 1, -50),
|
||||||
|
Size = UDim2.new(1, -32, 0, 36),
|
||||||
|
}, duelPrompt)
|
||||||
|
make("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
Padding = UDim.new(0, 10),
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||||
|
}, btnRow)
|
||||||
|
|
||||||
|
local function promptBtn(text: string, color: Color3, accept: boolean)
|
||||||
|
local b = make("TextButton", {
|
||||||
|
Text = text,
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 14,
|
||||||
|
TextColor3 = Color3.fromRGB(10, 10, 14),
|
||||||
|
BackgroundColor3 = color,
|
||||||
|
Size = UDim2.new(0, 160, 1, 0),
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
}, btnRow)
|
||||||
|
corner(b, 8)
|
||||||
|
b.MouseButton1Click:Connect(function()
|
||||||
|
rRespondDuel:FireServer(accept)
|
||||||
|
duelPrompt.Visible = false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
promptBtn("RUN IT", NEON_PINK, true)
|
||||||
|
promptBtn("NOT NOW", Color3.fromRGB(90, 90, 110), false)
|
||||||
|
|
||||||
|
-- Result card ------------------------------------------------------------
|
||||||
|
resultCard = make("Frame", {
|
||||||
|
Name = "ResultCard",
|
||||||
|
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||||
|
Position = UDim2.new(0.5, 0, 0.42, 0),
|
||||||
|
Size = UDim2.new(0, 420, 0, 110),
|
||||||
|
BackgroundColor3 = PANEL_BG,
|
||||||
|
BackgroundTransparency = 0.05,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
Visible = false,
|
||||||
|
}, screen)
|
||||||
|
corner(resultCard, 12)
|
||||||
|
make("UIStroke", { Color = NEON_GREEN, Thickness = 2, Transparency = 0.15 }, resultCard)
|
||||||
|
resultTitle = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.GothamBold,
|
||||||
|
TextSize = 26,
|
||||||
|
TextColor3 = NEON_GREEN,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 20),
|
||||||
|
Size = UDim2.new(1, 0, 0, 32),
|
||||||
|
}, resultCard)
|
||||||
|
resultSub = make("TextLabel", {
|
||||||
|
Text = "",
|
||||||
|
Font = Enum.Font.Gotham,
|
||||||
|
TextSize = 14,
|
||||||
|
TextColor3 = DIM,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(0, 0, 0, 58),
|
||||||
|
Size = UDim2.new(1, 0, 0, 20),
|
||||||
|
}, resultCard)
|
||||||
|
|
||||||
|
ModeUI.connect()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Behaviour -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ModeUI.showArmed(mode: Modes.ModeDef)
|
||||||
|
local hint = paPanel:FindFirstChild("Hint") :: TextLabel?
|
||||||
|
if hint then
|
||||||
|
hint.Text = string.format("%s armed — drive out the yellow gate to start.", mode.Name)
|
||||||
|
hint.TextColor3 = mode.Color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function showResult(title: string, sub: string, color: Color3)
|
||||||
|
resultTitle.Text = title
|
||||||
|
resultTitle.TextColor3 = color
|
||||||
|
resultSub.Text = sub
|
||||||
|
resultCard.Visible = true
|
||||||
|
task.delay(5, function()
|
||||||
|
resultCard.Visible = false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeUI.connect()
|
||||||
|
rModeUpdated.OnClientEvent:Connect(function(state: any)
|
||||||
|
if typeof(state) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local inPA = state.state == "IN_PA" or state.state == "ARMED"
|
||||||
|
paPanel.Visible = inPA
|
||||||
|
runHud.Visible = state.state == "RUNNING"
|
||||||
|
|
||||||
|
-- Reset the picker hint when we newly arrive back in the PA.
|
||||||
|
if state.state == "IN_PA" and lastState ~= "IN_PA" then
|
||||||
|
local hint = paPanel:FindFirstChild("Hint") :: TextLabel?
|
||||||
|
if hint then
|
||||||
|
hint.Text = "Pick a mode, then drive out the yellow gate onto the Wangan."
|
||||||
|
hint.TextColor3 = DIM
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lastState = state.state
|
||||||
|
|
||||||
|
if state.state ~= "RUNNING" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mode = state.mode and Modes.Get(state.mode)
|
||||||
|
if not mode then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
hudTitle.Text = mode.NameJP .. " " .. mode.Name
|
||||||
|
hudTitle.TextColor3 = mode.Color
|
||||||
|
|
||||||
|
if state.duel then
|
||||||
|
-- DUEL readout takes over the HUD.
|
||||||
|
local gap = state.duel.gap or 0
|
||||||
|
hudMain.Text = string.format("%+.0f", gap)
|
||||||
|
hudMain.TextColor3 = gap >= 0 and NEON_GREEN or NEON_PINK
|
||||||
|
hudSub.Text =
|
||||||
|
string.format("vs %s %s", state.duel.opponent, gap >= 0 and "you're ahead" or "you're behind")
|
||||||
|
gapBar.Visible = true
|
||||||
|
gapLabel.Visible = true
|
||||||
|
local frac = math.clamp(gap / (state.duel.winGap or 350), -1, 1)
|
||||||
|
gapFill.Position = UDim2.new(0.5 + frac * 0.5, 0, 0, 0)
|
||||||
|
gapFill.BackgroundColor3 = gap >= 0 and NEON_GREEN or NEON_PINK
|
||||||
|
gapLabel.Text = string.format("hold %d studs to win", state.duel.winGap or 350)
|
||||||
|
elseif mode.Timed then
|
||||||
|
gapBar.Visible = false
|
||||||
|
gapLabel.Visible = false
|
||||||
|
hudMain.Text = fmtTime(state.lapTime)
|
||||||
|
hudMain.TextColor3 = Color3.fromRGB(245, 245, 255)
|
||||||
|
local best = state.bestLapTime or 0
|
||||||
|
local parts = { string.format("SECTOR %d/4", state.sector or 1) }
|
||||||
|
if best > 0 then
|
||||||
|
table.insert(parts, "BEST " .. fmtTime(best))
|
||||||
|
end
|
||||||
|
if state.lastLapTime then
|
||||||
|
table.insert(parts, "LAST " .. fmtTime(state.lastLapTime))
|
||||||
|
end
|
||||||
|
hudSub.Text = table.concat(parts, " ")
|
||||||
|
else
|
||||||
|
gapBar.Visible = false
|
||||||
|
gapLabel.Visible = false
|
||||||
|
hudMain.Text = string.format("%.2f km", state.sessionKm or 0)
|
||||||
|
hudMain.TextColor3 = Color3.fromRGB(245, 245, 255)
|
||||||
|
hudSub.Text = string.format("this run · %.1f km lifetime", state.totalKm or 0)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
rDuelOffer.OnClientEvent:Connect(function(info: any)
|
||||||
|
if typeof(info) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
duelPromptLabel.Text = string.format("%s pulled alongside.\nRun it?", tostring(info.opponent))
|
||||||
|
duelPrompt.Visible = true
|
||||||
|
task.delay(10, function()
|
||||||
|
if duelPrompt.Visible then
|
||||||
|
duelPrompt.Visible = false
|
||||||
|
rRespondDuel:FireServer(false)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
rDuelResult.OnClientEvent:Connect(function(info: any)
|
||||||
|
if typeof(info) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
duelPrompt.Visible = false
|
||||||
|
if info.expired then
|
||||||
|
showResult("NO CONTEST", tostring(info.reason or ""), Color3.fromRGB(200, 200, 210))
|
||||||
|
elseif info.won then
|
||||||
|
showResult(
|
||||||
|
"WIN",
|
||||||
|
string.format("beat %s — %s", tostring(info.opponent), tostring(info.reason)),
|
||||||
|
NEON_GREEN
|
||||||
|
)
|
||||||
|
else
|
||||||
|
showResult("LOSS", string.format("%s got away", tostring(info.opponent)), NEON_PINK)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeUI.abandon()
|
||||||
|
rAbandonRun:FireServer()
|
||||||
|
end
|
||||||
|
|
||||||
|
return ModeUI
|
||||||
@@ -18,6 +18,7 @@ local Cars = require(Shared.Cars)
|
|||||||
local ChassisController = require(script.ChassisController)
|
local ChassisController = require(script.ChassisController)
|
||||||
local CameraController = require(script.CameraController)
|
local CameraController = require(script.CameraController)
|
||||||
local SoundController = require(script.SoundController)
|
local SoundController = require(script.SoundController)
|
||||||
|
local ModeUI = require(script.ModeUI)
|
||||||
|
|
||||||
ChassisController.init()
|
ChassisController.init()
|
||||||
CameraController.init()
|
CameraController.init()
|
||||||
@@ -53,7 +54,7 @@ local function corner(inst: Instance, radius: number)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local screen = make("ScreenGui", {
|
local screen = make("ScreenGui", {
|
||||||
Name = "FnF_UI",
|
Name = "Wangan_UI",
|
||||||
ResetOnSpawn = false,
|
ResetOnSpawn = false,
|
||||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||||
}, playerGui)
|
}, playerGui)
|
||||||
@@ -272,7 +273,7 @@ local function rebuildPicker()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Data sync -----------------------------------------------------------------
|
-- Data sync -----------------------------------------------------------------
|
||||||
rDataUpdated.OnClientEvent:Connect(function(carId: string)
|
rDataUpdated.OnClientEvent:Connect(function(carId: string, _stats: any)
|
||||||
currentCarId = carId
|
currentCarId = carId
|
||||||
local car = Cars.Get(carId)
|
local car = Cars.Get(carId)
|
||||||
carLabel.Text = "Car: " .. (car and car.Name or carId)
|
carLabel.Text = "Car: " .. (car and car.Name or carId)
|
||||||
@@ -296,4 +297,13 @@ respawnBtn.MouseButton1Click:Connect(function()
|
|||||||
showToast("Respawning your car...")
|
showToast("Respawning your car...")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
print("[FnF] Client UI ready (drive-only MVP).")
|
-- Mode system UI (PA picker, run HUD, duel prompts) -------------------------
|
||||||
|
ModeUI.build(screen)
|
||||||
|
|
||||||
|
local giveUpBtn = button("GIVE UP", Color3.fromRGB(255, 140, 60), 3)
|
||||||
|
giveUpBtn.MouseButton1Click:Connect(function()
|
||||||
|
ModeUI.abandon()
|
||||||
|
showToast("Run abandoned — head back to Daikoku PA.")
|
||||||
|
end)
|
||||||
|
|
||||||
|
print("[Wangan] Client UI ready (3 modes: cruise / duel / time attack).")
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
Ambience.luau (SERVER)
|
Ambience.luau (SERVER)
|
||||||
The night-city mood pass (M2). The Rojo project file sets the static
|
|
||||||
Lighting baseline (night, fog); this module adds everything that can't be
|
|
||||||
expressed as plain properties:
|
|
||||||
|
|
||||||
- Atmosphere: dense hazy air that catches the neon glow
|
Two looks, one switch (GameConfig.DevDaylight):
|
||||||
- Post effects: bloom (neon pop) + subtle cyan-shifted color correction
|
|
||||||
- A giant "moon" disc + low star field for a sky with depth
|
|
||||||
- A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so
|
|
||||||
shadows/sky aren't frozen — the game stays night forever by design,
|
|
||||||
it just breathes.
|
|
||||||
|
|
||||||
All tunables at the top. Runs once at boot; the drift loop is one cheap
|
NIGHT (shipping, DevDaylight = false)
|
||||||
task.spawn.
|
The night-city mood pass (M2). The Rojo project file sets the static
|
||||||
|
Lighting baseline (night, fog); this module adds everything that can't
|
||||||
|
be expressed as plain properties:
|
||||||
|
- Atmosphere: dense hazy air that catches the neon glow
|
||||||
|
- Post effects: bloom (neon pop) + subtle cyan-shifted color correction
|
||||||
|
- A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so
|
||||||
|
shadows/sky aren't frozen — the game stays night forever by design,
|
||||||
|
it just breathes.
|
||||||
|
|
||||||
|
DAYLIGHT (development, DevDaylight = true)
|
||||||
|
A flat, bright midday pass so geometry is actually visible while
|
||||||
|
building: road surface, kerbs, gap placement, PA layout. Full sun, no
|
||||||
|
fog, no bloom, no drift — a frozen noon.
|
||||||
|
|
||||||
|
This must ACTIVELY OVERRIDE the night values baked into
|
||||||
|
default.project.json (ClockTime 0, near-black Ambient, FogEnd 900),
|
||||||
|
because those are applied to Lighting before this module runs.
|
||||||
|
|
||||||
|
Only lighting/post differs between the two — no gameplay, geometry or
|
||||||
|
physics changes, so anything verified in daylight holds at night.
|
||||||
|
|
||||||
|
All tunables at the top. Runs once at boot; the night drift loop is one
|
||||||
|
cheap task.spawn that does not start in daylight mode.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Lighting = game:GetService("Lighting")
|
local Lighting = game:GetService("Lighting")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local GameConfig = require(ReplicatedStorage.Shared.GameConfig)
|
||||||
|
|
||||||
local Ambience = {}
|
local Ambience = {}
|
||||||
|
|
||||||
-- Tunables --------------------------------------------------------------------
|
-- Night tunables --------------------------------------------------------------
|
||||||
local ATMOS_DENSITY = 0.35
|
local ATMOS_DENSITY = 0.35
|
||||||
local ATMOS_HAZE = 2.1
|
local ATMOS_HAZE = 2.1
|
||||||
local BLOOM_INTENSITY = 0.9
|
local BLOOM_INTENSITY = 0.9
|
||||||
@@ -29,7 +46,42 @@ local NIGHT_START = 22 -- ClockTime loop range
|
|||||||
local NIGHT_END = 27 -- 27 == 03:00 next day
|
local NIGHT_END = 27 -- 27 == 03:00 next day
|
||||||
local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes
|
local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes
|
||||||
|
|
||||||
function Ambience.init()
|
-- Daylight tunables -----------------------------------------------------------
|
||||||
|
local DAY_CLOCK = 13.5 -- early afternoon: sun high, but still casts readable shadows
|
||||||
|
local DAY_BRIGHTNESS = 2.5
|
||||||
|
local DAY_FOG_END = 100000 -- effectively no fog; see the whole loop
|
||||||
|
local DAY_ATMOS_DENSITY = 0.18 -- a little depth on distant geometry, no murk
|
||||||
|
|
||||||
|
--- Bright, flat midday. Overrides the night baseline from the project file.
|
||||||
|
local function applyDaylight()
|
||||||
|
Lighting.ClockTime = DAY_CLOCK
|
||||||
|
Lighting.GeographicLatitude = 35.6 -- Tokyo, so the sun angle is plausible
|
||||||
|
Lighting.Brightness = DAY_BRIGHTNESS
|
||||||
|
Lighting.ExposureCompensation = 0
|
||||||
|
Lighting.Ambient = Color3.fromRGB(120, 120, 130)
|
||||||
|
Lighting.OutdoorAmbient = Color3.fromRGB(150, 152, 160)
|
||||||
|
Lighting.FogStart = 0
|
||||||
|
Lighting.FogEnd = DAY_FOG_END
|
||||||
|
Lighting.FogColor = Color3.fromRGB(190, 205, 225)
|
||||||
|
Lighting.EnvironmentDiffuseScale = 1
|
||||||
|
Lighting.EnvironmentSpecularScale = 1
|
||||||
|
Lighting.GlobalShadows = true
|
||||||
|
|
||||||
|
local atmosphere = Instance.new("Atmosphere")
|
||||||
|
atmosphere.Density = DAY_ATMOS_DENSITY
|
||||||
|
atmosphere.Offset = 0.1
|
||||||
|
atmosphere.Color = Color3.fromRGB(199, 209, 227)
|
||||||
|
atmosphere.Decay = Color3.fromRGB(106, 112, 125)
|
||||||
|
atmosphere.Glare = 0
|
||||||
|
atmosphere.Haze = 0.6
|
||||||
|
atmosphere.Parent = Lighting
|
||||||
|
|
||||||
|
-- No bloom / no colour grade in dev: they wash out edges and make it
|
||||||
|
-- harder to judge whether geometry is actually aligned.
|
||||||
|
end
|
||||||
|
|
||||||
|
--- The shipping neon-night look.
|
||||||
|
local function applyNight()
|
||||||
-- Atmosphere -----------------------------------------------------------------
|
-- Atmosphere -----------------------------------------------------------------
|
||||||
local atmosphere = Instance.new("Atmosphere")
|
local atmosphere = Instance.new("Atmosphere")
|
||||||
atmosphere.Density = ATMOS_DENSITY
|
atmosphere.Density = ATMOS_DENSITY
|
||||||
@@ -68,4 +120,26 @@ function Ambience.init()
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Ambience.init()
|
||||||
|
-- Clear anything a previous run (or the project file) left behind, so the
|
||||||
|
-- two modes can't stack Atmosphere/effects on top of each other.
|
||||||
|
for _, child in ipairs(Lighting:GetChildren()) do
|
||||||
|
if
|
||||||
|
child:IsA("Atmosphere")
|
||||||
|
or child:IsA("BloomEffect")
|
||||||
|
or child:IsA("ColorCorrectionEffect")
|
||||||
|
or child:IsA("SunRaysEffect")
|
||||||
|
then
|
||||||
|
child:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if GameConfig.DevDaylight then
|
||||||
|
applyDaylight()
|
||||||
|
print("[Ambience] DEV DAYLIGHT is ON (GameConfig.DevDaylight) — set it to false for the night look.")
|
||||||
|
else
|
||||||
|
applyNight()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return Ambience
|
return Ambience
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ type Track = { lastPos: Vector3, lastValid: CFrame }
|
|||||||
local tracked: { [Player]: Track } = {}
|
local tracked: { [Player]: Track } = {}
|
||||||
local SAMPLE_INTERVAL = 0.5
|
local SAMPLE_INTERVAL = 0.5
|
||||||
|
|
||||||
|
-- Server-initiated teleports (car spawn onto the pad, respawn, mode resets)
|
||||||
|
-- legitimately move a player thousands of studs in one frame, which trips the
|
||||||
|
-- speed check and snaps them straight back -- dragging their car with them.
|
||||||
|
-- Callers announce those moves via AntiCheat.grant() so the next sample is
|
||||||
|
-- accepted as the new baseline instead of treated as a hack.
|
||||||
|
local graceUntil: { [Player]: number } = {}
|
||||||
|
local GRACE_SECONDS = 2
|
||||||
|
|
||||||
|
--- Exempt a player from the speed check briefly after a legitimate teleport.
|
||||||
|
function AntiCheat.grant(player: Player, seconds: number?)
|
||||||
|
graceUntil[player] = os.clock() + (seconds or GRACE_SECONDS)
|
||||||
|
end
|
||||||
|
|
||||||
local function hrpOf(player: Player): BasePart?
|
local function hrpOf(player: Player): BasePart?
|
||||||
local char = player.Character
|
local char = player.Character
|
||||||
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
|
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
|
||||||
@@ -33,9 +46,23 @@ end
|
|||||||
function AntiCheat.init()
|
function AntiCheat.init()
|
||||||
local function watch(player: Player)
|
local function watch(player: Player)
|
||||||
player.CharacterAdded:Connect(function(char)
|
player.CharacterAdded:Connect(function(char)
|
||||||
|
-- RESPAWN FIX: drop the previous character's baseline IMMEDIATELY,
|
||||||
|
-- before the WaitForChild below yields. A new character spawns at
|
||||||
|
-- the SpawnLocation (the parking area, ~8,500 studs from the old
|
||||||
|
-- body at the origin). If the Heartbeat sampler runs during that
|
||||||
|
-- yield it measures that jump against the STALE baseline, calls it
|
||||||
|
-- a teleport hack, and snaps the fresh character back to the dead
|
||||||
|
-- character's CFrame -- dragging the car with it. Clearing the
|
||||||
|
-- entry means "no baseline yet", so nothing can be flagged until
|
||||||
|
-- the new one is recorded.
|
||||||
|
tracked[player] = nil
|
||||||
|
AntiCheat.grant(player)
|
||||||
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
|
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
|
||||||
if hrp then
|
if hrp then
|
||||||
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
|
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
|
||||||
|
-- The spawn itself is a legitimate long-distance move; keep the
|
||||||
|
-- grace alive past the first sample after the baseline lands.
|
||||||
|
AntiCheat.grant(player)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -66,7 +93,8 @@ function AntiCheat.init()
|
|||||||
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
|
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
|
||||||
local hSpeed = horizontal / interval
|
local hSpeed = horizontal / interval
|
||||||
|
|
||||||
if hSpeed > GameConfig.MaxPlausibleSpeed then
|
local inGrace = (graceUntil[player] or 0) > os.clock()
|
||||||
|
if hSpeed > GameConfig.MaxPlausibleSpeed and not inGrace then
|
||||||
-- Snap back: likely a speed/teleport hack.
|
-- Snap back: likely a speed/teleport hack.
|
||||||
hrp.CFrame = track.lastValid
|
hrp.CFrame = track.lastValid
|
||||||
hrp.AssemblyLinearVelocity = Vector3.zero
|
hrp.AssemblyLinearVelocity = Vector3.zero
|
||||||
|
|||||||
@@ -224,14 +224,14 @@ end
|
|||||||
|
|
||||||
local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, glass: Color3, style: Style)
|
local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, glass: Color3, style: Style)
|
||||||
local origin = body.CFrame
|
local origin = body.CFrame
|
||||||
local roofY = H / 2 + 1.5
|
local roofY = H / 2 + 1.9 -- tall enough that a seated avatar fits under it
|
||||||
local roofLen = L * 0.26
|
local roofLen = L * 0.26
|
||||||
|
|
||||||
-- Windshield: raked wedge (slope toward front)
|
-- Windshield: raked wedge (slope toward front)
|
||||||
vis({
|
vis({
|
||||||
Name = "Windshield",
|
Name = "Windshield",
|
||||||
Size = Vector3.new(W - 1.0, 1.3, 1.9),
|
Size = Vector3.new(W - 1.0, 1.7, 2.1),
|
||||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, -L * 0.09) * CFrame.Angles(0, math.pi, 0),
|
||||||
Color = glass,
|
Color = glass,
|
||||||
Material = Enum.Material.Glass,
|
Material = Enum.Material.Glass,
|
||||||
Transparency = 0.25,
|
Transparency = 0.25,
|
||||||
@@ -262,8 +262,8 @@ local function buildGreenhouse(folder: Folder, body: BasePart, paint: Color3, gl
|
|||||||
local rearGlassLen = if style.TailKind == "hatch" then 2.4 else 1.7
|
local rearGlassLen = if style.TailKind == "hatch" then 2.4 else 1.7
|
||||||
vis({
|
vis({
|
||||||
Name = "RearGlass",
|
Name = "RearGlass",
|
||||||
Size = Vector3.new(W - 1.2, 1.3, rearGlassLen),
|
Size = Vector3.new(W - 1.2, 1.7, rearGlassLen),
|
||||||
CFrame = origin * CFrame.new(0, H / 2 + 0.85 + 0.5, L * 0.235),
|
CFrame = origin * CFrame.new(0, H / 2 + 1.05 + 0.5, L * 0.235),
|
||||||
Color = glass,
|
Color = glass,
|
||||||
Material = Enum.Material.Glass,
|
Material = Enum.Material.Glass,
|
||||||
Transparency = 0.25,
|
Transparency = 0.25,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ local Chassis = require(Shared.Chassis)
|
|||||||
local GameConfig = require(Shared.GameConfig)
|
local GameConfig = require(Shared.GameConfig)
|
||||||
local DataManager = require(script.Parent.DataManager)
|
local DataManager = require(script.Parent.DataManager)
|
||||||
local CarBody = require(script.Parent.CarBody)
|
local CarBody = require(script.Parent.CarBody)
|
||||||
|
local AntiCheat = require(script.Parent.AntiCheat)
|
||||||
|
local PhysicsService = game:GetService("PhysicsService")
|
||||||
|
|
||||||
type Car = Cars.Car
|
type Car = Cars.Car
|
||||||
type Tuning = Chassis.Tuning
|
type Tuning = Chassis.Tuning
|
||||||
@@ -39,6 +41,75 @@ local CarManager = {}
|
|||||||
local activeCars: { [Player]: Model } = {}
|
local activeCars: { [Player]: Model } = {}
|
||||||
local carsFolder: Folder
|
local carsFolder: Folder
|
||||||
|
|
||||||
|
-- Spawn seating --------------------------------------------------------------
|
||||||
|
-- How far above the character's root the car body is placed before seating, so
|
||||||
|
-- the freshly teleported chassis doesn't start interpenetrating the ground.
|
||||||
|
local SEAT_LIFT = 3
|
||||||
|
-- Grace period after Sit() before network ownership is handed to the client.
|
||||||
|
-- The rig must be fully settled first or the client re-asserts a stale layout.
|
||||||
|
local SETTLE_TIME = 1.0
|
||||||
|
|
||||||
|
-- How far to sink an imported mesh shell into the chassis so it doesn't look
|
||||||
|
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
|
||||||
|
-- Sink the shell slightly so the tyres aren't fully swallowed by the bodywork.
|
||||||
|
-- Every stud of drop is a stud of cabin height lost, and hyper_x (lowest roof
|
||||||
|
-- in the library at 4.64) has none to spare, so keep this small.
|
||||||
|
local SHELL_DROP = 0.25
|
||||||
|
|
||||||
|
-- Collision groups ------------------------------------------------------------
|
||||||
|
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
||||||
|
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
|
||||||
|
-- its inner edge at 2.80 -- a 0.2 stud lateral interpenetration. Vertically the
|
||||||
|
-- wheel top reaches -0.2 against a body bottom of -0.7. Left in the same
|
||||||
|
-- collision group the wheels grind against the body and the car will not roll
|
||||||
|
-- (motors spin at ~1 rad/s against 20,000 torque). Putting wheels in their own
|
||||||
|
-- group that ignores the chassis is the fix; the geometry is intentional.
|
||||||
|
local WHEEL_GROUP = "CarWheel"
|
||||||
|
local CHASSIS_GROUP = "CarChassis"
|
||||||
|
-- Seating the driver on the cabin FLOOR (see Chassis.SEAT_ABOVE_GROUND) tucks
|
||||||
|
-- the avatar down inside the body slab, so its legs/torso now genuinely
|
||||||
|
-- interpenetrate the chassis. Overlapping colliders is exactly what launches a
|
||||||
|
-- character across the map, so the driver gets its own group that ignores the
|
||||||
|
-- car. The driver still collides with the world, so it can't clip through
|
||||||
|
-- terrain when ejected.
|
||||||
|
local DRIVER_GROUP = "CarDriver"
|
||||||
|
local groupsReady = false
|
||||||
|
|
||||||
|
local function ensureCollisionGroups()
|
||||||
|
if groupsReady then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
groupsReady = true
|
||||||
|
local ok = pcall(function()
|
||||||
|
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
|
||||||
|
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
|
||||||
|
PhysicsService:RegisterCollisionGroup(DRIVER_GROUP)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
-- Already registered (Play-solo re-entry) -- harmless.
|
||||||
|
end
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, CHASSIS_GROUP, false)
|
||||||
|
PhysicsService:CollisionGroupSetCollidable(DRIVER_GROUP, WHEEL_GROUP, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Put every part of a character into the driver collision group (or back into
|
||||||
|
--- Default when they get out), so a low-seated avatar can't fight the chassis.
|
||||||
|
local function setCharacterGroup(character: Model?, group: string)
|
||||||
|
if not character then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, d in ipairs(character:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
pcall(function()
|
||||||
|
d.CollisionGroup = group
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Helpers ---------------------------------------------------------------------
|
-- Helpers ---------------------------------------------------------------------
|
||||||
|
|
||||||
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
|
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
|
||||||
@@ -84,7 +155,21 @@ end
|
|||||||
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
-- Build one corner of the suspension. `isFront` corners get a steering servo.
|
||||||
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
|
||||||
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
|
||||||
local isFront = zSign < 0 -- car forward is -Z
|
-- FORWARD CONVENTION: car forward is body-local -Z. This is the Roblox
|
||||||
|
-- standard -- CFrame.LookVector IS the -Z basis vector.
|
||||||
|
--
|
||||||
|
-- DO NOT "fix" this to `zSign > 0`. That was tried on 2026-08-02 and made
|
||||||
|
-- things strictly worse: 2 of 5 cars were backwards before, all 5 after.
|
||||||
|
-- It was justified by tools/diag_seatdir.luau reporting "throttle drives
|
||||||
|
-- toward local +Z", which was a SIGN ERROR in the diagnostic, not a fact
|
||||||
|
-- about the game: expressing a part's own LookVector in its own local
|
||||||
|
-- frame is (0,0,-1) BY DEFINITION, so the script mixed world and local
|
||||||
|
-- space and then "confirmed" the inversion it was looking for.
|
||||||
|
--
|
||||||
|
-- Lesson: a measurement that merely restates a definition cannot be
|
||||||
|
-- evidence. Before trusting a diagnostic, ask what result would DISPROVE
|
||||||
|
-- the hypothesis -- if no possible output would, the check is worthless.
|
||||||
|
local isFront = zSign < 0
|
||||||
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
|
||||||
|
|
||||||
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
|
||||||
@@ -102,35 +187,36 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
|
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
|
||||||
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
|
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
|
||||||
|
|
||||||
local prismatic = Instance.new("PrismaticConstraint")
|
-- Rigid hub mount. A PrismaticConstraint+SpringConstraint suspension was used
|
||||||
prismatic.Name = "SuspSlide_" .. cornerName
|
-- here, but the hub (0.6 cube) is ~3 orders of magnitude lighter than the
|
||||||
prismatic.Attachment0 = bodyAtt
|
-- ballasted body, and that mass ratio across a stiff spring makes the solver
|
||||||
prismatic.Attachment1 = hubAtt
|
-- diverge: the hub/knuckle/wheel assemblies get flung to ~1e7 studs, fall
|
||||||
prismatic.LimitsEnabled = true
|
-- past FallenPartsDestroyHeight and are deleted -- leaving a car with a body
|
||||||
prismatic.LowerLimit = -Chassis.SUSPENSION_TRAVEL / 2
|
-- and no wheels that cannot move. Welding the hub makes it part of the body
|
||||||
prismatic.UpperLimit = Chassis.SUSPENSION_TRAVEL / 2
|
-- assembly, which is rock stable. Ride is rigid; the car actually drives.
|
||||||
prismatic.Parent = body
|
local hubWeld = Instance.new("WeldConstraint")
|
||||||
|
hubWeld.Name = "SuspWeld_" .. cornerName
|
||||||
local spring = Instance.new("SpringConstraint")
|
hubWeld.Part0 = body
|
||||||
spring.Name = "SuspSpring_" .. cornerName
|
hubWeld.Part1 = hub
|
||||||
spring.Attachment0 = bodyAtt
|
hubWeld.Parent = body
|
||||||
spring.Attachment1 = hubAtt
|
|
||||||
spring.FreeLength = 0.2
|
|
||||||
spring.Stiffness = tuning.SpringStiffness
|
|
||||||
spring.Damping = tuning.SpringDamping
|
|
||||||
spring.LimitsEnabled = false
|
|
||||||
spring.Parent = body
|
|
||||||
|
|
||||||
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
|
||||||
local wheelParent: Part = hub
|
local wheelParent: Part = hub
|
||||||
if isFront then
|
if isFront then
|
||||||
|
-- Knuckle size is load-bearing, not cosmetic: it sets the mass the
|
||||||
|
-- steering servo actually has to work with. See Chassis.KNUCKLE_SIZE
|
||||||
|
-- for the measured sweep -- at the old 0.5 the servo overshot its own
|
||||||
|
-- limits by 70+ degrees and the front wheels were uncontrollable.
|
||||||
|
-- It is CanCollide=false and fully transparent, so growing it changes
|
||||||
|
-- physics authority only, never the look of the car.
|
||||||
local knuckle = makePart({
|
local knuckle = makePart({
|
||||||
Name = "Knuckle_" .. cornerName,
|
Name = "Knuckle_" .. cornerName,
|
||||||
Size = Vector3.new(0.5, 0.5, 0.5),
|
Size = Vector3.new(Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE),
|
||||||
CFrame = wheelWorld,
|
CFrame = wheelWorld,
|
||||||
CanCollide = false,
|
CanCollide = false,
|
||||||
Parent = model,
|
Parent = model,
|
||||||
})
|
})
|
||||||
|
knuckle.Transparency = 1
|
||||||
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
|
||||||
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
|
||||||
|
|
||||||
@@ -151,6 +237,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
|
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
|
||||||
|
-- VISIBILITY: this cylinder is the PHYSICS wheel and must be invisible when
|
||||||
|
-- a real imported shell is used, because that shell brings its own modelled
|
||||||
|
-- wheels. Leaving it opaque put TWO sets of wheels on every car -- the mesh's
|
||||||
|
-- tyre plus a grey rubber cylinder on top of it -- which is what read as
|
||||||
|
-- "way too wide". They don't even line up: the cylinder's outer edge sits at
|
||||||
|
-- X=4.10 while the gtr_r34 mesh tyre sits at 4.52, so the duplicate poked out
|
||||||
|
-- past the real tyre at a different track width. The shell is applied later
|
||||||
|
-- in buildCarModel and hides these; if NO shell exists the procedural CarBody
|
||||||
|
-- kit supplies rims, so the cylinder is never the intended visual either way.
|
||||||
local wheel = makePart({
|
local wheel = makePart({
|
||||||
Name = "Wheel_" .. cornerName,
|
Name = "Wheel_" .. cornerName,
|
||||||
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
|
||||||
@@ -166,6 +261,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
|
|||||||
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
|
||||||
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
|
||||||
|
|
||||||
|
-- STEERING/MOTOR CONFLICT -- UNRESOLVED, see docs note below.
|
||||||
|
--
|
||||||
|
-- A steered front corner is two hinges in series: Hub --SteerServo(Y)-->
|
||||||
|
-- Knuckle --WheelMotor(X)--> Wheel. Measured (tools/diag_steer4.luau,
|
||||||
|
-- gtr_r34, target -20 deg): the servo tracks to within 0.01 deg ONLY when
|
||||||
|
-- the front wheel is detached or its hinge disabled; with the wheel
|
||||||
|
-- attached it stays pinned near 0 deg even hoisted in free air with
|
||||||
|
-- ServoMaxTorque up to 1e9. So it is NOT grip and NOT a torque contest.
|
||||||
|
--
|
||||||
|
-- Do NOT "fix" this by skipping the front motor: the motor hinge is the
|
||||||
|
-- only joint attaching Wheel_F* to Knuckle_F*, so omitting it leaves the
|
||||||
|
-- front wheels physically detached (verified, tools/diag_frontjoint.luau)
|
||||||
|
-- -- the servo then turns freely because it carries no load, and the car
|
||||||
|
-- generates no front lateral force at all.
|
||||||
local motor = Instance.new("HingeConstraint")
|
local motor = Instance.new("HingeConstraint")
|
||||||
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
motor.Name = Chassis.WHEEL_MOTOR_NAME
|
||||||
motor.Attachment0 = parentAtt
|
motor.Attachment0 = parentAtt
|
||||||
@@ -223,7 +332,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
seat.HeadsUpDisplay = false
|
seat.HeadsUpDisplay = false
|
||||||
seat.CanCollide = false
|
seat.CanCollide = false
|
||||||
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
seat.Color = Color3.new(0.1, 0.1, 0.1)
|
||||||
seat.CFrame = origin * CFrame.new(0, 1.2, -0.5)
|
-- Seat pan on the CABIN FLOOR, measured up from the ground plane the wheels
|
||||||
|
-- rest on -- NOT up from the body slab. The slab sits at the car's vertical
|
||||||
|
-- middle, so the old "slab top + 0.3" placement put the driver's hips at the
|
||||||
|
-- car's waistline and its head through the roof. Sitting the avatar this low
|
||||||
|
-- means its torso now overlaps the slab, which is why the driver goes in its
|
||||||
|
-- own collision group (see DRIVER_GROUP).
|
||||||
|
seat.CFrame = origin * CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2)
|
||||||
seat.Parent = model
|
seat.Parent = model
|
||||||
local seatWeld = Instance.new("WeldConstraint")
|
local seatWeld = Instance.new("WeldConstraint")
|
||||||
seatWeld.Part0 = body
|
seatWeld.Part0 = body
|
||||||
@@ -237,22 +352,389 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
-- Visual shell. If a real imported mesh exists under
|
||||||
CarBody.build(model, body, car)
|
-- ReplicatedStorage/CarModels/<carId>, use it; otherwise fall back to the
|
||||||
|
-- procedural CarBody kit. Meshes are visual-ONLY: every part is Massless,
|
||||||
|
-- CanCollide=false and welded to Body, so the constraint chassis (springs,
|
||||||
|
-- steering servos, motor wheels) and therefore the driving feel are
|
||||||
|
-- completely unchanged no matter which shell is used.
|
||||||
|
local shellTemplate = ReplicatedStorage:FindFirstChild("CarModels")
|
||||||
|
shellTemplate = shellTemplate and shellTemplate:FindFirstChild(car.Id)
|
||||||
|
if shellTemplate then
|
||||||
|
local shell = shellTemplate:Clone()
|
||||||
|
shell.Name = "Shell"
|
||||||
|
-- Measure the shell from its PARTS, not from Model:GetExtentsSize() /
|
||||||
|
-- Model:GetBoundingBox(). Both of those return an axis-aligned box that
|
||||||
|
-- is inflated by rotated parts: silv_s15 reports H=12.32 when its real
|
||||||
|
-- geometry is only 4.71 tall, which made it hover ~4 studs in the air.
|
||||||
|
-- Accumulating each part's own rotated half-extents gives the true box.
|
||||||
|
local function trueAABB(model)
|
||||||
|
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||||
|
local hi = Vector3.new(-math.huge, -math.huge, -math.huge)
|
||||||
|
local found = false
|
||||||
|
for _, p in ipairs(model:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
found = true
|
||||||
|
local cf, s = p.CFrame, p.Size
|
||||||
|
local hx = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.X * s.X)
|
||||||
|
+ math.abs(cf.UpVector.X * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.X * s.Z)
|
||||||
|
)
|
||||||
|
local hy = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.Y * s.X)
|
||||||
|
+ math.abs(cf.UpVector.Y * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.Y * s.Z)
|
||||||
|
)
|
||||||
|
local hz = 0.5
|
||||||
|
* (
|
||||||
|
math.abs(cf.RightVector.Z * s.X)
|
||||||
|
+ math.abs(cf.UpVector.Z * s.Y)
|
||||||
|
+ math.abs(cf.LookVector.Z * s.Z)
|
||||||
|
)
|
||||||
|
local c = p.Position
|
||||||
|
lo = Vector3.new(math.min(lo.X, c.X - hx), math.min(lo.Y, c.Y - hy), math.min(lo.Z, c.Z - hz))
|
||||||
|
hi = Vector3.new(math.max(hi.X, c.X + hx), math.max(hi.Y, c.Y + hy), math.max(hi.Z, c.Z + hz))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not found then
|
||||||
|
return nil, nil, nil
|
||||||
|
end
|
||||||
|
return lo, hi, hi - lo
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1. NORMALISE SIZE. Library meshes range 15.6 -> 19.0 studs long at
|
||||||
|
-- native scale, so cars looked inconsistent and the short ones had no
|
||||||
|
-- cabin room for the avatar. Uniform Model:ScaleTo keeps each mesh's
|
||||||
|
-- own proportions while making every car the same length.
|
||||||
|
-- The long axis is NOT always Z: gtr_r34 and hyper_x are modelled
|
||||||
|
-- rotated 90 degrees (length on X), so scaling by .Z blew them up to
|
||||||
|
-- ~2x. Use whichever horizontal axis is actually longer.
|
||||||
|
local _, _, rawSize = trueAABB(shell)
|
||||||
|
if rawSize then
|
||||||
|
local rawLen = math.max(rawSize.X, rawSize.Z)
|
||||||
|
if rawLen > 0.01 then
|
||||||
|
shell:ScaleTo(Chassis.SHELL_LENGTH / rawLen)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- 2. SIT IT ON THE GROUND. Align the true box's BOTTOM to the ground
|
||||||
|
-- plane the wheels rest on, then sink by SHELL_DROP so tyres aren't
|
||||||
|
-- swallowed. Measured against the pivot, since PivotTo moves the pivot.
|
||||||
|
local lo2 = trueAABB(shell)
|
||||||
|
local bottomOffset = 0
|
||||||
|
if lo2 then
|
||||||
|
bottomOffset = lo2.Y - shell:GetPivot().Position.Y
|
||||||
|
end
|
||||||
|
-- 2b. FACE THE SHELL FORWARD. PivotTo below forces the shell's pivot
|
||||||
|
-- orientation to the body's, so a shell inherits whatever yaw its
|
||||||
|
-- author happened to save it with -- there is no automatic
|
||||||
|
-- alignment. Three of the five library shells were modelled
|
||||||
|
-- nose-toward -Z (the chassis convention, since the front axle is
|
||||||
|
-- built at Z = -WHEELBASE/2) and looked right by luck; hatch_junk
|
||||||
|
-- and hyper_x were modelled nose-toward +Z and rendered BACKWARDS
|
||||||
|
-- on a correctly-built chassis.
|
||||||
|
-- Derive each shell's true forward from its OWN wheel names rather
|
||||||
|
-- than a hand-maintained per-car constant: F* wheels are the front
|
||||||
|
-- axle, R* the rear, so (front - rear) is the nose direction in the
|
||||||
|
-- shell's local frame. Solve the yaw that maps it onto the chassis
|
||||||
|
-- forward axis. This also covers 90-degree-rotated shells, and
|
||||||
|
-- self-corrects for any shell added later. If the cue is missing or
|
||||||
|
-- degenerate, rotate nothing (fail soft) rather than guess.
|
||||||
|
--
|
||||||
|
-- HISTORY: a SHELL_YAW_OVERRIDE table used to pin hatch_junk and
|
||||||
|
-- hyper_x to yaw 0, on the theory that their wheel labels were
|
||||||
|
-- reversed. That was WRONG and has been removed. Measurement
|
||||||
|
-- (tools/diag_shellyaw.luau) showed shell-vs-body yaw was already
|
||||||
|
-- 0.0 degrees on those cars -- there was no shell rotation error at
|
||||||
|
-- all. The real defect was the chassis forward convention being
|
||||||
|
-- inverted against the VehicleSeat (see buildCorner, isFront), which
|
||||||
|
-- made every car drive out of its labelled rear. With that fixed,
|
||||||
|
-- an override forcing yaw 0 would REINTRODUCE a 180-degree flip.
|
||||||
|
-- Do not re-add per-car yaw constants without first running
|
||||||
|
-- diag_shellyaw.luau and showing a non-zero yawDelta.
|
||||||
|
local shellYaw = 0
|
||||||
|
local wheelsFolder = shell:FindFirstChild("Wheels")
|
||||||
|
if wheelsFolder then
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local fSum, fN = Vector3.zero, 0
|
||||||
|
local rSum, rN = Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheelsFolder:GetChildren()) do
|
||||||
|
-- Use the group's CENTROID, not an arbitrary first BasePart:
|
||||||
|
-- FindFirstChildWhichIsA can return a rotor or caliper that
|
||||||
|
-- sits up to ~1 stud off the wheel centre (measured, see the
|
||||||
|
-- root-pick note in the wheel re-hang below).
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
local localPos = pivotInv * (sum / n)
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + localPos, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + localPos, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
-- Horizontal component only; a shell sitting nose-down must not
|
||||||
|
-- tilt the whole body.
|
||||||
|
if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then
|
||||||
|
-- Map the shell's nose onto chassis forward, which is -Z
|
||||||
|
-- (see the forward-convention note in buildCorner).
|
||||||
|
shellYaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Rotating about the pivot swings the bodywork sideways whenever the
|
||||||
|
-- pivot is not the geometric centre (hyper_x's centre sits 0.70 studs
|
||||||
|
-- off it, so a 180-degree flip would shift the shell 1.4 studs off the
|
||||||
|
-- chassis). Cancel that by re-centring the shell's horizontal AABB
|
||||||
|
-- centre onto the body axis; Y is untouched, so the ground alignment
|
||||||
|
-- solved above still holds (a yaw rotation cannot change world Y
|
||||||
|
-- extents).
|
||||||
|
local hi2 = select(2, trueAABB(shell))
|
||||||
|
local centreFix = CFrame.new()
|
||||||
|
if lo2 and hi2 then
|
||||||
|
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
|
||||||
|
centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
|
||||||
|
end
|
||||||
|
shell:PivotTo(
|
||||||
|
body.CFrame
|
||||||
|
* CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0)
|
||||||
|
* CFrame.Angles(0, shellYaw, 0)
|
||||||
|
* centreFix
|
||||||
|
)
|
||||||
|
-- 3. DROP THE SEAT TO FIT THIS SHELL'S ROOF. Cabin height varies by more
|
||||||
|
-- than a stud across the library (hyper_x roof sits 0.72 above the
|
||||||
|
-- body plane, gtr_r34 1.80). A single fixed seat height that looks
|
||||||
|
-- right in the tallest car pushes the driver's head through the
|
||||||
|
-- roof of the lowest one, so solve for the seat from the measured
|
||||||
|
-- roofline instead: seat = roof - head height - clearance.
|
||||||
|
local seatPart = model:FindFirstChild(Chassis.SEAT_NAME)
|
||||||
|
if seatPart and rawSize then
|
||||||
|
-- The roofline is NOT the top of the shell's bounding box. Every
|
||||||
|
-- library mesh carries junk above the cabin -- gtr_r34 has a stray
|
||||||
|
-- part literally named "Brick" 0.74 studs above its roof, rx7_fc
|
||||||
|
-- 0.51 -- so measuring to the AABB top invents headroom that isn't
|
||||||
|
-- there and the head pokes through the actual roof. Take the top of
|
||||||
|
-- the largest part by volume instead: that is the main body mesh.
|
||||||
|
local lo3, hi3 = trueAABB(shell)
|
||||||
|
local roofY = hi3 and hi3.Y or nil
|
||||||
|
local bestVol = -1
|
||||||
|
for _, p in ipairs(shell:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestVol then
|
||||||
|
bestVol = vol
|
||||||
|
local cf, sz = p.CFrame, p.Size
|
||||||
|
roofY = p.Position.Y
|
||||||
|
+ 0.5
|
||||||
|
* (math.abs(cf.RightVector.Y * sz.X) + math.abs(cf.UpVector.Y * sz.Y) + math.abs(
|
||||||
|
cf.LookVector.Y * sz.Z
|
||||||
|
))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if roofY and lo3 then
|
||||||
|
local roofLocal = roofY - body.Position.Y
|
||||||
|
-- HEAD_ABOVE_SEAT was measured from the seat part's CENTRE, but
|
||||||
|
-- seatLocal below is the pan height (the part is then placed at
|
||||||
|
-- seatLocal + Size.Y/2). Subtract the half-thickness here or every
|
||||||
|
-- car quietly loses that much of its intended clearance -- which
|
||||||
|
-- is why hyper_x measured 0.07 instead of the requested 0.30.
|
||||||
|
local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT - Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2
|
||||||
|
-- Allow sinking to the cabin floor: a low roof must win over the
|
||||||
|
-- nominal seat height, otherwise the clamp silently discards the
|
||||||
|
-- whole adaptive solve (which is what used to happen). The shell
|
||||||
|
-- bottom sits SHELL_DROP below the ground plane, so the seat can
|
||||||
|
-- go slightly under GROUND_OFFSET without breaching the underside
|
||||||
|
-- -- hyper_x (lowest roof) needs that headroom.
|
||||||
|
local floor = Chassis.GROUND_OFFSET - SHELL_DROP + 0.1
|
||||||
|
local seatLocal = math.clamp(wanted, floor, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND)
|
||||||
|
seatPart.CFrame = body.CFrame * CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2)
|
||||||
|
-- A WeldConstraint locks in the relative offset it had when it
|
||||||
|
-- was created, so moving the seat afterwards would be snapped
|
||||||
|
-- straight back. Rebuild the weld at the new offset.
|
||||||
|
for _, w in ipairs(body:GetChildren()) do
|
||||||
|
if w:IsA("WeldConstraint") and (w.Part1 == seatPart or w.Part0 == seatPart) then
|
||||||
|
w:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local seatWeld2 = Instance.new("WeldConstraint")
|
||||||
|
seatWeld2.Part0 = body
|
||||||
|
seatWeld2.Part1 = seatPart
|
||||||
|
seatWeld2.Parent = body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
d.Anchored = false
|
||||||
|
d.CanCollide = false
|
||||||
|
d.CanTouch = false
|
||||||
|
d.CanQuery = false
|
||||||
|
d.Massless = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if shellRoot then
|
||||||
|
local w = Instance.new("WeldConstraint")
|
||||||
|
w.Part0 = body
|
||||||
|
w.Part1 = shellRoot
|
||||||
|
w.Parent = body
|
||||||
|
end
|
||||||
|
shell.Parent = model
|
||||||
|
-- Hide the placeholder blocks the mesh replaces.
|
||||||
|
body.Transparency = 1
|
||||||
|
-- ...INCLUDING the physics wheels. The shell brings its own modelled
|
||||||
|
-- wheels, so leaving the chassis cylinders opaque puts TWO sets of
|
||||||
|
-- wheels on the car -- the mesh tyre plus a grey rubber cylinder over
|
||||||
|
-- it. That is what reads as absurdly wide tyres. They aren't even
|
||||||
|
-- concentric: the cylinder's outer edge sits at X=4.10 against the
|
||||||
|
-- gtr_r34 mesh tyre's 4.52, so the duplicate juts out past the real
|
||||||
|
-- wheel at a different track width.
|
||||||
|
-- This is deliberately INSIDE the shell branch. The procedural CarBody
|
||||||
|
-- fallback (else-branch) has no wheels of its own -- buildRims only
|
||||||
|
-- welds a small Rim disc INTO each Wheel_ part, at 1.2x radius versus
|
||||||
|
-- the wheel's 2x, and transparency is not inherited by children. Hide
|
||||||
|
-- these unconditionally and a shell-less car visibly rides on four
|
||||||
|
-- undersized floating discs.
|
||||||
|
for _, d in ipairs(model:GetChildren()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
d.Transparency = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- RE-HANG THE MODELLED WHEELS ON THE PHYSICS WHEELS. The imported
|
||||||
|
-- shells weld every wheel part straight to the body mesh (the template
|
||||||
|
-- weld graph is MB <-> FL, MB <-> Rotor, MB <-> Caliper, ...), so the
|
||||||
|
-- visible tyres are rigidly glued to the chassis: their assembly root
|
||||||
|
-- comes out as DriveSeat rather than the wheel. They never spin and
|
||||||
|
-- never steer -- painted-on hubcaps, which is most of why the wheels
|
||||||
|
-- looked wrong in motion. Cut those welds and re-weld each wheel group
|
||||||
|
-- to its own Wheel_ cylinder so it inherits that cylinder's spin, and
|
||||||
|
-- on the front axle its steer.
|
||||||
|
local wheelGroups = shell:FindFirstChild("Wheels")
|
||||||
|
if wheelGroups then
|
||||||
|
for _, w in ipairs(wheelGroups:GetChildren()) do
|
||||||
|
local phys = model:FindFirstChild("Wheel_" .. w.Name:upper())
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[#group + 1] = w
|
||||||
|
inGroup[w] = true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- ROOT MUST BE THE TYRE, so pick the largest-volume part in the
|
||||||
|
-- group. This previously used FindFirstChildWhichIsA, i.e. an
|
||||||
|
-- ARBITRARY first BasePart -- on the 18-part wheel groups that
|
||||||
|
-- lands on a rotor/caliper offset from the wheel centre. The
|
||||||
|
-- group is then translated to make root concentric with the
|
||||||
|
-- axle (see delta below), so a bad root displaces the visible
|
||||||
|
-- tyre by exactly that offset, which reads as wheels sticking
|
||||||
|
-- out past the bodywork. Measured root-to-centre offsets on the
|
||||||
|
-- templates (tools/diag_wheelroot2.luau): rx7_fc 0.955,
|
||||||
|
-- silv_s15 0.988, gtr_r34 0.285-0.310, hatch_junk 0.000-0.035
|
||||||
|
-- -- matching the report that the RX-7 was the obvious one.
|
||||||
|
local root, bestVol = nil, -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestVol then
|
||||||
|
root, bestVol = p, vol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and root then
|
||||||
|
-- Drop only the welds that tie this group to the OUTSIDE
|
||||||
|
-- (i.e. to the bodywork). Welds wholly inside the group are
|
||||||
|
-- the wheel's own construction and must survive.
|
||||||
|
for _, c in ipairs(shell:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn, bIn = a ~= nil and inGroup[a], b ~= nil and inGroup[b]
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
c:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Make it CONCENTRIC with the cylinder it now rides on.
|
||||||
|
-- Each shell models its own track and wheelbase (the
|
||||||
|
-- gtr_r34 rear tyre sits 1.34 studs ahead of the chassis
|
||||||
|
-- axle, the fronts 0.33 inboard). A weld preserves relative
|
||||||
|
-- offset, so welding at that offset would make the tyre
|
||||||
|
-- ORBIT the axle at 1.34 studs radius instead of spinning
|
||||||
|
-- on it -- far worse than the static wheel it replaces.
|
||||||
|
local delta = phys.Position - root.Position
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
p.CFrame = p.CFrame + delta
|
||||||
|
end
|
||||||
|
-- The template hung every part off the body, so with those
|
||||||
|
-- welds cut the group has no internal rigidity left of its
|
||||||
|
-- own. Rebuild it around root (these parts are already
|
||||||
|
-- Massless + CanCollide=false, so this adds no physics
|
||||||
|
-- cost), then hang root off the cylinder.
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
if p ~= root then
|
||||||
|
local iw = Instance.new("WeldConstraint")
|
||||||
|
iw.Part0 = root
|
||||||
|
iw.Part1 = p
|
||||||
|
iw.Parent = root
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local pw = Instance.new("WeldConstraint")
|
||||||
|
pw.Part0 = phys
|
||||||
|
pw.Part1 = root
|
||||||
|
pw.Parent = phys
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local cabin = model:FindFirstChild("Cabin")
|
||||||
|
if cabin then
|
||||||
|
cabin:Destroy()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
||||||
|
CarBody.build(model, body, car)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
|
||||||
|
ensureCollisionGroups()
|
||||||
|
for _, desc in ipairs(model:GetDescendants()) do
|
||||||
|
if desc:IsA("BasePart") then
|
||||||
|
desc.CollisionGroup = if desc.Name:match("^Wheel_") then WHEEL_GROUP else CHASSIS_GROUP
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
model.PrimaryPart = body
|
model.PrimaryPart = body
|
||||||
return model
|
return model
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
|
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
|
||||||
|
-- CharacterAdded can fire before MapBuilder.build() has finished, in which case
|
||||||
|
-- a naive lookup silently falls back to the origin and dumps the car ~8,600
|
||||||
|
-- studs off-circuit. Wait briefly for the pad rather than guessing.
|
||||||
local function spawnCFrame(): CFrame
|
local function spawnCFrame(): CFrame
|
||||||
local map = Workspace:FindFirstChild("Map")
|
local deadline = os.clock() + 10
|
||||||
if map then
|
repeat
|
||||||
local pad = map:FindFirstChild("SpawnPad", true)
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
local pad = map and map:FindFirstChild("SpawnPad", true)
|
||||||
if pad and pad:IsA("BasePart") then
|
if pad and pad:IsA("BasePart") then
|
||||||
return pad.CFrame * CFrame.new(0, 5, 0)
|
return pad.CFrame * CFrame.new(0, 5, 0)
|
||||||
end
|
end
|
||||||
end
|
task.wait(0.1)
|
||||||
|
until os.clock() > deadline
|
||||||
|
warn("[CarManager] SpawnPad never appeared; falling back to origin.")
|
||||||
return CFrame.new(0, 8, 0)
|
return CFrame.new(0, 8, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -285,27 +767,101 @@ function CarManager.spawn(player: Player, carId: string?)
|
|||||||
|
|
||||||
local tuning = Chassis.tuningFor(car)
|
local tuning = Chassis.tuningFor(car)
|
||||||
local model = buildCarModel(car, tuning)
|
local model = buildCarModel(car, tuning)
|
||||||
model:PivotTo(spawnCFrame())
|
local target = spawnCFrame()
|
||||||
model.Parent = carsFolder
|
model.Parent = carsFolder
|
||||||
|
-- Pivot AFTER parenting: pivoting an unparented model then reparenting lets
|
||||||
|
-- the physics solver re-resolve from the model's build origin, which drops
|
||||||
|
-- the car back at (0,0,0) instead of the spawn pad.
|
||||||
|
model:PivotTo(target)
|
||||||
model:SetAttribute("OwnerUserId", player.UserId)
|
model:SetAttribute("OwnerUserId", player.UserId)
|
||||||
model:SetAttribute("CarId", id)
|
model:SetAttribute("CarId", id)
|
||||||
|
|
||||||
-- Give the driver network ownership of EVERY assembly in the car. With a
|
-- Record every movable part's offset from the body while the model is
|
||||||
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE
|
-- guaranteed intact (freshly built, nothing has simulated yet), and hold
|
||||||
-- assemblies (constraints don't merge them like welds do) — missing one
|
-- SERVER authority for now. Handing ownership to the client before the
|
||||||
-- causes stuttery mixed-authority physics.
|
-- driver is seated lets the client re-assert a stale layout on top of us.
|
||||||
|
local primary = model.PrimaryPart
|
||||||
|
local offsets: { [BasePart]: CFrame } = {}
|
||||||
|
local movable: { BasePart } = {}
|
||||||
for _, desc in ipairs(model:GetDescendants()) do
|
for _, desc in ipairs(model:GetDescendants()) do
|
||||||
if desc:IsA("BasePart") and not desc.Anchored then
|
if desc:IsA("BasePart") and not desc.Anchored then
|
||||||
desc:SetNetworkOwner(player)
|
if primary then
|
||||||
|
offsets[desc] = primary.CFrame:Inverse() * desc.CFrame
|
||||||
|
end
|
||||||
|
table.insert(movable, desc)
|
||||||
|
pcall(function()
|
||||||
|
desc:SetNetworkOwner(nil)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local character = player.Character
|
local character = player.Character
|
||||||
if character then
|
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat?
|
||||||
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
|
if character and seat and primary then
|
||||||
if seat then
|
-- Why the car is moved to the player instead of the player to the car:
|
||||||
|
--
|
||||||
|
-- The character is CLIENT-owned, so a server-side PivotTo of it does
|
||||||
|
-- NOT stick -- it holds for one frame, then the client snaps it back.
|
||||||
|
-- Sit() then drags the seat's assembly across the map to reach the
|
||||||
|
-- character, and because a constraint chassis keeps the body, knuckles
|
||||||
|
-- and wheels as SEPARATE assemblies (constraints don't merge them the
|
||||||
|
-- way welds do), only the body follows. The rest stay at the pad and
|
||||||
|
-- the car is torn apart -- measured ~15,700 studs -- then falls out of
|
||||||
|
-- the world.
|
||||||
|
--
|
||||||
|
-- So: never make Sit() do the travelling. Teleport the INTACT car to
|
||||||
|
-- wherever the character already is, part-by-part through the recorded
|
||||||
|
-- offsets (PivotTo alone won't carry separate assemblies reliably),
|
||||||
|
-- and only then seat the driver. Sit() now moves nobody and nothing
|
||||||
|
-- tears. Ownership goes to the client last, once the rig is settled.
|
||||||
|
local humanoid = character:FindFirstChildOfClass("Humanoid")
|
||||||
|
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
||||||
|
if humanoid and root then
|
||||||
|
-- Face the car the way the character is facing, forced level so it
|
||||||
|
-- can never spawn on its roof.
|
||||||
|
local look = root.CFrame.LookVector
|
||||||
|
local flat = Vector3.new(look.X, 0, look.Z)
|
||||||
|
if flat.Magnitude < 0.1 then
|
||||||
|
flat = Vector3.new(0, 0, -1)
|
||||||
|
end
|
||||||
|
local origin = root.Position + Vector3.new(0, SEAT_LIFT, 0)
|
||||||
|
local base = CFrame.lookAt(origin, origin + flat.Unit)
|
||||||
|
for _, part in ipairs(movable) do
|
||||||
|
part.CFrame = base * offsets[part]
|
||||||
|
part.AssemblyLinearVelocity = Vector3.zero
|
||||||
|
part.AssemblyAngularVelocity = Vector3.zero
|
||||||
|
end
|
||||||
|
|
||||||
|
AntiCheat.grant(player)
|
||||||
|
-- Driver ignores the chassis BEFORE sitting: the avatar is seated
|
||||||
|
-- down inside the body slab, and overlapping colliders at Sit() is
|
||||||
|
-- what launches characters.
|
||||||
|
setCharacterGroup(character, DRIVER_GROUP)
|
||||||
|
seat:Sit(humanoid)
|
||||||
|
|
||||||
|
-- Restore normal collision when they get out, otherwise the player
|
||||||
|
-- walks around permanently unable to touch cars.
|
||||||
|
local seatConn: RBXScriptConnection
|
||||||
|
seatConn = seat:GetPropertyChangedSignal("Occupant"):Connect(function()
|
||||||
|
if seat.Occupant == nil then
|
||||||
|
setCharacterGroup(character, "Default")
|
||||||
|
seatConn:Disconnect()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
task.defer(function()
|
task.defer(function()
|
||||||
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
|
task.wait(SETTLE_TIME) -- let Sit() + the solver settle
|
||||||
|
if not primary:IsDescendantOf(Workspace) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
AntiCheat.grant(player)
|
||||||
|
for _, part in ipairs(movable) do
|
||||||
|
if part:IsDescendantOf(Workspace) then
|
||||||
|
pcall(function()
|
||||||
|
part:SetNetworkOwner(player)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -318,9 +874,174 @@ function CarManager.init()
|
|||||||
carsFolder.Name = "ActiveCars"
|
carsFolder.Name = "ActiveCars"
|
||||||
carsFolder.Parent = Workspace
|
carsFolder.Parent = Workspace
|
||||||
|
|
||||||
|
-- FLIP RECOVERY: a car that ends up on its roof has no way to right itself,
|
||||||
|
-- so the player is stranded upside down indefinitely -- endurance testing
|
||||||
|
-- left one inverted (up = -0.6) grinding a barrier for 140 seconds. When a
|
||||||
|
-- car is inverted AND effectively stationary for a few consecutive checks
|
||||||
|
-- (so we never flip someone mid-drift), lift it clear and set it upright,
|
||||||
|
-- preserving heading. Ownership is pulled back to the server for the
|
||||||
|
-- teleport: PivotTo on client-owned parts gets reverted by the owning
|
||||||
|
-- client on the next physics frame.
|
||||||
|
local flipTicks: { [Player]: number } = {}
|
||||||
|
task.spawn(function()
|
||||||
|
while true do
|
||||||
|
task.wait(1)
|
||||||
|
for _, player in ipairs(Players:GetPlayers()) do
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
local model = activeCars[player]
|
||||||
|
if not model or not model.Parent then
|
||||||
|
flipTicks[player] = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
|
||||||
|
if not root then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local upright = root.CFrame.UpVector:Dot(Vector3.yAxis)
|
||||||
|
local slow = root.AssemblyLinearVelocity.Magnitude < 8
|
||||||
|
if upright >= 0.35 or not slow then
|
||||||
|
flipTicks[player] = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
flipTicks[player] = (flipTicks[player] or 0) + 1
|
||||||
|
if flipTicks[player] < 3 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
flipTicks[player] = 0
|
||||||
|
|
||||||
|
local parts: { BasePart } = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and not d.Anchored then
|
||||||
|
table.insert(parts, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, d in ipairs(parts) do
|
||||||
|
pcall(function()
|
||||||
|
d:SetNetworkOwner(nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local look = root.CFrame.LookVector
|
||||||
|
local flat = Vector3.new(look.X, 0, look.Z)
|
||||||
|
if flat.Magnitude < 0.1 then
|
||||||
|
flat = Vector3.new(0, 0, -1)
|
||||||
|
end
|
||||||
|
local target = root.Position + Vector3.new(0, 6, 0)
|
||||||
|
model:PivotTo(CFrame.lookAt(target, target + flat.Unit))
|
||||||
|
for _, d in ipairs(parts) do
|
||||||
|
d.AssemblyLinearVelocity = Vector3.zero
|
||||||
|
d.AssemblyAngularVelocity = Vector3.zero
|
||||||
|
end
|
||||||
|
|
||||||
|
AntiCheat.grant(player, 3)
|
||||||
|
task.wait(0.3)
|
||||||
|
for _, d in ipairs(parts) do
|
||||||
|
pcall(function()
|
||||||
|
d:SetNetworkOwner(player)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
warn(("[CarManager] righted flipped car for %s"):format(player.Name))
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
warn("[CarManager] flip-recovery iteration failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
Players.PlayerRemoving:Connect(function(player)
|
Players.PlayerRemoving:Connect(function(player)
|
||||||
CarManager.despawn(player)
|
CarManager.despawn(player)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- VOID RECOVERY: nothing else rescues a car that leaves the world. If it
|
||||||
|
-- falls off the map it drops past Workspace.FallenPartsDestroyHeight, the
|
||||||
|
-- model is deleted out from under the driver, and the player is left
|
||||||
|
-- standing (or seatless) with no car and no way to get another -- observed
|
||||||
|
-- in endurance testing as 100+ seconds of a dead session. Watch for a car
|
||||||
|
-- that has fallen below the recovery floor OR whose model was destroyed
|
||||||
|
-- while the player is still in the game, and respawn it on the pad.
|
||||||
|
local RECOVER_Y = Workspace.FallenPartsDestroyHeight + 100
|
||||||
|
task.spawn(function()
|
||||||
|
while true do
|
||||||
|
task.wait(1)
|
||||||
|
for _, player in ipairs(Players:GetPlayers()) do
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
if not player.Parent or not player.Character then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local model = activeCars[player]
|
||||||
|
if not model then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local gone = model.Parent == nil
|
||||||
|
local fell = false
|
||||||
|
if not gone then
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
|
||||||
|
fell = root ~= nil and root.Position.Y < RECOVER_Y
|
||||||
|
end
|
||||||
|
if gone or fell then
|
||||||
|
warn(
|
||||||
|
("[CarManager] recovering car for %s (destroyed=%s fell=%s)"):format(
|
||||||
|
player.Name,
|
||||||
|
tostring(gone),
|
||||||
|
tostring(fell)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- CRITICAL: spawn() teleports the car to wherever the
|
||||||
|
-- CHARACTER is. During a void fall the seated character is
|
||||||
|
-- falling too, so respawning here would drop a fresh car
|
||||||
|
-- into the void and loop forever. Put the character back on
|
||||||
|
-- solid ground FIRST, then build the car around it.
|
||||||
|
local character = player.Character
|
||||||
|
local root = character and character:FindFirstChild("HumanoidRootPart")
|
||||||
|
if root and root:IsA("BasePart") then
|
||||||
|
local safe = spawnCFrame()
|
||||||
|
AntiCheat.grant(player, 5)
|
||||||
|
root.CFrame = safe + Vector3.new(0, 5, 0)
|
||||||
|
root.AssemblyLinearVelocity = Vector3.zero
|
||||||
|
root.AssemblyAngularVelocity = Vector3.zero
|
||||||
|
task.wait(0.2)
|
||||||
|
end
|
||||||
|
local data = DataManager.get(player)
|
||||||
|
CarManager.spawn(player, data and data.EquippedCarId)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
warn("[CarManager] void-recovery iteration failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The player's live car model, or nil if they have none right now.
|
||||||
|
function CarManager.getCar(player: Player): Model?
|
||||||
|
local model = activeCars[player]
|
||||||
|
if model and model.Parent then
|
||||||
|
return model
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The physics root of the player's car (the chassis Body). ModeManager samples
|
||||||
|
-- this to measure real, server-side position — never client-reported.
|
||||||
|
function CarManager.getRoot(player: Player): BasePart?
|
||||||
|
local model = CarManager.getCar(player)
|
||||||
|
if not model then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
if body and body:IsA("BasePart") then
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
return model.PrimaryPart
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test seam: build a bare chassis with no player, seat or network ownership.
|
||||||
|
-- Used by tools/diag_steering.luau to measure handling headlessly. Not part of
|
||||||
|
-- the game path -- CarManager.spawn is the only way a player ever gets a car.
|
||||||
|
CarManager._buildForDiag = buildCarModel
|
||||||
|
|
||||||
return CarManager
|
return CarManager
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ local DataManager = {}
|
|||||||
|
|
||||||
-- Profiles keyed by player. Value is the ProfileStore profile object.
|
-- Profiles keyed by player. Value is the ProfileStore profile object.
|
||||||
local profiles: { [Player]: any } = {}
|
local profiles: { [Player]: any } = {}
|
||||||
|
-- Players whose profile session is mid-flight (StartSessionAsync yields).
|
||||||
|
local loading: { [Player]: boolean } = {}
|
||||||
-- Callbacks queued before a profile finished loading.
|
-- Callbacks queued before a profile finished loading.
|
||||||
local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
|
local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
|
||||||
|
|
||||||
@@ -55,6 +57,16 @@ local function flushReady(player: Player, data: ProfileData)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function onPlayerAdded(player: Player)
|
local function onPlayerAdded(player: Player)
|
||||||
|
-- Guard against being invoked twice for the same player. init() connects
|
||||||
|
-- PlayerAdded *before* sweeping existing players (see init), so a player who
|
||||||
|
-- joins during that window can legitimately hit both paths. Loading twice
|
||||||
|
-- would make ProfileStore throw "Profile ... is already loaded in this
|
||||||
|
-- session" and leave the player with no data at all.
|
||||||
|
if profiles[player] or loading[player] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
loading[player] = true
|
||||||
|
|
||||||
local profile = store:StartSessionAsync(keyFor(player), {
|
local profile = store:StartSessionAsync(keyFor(player), {
|
||||||
Cancel = function()
|
Cancel = function()
|
||||||
-- Give up starting the session if the player already left.
|
-- Give up starting the session if the player already left.
|
||||||
@@ -64,7 +76,8 @@ local function onPlayerAdded(player: Player)
|
|||||||
|
|
||||||
if profile == nil then
|
if profile == nil then
|
||||||
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
||||||
player:Kick("[FnF] Your data could not be loaded. Please rejoin.")
|
loading[player] = nil
|
||||||
|
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -84,18 +97,21 @@ local function onPlayerAdded(player: Player)
|
|||||||
profile.OnSessionEnd:Connect(function()
|
profile.OnSessionEnd:Connect(function()
|
||||||
-- The session was lost (another server stole it, or we released it).
|
-- The session was lost (another server stole it, or we released it).
|
||||||
profiles[player] = nil
|
profiles[player] = nil
|
||||||
|
loading[player] = nil
|
||||||
if player.Parent == Players then
|
if player.Parent == Players then
|
||||||
player:Kick("[FnF] Your session ended (data loaded elsewhere). Please rejoin.")
|
player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if player.Parent ~= Players then
|
if player.Parent ~= Players then
|
||||||
-- Player left during the yield above; release immediately.
|
-- Player left during the yield above; release immediately.
|
||||||
|
loading[player] = nil
|
||||||
profile:EndSession()
|
profile:EndSession()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
profiles[player] = profile
|
profiles[player] = profile
|
||||||
|
loading[player] = nil
|
||||||
flushReady(player, profile.Data)
|
flushReady(player, profile.Data)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -105,6 +121,7 @@ local function onPlayerRemoving(player: Player)
|
|||||||
profile:EndSession() -- saves + releases the lock
|
profile:EndSession() -- saves + releases the lock
|
||||||
end
|
end
|
||||||
profiles[player] = nil
|
profiles[player] = nil
|
||||||
|
loading[player] = nil
|
||||||
readyQueue[player] = nil
|
readyQueue[player] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -143,11 +160,20 @@ function DataManager.init()
|
|||||||
store = base
|
store = base
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ORDER MATTERS: connect PlayerAdded FIRST, then sweep players who are
|
||||||
|
-- already in the game. The reverse order (sweep, then connect) has a gap —
|
||||||
|
-- in Studio the local player joins during that gap almost every run, so
|
||||||
|
-- neither path fired and the player was left with NO profile forever:
|
||||||
|
-- DataManager.get() stayed nil, CarManager.spawn() hit its `if not data`
|
||||||
|
-- guard and returned silently, and no car ever appeared.
|
||||||
|
-- onPlayerAdded is idempotent (see its profiles/loading guard), so a player
|
||||||
|
-- caught by both paths is loaded exactly once.
|
||||||
|
Players.PlayerAdded:Connect(onPlayerAdded)
|
||||||
|
Players.PlayerRemoving:Connect(onPlayerRemoving)
|
||||||
|
|
||||||
for _, player in ipairs(Players:GetPlayers()) do
|
for _, player in ipairs(Players:GetPlayers()) do
|
||||||
task.spawn(onPlayerAdded, player)
|
task.spawn(onPlayerAdded, player)
|
||||||
end
|
end
|
||||||
Players.PlayerAdded:Connect(onPlayerAdded)
|
|
||||||
Players.PlayerRemoving:Connect(onPlayerRemoving)
|
|
||||||
|
|
||||||
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
|
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
|
||||||
-- but ending sessions explicitly is tidy for the last frame).
|
-- but ending sessions explicitly is tidy for the last frame).
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
--!strict
|
||||||
|
--[[
|
||||||
|
ModeManager.luau (SERVER)
|
||||||
|
|
||||||
|
The heart of the mode system. Tokyo Extreme Racer structure:
|
||||||
|
|
||||||
|
IN THE PA -> select a mode -> drive out the gate -> RUNNING
|
||||||
|
^ |
|
||||||
|
+---------------- pull back into the PA ----------------+
|
||||||
|
|
||||||
|
States per player:
|
||||||
|
IN_PA — parked at Daikoku. Mode picker is open. Nothing is timed.
|
||||||
|
ARMED — mode chosen, still in the PA. Crossing the exit gate starts it.
|
||||||
|
RUNNING — out on the Wangan. Mode rules are live.
|
||||||
|
|
||||||
|
Every mode shares ONE progress primitive (Circuit.progressOf) so distance,
|
||||||
|
lap timing and duel gaps are all the same measurement. Server-authoritative:
|
||||||
|
the client never reports its own position, distance, or lap time — the
|
||||||
|
server samples the car's real position on a Heartbeat.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Players = game:GetService("Players")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage.Shared
|
||||||
|
local Circuit = require(Shared.Circuit)
|
||||||
|
local Modes = require(Shared.Modes)
|
||||||
|
local Remotes = require(Shared.Remotes)
|
||||||
|
|
||||||
|
local DataManager = require(script.Parent.DataManager)
|
||||||
|
local CarManager = require(script.Parent.CarManager)
|
||||||
|
|
||||||
|
local ModeManager = {}
|
||||||
|
|
||||||
|
export type State = "IN_PA" | "ARMED" | "RUNNING"
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
player: Player,
|
||||||
|
state: State,
|
||||||
|
mode: string?, -- selected ModeId
|
||||||
|
hint: number, -- last nearest-sample index (search accelerator)
|
||||||
|
progress: number, -- studs around the lap
|
||||||
|
lastProgress: number,
|
||||||
|
-- distance
|
||||||
|
sessionStuds: number,
|
||||||
|
-- time attack
|
||||||
|
lapStart: number?,
|
||||||
|
lapStartProgress: number?,
|
||||||
|
sectorTimes: { number },
|
||||||
|
sectorIndex: number,
|
||||||
|
lastLapTime: number?,
|
||||||
|
-- duel
|
||||||
|
duelId: number?,
|
||||||
|
-- housekeeping
|
||||||
|
lastPos: Vector3?,
|
||||||
|
offerCooldown: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
local sessions: { [Player]: Session } = {}
|
||||||
|
|
||||||
|
-- Duels ----------------------------------------------------------------------
|
||||||
|
type Duel = {
|
||||||
|
id: number,
|
||||||
|
a: Player,
|
||||||
|
b: Player,
|
||||||
|
startedAt: number,
|
||||||
|
leadSince: number?, -- when the current leader first hit WIN_GAP
|
||||||
|
leader: Player?,
|
||||||
|
}
|
||||||
|
local duels: { [number]: Duel } = {}
|
||||||
|
local nextDuelId = 1
|
||||||
|
|
||||||
|
local rModeUpdated: RemoteEvent
|
||||||
|
local rDuelOffer: RemoteEvent
|
||||||
|
local rDuelResult: RemoteEvent
|
||||||
|
|
||||||
|
-- Helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function carRoot(player: Player): BasePart?
|
||||||
|
return CarManager.getRoot(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sectorOf(progress: number): number
|
||||||
|
local n = Modes.TIME_ATTACK.SECTORS
|
||||||
|
local idx = math.floor((progress / Circuit.LAP_LENGTH) * n) + 1
|
||||||
|
return math.clamp(idx, 1, n)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Push the full HUD snapshot for one player.
|
||||||
|
local function push(session: Session)
|
||||||
|
local player = session.player
|
||||||
|
local data = DataManager.get(player)
|
||||||
|
local duel = session.duelId and duels[session.duelId] or nil
|
||||||
|
|
||||||
|
local duelInfo = nil
|
||||||
|
if duel then
|
||||||
|
local other = duel.a == player and duel.b or duel.a
|
||||||
|
local otherSession = sessions[other]
|
||||||
|
local gap = 0
|
||||||
|
if otherSession then
|
||||||
|
gap = Circuit.gap(session.progress, otherSession.progress)
|
||||||
|
end
|
||||||
|
duelInfo = {
|
||||||
|
opponent = other.DisplayName,
|
||||||
|
gap = gap,
|
||||||
|
elapsed = os.clock() - duel.startedAt,
|
||||||
|
winGap = Modes.DUEL.WIN_GAP,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
rModeUpdated:FireClient(player, {
|
||||||
|
state = session.state,
|
||||||
|
mode = session.mode,
|
||||||
|
inPA = session.state == "IN_PA",
|
||||||
|
lapTime = session.lapStart and (os.clock() - session.lapStart) or nil,
|
||||||
|
lastLapTime = session.lastLapTime,
|
||||||
|
bestLapTime = data and data.BestLapTime or 0,
|
||||||
|
sector = session.sectorIndex,
|
||||||
|
sectorTimes = session.sectorTimes,
|
||||||
|
sessionKm = session.sessionStuds * Circuit.STUDS_TO_KM,
|
||||||
|
totalKm = data and (data.DistanceStuds * Circuit.STUDS_TO_KM) or 0,
|
||||||
|
duel = duelInfo,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetRun(session: Session)
|
||||||
|
session.lapStart = nil
|
||||||
|
session.lapStartProgress = nil
|
||||||
|
session.sectorTimes = { 0, 0, 0, 0 }
|
||||||
|
session.sectorIndex = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function endDuel(duel: Duel, winner: Player?, reason: string)
|
||||||
|
duels[duel.id] = nil
|
||||||
|
for _, p in ipairs({ duel.a, duel.b }) do
|
||||||
|
local s = sessions[p]
|
||||||
|
if s then
|
||||||
|
s.duelId = nil
|
||||||
|
end
|
||||||
|
local data = DataManager.get(p)
|
||||||
|
local won = winner == p
|
||||||
|
if data and winner then
|
||||||
|
if won then
|
||||||
|
data.DuelsWon += 1
|
||||||
|
else
|
||||||
|
data.DuelsLost += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rDuelResult:FireClient(p, {
|
||||||
|
won = won,
|
||||||
|
expired = winner == nil,
|
||||||
|
reason = reason,
|
||||||
|
opponent = (duel.a == p and duel.b or duel.a).DisplayName,
|
||||||
|
})
|
||||||
|
if s then
|
||||||
|
push(s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Public API -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function ModeManager.onPlayerAdded(player: Player)
|
||||||
|
sessions[player] = {
|
||||||
|
player = player,
|
||||||
|
state = "IN_PA",
|
||||||
|
mode = nil,
|
||||||
|
hint = 1,
|
||||||
|
progress = 0,
|
||||||
|
lastProgress = 0,
|
||||||
|
sessionStuds = 0,
|
||||||
|
sectorTimes = { 0, 0, 0, 0 },
|
||||||
|
sectorIndex = 1,
|
||||||
|
offerCooldown = 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeManager.onPlayerRemoving(player: Player)
|
||||||
|
local session = sessions[player]
|
||||||
|
if session and session.duelId then
|
||||||
|
local duel = duels[session.duelId]
|
||||||
|
if duel then
|
||||||
|
local other = duel.a == player and duel.b or duel.a
|
||||||
|
endDuel(duel, other, "opponent left")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sessions[player] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Client asks to arm a mode. Only legal while parked in the PA.
|
||||||
|
function ModeManager.selectMode(player: Player, modeId: any): boolean
|
||||||
|
if not Modes.IsValid(modeId) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local session = sessions[player]
|
||||||
|
if not session then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if session.state == "RUNNING" then
|
||||||
|
return false -- must return to the PA first
|
||||||
|
end
|
||||||
|
session.mode = modeId
|
||||||
|
session.state = "ARMED"
|
||||||
|
resetRun(session)
|
||||||
|
push(session)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeManager.abandon(player: Player)
|
||||||
|
local session = sessions[player]
|
||||||
|
if not session then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if session.duelId then
|
||||||
|
local duel = duels[session.duelId]
|
||||||
|
if duel then
|
||||||
|
local other = duel.a == player and duel.b or duel.a
|
||||||
|
endDuel(duel, other, "opponent backed out")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session.state = "IN_PA"
|
||||||
|
resetRun(session)
|
||||||
|
push(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeManager.respondDuel(player: Player, accept: any)
|
||||||
|
local session = sessions[player]
|
||||||
|
if not session or typeof(accept) ~= "boolean" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local pendingId = session.duelId
|
||||||
|
if not pendingId then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local duel = duels[pendingId]
|
||||||
|
if not duel then
|
||||||
|
session.duelId = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not accept then
|
||||||
|
endDuel(duel, nil, "declined")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Accepting confirms it; the challenger already had it armed.
|
||||||
|
duel.startedAt = os.clock()
|
||||||
|
for _, p in ipairs({ duel.a, duel.b }) do
|
||||||
|
local s = sessions[p]
|
||||||
|
if s then
|
||||||
|
push(s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Try to pair two nearby players into a duel.
|
||||||
|
local function tryOfferDuel(session: Session)
|
||||||
|
if session.duelId or session.state ~= "RUNNING" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local mode = session.mode and Modes.Get(session.mode)
|
||||||
|
if not mode or not mode.AllowChallenges then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if os.clock() < session.offerCooldown then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local myRoot = carRoot(session.player)
|
||||||
|
if not myRoot then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for other, otherSession in pairs(sessions) do
|
||||||
|
if other ~= session.player and not otherSession.duelId and otherSession.state == "RUNNING" then
|
||||||
|
local otherMode = otherSession.mode and Modes.Get(otherSession.mode)
|
||||||
|
if otherMode and otherMode.AllowChallenges then
|
||||||
|
local otherRoot = carRoot(other)
|
||||||
|
if otherRoot and (otherRoot.Position - myRoot.Position).Magnitude <= Modes.DUEL.CHALLENGE_RANGE then
|
||||||
|
local duel: Duel = {
|
||||||
|
id = nextDuelId,
|
||||||
|
a = session.player,
|
||||||
|
b = other,
|
||||||
|
startedAt = os.clock(),
|
||||||
|
}
|
||||||
|
duels[nextDuelId] = duel
|
||||||
|
nextDuelId += 1
|
||||||
|
session.duelId = duel.id
|
||||||
|
otherSession.duelId = duel.id
|
||||||
|
session.offerCooldown = os.clock() + 30
|
||||||
|
otherSession.offerCooldown = os.clock() + 30
|
||||||
|
rDuelOffer:FireClient(session.player, { opponent = other.DisplayName })
|
||||||
|
rDuelOffer:FireClient(other, { opponent = session.player.DisplayName })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Per-player tick ------------------------------------------------------------
|
||||||
|
local function stepPlayer(session: Session, dt: number)
|
||||||
|
local root = carRoot(session.player)
|
||||||
|
if not root then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local pos = root.Position
|
||||||
|
|
||||||
|
local progress, hint = Circuit.progressOf(pos, session.hint)
|
||||||
|
session.hint = hint
|
||||||
|
session.lastProgress = session.progress
|
||||||
|
session.progress = progress
|
||||||
|
|
||||||
|
local inPA = Circuit.inPA(pos)
|
||||||
|
|
||||||
|
-- Distance travelled (real movement, server-measured) ---------------------
|
||||||
|
if session.lastPos then
|
||||||
|
local moved = (pos - session.lastPos).Magnitude
|
||||||
|
-- Ignore teleports/respawns: a car can't legitimately move 500 studs/frame.
|
||||||
|
if moved < 500 * dt + 5 then
|
||||||
|
session.sessionStuds += moved
|
||||||
|
local data = DataManager.get(session.player)
|
||||||
|
if data then
|
||||||
|
data.DistanceStuds += moved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session.lastPos = pos
|
||||||
|
|
||||||
|
-- State transitions -------------------------------------------------------
|
||||||
|
if session.state == "ARMED" and not inPA then
|
||||||
|
-- Crossed the gate onto the Wangan: the run is live.
|
||||||
|
session.state = "RUNNING"
|
||||||
|
local mode = session.mode and Modes.Get(session.mode)
|
||||||
|
if mode and mode.Timed then
|
||||||
|
session.lapStart = os.clock()
|
||||||
|
session.lapStartProgress = progress
|
||||||
|
session.sectorIndex = sectorOf(progress)
|
||||||
|
session.sectorTimes = { 0, 0, 0, 0 }
|
||||||
|
end
|
||||||
|
push(session)
|
||||||
|
elseif session.state == "RUNNING" and inPA then
|
||||||
|
-- Pulled back into the PA: run over, back to the hub.
|
||||||
|
if session.duelId then
|
||||||
|
local duel = duels[session.duelId]
|
||||||
|
if duel then
|
||||||
|
local other = duel.a == session.player and duel.b or duel.a
|
||||||
|
endDuel(duel, other, "returned to PA")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session.state = "IN_PA"
|
||||||
|
resetRun(session)
|
||||||
|
push(session)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if session.state ~= "RUNNING" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mode = session.mode and Modes.Get(session.mode)
|
||||||
|
if not mode then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TIME ATTACK: sector splits + lap completion ----------------------------
|
||||||
|
if mode.Timed and session.lapStart then
|
||||||
|
local sec = sectorOf(progress)
|
||||||
|
if sec ~= session.sectorIndex then
|
||||||
|
-- crossed a sector boundary (forward only)
|
||||||
|
local delta = Circuit.gap(progress, session.lastProgress)
|
||||||
|
if delta > 0 then
|
||||||
|
session.sectorTimes[session.sectorIndex] = os.clock() - session.lapStart
|
||||||
|
session.sectorIndex = sec
|
||||||
|
push(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lap completion: progress wrapped past the start point going forward.
|
||||||
|
local startP = session.lapStartProgress or 0
|
||||||
|
local wrapped = session.lastProgress > startP and progress < startP
|
||||||
|
local passed = session.lastProgress < startP and progress >= startP
|
||||||
|
local movedFar = math.abs(Circuit.gap(progress, session.lastProgress)) < Circuit.LAP_LENGTH / 4
|
||||||
|
if (wrapped or passed) and movedFar and (os.clock() - session.lapStart) > 20 then
|
||||||
|
local lap = os.clock() - session.lapStart
|
||||||
|
session.lastLapTime = lap
|
||||||
|
local data = DataManager.get(session.player)
|
||||||
|
if data then
|
||||||
|
if data.BestLapTime == 0 or lap < data.BestLapTime then
|
||||||
|
data.BestLapTime = lap
|
||||||
|
end
|
||||||
|
for i, t in ipairs(session.sectorTimes) do
|
||||||
|
if t > 0 and (data.BestSectors[i] == 0 or t < data.BestSectors[i]) then
|
||||||
|
data.BestSectors[i] = t
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Roll straight into the next flying lap.
|
||||||
|
session.lapStart = os.clock()
|
||||||
|
session.lapStartProgress = progress
|
||||||
|
session.sectorTimes = { 0, 0, 0, 0 }
|
||||||
|
session.sectorIndex = sectorOf(progress)
|
||||||
|
push(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- DUEL: gap-based win condition ------------------------------------------
|
||||||
|
if session.duelId then
|
||||||
|
local duel = duels[session.duelId]
|
||||||
|
if duel and duel.a == session.player then -- evaluate once per duel
|
||||||
|
local sa, sb = sessions[duel.a], sessions[duel.b]
|
||||||
|
if sa and sb then
|
||||||
|
local gap = Circuit.gap(sa.progress, sb.progress)
|
||||||
|
local leader = gap > 0 and duel.a or duel.b
|
||||||
|
local absGap = math.abs(gap)
|
||||||
|
|
||||||
|
if absGap >= Modes.DUEL.WIN_GAP then
|
||||||
|
if duel.leader ~= leader then
|
||||||
|
duel.leader = leader
|
||||||
|
duel.leadSince = os.clock()
|
||||||
|
elseif duel.leadSince and (os.clock() - duel.leadSince) >= Modes.DUEL.HOLD_TIME then
|
||||||
|
endDuel(duel, leader, "broke away")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
duel.leader = nil
|
||||||
|
duel.leadSince = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if (os.clock() - duel.startedAt) > Modes.DUEL.MAX_DURATION then
|
||||||
|
endDuel(duel, nil, "time expired")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
tryOfferDuel(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeManager.init()
|
||||||
|
rModeUpdated = Remotes.get("ModeUpdated") :: RemoteEvent
|
||||||
|
rDuelOffer = Remotes.get("DuelOffer") :: RemoteEvent
|
||||||
|
rDuelResult = Remotes.get("DuelResult") :: RemoteEvent
|
||||||
|
|
||||||
|
for _, player in ipairs(Players:GetPlayers()) do
|
||||||
|
ModeManager.onPlayerAdded(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- HUD push throttle: state changes push immediately, this is the steady
|
||||||
|
-- 5 Hz refresh for timers/gaps.
|
||||||
|
local pushAccum = 0
|
||||||
|
RunService.Heartbeat:Connect(function(dt)
|
||||||
|
for _, session in pairs(sessions) do
|
||||||
|
stepPlayer(session, dt)
|
||||||
|
end
|
||||||
|
pushAccum += dt
|
||||||
|
if pushAccum >= 0.2 then
|
||||||
|
pushAccum = 0
|
||||||
|
for _, session in pairs(sessions) do
|
||||||
|
if session.state == "RUNNING" then
|
||||||
|
push(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModeManager.pushTo(player: Player)
|
||||||
|
local session = sessions[player]
|
||||||
|
if session then
|
||||||
|
push(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return ModeManager
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
init.server.luau (SERVER ENTRY POINT)
|
init.server.luau (SERVER ENTRY POINT)
|
||||||
MVP scope: JUST DRIVING. The server:
|
|
||||||
1. Creates the remotes
|
|
||||||
2. Boots DataManager (safe saves), CarManager, AntiCheat
|
|
||||||
3. Builds the expressway map + lighting mood
|
|
||||||
4. Gives each player their car and lets them switch cars
|
|
||||||
|
|
||||||
Server-authoritative: the client REQUESTS a car, the server DECIDES (and
|
Scope: driving + the three-mode Wangan loop. The server:
|
||||||
enforces the RX-7 allowlist).
|
1. Creates the remotes
|
||||||
|
2. Boots DataManager (safe saves), CarManager, ModeManager, AntiCheat
|
||||||
|
3. Builds the expressway map + lighting mood
|
||||||
|
4. Gives each player their car, lets them switch cars, and runs the
|
||||||
|
PA -> mode select -> exit gate -> run -> back to PA loop
|
||||||
|
|
||||||
|
Server-authoritative: the client REQUESTS a car or a mode, the server
|
||||||
|
DECIDES. Distance, lap times and duel gaps are measured from the car's real
|
||||||
|
server-side position — the client never reports its own numbers.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
@@ -17,10 +20,12 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|||||||
local Shared = ReplicatedStorage.Shared
|
local Shared = ReplicatedStorage.Shared
|
||||||
local Remotes = require(Shared.Remotes)
|
local Remotes = require(Shared.Remotes)
|
||||||
local Cars = require(Shared.Cars)
|
local Cars = require(Shared.Cars)
|
||||||
|
local Circuit = require(Shared.Circuit)
|
||||||
local GameConfig = require(Shared.GameConfig)
|
local GameConfig = require(Shared.GameConfig)
|
||||||
|
|
||||||
local DataManager = require(script.DataManager)
|
local DataManager = require(script.DataManager)
|
||||||
local CarManager = require(script.CarManager)
|
local CarManager = require(script.CarManager)
|
||||||
|
local ModeManager = require(script.ModeManager)
|
||||||
local MapBuilder = require(script.MapBuilder)
|
local MapBuilder = require(script.MapBuilder)
|
||||||
local AntiCheat = require(script.AntiCheat)
|
local AntiCheat = require(script.AntiCheat)
|
||||||
local Ambience = require(script.Ambience)
|
local Ambience = require(script.Ambience)
|
||||||
@@ -30,36 +35,99 @@ Remotes.init()
|
|||||||
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
|
||||||
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
|
||||||
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
|
||||||
|
local rSelectMode = Remotes.get("SelectMode") :: RemoteFunction
|
||||||
|
local rRespondDuel = Remotes.get("RespondDuel") :: RemoteEvent
|
||||||
|
local rAbandonRun = Remotes.get("AbandonRun") :: RemoteEvent
|
||||||
|
|
||||||
local function pushCar(player: Player)
|
local function pushCar(player: Player)
|
||||||
local data = DataManager.get(player)
|
local data = DataManager.get(player)
|
||||||
if data then
|
if data then
|
||||||
rDataUpdated:FireClient(player, data.EquippedCarId)
|
rDataUpdated:FireClient(player, data.EquippedCarId, {
|
||||||
|
totalKm = data.DistanceStuds * Circuit.STUDS_TO_KM,
|
||||||
|
bestLapTime = data.BestLapTime,
|
||||||
|
duelsWon = data.DuelsWon,
|
||||||
|
duelsLost = data.DuelsLost,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 2. Boot managers ----------------------------------------------------------
|
-- 2. Boot managers ----------------------------------------------------------
|
||||||
DataManager.init()
|
DataManager.init()
|
||||||
CarManager.init()
|
CarManager.init()
|
||||||
|
ModeManager.init()
|
||||||
AntiCheat.init()
|
AntiCheat.init()
|
||||||
|
|
||||||
-- 3. Build the world --------------------------------------------------------
|
-- 3. Player lifecycle: push car id + auto-spawn their car -------------------
|
||||||
MapBuilder.build()
|
-- SPAWN FIX: characters must NOT auto-load. MapBuilder.build() yields for
|
||||||
Ambience.init()
|
-- several seconds and, until it returns, the world contains no SpawnLocation
|
||||||
|
-- at all -- so an auto-loaded character lands at world origin (0, 5, 0),
|
||||||
|
-- ~2,700 studs off the nearest road and ~8,500 from the parking area. Because
|
||||||
|
-- CarManager reassembles the car around the character's root, the car is
|
||||||
|
-- stranded at the origin too, sitting on an isolated pad; drive more than a
|
||||||
|
-- few hundred studs and you fall out of the world forever. Load characters
|
||||||
|
-- only once the map (and its PASpawn SpawnLocation) actually exists.
|
||||||
|
Players.CharacterAutoLoads = false
|
||||||
|
local mapReady = false
|
||||||
|
|
||||||
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
|
local handled: { [Player]: boolean } = {}
|
||||||
Players.PlayerAdded:Connect(function(player)
|
|
||||||
|
local function onPlayer(player: Player)
|
||||||
|
if handled[player] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
handled[player] = true
|
||||||
|
|
||||||
|
ModeManager.onPlayerAdded(player)
|
||||||
DataManager.onReady(player, function()
|
DataManager.onReady(player, function()
|
||||||
pushCar(player)
|
pushCar(player)
|
||||||
|
ModeManager.pushTo(player)
|
||||||
end)
|
end)
|
||||||
player.CharacterAdded:Connect(function()
|
player.CharacterAdded:Connect(function(character)
|
||||||
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)
|
||||||
end)
|
end)
|
||||||
|
-- CharacterAutoLoads = false also disables respawn-on-death, so drive
|
||||||
|
-- it manually or a dead player is stuck as a corpse forever.
|
||||||
|
local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
|
||||||
|
if humanoid then
|
||||||
|
humanoid.Died:Once(function()
|
||||||
|
task.wait(Players.RespawnTime)
|
||||||
|
if player.Parent then
|
||||||
|
player:LoadCharacter()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
task.spawn(function()
|
||||||
|
while not mapReady do
|
||||||
|
task.wait(0.1)
|
||||||
|
end
|
||||||
|
if player.Parent then
|
||||||
|
player:LoadCharacter()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Connected BEFORE the build so a player joining mid-build is never missed.
|
||||||
|
Players.PlayerAdded:Connect(onPlayer)
|
||||||
|
-- ...and catch anyone already present: in a Studio playtest the local player
|
||||||
|
-- routinely exists the moment this script runs, so PlayerAdded never fires.
|
||||||
|
for _, player in ipairs(Players:GetPlayers()) do
|
||||||
|
task.spawn(onPlayer, player)
|
||||||
|
end
|
||||||
|
|
||||||
|
Players.PlayerRemoving:Connect(function(player)
|
||||||
|
handled[player] = nil
|
||||||
|
ModeManager.onPlayerRemoving(player)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- 4. Build the world --------------------------------------------------------
|
||||||
|
MapBuilder.build()
|
||||||
|
Ambience.init()
|
||||||
|
mapReady = true
|
||||||
|
|
||||||
-- 5. Remote handlers (validated server-side) --------------------------------
|
-- 5. Remote handlers (validated server-side) --------------------------------
|
||||||
rSpawnCar.OnServerEvent:Connect(function(player)
|
rSpawnCar.OnServerEvent:Connect(function(player)
|
||||||
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
|
||||||
@@ -87,4 +155,22 @@ rEquipCar.OnServerInvoke = function(player, carId)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
print("[FnF] Server booted (drive-only MVP): map built, car system online.")
|
rSelectMode.OnServerInvoke = function(player, modeId)
|
||||||
|
return ModeManager.selectMode(player, modeId)
|
||||||
|
end
|
||||||
|
|
||||||
|
rRespondDuel.OnServerEvent:Connect(function(player, accept)
|
||||||
|
ModeManager.respondDuel(player, accept)
|
||||||
|
end)
|
||||||
|
|
||||||
|
rAbandonRun.OnServerEvent:Connect(function(player)
|
||||||
|
ModeManager.abandon(player)
|
||||||
|
end)
|
||||||
|
|
||||||
|
print(
|
||||||
|
string.format(
|
||||||
|
"[Wangan] Server booted: map built, car system online, 3 modes armed (lap = %.0f studs / %.2f km).",
|
||||||
|
Circuit.LAP_LENGTH,
|
||||||
|
Circuit.LAP_LENGTH * Circuit.STUDS_TO_KM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -31,14 +31,86 @@ Chassis.DRIVE_SIGN = -1 -- forward (-Z) needs negative angular velocity about +X
|
|||||||
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
|
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
|
||||||
|
|
||||||
-- Geometry (studs) ------------------------------------------------------------
|
-- Geometry (studs) ------------------------------------------------------------
|
||||||
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12)
|
-- Sized to REAL imported car meshes, not the other way around. The donor models
|
||||||
Chassis.WHEEL_RADIUS = 1.4
|
-- in ServerStorage/CarLibrary average 17.4 studs long / ~8.5 wide / ~5 tall,
|
||||||
|
-- which is correct for a Roblox character (4.7 studs tall) to sit in. The old
|
||||||
|
-- 12-stud chassis forced those meshes to be scaled DOWN ~26%, which left the
|
||||||
|
-- driver sticking out of the roof. Chassis now matches the meshes at 1:1 scale.
|
||||||
|
Chassis.BODY_SIZE = Vector3.new(6.6, 1.6, 17.0)
|
||||||
|
-- Wheel size is set from the shell length, not picked by eye. A real car's
|
||||||
|
-- tyre diameter is ~14% of its length; at SHELL_LENGTH 19 that is ~2.7 studs.
|
||||||
|
-- The old 4.0-stud tyre (21%) is what made every car read as a monster truck,
|
||||||
|
-- and because GROUND_OFFSET is derived from it the whole body rode 4.2 studs
|
||||||
|
-- up in the air to clear it.
|
||||||
|
Chassis.WHEEL_RADIUS = 1.35
|
||||||
Chassis.WHEEL_WIDTH = 1.1
|
Chassis.WHEEL_WIDTH = 1.1
|
||||||
Chassis.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre
|
Chassis.TRACK_HALF = 3.55 -- lateral distance body centre -> wheel centre
|
||||||
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle
|
Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle
|
||||||
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre
|
Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre
|
||||||
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
|
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
|
||||||
|
|
||||||
|
-- Steering knuckle ------------------------------------------------------------
|
||||||
|
-- The knuckle must be a SUBSTANTIAL body, not a token 0.5 cube. The steering
|
||||||
|
-- servo yaws the knuckle, and the knuckle has to carry the wheel (mass 5.67)
|
||||||
|
-- against tyre-to-ground friction. At the old 0.5 size the knuckle massed
|
||||||
|
-- 0.087 -- 65x LIGHTER than the wheel hanging off it -- and the servo simply
|
||||||
|
-- lost: measured under real controller input, CurrentAngle missed TargetAngle
|
||||||
|
-- by a mean of 32 deg (peak 72 deg) and broke through the servo's own
|
||||||
|
-- LimitsEnabled bounds on 6 of 20 samples. A constraint blowing past its hard
|
||||||
|
-- limits is the signature of an under-massed link, not of a weak motor:
|
||||||
|
-- raising ServoMaxTorque cannot fix it because the torque was never the
|
||||||
|
-- binding constraint.
|
||||||
|
--
|
||||||
|
-- Measured sweep (full lock, real SteerFloat input, gtr_r34):
|
||||||
|
-- size 0.5 (mass 0.09) -> mean err 32.2 deg, 6/20 limit violations, drives
|
||||||
|
-- size 1.6 (mass 2.87) -> mean err 2.2 deg, 1/20 violations, drives
|
||||||
|
-- density 80 (mass 328) -> mean err 0.1 deg, 0/20 violations, CAR FROZEN
|
||||||
|
-- Mass is what buys control, but overshoot it and the knuckles outweigh the
|
||||||
|
-- 125-mass body and the car cannot move at all. 1.6 studs at stock density
|
||||||
|
-- puts the knuckle in the same order of magnitude as its wheel, which is the
|
||||||
|
-- physical relationship that matters. Do NOT "improve" this with density.
|
||||||
|
Chassis.KNUCKLE_SIZE = 1.6
|
||||||
|
|
||||||
|
-- Cabin fit --------------------------------------------------------------------
|
||||||
|
-- The driver used to stick out of the roof for THREE compounding reasons, all
|
||||||
|
-- fixed here + in CarManager:
|
||||||
|
--
|
||||||
|
-- 1. SEAT AT ROOF HEIGHT. The Body slab sits at the car's vertical MIDDLE,
|
||||||
|
-- but the seat was mounted just above it (BODY_SIZE.Y/2 + 0.3). That put
|
||||||
|
-- the avatar's hips level with the car's waistline and its head a full
|
||||||
|
-- stud above the roof. A seat belongs on the CABIN FLOOR, which is near
|
||||||
|
-- the car's BOTTOM -- i.e. measured up from the ground plane, not up from
|
||||||
|
-- the body slab. SEAT_ABOVE_GROUND does that.
|
||||||
|
-- 2. SHELLS NEVER SCALED. Imported meshes were parented at native size, which
|
||||||
|
-- ranges 15.4 -> 19.0 studs long across the library. Cars visibly differed
|
||||||
|
-- in size and the short ones had no cabin room. SHELL_LENGTH normalises
|
||||||
|
-- every shell to one length; the mesh's own proportions are preserved
|
||||||
|
-- because the scale is uniform (Model:ScaleTo).
|
||||||
|
-- 3. SHELL FLOATED. Placement used ext.Y/2 (a raw extents number that ignores
|
||||||
|
-- a model's pivot offset -- silv_s15's pivot is 1.45 studs off its own
|
||||||
|
-- geometry), so shells hovered above the wheels. CarManager now aligns the
|
||||||
|
-- shell's true bounding box BOTTOM to the ground plane.
|
||||||
|
--
|
||||||
|
-- Ground plane, relative to the Body part's centre: the wheels rest on it, so
|
||||||
|
-- it is one suspension drop plus one wheel radius below the body centre.
|
||||||
|
Chassis.GROUND_OFFSET = -(Chassis.SUSPENSION_DROP + Chassis.WHEEL_RADIUS)
|
||||||
|
-- Height of the seat PAN above that ground plane (cabin floor + cushion).
|
||||||
|
-- This is the STARTING height; CarManager lowers it per-car after the shell is
|
||||||
|
-- measured (see HEAD_ABOVE_SEAT / ROOF_CLEARANCE), because shells differ in
|
||||||
|
-- roof height by over a stud (hyper_x 4.92 vs gtr_r34 6.00).
|
||||||
|
Chassis.SEAT_ABOVE_GROUND = 0.45
|
||||||
|
-- Measured in Play on a default R15 avatar: the head CENTRE sits 3.27 above the
|
||||||
|
-- seat pan, but the head is 1.20 tall, so the CROWN is 3.87 up. The old 3.27
|
||||||
|
-- constant compared a centre against a roofline and so under-measured the
|
||||||
|
-- avatar by half a head -- the head cleared on paper and poked out on screen.
|
||||||
|
-- This is deliberately the crown, i.e. the number that must stay under the roof.
|
||||||
|
Chassis.HEAD_ABOVE_SEAT = 3.90
|
||||||
|
-- Target gap between the crown and the shell roofline. Now that HEAD_ABOVE_SEAT
|
||||||
|
-- is a true crown height this is real headroom, so it can be modest.
|
||||||
|
Chassis.ROOF_CLEARANCE = 0.30
|
||||||
|
-- Every imported shell is uniformly scaled so its bounding box is this long.
|
||||||
|
Chassis.SHELL_LENGTH = 19.0
|
||||||
|
|
||||||
-- Per-car tuning derived from stats -------------------------------------------
|
-- Per-car tuning derived from stats -------------------------------------------
|
||||||
export type Tuning = {
|
export type Tuning = {
|
||||||
MaxSpeed: number, -- studs/sec target top speed
|
MaxSpeed: number, -- studs/sec target top speed
|
||||||
@@ -52,8 +124,50 @@ export type Tuning = {
|
|||||||
WheelFriction: number,
|
WheelFriction: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Suspension physics ----------------------------------------------------------
|
||||||
|
-- Derived rather than eyeballed. Reference: "In Depth Scripted Car Physics"
|
||||||
|
-- (devforum 3915628) -> docs/CAR_PHYSICS_REFERENCE.txt. That tutorial builds
|
||||||
|
-- raycast suspension, but the underlying spring math is identical for the
|
||||||
|
-- SpringConstraints we use, so the two tuning laws below carry over directly:
|
||||||
|
--
|
||||||
|
-- 1. STATIC SAG. A spring holding mass m compresses by x = m*g/k. If that
|
||||||
|
-- sag exceeds the available travel the suspension sits bottomed out, the
|
||||||
|
-- spring stops doing any work, and the body rides on the constraint limit
|
||||||
|
-- -- which reads as a twitchy, tippy car. The OLD constants (k=9000) gave
|
||||||
|
-- 1.10 studs of sag against 0.90 studs of TOTAL travel: permanently
|
||||||
|
-- bottomed. Now k is solved so the car rests at SAG_FRACTION of half
|
||||||
|
-- travel, leaving real droop AND real compression headroom.
|
||||||
|
--
|
||||||
|
-- 2. DAMPING RATIO. c = zeta * 2*sqrt(k*m). Damping cannot be picked
|
||||||
|
-- independently of stiffness -- it only means anything relative to
|
||||||
|
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
|
||||||
|
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
|
||||||
|
-- that band by Handling so a grippy car is also a composed one.
|
||||||
|
--
|
||||||
|
-- !! CURRENTLY INERT !! CarManager.buildCorner welds the hub rigidly to the
|
||||||
|
-- body (see the comment there: a light hub on a stiff spring made the solver
|
||||||
|
-- diverge and fling the wheels away). There is no SpringConstraint in the rig
|
||||||
|
-- today, so SpringStiffness/SpringDamping below are computed but never
|
||||||
|
-- consumed -- the ride is rigid, not damped. These stay here because the
|
||||||
|
-- numbers are now correct and are what a re-introduced spring should use;
|
||||||
|
-- do NOT read a smooth ride-height trace as evidence they are working.
|
||||||
|
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
|
||||||
|
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
|
||||||
|
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
|
||||||
|
|
||||||
function Chassis.tuningFor(car: Car): Tuning
|
function Chassis.tuningFor(car: Car): Tuning
|
||||||
local maxSpeed = 70 + car.Stats.Speed * 1.5
|
local maxSpeed = 70 + car.Stats.Speed * 1.5
|
||||||
|
local handling01 = car.Stats.Handling / 100
|
||||||
|
|
||||||
|
-- Solve stiffness from the sag target, then stiffen with Handling.
|
||||||
|
local halfTravel = Chassis.SUSPENSION_TRAVEL / 2
|
||||||
|
local baseK = CORNER_MASS * GRAVITY / (halfTravel * SAG_FRACTION)
|
||||||
|
local stiffness = baseK * (0.9 + handling01 * 0.45) -- ~28k soft .. ~41k stiff
|
||||||
|
|
||||||
|
-- Damping ratio 0.45 (compliant) -> 0.65 (buttoned down) by Handling.
|
||||||
|
local zeta = 0.45 + handling01 * 0.20
|
||||||
|
local damping = zeta * 2 * math.sqrt(stiffness * CORNER_MASS)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
MaxSpeed = maxSpeed,
|
MaxSpeed = maxSpeed,
|
||||||
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
|
||||||
@@ -61,8 +175,8 @@ function Chassis.tuningFor(car: Car): Tuning
|
|||||||
BrakeTorque = 30000,
|
BrakeTorque = 30000,
|
||||||
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
|
||||||
SteerSpeed = 6,
|
SteerSpeed = 6,
|
||||||
SpringStiffness = 9000,
|
SpringStiffness = stiffness,
|
||||||
SpringDamping = 420,
|
SpringDamping = damping,
|
||||||
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,4 +51,16 @@ GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Stu
|
|||||||
-- (studs/sec) for the fastest car. Sustained above this => snap back.
|
-- (studs/sec) for the fastest car. Sustained above this => snap back.
|
||||||
GameConfig.MaxPlausibleSpeed = 400
|
GameConfig.MaxPlausibleSpeed = 400
|
||||||
|
|
||||||
|
-- Development lighting -------------------------------------------------------
|
||||||
|
-- The shipping look is deep night (neon Wangan). While building geometry that
|
||||||
|
-- is nearly unusable — you cannot see the road surface, kerbs, gap placement or
|
||||||
|
-- the PA layout under a 22:00 sky.
|
||||||
|
--
|
||||||
|
-- Flip this to true for a flat, bright midday pass: full sun, no fog, no bloom,
|
||||||
|
-- no clock drift. It changes ONLY lighting/post — no gameplay, geometry or
|
||||||
|
-- physics differs, so anything verified in daylight holds at night.
|
||||||
|
--
|
||||||
|
-- Set back to false to restore the night look ("turn the lights off").
|
||||||
|
GameConfig.DevDaylight = true
|
||||||
|
|
||||||
return GameConfig
|
return GameConfig
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
--!strict
|
||||||
|
--[[
|
||||||
|
Modes.luau (SHARED)
|
||||||
|
The three ways to leave Daikoku Futo PA. Selected in the PA, armed when you
|
||||||
|
cross the exit gate onto the Wangan, ended when you pull back into the PA.
|
||||||
|
|
||||||
|
This is the Tokyo Extreme Racer structure: the parking area is the hub, the
|
||||||
|
exit ramp is the mode gate. You never "join a match" — you just drive out.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Modes = {}
|
||||||
|
|
||||||
|
export type ModeId = "CRUISE" | "DUEL" | "TIME_ATTACK"
|
||||||
|
|
||||||
|
export type ModeDef = {
|
||||||
|
Id: ModeId,
|
||||||
|
Name: string,
|
||||||
|
NameJP: string,
|
||||||
|
Blurb: string,
|
||||||
|
Color: Color3,
|
||||||
|
-- Can rivals pull alongside and challenge you in this mode?
|
||||||
|
AllowChallenges: boolean,
|
||||||
|
-- Is the lap timer running?
|
||||||
|
Timed: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
Modes.List = {
|
||||||
|
{
|
||||||
|
Id = "CRUISE" :: ModeId,
|
||||||
|
Name = "CRUISE",
|
||||||
|
NameJP = "流し",
|
||||||
|
Blurb = "Free roam the Wangan. Bank kilometres, meet other drivers.\nRivals may pull up alongside — flash them to accept.",
|
||||||
|
Color = Color3.fromRGB(40, 160, 255),
|
||||||
|
AllowChallenges = true,
|
||||||
|
Timed = false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id = "DUEL" :: ModeId,
|
||||||
|
Name = "DUEL",
|
||||||
|
NameJP = "バトル",
|
||||||
|
Blurb = "Hunt rivals. First to pull a car-length gap and hold it wins.\nNo laps, no checkpoints — just who breaks first.",
|
||||||
|
Color = Color3.fromRGB(255, 40, 120),
|
||||||
|
AllowChallenges = true,
|
||||||
|
Timed = false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id = "TIME_ATTACK" :: ModeId,
|
||||||
|
Name = "TIME ATTACK",
|
||||||
|
NameJP = "タイムアタック",
|
||||||
|
Blurb = "Solo flying lap of the full loop with sector splits.\nBeat your ghost. No rivals will interrupt you.",
|
||||||
|
Color = Color3.fromRGB(120, 255, 170),
|
||||||
|
AllowChallenges = false,
|
||||||
|
Timed = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local byId: { [string]: ModeDef } = {}
|
||||||
|
for _, m in ipairs(Modes.List) do
|
||||||
|
byId[m.Id] = m
|
||||||
|
end
|
||||||
|
|
||||||
|
function Modes.Get(id: string): ModeDef?
|
||||||
|
return byId[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
function Modes.IsValid(id: any): boolean
|
||||||
|
return typeof(id) == "string" and byId[id] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Duel tuning ----------------------------------------------------------------
|
||||||
|
Modes.DUEL = {
|
||||||
|
-- Gap (studs) you must hold to win. ~350 studs is roughly a long straight's
|
||||||
|
-- worth of daylight — the Wangan "he's gone" moment.
|
||||||
|
WIN_GAP = 350,
|
||||||
|
-- ...and you must hold it this long, so a slipstream pass doesn't insta-win.
|
||||||
|
HOLD_TIME = 3.0,
|
||||||
|
-- Duel auto-expires (nobody broke away).
|
||||||
|
MAX_DURATION = 180,
|
||||||
|
-- How close a rival must be to offer/accept a challenge.
|
||||||
|
CHALLENGE_RANGE = 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
Modes.TIME_ATTACK = {
|
||||||
|
SECTORS = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Modes
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
--!strict
|
--!strict
|
||||||
--[[
|
--[[
|
||||||
ProfileTemplate.luau
|
ProfileTemplate.luau
|
||||||
Saved data shape. MVP scope (just driving): we only remember which car the
|
Saved data shape. v3 adds the mode-system career stats: kilometres cruised,
|
||||||
player last drove, so they resume in it. No money, no owned-car list, no rep.
|
best lap, duel record. ProfileStore reconciles missing keys automatically,
|
||||||
ProfileStore reconciles missing keys from this template automatically.
|
so existing v2 saves gain these with the defaults below.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
export type ProfileData = {
|
export type ProfileData = {
|
||||||
EquippedCarId: string, -- last car the player was driving ("" = assign default on load)
|
EquippedCarId: string, -- last car the player was driving ("" = assign default)
|
||||||
|
-- Career stats -----------------------------------------------------------
|
||||||
|
DistanceStuds: number, -- lifetime distance driven, in studs (display as km)
|
||||||
|
BestLapTime: number, -- best TIME ATTACK lap, seconds (0 = none set)
|
||||||
|
BestSectors: { number }, -- best individual sector times, seconds (0 = none)
|
||||||
|
DuelsWon: number,
|
||||||
|
DuelsLost: number,
|
||||||
Version: number,
|
Version: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
local ProfileTemplate: ProfileData = {
|
local ProfileTemplate: ProfileData = {
|
||||||
EquippedCarId = "",
|
EquippedCarId = "",
|
||||||
Version = 2,
|
DistanceStuds = 0,
|
||||||
|
BestLapTime = 0,
|
||||||
|
BestSectors = { 0, 0, 0, 0 },
|
||||||
|
DuelsWon = 0,
|
||||||
|
DuelsLost = 0,
|
||||||
|
Version = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProfileTemplate
|
return ProfileTemplate
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
--[[
|
--[[
|
||||||
Remotes.luau
|
Remotes.luau
|
||||||
Single source of truth for every RemoteEvent / RemoteFunction.
|
Single source of truth for every RemoteEvent / RemoteFunction.
|
||||||
MVP scope (just driving): the only client->server actions are "give me a car"
|
|
||||||
and "switch which car I'm driving". Server creates these at boot; client reads.
|
|
||||||
|
|
||||||
Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere.
|
Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere.
|
||||||
]]
|
]]
|
||||||
@@ -15,12 +13,18 @@ local Remotes = {}
|
|||||||
|
|
||||||
-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ----------
|
-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ----------
|
||||||
local DEFS: { [string]: string } = {
|
local DEFS: { [string]: string } = {
|
||||||
-- Server -> Client: pushes the player's current car id (for HUD)
|
-- Server -> Client
|
||||||
DataUpdated = "Event",
|
DataUpdated = "Event", -- (carId, stats) player's car + career stats for HUD
|
||||||
|
ModeUpdated = "Event", -- (state) full mode/HUD snapshot (see ModeManager)
|
||||||
|
DuelOffer = "Event", -- (offerInfo) a rival is alongside and wants a run
|
||||||
|
DuelResult = "Event", -- (resultInfo) duel ended: won/lost/expired
|
||||||
|
|
||||||
-- Client -> Server requests
|
-- Client -> Server requests
|
||||||
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
|
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
|
||||||
EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car
|
EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car
|
||||||
|
SelectMode = "Function", -- (modeId) -> boolean: arm a mode (must be in the PA)
|
||||||
|
RespondDuel = "Event", -- (accept: boolean) answer a DuelOffer
|
||||||
|
AbandonRun = "Event", -- () give up the current run / return to PA
|
||||||
}
|
}
|
||||||
|
|
||||||
local FOLDER_NAME = "Remotes"
|
local FOLDER_NAME = "Remotes"
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the speed-change (aux) lanes ANCHORED to the parts themselves.
|
||||||
|
|
||||||
|
Hardcoded camera offsets are how you end up photographing empty air after the
|
||||||
|
geometry moves. Every shot here is framed from the LIVE position of the aux
|
||||||
|
lane / slip parts queried out of the DataModel at capture time, so the camera
|
||||||
|
cannot drift away from the thing under test.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# Ask Studio where the aux lanes actually are, then frame from that.
|
||||||
|
# Returns "x y z | x y z ..." anchors: west aux mid, east aux mid, and the
|
||||||
|
# two slip mouths.
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local aux = {}
|
||||||
|
local mouths = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local ai = d.Name:match("^AuxLane_(%d+)$")
|
||||||
|
if ai then table.insert(aux, {i = tonumber(ai), p = d.Position}) end
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k and tonumber(k) <= 1 then mouths[s] = d.Position end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #aux == 0 then return "NOAUX" end
|
||||||
|
table.sort(aux, function(a, b) return a.i < b.i end)
|
||||||
|
-- Split the aux segments into two clusters (west run and east run) by index gap.
|
||||||
|
local groups, cur = {}, {aux[1]}
|
||||||
|
for k = 2, #aux do
|
||||||
|
if aux[k].i - aux[k - 1].i > 3 then
|
||||||
|
table.insert(groups, cur); cur = {}
|
||||||
|
end
|
||||||
|
table.insert(cur, aux[k])
|
||||||
|
end
|
||||||
|
table.insert(groups, cur)
|
||||||
|
local parts = {}
|
||||||
|
for _, g in ipairs(groups) do
|
||||||
|
local mid = g[math.max(1, math.floor(#g / 2))].p
|
||||||
|
table.insert(parts, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
|
||||||
|
end
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local m = mouths[s]
|
||||||
|
if m then table.insert(parts, ("%.1f %.1f %.1f"):format(m.X, m.Y, m.Z)) end
|
||||||
|
end
|
||||||
|
return table.concat(parts, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(
|
||||||
|
Vector3.new({px}, {py}, {pz}),
|
||||||
|
Vector3.new({tx}, {ty}, {tz})
|
||||||
|
)
|
||||||
|
return "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOAUX" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
# Pull the coordinate triples out of whatever wrapper the bridge returns.
|
||||||
|
import re
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if not trip:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
pts = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
print("anchors: " + str(pts))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
for n, (x, y, z) in enumerate(pts[:2], start=1):
|
||||||
|
# Oblique from outside the deck, looking back at the merge.
|
||||||
|
d = grab(f"aux{n}_oblique", x + 260, y + 150, z + 260, x, y, z, fov=50)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
# Top-down: the merge taper reads clearest from directly above.
|
||||||
|
d = grab(f"aux{n}_topdown", x + 3, y + 430, z + 3, x, y, z, fov=42)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
# Driver's-eye down the lane.
|
||||||
|
d = grab(f"aux{n}_driver", x - 190, y + 16, z - 60, x + 120, y + 8, z + 40, fov=68)
|
||||||
|
if d:
|
||||||
|
made.append(d)
|
||||||
|
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the two spots the user circled in their screenshot.
|
||||||
|
|
||||||
|
Their view looks along the mainline with the ramp descending on the left; the
|
||||||
|
two marks sit at the suppression boundary (mainline samples ~525 and ~551),
|
||||||
|
where the aux lane ends and the parapet transitions back to the deck edge.
|
||||||
|
Anchor the camera to those SAMPLES, live, so the shot cannot drift.
|
||||||
|
|
||||||
|
left oval -> sample 525 area: was a 4-sample hole with no barrier
|
||||||
|
right circle -> sample 551 area: was a 26-stud protruding fence stub
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local deck = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$")
|
||||||
|
if k then deck[tonumber(k)] = d end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, i in ipairs({520, 525, 538, 551, 556}) do
|
||||||
|
local d = deck[i]
|
||||||
|
if d then
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(d.Position.X, d.Position.Y, d.Position.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #out < 5 then return "NOSAMP" end
|
||||||
|
return table.concat(out, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(
|
||||||
|
Vector3.new({px}, {py}, {pz}),
|
||||||
|
Vector3.new({tx}, {ty}, {tz})
|
||||||
|
)
|
||||||
|
return "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOSAMP" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if len(trip) < 5:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
p = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
s520, s525, s538, s551, s556 = p
|
||||||
|
print("anchors: " + str(p))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
|
||||||
|
# LEFT OVAL: the formerly-bare run. Look along the road from upstream so the
|
||||||
|
# barrier line is seen edge-on -- a gap would read as an obvious break.
|
||||||
|
made.append(grab("fix_left_oval",
|
||||||
|
s520[0] - 90, s520[1] + 55, s520[2] - 90,
|
||||||
|
s538[0], s538[1], s538[2], fov=55))
|
||||||
|
|
||||||
|
# RIGHT CIRCLE: the transition where the fence used to jut out 26 studs.
|
||||||
|
made.append(grab("fix_right_circle",
|
||||||
|
s556[0] + 120, s556[1] + 50, s556[2] + 120,
|
||||||
|
s551[0], s551[1], s551[2], fov=50))
|
||||||
|
|
||||||
|
# Top-down over each boundary: a jut shows instantly as a step in the line.
|
||||||
|
made.append(grab("fix_boundary_525_top",
|
||||||
|
s525[0], s525[1] + 300, s525[2],
|
||||||
|
s525[0], s525[1], s525[2], fov=55))
|
||||||
|
made.append(grab("fix_boundary_551_top",
|
||||||
|
s551[0], s551[1] + 300, s551[2],
|
||||||
|
s551[0], s551[1], s551[2], fov=55))
|
||||||
|
|
||||||
|
# Wide overview matching the user's framing.
|
||||||
|
made.append(grab("fix_overview",
|
||||||
|
s538[0] - 420, s538[1] + 240, s538[2] - 420,
|
||||||
|
s538[0], s538[1], s538[2], fov=60))
|
||||||
|
|
||||||
|
made = [m for m in made if m]
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Eyes-on shots at the two REPORTED defects (not generic beauty angles).
|
||||||
|
|
||||||
|
Targets:
|
||||||
|
A entry ramp start (-4122, 47, -7860) -- the 390-stud access gap
|
||||||
|
B exit ramp start (-4258, 47, -7836)
|
||||||
|
C mainline junction (-3700, 102, -7184) -- the dY -6.0 step
|
||||||
|
D ramp vs nearest building(-4419, 223, -6751)
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
SHOTS = [
|
||||||
|
# name, camera pos, look-at
|
||||||
|
("D1_entry_ramp_start", (-4122 + 210, 47 + 120, -7860 + 210), (-4122, 47, -7860)),
|
||||||
|
("D2_entry_start_low", (-4122 + 70, 47 + 25, -7860 + 70), (-4122, 55, -7860)),
|
||||||
|
("D3_exit_ramp_start", (-4258 - 200, 47 + 110, -7836 + 200), (-4258, 47, -7836)),
|
||||||
|
("D4_junction_step", (-3700 + 190, 102 + 90, -7184 + 190), (-3700, 102, -7184)),
|
||||||
|
("D5_ramp_vs_building", (-4419 + 520, 223 + 180, -6751 + 520), (-4300, 120, -7100)),
|
||||||
|
("D6_corridor_along", (-4122, 47 + 260, -7860 + 480), (-4300, 110, -7200)),
|
||||||
|
]
|
||||||
|
|
||||||
|
CAM = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
|
||||||
|
return "ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
made = []
|
||||||
|
for name, (px, py, pz), (tx, ty, tz) in SHOTS:
|
||||||
|
fov = 70 if "low" in name or "along" in name else 55
|
||||||
|
r = rbx.call(
|
||||||
|
"execute_luau",
|
||||||
|
{
|
||||||
|
"datamodel_type": "Edit",
|
||||||
|
"code": CAM.format(
|
||||||
|
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
|
||||||
|
),
|
||||||
|
},
|
||||||
|
wait=12,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {str(r)[:120]}")
|
||||||
|
continue
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call(
|
||||||
|
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
|
||||||
|
)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
|
||||||
|
continue
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
made.append(dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
print(f"\nMADE {len(made)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Eyes-on shots of the PA flyover ramps.
|
||||||
|
|
||||||
|
Camera targets are QUERIED FROM THE LIVE GEOMETRY on every run, never
|
||||||
|
hardcoded. The previous capture script pinned literal coordinates at the
|
||||||
|
old defect sites; after the ramps were redesigned those coords pointed at
|
||||||
|
empty air and the shots silently framed the wrong thing. Same class of bug
|
||||||
|
as the hardcoded yFork=96 -- a constant that stops matching the model.
|
||||||
|
|
||||||
|
Each shot names a part and an offset; the part's real position is looked up
|
||||||
|
in Studio at capture time. If a named part is missing the shot is SKIPPED
|
||||||
|
with a loud message rather than falling back to a stale guess.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# name, anchor part, camera offset from anchor, look-at offset from anchor, fov
|
||||||
|
SHOTS = [
|
||||||
|
# The flyover itself, from the side -- the money shot: ramp over deck.
|
||||||
|
("F1_flyover_side", "PAEntryRampCross_100", (300, 150, 300), (0, -20, 0), 55),
|
||||||
|
# Underneath the crossing, looking up: proves real clearance.
|
||||||
|
("F2_flyover_under", "PAEntryRampCross_100", (140, -70, 140), (0, 10, 0), 75),
|
||||||
|
# The spiral descending into the PA apron.
|
||||||
|
("F3_spiral_into_pa", "PAEntryRampTrunk_200", (340, 220, 340), (-120, -60, -120), 60),
|
||||||
|
# Exit ramp climbing out of the PA.
|
||||||
|
("F4_exit_climb", "PAExitRampTrunk_200", (-340, 200, 300), (60, -40, 40), 60),
|
||||||
|
# Where the descent lands on the far carriageway.
|
||||||
|
("F5_landing_merge", "PAEntryRampNear_200", (220, 130, 220), (0, -15, 0), 55),
|
||||||
|
# Wide establishing shot of the whole interchange.
|
||||||
|
("F6_wide", "PAEntryRampCross_100", (900, 620, 900), (-250, -120, -250), 60),
|
||||||
|
]
|
||||||
|
|
||||||
|
POS_QUERY = """
|
||||||
|
local ws=game:GetService("Workspace"); local map=ws:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local want={}
|
||||||
|
for _,n in ipairs({%s}) do want[n]=true end
|
||||||
|
local found={}
|
||||||
|
for _,d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and want[d.Name] then
|
||||||
|
found[#found+1]=("%%s=%%.1f,%%.1f,%%.1f"):format(d.Name,d.Position.X,d.Position.Y,d.Position.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(found,"|")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(Vector3.new({px},{py},{pz}), Vector3.new({tx},{ty},{tz}))
|
||||||
|
return "ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_anchors(names):
|
||||||
|
"""Look up each anchor part's real position in the live datamodel."""
|
||||||
|
lst = ",".join('"%s"' % n for n in sorted(set(names)))
|
||||||
|
r = rbx.call(
|
||||||
|
"execute_luau",
|
||||||
|
{"datamodel_type": "Edit", "code": POS_QUERY % lst},
|
||||||
|
wait=40,
|
||||||
|
timeout=400,
|
||||||
|
)
|
||||||
|
txt = str(r)
|
||||||
|
out = {}
|
||||||
|
for chunk in txt.split("|"):
|
||||||
|
if "=" not in chunk:
|
||||||
|
continue
|
||||||
|
nm, _, vals = chunk.partition("=")
|
||||||
|
nm = nm.strip().strip("'\"[]{}() ")
|
||||||
|
parts = vals.strip().strip("'\"[]{}() ").split(",")
|
||||||
|
if len(parts) != 3:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[nm] = tuple(float(v) for v in parts)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
anchors = fetch_anchors([s[1] for s in SHOTS])
|
||||||
|
print(f"resolved {len(anchors)} anchors: {sorted(anchors)}")
|
||||||
|
made, skipped = [], []
|
||||||
|
for name, anchor, cam_off, look_off, fov in SHOTS:
|
||||||
|
if anchor not in anchors:
|
||||||
|
print(f"{name}: SKIP -- anchor {anchor} not found in model")
|
||||||
|
skipped.append(name)
|
||||||
|
continue
|
||||||
|
ax, ay, az = anchors[anchor]
|
||||||
|
px, py, pz = ax + cam_off[0], ay + cam_off[1], az + cam_off[2]
|
||||||
|
tx, ty, tz = ax + look_off[0], ay + look_off[1], az + look_off[2]
|
||||||
|
r = rbx.call(
|
||||||
|
"execute_luau",
|
||||||
|
{
|
||||||
|
"datamodel_type": "Edit",
|
||||||
|
"code": CAM.format(
|
||||||
|
fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz
|
||||||
|
),
|
||||||
|
},
|
||||||
|
wait=12,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {str(r)[:120]}")
|
||||||
|
skipped.append(name)
|
||||||
|
continue
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call(
|
||||||
|
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
|
||||||
|
)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:140]}")
|
||||||
|
skipped.append(name)
|
||||||
|
continue
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
made.append(dest)
|
||||||
|
print(f"{name}: OK ({anchor} @ {ax:.0f},{ay:.0f},{az:.0f}) -> {dest}")
|
||||||
|
print(f"\nMADE {len(made)} SKIPPED {len(skipped)} {skipped if skipped else ''}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the PA slip ramps from the user's own viewpoint, LIVE-ANCHORED.
|
||||||
|
|
||||||
|
The user's screenshot looks along the mainline from above the deck with the
|
||||||
|
ramp descending on the left. Reproduce that framing from the actual ramp
|
||||||
|
geometry so the shot cannot drift onto empty air after an edit.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# Anchors: for each slip -> mouth (first deck seg), mid-span, landing (last seg).
|
||||||
|
ANCHOR_CODE = """
|
||||||
|
local map = workspace:FindFirstChild("Map")
|
||||||
|
if not map then return "NOMAP" end
|
||||||
|
local segs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local k = d.Name:match("^" .. s .. "_Deck_(%d+)$")
|
||||||
|
if k then
|
||||||
|
segs[s] = segs[s] or {}
|
||||||
|
table.insert(segs[s], {i = tonumber(k), p = d.Position})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do
|
||||||
|
local g = segs[s]
|
||||||
|
if g and #g > 2 then
|
||||||
|
table.sort(g, function(a, b) return a.i < b.i end)
|
||||||
|
local mouth = g[1].p
|
||||||
|
local mid = g[math.floor(#g / 2)].p
|
||||||
|
local land = g[#g].p
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(mouth.X, mouth.Y, mouth.Z))
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z))
|
||||||
|
table.insert(out, ("%.1f %.1f %.1f"):format(land.X, land.Y, land.Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #out == 0 then return "NOSLIP" end
|
||||||
|
return table.concat(out, " | ")
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(
|
||||||
|
Vector3.new({px}, {py}, {pz}),
|
||||||
|
Vector3.new({tx}, {ty}, {tz})
|
||||||
|
)
|
||||||
|
return "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(name, px, py, pz, tx, ty, tz, fov=55):
|
||||||
|
code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
return None
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
|
||||||
|
return None
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE},
|
||||||
|
wait=40, timeout=300)
|
||||||
|
txt = a if isinstance(a, str) else str(a)
|
||||||
|
if "NOMAP" in txt or "NOSLIP" in txt:
|
||||||
|
print("ANCHOR FAIL: " + txt[:200])
|
||||||
|
return
|
||||||
|
trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt)
|
||||||
|
if len(trip) < 6:
|
||||||
|
print("ANCHOR PARSE FAIL: " + txt[:300])
|
||||||
|
return
|
||||||
|
pts = [tuple(float(v) for v in t) for t in trip]
|
||||||
|
print("anchors: " + str(pts))
|
||||||
|
|
||||||
|
made = []
|
||||||
|
for n, base in enumerate((0, 3), start=1):
|
||||||
|
mouth, mid, land = pts[base], pts[base + 1], pts[base + 2]
|
||||||
|
tag = ["west", "east"][n - 1]
|
||||||
|
|
||||||
|
# 1. The user's view: high, behind the mouth, looking down the ramp.
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_userview",
|
||||||
|
mouth[0] - 300, mouth[1] + 120, mouth[2] - 300,
|
||||||
|
land[0], land[1], land[2], fov=60))
|
||||||
|
|
||||||
|
# 2. Side elevation: shows the descent profile against the deck.
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_side",
|
||||||
|
mid[0] + 420, mid[1] + 90, mid[2],
|
||||||
|
mid[0], mid[1], mid[2], fov=48))
|
||||||
|
|
||||||
|
# 3. Top-down over mid-span: is it parallel to the deck or cutting away?
|
||||||
|
made.append(grab(
|
||||||
|
f"ramp_{tag}_topdown",
|
||||||
|
mid[0], mid[1] + 560, mid[2],
|
||||||
|
mid[0], mid[1], mid[2], fov=55))
|
||||||
|
|
||||||
|
made = [m for m in made if m]
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture a set of Studio viewport angles of the Daikoku PA junction.
|
||||||
|
|
||||||
|
Sets the Edit-mode camera via execute_luau, then pulls a screen_capture for
|
||||||
|
each shot. Bridge calls are slow (~30-60s each), so this runs as one batch.
|
||||||
|
|
||||||
|
NOTE (corrects an old assumption): screen_capture DOES work on this setup.
|
||||||
|
It needs capture_id='ScreenCapture_1'. Earlier notes claimed the Studio
|
||||||
|
viewport could not be screenshotted from wks-victus -- that was wrong.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, "/tmp")
|
||||||
|
import rbx # noqa: E402
|
||||||
|
|
||||||
|
OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots"
|
||||||
|
|
||||||
|
# PA centre measured from the built parts.
|
||||||
|
CX, CY, CZ = -4116, 51, -7479
|
||||||
|
|
||||||
|
SHOTS = [
|
||||||
|
# name, camera offset from PA centre, look-at offset
|
||||||
|
("01_overview_oblique", (520, 340, 520), (0, 0, 0)),
|
||||||
|
("02_top_down", (5, 900, 5), (0, 0, 0)),
|
||||||
|
("03_underneath_clearance", (330, 20, 300), (0, 30, 0)),
|
||||||
|
("04_onramp_low", (-250, 70, 60), (60, 20, -40)),
|
||||||
|
("05_offramp_low", (300, 75, -160), (-60, 20, 40)),
|
||||||
|
("06_section_side", (900, 120, 40), (0, 25, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
CAM_TPL = """
|
||||||
|
local cam = workspace.CurrentCamera
|
||||||
|
cam.CameraType = Enum.CameraType.Scriptable
|
||||||
|
cam.FieldOfView = {fov}
|
||||||
|
cam.CFrame = CFrame.lookAt(
|
||||||
|
Vector3.new({px}, {py}, {pz}),
|
||||||
|
Vector3.new({tx}, {ty}, {tz})
|
||||||
|
)
|
||||||
|
return "cam ok"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
made = []
|
||||||
|
for name, (ox, oy, oz), (tx, ty, tz) in SHOTS:
|
||||||
|
fov = 55 if "top_down" not in name else 40
|
||||||
|
code = CAM_TPL.format(
|
||||||
|
fov=fov,
|
||||||
|
px=CX + ox,
|
||||||
|
py=CY + oy,
|
||||||
|
pz=CZ + oz,
|
||||||
|
tx=CX + tx,
|
||||||
|
ty=CY + ty,
|
||||||
|
tz=CZ + tz,
|
||||||
|
)
|
||||||
|
r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code})
|
||||||
|
if isinstance(r, dict) and r.get("isError"):
|
||||||
|
print(f"{name}: CAM FAIL {r}")
|
||||||
|
continue
|
||||||
|
time.sleep(1.5)
|
||||||
|
cap = rbx.call(
|
||||||
|
"screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300
|
||||||
|
)
|
||||||
|
imgs = cap.get("images") if isinstance(cap, dict) else None
|
||||||
|
if not imgs:
|
||||||
|
print(f"{name}: CAPTURE FAIL {str(cap)[:160]}")
|
||||||
|
continue
|
||||||
|
dest = f"{OUT}/{name}.jpg"
|
||||||
|
shutil.copy(imgs[0], dest)
|
||||||
|
made.append(dest)
|
||||||
|
print(f"{name}: OK -> {dest}")
|
||||||
|
print("\nMADE " + str(len(made)) + " shots")
|
||||||
|
for m in made:
|
||||||
|
print(m)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
--[[
|
||||||
|
check_live_source.luau -- did the pushed CarManager source actually land,
|
||||||
|
and is the running session using it?
|
||||||
|
|
||||||
|
diag_steering still reports "found 4 motors" after a push that reported
|
||||||
|
assembled==expected match=true. Two candidate explanations:
|
||||||
|
1. the source in the DataModel is stale (push landed elsewhere), or
|
||||||
|
2. the source is fresh but require() returned a CACHED module instance
|
||||||
|
from earlier in this Play session, so the old build function is live.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local mod = server and server:FindFirstChild("CarManager")
|
||||||
|
log("module:", mod and mod:GetFullName() or "<not found>")
|
||||||
|
|
||||||
|
if mod then
|
||||||
|
local src = mod.Source
|
||||||
|
log("source bytes:", #src)
|
||||||
|
log("has REAR-WHEEL DRIVE marker:", tostring(src:find("REAR%-WHEEL DRIVE") ~= nil))
|
||||||
|
log("has 'if isFront then\\n\\t\\treturn' guard:", tostring(src:find("isFront then\n\t\treturn") ~= nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How many CarManager-like ModuleScripts exist anywhere? A duplicate copy
|
||||||
|
-- would explain a push landing in the wrong place.
|
||||||
|
local hits = {}
|
||||||
|
for _, d in ipairs(game:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
table.insert(hits, d:GetFullName() .. " (" .. #d.Source .. " bytes)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log("all CarManager ModuleScripts in this DataModel:")
|
||||||
|
for _, h in ipairs(hits) do
|
||||||
|
log(" " .. h)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- CONTROL + CONNECTIVITY.
|
||||||
|
-- 1. Control: deliberately test a part known to intersect structure, so a
|
||||||
|
-- 0-hit result on the ramps means "clear", not "probe is broken".
|
||||||
|
-- 2. Connectivity: largest gap between consecutive deck segments within each
|
||||||
|
-- run, and the trunk->near handoff distance.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "no Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
local structural, runs = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
|
||||||
|
table.insert(structural, d)
|
||||||
|
end
|
||||||
|
for _, base in ipairs({
|
||||||
|
"PAEntryRampTrunk",
|
||||||
|
"PAEntryRampCross",
|
||||||
|
"PAEntryRampNear",
|
||||||
|
"PAExitRampTrunk",
|
||||||
|
"PAExitRampCross",
|
||||||
|
"PAExitRampNear",
|
||||||
|
}) do
|
||||||
|
if nm:sub(1, #base + 1) == base .. "_" then
|
||||||
|
runs[base] = runs[base] or {}
|
||||||
|
table.insert(runs[base], d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function overlaps(p, s)
|
||||||
|
local d1, d2 = p.Position, s.Position
|
||||||
|
return math.abs(d1.X - d2.X) < (p.Size.X + s.Size.X) / 2
|
||||||
|
and math.abs(d1.Z - d2.Z) < (p.Size.Z + s.Size.Z) / 2
|
||||||
|
and math.abs(d1.Y - d2.Y) < (p.Size.Y + s.Size.Y) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
|
||||||
|
-- CONTROL: a probe box placed exactly on the first structural part must hit.
|
||||||
|
local ctrl = structural[1]
|
||||||
|
local fake = { Position = ctrl.Position, Size = Vector3.new(4, 4, 4) }
|
||||||
|
local ctrlHits = 0
|
||||||
|
for _, s in ipairs(structural) do
|
||||||
|
if overlaps(fake, s) then
|
||||||
|
ctrlHits += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("CONTROL (box on a known pier): hits=%d -> probe %s"):format(
|
||||||
|
ctrlHits,
|
||||||
|
ctrlHits > 0 and "WORKS" or "IS BROKEN/VACUOUS"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table.insert(out, "structuralParts=" .. #structural)
|
||||||
|
|
||||||
|
-- CONNECTIVITY
|
||||||
|
local function sortKey(p)
|
||||||
|
return tonumber(p.Name:match("_(%d+)$")) or 0
|
||||||
|
end
|
||||||
|
for _, base in ipairs({
|
||||||
|
"PAEntryRampTrunk",
|
||||||
|
"PAEntryRampCross",
|
||||||
|
"PAEntryRampNear",
|
||||||
|
"PAExitRampTrunk",
|
||||||
|
"PAExitRampCross",
|
||||||
|
"PAExitRampNear",
|
||||||
|
}) do
|
||||||
|
local parts = runs[base]
|
||||||
|
if not parts then
|
||||||
|
table.insert(out, base .. ": MISSING")
|
||||||
|
else
|
||||||
|
table.sort(parts, function(a, b)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
local maxGap = 0
|
||||||
|
for i = 2, #parts do
|
||||||
|
local g = (parts[i].Position - parts[i - 1].Position).Magnitude
|
||||||
|
maxGap = math.max(maxGap, g)
|
||||||
|
end
|
||||||
|
table.insert(out, ("%s: n=%d maxSegGap=%.1f"):format(base, #parts, maxGap))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handoffs along the flyover chain: Trunk -> Cross -> Near. The ramps now
|
||||||
|
-- cross OVER the mainline, so `Cross` sits between the spiral and the
|
||||||
|
-- descent; checking Trunk->Near directly would report a false gap.
|
||||||
|
for _, pair in ipairs({
|
||||||
|
{ "PAEntryRampTrunk", "PAEntryRampCross" },
|
||||||
|
{ "PAEntryRampCross", "PAEntryRampNear" },
|
||||||
|
{ "PAExitRampTrunk", "PAExitRampCross" },
|
||||||
|
{ "PAExitRampCross", "PAExitRampNear" },
|
||||||
|
}) do
|
||||||
|
local t, nr = runs[pair[1]], runs[pair[2]]
|
||||||
|
if t and nr then
|
||||||
|
table.sort(t, function(a, b)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
table.sort(nr, function(a, b)
|
||||||
|
return sortKey(a) < sortKey(b)
|
||||||
|
end)
|
||||||
|
local best = math.huge
|
||||||
|
for _, a in ipairs({ t[1], t[#t] }) do
|
||||||
|
for _, b in ipairs({ nr[1], nr[#nr] }) do
|
||||||
|
best = math.min(best, (a.Position - b.Position).Magnitude)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(out, ("HANDOFF %s->%s closestEnd=%.1f"):format(pair[1], pair[2], best))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,145 @@
|
|||||||
|
-- Diagnose the two reported defects:
|
||||||
|
-- (1) ramps not accessible in either direction (connectivity)
|
||||||
|
-- (2) ramps pass through buildings (clipping)
|
||||||
|
-- Uses native GetPartBoundsInBox so this stays fast on ~22k parts.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "NO MAP"
|
||||||
|
end
|
||||||
|
|
||||||
|
local BUILDING = {
|
||||||
|
Building = true,
|
||||||
|
TokyoTower = true,
|
||||||
|
TowerDeck = true,
|
||||||
|
TowerAntenna = true,
|
||||||
|
ShiodomeTower = true,
|
||||||
|
TowerCrown = true,
|
||||||
|
DietTower = true,
|
||||||
|
RoppongiHills = true,
|
||||||
|
TokyoStation = true,
|
||||||
|
StationDome = true,
|
||||||
|
Skytree = true,
|
||||||
|
GinzaBlock = true,
|
||||||
|
NationalDiet = true,
|
||||||
|
BridgeTower = true,
|
||||||
|
TowerCrossbeam = true,
|
||||||
|
Structure = true,
|
||||||
|
PalaceWall = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local ramp, drivable = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local n = d.Name
|
||||||
|
if n:find("PAEntryRamp") or n:find("PAExitRamp") then
|
||||||
|
table.insert(ramp, d)
|
||||||
|
elseif n:match("^Road_%d+$") or n == "PAFloor" then
|
||||||
|
table.insert(drivable, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #ramp == 0 then
|
||||||
|
return "no ramp parts"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------- (2) BUILDING CLIP CHECK ----------
|
||||||
|
local op = OverlapParams.new()
|
||||||
|
op.MaxParts = 40
|
||||||
|
local clipHits, clipNames, worst = 0, {}, {}
|
||||||
|
for _, r in ipairs(ramp) do
|
||||||
|
local hits = ws:GetPartBoundsInBox(r.CFrame, r.Size, op)
|
||||||
|
for _, h in ipairs(hits) do
|
||||||
|
if h ~= r and BUILDING[h.Name] then
|
||||||
|
clipHits += 1
|
||||||
|
clipNames[h.Name] = (clipNames[h.Name] or 0) + 1
|
||||||
|
if #worst < 5 then
|
||||||
|
table.insert(worst, ("%s@%.0f,%.0f,%.0f"):format(r.Name, r.Position.X, r.Position.Y, r.Position.Z))
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CONTROL: same probe against mainline Road_ parts (known-good, should be ~0)
|
||||||
|
local ctrlHits, ctrlN = 0, 0
|
||||||
|
for i = 1, #drivable, 9 do
|
||||||
|
local d = drivable[i]
|
||||||
|
ctrlN += 1
|
||||||
|
local hits = ws:GetPartBoundsInBox(d.CFrame, d.Size, op)
|
||||||
|
for _, h in ipairs(hits) do
|
||||||
|
if h ~= d and BUILDING[h.Name] then
|
||||||
|
ctrlHits += 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local cn = {}
|
||||||
|
for k, v in pairs(clipNames) do
|
||||||
|
table.insert(cn, k .. "=" .. v)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------- (1) CONNECTIVITY ----------
|
||||||
|
-- Ramp basename groups, then endpoints of each group by trailing index.
|
||||||
|
local groups = {}
|
||||||
|
for _, r in ipairs(ramp) do
|
||||||
|
local base, idx = r.Name:match("^(.-)_?(%d+)$")
|
||||||
|
base = base or r.Name
|
||||||
|
idx = tonumber(idx) or 0
|
||||||
|
local g = groups[base]
|
||||||
|
if not g then
|
||||||
|
g = { lo = math.huge, hi = -math.huge, loP = nil, hiP = nil, n = 0 }
|
||||||
|
groups[base] = g
|
||||||
|
end
|
||||||
|
g.n += 1
|
||||||
|
if idx < g.lo then
|
||||||
|
g.lo, g.loP = idx, r
|
||||||
|
end
|
||||||
|
if idx > g.hi then
|
||||||
|
g.hi, g.hiP = idx, r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- For an endpoint, find nearest drivable surface and report gap + height delta.
|
||||||
|
local function nearestDrivable(p)
|
||||||
|
local bd, bp = math.huge, nil
|
||||||
|
for _, d in ipairs(drivable) do
|
||||||
|
local dd = (d.Position - p).Magnitude
|
||||||
|
if dd < bd then
|
||||||
|
bd, bp = dd, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return bd, bp
|
||||||
|
end
|
||||||
|
|
||||||
|
local lines = {}
|
||||||
|
for base, g in pairs(groups) do
|
||||||
|
if g.loP and g.hiP then
|
||||||
|
local d1, p1 = nearestDrivable(g.loP.Position)
|
||||||
|
local d2, p2 = nearestDrivable(g.hiP.Position)
|
||||||
|
table.insert(
|
||||||
|
lines,
|
||||||
|
("%s n=%d | START gap=%.0f dY=%.1f to %s | END gap=%.0f dY=%.1f to %s"):format(
|
||||||
|
base,
|
||||||
|
g.n,
|
||||||
|
d1,
|
||||||
|
p1 and (g.loP.Position.Y - p1.Position.Y) or -999,
|
||||||
|
p1 and p1.Name or "nil",
|
||||||
|
d2,
|
||||||
|
p2 and (g.hiP.Position.Y - p2.Position.Y) or -999,
|
||||||
|
p2 and p2.Name or "nil"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return ("CLIP ramp=%d/%d parts hit buildings [%s] samples=[%s] || CONTROL road %d/%d hit\n%s"):format(
|
||||||
|
clipHits,
|
||||||
|
#ramp,
|
||||||
|
table.concat(cn, ", "),
|
||||||
|
table.concat(worst, " ; "),
|
||||||
|
ctrlHits,
|
||||||
|
ctrlN,
|
||||||
|
table.concat(lines, "\n")
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_bootcheck.luau -- decide facing using the BOOT/SPOILER cue, which is
|
||||||
|
directly interpretable, and verify it survives the build.
|
||||||
|
|
||||||
|
Findings so far:
|
||||||
|
* gtr_r34 template: wheels -10.27, hood -6.22, boot +6.64 -> all three
|
||||||
|
cues AGREE, nose at -Z. So wheel labels are NOT swapped.
|
||||||
|
* bodywork "asym" gave gtr_r34 -0.37 while rx7_fc/silv_s15 were
|
||||||
|
positive, yet all three have boot at +Z. asym is therefore a poor
|
||||||
|
proxy (it is dominated by whichever big slab a given shell happens to
|
||||||
|
have) and its verdicts must be discarded.
|
||||||
|
|
||||||
|
The boot/spoiler is unambiguous: it belongs at the TAIL. On a correctly
|
||||||
|
faced car (chassis forward = -Z) the boot must land at POSITIVE body-local
|
||||||
|
Z. Measure that on the BUILT car, per shell, and compare to the template.
|
||||||
|
Shells with no named boot get a fallback: the tail is the end furthest
|
||||||
|
from the front wheels, per that shell's own wheel cue.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
local CarManager = require(cm)
|
||||||
|
|
||||||
|
local TAIL = { boot = true, spoiler = true, trunk = true, taillight = true, exhaust = true, diffuser = true }
|
||||||
|
local NOSE = { hood = true, windshield = true, bonnet = true, grille = true, headlight = true, splitter = true }
|
||||||
|
|
||||||
|
local function cueZ(root: Instance, frame: CFrame)
|
||||||
|
local inv = frame:Inverse()
|
||||||
|
local tS, tN, nS, nN = 0, 0, 0, 0
|
||||||
|
for _, p in ipairs(root:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
local z = (inv * p.Position).Z
|
||||||
|
if TAIL[nm] then
|
||||||
|
tS, tN = tS + z, tN + 1
|
||||||
|
elseif NOSE[nm] then
|
||||||
|
nS, nN = nS + z, nN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return (tN > 0 and tS / tN or nil), (nN > 0 and nS / nN or nil), tN, nN
|
||||||
|
end
|
||||||
|
|
||||||
|
log("chassis forward = -Z, so TAIL parts must land at POSITIVE body-local Z.")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-14s %22s %30s", "car", "TEMPLATE (tail/nose)", "BUILT (tail/nose) verdict"))
|
||||||
|
log(string.rep("-", 74))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = t.Name
|
||||||
|
local tT, tNz, tCount, nCount = cueZ(t, t:GetPivot())
|
||||||
|
|
||||||
|
local bT, bN = nil, nil
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 3 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local sh = m:FindFirstChild("Shell")
|
||||||
|
if sh then
|
||||||
|
bT, bN = cueZ(sh, m.PrimaryPart.CFrame)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if bT then
|
||||||
|
verdict = bT > 0 and "CORRECT" or "*** BACKWARDS ***"
|
||||||
|
elseif bN then
|
||||||
|
verdict = bN < 0 and "CORRECT" or "*** BACKWARDS ***"
|
||||||
|
else
|
||||||
|
verdict = "no named cue - cannot judge"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s tail %8s nose %7s tail %8s nose %7s %s",
|
||||||
|
id,
|
||||||
|
tT and string.format("%+.2f", tT) or "n/a",
|
||||||
|
tNz and string.format("%+.2f", tNz) or "n/a",
|
||||||
|
bT and string.format("%+.2f", bT) or "n/a",
|
||||||
|
bN and string.format("%+.2f", bN) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
cm:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
--[[
|
||||||
|
diag_builtnose.luau -- END-TO-END measurement. Build each car for real via
|
||||||
|
CarManager._buildForDiag, then measure where the finished shell's nose
|
||||||
|
actually points relative to the chassis forward axis.
|
||||||
|
|
||||||
|
Why this and not more code reading: the solver computes the right 180-degree
|
||||||
|
yaw (diag_solveryaw) and applies it without centring error (diag_centrefix),
|
||||||
|
yet two cars still look backwards. So something AFTER the PivotTo must be
|
||||||
|
undoing it. Rather than guess which line, measure the finished article.
|
||||||
|
|
||||||
|
noseDot = (shell nose direction) dot (body LookVector)
|
||||||
|
+1 -> shell nose points where the car drives (CORRECT)
|
||||||
|
-1 -> shell nose points backwards (REVERSED)
|
||||||
|
|
||||||
|
Cache guard first: require() caches per Studio session, so a stale
|
||||||
|
CarManager would silently build old code and make this whole test a lie.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local cmMod = find("CarManager")
|
||||||
|
if not cmMod then
|
||||||
|
return "FATAL: CarManager not found"
|
||||||
|
end
|
||||||
|
-- Guard: the reverted source must be what require() hands back.
|
||||||
|
local srcOK = cmMod.Source:find("isFront = zSign < 0", 1, true) ~= nil
|
||||||
|
if not srcOK then
|
||||||
|
return "FATAL: CarManager source is not the reverted version -- aborting"
|
||||||
|
end
|
||||||
|
local ok, CM = pcall(require, cmMod)
|
||||||
|
if not ok then
|
||||||
|
return "FATAL: require(CarManager) failed: " .. tostring(CM)
|
||||||
|
end
|
||||||
|
if type(CM._buildForDiag) ~= "function" then
|
||||||
|
return "FATAL: CarManager._buildForDiag missing (stale require cache?) -- restart Studio"
|
||||||
|
end
|
||||||
|
|
||||||
|
local Cars = require(RS:WaitForChild("Shared"):WaitForChild("Cars"))
|
||||||
|
local ChassisMod = require(RS:WaitForChild("Shared"):WaitForChild("Chassis"))
|
||||||
|
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
|
||||||
|
log(string.format("%-11s %-10s %-12s %s", "shell", "noseDot", "yaw err", "verdict"))
|
||||||
|
log(string.rep("-", 62))
|
||||||
|
|
||||||
|
local built = 0
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
-- buildCarModel(car: Car, tuning: Tuning) -- NOT a string id. Passing a
|
||||||
|
-- string was the harness bug that produced the CarManager:277 nil error.
|
||||||
|
local carDef = Cars.Get(name)
|
||||||
|
if not carDef then
|
||||||
|
log(string.format("%-11s NOT IN Cars.List", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local tuning = ChassisMod.tuningFor(carDef)
|
||||||
|
local okB, car = pcall(CM._buildForDiag, carDef, tuning)
|
||||||
|
if not okB or not car then
|
||||||
|
log(string.format("%-11s BUILD FAILED: %s", name, tostring(car)))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
built += 1
|
||||||
|
|
||||||
|
local body = car:FindFirstChild("Body") or car.PrimaryPart
|
||||||
|
local shell = nil
|
||||||
|
for _, d in ipairs(car:GetChildren()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") then
|
||||||
|
shell = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not body or not shell then
|
||||||
|
log(string.format("%-11s no body/shell on built car", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shell nose in WORLD space, from the shell's own wheel labels.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + (sum / n), fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + (sum / n), rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s no F/R labels on built shell", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
nose = Vector3.new(nose.X, 0, nose.Z)
|
||||||
|
if nose.Magnitude < 0.01 then
|
||||||
|
log(string.format("%-11s degenerate nose vector", name))
|
||||||
|
car:Destroy()
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
nose = nose.Unit
|
||||||
|
|
||||||
|
local fwd = body.CFrame.LookVector
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z).Unit
|
||||||
|
|
||||||
|
local dot = nose:Dot(fwd)
|
||||||
|
local yawErr = math.deg(math.acos(math.clamp(dot, -1, 1)))
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if dot > 0.85 then
|
||||||
|
verdict = "CORRECT (nose = drive dir)"
|
||||||
|
elseif dot < -0.85 then
|
||||||
|
verdict = "*** REVERSED ***"
|
||||||
|
else
|
||||||
|
verdict = "skewed"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-10s %-12s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.3f", dot),
|
||||||
|
string.format("%.1f deg", yawErr),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
car:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if built == 0 then
|
||||||
|
log(">>> NOTHING BUILT -- this is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> built and measured %d/5 cars, then destroyed them (no scene changes)", built))
|
||||||
|
log(" This is the finished article, after every stage of the build pipeline.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
--[[
|
||||||
|
diag_centrefix.luau -- test whether centreFix is applied in the wrong order.
|
||||||
|
|
||||||
|
The build does:
|
||||||
|
shell:PivotTo(body.CFrame * heightCF * CFrame.Angles(0,yaw,0) * centreFix)
|
||||||
|
|
||||||
|
centreFix is a translation derived from the shell's CURRENT (unrotated)
|
||||||
|
AABB centre. Placing it to the RIGHT of CFrame.Angles means it is expressed
|
||||||
|
in the ROTATED frame, so a 180-degree yaw negates its X and Z. For the three
|
||||||
|
shells with yaw ~= 0 this is harmless. For hatch_junk and hyper_x (yaw 180)
|
||||||
|
it moves the shell by -2 * centreLocal instead of cancelling the offset.
|
||||||
|
|
||||||
|
This measures the resulting AABB-centre error for BOTH orderings, per shell.
|
||||||
|
The numbers can come out either way -- if the current ordering is fine, both
|
||||||
|
errors are ~0 and the hypothesis is dead.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
for _, t in ipairs(TARGETS) do
|
||||||
|
if d.Name == t then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Replicate trueAABB: min/max corner over all BaseParts, world space.
|
||||||
|
local function trueAABB(model)
|
||||||
|
local lo, hi
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = d.Position
|
||||||
|
lo = lo and Vector3.new(math.min(lo.X, p.X), math.min(lo.Y, p.Y), math.min(lo.Z, p.Z)) or p
|
||||||
|
hi = hi and Vector3.new(math.max(hi.X, p.X), math.max(hi.Y, p.Y), math.max(hi.Z, p.Z)) or p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return lo, hi
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-11s %-10s %-22s %-14s %s", "shell", "yaw", "centreLocal(x,z)", "CURRENT err", "FIXED err"))
|
||||||
|
log(string.rep("-", 82))
|
||||||
|
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
-- yaw the solver would compute
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local lp = pivotInv * (sum / n)
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local yaw = 0
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then
|
||||||
|
yaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local lo2, hi2 = trueAABB(shell)
|
||||||
|
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
|
||||||
|
local centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
|
||||||
|
|
||||||
|
-- Simulate both orderings against an identity body at the origin.
|
||||||
|
local body = CFrame.new()
|
||||||
|
local rot = CFrame.Angles(0, yaw, 0)
|
||||||
|
|
||||||
|
-- Where does the shell's AABB centre land, relative to the body axis?
|
||||||
|
-- Model the centre as a point at centreLocal in the shell's pivot frame.
|
||||||
|
local centrePoint = CFrame.new(centreLocal.X, 0, centreLocal.Z)
|
||||||
|
|
||||||
|
local currentCF = body * rot * centreFix
|
||||||
|
local fixedCF = body * centreFix * rot -- translation applied BEFORE rotation
|
||||||
|
|
||||||
|
local curCentre = (currentCF * centrePoint).Position
|
||||||
|
local fixCentre = (fixedCF * centrePoint).Position
|
||||||
|
|
||||||
|
local curErr = math.sqrt(curCentre.X ^ 2 + curCentre.Z ^ 2)
|
||||||
|
local fixErr = math.sqrt(fixCentre.X ^ 2 + fixCentre.Z ^ 2)
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-10s (%+.2f,%+.2f)%-8s %-14s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.0f deg", math.deg(yaw)),
|
||||||
|
centreLocal.X,
|
||||||
|
centreLocal.Z,
|
||||||
|
"",
|
||||||
|
string.format("%.3f", curErr),
|
||||||
|
string.format("%.3f", fixErr)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("err = how far the shell's AABB centre sits from the chassis axis (studs).")
|
||||||
|
log("A large CURRENT err on the 180-degree shells with a near-zero FIXED err")
|
||||||
|
log("confirms the ordering bug. If both columns match, the hypothesis is WRONG")
|
||||||
|
log("and the offset is not the cause of the reversal.")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- Why did the building-clip probe return 0 with a 0 control? Diagnose the metric.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
|
||||||
|
local BUILDING = {
|
||||||
|
Building = true,
|
||||||
|
TokyoTower = true,
|
||||||
|
TowerDeck = true,
|
||||||
|
TowerAntenna = true,
|
||||||
|
ShiodomeTower = true,
|
||||||
|
TowerCrown = true,
|
||||||
|
DietTower = true,
|
||||||
|
RoppongiHills = true,
|
||||||
|
TokyoStation = true,
|
||||||
|
StationDome = true,
|
||||||
|
Skytree = true,
|
||||||
|
GinzaBlock = true,
|
||||||
|
NationalDiet = true,
|
||||||
|
BridgeTower = true,
|
||||||
|
TowerCrossbeam = true,
|
||||||
|
Structure = true,
|
||||||
|
PalaceWall = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local ramp, bld = {}, {}
|
||||||
|
local anyQueryOff, anyCollideOff = 0, 0
|
||||||
|
for _, d in ipairs(ws:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
if d.Name:find("PAEntryRamp") or d.Name:find("PAExitRamp") then
|
||||||
|
table.insert(ramp, d)
|
||||||
|
elseif BUILDING[d.Name] then
|
||||||
|
table.insert(bld, d)
|
||||||
|
if not d.CanQuery then
|
||||||
|
anyQueryOff += 1
|
||||||
|
end
|
||||||
|
if not d.CanCollide then
|
||||||
|
anyCollideOff += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #bld == 0 then
|
||||||
|
return "NO building parts found in Workspace at all (name set wrong?)"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- nearest building to the ramp chain (subsample ramps for speed)
|
||||||
|
local bd, br, bb = math.huge, nil, nil
|
||||||
|
for i = 1, #ramp, 7 do
|
||||||
|
local r = ramp[i]
|
||||||
|
for _, b in ipairs(bld) do
|
||||||
|
local dd = (r.Position - b.Position).Magnitude
|
||||||
|
if dd < bd then
|
||||||
|
bd, br, bb = dd, r, b
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- how many buildings are anywhere near the PA at all?
|
||||||
|
local cen = Vector3.new(-4128, 81, -7438)
|
||||||
|
local near2000 = 0
|
||||||
|
for _, b in ipairs(bld) do
|
||||||
|
if (b.Position - cen).Magnitude < 2000 then
|
||||||
|
near2000 += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- does the raw overlap API return ANYTHING for a ramp part? (metric sanity)
|
||||||
|
local op = OverlapParams.new()
|
||||||
|
op.MaxParts = 50
|
||||||
|
local sample = ramp[math.floor(#ramp / 2)]
|
||||||
|
local hits = ws:GetPartBoundsInBox(sample.CFrame, sample.Size * 1.2, op)
|
||||||
|
local hn = {}
|
||||||
|
for i = 1, math.min(#hits, 8) do
|
||||||
|
table.insert(hn, hits[i].Name)
|
||||||
|
end
|
||||||
|
|
||||||
|
return ("buildings=%d (CanQuery off=%d, CanCollide off=%d) | nearestBldToRamp=%.0f (%s -> %s @%.0f,%.0f,%.0f) | bldWithin2000ofPA=%d | overlapSanity hits=%d [%s]"):format(
|
||||||
|
#bld,
|
||||||
|
anyQueryOff,
|
||||||
|
anyCollideOff,
|
||||||
|
bd,
|
||||||
|
br and br.Name or "nil",
|
||||||
|
bb and bb.Name or "nil",
|
||||||
|
bb and bb.Position.X or 0,
|
||||||
|
bb and bb.Position.Y or 0,
|
||||||
|
bb and bb.Position.Z or 0,
|
||||||
|
near2000,
|
||||||
|
#hits,
|
||||||
|
table.concat(hn, ",")
|
||||||
|
)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
--[[
|
||||||
|
diag_contact.luau -- the front wheels now steer correctly (0.01 deg error)
|
||||||
|
but the car still will not change heading (1.4 deg over 5 s, understeer
|
||||||
|
3465x). Steering geometry is therefore no longer the problem: the tyres
|
||||||
|
are producing no lateral force.
|
||||||
|
|
||||||
|
Most likely cause: the hub is welded RIGIDLY to the body with no
|
||||||
|
suspension travel, and the Body part is CanCollide=true. If the body
|
||||||
|
is resting on the pad, the car slides on a big low-friction box and the
|
||||||
|
wheels are carrying little or no load.
|
||||||
|
|
||||||
|
This measures actual ground contact and per-wheel load.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(6000, 8, 6000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
log(string.format("pad top surface Y = %.2f", padTop))
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local wheels, servos, motors = {}, {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
table.insert(wheels, d)
|
||||||
|
elseif d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(wheels, function(a, b)
|
||||||
|
return a.Name < b.Name
|
||||||
|
end)
|
||||||
|
|
||||||
|
for _ = 1, 120 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("--- AT REST ---")
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"body: centre Y %.2f, size %s, CanCollide %s",
|
||||||
|
body.Position.Y,
|
||||||
|
tostring(body.Size),
|
||||||
|
tostring(body.CanCollide)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"body underside Y = %.2f (pad top %.2f) -> gap %.2f %s",
|
||||||
|
body.Position.Y - body.Size.Y / 2,
|
||||||
|
padTop,
|
||||||
|
(body.Position.Y - body.Size.Y / 2) - padTop,
|
||||||
|
((body.Position.Y - body.Size.Y / 2) - padTop) < 0.05 and "*** BODY ON THE GROUND ***" or "(clear)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format("WHEEL_RADIUS %.2f", Chassis.WHEEL_RADIUS))
|
||||||
|
|
||||||
|
for _, w in ipairs(wheels) do
|
||||||
|
local bottom = w.Position.Y - Chassis.WHEEL_RADIUS
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-10s centre Y %7.2f bottom %7.2f gap to pad %6.2f %s",
|
||||||
|
w.Name,
|
||||||
|
w.Position.Y,
|
||||||
|
bottom,
|
||||||
|
bottom - padTop,
|
||||||
|
math.abs(bottom - padTop) < 0.1 and "CONTACT" or ((bottom - padTop) > 0.1 and "FLOATING" or "SUNK")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Who is actually touching the pad?
|
||||||
|
log("")
|
||||||
|
log("--- PARTS TOUCHING THE PAD ---")
|
||||||
|
local touching = pad:GetTouchingParts()
|
||||||
|
local names = {}
|
||||||
|
for _, p in ipairs(touching) do
|
||||||
|
if p:IsDescendantOf(model) then
|
||||||
|
names[#names + 1] = p.Name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(#names > 0 and (" " .. table.concat(names, ", ")) or " <nothing from the car>")
|
||||||
|
|
||||||
|
-- Rolling, steered: is the body dragging?
|
||||||
|
log("")
|
||||||
|
log("--- ROLLING @ ~200, STEERED -14 ---")
|
||||||
|
local topW = tuning.MaxWheelAngularVelocity
|
||||||
|
for step = 1, 240 do
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = tuning.MotorMaxTorque
|
||||||
|
mo.AngularVelocity = -topW
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -14
|
||||||
|
end
|
||||||
|
if step % 60 == 0 then
|
||||||
|
local contactNames = {}
|
||||||
|
for _, p in ipairs(pad:GetTouchingParts()) do
|
||||||
|
if p:IsDescendantOf(model) then
|
||||||
|
contactNames[#contactNames + 1] = p.Name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local slip = body.AssemblyLinearVelocity
|
||||||
|
local fwd = body.CFrame.LookVector
|
||||||
|
local right = body.CFrame.RightVector
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"t %.1f speed %6.1f fwd %6.1f lateral %6.1f yaw %6.3f bodyGap %5.2f touching: %s",
|
||||||
|
step / 60,
|
||||||
|
slip.Magnitude,
|
||||||
|
slip:Dot(fwd),
|
||||||
|
slip:Dot(right),
|
||||||
|
body.AssemblyAngularVelocity.Y,
|
||||||
|
(body.Position.Y - body.Size.Y / 2) - padTop,
|
||||||
|
#contactNames > 0 and table.concat(contactNames, ",") or "none"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
--!strict
|
||||||
|
-- Verify the rebuilt Daikoku PA junction IN-ENGINE, after MapBuilder has run.
|
||||||
|
--
|
||||||
|
-- MEASURE FIRST (project rule): ray tests and code reasoning both mislead;
|
||||||
|
-- actual part positions are ground truth. This reads the built parts back out
|
||||||
|
-- of Workspace rather than trusting the source that generated them.
|
||||||
|
--
|
||||||
|
-- Checks:
|
||||||
|
-- 1. No PA ring/parapet part intrudes into the mainline carriageway.
|
||||||
|
-- 2. Mainline deck clears the ring/apron by a usable height.
|
||||||
|
-- 3. Both ramps form a continuous, climbable chain (no cliff, no gap).
|
||||||
|
-- 4. The old PARingPillar parts are gone.
|
||||||
|
local ws = workspace
|
||||||
|
|
||||||
|
local function findFirst(name: string): Instance?
|
||||||
|
return ws:FindFirstChild(name, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function say(s: string)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Locate the PA + road containers -------------------------------------------
|
||||||
|
local pa = findFirst("ParkingArea") or findFirst("PA") or findFirst("DaikokuPA")
|
||||||
|
local road = findFirst("Road")
|
||||||
|
local struct = findFirst("Structure") or findFirst("Struct")
|
||||||
|
|
||||||
|
say("== CONTAINERS ==")
|
||||||
|
say(
|
||||||
|
("pa=%s road=%s struct=%s"):format(
|
||||||
|
pa and pa:GetFullName() or "NIL",
|
||||||
|
road and road:GetFullName() or "NIL",
|
||||||
|
struct and struct:GetFullName() or "NIL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Gather parts by name pattern anywhere under Workspace ----------------------
|
||||||
|
local buckets: { [string]: { BasePart } } = {}
|
||||||
|
local function bucket(key: string, p: BasePart)
|
||||||
|
buckets[key] = buckets[key] or {}
|
||||||
|
table.insert(buckets[key], p)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, d in ipairs(ws:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local n = d.Name
|
||||||
|
if n:match("^PARingWall") or n:match("^PARingRail") then
|
||||||
|
bucket("ringwall", d)
|
||||||
|
elseif n:match("^PARingPillar") then
|
||||||
|
bucket("ringpillar", d)
|
||||||
|
elseif n:match("^PARamp") and n:match("Pillar") then
|
||||||
|
bucket("ramppillar", d)
|
||||||
|
elseif n:match("^PARampKerb") then
|
||||||
|
bucket("rampkerb", d)
|
||||||
|
elseif n:match("^PAOnRamp") then
|
||||||
|
bucket("entryramp", d)
|
||||||
|
elseif n:match("^PAOffRamp") then
|
||||||
|
bucket("exitramp", d)
|
||||||
|
elseif n:match("^Road_") then
|
||||||
|
bucket("roaddeck", d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say("")
|
||||||
|
say("== PART COUNTS ==")
|
||||||
|
for _, k in ipairs({ "roaddeck", "ringwall", "ringpillar", "entryramp", "exitramp", "ramppillar", "rampkerb" }) do
|
||||||
|
say(("%-12s %d"):format(k, buckets[k] and #buckets[k] or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Old ring pillars must be gone ------------------------------------------
|
||||||
|
say("")
|
||||||
|
say("== CHECK: legacy ring pillars removed ==")
|
||||||
|
local rp = buckets["ringpillar"]
|
||||||
|
if rp and #rp > 0 then
|
||||||
|
say(("FAIL: %d PARingPillar parts still present (ring should be ground-level)"):format(#rp))
|
||||||
|
else
|
||||||
|
say("PASS: no PARingPillar parts (ring is ground-level)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1 + 2. Ring structures vs mainline carriageway -----------------------------
|
||||||
|
say("")
|
||||||
|
say("== CHECK: ring walls do not intrude into the carriageway ==")
|
||||||
|
local decks = buckets["roaddeck"] or {}
|
||||||
|
local walls = buckets["ringwall"] or {}
|
||||||
|
local worstGap, worstDesc = math.huge, "n/a"
|
||||||
|
local intrusions = 0
|
||||||
|
for _, w in ipairs(walls) do
|
||||||
|
local wTop = w.Position.Y + w.Size.Y / 2
|
||||||
|
for _, dk in ipairs(decks) do
|
||||||
|
local dxz = (Vector3.new(dk.Position.X, 0, dk.Position.Z) - Vector3.new(w.Position.X, 0, w.Position.Z)).Magnitude
|
||||||
|
-- Only decks whose footprint actually overlaps this wall matter.
|
||||||
|
if dxz < (dk.Size.X / 2 + w.Size.X / 2 + 30) then
|
||||||
|
local deckBottom = dk.Position.Y - dk.Size.Y / 2
|
||||||
|
local gap = deckBottom - wTop
|
||||||
|
if gap < worstGap then
|
||||||
|
worstGap = gap
|
||||||
|
worstDesc = ("%s vs %s dxz=%.1f"):format(dk.Name, w.Name, dxz)
|
||||||
|
end
|
||||||
|
if gap < 0 then
|
||||||
|
intrusions += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if worstGap == math.huge then
|
||||||
|
say("PASS: no ring wall shares a footprint with any mainline deck")
|
||||||
|
else
|
||||||
|
say(("closest ring-wall/deck pair: %s"):format(worstDesc))
|
||||||
|
say(("vertical gap = %.1f studs"):format(worstGap))
|
||||||
|
if intrusions > 0 then
|
||||||
|
say(("FAIL: %d wall/deck pairs overlap vertically -- wall in the road"):format(intrusions))
|
||||||
|
else
|
||||||
|
say("PASS: every overlapping ring wall passes UNDER the deck")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Ramp continuity ---------------------------------------------------------
|
||||||
|
local function checkRamp(key: string, label: string)
|
||||||
|
say("")
|
||||||
|
say(("== CHECK: %s continuity =="):format(label))
|
||||||
|
local segs = buckets[key]
|
||||||
|
if not segs or #segs == 0 then
|
||||||
|
say("FAIL: no segments found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
table.sort(segs, function(a, b)
|
||||||
|
local ai = tonumber(a.Name:match("_(%d+)$")) or 0
|
||||||
|
local bi = tonumber(b.Name:match("_(%d+)$")) or 0
|
||||||
|
return ai < bi
|
||||||
|
end)
|
||||||
|
local loY = segs[1].Position.Y
|
||||||
|
local hiY = segs[#segs].Position.Y
|
||||||
|
say(("%d segments, Y from %.1f to %.1f (rise %.1f)"):format(#segs, loY, hiY, hiY - loY))
|
||||||
|
local maxStep, maxGap = 0, 0
|
||||||
|
for i = 2, #segs do
|
||||||
|
local a, b = segs[i - 1], segs[i]
|
||||||
|
local step = math.abs(b.Position.Y - a.Position.Y)
|
||||||
|
local horiz = (Vector3.new(a.Position.X, 0, a.Position.Z) - Vector3.new(b.Position.X, 0, b.Position.Z)).Magnitude
|
||||||
|
-- gap = horizontal distance minus the two half-lengths that face
|
||||||
|
-- each other; negative means the segments overlap (good).
|
||||||
|
local gap = horiz - (a.Size.Z / 2 + b.Size.Z / 2)
|
||||||
|
maxStep = math.max(maxStep, step)
|
||||||
|
maxGap = math.max(maxGap, gap)
|
||||||
|
end
|
||||||
|
say(("max vertical step between segments = %.2f studs"):format(maxStep))
|
||||||
|
say(("max horizontal gap between segments = %.2f studs (negative = overlap)"):format(maxGap))
|
||||||
|
if maxStep > 6 then
|
||||||
|
say("FAIL: a step that large is a cliff a car cannot climb")
|
||||||
|
elseif maxGap > 0.5 then
|
||||||
|
say("FAIL: segments do not meet -- wheels will drop into the seam")
|
||||||
|
else
|
||||||
|
say("PASS: ramp is continuous and climbable")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
checkRamp("entryramp", "entry ramp")
|
||||||
|
checkRamp("exitramp", "exit ramp")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
--[[
|
||||||
|
diag_editstate.luau -- read CarManager in the EDIT datamodel.
|
||||||
|
|
||||||
|
This is the datamodel that PERSISTS (unlike Play, which is a sandbox that
|
||||||
|
is discarded on stop -- the thing that silently ate every previous push).
|
||||||
|
|
||||||
|
Purpose: capture a BEFORE snapshot so that after Rojo connects we can prove
|
||||||
|
the sync actually replaced the source, rather than assuming it did. Records
|
||||||
|
length and the two markers unique to the current fixes:
|
||||||
|
|
||||||
|
SHELL_YAW_OVERRIDE -> facing fix for hatch_junk / hyper_x (NEW, x3 in src)
|
||||||
|
bestVol -> pre-existing var, NOT a freshness signal (x6 in src)
|
||||||
|
|
||||||
|
Note bestVol existed in the old code too (git HEAD line 479), so it proves
|
||||||
|
nothing on its own -- SHELL_YAW_OVERRIDE is the real discriminator.
|
||||||
|
Disk source is 41650 bytes; anything at 30631 is the old pre-fix version.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== EDIT datamodel: CarManager state ===")
|
||||||
|
|
||||||
|
local found = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
found[#found + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("instances found: " .. #found)
|
||||||
|
if #found == 0 then
|
||||||
|
log(" !! no CarManager in ServerScriptService at all")
|
||||||
|
log(" Rojo sync will CREATE it under ServerScriptService.Server")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, m in ipairs(found) do
|
||||||
|
local src = ""
|
||||||
|
pcall(function()
|
||||||
|
src = m.Source
|
||||||
|
end)
|
||||||
|
local hasOverride = src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil
|
||||||
|
log(string.format(" %s", m:GetFullName()))
|
||||||
|
log(string.format(" len=%d (disk source = 41650, old = 30631)", #src))
|
||||||
|
log(string.format(" SHELL_YAW_OVERRIDE=%s <-- the real freshness marker", tostring(hasOverride)))
|
||||||
|
log(string.format(" verdict: %s", hasOverride and "FIXED source present" or "OLD pre-fix source"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Also note whether the stale proof marker survived (it should NOT have --
|
||||||
|
-- it only ever existed in the discarded Play sandbox).
|
||||||
|
local marker = nil
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d.Name == "HERMES_PROOF_MARKER" then
|
||||||
|
marker = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log(
|
||||||
|
"HERMES_PROOF_MARKER in Edit: "
|
||||||
|
.. (marker and "present (unexpected)" or "absent (expected - Play sandbox discarded)")
|
||||||
|
)
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
--[[
|
||||||
|
diag_elevation.luau -- measure the vertical relationship between the wangan
|
||||||
|
mainline and the Daikoku PA, so the "elevate the highway over the PA"
|
||||||
|
rebuild can be designed from real numbers instead of guesses.
|
||||||
|
|
||||||
|
Reports, for the stretch of mainline nearest the PA:
|
||||||
|
* the deck Y profile (is it flat? already climbing? in a tunnel?)
|
||||||
|
* which SIDE of the carriageway the PA sits on, per sample
|
||||||
|
* perpendicular distance from the mainline centreline to the PA centre
|
||||||
|
* terrain/ground height under the PA and under the mainline
|
||||||
|
|
||||||
|
Read-only.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit"))
|
||||||
|
|
||||||
|
local samples = Circuit.samples
|
||||||
|
local c = Circuit.PA_CENTER
|
||||||
|
local n = #samples
|
||||||
|
|
||||||
|
-- Nearest non-tunnel sample = where planPA anchors the junctions.
|
||||||
|
local bestIdx, bestD = 1, math.huge
|
||||||
|
for i, s in ipairs(samples) do
|
||||||
|
local d = (Vector3.new(s.pos.X, 0, s.pos.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude
|
||||||
|
if not s.tunnel and d < bestD then
|
||||||
|
bestIdx, bestD = i, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ground probe: ray straight down, ignoring the built map so we hit terrain.
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
local rp = RaycastParams.new()
|
||||||
|
rp.FilterType = Enum.RaycastFilterType.Exclude
|
||||||
|
rp.FilterDescendantsInstances = map and { map } or {}
|
||||||
|
|
||||||
|
local function groundY(x, z, fromY)
|
||||||
|
local hit = Workspace:Raycast(Vector3.new(x, fromY, z), Vector3.new(0, -4000, 0), rp)
|
||||||
|
return hit and math.floor(hit.Position.Y * 10) / 10 or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = {}
|
||||||
|
for k = -30, 30 do
|
||||||
|
local i = ((bestIdx + k - 1) % n) + 1
|
||||||
|
local s = samples[i]
|
||||||
|
local nxt = samples[(i % n) + 1]
|
||||||
|
local fwd = (nxt.pos - s.pos)
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z)
|
||||||
|
if fwd.Magnitude < 0.01 then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
fwd = fwd.Unit
|
||||||
|
local right = Vector3.new(-fwd.Z, 0, fwd.X)
|
||||||
|
local toPA = Vector3.new(c.X - s.pos.X, 0, c.Z - s.pos.Z)
|
||||||
|
table.insert(rows, {
|
||||||
|
k = k,
|
||||||
|
i = i,
|
||||||
|
y = math.floor(s.pos.Y * 10) / 10,
|
||||||
|
tun = s.tunnel and 1 or 0,
|
||||||
|
-- +1 => PA is to the RIGHT of travel, -1 => LEFT
|
||||||
|
side = (toPA.Unit:Dot(right) >= 0) and 1 or -1,
|
||||||
|
-- signed perpendicular offset and along-track offset to the PA centre
|
||||||
|
perp = math.floor(toPA:Dot(right)),
|
||||||
|
along = math.floor(toPA:Dot(fwd)),
|
||||||
|
dist = math.floor(toPA.Magnitude),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local ys = {}
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
table.insert(ys, r.y)
|
||||||
|
end
|
||||||
|
table.sort(ys)
|
||||||
|
|
||||||
|
return game:GetService("HttpService"):JSONEncode({
|
||||||
|
bestIdx = bestIdx,
|
||||||
|
bestDist = math.floor(bestD),
|
||||||
|
paY = c.Y,
|
||||||
|
mainYmin = ys[1],
|
||||||
|
mainYmax = ys[#ys],
|
||||||
|
groundUnderPA = groundY(c.X, c.Z, c.Y + 500),
|
||||||
|
groundUnderMain = groundY(samples[bestIdx].pos.X, samples[bestIdx].pos.Z, samples[bestIdx].pos.Y + 500),
|
||||||
|
ringRadius = (require(RS.Shared.Circuit) and Circuit.PA_RADIUS + 130) or 0,
|
||||||
|
rows = rows,
|
||||||
|
})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
--[[
|
||||||
|
diag_forward.luau -- settle which end of the car is the NOSE.
|
||||||
|
|
||||||
|
Contradiction to resolve: the chassis places its FRONT axle at local -Z
|
||||||
|
(WHEELBASE_HALF = 5.9, frontZ = -5.90) but Body.LookVector is +Z. Those
|
||||||
|
disagree: the part the code calls "front" is BEHIND the direction the body
|
||||||
|
looks. One of the two conventions is wrong, and the shell is being aligned
|
||||||
|
to the wrong one -- which is why the car renders backwards even though
|
||||||
|
shell and body agree (yawDelta 0).
|
||||||
|
|
||||||
|
Ground truth used here, in order of reliability:
|
||||||
|
|
||||||
|
1. VehicleSeat.LookVector -- in Roblox a seated driver faces the seat's
|
||||||
|
+Z look direction and positive throttle drives that way. This is the
|
||||||
|
direction the car ACTUALLY moves, so it defines "forward" for gameplay.
|
||||||
|
2. Steering: which wheels have a steering constraint. Only the FRONT
|
||||||
|
wheels steer, so a steered wheel's local Z tells us where the front is.
|
||||||
|
3. Shell art extents: the visual mesh bounding box relative to its pivot.
|
||||||
|
|
||||||
|
Reports each independently so a disagreement is visible rather than assumed.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local cars = {}
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d.Name:match("^Car_") then
|
||||||
|
cars[#cars + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #cars == 0 then
|
||||||
|
return "No live Car_* models."
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(cars) do
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
if not (body and body:IsA("BasePart")) then
|
||||||
|
log(" no Body part")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local bodyCF = body.CFrame
|
||||||
|
local inv = bodyCF:Inverse()
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" bodyLook (world) = (%.2f, %.2f, %.2f)",
|
||||||
|
bodyCF.LookVector.X,
|
||||||
|
bodyCF.LookVector.Y,
|
||||||
|
bodyCF.LookVector.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 1. VehicleSeat: the direction the car actually drives.
|
||||||
|
local seat = nil
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") then
|
||||||
|
seat = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if seat then
|
||||||
|
local seatLocalZ = (inv * seat.CFrame).Z
|
||||||
|
local dot = bodyCF.LookVector:Dot(seat.CFrame.LookVector)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [1] VehicleSeat: localZ=%.2f, seatLook.dot(bodyLook)=%.2f -> seat faces %s body",
|
||||||
|
seatLocalZ,
|
||||||
|
dot,
|
||||||
|
(dot > 0) and "SAME as" or "OPPOSITE to"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" => car drives toward body %s Z", (dot > 0) and "+" or "-"))
|
||||||
|
else
|
||||||
|
log(" [1] no VehicleSeat found")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Which wheels steer? Only the front axle has a steering servo.
|
||||||
|
local steered, plain = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
local z = (inv * d.CFrame).Z
|
||||||
|
local hasServo = false
|
||||||
|
for _, c in ipairs(d:GetDescendants()) do
|
||||||
|
if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
hasServo = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- servo may live on the attachment's parent chain instead
|
||||||
|
if not hasServo and d.Parent then
|
||||||
|
for _, c in ipairs(d.Parent:GetChildren()) do
|
||||||
|
if c:IsA("HingeConstraint") and c.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
local a1, a0 = c.Attachment1, c.Attachment0
|
||||||
|
if (a1 and a1:IsDescendantOf(d)) or (a0 and a0:IsDescendantOf(d)) then
|
||||||
|
hasServo = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(hasServo and steered or plain, { name = d.Name, z = z })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function meanZ(t)
|
||||||
|
if #t == 0 then
|
||||||
|
return 0 / 0
|
||||||
|
end
|
||||||
|
local s = 0
|
||||||
|
for _, v in ipairs(t) do
|
||||||
|
s = s + v.z
|
||||||
|
end
|
||||||
|
return s / #t
|
||||||
|
end
|
||||||
|
|
||||||
|
local sz, pz = meanZ(steered), meanZ(plain)
|
||||||
|
log(string.format(" [2] steered wheels=%d meanZ=%.2f | unsteered=%d meanZ=%.2f", #steered, sz, #plain, pz))
|
||||||
|
if #steered > 0 and #plain > 0 then
|
||||||
|
log(string.format(" => FRONT (steered) axle is at body %s Z", (sz < pz) and "-" or "+"))
|
||||||
|
end
|
||||||
|
for _, v in ipairs(steered) do
|
||||||
|
log(string.format(" steered: %-14s z=%.2f", v.name, v.z))
|
||||||
|
end
|
||||||
|
for _, v in ipairs(plain) do
|
||||||
|
log(string.format(" plain : %-14s z=%.2f", v.name, v.z))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Shell art: where is the visual mass, and is it symmetric?
|
||||||
|
local shell = nil
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
if c:IsA("Model") then
|
||||||
|
shell = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if shell then
|
||||||
|
local cf, size = shell:GetBoundingBox()
|
||||||
|
local localCentre = (inv * cf.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [3] shell bbox size=(%.1f, %.1f, %.1f) centre localZ=%.2f",
|
||||||
|
size.X,
|
||||||
|
size.Y,
|
||||||
|
size.Z,
|
||||||
|
localCentre.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- named bodywork gives an independent nose/tail cue
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
local n = d.Name:lower()
|
||||||
|
if
|
||||||
|
d:IsA("BasePart")
|
||||||
|
and (
|
||||||
|
n:match("hood")
|
||||||
|
or n:match("bonnet")
|
||||||
|
or n:match("boot")
|
||||||
|
or n:match("trunk")
|
||||||
|
or n:match("spoiler")
|
||||||
|
or n:match("windscreen")
|
||||||
|
or n:match("bumper")
|
||||||
|
)
|
||||||
|
then
|
||||||
|
log(string.format(" %-16s localZ=%.2f", d.Name, (inv * d.CFrame).Z))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" [3] no shell model child")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_frontjoint.luau -- is the front wheel still ATTACHED?
|
||||||
|
|
||||||
|
My RWD change returns early before creating the motor hinge, which is the
|
||||||
|
ONLY joint that ever connected Wheel_F* to Knuckle_F*. If nothing else
|
||||||
|
joins them, the front wheels are now free-floating parts that merely start
|
||||||
|
in the right place -- which would explain both observations at once:
|
||||||
|
the servo turns perfectly (it has nothing to fight) AND the car generates
|
||||||
|
no lateral force (the front tyres are not attached to the chassis).
|
||||||
|
|
||||||
|
The same doubt applies to the earlier V7 "front motors disabled" result:
|
||||||
|
Enabled=false may have freed the servo for the same wrong reason.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(6000, 8, 6000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
|
||||||
|
local function get(n)
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name == n then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("Joints touching each WHEEL part:")
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_FR", "Wheel_RL", "Wheel_RR" }) do
|
||||||
|
local w = get(wn)
|
||||||
|
local found = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
local p0, p1, kind
|
||||||
|
if d:IsA("WeldConstraint") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, "Weld"
|
||||||
|
elseif d:IsA("Constraint") then
|
||||||
|
p0 = d.Attachment0 and d.Attachment0.Parent
|
||||||
|
p1 = d.Attachment1 and d.Attachment1.Parent
|
||||||
|
kind = d.ClassName .. "/" .. d.Name
|
||||||
|
end
|
||||||
|
if p0 and p1 and (p0 == w or p1 == w) then
|
||||||
|
found[#found + 1] = string.format("%s(%s<->%s)", kind, p0.Name, p1.Name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" %-10s %s", wn, #found > 0 and table.concat(found, " ") or "*** NO JOINTS -- DETACHED ***"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop test: a detached wheel will not follow the body when the body is moved.
|
||||||
|
log("")
|
||||||
|
log("TELEPORT TEST (move body 300 studs; attached wheels follow):")
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local before = {}
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do
|
||||||
|
before[wn] = get(wn).Position
|
||||||
|
end
|
||||||
|
model:PivotTo(model:GetPivot() * CFrame.new(0, 0, -300))
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, wn in ipairs({ "Wheel_FL", "Wheel_RL" }) do
|
||||||
|
local w = get(wn)
|
||||||
|
local moved = (w.Position - before[wn]).Magnitude
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-10s moved %7.1f studs %s",
|
||||||
|
wn,
|
||||||
|
moved,
|
||||||
|
moved > 100 and "followed the car" or "*** LEFT BEHIND (detached) ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
--[[
|
||||||
|
diag_line277.luau -- the build harness died at CarManager:277 with
|
||||||
|
"attempt to concatenate string with nil", but line 277 of the LOCAL file is
|
||||||
|
a comment. So the code that ran is not the code on disk.
|
||||||
|
|
||||||
|
Two candidates:
|
||||||
|
(a) the pushed Source is out of sync with disk
|
||||||
|
(b) require() handed back a STALE CACHED module from earlier this session
|
||||||
|
(the known Studio trap: editing Source does NOT invalidate the cache)
|
||||||
|
|
||||||
|
This reads the Source text directly -- never require() -- and prints the
|
||||||
|
real lines around 277, plus the reverted-convention markers, so we can tell
|
||||||
|
(a) from (b) instead of guessing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local cm = find("CarManager")
|
||||||
|
if not cm then
|
||||||
|
return "FATAL: CarManager not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
local src = cm.Source
|
||||||
|
local lines = {}
|
||||||
|
for l in (src .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
|
table.insert(lines, l)
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
table.insert(out, string.format("%s %d bytes, %d lines", cm:GetFullName(), #src, #lines))
|
||||||
|
table.insert(out, "")
|
||||||
|
table.insert(out, "--- Studio Source lines 270..284 ---")
|
||||||
|
for i = 270, math.min(284, #lines) do
|
||||||
|
table.insert(out, string.format("%4d | %s", i, lines[i]))
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(out, "")
|
||||||
|
table.insert(out, "--- markers ---")
|
||||||
|
table.insert(out, " isFront = zSign < 0 present : " .. tostring(src:find("isFront = zSign < 0", 1, true) ~= nil))
|
||||||
|
table.insert(out, " _buildForDiag present : " .. tostring(src:find("_buildForDiag", 1, true) ~= nil))
|
||||||
|
|
||||||
|
-- Where does _buildForDiag get assigned, per the Studio copy?
|
||||||
|
for i, l in ipairs(lines) do
|
||||||
|
if l:find("_buildForDiag", 1, true) then
|
||||||
|
table.insert(out, string.format(" _buildForDiag at line %d: %s", i, l))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What does buildCarModel expect? Print its declaration line.
|
||||||
|
for i, l in ipairs(lines) do
|
||||||
|
if l:find("function buildCarModel", 1, true) or l:find("local function buildCarModel", 1, true) then
|
||||||
|
table.insert(out, string.format(" buildCarModel declared line %d: %s", i, l))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
--[[
|
||||||
|
diag_nosecue.luau -- confirm WHICH cue is lying.
|
||||||
|
|
||||||
|
diag_shellgeom (bodywork asymmetry, wheel-independent) says the built
|
||||||
|
fleet disagrees on facing: rx7_fc +0.550, silv_s15 +0.206, hatch_junk
|
||||||
|
+0.614 all land one way, while hyper_x -0.290 and gtr_r34 -0.362 land the
|
||||||
|
other. Exactly two cars wrong -- matching the user's report.
|
||||||
|
|
||||||
|
The aligner derives facing from each shell's Wheels folder. If a shell's
|
||||||
|
F*/R* wheel labels are swapped relative to its bodywork, the aligner
|
||||||
|
confidently rotates it backwards.
|
||||||
|
|
||||||
|
This cross-checks the wheel cue against two INDEPENDENT cues that cannot
|
||||||
|
both be wrong:
|
||||||
|
* named bodywork: Hood/Windshield sit at the nose, Boot/Spoiler at the tail
|
||||||
|
* bodywork asymmetry (as before)
|
||||||
|
If wheels disagree with the bodywork, the wheel labels are the bug.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-14s %10s %10s %10s %10s %s", "car", "wheelZ", "hoodZ", "bootZ", "asym", "wheels-vs-body"))
|
||||||
|
log(string.rep("-", 78))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local pivotInv = t:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- cue 1: wheels
|
||||||
|
local wheelDz = nil
|
||||||
|
local wf = t:FindFirstChild("Wheels")
|
||||||
|
if wf then
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
local p = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if p then
|
||||||
|
local z = (pivotInv * p.Position).Z
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
if h == "F" then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
wheelDz = fS / fN - rS / rN
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cue 2: named bodywork
|
||||||
|
local function avgZ(patterns)
|
||||||
|
local s, n = 0, 0
|
||||||
|
for _, p in ipairs(t:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
for _, pat in ipairs(patterns) do
|
||||||
|
if nm == pat then
|
||||||
|
s += (pivotInv * p.Position).Z
|
||||||
|
n += 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n > 0 and (s / n) or nil
|
||||||
|
end
|
||||||
|
local hoodZ = avgZ({ "hood", "windshield", "bonnet", "grille" })
|
||||||
|
local bootZ = avgZ({ "boot", "spoiler", "trunk" })
|
||||||
|
|
||||||
|
-- cue 3: asymmetry
|
||||||
|
local sum, wsum, lo, hi = 0, 0, math.huge, -math.huge
|
||||||
|
for _, p in ipairs(t:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local nm = p.Name:lower()
|
||||||
|
if not (nm:find("wheel") or nm:find("tyre") or nm:find("tire") or nm:find("rim")) then
|
||||||
|
local z = (pivotInv * p.Position).Z
|
||||||
|
local v = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
sum, wsum = sum + z * v, wsum + v
|
||||||
|
lo, hi = math.min(lo, z), math.max(hi, z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local asym = wsum > 0 and (sum / wsum - (lo + hi) / 2) or nil
|
||||||
|
|
||||||
|
-- Wheels say nose is at sign(wheelDz). Bodywork says nose is where the
|
||||||
|
-- hood is, i.e. sign(hoodZ). They must agree.
|
||||||
|
local verdict = "?"
|
||||||
|
if wheelDz and hoodZ then
|
||||||
|
local wheelNose = wheelDz < 0 and -1 or 1
|
||||||
|
local hoodNose = hoodZ < 0 and -1 or 1
|
||||||
|
verdict = (wheelNose == hoodNose) and "agree" or "*** DISAGREE -- wheel labels swapped ***"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s %10s %10s %10s %10s %s",
|
||||||
|
t.Name,
|
||||||
|
wheelDz and string.format("%+.2f", wheelDz) or "n/a",
|
||||||
|
hoodZ and string.format("%+.2f", hoodZ) or "n/a",
|
||||||
|
bootZ and string.format("%+.2f", bootZ) or "n/a",
|
||||||
|
asym and string.format("%+.2f", asym) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
--[[
|
||||||
|
diag_nosecue2.luau -- settle which is lying on hatch_junk / hyper_x: the
|
||||||
|
wheel LABELS, or the shell MESH itself.
|
||||||
|
|
||||||
|
Background: diag_shelllabels.luau showed those two shells label their F
|
||||||
|
axle toward +Z while rx7_fc / silv_s15 / gtr_r34 label F toward -Z. That
|
||||||
|
matches the reported symptom exactly, but it does NOT say which side is
|
||||||
|
wrong -- either the labels are swapped, or the whole mesh is authored
|
||||||
|
facing the other way and the labels are honest.
|
||||||
|
|
||||||
|
So: gather cues that are INDEPENDENT of the wheel names, from the shell's
|
||||||
|
own parts. Every cue is read off part names/positions in the shell's local
|
||||||
|
frame; none is derived from a constant, and each can come out either way.
|
||||||
|
|
||||||
|
headlights -- sit at the NOSE
|
||||||
|
taillights -- sit at the TAIL
|
||||||
|
*front* / *rear* named parts (bumpers, grille) -- self-describing
|
||||||
|
seat -- a VehicleSeat/Seat inside the shell looks FORWARD
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
* cues agree with labels -> the MESH is rotated; labels are honest
|
||||||
|
* cues oppose the labels -> the LABELS are swapped; mesh is fine
|
||||||
|
A shell where cues themselves disagree is reported as INCONCLUSIVE rather
|
||||||
|
than being forced into a verdict.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
local SS = game:GetService("ServerStorage")
|
||||||
|
|
||||||
|
local TARGETS = { rx7_fc = true, hatch_junk = true, silv_s15 = true, gtr_r34 = true, hyper_x = true }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect the five target shells wherever they live.
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, SS, workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mean local Z of every part whose lowercased name matches a pattern.
|
||||||
|
local function meanZ(shell, pivotInv, patterns)
|
||||||
|
local sum, n = 0, 0
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name:lower()
|
||||||
|
for _, pat in ipairs(patterns) do
|
||||||
|
if nm:find(pat, 1, true) then
|
||||||
|
sum += (pivotInv * d.Position).Z
|
||||||
|
n += 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n == 0 then
|
||||||
|
return nil, 0
|
||||||
|
end
|
||||||
|
return sum / n, n
|
||||||
|
end
|
||||||
|
|
||||||
|
local order = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-14s %-14s %-14s %-14s %s",
|
||||||
|
"shell",
|
||||||
|
"labelF.Z",
|
||||||
|
"headlight.Z",
|
||||||
|
"taillight.Z",
|
||||||
|
"seatFwd.Z",
|
||||||
|
"verdict"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.rep("-", 104))
|
||||||
|
|
||||||
|
local seen = 0
|
||||||
|
for _, name in ipairs(order) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
seen += 1
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- Wheel-label cue: sign of (F centroid - R centroid).Z
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local z = (pivotInv * (sum / n)).Z
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + z, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local labelZ = (fN > 0 and rN > 0) and ((fSum / fN) - (rSum / rN)) or nil
|
||||||
|
|
||||||
|
local headZ, headN = meanZ(shell, pivotInv, { "headlight", "headlamp", "grille", "grill" })
|
||||||
|
local tailZ, tailN = meanZ(shell, pivotInv, { "taillight", "taillamp", "tailight", "brakelight" })
|
||||||
|
|
||||||
|
-- Seat cue: a seat's LookVector, expressed in the SHELL's frame.
|
||||||
|
local seatZ = nil
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") or d:IsA("Seat") then
|
||||||
|
-- Rotate (not translate) the seat's world look into shell-local axes.
|
||||||
|
seatZ = (shell:GetPivot():Inverse() * (d.CFrame.LookVector + shell:GetPivot().Position)).Z
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Each cue votes for which local Z direction is the NOSE.
|
||||||
|
-- Convention target: nose should be at -Z.
|
||||||
|
local votes = {}
|
||||||
|
if headZ then
|
||||||
|
votes[#votes + 1] = (headZ < 0) and -1 or 1
|
||||||
|
end
|
||||||
|
if tailZ then
|
||||||
|
votes[#votes + 1] = (tailZ < 0) and 1 or -1
|
||||||
|
end -- tail opposes nose
|
||||||
|
if seatZ then
|
||||||
|
votes[#votes + 1] = (seatZ < 0) and -1 or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local noseSign = nil
|
||||||
|
if #votes > 0 then
|
||||||
|
local s = 0
|
||||||
|
for _, v in ipairs(votes) do
|
||||||
|
s += v
|
||||||
|
end
|
||||||
|
if s > 0 then
|
||||||
|
noseSign = 1
|
||||||
|
elseif s < 0 then
|
||||||
|
noseSign = -1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
if not labelZ then
|
||||||
|
verdict = "no F/R labels"
|
||||||
|
elseif #votes == 0 then
|
||||||
|
verdict = "NO INDEPENDENT CUE (no lights/seat found)"
|
||||||
|
elseif noseSign == nil then
|
||||||
|
verdict = "INCONCLUSIVE (cues split)"
|
||||||
|
else
|
||||||
|
local labelSign = (labelZ < 0) and -1 or 1
|
||||||
|
if labelSign == noseSign then
|
||||||
|
verdict = (noseSign < 0) and "consistent, nose -Z (fine)" or "LABELS HONEST -> MESH is rotated 180"
|
||||||
|
else
|
||||||
|
verdict = "LABELS SWAPPED -> mesh is fine"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fmt(v, n)
|
||||||
|
return v and string.format("%+.1f(%d)", v, n or 0) or "--"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-14s %-14s %-14s %-14s %s",
|
||||||
|
name,
|
||||||
|
labelZ and string.format("%+.1f", labelZ) or "--",
|
||||||
|
fmt(headZ, headN),
|
||||||
|
fmt(tailZ, tailN),
|
||||||
|
seatZ and string.format("%+.2f", seatZ) or "--",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if seen == 0 then
|
||||||
|
log(">>> NOTHING INSPECTED -- this is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> inspected %d/5 shells", seen))
|
||||||
|
log(" labelF.Z = (F centroid - R centroid).Z; negative means labels put F toward -Z")
|
||||||
|
log(" headlight.Z = mean local Z of headlight/grille parts (count); nose should be -Z")
|
||||||
|
log(" seatFwd.Z = seat LookVector Z in shell frame; negative = seat faces -Z")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
--[[
|
||||||
|
diag_paexit.luau -- work out the real route from the SpawnPad out of the
|
||||||
|
Daikoku PA and onto the wangan mainline.
|
||||||
|
|
||||||
|
Reports, in world coordinates: the spawn pad, the ring road, both ramps,
|
||||||
|
and -- crucially -- the COMPASS BEARING and distance from the pad to each
|
||||||
|
ramp, so the answer can be phrased as "drive X studs toward <direction>"
|
||||||
|
rather than a vague description.
|
||||||
|
|
||||||
|
Bearings are given in Roblox world terms: -Z is treated as North.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map in Workspace -- build the map first (Play once)"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function grab(name)
|
||||||
|
local hit = map:FindFirstChild(name, true)
|
||||||
|
return (hit and hit:IsA("BasePart")) and hit or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local pad = grab("SpawnPad")
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: SpawnPad not found under Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"SpawnPad pos=(%.0f, %.0f, %.0f) size=(%.0f x %.0f)",
|
||||||
|
pad.Position.X,
|
||||||
|
pad.Position.Y,
|
||||||
|
pad.Position.Z,
|
||||||
|
pad.Size.X,
|
||||||
|
pad.Size.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Compass bearing from the pad to a target, with -Z = North.
|
||||||
|
local function bearing(from, to)
|
||||||
|
local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z)
|
||||||
|
if d.Magnitude < 0.01 then
|
||||||
|
return "--", 0
|
||||||
|
end
|
||||||
|
local deg = math.deg(math.atan2(d.X, -d.Z)) -- 0 = -Z (north), +90 = +X (east)
|
||||||
|
if deg < 0 then
|
||||||
|
deg += 360
|
||||||
|
end
|
||||||
|
local names = { "North", "NE", "East", "SE", "South", "SW", "West", "NW" }
|
||||||
|
local idx = math.floor(((deg + 22.5) % 360) / 45) + 1
|
||||||
|
return names[idx], d.Magnitude, deg
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("--- ramps and ring ---")
|
||||||
|
for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do
|
||||||
|
local p = grab(nm)
|
||||||
|
if p then
|
||||||
|
local dir, dist, deg = bearing(pad.Position, p.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-10s pos=(%.0f, %.0f, %.0f) %s of pad, %.0f studs, bearing %.0f deg",
|
||||||
|
nm,
|
||||||
|
p.Position.X,
|
||||||
|
p.Position.Y,
|
||||||
|
p.Position.Z,
|
||||||
|
dir,
|
||||||
|
dist,
|
||||||
|
deg or 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format("%-10s NOT FOUND", nm))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ring segments: report the ring's radius and height vs the pad.
|
||||||
|
local ringParts = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name:find("Ring") or d.Name == "PARing") then
|
||||||
|
table.insert(ringParts, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #ringParts > 0 then
|
||||||
|
local minY, maxY = math.huge, -math.huge
|
||||||
|
local maxR = 0
|
||||||
|
for _, p in ipairs(ringParts) do
|
||||||
|
minY = math.min(minY, p.Position.Y)
|
||||||
|
maxY = math.max(maxY, p.Position.Y)
|
||||||
|
local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
maxR = math.max(maxR, r)
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"ring %d parts, radius up to %.0f studs, Y %.0f..%.0f (pad Y %.0f)",
|
||||||
|
#ringParts,
|
||||||
|
maxR,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
pad.Position.Y
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" ring sits %.0f studs ABOVE the pad", minY - pad.Position.Y))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Where is the mainline relative to the pad?
|
||||||
|
log("")
|
||||||
|
log("--- mainline ---")
|
||||||
|
local roadFolder = map:FindFirstChild("Road") or map:FindFirstChild("Circuit")
|
||||||
|
if roadFolder then
|
||||||
|
local nearest, nearestD = nil, math.huge
|
||||||
|
for _, d in ipairs(roadFolder:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
if dist < nearestD then
|
||||||
|
nearestD, nearest = dist, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if nearest then
|
||||||
|
local dir, dist = bearing(pad.Position, nearest.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"nearest mainline part '%s' at (%.0f, %.0f, %.0f)",
|
||||||
|
nearest.Name,
|
||||||
|
nearest.Position.X,
|
||||||
|
nearest.Position.Y,
|
||||||
|
nearest.Position.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" -> %s of the pad, %.0f studs away, Y %+.0f vs pad",
|
||||||
|
dir,
|
||||||
|
dist,
|
||||||
|
nearest.Position.Y - pad.Position.Y
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local names = {}
|
||||||
|
for _, d in ipairs(map:GetChildren()) do
|
||||||
|
table.insert(names, d.Name)
|
||||||
|
end
|
||||||
|
log("no Road/Circuit folder; Map children: " .. table.concat(names, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
--[[
|
||||||
|
diag_paexit2.luau -- follow-up: the first pass looked for a "Road" folder,
|
||||||
|
but the map's mainline lives under "Expressway". Also resolve which of the
|
||||||
|
two ramps is the way OUT (the off-ramp rejoins the mainline), and give the
|
||||||
|
pad-relative direction in terms the player can act on.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map"
|
||||||
|
end
|
||||||
|
local pad = map:FindFirstChild("SpawnPad", true)
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: no SpawnPad"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bearing(from, to)
|
||||||
|
local d = Vector3.new(to.X - from.X, 0, to.Z - from.Z)
|
||||||
|
if d.Magnitude < 0.01 then
|
||||||
|
return "--", 0, 0
|
||||||
|
end
|
||||||
|
local deg = math.deg(math.atan2(d.X, -d.Z))
|
||||||
|
if deg < 0 then
|
||||||
|
deg += 360
|
||||||
|
end
|
||||||
|
local names = { "North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West" }
|
||||||
|
return names[math.floor(((deg + 22.5) % 360) / 45) + 1], d.Magnitude, deg
|
||||||
|
end
|
||||||
|
|
||||||
|
local expw = map:FindFirstChild("Expressway")
|
||||||
|
log(string.format("pad at (%.0f, %.0f, %.0f)", pad.Position.X, pad.Position.Y, pad.Position.Z))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
if expw then
|
||||||
|
local nearest, nearestD
|
||||||
|
local count = 0
|
||||||
|
for _, d in ipairs(expw:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
count += 1
|
||||||
|
local dist = (Vector3.new(d.Position.X - pad.Position.X, 0, d.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
if not nearestD or dist < nearestD then
|
||||||
|
nearestD, nearest = dist, d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format("Expressway: %d parts", count))
|
||||||
|
if nearest then
|
||||||
|
local dir, dist = bearing(pad.Position, nearest.Position)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" nearest mainline part '%s' (%.0f, %.0f, %.0f)",
|
||||||
|
nearest.Name,
|
||||||
|
nearest.Position.X,
|
||||||
|
nearest.Position.Y,
|
||||||
|
nearest.Position.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format(" -> %s of pad, %.0f studs, Y %+.0f vs pad", dir, dist, nearest.Position.Y - pad.Position.Y))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ramp detail: where each ramp STARTS (ring end) and ENDS (mainline end).
|
||||||
|
log("")
|
||||||
|
log("--- ramp geometry (which way out) ---")
|
||||||
|
for _, nm in ipairs({ "PAOnRamp", "PAOffRamp" }) do
|
||||||
|
local p = map:FindFirstChild(nm, true)
|
||||||
|
if not p then
|
||||||
|
log(nm .. ": NOT FOUND")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
-- A ramp deck is built with lookAt(mid, road): its LookVector points at the
|
||||||
|
-- mainline end. Report both ends so the direction of travel is unambiguous.
|
||||||
|
local halfLen = p.Size.Z / 2
|
||||||
|
local toRoad = p.CFrame * CFrame.new(0, 0, -halfLen)
|
||||||
|
local toRing = p.CFrame * CFrame.new(0, 0, halfLen)
|
||||||
|
local dir, dist = bearing(pad.Position, p.Position)
|
||||||
|
log(string.format("%s centre %s of pad, %.0f studs", nm, dir, dist))
|
||||||
|
log(string.format(" ring end (%.0f, %.0f, %.0f)", toRing.Position.X, toRing.Position.Y, toRing.Position.Z))
|
||||||
|
log(string.format(" mainline end (%.0f, %.0f, %.0f)", toRoad.Position.X, toRoad.Position.Y, toRoad.Position.Z))
|
||||||
|
log(string.format(" length %.0f studs, width %.0f", p.Size.Z, p.Size.X))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How far from the pad edge to the ring, in each cardinal direction?
|
||||||
|
log("")
|
||||||
|
log("--- getting from pad to ring ---")
|
||||||
|
log(string.format("pad is %.0f x %.0f, so its edge is %.0f studs from centre", pad.Size.X, pad.Size.Z, pad.Size.X / 2))
|
||||||
|
local ringParts = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:find("Ring") then
|
||||||
|
table.insert(ringParts, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #ringParts > 0 then
|
||||||
|
local minR, maxR = math.huge, 0
|
||||||
|
for _, p in ipairs(ringParts) do
|
||||||
|
local r = (Vector3.new(p.Position.X - pad.Position.X, 0, p.Position.Z - pad.Position.Z)).Magnitude
|
||||||
|
minR = math.min(minR, r)
|
||||||
|
maxR = math.max(maxR, r)
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"ring road lies %.0f..%.0f studs from the pad centre, in every direction (it encircles the lot)",
|
||||||
|
minR,
|
||||||
|
maxR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
--[[
|
||||||
|
diag_pawall.luau -- is the parking area actually sealed?
|
||||||
|
|
||||||
|
The user reports a continuous low wall around the lot with no way out. The
|
||||||
|
suspect: planPA builds PARingWall on BOTH edges of every ring segment
|
||||||
|
(for sgn in {1,-1}) with no opening carved where PAOnRamp / PAOffRamp meet
|
||||||
|
the ring -- so the INNER lip would form an unbroken fence around the apron.
|
||||||
|
|
||||||
|
This does not reason about the code. It fires RAYS outward from the spawn
|
||||||
|
pad in 72 directions and reports what each one hits first, so a genuine gap
|
||||||
|
shows up as a ray that reaches the ring deck (or beyond) without hitting a
|
||||||
|
wall. If every ray hits PARingWall, the lot is sealed -- confirmed by
|
||||||
|
measurement, not inspection.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local map = Workspace:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "FATAL: no Map"
|
||||||
|
end
|
||||||
|
local pad = map:FindFirstChild("SpawnPad", true)
|
||||||
|
if not pad then
|
||||||
|
return "FATAL: no SpawnPad"
|
||||||
|
end
|
||||||
|
|
||||||
|
local origin = pad.Position + Vector3.new(0, 2, 0)
|
||||||
|
|
||||||
|
local params = RaycastParams.new()
|
||||||
|
params.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
params.FilterDescendantsInstances = { map }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local N = 72
|
||||||
|
local hits = {}
|
||||||
|
local escapes = {}
|
||||||
|
local wallH = {}
|
||||||
|
|
||||||
|
for i = 0, N - 1 do
|
||||||
|
local ang = (i / N) * math.pi * 2
|
||||||
|
local dir = Vector3.new(math.cos(ang), 0, math.sin(ang))
|
||||||
|
local res = Workspace:Raycast(origin, dir * 600, params)
|
||||||
|
local bearingDeg = math.deg(math.atan2(dir.X, -dir.Z))
|
||||||
|
if bearingDeg < 0 then
|
||||||
|
bearingDeg += 360
|
||||||
|
end
|
||||||
|
if res then
|
||||||
|
local nm = res.Instance.Name
|
||||||
|
hits[nm] = (hits[nm] or 0) + 1
|
||||||
|
if nm:find("Wall") or nm:find("Barrier") or nm:find("Rail") then
|
||||||
|
-- how tall is the blocker, and how high above the apron does it reach?
|
||||||
|
local top = res.Instance.Position.Y + res.Instance.Size.Y / 2
|
||||||
|
table.insert(wallH, top - pad.Position.Y)
|
||||||
|
else
|
||||||
|
table.insert(escapes, string.format("bearing %3.0f deg -> %s at %.0f studs", bearingDeg, nm, res.Distance))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
table.insert(escapes, string.format("bearing %3.0f deg -> CLEAR (no hit in 600 studs)", bearingDeg))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("cast %d rays outward from the pad at Y+2", N))
|
||||||
|
log("")
|
||||||
|
log("--- what the rays hit first ---")
|
||||||
|
for nm, n in pairs(hits) do
|
||||||
|
log(string.format(" %-16s %d rays", nm, n))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if #escapes == 0 then
|
||||||
|
log(">>> SEALED: every one of the " .. N .. " rays hit a wall/barrier.")
|
||||||
|
log(" There is NO gap in the ring wall -- the lot is fully enclosed.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> %d ray(s) found a non-wall path out:", #escapes))
|
||||||
|
for _, e in ipairs(escapes) do
|
||||||
|
log(" " .. e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #wallH > 0 then
|
||||||
|
local mn, mx = math.huge, -math.huge
|
||||||
|
for _, h in ipairs(wallH) do
|
||||||
|
mn = math.min(mn, h)
|
||||||
|
mx = math.max(mx, h)
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
log(string.format("blocking wall top sits %.1f..%.1f studs above the apron", mn, mx))
|
||||||
|
log("(a car can climb roughly 2-3 studs; anything above that is a hard barrier)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Distance from pad centre to the first wall, to size the enclosure.
|
||||||
|
local r = Workspace:Raycast(origin, Vector3.new(1, 0, 0) * 600, params)
|
||||||
|
if r then
|
||||||
|
log(string.format("first blocker due East is %.0f studs from pad centre", r.Distance))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- Diagnose the raycast outlier: report WHICH parts the rays hit.
|
||||||
|
-- The v3 raycast filtered on the whole map, so overhead geometry (overpass
|
||||||
|
-- decks, girders, gantries, tunnel ceilings) can be hit before the road.
|
||||||
|
local sss = game:GetService("ServerScriptService")
|
||||||
|
local orig = sss:FindFirstChild("MapBuilder", true)
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Name = "MapBuilder_Diag"
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local MB = require(clone)
|
||||||
|
local map = MB.build()
|
||||||
|
local Circuit = require(game:GetService("ReplicatedStorage"):FindFirstChild("Circuit", true))
|
||||||
|
local samples = Circuit.samples
|
||||||
|
local n = #samples
|
||||||
|
|
||||||
|
local rp = RaycastParams.new()
|
||||||
|
rp.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
rp.FilterDescendantsInstances = { map }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
-- Road-only params: what the driver actually touches.
|
||||||
|
local roadOnly = RaycastParams.new()
|
||||||
|
roadOnly.FilterType = Enum.RaycastFilterType.Include
|
||||||
|
roadOnly.FilterDescendantsInstances = { map:FindFirstChild("Expressway") }
|
||||||
|
|
||||||
|
local worstAll, worstRoad, atAll, atRoad = 0, 0, -1, -1
|
||||||
|
local hits = {}
|
||||||
|
for i = 1, n do
|
||||||
|
local j = (i % n) + 1
|
||||||
|
local p = samples[j].pos
|
||||||
|
local fwd = (samples[(j % n) + 1].pos - samples[i].pos)
|
||||||
|
fwd = Vector3.new(fwd.X, 0, fwd.Z)
|
||||||
|
if fwd.Magnitude > 0.01 then
|
||||||
|
fwd = fwd.Unit
|
||||||
|
local o1 = p - fwd * 3 + Vector3.new(0, 40, 0)
|
||||||
|
local o2 = p + fwd * 3 + Vector3.new(0, 40, 0)
|
||||||
|
local a1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), rp)
|
||||||
|
local a2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), rp)
|
||||||
|
if a1 and a2 then
|
||||||
|
local d = math.abs(a1.Position.Y - a2.Position.Y)
|
||||||
|
if d > worstAll then
|
||||||
|
worstAll, atAll = d, i
|
||||||
|
end
|
||||||
|
if d > 1 then
|
||||||
|
hits[#hits + 1] = ("i=%d(%s) d=%.2f hitA=%s hitB=%s"):format(
|
||||||
|
i,
|
||||||
|
samples[i].piece,
|
||||||
|
d,
|
||||||
|
a1.Instance.Name,
|
||||||
|
a2.Instance.Name
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local b1 = workspace:Raycast(o1, Vector3.new(0, -120, 0), roadOnly)
|
||||||
|
local b2 = workspace:Raycast(o2, Vector3.new(0, -120, 0), roadOnly)
|
||||||
|
if b1 and b2 then
|
||||||
|
local d = math.abs(b1.Position.Y - b2.Position.Y)
|
||||||
|
if d > worstRoad then
|
||||||
|
worstRoad, atRoad = d, i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
out[#out + 1] = ("ALL-GEOMETRY worst=%.3f at i=%d"):format(worstAll, atAll)
|
||||||
|
out[#out + 1] = ("ROAD-ONLY worst=%.3f at i=%d -> %s"):format(
|
||||||
|
worstRoad,
|
||||||
|
atRoad,
|
||||||
|
worstRoad <= 0.60 and "PASS" or "FAIL"
|
||||||
|
)
|
||||||
|
out[#out + 1] = ("outlier samples (d>1), first 12 of %d:"):format(#hits)
|
||||||
|
for k = 1, math.min(12, #hits) do
|
||||||
|
out[#out + 1] = " " .. hits[k]
|
||||||
|
end
|
||||||
|
|
||||||
|
map:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
--[[
|
||||||
|
diag_seatdir.luau -- decide, empirically, which way the car DRIVES.
|
||||||
|
|
||||||
|
The contradiction: CarManager:158 says `isFront = zSign < 0 -- car forward
|
||||||
|
is -Z`, so the front axle is built at local -Z. But the VehicleSeat's
|
||||||
|
LookVector points +Z (measured: seatLook.dot(bodyLook) = 1.00).
|
||||||
|
|
||||||
|
In Roblox a VehicleSeat applies throttle along its OWN +Z LookVector, so
|
||||||
|
positive throttle drives the car toward +Z -- i.e. toward the axle the code
|
||||||
|
labels REAR. If that holds, every car drives "backwards" and the shell,
|
||||||
|
which is aligned to the chassis convention, renders nose-first into the
|
||||||
|
direction of travel... backwards on screen.
|
||||||
|
|
||||||
|
This does not guess: it reads the seat's actual Throttle/steer wiring and
|
||||||
|
compares the seat basis against the wheel labels, then reports which axle
|
||||||
|
leads under positive throttle.
|
||||||
|
|
||||||
|
It also checks whether the steering servo lives on the axle that leads,
|
||||||
|
because a car steering with its TRAILING axle is the classic shopping-cart
|
||||||
|
castering symptom the user reported.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(workspace:GetDescendants()) do
|
||||||
|
if not (model:IsA("Model") and model.Name:match("^Car_")) then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
local seat = nil
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("VehicleSeat") then
|
||||||
|
seat = d
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not (body and body:IsA("BasePart") and seat) then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
|
||||||
|
-- Seat look expressed in BODY-LOCAL coords: which local axis does the car
|
||||||
|
-- drive along under positive throttle?
|
||||||
|
local seatLookLocal = inv:VectorToObjectSpace(seat.CFrame.LookVector)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" seat look in body-local = (%.2f, %.2f, %.2f)",
|
||||||
|
seatLookLocal.X,
|
||||||
|
seatLookLocal.Y,
|
||||||
|
seatLookLocal.Z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local drivesTowardPosZ = seatLookLocal.Z > 0
|
||||||
|
log(string.format(" => positive throttle drives toward local %sZ", drivesTowardPosZ and "+" or "-"))
|
||||||
|
|
||||||
|
-- Where are the labelled axles, and which one has the steering servo?
|
||||||
|
local fZ, fN, rZ, rN = 0, 0, 0, 0
|
||||||
|
local steerAxleZ, steerCount = 0, 0
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
|
||||||
|
local z = (inv * d.CFrame).Z
|
||||||
|
if d.Name:sub(7, 7):upper() == "F" then
|
||||||
|
fZ, fN = fZ + z, fN + 1
|
||||||
|
else
|
||||||
|
rZ, rN = rZ + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- servo is parented to the HUB, not the wheel -- find it by name
|
||||||
|
if d:IsA("HingeConstraint") and d.ActuatorType == Enum.ActuatorType.Servo then
|
||||||
|
local host = d.Parent
|
||||||
|
if host and host:IsA("BasePart") then
|
||||||
|
steerAxleZ = steerAxleZ + (inv * host.CFrame).Z
|
||||||
|
steerCount = steerCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local fm, rm = fZ / fN, rZ / rN
|
||||||
|
log(string.format(" labelled FRONT axle localZ=%.2f | REAR axle localZ=%.2f", fm, rm))
|
||||||
|
local leadingIsFront = (drivesTowardPosZ and fm > rm) or (not drivesTowardPosZ and fm < rm)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" => the axle LEADING under throttle is the one labelled %s",
|
||||||
|
leadingIsFront and "FRONT (correct)" or "REAR (car drives backwards!)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if steerCount > 0 then
|
||||||
|
local sm = steerAxleZ / steerCount
|
||||||
|
log(string.format(" steering servos=%d, mean host localZ=%.2f", steerCount, sm))
|
||||||
|
local steerLeads = (drivesTowardPosZ and sm > 0) or (not drivesTowardPosZ and sm < 0)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" => steered axle %s under throttle %s",
|
||||||
|
steerLeads and "LEADS" or "TRAILS",
|
||||||
|
steerLeads and "(normal car)" or "(SHOPPING-CART castering)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(" no steering servos found by name scan")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
if #out == 0 then
|
||||||
|
return "No live cars with a Body + VehicleSeat."
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellbuilt.luau -- does the solved yaw actually reach the built car?
|
||||||
|
|
||||||
|
diag_shellyaw2 proved the aligner SEES the problem: hatch_junk and hyper_x
|
||||||
|
are authored nose-toward +Z and solve to yaw ~180, the other three to ~0.
|
||||||
|
That is exactly the "at least two cars are backwards" the user reports, so
|
||||||
|
the cue is right and the question is whether the rotation survives to the
|
||||||
|
built model.
|
||||||
|
|
||||||
|
The earlier attempt measured -11.80 for every car -- that is the CHASSIS
|
||||||
|
wheelbase, i.e. it matched the chassis Wheel_* parts, not the shell's own
|
||||||
|
Wheels folder. This one scopes the search strictly INSIDE the Shell model
|
||||||
|
and skips anything named Wheel_* (the chassis convention), so it can only
|
||||||
|
ever read shell wheels.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local clone = orig:Clone()
|
||||||
|
clone.Parent = orig.Parent
|
||||||
|
local CarManager = require(clone)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(400, 8, 400)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
|
||||||
|
log("chassis forward = -Z. Shell nose must land at NEGATIVE body-local Z.")
|
||||||
|
log("")
|
||||||
|
|
||||||
|
for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = template.Name
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
if not car then
|
||||||
|
log(string.format("%-14s <no car def for this shell id>", id))
|
||||||
|
else
|
||||||
|
local model = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 5 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local shell = model:FindFirstChild("Shell")
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-14s <no Shell child -- using procedural CarBody>", id))
|
||||||
|
else
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
-- Only look at wheels INSIDE the shell, and never at chassis Wheel_*.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
if wf then
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
if w.Name:sub(1, 6) ~= "Wheel_" then
|
||||||
|
local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if part then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
local lp = inv * part.Position
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local f, r = fSum / fN, rSum / rN
|
||||||
|
local dz = f.Z - r.Z
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s shell wheels: frontZ %+6.2f rearZ %+6.2f dZ %+6.2f -> %s",
|
||||||
|
id,
|
||||||
|
f.Z,
|
||||||
|
r.Z,
|
||||||
|
dz,
|
||||||
|
dz < 0 and "CORRECT" or "*** STILL BACKWARDS ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format("%-14s <shell has no usable F/R wheels after build (F=%d R=%d)>", id, fN, rN))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
model:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
clone:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellgeom.luau -- measure the SHELL BODYWORK directly.
|
||||||
|
|
||||||
|
Two prior attempts both returned dZ = -11.80 for every car, which is the
|
||||||
|
chassis wheelbase: the "shell wheels" being matched are in fact the
|
||||||
|
chassis Wheel_* parts, so those runs proved nothing about shell facing.
|
||||||
|
|
||||||
|
Stop relying on wheel names. Instead:
|
||||||
|
1. Dump the real child structure of one shell so we can see what is
|
||||||
|
actually in there after the build.
|
||||||
|
2. Measure asymmetric BODYWORK mass: a car's cabin/rear sits behind its
|
||||||
|
nose, so the shell's own parts are not symmetric front-to-back.
|
||||||
|
Compare the shell part centroid against the shell AABB centre along
|
||||||
|
body-local Z -- the sign flips when the shell is rotated 180.
|
||||||
|
3. Cross-check against the same measurement on the raw TEMPLATE, where
|
||||||
|
diag_shellyaw2 already established ground truth (hatch_junk and
|
||||||
|
hyper_x authored nose-toward +Z, the other three nose-toward -Z).
|
||||||
|
If the built car's sign matches the template's, the flip never happened.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cloneMod = orig:Clone()
|
||||||
|
cloneMod.Parent = orig.Parent
|
||||||
|
local CarManager = require(cloneMod)
|
||||||
|
|
||||||
|
-- 1. What is actually inside a built shell?
|
||||||
|
local car0 = Cars.Get("hyper_x")
|
||||||
|
local m0 = CarManager._buildForDiag(car0, Chassis.tuningFor(car0))
|
||||||
|
m0.Parent = workspace
|
||||||
|
m0:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
local shell0 = m0:FindFirstChild("Shell")
|
||||||
|
log("=== STRUCTURE of built hyper_x Shell ===")
|
||||||
|
if shell0 then
|
||||||
|
for _, c in ipairs(shell0:GetChildren()) do
|
||||||
|
local extra = ""
|
||||||
|
if c:IsA("Folder") or c:IsA("Model") then
|
||||||
|
local kids = {}
|
||||||
|
for _, k in ipairs(c:GetChildren()) do
|
||||||
|
kids[#kids + 1] = k.Name
|
||||||
|
end
|
||||||
|
extra = " {" .. table.concat(kids, ",") .. "}"
|
||||||
|
end
|
||||||
|
log(string.format(" %-16s %s%s", c.Name, c.ClassName, extra))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" <no Shell>")
|
||||||
|
end
|
||||||
|
m0:Destroy()
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- 2/3. Bodywork asymmetry, template vs built.
|
||||||
|
local function asymmetry(root: Instance, frame: CFrame): number?
|
||||||
|
-- Volume-weighted centroid of bodywork, minus AABB centre, along local Z.
|
||||||
|
local inv = frame:Inverse()
|
||||||
|
local sum, wsum = 0, 0
|
||||||
|
local lo, hi = math.huge, -math.huge
|
||||||
|
for _, p in ipairs(root:GetDescendants()) do
|
||||||
|
if p:IsA("BasePart") then
|
||||||
|
local n = p.Name:lower()
|
||||||
|
-- exclude wheels/tyres of any naming convention
|
||||||
|
if not (n:find("wheel") or n:find("tyre") or n:find("tire") or n:find("rim")) then
|
||||||
|
local z = (inv * p.Position).Z
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
sum += z * vol
|
||||||
|
wsum += vol
|
||||||
|
lo = math.min(lo, z)
|
||||||
|
hi = math.max(hi, z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if wsum <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local centroid = sum / wsum
|
||||||
|
local aabbCentre = (lo + hi) / 2
|
||||||
|
return centroid - aabbCentre
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== BODYWORK ASYMMETRY along local Z (centroid - aabb centre) ===")
|
||||||
|
log("A car has more mass toward the REAR (cabin/boot), so on a correctly")
|
||||||
|
log("faced shell this should be POSITIVE (mass behind centre, nose at -Z).")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-14s %14s %14s %s", "car", "template", "built", "verdict"))
|
||||||
|
|
||||||
|
for _, template in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local id = template.Name
|
||||||
|
local tA = asymmetry(template, template:GetPivot())
|
||||||
|
local car = Cars.Get(id)
|
||||||
|
local bA = nil
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 3 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local sh = m:FindFirstChild("Shell")
|
||||||
|
if sh then
|
||||||
|
bA = asymmetry(sh, m.PrimaryPart.CFrame)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
local verdict = "?"
|
||||||
|
if tA and bA then
|
||||||
|
local flipped = (tA > 0) ~= (bA > 0)
|
||||||
|
verdict = flipped and "rotated by build" or "NOT rotated"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s %14s %14s %s",
|
||||||
|
id,
|
||||||
|
tA and string.format("%+.3f", tA) or "n/a",
|
||||||
|
bA and string.format("%+.3f", bA) or "n/a",
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
cloneMod:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
--[[
|
||||||
|
diag_shelllabels.luau -- compare the RAW shell assets, before any code
|
||||||
|
touches them. Purpose: find what is different about hatch_junk and hyper_x
|
||||||
|
(reported backwards) versus rx7_fc / silv_s15 / gtr_r34 (reported fine).
|
||||||
|
|
||||||
|
A convention error would hit all five equally, so the defect must be
|
||||||
|
per-asset. The shell yaw solver derives "nose" from the F*/R* wheel NAMES,
|
||||||
|
so if those two shells label their axles opposite to the others, the solver
|
||||||
|
is fed a reversed cue and dutifully rotates them 180 degrees.
|
||||||
|
|
||||||
|
IMPORTANT -- this reads the stored shell assets directly. It does NOT call
|
||||||
|
require() on CarManager, and it does NOT rely on any value derived from a
|
||||||
|
constant. Every number below is read off the asset's own geometry, so it
|
||||||
|
genuinely differs between a correctly-labelled and a mislabelled shell.
|
||||||
|
|
||||||
|
Independent cue: cars are longer than they are wide, so the shell's LONG
|
||||||
|
horizontal axis is the longitudinal (nose-tail) axis. Comparing the wheel
|
||||||
|
label cue against the bounding-box long axis tells us whether the labels
|
||||||
|
agree with the geometry -- without trusting either one alone.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Locate the folder holding the shell models. Search broadly rather than
|
||||||
|
-- assuming a path, and report what was found so a wrong guess is visible.
|
||||||
|
local function findShellRoot()
|
||||||
|
for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Folder") and (d.Name == "Shells" or d.Name == "CarShells" or d.Name == "Bodies") then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local shellRoot = findShellRoot()
|
||||||
|
if not shellRoot then
|
||||||
|
-- Fall back: find any Model that contains a "Wheels" child.
|
||||||
|
local found = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, workspace, game:GetService("ServerStorage") }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") then
|
||||||
|
table.insert(found, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #found == 0 then
|
||||||
|
return "FATAL: no shell models found (no Shells folder, no Model with a Wheels child)"
|
||||||
|
end
|
||||||
|
log("no Shells folder; found shells by Wheels child:")
|
||||||
|
shellRoot = nil
|
||||||
|
local list = {}
|
||||||
|
for _, m in ipairs(found) do
|
||||||
|
table.insert(list, m)
|
||||||
|
end
|
||||||
|
-- Reuse the same reporting path below.
|
||||||
|
shellRoot = {
|
||||||
|
GetChildren = function()
|
||||||
|
return list
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-26s %-22s %-18s %s",
|
||||||
|
"shell",
|
||||||
|
"F centroid (local)",
|
||||||
|
"R centroid",
|
||||||
|
"long axis",
|
||||||
|
"labels agree?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.rep("-", 100))
|
||||||
|
|
||||||
|
local shells = shellRoot:GetChildren()
|
||||||
|
local reported = 0
|
||||||
|
|
||||||
|
for _, shell in ipairs(shells) do
|
||||||
|
if not shell:IsA("Model") then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
if not wheels then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
-- Centroid of each labelled axle, in the shell's own local frame.
|
||||||
|
local fSum, fN = Vector3.zero, 0
|
||||||
|
local rSum, rN = Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
local localPos = pivotInv * (sum / n)
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + localPos, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + localPos, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s NO F/R WHEEL LABELS (F=%d R=%d)", shell.Name, fN, rN))
|
||||||
|
reported += 1
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local fc = fSum / fN
|
||||||
|
local rc = rSum / rN
|
||||||
|
local nose = fc - rc
|
||||||
|
|
||||||
|
-- Independent geometric cue: the shell's longest horizontal extent is the
|
||||||
|
-- nose-tail axis. If |nose.Z| dominates, labels lie along Z (expected).
|
||||||
|
local minV, maxV
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = pivotInv * d.Position
|
||||||
|
minV = minV and Vector3.new(math.min(minV.X, p.X), 0, math.min(minV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z)
|
||||||
|
maxV = maxV and Vector3.new(math.max(maxV.X, p.X), 0, math.max(maxV.Z, p.Z)) or Vector3.new(p.X, 0, p.Z)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local spanX = maxV and (maxV.X - minV.X) or 0
|
||||||
|
local spanZ = maxV and (maxV.Z - minV.Z) or 0
|
||||||
|
local longAxis = (spanZ >= spanX) and "Z" or "X"
|
||||||
|
|
||||||
|
-- The convention is: forward = -Z. So a correctly-labelled shell has its
|
||||||
|
-- FRONT axle at a MORE NEGATIVE local Z than its rear, i.e. nose.Z < 0.
|
||||||
|
local agrees = nose.Z < 0
|
||||||
|
local verdict
|
||||||
|
if math.abs(nose.Z) < 0.05 then
|
||||||
|
verdict = "DEGENERATE (no Z separation)"
|
||||||
|
elseif agrees then
|
||||||
|
verdict = "OK (F is toward -Z)"
|
||||||
|
else
|
||||||
|
verdict = "*** REVERSED (F is toward +Z) ***"
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s F(%+.1f,%+.1f) %-11s R(%+.1f,%+.1f) %-6s %s(x%.0f z%.0f) %s",
|
||||||
|
shell.Name,
|
||||||
|
fc.X,
|
||||||
|
fc.Z,
|
||||||
|
"",
|
||||||
|
rc.X,
|
||||||
|
rc.Z,
|
||||||
|
"",
|
||||||
|
longAxis,
|
||||||
|
spanX,
|
||||||
|
spanZ,
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reported += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
if reported == 0 then
|
||||||
|
log(">>> NOTHING INSPECTED -- no shells with a Wheels child. This is a FAILURE, not a pass.")
|
||||||
|
else
|
||||||
|
log(string.format(">>> inspected %d shells", reported))
|
||||||
|
log(" Convention: forward = -Z, so a correct shell has F wheels at more NEGATIVE Z than R.")
|
||||||
|
log(" Any shell marked REVERSED has mislabelled wheels -- the yaw solver's cue is inverted")
|
||||||
|
log(" for that car only, which is exactly the per-car symptom reported.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellparts.luau -- dump the actual part inventory of hatch_junk and
|
||||||
|
hyper_x. diag_nosecue2 found no headlight/taillight/seat on either, so the
|
||||||
|
naming convention must differ. Read-only: list what IS there, plus a purely
|
||||||
|
geometric asymmetry cue that needs no naming at all.
|
||||||
|
|
||||||
|
Geometric cue (naming-independent): on a car, the passenger cabin /
|
||||||
|
windscreen sits BEHIND the nose, so the mass distribution along the
|
||||||
|
longitudinal axis is asymmetric. Cars are also taller at the cabin than at
|
||||||
|
the nose. Reporting mean height per Z-half lets the shape itself indicate
|
||||||
|
which end is the bonnet -- the tall half is the cabin/rear-biased end.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
local SS = game:GetService("ServerStorage")
|
||||||
|
|
||||||
|
local TARGETS = { hatch_junk = true, hyper_x = true, rx7_fc = true }
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, SS, workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and TARGETS[d.Name] and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs({ "rx7_fc", "hatch_junk", "hyper_x" }) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(name .. ": NOT FOUND")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
|
||||||
|
log("=== " .. name .. " ===")
|
||||||
|
|
||||||
|
-- Part names outside the Wheels folder, with local Z, sorted front-to-back.
|
||||||
|
local rows = {}
|
||||||
|
for _, d in ipairs(shell:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and not d:IsDescendantOf(shell.Wheels) then
|
||||||
|
local p = pivotInv * d.Position
|
||||||
|
table.insert(rows, { name = d.Name, z = p.Z, y = p.Y, cls = d.ClassName })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(rows, function(a, b)
|
||||||
|
return a.z < b.z
|
||||||
|
end)
|
||||||
|
|
||||||
|
log(string.format(" %d non-wheel parts (sorted by local Z, -Z first):", #rows))
|
||||||
|
local shown = 0
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if shown < 24 then
|
||||||
|
log(string.format(" z=%+7.2f y=%+6.2f %-28s %s", r.z, r.y, r.name, r.cls))
|
||||||
|
shown += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #rows > 24 then
|
||||||
|
log(string.format(" ... %d more", #rows - 24))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Naming-independent shape cue: mean height of parts in each Z half.
|
||||||
|
local negY, negN, posY, posN = 0, 0, 0, 0
|
||||||
|
local minZ, maxZ
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
minZ = minZ and math.min(minZ, r.z) or r.z
|
||||||
|
maxZ = maxZ and math.max(maxZ, r.z) or r.z
|
||||||
|
end
|
||||||
|
local midZ = (minZ and maxZ) and ((minZ + maxZ) / 2) or 0
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if r.z < midZ then
|
||||||
|
negY, negN = negY + r.y, negN + 1
|
||||||
|
else
|
||||||
|
posY, posN = posY + r.y, posN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if negN > 0 and posN > 0 then
|
||||||
|
local negMean, posMean = negY / negN, posY / posN
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" shape cue: mean height -Z half = %+.2f (n=%d) +Z half = %+.2f (n=%d)",
|
||||||
|
negMean,
|
||||||
|
negN,
|
||||||
|
posMean,
|
||||||
|
posN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local tallEnd = (negMean > posMean) and "-Z" or "+Z"
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" taller (cabin) end = %s => nose is likely %s",
|
||||||
|
tallEnd,
|
||||||
|
(tallEnd == "-Z") and "+Z" or "-Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(" shape cue: unavailable")
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellyaw.luau -- measure the ACTUAL shell orientation on LIVE cars.
|
||||||
|
|
||||||
|
Why this exists: the previous harness measured Wheel_* positions, which come
|
||||||
|
from the constant Chassis.WHEEL_MOTOR/WHEELBASE_HALF (5.9). Those are
|
||||||
|
identical for every car whether or not any fix applied, so the test passed
|
||||||
|
vacuously. The visible "backwards" car is the COSMETIC SHELL, which is a
|
||||||
|
separate model attached to the chassis -- that is what must be measured.
|
||||||
|
|
||||||
|
Runs against the running game (Server datamodel), inspecting cars that the
|
||||||
|
real spawn path actually produced. Reports, per live car:
|
||||||
|
|
||||||
|
bodyLook : chassis Body forward (should be the car's true nose direction)
|
||||||
|
shellLook : shell model forward
|
||||||
|
yawDelta : signed angle between them, degrees. ~0 = aligned,
|
||||||
|
~180 = shell rendering backwards relative to chassis.
|
||||||
|
|
||||||
|
Also dumps the shell's own internal cue (mean Z of its F*/R* wheel labels)
|
||||||
|
so we can see WHY the solver chose what it chose, and whether
|
||||||
|
SHELL_YAW_OVERRIDE actually took effect on this car.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function yawBetween(a: Vector3, b: Vector3): number
|
||||||
|
local av = Vector3.new(a.X, 0, a.Z)
|
||||||
|
local bv = Vector3.new(b.X, 0, b.Z)
|
||||||
|
if av.Magnitude < 1e-4 or bv.Magnitude < 1e-4 then
|
||||||
|
return 0 / 0
|
||||||
|
end
|
||||||
|
av, bv = av.Unit, bv.Unit
|
||||||
|
local dot = math.clamp(av:Dot(bv), -1, 1)
|
||||||
|
local ang = math.deg(math.acos(dot))
|
||||||
|
local cross = av:Cross(bv).Y
|
||||||
|
return (cross < 0) and -ang or ang
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find live cars anywhere in workspace (folder name varies).
|
||||||
|
local cars = {}
|
||||||
|
for _, d in ipairs(workspace:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d.Name:match("^Car_") then
|
||||||
|
cars[#cars + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("live Car_* models found: %d", #cars))
|
||||||
|
if #cars == 0 then
|
||||||
|
log("")
|
||||||
|
log("No spawned cars. Drive/spawn a car, or this is the wrong datamodel.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, model in ipairs(cars) do
|
||||||
|
log("")
|
||||||
|
log("=== " .. model.Name .. " ===")
|
||||||
|
|
||||||
|
local body = model:FindFirstChild("Body")
|
||||||
|
if not body or not body:IsA("BasePart") then
|
||||||
|
log(" no Body part -- cannot establish chassis frame")
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local bodyLook = body.CFrame.LookVector
|
||||||
|
log(string.format(" bodyLook = (%.2f, %.2f, %.2f)", bodyLook.X, bodyLook.Y, bodyLook.Z))
|
||||||
|
|
||||||
|
-- The shell: a Model child that is not the chassis scaffolding.
|
||||||
|
local shell = nil
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
if c:IsA("Model") then
|
||||||
|
shell = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not shell then
|
||||||
|
log(" NO SHELL MODEL as a child -- cosmetic mesh may be parented elsewhere")
|
||||||
|
local kids = {}
|
||||||
|
for _, c in ipairs(model:GetChildren()) do
|
||||||
|
kids[#kids + 1] = c.ClassName .. ":" .. c.Name
|
||||||
|
end
|
||||||
|
log(" children: " .. table.concat(kids, ", "))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local shellLook = shell:GetPivot().LookVector
|
||||||
|
log(string.format(" shell = %s", shell.Name))
|
||||||
|
log(string.format(" shellLook = (%.2f, %.2f, %.2f)", shellLook.X, shellLook.Y, shellLook.Z))
|
||||||
|
|
||||||
|
local delta = yawBetween(bodyLook, shellLook)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" yawDelta = %.1f deg --> %s",
|
||||||
|
delta,
|
||||||
|
(math.abs(delta) < 30) and "ALIGNED"
|
||||||
|
or (math.abs(math.abs(delta) - 180) < 30) and "BACKWARDS (180 flip)"
|
||||||
|
or "SKEWED"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Why: the shell's own wheel-label cue, in the shell's local frame.
|
||||||
|
local wf = shell:FindFirstChild("Wheels")
|
||||||
|
if wf then
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, d in ipairs(wf:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name:upper()
|
||||||
|
local z = (pivotInv * d.CFrame).Z
|
||||||
|
if nm:match("^F") then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif nm:match("^R") then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" shell wheel cue: frontZ=%.2f rearZ=%.2f (labels say nose at %s)",
|
||||||
|
fS / fN,
|
||||||
|
rS / rN,
|
||||||
|
(fS / fN < rS / rN) and "-Z" or "+Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(string.format(" shell wheel cue: unusable (F parts=%d, R parts=%d)", fN, rN))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
log(" shell has no Wheels folder -- no cue available")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
--[[
|
||||||
|
diag_shellyaw2.luau -- corrected. The first attempt looked in the wrong
|
||||||
|
place (CarShells/Shells/Cars) and then measured the CHASSIS wheels, so it
|
||||||
|
reported an identical -11.80 wheelbase for every car and proved nothing.
|
||||||
|
|
||||||
|
Shells actually live in ReplicatedStorage.CarModels[car.Id] (CarManager
|
||||||
|
line 347). This inspects those templates directly and reports the exact
|
||||||
|
cue the auto-aligner uses, plus the resulting yaw.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local models = ReplicatedStorage:FindFirstChild("CarModels")
|
||||||
|
if not models then
|
||||||
|
log("ReplicatedStorage.CarModels: MISSING -> every car uses the procedural")
|
||||||
|
log("CarBody shell, which is authored nose-toward -Z and cannot be backwards.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
log("ReplicatedStorage.CarModels children:")
|
||||||
|
for _, t in ipairs(models:GetChildren()) do
|
||||||
|
log(string.format(" %-14s (%s)", t.Name, t.ClassName))
|
||||||
|
end
|
||||||
|
log("")
|
||||||
|
|
||||||
|
for _, template in ipairs(models:GetChildren()) do
|
||||||
|
if template:IsA("Model") then
|
||||||
|
local wf = template:FindFirstChild("Wheels")
|
||||||
|
if not wf then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s Wheels folder MISSING -> shellYaw = 0 (fails soft, keeps authored yaw)",
|
||||||
|
template.Name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
local pivotInv = template:GetPivot():Inverse()
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
local names = {}
|
||||||
|
for _, w in ipairs(wf:GetChildren()) do
|
||||||
|
table.insert(names, w.Name)
|
||||||
|
local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
if part then
|
||||||
|
local h = w.Name:sub(1, 1):upper()
|
||||||
|
local lp = pivotInv * part.Position
|
||||||
|
if h == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif h == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
local usable = (math.abs(nose.X) + math.abs(nose.Z)) > 0.05
|
||||||
|
local yaw = usable and -math.atan2(nose.X, -nose.Z) or 0
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s [%s] F=%d R=%d nose=(%.2f,%.2f,%.2f) -> yaw %+.1f deg %s",
|
||||||
|
template.Name,
|
||||||
|
table.concat(names, ","),
|
||||||
|
fN,
|
||||||
|
rN,
|
||||||
|
nose.X,
|
||||||
|
nose.Y,
|
||||||
|
nose.Z,
|
||||||
|
math.deg(yaw),
|
||||||
|
usable and "" or "*** DEGENERATE -> yaw 0 ***"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-14s [%s] F=%d R=%d -> *** NO F/R CUE, shellYaw = 0, stays as authored ***",
|
||||||
|
template.Name,
|
||||||
|
table.concat(names, ","),
|
||||||
|
fN,
|
||||||
|
rN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
--[[
|
||||||
|
diag_solveryaw.luau -- does the shell yaw solver actually fire for
|
||||||
|
hatch_junk / hyper_x, and what does it produce?
|
||||||
|
|
||||||
|
Hypothesis under test: the solver derives "nose" from the F/R wheel labels,
|
||||||
|
so for a shell whose F wheels sit at +Z it should compute a 180-degree yaw
|
||||||
|
and rotate the shell to face -Z. If that is happening, those two cars would
|
||||||
|
look CORRECT -- but they look backwards. So either the solver is not being
|
||||||
|
reached, or it computes something other than 180.
|
||||||
|
|
||||||
|
CACHE GUARD FIRST. require() caches for the whole Studio session, so a
|
||||||
|
build test can silently run OLD code. Before trusting anything below, the
|
||||||
|
script re-reads Chassis.DRIVE_SIGN via require() and compares it to the
|
||||||
|
.Source text. If they disagree, the cache is stale and the script ABORTS
|
||||||
|
rather than reporting a number that means nothing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RS = game:GetService("ReplicatedStorage")
|
||||||
|
local SSS = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(s)
|
||||||
|
table.insert(out, s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(name)
|
||||||
|
for _, root in ipairs({ SSS, RS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
local m = root:FindFirstChild(name, true)
|
||||||
|
if m and m:IsA("ModuleScript") then
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- cache guard -------------------------------------------------------
|
||||||
|
local chMod = find("Chassis")
|
||||||
|
if not chMod then
|
||||||
|
return "FATAL: Chassis not found"
|
||||||
|
end
|
||||||
|
local srcHasNeg1 = chMod.Source:find("DRIVE_SIGN = -1", 1, true) ~= nil
|
||||||
|
local okReq, Chassis = pcall(require, chMod)
|
||||||
|
if not okReq then
|
||||||
|
return "FATAL: require(Chassis) failed: " .. tostring(Chassis)
|
||||||
|
end
|
||||||
|
local liveNeg1 = (Chassis.DRIVE_SIGN == -1)
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"cache guard: source DRIVE_SIGN=-1? %s require() DRIVE_SIGN=%s",
|
||||||
|
tostring(srcHasNeg1),
|
||||||
|
tostring(Chassis.DRIVE_SIGN)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if srcHasNeg1 ~= liveNeg1 then
|
||||||
|
log("")
|
||||||
|
log(">>> ABORT: require() cache disagrees with source. Any build measured now")
|
||||||
|
log(" would use STALE code. Restart Studio (or reopen the place) and re-run.")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
log("cache guard: PASS (require matches source)")
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- ---- what does the solver compute? -------------------------------------
|
||||||
|
-- Recompute the solver's own input the same way the source does: nose is the
|
||||||
|
-- F-centroid minus R-centroid, in shell-local space; yaw = -atan2(nose.X, -nose.Z).
|
||||||
|
local TARGETS = { "rx7_fc", "silv_s15", "gtr_r34", "hatch_junk", "hyper_x" }
|
||||||
|
local shells = {}
|
||||||
|
for _, root in ipairs({ RS, SSS, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(root:GetDescendants()) do
|
||||||
|
if d:IsA("Model") and d:FindFirstChild("Wheels") and not shells[d.Name] then
|
||||||
|
for _, t in ipairs(TARGETS) do
|
||||||
|
if d.Name == t then
|
||||||
|
shells[d.Name] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("%-11s %-12s %-14s %s", "shell", "nose.Z", "solver yaw", "meaning"))
|
||||||
|
log(string.rep("-", 74))
|
||||||
|
|
||||||
|
for _, name in ipairs(TARGETS) do
|
||||||
|
local shell = shells[name]
|
||||||
|
if not shell then
|
||||||
|
log(string.format("%-11s NOT FOUND", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local pivotInv = shell:GetPivot():Inverse()
|
||||||
|
local wheels = shell:FindFirstChild("Wheels")
|
||||||
|
local fSum, fN, rSum, rN = Vector3.zero, 0, Vector3.zero, 0
|
||||||
|
for _, w in ipairs(wheels:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local lp = pivotInv * (sum / n)
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fSum, fN = fSum + lp, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rSum, rN = rSum + lp, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN == 0 or rN == 0 then
|
||||||
|
log(string.format("%-11s no F/R labels", name))
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local nose = (fSum / fN) - (rSum / rN)
|
||||||
|
local yaw = -math.atan2(nose.X, -nose.Z)
|
||||||
|
local deg = math.deg(yaw)
|
||||||
|
local meaning
|
||||||
|
if math.abs(deg) < 15 then
|
||||||
|
meaning = "no rotation applied"
|
||||||
|
elseif math.abs(math.abs(deg) - 180) < 15 then
|
||||||
|
meaning = "180 flip -> solver DOES correct this shell"
|
||||||
|
else
|
||||||
|
meaning = "partial rotation"
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-11s %-12s %-14s %s",
|
||||||
|
name,
|
||||||
|
string.format("%+.1f", nose.Z),
|
||||||
|
string.format("%+.1f deg", deg),
|
||||||
|
meaning
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("If the two reported-backwards shells show a 180 flip here, the solver is")
|
||||||
|
log("already correcting them in theory -- so the fault is that this code path")
|
||||||
|
log("is not reached at build time, NOT the maths. That distinction decides the fix.")
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
--[[
|
||||||
|
diag_stale.luau -- is the live CarManager module the source I pushed?
|
||||||
|
|
||||||
|
diag_wheelroot2 failed with "_buildForDiag is nil" even though the file on
|
||||||
|
disk defines it at line 988 and push_module reported
|
||||||
|
assembled=39591 expected=39591 match=true.
|
||||||
|
|
||||||
|
So either (a) the ModuleScript I am cloning is not the one I pushed to, or
|
||||||
|
(b) there is more than one CarManager, or (c) the clone-and-require trick
|
||||||
|
is returning a cached table from the ORIGINAL require rather than
|
||||||
|
re-executing the clone's source.
|
||||||
|
|
||||||
|
(c) is the important one: if require(clone) hands back the cached original
|
||||||
|
table, then every "built car" measurement in this session came from the
|
||||||
|
OLD module, and all of those results are void.
|
||||||
|
|
||||||
|
Checks, with no assumptions:
|
||||||
|
1. Every ModuleScript named CarManager anywhere in the DataModel, with
|
||||||
|
its source length and whether its text contains _buildForDiag.
|
||||||
|
2. What require() actually returns for the original and for a fresh
|
||||||
|
clone -- key list and identity comparison.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
log("=== every CarManager ModuleScript in the DataModel ===")
|
||||||
|
local found = {}
|
||||||
|
for _, svc in ipairs({ ServerScriptService, ReplicatedStorage, game:GetService("ServerStorage"), workspace }) do
|
||||||
|
for _, d in ipairs(svc:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
found[#found + 1] = d
|
||||||
|
local src = ""
|
||||||
|
local okSrc = pcall(function()
|
||||||
|
src = d.Source
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-58s len=%s has _buildForDiag=%s",
|
||||||
|
d:GetFullName(),
|
||||||
|
okSrc and tostring(#src) or "<unreadable>",
|
||||||
|
okSrc and tostring(src:find("_buildForDiag") ~= nil) or "?"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" total found: %d", #found))
|
||||||
|
|
||||||
|
if #found == 0 then
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local target = found[1]
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=== what require() returns ===")
|
||||||
|
local okA, A = pcall(require, target)
|
||||||
|
log(" require(original): ok=" .. tostring(okA))
|
||||||
|
if okA and typeof(A) == "table" then
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(A) do
|
||||||
|
keys[#keys + 1] = tostring(k)
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
log(" keys: " .. table.concat(keys, ", "))
|
||||||
|
log(" _buildForDiag = " .. typeof(A._buildForDiag))
|
||||||
|
end
|
||||||
|
|
||||||
|
local clone = target:Clone()
|
||||||
|
clone.Parent = target.Parent
|
||||||
|
local okB, B = pcall(require, clone)
|
||||||
|
log(" require(clone): ok=" .. tostring(okB))
|
||||||
|
if okB and typeof(B) == "table" then
|
||||||
|
local keys = {}
|
||||||
|
for k in pairs(B) do
|
||||||
|
keys[#keys + 1] = tostring(k)
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
log(" keys: " .. table.concat(keys, ", "))
|
||||||
|
log(" _buildForDiag = " .. typeof(B._buildForDiag))
|
||||||
|
end
|
||||||
|
if okA and okB then
|
||||||
|
log(
|
||||||
|
" clone table IS original table: "
|
||||||
|
.. tostring(rawequal(A, B))
|
||||||
|
.. " <- if true, the clone trick does NOT reload source"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
clone:Destroy()
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer2.luau -- WHY does the steering servo not rotate?
|
||||||
|
|
||||||
|
diag_steering showed TargetAngle -14..-26 deg with CurrentAngle stuck near
|
||||||
|
0.3 deg. This isolates the cause by removing one load at a time:
|
||||||
|
|
||||||
|
A. HOISTED -- body anchored, wheels in free air. No tyre load at all.
|
||||||
|
If the servo still will not turn, the fault is the
|
||||||
|
constraint itself, not grip.
|
||||||
|
B. GROUND, STATIC -- resting on the pad, no throttle.
|
||||||
|
C. GROUND, ROLLING -- coasting, no drive torque (drive is what saturates
|
||||||
|
the friction circle).
|
||||||
|
D. TORQUE SWEEP -- is 1e6 actually enough?
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Name = "DiagPad2"
|
||||||
|
pad.Size = Vector3.new(4000, 8, 4000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
|
||||||
|
local function build()
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function partsOf(model)
|
||||||
|
local servos, motors = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors
|
||||||
|
end
|
||||||
|
|
||||||
|
local LOCK = tuning.MaxSteerAngle
|
||||||
|
log(string.format("MaxSteerAngle %.1f deg | ServoMaxTorque 1e6 | AngularSpeed %.1f rad/s", LOCK, tuning.SteerSpeed))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
-- Report the hinge axes once: a mis-set axis would make the servo yaw about
|
||||||
|
-- the wrong vector and look exactly like "no steering".
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for i, s in ipairs(servos) do
|
||||||
|
local a0 = s.Attachment0
|
||||||
|
local a1 = s.Attachment1
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"servo %d axis0 (%.2f,%.2f,%.2f) axis1 (%.2f,%.2f,%.2f) | A0 on %s, A1 on %s",
|
||||||
|
i,
|
||||||
|
a0.WorldAxis.X,
|
||||||
|
a0.WorldAxis.Y,
|
||||||
|
a0.WorldAxis.Z,
|
||||||
|
a1.WorldAxis.X,
|
||||||
|
a1.WorldAxis.Y,
|
||||||
|
a1.WorldAxis.Z,
|
||||||
|
a0.Parent.Name,
|
||||||
|
a1.Parent.Name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
log("")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A. HOISTED ---------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 40, 0)) -- well clear of the pad
|
||||||
|
body.Anchored = true
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 30 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if step % 20 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("A. HOISTED (no tyre load): target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", ")))
|
||||||
|
log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK)))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- B. GROUND, STATIC --------------------------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if step % 20 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("B. GROUND STATIC: target %.1f -> CurrentAngle %s", -LOCK, table.concat(trace, ", ")))
|
||||||
|
log(string.format(" final err %.2f deg", math.abs(servos[1].CurrentAngle - -LOCK)))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- C. GROUND, COASTING (no drive torque) ------------------------------------
|
||||||
|
do
|
||||||
|
local m = build()
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local servos, motors = partsOf(m)
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
-- Push to a moderate speed, then cut drive entirely and steer.
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * 60
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0 -- freewheel: friction circle fully available for cornering
|
||||||
|
mo.AngularVelocity = 0
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
local trace, yawTrace = {}, {}
|
||||||
|
for step = 1, 150 do
|
||||||
|
if step % 30 == 0 then
|
||||||
|
table.insert(trace, string.format("%.1f", servos[1].CurrentAngle))
|
||||||
|
table.insert(yawTrace, string.format("%.3f", body.AssemblyAngularVelocity.Y))
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("C. GROUND COASTING @60: CurrentAngle %s", table.concat(trace, ", ")))
|
||||||
|
log(string.format(" yawRate %s", table.concat(yawTrace, ", ")))
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- D. TORQUE SWEEP (ground, static) -----------------------------------------
|
||||||
|
log("")
|
||||||
|
log("D. ServoMaxTorque sweep (ground, static, full lock, 1.5 s):")
|
||||||
|
for _, tq in ipairs({ 1e5, 1e6, 1e7, 1e8, 1e9 }) do
|
||||||
|
local m = build()
|
||||||
|
local servos = partsOf(m)
|
||||||
|
for _ = 1, 45 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = tq
|
||||||
|
s.TargetAngle = -LOCK
|
||||||
|
end
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" torque %10.0e -> CurrentAngle %6.1f (err %5.1f)",
|
||||||
|
tq,
|
||||||
|
servos[1].CurrentAngle,
|
||||||
|
math.abs(servos[1].CurrentAngle + LOCK)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer3.luau -- WHAT is holding the knuckle rigid?
|
||||||
|
|
||||||
|
diag_steer2 proved the servo will not rotate even hoisted with zero tyre
|
||||||
|
load and ServoMaxTorque up to 1e9, with both hinge axes correctly +Y.
|
||||||
|
That means the knuckle is rigidly bonded to the hub by something other
|
||||||
|
than the servo.
|
||||||
|
|
||||||
|
Decisive test: parts joined by a WELD share an AssemblyRootPart. Parts
|
||||||
|
joined by a moving CONSTRAINT do not. So if Hub_FL and Knuckle_FL report
|
||||||
|
the same AssemblyRootPart, a weld is bypassing the servo.
|
||||||
|
|
||||||
|
Also builds an isolated 2-part servo to confirm the engine/API itself
|
||||||
|
behaves, ruling out a bad assumption about HingeConstraint.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 1. ISOLATED SERVO --------------------------------------------------------
|
||||||
|
-- Two free parts, one servo, nothing else. If this turns, the API is fine
|
||||||
|
-- and the fault is in how the car is assembled.
|
||||||
|
do
|
||||||
|
local folder = Instance.new("Folder")
|
||||||
|
folder.Name = "IsolatedServoTest"
|
||||||
|
folder.Parent = workspace
|
||||||
|
|
||||||
|
local base = Instance.new("Part")
|
||||||
|
base.Size = Vector3.new(4, 1, 4)
|
||||||
|
base.Position = Vector3.new(0, 5100, 0)
|
||||||
|
base.Anchored = true
|
||||||
|
base.Parent = folder
|
||||||
|
|
||||||
|
local arm = Instance.new("Part")
|
||||||
|
arm.Size = Vector3.new(1, 1, 6)
|
||||||
|
arm.Position = Vector3.new(0, 5102, 0)
|
||||||
|
arm.Parent = folder
|
||||||
|
|
||||||
|
local VERT = CFrame.Angles(0, 0, math.rad(90))
|
||||||
|
local a0 = Instance.new("Attachment")
|
||||||
|
a0.CFrame = VERT
|
||||||
|
a0.Parent = base
|
||||||
|
local a1 = Instance.new("Attachment")
|
||||||
|
a1.CFrame = VERT
|
||||||
|
a1.Parent = arm
|
||||||
|
|
||||||
|
local s = Instance.new("HingeConstraint")
|
||||||
|
s.Attachment0 = a0
|
||||||
|
s.Attachment1 = a1
|
||||||
|
s.ActuatorType = Enum.ActuatorType.Servo
|
||||||
|
s.ServoMaxTorque = 1e6
|
||||||
|
s.AngularSpeed = 6
|
||||||
|
s.LimitsEnabled = true
|
||||||
|
s.LowerAngle = -35
|
||||||
|
s.UpperAngle = 35
|
||||||
|
s.TargetAngle = -30
|
||||||
|
s.Parent = base
|
||||||
|
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"1. ISOLATED servo: target -30 -> CurrentAngle %.2f (%s)",
|
||||||
|
s.CurrentAngle,
|
||||||
|
math.abs(s.CurrentAngle + 30) < 5 and "WORKS" or "BROKEN"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
folder:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. ASSEMBLY TOPOLOGY OF A REAL CAR ---------------------------------------
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(2000, 8, 2000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Parent = workspace
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local model = CarManager._buildForDiag(car, tuning)
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, 5007, 0))
|
||||||
|
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get(name)
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name == name then
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local hubFL = get("Hub_FL")
|
||||||
|
local knuckleFL = get("Knuckle_FL")
|
||||||
|
local wheelFL = get("Wheel_FL")
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("2. ASSEMBLY ROOTS (same root == rigidly welded together):")
|
||||||
|
local function rootName(p)
|
||||||
|
if not p then
|
||||||
|
return "<missing>"
|
||||||
|
end
|
||||||
|
local r = p.AssemblyRootPart
|
||||||
|
return r and r:GetFullName():gsub("^.*%.", "") or "<none>"
|
||||||
|
end
|
||||||
|
log(" Body root:", rootName(body))
|
||||||
|
log(" Hub_FL root:", rootName(hubFL))
|
||||||
|
log(" Knuckle_FL root:", rootName(knuckleFL))
|
||||||
|
log(" Wheel_FL root:", rootName(wheelFL))
|
||||||
|
if hubFL and knuckleFL then
|
||||||
|
local same = hubFL.AssemblyRootPart == knuckleFL.AssemblyRootPart
|
||||||
|
log(
|
||||||
|
" >>> Hub and Knuckle share an assembly:",
|
||||||
|
tostring(same),
|
||||||
|
same and "<-- WELDED, SERVO BYPASSED" or "(ok, servo is free)"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. EVERY joint touching those parts --------------------------------------
|
||||||
|
log("")
|
||||||
|
log("3. ALL joints touching Hub_FL / Knuckle_FL / Wheel_FL:")
|
||||||
|
local targets = { [hubFL] = "Hub_FL", [knuckleFL] = "Knuckle_FL", [wheelFL] = "Wheel_FL" }
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
local p0, p1, kind
|
||||||
|
if d:IsA("WeldConstraint") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, "WeldConstraint"
|
||||||
|
elseif d:IsA("Weld") or d:IsA("Motor6D") then
|
||||||
|
p0, p1, kind = d.Part0, d.Part1, d.ClassName
|
||||||
|
elseif d:IsA("Constraint") then
|
||||||
|
local a0, a1 = d.Attachment0, d.Attachment1
|
||||||
|
p0 = a0 and a0.Parent or nil
|
||||||
|
p1 = a1 and a1.Parent or nil
|
||||||
|
kind = d.ClassName .. "/" .. d.Name
|
||||||
|
end
|
||||||
|
if p0 and p1 and (targets[p0] or targets[p1]) then
|
||||||
|
log(string.format(" %-28s %s <-> %s", kind, p0.Name, p1.Name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Servo runtime state ---------------------------------------------------
|
||||||
|
log("")
|
||||||
|
log("4. SERVO RUNTIME STATE:")
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %s Enabled=%s Active=%s Actuator=%s Torque=%.0e Speed=%.1f Limits=%s [%.1f..%.1f] Target=%.1f Current=%.2f",
|
||||||
|
d.Parent.Name,
|
||||||
|
tostring(d.Enabled),
|
||||||
|
tostring(d.Active),
|
||||||
|
d.ActuatorType.Name,
|
||||||
|
d.ServoMaxTorque,
|
||||||
|
d.AngularSpeed,
|
||||||
|
tostring(d.LimitsEnabled),
|
||||||
|
d.LowerAngle,
|
||||||
|
d.UpperAngle,
|
||||||
|
d.TargetAngle,
|
||||||
|
d.CurrentAngle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer4.luau -- isolate the real constraint on steering.
|
||||||
|
|
||||||
|
Prior runs had a CONFOUND: every hoisted/static test commanded exactly
|
||||||
|
-MaxSteerAngle, which is exactly the servo's LowerAngle limit. Sitting a
|
||||||
|
servo precisely on its own limit can deadlock it. The isolated 2-part
|
||||||
|
servo (target -30, limits +/-35) worked perfectly, so the API is fine.
|
||||||
|
|
||||||
|
This matrix commands -20 deg (well INSIDE the +/-35.08 limits) and varies
|
||||||
|
one thing at a time.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(3000, 8, 3000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local LIMIT = tuning.MaxSteerAngle
|
||||||
|
local INSIDE = -20 -- comfortably inside +/-35.08
|
||||||
|
|
||||||
|
local function build(y)
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, y, 0))
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function servosOf(model)
|
||||||
|
local servos, motors = {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run one case: returns the CurrentAngle trace.
|
||||||
|
local function runCase(label, opts)
|
||||||
|
local m = build(opts.hoist and (padTop + 40) or (padTop + 3))
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local servos, motors = servosOf(m)
|
||||||
|
|
||||||
|
if opts.hoist then
|
||||||
|
body.Anchored = true
|
||||||
|
end
|
||||||
|
if opts.noWheel then
|
||||||
|
for _, d in ipairs(m:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
d:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if opts.motorOff then
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.Enabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if opts.noLimits then
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.LimitsEnabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts.speed then
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * opts.speed
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = opts.target
|
||||||
|
end
|
||||||
|
|
||||||
|
local trace = {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
if opts.speed then
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = opts.target
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if step % 24 == 0 then
|
||||||
|
trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local final = servos[1].CurrentAngle
|
||||||
|
local ok = math.abs(final - opts.target) < 5
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-42s target %6.1f -> [%s] final %6.2f %s",
|
||||||
|
label,
|
||||||
|
opts.target,
|
||||||
|
table.concat(trace, " "),
|
||||||
|
final,
|
||||||
|
ok and "*** TURNS ***" or "stuck"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(
|
||||||
|
string.format("limits +/-%.2f deg | INSIDE target %.1f | AngularSpeed %.1f rad/s", LIMIT, INSIDE, tuning.SteerSpeed)
|
||||||
|
)
|
||||||
|
log("")
|
||||||
|
|
||||||
|
runCase("A1 hoisted, target INSIDE limits", { hoist = true, target = INSIDE })
|
||||||
|
runCase("A2 hoisted, target AT limit", { hoist = true, target = -LIMIT })
|
||||||
|
runCase("A3 hoisted, LimitsEnabled=false", { hoist = true, target = INSIDE, noLimits = true })
|
||||||
|
runCase("A4 hoisted, wheels removed", { hoist = true, target = INSIDE, noWheel = true })
|
||||||
|
runCase("A5 hoisted, wheel motors disabled", { hoist = true, target = INSIDE, motorOff = true })
|
||||||
|
log("")
|
||||||
|
runCase("B1 ground static, target INSIDE", { target = INSIDE })
|
||||||
|
runCase("B2 ground static, motors disabled", { target = INSIDE, motorOff = true })
|
||||||
|
log("")
|
||||||
|
runCase("C1 ground coasting @60, INSIDE", { target = INSIDE, speed = 60 })
|
||||||
|
runCase("C2 ground coasting @120, INSIDE", { target = INSIDE, speed = 120 })
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
--[[
|
||||||
|
diag_steer5.luau -- find a FIX for the servo/motor conflict.
|
||||||
|
|
||||||
|
Established by diag_steer4: an ENABLED WheelMotor hinge locks the knuckle
|
||||||
|
so the steer servo cannot yaw it. Disabling the motor (or deleting the
|
||||||
|
wheel) restores perfect tracking (-19.99 of -20.0). MotorMaxTorque is 0 at
|
||||||
|
build time, so this is NOT a torque contest -- it is a degenerate
|
||||||
|
constraint configuration.
|
||||||
|
|
||||||
|
Suspicion: Hub, Knuckle and Wheel are all built at the SAME CFrame, and
|
||||||
|
the motor's two attachments are both identity CFrames, so every attachment
|
||||||
|
sits at the same world point. Two hinges sharing a point with perpendicular
|
||||||
|
axes is ill-conditioned for the solver.
|
||||||
|
|
||||||
|
Each variant below changes ONE thing and re-measures.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Size = Vector3.new(3000, 8, 3000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
local car = Cars.Get("gtr_r34")
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
local TARGET = -20
|
||||||
|
|
||||||
|
local function parts(model)
|
||||||
|
local servos, motors, wheels, knuckles = {}, {}, {}, {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") then
|
||||||
|
if d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
elseif d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
elseif d:IsA("BasePart") then
|
||||||
|
if d.Name:sub(1, 6) == "Wheel_" then
|
||||||
|
table.insert(wheels, d)
|
||||||
|
elseif d.Name:sub(1, 8) == "Knuckle_" then
|
||||||
|
table.insert(knuckles, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return servos, motors, wheels, knuckles
|
||||||
|
end
|
||||||
|
|
||||||
|
-- mutate: applied after build, before settling. drive: applied after settling.
|
||||||
|
local function variant(label, mutate, drive)
|
||||||
|
local m = CarManager._buildForDiag(car, tuning)
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, padTop + 3, 0))
|
||||||
|
local servos, motors, wheels, knuckles = parts(m)
|
||||||
|
if mutate then
|
||||||
|
mutate(servos, motors, wheels, knuckles, m)
|
||||||
|
end
|
||||||
|
for _ = 1, 60 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
body.AssemblyLinearVelocity = body.CFrame.LookVector * 80
|
||||||
|
if drive then
|
||||||
|
drive(servos, motors, wheels, knuckles, m)
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = TARGET
|
||||||
|
end
|
||||||
|
local trace, yaw = {}, {}
|
||||||
|
for step = 1, 120 do
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = TARGET
|
||||||
|
end
|
||||||
|
if step % 24 == 0 then
|
||||||
|
trace[#trace + 1] = string.format("%.1f", servos[1].CurrentAngle)
|
||||||
|
yaw[#yaw + 1] = string.format("%.2f", body.AssemblyAngularVelocity.Y)
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local final = servos[1].CurrentAngle
|
||||||
|
local ok = math.abs(final - TARGET) < 5
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-40s [%s] final %6.2f yaw[%s] %s",
|
||||||
|
label,
|
||||||
|
table.concat(trace, " "),
|
||||||
|
final,
|
||||||
|
table.concat(yaw, " "),
|
||||||
|
ok and "*** STEERS ***" or "stuck"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("target %.1f deg, rolling at 80 studs/s, motors at build-default torque", TARGET))
|
||||||
|
log("")
|
||||||
|
|
||||||
|
variant("V0 baseline (unmodified)", nil, nil)
|
||||||
|
|
||||||
|
variant("V1 motor ActuatorType = None", function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.ActuatorType = Enum.ActuatorType.None
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V2 motor attachments offset 0.01 X", function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.Attachment0.CFrame = mo.Attachment0.CFrame * CFrame.new(0.01, 0, 0)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V3 knuckle mass x20 (density)", function(_, _, _, knuckles)
|
||||||
|
for _, k in ipairs(knuckles) do
|
||||||
|
k.CustomPhysicalProperties = PhysicalProperties.new(20, 0.3, 0.5, 1, 1)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V4 wheel Massless=true", function(_, _, wheels)
|
||||||
|
for _, w in ipairs(wheels) do
|
||||||
|
w.Massless = true
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V5 servo torque 1e9 + speed 30", function(servos)
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = 1e9
|
||||||
|
s.AngularSpeed = 30
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V6 motor torque 0 held every frame", nil, function(_, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.MotorMaxTorque = 0
|
||||||
|
mo.AngularVelocity = 0
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V7 front motors disabled only", function(_, motors)
|
||||||
|
-- rear-wheel drive: only the steered corners freewheel
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
local n = mo.Parent and mo.Parent.Name or ""
|
||||||
|
if n:find("F") then
|
||||||
|
mo.Enabled = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
variant("V8 V1 + V5 combined", function(servos, motors)
|
||||||
|
for _, mo in ipairs(motors) do
|
||||||
|
mo.ActuatorType = Enum.ActuatorType.None
|
||||||
|
end
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.ServoMaxTorque = 1e9
|
||||||
|
s.AngularSpeed = 30
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
--[[
|
||||||
|
diag_steering.luau -- measure WHY the car will not turn.
|
||||||
|
|
||||||
|
Builds a real car through CarManager, gives it a straight test surface,
|
||||||
|
then drives it at full lock and measures what actually happens:
|
||||||
|
* masses (body vs unsprung vs ballast)
|
||||||
|
* servo TargetAngle vs CurrentAngle under load
|
||||||
|
* achieved yaw rate and turning radius vs the Ackermann prediction
|
||||||
|
* lateral slip at each wheel
|
||||||
|
Everything is measured in-engine; nothing here is assumed.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local Server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
local CarManager = require(Server.CarManager)
|
||||||
|
|
||||||
|
-- Flat, generous test pad well away from the map.
|
||||||
|
local pad = Instance.new("Part")
|
||||||
|
pad.Name = "DiagPad"
|
||||||
|
pad.Size = Vector3.new(4000, 8, 4000)
|
||||||
|
pad.Position = Vector3.new(0, 5000, 0)
|
||||||
|
pad.Anchored = true
|
||||||
|
pad.Material = Enum.Material.Concrete
|
||||||
|
pad.Parent = workspace
|
||||||
|
local padTop = pad.Position.Y + 4
|
||||||
|
|
||||||
|
log(
|
||||||
|
"pad friction:",
|
||||||
|
string.format("%.2f", pad.CurrentPhysicalProperties.Friction),
|
||||||
|
" frictionWeight:",
|
||||||
|
string.format("%.2f", pad.CurrentPhysicalProperties.FrictionWeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
local carId = "gtr_r34"
|
||||||
|
local car = Cars.Get(carId)
|
||||||
|
local tuning = Chassis.tuningFor(car)
|
||||||
|
log("car:", carId, "Handling:", car.Stats.Handling, "Speed:", car.Stats.Speed)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"tuning MaxSteerAngle %.1f deg, SteerSpeed %.1f, WheelFriction %.2f, MaxSpeed %.0f",
|
||||||
|
tuning.MaxSteerAngle,
|
||||||
|
tuning.SteerSpeed,
|
||||||
|
tuning.WheelFriction,
|
||||||
|
tuning.MaxSpeed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Build the car exactly as the game does.
|
||||||
|
local model = CarManager._buildForDiag and CarManager._buildForDiag(car, tuning) or nil
|
||||||
|
if not model then
|
||||||
|
-- fall back: use the public spawn path into the pad area
|
||||||
|
log("NOTE: no diag hook; using CarManager.SpawnCarAt if present")
|
||||||
|
end
|
||||||
|
if not model then
|
||||||
|
log("FATAL: cannot build a car model headlessly -- need a build hook")
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
model.Parent = workspace
|
||||||
|
model:PivotTo(CFrame.new(0, padTop + 4, 0))
|
||||||
|
|
||||||
|
local body = model.PrimaryPart
|
||||||
|
local function findAll(name)
|
||||||
|
local t = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d.Name == name or (d:IsA("BasePart") and d.Name:sub(1, #name) == name) then
|
||||||
|
table.insert(t, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- MASS BREAKDOWN -----------------------------------------------------------
|
||||||
|
local total, sprung, unsprung = 0, 0, 0
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and not d.Massless then
|
||||||
|
local m = d:GetMass()
|
||||||
|
total += m
|
||||||
|
if d.Name:sub(1, 5) == "Wheel" or d.Name:sub(1, 3) == "Hub" or d.Name:sub(1, 7) == "Knuckle" then
|
||||||
|
unsprung += m
|
||||||
|
else
|
||||||
|
sprung += m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"MASS total %.1f | sprung %.1f | unsprung %.1f (%.0f%% unsprung)",
|
||||||
|
total,
|
||||||
|
sprung,
|
||||||
|
unsprung,
|
||||||
|
100 * unsprung / math.max(total, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
local wheels = findAll("Wheel_")
|
||||||
|
local servos = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.STEER_SERVO_NAME then
|
||||||
|
table.insert(servos, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local motors = {}
|
||||||
|
for _, d in ipairs(model:GetDescendants()) do
|
||||||
|
if d:IsA("HingeConstraint") and d.Name == Chassis.WHEEL_MOTOR_NAME then
|
||||||
|
table.insert(motors, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log("found", #wheels, "wheels,", #servos, "servos,", #motors, "motors")
|
||||||
|
if #wheels > 0 then
|
||||||
|
local w = wheels[1]
|
||||||
|
local p = w.CurrentPhysicalProperties
|
||||||
|
log(string.format("wheel friction %.2f weight %.2f | mass %.2f", p.Friction, p.FrictionWeight, w:GetMass()))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Let it settle on the pad.
|
||||||
|
for _ = 1, 90 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
log(string.format("settled: body Y %.2f, vel %.2f", body.Position.Y, body.AssemblyLinearVelocity.Magnitude))
|
||||||
|
|
||||||
|
-- DRIVE TEST: full throttle, full right lock --------------------------------
|
||||||
|
local samples = {}
|
||||||
|
local t0 = os.clock()
|
||||||
|
local startCF = body.CFrame
|
||||||
|
local maxServoErr, servoErrSum, servoN = 0, 0, 0
|
||||||
|
|
||||||
|
for step = 1, 300 do
|
||||||
|
local speed = body.AssemblyLinearVelocity.Magnitude
|
||||||
|
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1)
|
||||||
|
local lockScale = 1 - 0.6 * speedFrac
|
||||||
|
local targetAngle = Chassis.STEER_SIGN * 1 * tuning.MaxSteerAngle * lockScale
|
||||||
|
for _, s in ipairs(servos) do
|
||||||
|
s.TargetAngle = targetAngle
|
||||||
|
local err = math.abs(s.CurrentAngle - targetAngle)
|
||||||
|
maxServoErr = math.max(maxServoErr, err)
|
||||||
|
servoErrSum += err
|
||||||
|
servoN += 1
|
||||||
|
end
|
||||||
|
for _, m in ipairs(motors) do
|
||||||
|
m.AngularVelocity = Chassis.DRIVE_SIGN * tuning.MaxWheelAngularVelocity
|
||||||
|
m.MotorMaxTorque = tuning.MotorMaxTorque
|
||||||
|
end
|
||||||
|
if step % 30 == 0 then
|
||||||
|
local yawRate = body.AssemblyAngularVelocity.Y
|
||||||
|
table.insert(samples, {
|
||||||
|
t = os.clock() - t0,
|
||||||
|
speed = speed,
|
||||||
|
yaw = yawRate,
|
||||||
|
target = targetAngle,
|
||||||
|
cur = #servos > 0 and servos[1].CurrentAngle or 0,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("SERVO tracking: mean err %.2f deg, max %.2f deg", servoErrSum / math.max(servoN, 1), maxServoErr))
|
||||||
|
log("t(s) speed yawRate targetAng curAng radius")
|
||||||
|
for _, s in ipairs(samples) do
|
||||||
|
local radius = math.abs(s.yaw) > 1e-4 and (s.speed / math.abs(s.yaw)) or math.huge
|
||||||
|
log(string.format("%4.1f %6.1f %7.3f %8.1f %7.1f %8.1f", s.t, s.speed, s.yaw, s.target, s.cur, radius))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ackermann prediction for comparison.
|
||||||
|
local wheelbase = Chassis.WHEELBASE_HALF * 2
|
||||||
|
local last = samples[#samples]
|
||||||
|
if last then
|
||||||
|
local predicted = wheelbase / math.tan(math.rad(math.abs(last.cur)))
|
||||||
|
local actual = math.abs(last.yaw) > 1e-4 and (last.speed / math.abs(last.yaw)) or math.huge
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"wheelbase %.1f studs | Ackermann radius at %.1f deg = %.1f studs | ACTUAL %.1f studs",
|
||||||
|
wheelbase,
|
||||||
|
math.abs(last.cur),
|
||||||
|
predicted,
|
||||||
|
actual
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log(string.format("UNDERSTEER FACTOR: %.2fx (1.0 = neutral, >2 = plough)", actual / math.max(predicted, 0.01)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Net heading change over the run.
|
||||||
|
local dot = startCF.LookVector:Dot(body.CFrame.LookVector)
|
||||||
|
log(string.format("heading change over run: %.1f deg", math.deg(math.acos(math.clamp(dot, -1, 1)))))
|
||||||
|
|
||||||
|
model:Destroy()
|
||||||
|
pad:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
--[[
|
||||||
|
diag_wallmap.luau -- export every wall/barrier part near the PA as JSON so
|
||||||
|
it can be plotted top-down on the workstation.
|
||||||
|
|
||||||
|
Screen capture of the Studio viewport is unavailable on this machine
|
||||||
|
(Vinegar/Wine on a nested X display, DXVK renders the viewport so XGetImage
|
||||||
|
returns black), so instead of a screenshot we dump geometry and draw the
|
||||||
|
map ourselves. For "are there gaps / is it jumbled" questions a top-down
|
||||||
|
plot is strictly better than a screenshot anyway: it is to scale and every
|
||||||
|
part is labelled.
|
||||||
|
|
||||||
|
Read-only. Builds nothing, changes nothing.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Workspace = game:GetService("Workspace")
|
||||||
|
|
||||||
|
local WALL_NAMES = {
|
||||||
|
Barrier = true,
|
||||||
|
EdgeRail = true,
|
||||||
|
KillWall = true,
|
||||||
|
PARingWall = true,
|
||||||
|
PARingRail = true,
|
||||||
|
Median = true,
|
||||||
|
MedianFence = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function classify(name)
|
||||||
|
if WALL_NAMES[name] then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
-- Median_12 / Road_37 style suffixed names
|
||||||
|
local base = name:match("^([%a]+)_%d+$")
|
||||||
|
if base and WALL_NAMES[base] then
|
||||||
|
return base
|
||||||
|
end
|
||||||
|
if name:find("^Road") or name:find("^PARing$") or name == "PARing" then
|
||||||
|
return "Road"
|
||||||
|
end
|
||||||
|
if name == "PAOnRamp" or name == "PAOffRamp" or name == "PARampFlare" then
|
||||||
|
return "Ramp"
|
||||||
|
end
|
||||||
|
if name == "PAApron" or name:find("Apron") then
|
||||||
|
return "Apron"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local counts = {}
|
||||||
|
|
||||||
|
-- The bridge truncates payloads at ~100k chars, so only export parts near the
|
||||||
|
-- PA -- that is the region under investigation anyway. Counts are still taken
|
||||||
|
-- over the WHOLE map so nothing is hidden by the filter.
|
||||||
|
local PA = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared"):WaitForChild("Circuit")).PA_CENTER
|
||||||
|
local NEAR = 900
|
||||||
|
|
||||||
|
for _, d in ipairs(Workspace:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local kind = classify(d.Name)
|
||||||
|
if kind then
|
||||||
|
counts[kind] = (counts[kind] or 0) + 1
|
||||||
|
local p = d.Position
|
||||||
|
local dist = math.sqrt((p.X - PA.X) ^ 2 + (p.Z - PA.Z) ^ 2)
|
||||||
|
if dist <= NEAR then
|
||||||
|
table.insert(out, {
|
||||||
|
k = kind,
|
||||||
|
x = math.floor(p.X * 10) / 10,
|
||||||
|
y = math.floor(p.Y * 10) / 10,
|
||||||
|
z = math.floor(p.Z * 10) / 10,
|
||||||
|
sx = math.floor(d.Size.X * 10) / 10,
|
||||||
|
sz = math.floor(d.Size.Z * 10) / 10,
|
||||||
|
r = math.floor(math.deg(select(2, d.CFrame:ToEulerAnglesYXZ())) * 10) / 10,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local summary = {}
|
||||||
|
for k, v in pairs(counts) do
|
||||||
|
table.insert(summary, string.format("%s=%d", k, v))
|
||||||
|
end
|
||||||
|
table.sort(summary)
|
||||||
|
|
||||||
|
-- execute_luau returns the RETURN VALUE, not stdout -- prints are discarded by
|
||||||
|
-- the bridge, so hand the payload back directly.
|
||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
return HttpService:JSONEncode({
|
||||||
|
counts = table.concat(summary, " "),
|
||||||
|
total = #out,
|
||||||
|
parts = out,
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
--[[
|
||||||
|
diag_whatami.luau -- answer three questions the user's report raises, with
|
||||||
|
no assumptions:
|
||||||
|
|
||||||
|
Q1. Is the module in THIS DataModel the source I edited on disk?
|
||||||
|
(compare length + marker strings; disk file is 41650 bytes and
|
||||||
|
contains SHELL_YAW_OVERRIDE and bestVol)
|
||||||
|
|
||||||
|
Q2. Is the require() cache still pinned to pre-push code?
|
||||||
|
(_buildForDiag present in source but nil after require == pinned,
|
||||||
|
meaning NO fix can be in effect yet this session)
|
||||||
|
|
||||||
|
Q3. Are the cars the user is looking at STALE STAGED PROPS?
|
||||||
|
workspace.ShellReview was populated by tools/shot_shells.luau in an
|
||||||
|
earlier run. Those models are a frozen snapshot -- they are inert
|
||||||
|
Instances, not live rebuilds, so they will look identical forever
|
||||||
|
regardless of what CarManager says. Report whether ShellReview
|
||||||
|
exists, what is in it, and how each car is oriented RIGHT NOW so we
|
||||||
|
can tell "stale props" from "fix genuinely not working".
|
||||||
|
|
||||||
|
For Q3 the facing is read from the staged model directly: compare the mean
|
||||||
|
Z of the F* wheel groups against the R* groups in the model's own frame.
|
||||||
|
Front should sit at NEGATIVE Z (chassis convention: front axle built at
|
||||||
|
Z = -WHEELBASE/2). If front is at +Z the car is backwards.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ Q1 + Q2
|
||||||
|
log("=== Q1: is the live module my edited source? ===")
|
||||||
|
local mods = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
mods[#mods + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format(" instances: %d", #mods))
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
local src = ""
|
||||||
|
pcall(function()
|
||||||
|
src = m.Source
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %s len=%d (disk 41650) SHELL_YAW_OVERRIDE=%s bestVol=%s",
|
||||||
|
m:GetFullName(),
|
||||||
|
#src,
|
||||||
|
tostring(src:find("SHELL_YAW_OVERRIDE", 1, true) ~= nil),
|
||||||
|
tostring(src:find("bestVol", 1, true) ~= nil)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=== Q2: is require() still pinned to old code? ===")
|
||||||
|
if #mods == 1 then
|
||||||
|
local ok, mod = pcall(require, mods[1])
|
||||||
|
if ok and typeof(mod) == "table" then
|
||||||
|
local fresh = typeof(mod._buildForDiag) == "function"
|
||||||
|
log(" _buildForDiag after require: " .. typeof(mod._buildForDiag))
|
||||||
|
log(
|
||||||
|
fresh and " => NEW code is live; fixes CAN take effect"
|
||||||
|
or " => STILL PINNED to pre-push code; NO fix can be in effect yet"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------- Q3
|
||||||
|
log("")
|
||||||
|
log("=== Q3: what is actually sitting in workspace.ShellReview? ===")
|
||||||
|
local review = workspace:FindFirstChild("ShellReview")
|
||||||
|
if not review then
|
||||||
|
log(" ShellReview does NOT exist -- user must be looking at something else")
|
||||||
|
log(" (spawned cars in-game, or the templates in ReplicatedStorage)")
|
||||||
|
else
|
||||||
|
log(" ShellReview EXISTS with " .. #review:GetChildren() .. " children.")
|
||||||
|
log(" These are FROZEN PROPS from an earlier shot_shells.luau run.")
|
||||||
|
log(" They do not rebuild themselves -- code fixes cannot change them.")
|
||||||
|
log("")
|
||||||
|
log(string.format(" %-14s %10s %10s %s", "model", "frontZ", "rearZ", "verdict"))
|
||||||
|
for _, m in ipairs(review:GetChildren()) do
|
||||||
|
if m:IsA("Model") then
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if not wg then
|
||||||
|
local f = m:FindFirstChild("Wheels", true)
|
||||||
|
wg = f
|
||||||
|
end
|
||||||
|
if wg then
|
||||||
|
local pivot = m:GetPivot()
|
||||||
|
local inv = pivot:Inverse()
|
||||||
|
local fS, fN, rS, rN = 0, 0, 0, 0
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local sum, n = Vector3.zero, 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
sum, n = w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
sum, n = sum + d.Position, n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n > 0 then
|
||||||
|
local z = (inv * (sum / n)).Z
|
||||||
|
local head = w.Name:sub(1, 1):upper()
|
||||||
|
if head == "F" then
|
||||||
|
fS, fN = fS + z, fN + 1
|
||||||
|
elseif head == "R" then
|
||||||
|
rS, rN = rS + z, rN + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fN > 0 and rN > 0 then
|
||||||
|
local fz, rz = fS / fN, rS / rN
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" %-14s %10.3f %10.3f %s",
|
||||||
|
m.Name,
|
||||||
|
fz,
|
||||||
|
rz,
|
||||||
|
fz < rz and "front at -Z OK" or "front at +Z BACKWARDS"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
--[[
|
||||||
|
diag_wheelroot.luau -- two suspected bugs in the shell wheel re-hang
|
||||||
|
(CarManager ~line 585-641).
|
||||||
|
|
||||||
|
(1) ROOT CHOICE. Line 589 picks
|
||||||
|
root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
For a wheel group that is a Model, FindFirstChildWhichIsA returns an
|
||||||
|
ARBITRARY first BasePart -- a rotor, caliper or hub, not necessarily
|
||||||
|
the tyre. Line 621 then does delta = phys.Position - root.Position and
|
||||||
|
shifts the whole group, making THAT part concentric with the axle. If
|
||||||
|
root is not the tyre centre, the visible tyre is displaced by the
|
||||||
|
root-to-tyre offset. User reports rx7_fc wheels "sticking out from the
|
||||||
|
side of the car", which is this signature.
|
||||||
|
|
||||||
|
(2) SURVIVING OUTSIDE WELDS. Line 605 only scans shell:GetDescendants()
|
||||||
|
for welds to cut. Any weld parented OUTSIDE the shell (on the model,
|
||||||
|
on body, etc.) that ties a wheel part to the bodywork survives, which
|
||||||
|
would rigidly glue the wheel to the chassis and pin the knuckle --
|
||||||
|
the measured steering lock.
|
||||||
|
|
||||||
|
Reports, per wheel group: which part was chosen as root, how far that root
|
||||||
|
sits from the group's true centre, and any surviving weld from the group
|
||||||
|
to a part outside it.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
local Cars = require(Shared.Cars)
|
||||||
|
local Chassis = require(Shared.Chassis)
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
local cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
local CarManager = require(cm)
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- template
|
||||||
|
log("=== TEMPLATE wheel groups: what does line 589 pick as root? ===")
|
||||||
|
log(string.format("%-12s %-5s %-16s %8s %8s", "car", "grp", "root picked", "off|X|", "nParts"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local wg = t:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
-- true centre of the group = AABB centre of all its parts
|
||||||
|
local lo = Vector3.new(math.huge, math.huge, math.huge)
|
||||||
|
local hi = -lo
|
||||||
|
local n = 0
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
lo, hi, n = w.Position, w.Position, 1
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
lo = Vector3.new(
|
||||||
|
math.min(lo.X, d.Position.X),
|
||||||
|
math.min(lo.Y, d.Position.Y),
|
||||||
|
math.min(lo.Z, d.Position.Z)
|
||||||
|
)
|
||||||
|
hi = Vector3.new(
|
||||||
|
math.max(hi.X, d.Position.X),
|
||||||
|
math.max(hi.Y, d.Position.Y),
|
||||||
|
math.max(hi.Z, d.Position.Z)
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if root and n > 0 then
|
||||||
|
local centre = (lo + hi) / 2
|
||||||
|
local off = root.Position - centre
|
||||||
|
log(string.format("%-12s %-5s %-16s %8.3f %8d", t.Name, w.Name, root.Name, math.abs(off.X), n))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- built
|
||||||
|
log("")
|
||||||
|
log("=== BUILT cars: tyre alignment + surviving outside welds ===")
|
||||||
|
log("tyreErrX = how far the widest shell wheel part sits from its physics")
|
||||||
|
log("cylinder along the car's lateral axis. Should be ~0 on every corner.")
|
||||||
|
log("")
|
||||||
|
log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds tying group to OUTSIDE"))
|
||||||
|
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local car = Cars.Get(t.Name)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 4 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local phys = m:FindFirstChild("Wheel_" .. w.Name:upper())
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[1], inGroup[w] = w, true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and #group > 0 then
|
||||||
|
-- widest part = the tyre; compare its |X| to the cylinder's
|
||||||
|
local physX = (inv * phys.Position).X
|
||||||
|
local best, bestW = nil, -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local vol = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if vol > bestW then
|
||||||
|
best, bestW = p, vol
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local errX = (inv * best.Position).X - physX
|
||||||
|
|
||||||
|
-- any weld from this group to something outside it,
|
||||||
|
-- searched across the WHOLE model, not just the shell
|
||||||
|
local bad = {}
|
||||||
|
for _, c in ipairs(m:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn = a ~= nil and inGroup[a] or false
|
||||||
|
local bIn = b ~= nil and inGroup[b] or false
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
local other = aIn and b or a
|
||||||
|
local oname = other and other.Name or "nil"
|
||||||
|
-- weld to its own physics cylinder is CORRECT
|
||||||
|
if oname ~= ("Wheel_" .. w.Name:upper()) then
|
||||||
|
bad[#bad + 1] = oname .. "(" .. c.Parent.Name .. ")"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %9.3f %s",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
errX,
|
||||||
|
#bad > 0 and table.concat(bad, ", ") or "-none-"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cm:Destroy()
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
--[[
|
||||||
|
diag_wheelroot2.luau -- same investigation as diag_wheelroot, but wrapped
|
||||||
|
so a failure reports WHERE. The first attempt died with a bare "attempt to
|
||||||
|
call a nil value" and no line, which tells us nothing; both _buildForDiag
|
||||||
|
(CarManager:988) and Chassis.tuningFor (Chassis:158) are confirmed to
|
||||||
|
exist, so the nil is something else in the script.
|
||||||
|
|
||||||
|
Runs each stage under pcall + traceback and prints progress markers, so
|
||||||
|
the last marker printed localises the fault even if the traceback is
|
||||||
|
swallowed by the executor.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stage(name, fn)
|
||||||
|
local ok, err = xpcall(fn, function(e)
|
||||||
|
return tostring(e) .. "\n" .. debug.traceback("", 2)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
log("!! FAILED at stage: " .. name)
|
||||||
|
log(err)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
log(" ok: " .. name)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local Shared, Cars, Chassis, CarManager, cm
|
||||||
|
|
||||||
|
stage("require shared", function()
|
||||||
|
Shared = ReplicatedStorage:FindFirstChild("Shared")
|
||||||
|
Cars = require(Shared.Cars)
|
||||||
|
Chassis = require(Shared.Chassis)
|
||||||
|
end)
|
||||||
|
|
||||||
|
stage("clone+require CarManager", function()
|
||||||
|
local orig = ServerScriptService:FindFirstChild("Server", true):FindFirstChild("CarManager")
|
||||||
|
cm = orig:Clone()
|
||||||
|
cm.Parent = orig.Parent
|
||||||
|
CarManager = require(cm)
|
||||||
|
assert(typeof(CarManager._buildForDiag) == "function", "_buildForDiag is " .. typeof(CarManager._buildForDiag))
|
||||||
|
assert(typeof(Chassis.tuningFor) == "function", "tuningFor is " .. typeof(Chassis.tuningFor))
|
||||||
|
assert(typeof(Cars.Get) == "function", "Cars.Get is " .. typeof(Cars.Get))
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ------------------------------------------------- stage 1: template roots
|
||||||
|
stage("template root pick", function()
|
||||||
|
log("")
|
||||||
|
log("=== TEMPLATE: what does line 589 pick as root? ===")
|
||||||
|
log(string.format("%-12s %-5s %-18s %8s %7s", "car", "grp", "root picked", "offX", "nParts"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local wg = t:FindFirstChild("Wheels")
|
||||||
|
if wg then
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
|
||||||
|
local parts = {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
parts[1] = w
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
parts[#parts + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if root and #parts > 0 then
|
||||||
|
local lo, hi = parts[1].Position, parts[1].Position
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
lo = Vector3.new(
|
||||||
|
math.min(lo.X, p.Position.X),
|
||||||
|
math.min(lo.Y, p.Position.Y),
|
||||||
|
math.min(lo.Z, p.Position.Z)
|
||||||
|
)
|
||||||
|
hi = Vector3.new(
|
||||||
|
math.max(hi.X, p.Position.X),
|
||||||
|
math.max(hi.Y, p.Position.Y),
|
||||||
|
math.max(hi.Z, p.Position.Z)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
local centre = (lo + hi) / 2
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %-18s %8.3f %7d",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
root.Name,
|
||||||
|
math.abs(root.Position.X - centre.X),
|
||||||
|
#parts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- ------------------------------------------------- stage 2: built cars
|
||||||
|
stage("built car check", function()
|
||||||
|
log("")
|
||||||
|
log("=== BUILT: tyre offset from its physics cylinder + stray welds ===")
|
||||||
|
log(string.format("%-12s %-5s %9s %s", "car", "grp", "tyreErrX", "welds to OUTSIDE the group"))
|
||||||
|
for _, t in ipairs(ReplicatedStorage.CarModels:GetChildren()) do
|
||||||
|
local car = Cars.Get(t.Name)
|
||||||
|
if car then
|
||||||
|
local m = CarManager._buildForDiag(car, Chassis.tuningFor(car))
|
||||||
|
m.Parent = workspace
|
||||||
|
m:PivotTo(CFrame.new(0, 5010, 0))
|
||||||
|
for _ = 1, 4 do
|
||||||
|
RunService.Heartbeat:Wait()
|
||||||
|
end
|
||||||
|
local body = m.PrimaryPart
|
||||||
|
local shell = m:FindFirstChild("Shell")
|
||||||
|
local wg = shell and shell:FindFirstChild("Wheels")
|
||||||
|
if wg and body then
|
||||||
|
local inv = body.CFrame:Inverse()
|
||||||
|
for _, w in ipairs(wg:GetChildren()) do
|
||||||
|
local physName = "Wheel_" .. w.Name:upper()
|
||||||
|
local phys = m:FindFirstChild(physName)
|
||||||
|
local group, inGroup = {}, {}
|
||||||
|
if w:IsA("BasePart") then
|
||||||
|
group[1], inGroup[w] = w, true
|
||||||
|
end
|
||||||
|
for _, d in ipairs(w:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
group[#group + 1] = d
|
||||||
|
inGroup[d] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if phys and #group > 0 then
|
||||||
|
local physX = (inv * phys.Position).X
|
||||||
|
local best, bestV = group[1], -1
|
||||||
|
for _, p in ipairs(group) do
|
||||||
|
local v = p.Size.X * p.Size.Y * p.Size.Z
|
||||||
|
if v > bestV then
|
||||||
|
best, bestV = p, v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local errX = (inv * best.Position).X - physX
|
||||||
|
local bad = {}
|
||||||
|
for _, c in ipairs(m:GetDescendants()) do
|
||||||
|
if c:IsA("WeldConstraint") or c:IsA("Weld") then
|
||||||
|
local a, b = c.Part0, c.Part1
|
||||||
|
local aIn = (a ~= nil) and inGroup[a] or false
|
||||||
|
local bIn = (b ~= nil) and inGroup[b] or false
|
||||||
|
if (aIn or bIn) and not (aIn and bIn) then
|
||||||
|
local other = aIn and b or a
|
||||||
|
local oname = other and other.Name or "nil"
|
||||||
|
if oname ~= physName then
|
||||||
|
bad[#bad + 1] = oname
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
"%-12s %-5s %9.3f %s",
|
||||||
|
t.Name,
|
||||||
|
w.Name,
|
||||||
|
errX,
|
||||||
|
#bad > 0 and table.concat(bad, ", ") or "-none-"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
m:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if cm then
|
||||||
|
cm:Destroy()
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Export a top-down plan of the PA area as JSON-ish text so it can be
|
||||||
|
-- plotted offline, and measure the median-barrier opening properly by
|
||||||
|
-- looking for gaps along the mainline centreline near each junction.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
local PA = Vector3.new(-4124, 46, -7470)
|
||||||
|
local RANGE = 2600 -- studs around the PA to export
|
||||||
|
|
||||||
|
local buckets = {
|
||||||
|
EntryTrunk = {},
|
||||||
|
EntryNear = {},
|
||||||
|
ExitTrunk = {},
|
||||||
|
ExitNear = {},
|
||||||
|
Road = {},
|
||||||
|
Barrier = {},
|
||||||
|
Ring = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local p = d.Position
|
||||||
|
if math.abs(p.X - PA.X) < RANGE and math.abs(p.Z - PA.Z) < RANGE then
|
||||||
|
local nm = d.Name
|
||||||
|
local key
|
||||||
|
if nm:sub(1, 18) == "PAEntryRampTrunk_" or nm:sub(1, 17) == "PAEntryRampTrunk_" then
|
||||||
|
key = "EntryTrunk"
|
||||||
|
elseif nm:sub(1, 16) == "PAEntryRampNear_" then
|
||||||
|
key = "EntryNear"
|
||||||
|
elseif nm:sub(1, 16) == "PAExitRampTrunk_" then
|
||||||
|
key = "ExitTrunk"
|
||||||
|
elseif nm:sub(1, 15) == "PAExitRampNear_" then
|
||||||
|
key = "ExitNear"
|
||||||
|
elseif nm:gsub("_?%d+$", "") == "Road" then
|
||||||
|
key = "Road"
|
||||||
|
elseif nm:gsub("_?%d+$", "") == "Barrier" then
|
||||||
|
key = "Barrier"
|
||||||
|
elseif nm:find("PARing") then
|
||||||
|
key = "Ring"
|
||||||
|
end
|
||||||
|
if key then
|
||||||
|
table.insert(buckets[key], ("%d,%d,%d"):format(math.floor(p.X), math.floor(p.Z), math.floor(p.Y)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
for k, v in pairs(buckets) do
|
||||||
|
table.insert(out, "#" .. k .. "#" .. table.concat(v, ";"))
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
--[[
|
||||||
|
fix_dupes.luau -- remove leftover CarManager clones and report the truth.
|
||||||
|
|
||||||
|
diag_stale found TWO ModuleScripts at ServerScriptService.Server.CarManager,
|
||||||
|
both len=30631 with no _buildForDiag, while the file on disk is 39591 bytes
|
||||||
|
and defines it. My diagnostic scripts cloned the module and did not always
|
||||||
|
destroy the clone, so duplicates accumulated; require() may bind to either.
|
||||||
|
|
||||||
|
Deletes every CarManager except the one that is a genuine child of the
|
||||||
|
Server folder (keeping the first by index), then reports what remains so
|
||||||
|
the next push has a single unambiguous target.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local ServerScriptService = game:GetService("ServerScriptService")
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local function log(...)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
parts[#parts + 1] = tostring((select(i, ...)))
|
||||||
|
end
|
||||||
|
table.insert(out, table.concat(parts, " "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local server = ServerScriptService:FindFirstChild("Server", true)
|
||||||
|
if not server then
|
||||||
|
return "no Server folder"
|
||||||
|
end
|
||||||
|
|
||||||
|
local mods = {}
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
mods[#mods + 1] = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log(string.format("found %d CarManager module(s)", #mods))
|
||||||
|
for i, m in ipairs(mods) do
|
||||||
|
local len = "?"
|
||||||
|
pcall(function()
|
||||||
|
len = tostring(#m.Source)
|
||||||
|
end)
|
||||||
|
log(
|
||||||
|
string.format(
|
||||||
|
" [%d] %s len=%s parent=%s",
|
||||||
|
i,
|
||||||
|
m:GetFullName(),
|
||||||
|
len,
|
||||||
|
m.Parent and m.Parent:GetFullName() or "nil"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep exactly one: prefer a direct child of the Server folder.
|
||||||
|
local keep = nil
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
if m.Parent == server then
|
||||||
|
keep = m
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
keep = keep or mods[1]
|
||||||
|
|
||||||
|
local removed = 0
|
||||||
|
for _, m in ipairs(mods) do
|
||||||
|
if m ~= keep then
|
||||||
|
m:Destroy()
|
||||||
|
removed += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log(string.format("kept: %s", keep and keep:GetFullName() or "nil"))
|
||||||
|
log(string.format("removed: %d duplicate(s)", removed))
|
||||||
|
|
||||||
|
-- Confirm the survivor
|
||||||
|
local after = 0
|
||||||
|
for _, d in ipairs(ServerScriptService:GetDescendants()) do
|
||||||
|
if d:IsA("ModuleScript") and d.Name == "CarManager" then
|
||||||
|
after += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
log(string.format("remaining CarManager count: %d", after))
|
||||||
|
if keep then
|
||||||
|
local len = "?"
|
||||||
|
pcall(function()
|
||||||
|
len = tostring(#keep.Source)
|
||||||
|
end)
|
||||||
|
log("survivor source length: " .. len .. " (disk file is 39591)")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
-- Measure the redesigned ramps: extent, grade, side (E/W), and collisions
|
||||||
|
-- with STRUCTURAL parts (piers, masts, girders). Read-only.
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
if not map then
|
||||||
|
return "no Map"
|
||||||
|
end
|
||||||
|
|
||||||
|
local PA = Vector3.new(-4124, 46, -7470)
|
||||||
|
|
||||||
|
-- Gather deck parts per run.
|
||||||
|
local runs = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
for _, base in ipairs({ "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }) do
|
||||||
|
if nm:sub(1, #base) == base then
|
||||||
|
runs[base] = runs[base] or {}
|
||||||
|
table.insert(runs[base], d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
local order = { "PAEntryRampTrunk", "PAEntryRampNear", "PAExitRampTrunk", "PAExitRampNear" }
|
||||||
|
for _, base in ipairs(order) do
|
||||||
|
local parts = runs[base]
|
||||||
|
if not parts then
|
||||||
|
table.insert(out, base .. ": MISSING")
|
||||||
|
else
|
||||||
|
local minY, maxY = math.huge, -math.huge
|
||||||
|
local minX, maxX = math.huge, -math.huge
|
||||||
|
local minZ, maxZ = math.huge, -math.huge
|
||||||
|
local maxGrade = 0
|
||||||
|
table.sort(parts, function(a, b)
|
||||||
|
return a.Name < b.Name
|
||||||
|
end)
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
local q = p.Position
|
||||||
|
minY, maxY = math.min(minY, q.Y), math.max(maxY, q.Y)
|
||||||
|
minX, maxX = math.min(minX, q.X), math.max(maxX, q.X)
|
||||||
|
minZ, maxZ = math.min(minZ, q.Z), math.max(maxZ, q.Z)
|
||||||
|
end
|
||||||
|
for i = 2, #parts do
|
||||||
|
local a, b = parts[i - 1].Position, parts[i].Position
|
||||||
|
local horiz = (Vector3.new(a.X, 0, a.Z) - Vector3.new(b.X, 0, b.Z)).Magnitude
|
||||||
|
if horiz > 0.5 then
|
||||||
|
maxGrade = math.max(maxGrade, math.abs(b.Y - a.Y) / horiz * 100)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- mean X relative to PA centre tells us east (+) or west (-)
|
||||||
|
local sx = 0
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
sx += p.Position.X
|
||||||
|
end
|
||||||
|
local side = (sx / #parts) > PA.X and "EAST" or "WEST"
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("%s: n=%d Y=%.0f..%.0f grade<=%.2f%% side=%s X=%.0f..%.0f Z=%.0f..%.0f"):format(
|
||||||
|
base,
|
||||||
|
#parts,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
maxGrade,
|
||||||
|
side,
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minZ,
|
||||||
|
maxZ
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Structural collision test: does any ramp deck part overlap a pier/mast?
|
||||||
|
local structural = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local nm = d.Name
|
||||||
|
if nm:find("PierColumn") or nm:find("PAMast") or nm:find("GirderRib") then
|
||||||
|
table.insert(structural, d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(out, "structuralParts=" .. #structural)
|
||||||
|
|
||||||
|
local hits = 0
|
||||||
|
local detail = {}
|
||||||
|
for _, base in ipairs(order) do
|
||||||
|
local parts = runs[base] or {}
|
||||||
|
local h = 0
|
||||||
|
for _, p in ipairs(parts) do
|
||||||
|
for _, s in ipairs(structural) do
|
||||||
|
local dx = math.abs(p.Position.X - s.Position.X)
|
||||||
|
local dz = math.abs(p.Position.Z - s.Position.Z)
|
||||||
|
local dy = math.abs(p.Position.Y - s.Position.Y)
|
||||||
|
local rx = (p.Size.X + s.Size.X) / 2
|
||||||
|
local rz = (p.Size.Z + s.Size.Z) / 2
|
||||||
|
local ry = (p.Size.Y + s.Size.Y) / 2
|
||||||
|
if dx < rx and dz < rz and dy < ry then
|
||||||
|
h += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hits += h
|
||||||
|
table.insert(detail, base .. "=" .. h)
|
||||||
|
end
|
||||||
|
table.insert(out, "STRUCTURAL_HITS total=" .. hits .. " (" .. table.concat(detail, " ") .. ")")
|
||||||
|
return table.concat(out, "\n")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Median opening test, now against the CORRECT part name. The centre divider
|
||||||
|
-- is "Median_<i>"; "Barrier" is the outer edge parapet (which must NOT open).
|
||||||
|
local ws = game:GetService("Workspace")
|
||||||
|
local map = ws:FindFirstChild("Map")
|
||||||
|
|
||||||
|
local medians, roads = {}, {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") then
|
||||||
|
local b = d.Name:gsub("_?%d+$", "")
|
||||||
|
if b == "Median" then
|
||||||
|
table.insert(medians, d.Position)
|
||||||
|
elseif b == "Road" then
|
||||||
|
table.insert(roads, d.Position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local anchors = {}
|
||||||
|
for _, d in ipairs(map:GetDescendants()) do
|
||||||
|
if d:IsA("BasePart") and (d.Name == "PAEntryRampNear_200" or d.Name == "PAExitRampNear_200") then
|
||||||
|
table.insert(anchors, { d.Name, d.Position })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local WIN = 220
|
||||||
|
local function countNear(pos, list)
|
||||||
|
local n = 0
|
||||||
|
for _, bp in ipairs(list) do
|
||||||
|
if (Vector3.new(pos.X, 0, pos.Z) - Vector3.new(bp.X, 0, bp.Z)).Magnitude < WIN then
|
||||||
|
n += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
table.insert(out, ("medianParts=%d roadParts=%d window=+/-%d"):format(#medians, #roads, WIN))
|
||||||
|
|
||||||
|
local ctrl = {}
|
||||||
|
local step = math.max(1, math.floor(#roads / 14))
|
||||||
|
for i = 1, #roads, step do
|
||||||
|
local p = roads[i]
|
||||||
|
local far = true
|
||||||
|
for _, a in ipairs(anchors) do
|
||||||
|
if (Vector3.new(p.X, 0, p.Z) - Vector3.new(a[2].X, 0, a[2].Z)).Magnitude < 700 then
|
||||||
|
far = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if far then
|
||||||
|
table.insert(ctrl, countNear(p, medians))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(ctrl)
|
||||||
|
local sum = 0
|
||||||
|
for _, v in ipairs(ctrl) do
|
||||||
|
sum += v
|
||||||
|
end
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
("CONTROL plain mainline (n=%d): min=%d median=%d max=%d mean=%.1f"):format(
|
||||||
|
#ctrl,
|
||||||
|
ctrl[1] or -1,
|
||||||
|
ctrl[math.max(1, math.floor(#ctrl / 2))] or -1,
|
||||||
|
ctrl[#ctrl] or -1,
|
||||||
|
#ctrl > 0 and sum / #ctrl or -1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _, a in ipairs(anchors) do
|
||||||
|
table.insert(out, ("JUNCTION %s: mediansInWindow=%d"):format(a[1], countNear(a[2], medians)))
|
||||||
|
end
|
||||||
|
return table.concat(out, "\n")
|
||||||