Compare commits
5
Commits
1f888b3693
...
3ae71f9dd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ae71f9dd3 | ||
|
|
45069188be | ||
|
|
d45f35736a | ||
|
|
766fce5088 | ||
|
|
0dbd7420d4 |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -42,13 +42,14 @@
|
|||||||
"$className": "Lighting",
|
"$className": "Lighting",
|
||||||
"$properties": {
|
"$properties": {
|
||||||
"Technology": "Future",
|
"Technology": "Future",
|
||||||
"ClockTime": 0.0,
|
"ClockTime": 14.0,
|
||||||
"Brightness": 1.0,
|
"Brightness": 2.0,
|
||||||
"Ambient": [0.05, 0.05, 0.1],
|
"Ambient": [0.35, 0.35, 0.38],
|
||||||
"OutdoorAmbient": [0.1, 0.1, 0.18],
|
"OutdoorAmbient": [0.45, 0.46, 0.5],
|
||||||
"FogEnd": 900.0,
|
"FogEnd": 6000.0,
|
||||||
"FogColor": [0.03, 0.03, 0.08],
|
"FogColor": [0.55, 0.6, 0.68],
|
||||||
"EnvironmentDiffuseScale": 0.4,
|
"EnvironmentDiffuseScale": 1.0,
|
||||||
|
"EnvironmentSpecularScale": 1.0,
|
||||||
"GlobalShadows": true
|
"GlobalShadows": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -115,13 +115,49 @@ 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
|
||||||
|
local seatPart = humanoid.SeatPart
|
||||||
|
local seated = seatPart
|
||||||
|
and seatPart:IsA("VehicleSeat")
|
||||||
|
and seatPart.Name == Chassis.SEAT_NAME
|
||||||
|
if seated and not heartbeatConn then
|
||||||
|
startDriving(seatPart :: VehicleSeat)
|
||||||
|
elseif not seated and heartbeatConn then
|
||||||
|
stopDriving()
|
||||||
|
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
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -66,7 +79,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
|
||||||
|
|||||||
+139
-32
@@ -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,43 @@ 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
|
||||||
|
|
||||||
|
-- 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"
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
-- Helpers ---------------------------------------------------------------------
|
-- Helpers ---------------------------------------------------------------------
|
||||||
|
|
||||||
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
|
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
|
||||||
@@ -102,24 +141,18 @@ 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
|
||||||
@@ -243,19 +276,33 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
|
|||||||
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
|
||||||
CarBody.build(model, body, car)
|
CarBody.build(model, body, car)
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
@@ -288,27 +335,87 @@ 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)
|
||||||
|
seat:Sit(humanoid)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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,6 +76,7 @@ local function onPlayerAdded(player: Player)
|
|||||||
|
|
||||||
if profile == nil then
|
if profile == nil then
|
||||||
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
-- Another server holds the lock, or player left mid-load. Kick to be safe.
|
||||||
|
loading[player] = nil
|
||||||
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
|
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -84,6 +97,7 @@ 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("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
|
player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
|
||||||
end
|
end
|
||||||
@@ -91,11 +105,13 @@ local function onPlayerAdded(player: Player)
|
|||||||
|
|
||||||
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).
|
||||||
|
|||||||
+319
-13
@@ -333,6 +333,21 @@ local function buildPA(map: Folder)
|
|||||||
})
|
})
|
||||||
pad.Parent = pa
|
pad.Parent = pa
|
||||||
|
|
||||||
|
-- Roblox drops the character at (0,0,0) unless a real SpawnLocation exists,
|
||||||
|
-- and CarManager spawns the car onto the character -- so without this the
|
||||||
|
-- player starts ~8,600 studs off-circuit regardless of where SpawnPad is.
|
||||||
|
-- Neutral + non-collidable so it never becomes an obstacle on the pad.
|
||||||
|
local spawnLoc = Instance.new("SpawnLocation")
|
||||||
|
spawnLoc.Name = "PASpawn"
|
||||||
|
spawnLoc.Size = Vector3.new(24, 1, 24)
|
||||||
|
spawnLoc.CFrame = CFrame.new(c + Vector3.new(0, 2, 0))
|
||||||
|
spawnLoc.Transparency = 1
|
||||||
|
spawnLoc.CanCollide = false
|
||||||
|
spawnLoc.Anchored = true
|
||||||
|
spawnLoc.Neutral = true
|
||||||
|
spawnLoc.Duration = 0
|
||||||
|
spawnLoc.Parent = pa
|
||||||
|
|
||||||
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
|
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -562,21 +577,28 @@ local function buildSkyline(map: Folder)
|
|||||||
local x = math.sin(a) * r - 477
|
local x = math.sin(a) * r - 477
|
||||||
local z = math.cos(a) * r - 473
|
local z = math.cos(a) * r - 473
|
||||||
|
|
||||||
-- keep buildings off the racing surface
|
local h = 120 + det(i * 5.1) ^ 2.2 * 900
|
||||||
|
local w = 90 + det(i * 8.3) * 130
|
||||||
|
|
||||||
|
-- keep buildings off the racing surface.
|
||||||
|
-- The test must use the building's OUTER FACE, not its centre: a 220-stud
|
||||||
|
-- centre setback still lets a 220-wide tower reach 110 studs inward, which
|
||||||
|
-- put it through the tunnel bore (walls sit at ROAD_WIDTH/2 + 1.5 + 3).
|
||||||
|
-- Sample every point too -- striding by 3 skipped the closest approach on
|
||||||
|
-- tight corners.
|
||||||
|
local CLEAR = ROAD_WIDTH / 2 + 60
|
||||||
local near = math.huge
|
local near = math.huge
|
||||||
for k = 1, n, 3 do
|
for k = 1, n do
|
||||||
local s = samples[k].pos
|
local s = samples[k].pos
|
||||||
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude
|
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude
|
||||||
if d < near then
|
if d < near then
|
||||||
near = d
|
near = d
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if near < 220 then
|
if near - w / 2 < CLEAR then
|
||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
local h = 120 + det(i * 5.1) ^ 2.2 * 900
|
|
||||||
local w = 90 + det(i * 8.3) * 130
|
|
||||||
local b = part({
|
local b = part({
|
||||||
Name = "Building",
|
Name = "Building",
|
||||||
Size = Vector3.new(w, h, w),
|
Size = Vector3.new(w, h, w),
|
||||||
@@ -588,15 +610,27 @@ local function buildSkyline(map: Folder)
|
|||||||
b.Parent = folder
|
b.Parent = folder
|
||||||
placed += 1
|
placed += 1
|
||||||
|
|
||||||
|
-- signs hang off the +X face, so they need their own clearance check
|
||||||
if det(i * 13.7) > 0.72 then
|
if det(i * 13.7) > 0.72 then
|
||||||
local sign = part({
|
local sx = x + w / 2 + 6
|
||||||
Name = "NeonSign",
|
local snear = math.huge
|
||||||
Size = Vector3.new(12, h * 0.42, 5),
|
for k = 1, n do
|
||||||
CFrame = CFrame.new(x + w / 2, h * 0.62, z),
|
local s = samples[k].pos
|
||||||
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
|
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(sx, 0, z)).Magnitude
|
||||||
Material = Enum.Material.Neon,
|
if d < snear then
|
||||||
})
|
snear = d
|
||||||
sign.Parent = folder
|
end
|
||||||
|
end
|
||||||
|
if snear > CLEAR then
|
||||||
|
local sign = part({
|
||||||
|
Name = "NeonSign",
|
||||||
|
Size = Vector3.new(12, h * 0.42, 5),
|
||||||
|
CFrame = CFrame.new(sx, h * 0.62, z),
|
||||||
|
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
|
||||||
|
Material = Enum.Material.Neon,
|
||||||
|
})
|
||||||
|
sign.Parent = folder
|
||||||
|
end
|
||||||
end
|
end
|
||||||
if placed > 620 then
|
if placed > 620 then
|
||||||
break
|
break
|
||||||
@@ -614,6 +648,277 @@ local function buildSkyline(map: Folder)
|
|||||||
ground.Parent = folder
|
ground.Parent = folder
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Signature crossings.
|
||||||
|
|
||||||
|
Two hand-placed structures on top of the generated road:
|
||||||
|
|
||||||
|
* NihonbashiViaduct -- the C1's most famous stretch. The expressway runs
|
||||||
|
on a box girder directly OVER the historic Nihonbashi stone bridge and
|
||||||
|
the Nihonbashi-gawa. The stone bridge underneath is the actual landmark,
|
||||||
|
so it is modelled too (deck, balustrades, ornamental posts, arches).
|
||||||
|
|
||||||
|
* CableStayed -- a red twin-tower cable-stayed span on the straightest
|
||||||
|
bay-side run, with fanned stay cables and aviation warning lights.
|
||||||
|
|
||||||
|
Both index into Circuit.samples, so they follow the road wherever it moves.
|
||||||
|
Span indices were chosen from the georeferenced OSM trace: Nihonbashi maps
|
||||||
|
to the Edobashi-junction samples, the cable-stayed span to the Shiodome
|
||||||
|
straight (lowest cumulative heading change of any elevated, non-tunnel run).
|
||||||
|
]]
|
||||||
|
local function buildBridges(map: Folder)
|
||||||
|
local folder = Instance.new("Folder")
|
||||||
|
folder.Name = "Bridges"
|
||||||
|
folder.Parent = map
|
||||||
|
|
||||||
|
local samples = Circuit.samples
|
||||||
|
local n = Circuit.SAMPLE_COUNT
|
||||||
|
|
||||||
|
local function mid(i: number): Vector3
|
||||||
|
local a = samples[i]
|
||||||
|
local b = samples[(i % n) + 1]
|
||||||
|
return (a.pos + b.pos) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
----------------------------------------------------------------- Nihonbashi
|
||||||
|
do
|
||||||
|
local NB = Instance.new("Folder")
|
||||||
|
NB.Name = "NihonbashiViaduct"
|
||||||
|
NB.Parent = folder
|
||||||
|
|
||||||
|
local A, B = 46, 62
|
||||||
|
local pa, pb = samples[A].pos, samples[B].pos
|
||||||
|
local centre = (pa + pb) / 2
|
||||||
|
local axis = pb - pa
|
||||||
|
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
|
||||||
|
local right = flat:Cross(UP)
|
||||||
|
local span = axis.Magnitude
|
||||||
|
|
||||||
|
-- Nihonbashi-gawa flowing under the viaduct
|
||||||
|
local waterCF = CFrame.lookAt(Vector3.new(centre.X, 2, centre.Z), Vector3.new(centre.X, 2, centre.Z) + flat, UP)
|
||||||
|
local water = part({
|
||||||
|
Name = "RiverWater",
|
||||||
|
Size = Vector3.new(150, 3, span + 300),
|
||||||
|
CFrame = waterCF,
|
||||||
|
Color = Color3.fromRGB(18, 32, 42),
|
||||||
|
Material = Enum.Material.Glass,
|
||||||
|
})
|
||||||
|
water.Reflectance = 0.35
|
||||||
|
water.Parent = NB
|
||||||
|
|
||||||
|
for _, side in ipairs({ -1, 1 }) do
|
||||||
|
local q = part({
|
||||||
|
Name = "RiverWall",
|
||||||
|
Size = Vector3.new(10, 26, span + 300),
|
||||||
|
CFrame = waterCF * CFrame.new(side * 80, 11, 0),
|
||||||
|
Color = Color3.fromRGB(58, 56, 52),
|
||||||
|
Material = Enum.Material.Concrete,
|
||||||
|
})
|
||||||
|
q.Parent = NB
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the historic stone bridge, crossing UNDER the expressway
|
||||||
|
local bpos = Vector3.new(centre.X, 16, centre.Z)
|
||||||
|
local bcf = CFrame.lookAt(bpos, bpos + right, UP)
|
||||||
|
local deck = part({
|
||||||
|
Name = "NihonbashiStoneDeck",
|
||||||
|
Size = Vector3.new(46, 4, 180),
|
||||||
|
CFrame = bcf,
|
||||||
|
Color = Color3.fromRGB(150, 142, 128),
|
||||||
|
Material = Enum.Material.Slate,
|
||||||
|
})
|
||||||
|
deck.Parent = NB
|
||||||
|
|
||||||
|
for _, s in ipairs({ -1, 1 }) do
|
||||||
|
local bal = part({
|
||||||
|
Name = "Balustrade",
|
||||||
|
Size = Vector3.new(2.5, 5, 180),
|
||||||
|
CFrame = bcf * CFrame.new(s * 22, 4, 0),
|
||||||
|
Color = Color3.fromRGB(168, 160, 146),
|
||||||
|
Material = Enum.Material.Slate,
|
||||||
|
})
|
||||||
|
bal.Parent = NB
|
||||||
|
for k = -2, 2 do
|
||||||
|
local post = part({
|
||||||
|
Name = "BridgePost",
|
||||||
|
Size = Vector3.new(6, 14, 6),
|
||||||
|
CFrame = bcf * CFrame.new(s * 22, 8, k * 44),
|
||||||
|
Color = Color3.fromRGB(120, 112, 100),
|
||||||
|
Material = Enum.Material.Slate,
|
||||||
|
})
|
||||||
|
post.Parent = NB
|
||||||
|
if math.abs(k) == 2 then
|
||||||
|
local lamp = part({
|
||||||
|
Name = "BridgeLamp",
|
||||||
|
Size = Vector3.new(4, 6, 4),
|
||||||
|
CFrame = bcf * CFrame.new(s * 22, 17, k * 44),
|
||||||
|
Color = Color3.fromRGB(255, 214, 150),
|
||||||
|
Material = Enum.Material.Neon,
|
||||||
|
})
|
||||||
|
local pl = Instance.new("PointLight")
|
||||||
|
pl.Brightness = 2
|
||||||
|
pl.Range = 60
|
||||||
|
pl.Color = Color3.fromRGB(255, 214, 150)
|
||||||
|
pl.Parent = lamp
|
||||||
|
lamp.Parent = NB
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, s in ipairs({ -1, 1 }) do
|
||||||
|
local arch = part({
|
||||||
|
Name = "StoneArch",
|
||||||
|
Size = Vector3.new(40, 22, 70),
|
||||||
|
CFrame = bcf * CFrame.new(0, -10, s * 46),
|
||||||
|
Color = Color3.fromRGB(138, 130, 118),
|
||||||
|
Material = Enum.Material.Slate,
|
||||||
|
})
|
||||||
|
arch.Parent = NB
|
||||||
|
end
|
||||||
|
|
||||||
|
-- box girder + pier bents carrying the expressway deck
|
||||||
|
for i = A, B do
|
||||||
|
local m = mid(i)
|
||||||
|
local fwd = Circuit.forwardAt(i)
|
||||||
|
local cf = CFrame.lookAt(m, m + fwd, UP)
|
||||||
|
local g = part({
|
||||||
|
Name = "BoxGirder",
|
||||||
|
Size = Vector3.new(ROAD_WIDTH + 10, 9, 64),
|
||||||
|
CFrame = cf * CFrame.new(0, -6, 0),
|
||||||
|
Color = Color3.fromRGB(64, 64, 72),
|
||||||
|
Material = Enum.Material.Metal,
|
||||||
|
})
|
||||||
|
g.Parent = NB
|
||||||
|
|
||||||
|
if i % 3 == 0 then
|
||||||
|
local y = samples[i].pos.Y
|
||||||
|
for _, side in ipairs({ -1, 1 }) do
|
||||||
|
local leg = part({
|
||||||
|
Name = "ViaductPier",
|
||||||
|
Size = Vector3.new(9, math.max(y - 14, 4), 9),
|
||||||
|
CFrame = CFrame.new(Vector3.new(m.X, math.max(y - 14, 4) / 2 + 14, m.Z) + right * (side * 34)),
|
||||||
|
Color = Color3.fromRGB(72, 70, 76),
|
||||||
|
Material = Enum.Material.Concrete,
|
||||||
|
})
|
||||||
|
leg.Parent = NB
|
||||||
|
end
|
||||||
|
local cap = part({
|
||||||
|
Name = "PierCap",
|
||||||
|
Size = Vector3.new(88, 6, 12),
|
||||||
|
CFrame = cf * CFrame.new(0, -13, 0),
|
||||||
|
Color = Color3.fromRGB(72, 70, 76),
|
||||||
|
Material = Enum.Material.Concrete,
|
||||||
|
})
|
||||||
|
cap.Parent = NB
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--------------------------------------------------------------- cable-stayed
|
||||||
|
do
|
||||||
|
local CS = Instance.new("Folder")
|
||||||
|
CS.Name = "CableStayed"
|
||||||
|
CS.Parent = folder
|
||||||
|
|
||||||
|
local A, B = 646, 659
|
||||||
|
local pa, pb = samples[A].pos, samples[B].pos
|
||||||
|
local axis = pb - pa
|
||||||
|
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
|
||||||
|
local right = flat:Cross(UP)
|
||||||
|
local deckY = pa.Y
|
||||||
|
local TOWER_H = 210
|
||||||
|
local RED = Color3.fromRGB(196, 72, 72)
|
||||||
|
|
||||||
|
local tops = {}
|
||||||
|
for _, idx in ipairs({ A + 3, B - 3 }) do
|
||||||
|
local c = mid(idx)
|
||||||
|
for _, side in ipairs({ -1, 1 }) do
|
||||||
|
local top = deckY + TOWER_H
|
||||||
|
local leg = part({
|
||||||
|
Name = "BridgeTower",
|
||||||
|
Size = Vector3.new(12, top, 12),
|
||||||
|
CFrame = CFrame.new(Vector3.new(c.X, top / 2, c.Z) + right * (side * 56)),
|
||||||
|
Color = RED,
|
||||||
|
Material = Enum.Material.Metal,
|
||||||
|
})
|
||||||
|
leg.Parent = CS
|
||||||
|
table.insert(tops, { pos = Vector3.new(c.X, top, c.Z) + right * (side * 56), idx = idx, side = side })
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, h in ipairs({ 0.55, 0.85 }) do
|
||||||
|
local y = deckY + TOWER_H * h
|
||||||
|
local beam = part({
|
||||||
|
Name = "TowerCrossbeam",
|
||||||
|
Size = Vector3.new(124, 8, 8),
|
||||||
|
CFrame = CFrame.lookAt(Vector3.new(c.X, y, c.Z), Vector3.new(c.X, y, c.Z) + flat, UP)
|
||||||
|
* CFrame.Angles(0, math.pi / 2, 0),
|
||||||
|
Color = RED,
|
||||||
|
Material = Enum.Material.Metal,
|
||||||
|
})
|
||||||
|
beam.Parent = CS
|
||||||
|
end
|
||||||
|
|
||||||
|
local al = part({
|
||||||
|
Name = "AviationLight",
|
||||||
|
Size = Vector3.new(6, 3, 6),
|
||||||
|
CFrame = CFrame.new(c.X, deckY + TOWER_H + 3, c.Z),
|
||||||
|
Color = Color3.fromRGB(255, 60, 60),
|
||||||
|
Material = Enum.Material.Neon,
|
||||||
|
})
|
||||||
|
local pl = Instance.new("PointLight")
|
||||||
|
pl.Brightness = 3
|
||||||
|
pl.Range = 140
|
||||||
|
pl.Color = Color3.fromRGB(255, 60, 60)
|
||||||
|
pl.Parent = al
|
||||||
|
al.Parent = CS
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fanned stay cables
|
||||||
|
for _, t in ipairs(tops) do
|
||||||
|
for k = 1, 6 do
|
||||||
|
local target = math.clamp(t.idx + (k * 2 - 7), A, B)
|
||||||
|
local anchor = mid(target) + right * (t.side * 46) + Vector3.new(0, 2, 0)
|
||||||
|
local delta = anchor - t.pos
|
||||||
|
local dist = delta.Magnitude
|
||||||
|
if dist > 1 then
|
||||||
|
local cable = part({
|
||||||
|
Name = "Cable",
|
||||||
|
Size = Vector3.new(0.9, 0.9, dist),
|
||||||
|
CFrame = CFrame.lookAt(t.pos + delta / 2, anchor, UP),
|
||||||
|
Color = Color3.fromRGB(225, 225, 232),
|
||||||
|
Material = Enum.Material.Metal,
|
||||||
|
})
|
||||||
|
cable.Parent = CS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = A, B do
|
||||||
|
local m = mid(i)
|
||||||
|
local fwd = Circuit.forwardAt(i)
|
||||||
|
local g = part({
|
||||||
|
Name = "BridgeGirder",
|
||||||
|
Size = Vector3.new(ROAD_WIDTH + 14, 7, 64),
|
||||||
|
CFrame = CFrame.lookAt(m, m + fwd, UP) * CFrame.new(0, -5, 0),
|
||||||
|
Color = RED,
|
||||||
|
Material = Enum.Material.Metal,
|
||||||
|
})
|
||||||
|
g.Parent = CS
|
||||||
|
end
|
||||||
|
|
||||||
|
local centre = (pa + pb) / 2
|
||||||
|
local bay = part({
|
||||||
|
Name = "BayWater",
|
||||||
|
Size = Vector3.new(900, 3, axis.Magnitude + 500),
|
||||||
|
CFrame = CFrame.lookAt(Vector3.new(centre.X, 1, centre.Z), Vector3.new(centre.X, 1, centre.Z) + flat, UP),
|
||||||
|
Color = Color3.fromRGB(14, 26, 38),
|
||||||
|
Material = Enum.Material.Glass,
|
||||||
|
})
|
||||||
|
bay.Reflectance = 0.4
|
||||||
|
bay.Parent = CS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function MapBuilder.build(): Folder
|
function MapBuilder.build(): Folder
|
||||||
local existing = Workspace:FindFirstChild("Map")
|
local existing = Workspace:FindFirstChild("Map")
|
||||||
if existing then
|
if existing then
|
||||||
@@ -628,6 +933,7 @@ function MapBuilder.build(): Folder
|
|||||||
buildPA(map)
|
buildPA(map)
|
||||||
buildLandmarks(map)
|
buildLandmarks(map)
|
||||||
buildSkyline(map)
|
buildSkyline(map)
|
||||||
|
buildBridges(map)
|
||||||
|
|
||||||
-- Start/finish across the road at sample 1
|
-- Start/finish across the road at sample 1
|
||||||
local s1 = Circuit.samples[1]
|
local s1 = Circuit.samples[1]
|
||||||
|
|||||||
Reference in New Issue
Block a user