Corrected against 4K dusk onboard footage of the junction (jct_tsubasa_dusk, t=110..140s), which shows the previous pass got the chevrons wrong in kind, not just in placement. What the footage actually shows: red-and-white chevron delineators are VERTICAL PANELS mounted on the outside face of the concrete barrier, running continuously along the outside of each curve. The first pass built them as flat red slabs lying on the road surface. On the deck they were nearly invisible from the driver's seat, which defeats the point -- these panels are the main thing that makes a helix legible from inside the car. Now modelled as 0.5 x 2.0 x segLen panels on the barrier face, alternating red/white per segment (Roblox part granularity can't put a diagonal stripe inside a single panel). Also fixes a placement bug found by measurement. Offsetting the panel along the raw radial (segMid - O) is correct on the circular arc but wrong on the straight tangent tail, where the radial is no longer perpendicular to travel: the panel line walked diagonally across the deck instead of tracking its edge, up to 33 studs of along-travel error over the last ~9 segments. Panels now always offset along the true perpendicular (rightV); the radial only picks which side is outboard. Measured on the built geometry: PAOnRamp 84 segs 0 misaligned worst along-travel 0.06 studs PAOffRamp 64 segs 0 misaligned worst along-travel 0.08 studs (excluding the last 3 segments of each ramp, the merge mouth, which carries no kerb or chevron by design so the merge stays open) 144 panels total, 72 red / 72 white. Ramp geometry is unchanged: grades still 1.48% / 1.95%, rotation 344 / 282 deg. Barrier colour was checked at the same time and left alone -- the green cast in the night footage is sodium-vapour lighting on pale concrete, not paint. C_PARAPET stays as is.
2467 lines
93 KiB
Luau
2467 lines
93 KiB
Luau
--!strict
|
|
--[[
|
|
MapBuilder.luau (SERVER)
|
|
v4 — "C1: Shuto Inner Circular". The road is no longer an invented
|
|
rounded-rectangle: it is the REAL Shuto Expressway Inner Circular Route
|
|
(首都高速都心環状線) traced from OpenStreetMap and driven straight off
|
|
Circuit.samples, so the map and the lap timing share one geometry.
|
|
|
|
What that buys you, all of it authentic:
|
|
* The true C1 shape — Tanimachi JCT, Miyakezaka, Kasumigaseki, Ginza,
|
|
Kyobashi, Edobashi, Hitotsubashi — in their real relative positions.
|
|
* Real tunnel sections (the C1 spends a lot of its length underground)
|
|
built as lit bores; real bridge/viaduct sections built elevated on
|
|
pillars over the city.
|
|
* Tokyo landmarks placed at their true coordinates relative to the
|
|
loop: Tokyo Tower, the Imperial Palace moat, Tokyo Station, Ginza,
|
|
the Shiodome towers, Roppongi Hills, the National Diet, Skytree.
|
|
* A Daikoku-style PA hanging off the straightest non-tunnel run.
|
|
|
|
Gameplay parts keep their contract names (SpawnPad, FinishLine, KillWall_*)
|
|
so nothing else in the codebase changes.
|
|
]]
|
|
|
|
local Workspace = game:GetService("Workspace")
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
local Circuit = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("Circuit"))
|
|
|
|
local MapBuilder = {}
|
|
|
|
local ROAD_WIDTH = Circuit.ROAD_WIDTH
|
|
local WALL_HEIGHT = 26
|
|
|
|
-- Divided-expressway layout (real Wangan/C1 geometry) -------------------------
|
|
-- The deck is ONE slab; the median is what splits it into two carriageways
|
|
-- running in opposite directions. Outer edges get a visible concrete barrier
|
|
-- (you can see it, like real life) backed by an invisible taller wall so a car
|
|
-- at 300 km/h can't tunnel through it in a single physics step.
|
|
-- Dimensions and palette verified against real photographs of Daikoku JCT /
|
|
-- Daikoku Futo PA (Wikimedia Commons aerial + ground refs) and OSM tagging of
|
|
-- the junction (way data: lanes=2, oneway=yes per carriageway, layers 1-4).
|
|
-- Corrections made after looking at the real thing rather than guessing:
|
|
-- * Shutoko structures are PALE CONCRETE / off-white, not dark grey.
|
|
-- * The median is a NARROW raised kerb, not a wide chunky wall.
|
|
-- * The edge is a low white parapet TOPPED BY A DARK METAL RAILING -- that
|
|
-- silhouette is the single most recognisable feature of the road.
|
|
-- * Asphalt reads mid-grey in daylight, not near-black.
|
|
local MEDIAN_WIDTH = 6
|
|
local MEDIAN_HEIGHT = 3.2
|
|
local BARRIER_H = 5 -- white concrete parapet height
|
|
local BARRIER_T = 2.2 -- parapet thickness
|
|
local RAIL_H = 3 -- dark metal railing sitting on the parapet
|
|
-- Steel box-girder depth under bridge decks (鋼箱桁). Real Shutoko girders run
|
|
-- roughly 1/20 of the span depth; at ~35 m spans that's ~1.8 m => ~6.4 studs.
|
|
local GIRDER_H = 6.4
|
|
|
|
local C_ASPHALT = Color3.fromRGB(78, 78, 82)
|
|
local C_CONCRETE = Color3.fromRGB(214, 212, 205) -- Shutoko pale concrete
|
|
local C_PARAPET = Color3.fromRGB(226, 224, 218)
|
|
local C_RAIL = Color3.fromRGB(96, 100, 106)
|
|
-- Weathered grey-green steel of the box girders under the elevated sections.
|
|
local C_GIRDER = Color3.fromRGB(122, 128, 124)
|
|
local UP = Vector3.new(0, 1, 0)
|
|
|
|
-- REAL-WORLD SCALE ------------------------------------------------------------
|
|
-- The rest of the codebase converts speed with 0.28 m per stud, so all the
|
|
-- metric road-standard figures below are converted through the same constant.
|
|
-- Sanity check: the 49,383-stud loop comes out at 13.8 km against the real
|
|
-- C1 Inner Circular Route's 14.8 km, so the circuit is very close to 1:1.
|
|
local M = 1 / 0.28 -- studs per metre (~3.571)
|
|
|
|
-- Road markings per 全標協 (Japan Road Marking Assoc.) 2012 p.42 / 2018 pp.97-98,
|
|
-- read from the primary standard rather than repeated folklore:
|
|
-- * 車線境界線 (lane boundary, dashed): line length 3-10 m, gap = 1x to 2x the
|
|
-- line length, width 10-15 cm (15 cm "desirable"). The 8 m line / 12 m gap
|
|
-- used here is the high-speed end of that legal range (gap = 1.5x line) and
|
|
-- is the configuration expressways actually use -- it's what makes the
|
|
-- rhythm read as an expressway rather than a 5 m/5 m surface street.
|
|
-- * Where overtaking is undesirable (屈曲部 -- curves), the standard says the
|
|
-- line becomes SOLID rather than dashed. On expressways that no-lane-change
|
|
-- line is painted yellow.
|
|
-- * NOTE: the 45 cm width sometimes quoted for "expressway lines" is NOT the
|
|
-- lane boundary -- it's the boundary against a 変速車線 (accel/decel lane).
|
|
-- Applied below at ramps only.
|
|
local DASH_PAINT = 8 * M -- 28.6 studs
|
|
local DASH_GAP = 12 * M -- 42.9 studs
|
|
local DASH_CYCLE = DASH_PAINT + DASH_GAP -- 20 m
|
|
local LANE_LINE_W = 0.15 * M -- 0.54 studs
|
|
local EDGE_LINE_W = 0.20 * M -- 0.71 studs
|
|
local RAMP_LINE_W = 0.45 * M -- 1.61 studs; through lane vs speed-change lane
|
|
local LANE_W = 3.25 * M -- 11.6 studs; narrow urban lane used on the C1
|
|
-- 導流帯 (gore/zebra) per 全標協 2018 pp.97-98: 45 cm white solid lines repeated
|
|
-- every 1.0 m, bounded by a 15-20 cm edge line.
|
|
local ZEBRA_W = 0.45 * M
|
|
local ZEBRA_PITCH = 1.0 * M
|
|
local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting
|
|
local MARK_H = 2.1
|
|
-- Heading change per 60-stud sample above which the curve counts as tight
|
|
-- enough that the real road would paint a solid yellow no-lane-change line.
|
|
local TIGHT_CURVE = math.rad(4)
|
|
|
|
local C_PAINT_WHITE = Color3.fromRGB(232, 232, 236)
|
|
local C_PAINT_YELLOW = Color3.fromRGB(240, 190, 40) -- 黄色 no-lane-change line
|
|
|
|
-- Signage colours sampled from the official Japanese sign artwork (Wikimedia
|
|
-- Commons SVGs of the 標識令 plates), not eyeballed:
|
|
-- guide sign (案内標識) green #005D35
|
|
-- Shutoko route badge green #007F1F
|
|
local C_SIGN_GREEN = Color3.fromRGB(0, 93, 53)
|
|
local C_BADGE_GREEN = Color3.fromRGB(0, 127, 31)
|
|
local C_SIGN_WHITE = Color3.fromRGB(255, 255, 255)
|
|
|
|
-- Lighting. Japanese expressway/tunnel lighting is high-pressure sodium
|
|
-- (warm amber) with newer LED sections in cool white -- NOT the blue/teal
|
|
-- that reads as generic sci-fi. Tunnel basic lighting runs continuously.
|
|
local C_SODIUM = Color3.fromRGB(255, 176, 92)
|
|
local C_TUNNEL_LED = Color3.fromRGB(255, 244, 224)
|
|
|
|
local NEON_PINK = Color3.fromRGB(255, 40, 120)
|
|
local NEON_BLUE = Color3.fromRGB(40, 160, 255)
|
|
local NEON_TEAL = Color3.fromRGB(40, 230, 200)
|
|
local NEON_AMBER = Color3.fromRGB(255, 170, 60)
|
|
|
|
-- SECTION PROFILE (real C1 lane counts and posted speeds) ---------------------
|
|
-- The C1 is NOT uniform. Verified against the 車線・最高速度 table in the
|
|
-- 首都高速都心環状線 article:
|
|
--
|
|
-- 江戸橋JCT内 2 = 1+1 40 km/h
|
|
-- 江戸橋JCT - 汐留JCT 4 = 2+2 50 km/h
|
|
-- 汐留JCT - 浜崎橋JCT 6 = 3+3 50 km/h <-- widest section
|
|
-- 浜崎橋JCT内 4 = 2+2 40 km/h
|
|
-- 浜崎橋JCT - 江戸橋JCT 4 = 2+2 50 km/h
|
|
-- 京橋JCT - 旧白魚橋乗継所 3 = 1+2 40 km/h
|
|
--
|
|
-- So junction interiors are posted 40, mainline 50, and the Shiodome ->
|
|
-- Hamazakibashi run carries three lanes each way rather than two.
|
|
local JCT_ZONES: { [string]: boolean } = {
|
|
["Edobashi junction"] = true,
|
|
["Takebashi Junction"] = true,
|
|
["Miyakezaka JCT"] = true,
|
|
["Miyakezaka Junction"] = true,
|
|
["Tanimachi JCT"] = true,
|
|
}
|
|
-- 汐留JCT - 浜崎橋JCT: 6 lanes (3+3).
|
|
local WIDE_ZONES: { [string]: boolean } = { ["Shiodome"] = true }
|
|
-- 銀座: the 半地下 section built in the filled-in 楓川/築地川 river beds. Several
|
|
-- of the original river bridges survive as 跨道橋 crossing OVER the expressway,
|
|
-- and their piers stand BETWEEN the running lanes -- which is why the real road
|
|
-- bans lane changes through here.
|
|
local GINZA_ZONES: { [string]: boolean } = { ["Ginza"] = true }
|
|
|
|
-- Deck width needed for 3+3: 6 x 3.25 m lanes + median + shoulders.
|
|
local WIDE_ROAD_WIDTH = 82
|
|
local MAX_ROAD_WIDTH = math.max(ROAD_WIDTH, WIDE_ROAD_WIDTH)
|
|
|
|
local function zoneOf(i: number): string
|
|
return Circuit.samples[i].piece
|
|
end
|
|
local function isJctSample(i: number): boolean
|
|
return JCT_ZONES[zoneOf(i)] == true
|
|
end
|
|
local function lanesAt(i: number): number
|
|
return WIDE_ZONES[zoneOf(i)] and 3 or 2
|
|
end
|
|
|
|
-- Per-sample deck width, smoothed so the carriageway tapers into and out of the
|
|
-- 3+3 section instead of stepping by 22 studs between two adjacent segments.
|
|
local ROAD_W: { number } = {}
|
|
do
|
|
local n = #Circuit.samples
|
|
local base: { number } = {}
|
|
for i = 1, n do
|
|
base[i] = WIDE_ZONES[Circuit.samples[i].piece] and WIDE_ROAD_WIDTH or ROAD_WIDTH
|
|
end
|
|
local TAPER = 6
|
|
for i = 1, n do
|
|
local acc = 0
|
|
for k = -TAPER, TAPER do
|
|
acc += base[((i - 1 + k) % n) + 1]
|
|
end
|
|
ROAD_W[i] = acc / (2 * TAPER + 1)
|
|
end
|
|
end
|
|
local function widthAt(i: number): number
|
|
return ROAD_W[i]
|
|
end
|
|
|
|
local function part(props: { [string]: any }): Part
|
|
local p = Instance.new("Part")
|
|
p.Anchored = true
|
|
p.TopSurface = Enum.SurfaceType.Smooth
|
|
p.BottomSurface = Enum.SurfaceType.Smooth
|
|
for k, v in pairs(props) do
|
|
(p :: any)[k] = v
|
|
end
|
|
return p
|
|
end
|
|
|
|
-- Deterministic pseudo-random so every server builds the same city ------------
|
|
local function det(seed: number): number
|
|
local x = math.sin(seed * 12.9898) * 43758.5453
|
|
return x - math.floor(x)
|
|
end
|
|
|
|
-- Real interchange names. Japanese is what's actually painted on the gantry;
|
|
-- the romaji underneath is the smaller secondary line every Shutoko guide sign
|
|
-- carries. Names cross-checked against OpenStreetMap's tagging of the real
|
|
-- Inner Circular Route (name / name:en on the motorway_junction nodes).
|
|
local JUNCTION_NAMES: { [string]: { string } } = {
|
|
["Kyobashi"] = { "京橋", "Kyobashi" },
|
|
["Takaracho"] = { "宝町", "Takaracho" },
|
|
["Edobashi junction"] = { "江戸橋JCT", "Edobashi JCT" },
|
|
["Takebashi Junction"] = { "竹橋JCT", "Takebashi JCT" },
|
|
["Hitotsubashi"] = { "一ツ橋", "Hitotsubashi" },
|
|
["Kitanomaru"] = { "北の丸", "Kitanomaru" },
|
|
["Daikancho"] = { "代官町", "Daikancho" },
|
|
["Miyakezaka JCT"] = { "三宅坂JCT", "Miyakezaka JCT" },
|
|
["Miyakezaka Junction"] = { "三宅坂JCT", "Miyakezaka JCT" },
|
|
["Kasumigaseki"] = { "霞が関", "Kasumigaseki" },
|
|
["Tanimachi JCT"] = { "谷町JCT", "Tanimachi JCT" },
|
|
["Iikura"] = { "飯倉", "Iikura" },
|
|
["Shiodome"] = { "汐留", "Shiodome" },
|
|
["Ginza"] = { "銀座", "Ginza" },
|
|
["Shintomicho"] = { "新富町", "Shintomicho" },
|
|
}
|
|
|
|
-- Japanese expressway guide sign (案内標識). Green board, white text, white
|
|
-- hairline border inset from the edge, Japanese place name large with the
|
|
-- romaji reading beneath it, and the circular route badge on the left.
|
|
local function makeSign(text: string, cf: CFrame, parent: Instance)
|
|
local names = JUNCTION_NAMES[text] or { text, text }
|
|
local ja, romaji = names[1], names[2]
|
|
|
|
local board = part({
|
|
Name = "Sign",
|
|
Size = Vector3.new(34, 10, 1),
|
|
CFrame = cf,
|
|
Color = C_SIGN_GREEN,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
board.Parent = parent
|
|
|
|
local gui = Instance.new("SurfaceGui")
|
|
gui.Face = Enum.NormalId.Front
|
|
gui.CanvasSize = Vector2.new(680, 200)
|
|
gui.Parent = board
|
|
|
|
-- Thin white border inset from the panel edge -- present on every real
|
|
-- Japanese guide sign and a big part of why they read as "Japanese".
|
|
local border = Instance.new("Frame")
|
|
border.Size = UDim2.new(1, -24, 1, -24)
|
|
border.Position = UDim2.fromOffset(12, 12)
|
|
border.BackgroundTransparency = 1
|
|
border.BorderSizePixel = 3
|
|
border.BorderColor3 = C_SIGN_WHITE
|
|
border.Parent = gui
|
|
|
|
-- Route badge: the C1 shield, green disc with white ring and "C1".
|
|
local badge = Instance.new("Frame")
|
|
badge.Size = UDim2.fromOffset(104, 104)
|
|
badge.Position = UDim2.fromOffset(34, 48)
|
|
badge.BackgroundColor3 = C_BADGE_GREEN
|
|
badge.BorderSizePixel = 0
|
|
badge.Parent = gui
|
|
local badgeCorner = Instance.new("UICorner")
|
|
badgeCorner.CornerRadius = UDim.new(1, 0)
|
|
badgeCorner.Parent = badge
|
|
local badgeStroke = Instance.new("UIStroke")
|
|
badgeStroke.Color = C_SIGN_WHITE
|
|
badgeStroke.Thickness = 5
|
|
badgeStroke.Parent = badge
|
|
local badgeText = Instance.new("TextLabel")
|
|
badgeText.Size = UDim2.fromScale(1, 1)
|
|
badgeText.BackgroundTransparency = 1
|
|
badgeText.Font = Enum.Font.GothamBold
|
|
badgeText.TextColor3 = C_SIGN_WHITE
|
|
badgeText.TextScaled = true
|
|
badgeText.Text = "C1"
|
|
badgeText.Parent = badge
|
|
|
|
-- Japanese name (primary, large)
|
|
local jaLabel = Instance.new("TextLabel")
|
|
jaLabel.Size = UDim2.new(1, -190, 0, 96)
|
|
jaLabel.Position = UDim2.fromOffset(168, 36)
|
|
jaLabel.BackgroundTransparency = 1
|
|
jaLabel.Font = Enum.Font.GothamBold
|
|
jaLabel.TextColor3 = C_SIGN_WHITE
|
|
jaLabel.TextScaled = true
|
|
jaLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
jaLabel.Text = ja
|
|
jaLabel.Parent = gui
|
|
|
|
-- Romaji reading (secondary, smaller, beneath)
|
|
local enLabel = Instance.new("TextLabel")
|
|
enLabel.Size = UDim2.new(1, -190, 0, 44)
|
|
enLabel.Position = UDim2.fromOffset(168, 132)
|
|
enLabel.BackgroundTransparency = 1
|
|
enLabel.Font = Enum.Font.Gotham
|
|
enLabel.TextColor3 = C_SIGN_WHITE
|
|
enLabel.TextScaled = true
|
|
enLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
enLabel.Text = romaji
|
|
enLabel.Parent = gui
|
|
end
|
|
|
|
-- PA junction planning -----------------------------------------------------------
|
|
-- Daikoku PA sits inside a loop of ramp road, and is reachable from BOTH
|
|
-- directions of travel. That means the median has to OPEN at the junctions,
|
|
-- otherwise the divider we just added would wall the PA off from the far
|
|
-- carriageway. buildRoad and buildPA must agree on exactly where those
|
|
-- openings are, so the plan is computed once, here, and shared.
|
|
local PAPlan = nil :: any
|
|
|
|
local function planPA()
|
|
if PAPlan then
|
|
return PAPlan
|
|
end
|
|
local samples = Circuit.samples
|
|
local c = Circuit.PA_CENTER
|
|
-- Nearest non-tunnel sample to the PA = where the ramps meet the mainline.
|
|
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
|
|
-- Entry ramp leaves the mainline before the PA, exit ramp rejoins after it.
|
|
local n = #samples
|
|
local SPAN = 6 -- samples of median opening around each junction
|
|
local entryIdx = ((bestIdx - 14 - 1) % n) + 1
|
|
local exitIdx = ((bestIdx + 14 - 1) % n) + 1
|
|
local open = {}
|
|
for _, centreIdx in ipairs({ entryIdx, exitIdx }) do
|
|
for k = -SPAN, SPAN do
|
|
open[((centreIdx + k - 1) % n) + 1] = true
|
|
end
|
|
end
|
|
-- The OUTER edge barrier needs its own, much narrower opening -- and it is
|
|
-- NOT the same thing as the median opening.
|
|
--
|
|
-- BUG (2026-08-02): buildRoad reused `medianOpen` to skip the outer
|
|
-- barriers too, and did it inside `for side in {-1,1}`, so at all 13
|
|
-- samples per junction the mainline lost its parapet, railing AND kill
|
|
-- wall on BOTH edges -- 26 samples of expressway with no walls at all,
|
|
-- including the far side where no ramp exists. That is the "some of the
|
|
-- wangan has no walls, jumbled near the parking area" report.
|
|
--
|
|
-- `medianOpen` exists so both carriageways can reach the PA (the median is
|
|
-- the centre divider). The outer edge is a different structure with a drop
|
|
-- behind it, so it may only open on the ONE side the ramp leaves through,
|
|
-- and only right at the ramp mouth.
|
|
local RAMP_SPAN = 2 -- 5 samples, vs 13 for the median
|
|
local rampOpen = {}
|
|
for _, centreIdx in ipairs({ entryIdx, exitIdx }) do
|
|
for k = -RAMP_SPAN, RAMP_SPAN do
|
|
rampOpen[((centreIdx + k - 1) % n) + 1] = true
|
|
end
|
|
end
|
|
PAPlan = {
|
|
nearIdx = bestIdx,
|
|
entryIdx = entryIdx,
|
|
exitIdx = exitIdx,
|
|
medianOpen = open,
|
|
rampOpen = rampOpen,
|
|
ringRadius = Circuit.PA_RADIUS + 130,
|
|
}
|
|
return PAPlan
|
|
end
|
|
|
|
-- The expressway itself ---------------------------------------------------------
|
|
local function buildRoad(map: Folder)
|
|
local road = Instance.new("Folder")
|
|
road.Name = "Expressway"
|
|
road.Parent = map
|
|
local struct = Instance.new("Folder")
|
|
struct.Name = "Structure"
|
|
struct.Parent = map
|
|
|
|
local samples = Circuit.samples
|
|
local n = #samples
|
|
local plan = planPA()
|
|
|
|
-- Running arc length around the loop, so lane dashes keep a continuous
|
|
-- 20 m rhythm instead of restarting at every sample.
|
|
local segStart = 0
|
|
|
|
for i = 1, n do
|
|
local a = samples[i]
|
|
local b = samples[(i % n) + 1]
|
|
local delta = b.pos - a.pos
|
|
if delta.Magnitude < 0.5 then
|
|
continue
|
|
end
|
|
local len = delta.Magnitude + 2
|
|
local mid = (a.pos + b.pos) / 2
|
|
local dir = delta.Unit
|
|
local flatDir = Vector3.new(dir.X, 0, dir.Z)
|
|
flatDir = flatDir.Magnitude > 0.01 and flatDir.Unit or Vector3.xAxis
|
|
local right = flatDir:Cross(UP)
|
|
local cf = CFrame.lookAt(mid, mid + dir, UP)
|
|
|
|
-- How hard this segment turns, used to decide where the real road
|
|
-- would forbid lane changes (solid yellow) rather than allow them.
|
|
local prev = samples[((i - 2) % n) + 1]
|
|
local inDir = (a.pos - prev.pos)
|
|
inDir = Vector3.new(inDir.X, 0, inDir.Z)
|
|
local curvature = 0
|
|
if inDir.Magnitude > 0.01 then
|
|
curvature = math.acos(math.clamp(inDir.Unit:Dot(flatDir), -1, 1))
|
|
end
|
|
|
|
local deck = part({
|
|
Name = "Road_" .. i,
|
|
Size = Vector3.new(widthAt(i), 2, len),
|
|
CFrame = cf,
|
|
Color = C_ASPHALT,
|
|
Material = Enum.Material.Asphalt,
|
|
})
|
|
deck.Parent = road
|
|
|
|
-- CENTRAL DIVIDER ---------------------------------------------------
|
|
-- Real Wangan has a solid median, not a painted line: two lanes each
|
|
-- way, physically separated. This is collidable, so a head-on into
|
|
-- oncoming traffic isn't possible -- you hit the divider instead.
|
|
local HALF = widthAt(i) / 2
|
|
local MED_HALF = MEDIAN_WIDTH / 2
|
|
-- ...except at the PA junctions, where it opens so both directions of
|
|
-- travel can cross into the parking area (see planPA).
|
|
if not plan.medianOpen[i] then
|
|
local median = part({
|
|
Name = "Median_" .. i,
|
|
Size = Vector3.new(MEDIAN_WIDTH, MEDIAN_HEIGHT, len),
|
|
CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT / 2, 0),
|
|
Color = C_PARAPET,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
median.Parent = struct
|
|
-- Dark steel anti-glare fence along the median -- on the real road
|
|
-- this strip of vertical blades is what separates the directions.
|
|
local fence = part({
|
|
Name = "MedianFence",
|
|
Size = Vector3.new(0.8, 2.6, len),
|
|
CFrame = cf * CFrame.new(0, 1 + MEDIAN_HEIGHT + 1.3, 0),
|
|
Color = C_RAIL,
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
fence.Parent = struct
|
|
end
|
|
|
|
-- LANE MARKINGS -----------------------------------------------------
|
|
-- Lane count varies by section (see SECTION PROFILE above): most of the
|
|
-- C1 is 2+2, but 汐留JCT-浜崎橋JCT is 3+3. A 3-lane carriageway needs
|
|
-- TWO boundary lines, not one, so the markings are generated per lane
|
|
-- boundary rather than assuming a single centre dash.
|
|
--
|
|
-- Dashes are driven by ARC LENGTH along the circuit, not by sample
|
|
-- index. That matters: samples are 60 studs (16.8 m) apart but the
|
|
-- legal dash cycle is 20 m, so an index-based dash can never land on
|
|
-- the right rhythm -- it has to be computed in metres and clipped to
|
|
-- each segment.
|
|
local nLanes = lanesAt(i)
|
|
-- Lane changes are prohibited (yellow line) through the tunnels, on the
|
|
-- tight curves, and through Ginza -- where 跨道橋 piers stand between
|
|
-- the lanes, which is exactly why the real road forbids it there.
|
|
local noLaneChange = a.tunnel or curvature > TIGHT_CURVE or GINZA_ZONES[a.piece] == true
|
|
local laneColor = noLaneChange and C_PAINT_YELLOW or C_PAINT_WHITE
|
|
|
|
for _, s in ipairs({ -1, 1 }) do
|
|
-- 車道外側線: solid, continuous, 0.20 m wide.
|
|
local edge = part({
|
|
Name = "EdgeLine",
|
|
Size = Vector3.new(EDGE_LINE_W, MARK_H, len),
|
|
CFrame = cf * CFrame.new(s * (HALF - 1.6), MARK_Y, 0),
|
|
Color = C_PAINT_WHITE,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
edge.Parent = road
|
|
|
|
-- 車線境界線: one per boundary between adjacent lanes.
|
|
for lane = 1, nLanes - 1 do
|
|
local cwMid = MED_HALF + LANE_W * lane
|
|
if laneColor == C_PAINT_YELLOW then
|
|
-- Yellow no-lane-change line is SOLID, not dashed.
|
|
local solid = part({
|
|
Name = "LaneLine",
|
|
Size = Vector3.new(LANE_LINE_W, MARK_H, len),
|
|
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, 0),
|
|
Color = C_PAINT_YELLOW,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
solid.Parent = road
|
|
else
|
|
-- Dashed: paint the parts of the 20 m cycle that fall inside
|
|
-- this segment. segStart is the distance travelled around the
|
|
-- loop, so the rhythm stays continuous across segment joins.
|
|
local segLen = delta.Magnitude
|
|
local k = math.floor(segStart / DASH_CYCLE)
|
|
while k * DASH_CYCLE < segStart + segLen do
|
|
local pStart = math.max(k * DASH_CYCLE, segStart)
|
|
local pEnd = math.min(k * DASH_CYCLE + DASH_PAINT, segStart + segLen)
|
|
if pEnd > pStart then
|
|
-- Convert back to an offset from the segment centre.
|
|
local mid01 = ((pStart + pEnd) / 2 - segStart) / segLen
|
|
local dash = part({
|
|
Name = "Dash",
|
|
Size = Vector3.new(LANE_LINE_W, MARK_H, pEnd - pStart),
|
|
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, (mid01 - 0.5) * segLen),
|
|
Color = C_PAINT_WHITE,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
dash.Parent = road
|
|
end
|
|
k += 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- OUTER EDGE BARRIERS -----------------------------------------------
|
|
-- Visible concrete Jersey barrier sitting ON the deck (this is the part
|
|
-- you actually see), then an invisible taller wall just outside it as a
|
|
-- backstop against tunnelling at high speed.
|
|
-- At a PA junction the barrier AND the kill wall must open on the side
|
|
-- the ramp leaves through, or the ramp would dead-end into a wall.
|
|
--
|
|
-- FIXED (2026-08-02): this used `plan.medianOpen[i]` and `continue`d
|
|
-- for BOTH sides, stripping barrier + rail + kill wall from 26 samples
|
|
-- of mainline on both edges. Two corrections:
|
|
-- * use `rampOpen` (5 samples) not `medianOpen` (13) -- the median
|
|
-- opening is a different, wider thing for a different purpose.
|
|
-- * only skip the ONE side the PA is actually on. The far edge of
|
|
-- the expressway has a drop behind it and must never open.
|
|
local junction = plan.rampOpen[i]
|
|
local paSide = 0
|
|
if junction then
|
|
-- Which side of the carriageway is the PA on? Positive dot with
|
|
-- `right` means the PA lies to the +1 side at this sample.
|
|
local toPA = Vector3.new(Circuit.PA_CENTER.X - mid.X, 0, Circuit.PA_CENTER.Z - mid.Z)
|
|
if toPA.Magnitude > 0.01 then
|
|
paSide = (toPA.Unit:Dot(right) >= 0) and 1 or -1
|
|
end
|
|
end
|
|
for _, side in ipairs({ -1, 1 }) do
|
|
if junction and side == paSide then
|
|
continue
|
|
end
|
|
local boff = right * (side * (HALF - BARRIER_T / 2))
|
|
local bcf = CFrame.lookAt(mid + boff, mid + boff + dir, UP)
|
|
-- White concrete parapet...
|
|
local barrier = part({
|
|
Name = "Barrier",
|
|
Size = Vector3.new(BARRIER_T, BARRIER_H, len),
|
|
CFrame = bcf * CFrame.new(0, 1 + BARRIER_H / 2, 0),
|
|
Color = C_PARAPET,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
barrier.Parent = struct
|
|
-- ...topped by the dark metal railing. Every photo of the Shutoko
|
|
-- shows this two-tone parapet+railing edge; without the railing the
|
|
-- road reads as a generic slab instead of a Japanese expressway.
|
|
local rail = part({
|
|
Name = "EdgeRail",
|
|
Size = Vector3.new(0.6, RAIL_H, len),
|
|
CFrame = bcf * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0),
|
|
Color = C_RAIL,
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
rail.Parent = struct
|
|
|
|
local woff = right * (side * (HALF + 1))
|
|
local wall = part({
|
|
Name = "KillWall",
|
|
Size = Vector3.new(2, WALL_HEIGHT, len + 6),
|
|
CFrame = CFrame.lookAt(mid + woff, mid + woff + dir, UP) * CFrame.new(0, WALL_HEIGHT / 2, 0),
|
|
Transparency = 1,
|
|
CanCollide = true,
|
|
})
|
|
wall.Parent = struct
|
|
end
|
|
|
|
if a.tunnel then
|
|
-- Yamate/C1-style bore: walls, ceiling, strip lighting
|
|
local h = 24
|
|
for _, side in ipairs({ -1, 1 }) do
|
|
local woff = right * (side * (HALF + 1.5))
|
|
local w = part({
|
|
Name = "TunnelWall",
|
|
Size = Vector3.new(3, h, len + 2),
|
|
CFrame = CFrame.lookAt(mid + woff, mid + woff + dir, UP) * CFrame.new(0, h / 2, 0),
|
|
Color = Color3.fromRGB(38, 38, 46),
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
w.Parent = struct
|
|
end
|
|
local ceil = part({
|
|
Name = "TunnelCeiling",
|
|
Size = Vector3.new(widthAt(i) + 6, 2, len + 2),
|
|
CFrame = cf * CFrame.new(0, h + 1, 0),
|
|
Color = Color3.fromRGB(30, 30, 38),
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
ceil.Parent = struct
|
|
-- Tunnel lighting is high-pressure sodium (warm amber) with newer
|
|
-- LED sections in cool white. Real tunnel "basic lighting" is a
|
|
-- continuous run, not isolated dots, so the strip is placed on
|
|
-- every segment rather than every other one.
|
|
local strip = part({
|
|
Name = "TunnelLight",
|
|
Size = Vector3.new(widthAt(i) * 0.5, 0.6, len),
|
|
CFrame = cf * CFrame.new(0, h - 0.5, 0),
|
|
Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED,
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
strip.Parent = struct
|
|
else
|
|
-- BRIDGE / VIADUCT SUPERSTRUCTURE ------------------------------
|
|
-- Circuit.luau flags 474 samples as bridge=1, but nothing was ever
|
|
-- reading that flag -- every elevated span rendered as the same
|
|
-- plain slab on square columns. On the real C1 the flagged
|
|
-- sections are steel box-girder spans (鋼箱桁橋) over the old
|
|
-- river/canal alignments: a deep girder under the deck, sitting on
|
|
-- a transverse pier cap carried by round columns on a wider pitch.
|
|
if a.bridge then
|
|
-- Deep girder immediately under the deck -- this is the single
|
|
-- most recognisable feature of a Shutoko viaduct in profile.
|
|
local girder = part({
|
|
Name = "BridgeGirder",
|
|
Size = Vector3.new(widthAt(i) * 0.72, GIRDER_H, len),
|
|
CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, 0),
|
|
Color = C_GIRDER,
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
girder.Parent = struct
|
|
-- Girders are fabricated in segments; the transverse stiffener
|
|
-- ribs between them are what give the underside its rhythm.
|
|
local rib = part({
|
|
Name = "GirderRib",
|
|
Size = Vector3.new(widthAt(i) * 0.78, GIRDER_H * 0.82, 1.2),
|
|
CFrame = cf * CFrame.new(0, -1 - GIRDER_H / 2, len / 2),
|
|
Color = C_GIRDER,
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
rib.Parent = struct
|
|
|
|
-- Piers on a wider pitch than the viaduct columns, with the
|
|
-- pier cap (橋脚梁) spanning the full deck width.
|
|
if i % 6 == 0 and a.pos.Y > 14 then
|
|
local capY = a.pos.Y - 1 - GIRDER_H - 1.6
|
|
local cap = part({
|
|
Name = "PierCap",
|
|
Size = Vector3.new(widthAt(i) * 0.92, 3.2, 7),
|
|
CFrame = CFrame.new(mid.X, capY, mid.Z) * CFrame.Angles(0, math.atan2(flatDir.X, flatDir.Z), 0),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
cap.Parent = struct
|
|
local col = part({
|
|
Name = "PierColumn",
|
|
Size = Vector3.new(8, capY - 1.6, 8),
|
|
CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
Shape = Enum.PartType.Cylinder,
|
|
})
|
|
-- Cylinder's axis is X, so stand it upright.
|
|
col.CFrame = CFrame.new(mid.X, (capY - 1.6) / 2, mid.Z) * CFrame.Angles(0, 0, math.rad(90))
|
|
col.Parent = struct
|
|
end
|
|
-- Elevated viaduct: support pillars down to street level
|
|
elseif i % 3 == 0 and a.pos.Y > 18 then
|
|
local pil = part({
|
|
Name = "Pillar",
|
|
Size = Vector3.new(9, a.pos.Y, 9),
|
|
CFrame = CFrame.new(mid.X, a.pos.Y / 2 - 1, mid.Z),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
pil.Parent = struct
|
|
end
|
|
-- Sodium lamps along the deck. Japanese expressway lighting is
|
|
-- warm high-pressure sodium on a ~35 m pole pitch; samples are
|
|
-- 60 studs (16.8 m) apart so every 2nd sample ~= 33.6 m.
|
|
if i % 2 == 0 then
|
|
local lamp = part({
|
|
Name = "RoadLight",
|
|
Size = Vector3.new(1.4, 1.4, 1.4),
|
|
CFrame = CFrame.new(mid + right * HALF + Vector3.new(0, 5.5, 0)),
|
|
Color = C_SODIUM,
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
lamp.Parent = struct
|
|
end
|
|
-- Delineators (視線誘導標): small reflective posts on the outer
|
|
-- edge that mark the road's line ahead at night. Real spacing on
|
|
-- curves is tight; ~17 m here.
|
|
local delin = part({
|
|
Name = "Delineator",
|
|
Size = Vector3.new(0.5, 1.1, 0.5),
|
|
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 2.5, 0)),
|
|
Color = C_PAINT_WHITE,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
delin.Parent = struct
|
|
local eye = part({
|
|
Name = "DelineatorEye",
|
|
Size = Vector3.new(0.36, 0.36, 0.36),
|
|
CFrame = CFrame.new(mid + right * (HALF - 2.6) + Vector3.new(0, 3.0, 0)),
|
|
Color = Color3.fromRGB(255, 190, 80),
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
eye.Parent = struct
|
|
if i % 9 == 0 then
|
|
local pylon = part({
|
|
Name = "Pylon",
|
|
Size = Vector3.new(1, 14, 1),
|
|
CFrame = CFrame.new(mid - right * (HALF + 3) + Vector3.new(0, 7, 0)),
|
|
Color = (i % 18 == 0) and NEON_PINK or NEON_BLUE,
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
pylon.Parent = struct
|
|
end
|
|
end
|
|
|
|
-- GINZA 跨道橋 (bridges crossing OVER the expressway) ----------------
|
|
-- The Ginza section runs in the filled-in 楓川・築地川 river beds, so it
|
|
-- sits BELOW street level and the surviving city bridges cross OVER it.
|
|
-- Their piers land in the median, physically between the carriageways --
|
|
-- which is the real reason lane changes are banned here (the yellow
|
|
-- line above is driven by GINZA_ZONES for exactly this reason).
|
|
-- Placed outside the tunnel/viaduct branch: Ginza has 0 bridge-flagged
|
|
-- samples, so nesting this under `a.bridge` would never render it.
|
|
if GINZA_ZONES[a.piece] and i % 7 == 0 then
|
|
local deckY = a.pos.Y + 26
|
|
local yaw = math.atan2(flatDir.X, flatDir.Z)
|
|
local ov = part({
|
|
Name = "GinzaOverpass",
|
|
Size = Vector3.new(widthAt(i) + 70, 3.4, 26),
|
|
CFrame = CFrame.new(mid.X, deckY, mid.Z) * CFrame.Angles(0, yaw, 0),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
ov.Parent = struct
|
|
for _, es in ipairs({ -1, 1 }) do
|
|
local par = part({
|
|
Name = "OverpassParapet",
|
|
Size = Vector3.new(widthAt(i) + 70, 3.6, 1.4),
|
|
CFrame = CFrame.new(mid.X, deckY + 3.4, mid.Z)
|
|
* CFrame.Angles(0, yaw, 0)
|
|
* CFrame.new(0, 0, es * 12.3),
|
|
Color = C_PARAPET,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
par.Parent = struct
|
|
end
|
|
-- Centre pier, standing in the median between the directions.
|
|
local pierH = deckY - a.pos.Y - 1.7
|
|
local pier = part({
|
|
Name = "OverpassPier",
|
|
Size = Vector3.new(4.2, pierH, 9),
|
|
CFrame = CFrame.new(mid.X, a.pos.Y + pierH / 2 + 1, mid.Z) * CFrame.Angles(0, yaw, 0),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
pier.Parent = struct
|
|
end
|
|
|
|
-- 導流帯 / gore zebra at the PA ramp junctions --------------------
|
|
-- Where the ramp splits off the mainline the real road paints a
|
|
-- zebra-hatched no-drive wedge in the gore. 45 cm bars every 1.0 m
|
|
-- per 全標協 2018 pp.97-98.
|
|
if plan.medianOpen[i] then
|
|
local nBars = math.floor(delta.Magnitude / ZEBRA_PITCH)
|
|
for k = 0, nBars - 1 do
|
|
local z = (k * ZEBRA_PITCH - delta.Magnitude / 2) + ZEBRA_PITCH / 2
|
|
for _, s in ipairs({ -1, 1 }) do
|
|
local bar = part({
|
|
Name = "GoreZebra",
|
|
Size = Vector3.new(6, MARK_H, ZEBRA_W),
|
|
CFrame = cf * CFrame.new(s * (HALF - 5), MARK_Y, z),
|
|
Color = C_PAINT_WHITE,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
bar.Parent = road
|
|
end
|
|
end
|
|
-- 45 cm boundary line separating the through lane from the
|
|
-- speed-change lane, per 全標協 2012 p.42.
|
|
for _, s in ipairs({ -1, 1 }) do
|
|
local rl = part({
|
|
Name = "RampBoundary",
|
|
Size = Vector3.new(RAMP_LINE_W, MARK_H, len),
|
|
CFrame = cf * CFrame.new(s * (HALF - 8.5), MARK_Y, 0),
|
|
Color = C_PAINT_WHITE,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
rl.Parent = road
|
|
end
|
|
end
|
|
|
|
-- Advance the running arc length so the next segment's dashes
|
|
-- continue the 20 m rhythm instead of restarting.
|
|
segStart += delta.Magnitude
|
|
end
|
|
end
|
|
|
|
-- Regulatory speed limit sign (規制標識 323 最高速度) -----------------------------
|
|
-- Circular, white face, red annulus, black numerals. The real C1 is posted at
|
|
-- 50 km/h (40 in the tightest junction sections) -- verified against the route
|
|
-- article's own signage table, not the 80 km/h used on the Bayshore Route.
|
|
local function makeSpeedSign(kmh: number, cf: CFrame, parent: Instance)
|
|
local post = part({
|
|
Name = "SignPost",
|
|
Size = Vector3.new(0.6, 12, 0.6),
|
|
CFrame = cf * CFrame.new(0, -6, 0),
|
|
Color = C_RAIL,
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
post.Parent = parent
|
|
|
|
local face = part({
|
|
Name = "SpeedLimitSign",
|
|
Size = Vector3.new(6, 6, 0.4),
|
|
CFrame = cf,
|
|
Color = Color3.fromRGB(255, 255, 255),
|
|
Material = Enum.Material.SmoothPlastic,
|
|
Shape = Enum.PartType.Cylinder,
|
|
})
|
|
-- Cylinder's flat faces point along X, so rotate it to face the driver.
|
|
face.CFrame = cf * CFrame.Angles(0, math.rad(90), 0)
|
|
face.Parent = parent
|
|
|
|
for _, faceId in ipairs({ Enum.NormalId.Right, Enum.NormalId.Left }) do
|
|
local gui = Instance.new("SurfaceGui")
|
|
gui.Face = faceId
|
|
gui.CanvasSize = Vector2.new(256, 256)
|
|
gui.Parent = face
|
|
|
|
local disc = Instance.new("Frame")
|
|
disc.Size = UDim2.fromScale(1, 1)
|
|
disc.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
|
|
disc.BorderSizePixel = 0
|
|
disc.Parent = gui
|
|
local dc = Instance.new("UICorner")
|
|
dc.CornerRadius = UDim.new(1, 0)
|
|
dc.Parent = disc
|
|
-- Red annulus is ~11% of the diameter on the JP plate.
|
|
local ring = Instance.new("UIStroke")
|
|
ring.Color = Color3.fromRGB(200, 30, 45)
|
|
ring.Thickness = 28
|
|
ring.Parent = disc
|
|
|
|
local num = Instance.new("TextLabel")
|
|
num.Size = UDim2.fromScale(0.72, 0.56)
|
|
num.Position = UDim2.fromScale(0.14, 0.22)
|
|
num.BackgroundTransparency = 1
|
|
num.Font = Enum.Font.GothamBold
|
|
num.TextColor3 = Color3.fromRGB(20, 20, 20)
|
|
num.TextScaled = true
|
|
num.Text = tostring(kmh)
|
|
num.Parent = disc
|
|
end
|
|
end
|
|
|
|
-- Kilometre post (キロポスト) ----------------------------------------------------
|
|
local function makeKmPost(km: number, cf: CFrame, parent: Instance)
|
|
local board = part({
|
|
Name = "KmPost",
|
|
Size = Vector3.new(2.6, 3.2, 0.3),
|
|
CFrame = cf,
|
|
Color = C_SIGN_GREEN,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
board.Parent = parent
|
|
local gui = Instance.new("SurfaceGui")
|
|
gui.Face = Enum.NormalId.Front
|
|
gui.CanvasSize = Vector2.new(130, 160)
|
|
gui.Parent = board
|
|
local label = Instance.new("TextLabel")
|
|
label.Size = UDim2.fromScale(1, 1)
|
|
label.BackgroundTransparency = 1
|
|
label.Font = Enum.Font.GothamBold
|
|
label.TextColor3 = C_SIGN_WHITE
|
|
label.TextScaled = true
|
|
label.Text = string.format("%.1f", km)
|
|
label.Parent = gui
|
|
end
|
|
|
|
-- Junction gantry signs at each real interchange ---------------------------------
|
|
local function buildJunctionSigns(map: Folder)
|
|
local folder = Instance.new("Folder")
|
|
folder.Name = "Signs"
|
|
folder.Parent = map
|
|
|
|
local samples = Circuit.samples
|
|
local n = #samples
|
|
local seen: { [string]: boolean } = {}
|
|
for i = 1, n do
|
|
local s = samples[i]
|
|
if s.tunnel or seen[s.piece] then
|
|
continue
|
|
end
|
|
seen[s.piece] = true
|
|
local fwd = Circuit.forwardAt(i)
|
|
local pos = s.pos + Vector3.new(0, 15, 0)
|
|
makeSign(s.piece, CFrame.lookAt(pos, pos - fwd, UP), folder)
|
|
end
|
|
|
|
-- Speed limit repeaters and kilometre posts around the loop.
|
|
local dist = 0
|
|
local nextKm = 0
|
|
for i = 1, n do
|
|
local a = samples[i]
|
|
local b = samples[(i % n) + 1]
|
|
local seg = (b.pos - a.pos).Magnitude
|
|
local fwd = Circuit.forwardAt(i)
|
|
local flat = Vector3.new(fwd.X, 0, fwd.Z)
|
|
flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis
|
|
local right = flat:Cross(UP)
|
|
|
|
-- Posted speed: 50 km/h on the mainline, but 40 km/h inside the
|
|
-- junction sections (江戸橋JCT内, 浜崎橋JCT内, 京橋JCT-旧白魚橋乗継所
|
|
-- are all posted 40 in the route's own 車線・最高速度 table). Signs
|
|
-- repeat roughly every 1 km; junction plates go up more often because
|
|
-- the restricted stretches are short.
|
|
local jct = isJctSample(i)
|
|
local every = jct and 24 or 60
|
|
if i % every == 0 and not a.tunnel then
|
|
local p = a.pos + right * (widthAt(i) / 2 - 3) + Vector3.new(0, 9, 0)
|
|
makeSpeedSign(jct and 40 or 50, CFrame.lookAt(p, p - flat, UP), folder)
|
|
end
|
|
|
|
-- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs).
|
|
if dist >= nextKm * 500 * M then
|
|
local p = a.pos + right * (widthAt(i) / 2 - 2) + Vector3.new(0, 4, 0)
|
|
makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder)
|
|
nextKm += 1
|
|
end
|
|
dist += seg
|
|
end
|
|
end
|
|
|
|
-- Daikoku-style PA ---------------------------------------------------------------
|
|
local function buildPA(map: Folder)
|
|
local pa = Instance.new("Folder")
|
|
pa.Name = "ParkingArea"
|
|
pa.Parent = map
|
|
|
|
local c = Circuit.PA_CENTER
|
|
local R = Circuit.PA_RADIUS
|
|
|
|
-- Real Daikoku Futo PA: the parking apron is a huge flat lot, and the loop
|
|
-- ramps sweep AROUND and OVER it on pillars (see the aerial reference) --
|
|
-- the parking is inside the ring, at deck level, with the ring elevated
|
|
-- above it. The apron reaches out under the ring so there is no void gap.
|
|
local APRON = Circuit.PA_RADIUS + 130 + 24 -- ringRadius + half ring width
|
|
local slab = part({
|
|
Name = "PAFloor",
|
|
Size = Vector3.new(APRON * 2, 2, APRON * 2),
|
|
CFrame = CFrame.new(c),
|
|
Color = C_ASPHALT,
|
|
Material = Enum.Material.Asphalt,
|
|
})
|
|
slab.Parent = pa
|
|
|
|
-- REAL DAIKOKU FUTO PA LAYOUT ---------------------------------------------
|
|
-- Footprint measured from OpenStreetMap way 59608205 (大黒ふ頭パーキングエリア):
|
|
-- the apron is 181 m x 174 m (1.6 ha) -- a big, essentially SQUARE lot, not
|
|
-- a circular one. The spiral ramps sweep around and over it, which is the
|
|
-- "螺旋状の道路" the PA is famous for as a photo backdrop.
|
|
--
|
|
-- Capacity from ja.wikipedia 大黒パーキングエリア (首都高速で最大のPA, opened
|
|
-- 1989-09-27): 小型 341 + 大型 59 + 身障者用 4 = 404 spaces. Those three
|
|
-- counts are reproduced EXACTLY below rather than approximated, because the
|
|
-- lot's character is the sheer amount of parking.
|
|
--
|
|
-- Stall dimensions (小型 2.5 x 5.0 m, 大型 3.3 x 12 m, 身障者用 3.5 x 5.0 m,
|
|
-- 6 m aisles) are conventional Japanese parking-design values -- NOT read
|
|
-- from a primary standard, unlike the road markings. Paint is 15 cm, which
|
|
-- does match the 全標協 line width used on the mainline.
|
|
-- APRON EXTENT. Previously 181 x 174 m, which was the bounding box of the
|
|
-- OSM parking polygon alone. That polygon covers only the STALLS -- the
|
|
-- buildings, terrace and fountain sit alongside it and are separate OSM
|
|
-- ways, so a slab sized to the stalls left 本館, フォンターナ and the Lawson
|
|
-- hanging over the edge (measured: 4 of 12 features off-slab).
|
|
--
|
|
-- Sized here to the FULL surveyed site instead. After the +32 m north shift
|
|
-- and 0.88 uniform scale applied to the survey (see the building block),
|
|
-- the outermost features reach ~100 m radius, so a 210 x 200 m slab clears
|
|
-- everything with a margin for circulation. Still inside the ring road,
|
|
-- whose clear inner radius is 103.0 m.
|
|
local APRON_X = 210 * M
|
|
local APRON_Z = 200 * M
|
|
local STALL_W = 2.5 * M
|
|
local STALL_D = 5.0 * M
|
|
local BIG_W = 3.3 * M
|
|
local BIG_D = 12.0 * M
|
|
local ACC_W = 3.5 * M
|
|
local AISLE = 6.0 * M
|
|
local LINE_W = 0.15 * M
|
|
local DECK_Y = c.Y + 1 -- top face of the apron slab
|
|
local C_PAINT = Color3.fromRGB(235, 235, 240)
|
|
|
|
-- Painted markings are PAINT, not kerbs. They must be flush with the apron.
|
|
--
|
|
-- BUG (2026-08-02, test drive): these were spawned 2.2 studs tall sitting
|
|
-- at DECK_Y + 0.1, so every stall divider stood ~1.2 studs PROUD of the
|
|
-- tarmac -- a kerb-height ridge exactly where you park. Driving the rows
|
|
-- meant bouncing over 300+ concrete strips ("like a whole foot out of the
|
|
-- ground" -- measured 1.2, and a stud is ~a foot, so: exactly right).
|
|
--
|
|
-- Fix: 0.12 studs thick, sunk so its TOP sits a hair above the deck. A
|
|
-- decal would be flatter still, but a thin slab keeps this buildable from
|
|
-- pure geometry with no asset IDs, and 0.06 studs proud is under tyre
|
|
-- resolution -- you cannot feel it.
|
|
local PAINT_T = 0.12
|
|
|
|
-- STALL PAINT DISABLED (2026-08-02, user call): the generated stall
|
|
-- markings are being replaced with pre-made parking assets from the
|
|
-- Creator Store. Set this back to true to restore the painted lines.
|
|
--
|
|
-- Deliberately a FLAG rather than deleting markRow and its callers: the
|
|
-- row placement below is surveyed against OSM (二番館 frontage, the 大型
|
|
-- rows, the 身障者用 bays) and feeds PACapacity, which the headless
|
|
-- verifier asserts. Ripping it out would throw away that survey work and
|
|
-- silently change the recorded capacity. With the flag off, every stall
|
|
-- position is still computed -- they just emit no geometry.
|
|
local DRAW_STALL_PAINT = false
|
|
|
|
local function paint(name, size, x, z, colour)
|
|
if not DRAW_STALL_PAINT then
|
|
return nil
|
|
end
|
|
local m = part({
|
|
Name = name,
|
|
-- caller passes the old 2.2 in size.Y; ignore it and use PAINT_T
|
|
Size = Vector3.new(size.X, PAINT_T, size.Z),
|
|
CFrame = CFrame.new(x, DECK_Y + PAINT_T / 2 - 0.06, z),
|
|
Color = colour or C_PAINT,
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
m.Parent = pa
|
|
return m
|
|
end
|
|
|
|
-- One row of stalls: n+1 divider lines perpendicular to the aisle, plus the
|
|
-- head line the cars nose up to. `sign` is the direction the stalls extend
|
|
-- away from the aisle edge.
|
|
local function markRow(name, x0, zEdge, n, w, d, sign)
|
|
local zMid = zEdge + sign * d / 2
|
|
for k = 0, n do
|
|
paint(name .. "Line", Vector3.new(LINE_W, 2.2, d), x0 + k * w, zMid)
|
|
end
|
|
paint(name .. "Head", Vector3.new(n * w, 2.2, LINE_W), x0 + n * w / 2, zEdge)
|
|
end
|
|
|
|
-- 小型 341: eleven rows of 31, stacked as back-to-back pairs with 6 m aisles.
|
|
-- Z origin is derived, not eyeballed: the block starts one stall-depth clear
|
|
-- of the フォンターナ frontage and must finish north of the 大型 rows. Placing
|
|
-- it by hand overlapped the truck bays by 42 studs -- see verify_daikoku_pa.
|
|
--
|
|
-- 2026-08-02: this used to hang off the old (wrong) north-edge 二番館
|
|
-- position. The building is now surveyed to OSM way 88790141, centre
|
|
-- (+13.9 E, +123.1 N) m with a 43 m long axis, so its southern frontage is
|
|
-- at 123.1 - 43/2 = 101.6 m north in OSM coords. That must go through the
|
|
-- SAME shift+scale as the buildings themselves (OSM_SHIFT_N = 32 m,
|
|
-- OSM_SCALE = 0.88, both defined with the building block below) or the
|
|
-- stalls detach from the building they are supposed to line up with.
|
|
local SMALL_PER_ROW = 31
|
|
local SMALL_ROWS = 11 -- 31 x 11 = 341 exactly
|
|
local smallX0 = c.X - 20 * M
|
|
local pairPitch = 2 * STALL_D + AISLE
|
|
local nibanFace = c.Z - (123.1 - 43 / 2 - 32.0) * 0.88 * M
|
|
local smallZ0 = nibanFace + STALL_D + 8
|
|
local placedSmall = 0
|
|
for row = 0, SMALL_ROWS - 1 do
|
|
local pairIdx = math.floor(row / 2)
|
|
local zc = smallZ0 + pairIdx * pairPitch
|
|
local sign = (row % 2 == 0) and -1 or 1
|
|
markRow("Stall", smallX0, zc, SMALL_PER_ROW, STALL_W, STALL_D, sign)
|
|
placedSmall += SMALL_PER_ROW
|
|
end
|
|
|
|
-- 大型 59: trucks and buses park along the south edge where the bays are
|
|
-- long enough -- three rows of 20/20/19.
|
|
local bigZ = c.Z + APRON_Z / 2 - BIG_D - 14
|
|
local bigCounts = { 20, 20, 19 } -- = 59
|
|
local placedBig = 0
|
|
for r, n in ipairs(bigCounts) do
|
|
markRow("BigStall", c.X - APRON_X / 2 + 18 * M, bigZ - (r - 1) * (BIG_D + AISLE), n, BIG_W, BIG_D, 1)
|
|
placedBig += n
|
|
end
|
|
|
|
-- 身障者用 4: hard against the 本館 entrance, as they are in reality.
|
|
local accX = c.X - APRON_X / 2 + 34 * M
|
|
markRow("AccStall", accX, c.Z - 2 * ACC_W, 4, ACC_W, STALL_D, 1)
|
|
local placedAcc = 4
|
|
|
|
-- Recorded so the headless verifier can assert the real capacity.
|
|
local cap = Instance.new("StringValue")
|
|
cap.Name = "PACapacity"
|
|
cap.Value = ("small=%d,large=%d,accessible=%d,total=%d"):format(
|
|
placedSmall,
|
|
placedBig,
|
|
placedAcc,
|
|
placedSmall + placedBig + placedAcc
|
|
)
|
|
cap.Parent = pa
|
|
|
|
-- Light towers at the apron corners.
|
|
-- Height 60 -> 40 (2026-08-02). With PA_LIFT at 56 the mainline slab
|
|
-- underside sits at ~101; a 60-stud mast topped out at 107 and would have
|
|
-- speared the deck -- swapping one clash for another. At 40 the head sits
|
|
-- at 87, ~14 studs clear. Real PA lighting under a viaduct is short for
|
|
-- exactly this reason. If PA_LIFT changes again, re-check this.
|
|
local MAST_H = 40
|
|
for _, sx in ipairs({ -1, 1 }) do
|
|
for _, sz in ipairs({ -1, 1 }) do
|
|
local lx = c.X + sx * (APRON_X / 2 - 10 * M)
|
|
local lz = c.Z + sz * (APRON_Z / 2 - 10 * M)
|
|
local mast = part({
|
|
Name = "PAMast",
|
|
Size = Vector3.new(3, MAST_H, 3),
|
|
CFrame = CFrame.new(lx, DECK_Y + MAST_H / 2, lz),
|
|
Color = Color3.fromRGB(70, 70, 78),
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
mast.Parent = pa
|
|
local head = part({
|
|
Name = "PALight",
|
|
Size = Vector3.new(10, 2, 10),
|
|
CFrame = CFrame.new(lx, DECK_Y + MAST_H, lz),
|
|
Color = Color3.fromRGB(255, 240, 200),
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
head.Parent = pa
|
|
local pl = Instance.new("PointLight")
|
|
pl.Brightness = 3
|
|
pl.Range = 120
|
|
pl.Color = Color3.fromRGB(255, 235, 190)
|
|
pl.Parent = head
|
|
end
|
|
end
|
|
|
|
-- RING ROAD + ON/OFF RAMPS ------------------------------------------------
|
|
-- At the real Daikoku Futo PA the slip road wraps right around the parking
|
|
-- area in a full circle, with the on-ramp and off-ramp feeding that loop.
|
|
-- Build the ring as a fan of short arc segments, then two ramps joining it
|
|
-- to the expressway at the junctions the median opens for.
|
|
local samples = Circuit.samples
|
|
local plan = planPA()
|
|
local RING_R = plan.ringRadius
|
|
local RING_W = 44
|
|
local ARC_N = 72
|
|
|
|
local function deck(name, size, cframe, colour)
|
|
local d = part({
|
|
Name = name,
|
|
Size = size,
|
|
CFrame = cframe,
|
|
Color = colour or Color3.fromRGB(38, 38, 44),
|
|
Material = Enum.Material.Asphalt,
|
|
})
|
|
d.Parent = pa
|
|
return d
|
|
end
|
|
|
|
-- The ring itself
|
|
local ringY = c.Y
|
|
|
|
-- Ramp junctions, resolved BEFORE the ring is built so the ring wall can
|
|
-- open a gate at each one.
|
|
--
|
|
-- BUG (2026-08-02): the parapet loop below ran `for sgn in {1,-1}` over
|
|
-- EVERY segment, walling both edges of the ring with no opening anywhere.
|
|
-- The inner lip therefore sealed the parking apron completely: 72 of 72
|
|
-- rays cast outward from the SpawnPad hit a wall/barrier standing ~5.7
|
|
-- studs above the apron (tools/diag_pawall.luau), well over the ~2-3 studs
|
|
-- a car can climb -- so there was no way to drive out of the PA at all.
|
|
-- The mainline barrier already handles this correctly at PA junctions
|
|
-- ("the barrier AND the kill wall must open on the side the ramp leaves
|
|
-- through", see the deck loop above); the ring never got the same
|
|
-- treatment.
|
|
--
|
|
-- TWO gates are needed per junction, and both matter:
|
|
-- * INNER lip -- so the apron connects to the ring road at all.
|
|
-- * OUTER lip -- so the ring road connects to the ramp deck.
|
|
-- Opening only one still leaves the lot sealed.
|
|
--
|
|
-- The two gates are deliberately different sizes. Inside the ring the
|
|
-- apron is solid deck, so a wide opening is free. OUTSIDE the ring is a
|
|
-- ~46-stud drop to ground level, so that gate is kept tight and every
|
|
-- stud of it is backed by ramp/flare deck (see PARampFlare below) --
|
|
-- otherwise we would re-create the old "drove off the side and fell past
|
|
-- FallenPartsDestroyHeight" failure this structure was built to prevent.
|
|
local GATE_IN = 70 -- half-width of the apron-side opening
|
|
local GATE_OUT = 30 -- half-width of the ramp-side opening
|
|
local junctions = {}
|
|
for _, j in ipairs({
|
|
{ idx = plan.entryIdx, name = "PAOnRamp" },
|
|
{ idx = plan.exitIdx, name = "PAOffRamp" },
|
|
}) do
|
|
local road = samples[j.idx].pos
|
|
local toC = Vector3.new(c.X - road.X, 0, c.Z - road.Z)
|
|
if toC.Magnitude >= 1 then
|
|
local ringPt = Vector3.new(c.X, ringY, c.Z) - toC.Unit * RING_R
|
|
if (ringPt - road).Magnitude >= 4 then
|
|
-- roadY: the mainline deck height AT this junction. The road is
|
|
-- lifted over the PA (Circuit.PA_LIFT), so the ramp has to climb
|
|
-- to whatever the sample actually sits at -- reading it here
|
|
-- keeps ramp and road in lockstep if the lift profile changes.
|
|
--
|
|
-- heading: mainline direction of travel at this junction. The
|
|
-- helix ramp must leave the carriageway TANGENTIALLY, so it
|
|
-- needs the direction, not just the two endpoints.
|
|
local nxt = samples[(j.idx % #samples) + 1].pos
|
|
local fwd = Vector3.new(nxt.X - road.X, 0, nxt.Z - road.Z)
|
|
table.insert(junctions, {
|
|
name = j.name,
|
|
road = road,
|
|
ringPt = ringPt,
|
|
roadY = road.Y,
|
|
idx = j.idx,
|
|
heading = math.atan2(fwd.Z, fwd.X),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
local function nearJunction(pos, halfWidth)
|
|
for _, jn in ipairs(junctions) do
|
|
local d = Vector3.new(pos.X - jn.ringPt.X, 0, pos.Z - jn.ringPt.Z)
|
|
if d.Magnitude <= halfWidth then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
for i = 0, ARC_N - 1 do
|
|
local a0 = (i / ARC_N) * math.pi * 2
|
|
local a1 = ((i + 1) / ARC_N) * math.pi * 2
|
|
local p0 = Vector3.new(c.X + math.cos(a0) * RING_R, ringY, c.Z + math.sin(a0) * RING_R)
|
|
local p1 = Vector3.new(c.X + math.cos(a1) * RING_R, ringY, c.Z + math.sin(a1) * RING_R)
|
|
local segMid = (p0 + p1) / 2
|
|
local segLen = (p1 - p0).Magnitude + 1.5
|
|
local segCF = CFrame.lookAt(segMid, p1, UP)
|
|
-- PARing deck REMOVED (2026-08-02, user call): this laid a 44-stud-wide
|
|
-- band of road deck in a circle around the PA. Measured flush with the
|
|
-- apron (both tops at y=47.00) and entirely inside the apron footprint
|
|
-- (ring bbox X -4535..-3712 vs apron X -4538..-3710), so it was not
|
|
-- structural -- just a differently-shaded ring of pavement painted over
|
|
-- asphalt the apron already provides. Reads as a leftover ring where
|
|
-- the old barriers used to be. Deleting it leaves no hole: the apron
|
|
-- slab underneath is continuous.
|
|
--
|
|
-- The loop itself still runs: `junctions` and the ramp/flare geometry
|
|
-- below are positioned off these arc points, so the circle is still
|
|
-- the reference line -- it just no longer emits a deck.
|
|
|
|
-- Ring edge: NO barrier on the circulation road any more.
|
|
--
|
|
-- CHANGE (2026-08-02, user call, third pass): the edge used to be
|
|
-- emitted here, per ring-road arc segment, so it inherited the ring's
|
|
-- CIRCLE -- radius ~412 about the PA centre. But the parking area is a
|
|
-- SQUARE 828 x 828 apron (real Daikoku Futo is 181 m x 174 m, see the
|
|
-- layout note above). A circle inscribed in that square bulges past
|
|
-- the apron at the four corners and cuts INSIDE it at the four edge
|
|
-- midpoints, so the barrier visibly sliced across the lot instead of
|
|
-- following its perimeter -- read as a second ring.
|
|
--
|
|
-- The perimeter barrier is now built ONCE, after this loop, as four
|
|
-- straight runs along the apron edge. Nothing is emitted per-arc.
|
|
|
|
-- No pillars under the ring any more. The ring is now a GROUND-LEVEL
|
|
-- circulation road sharing the apron slab (Circuit.PA_LIFT raises the
|
|
-- MAINLINE over the PA instead of leaving both at y=46). It is the
|
|
-- expressway that flies overhead on pillars here, exactly as in the
|
|
-- reference photos: a flat lot with parked cars, elevated deck above.
|
|
-- Mainline pillars are built in buildRoad; see PA_LIFT.
|
|
end
|
|
|
|
-- PERIMETER BARRIER -------------------------------------------------------
|
|
-- A single simple barrier following the EDGE OF THE PARKING AREA, which is
|
|
-- a square apron (APRON half-extent), not the circular ring road. Built as
|
|
-- four straight runs of short segments so gaps can be opened wherever a
|
|
-- ramp crosses the boundary -- otherwise the barrier would seal the PA and
|
|
-- trap the player. Segment length is kept modest so a gap lands cleanly.
|
|
do
|
|
local PERIM_H = 3.0
|
|
local PERIM_T = 1.6
|
|
local SEG = 40
|
|
-- Sit the barrier just inside the slab edge so it reads as the lot's
|
|
-- own kerb line rather than floating off the asphalt.
|
|
local half = APRON - PERIM_T / 2 - 1
|
|
local baseY = DECK_Y + 1 + PERIM_H / 2
|
|
|
|
-- Ramps tee in at the ring; open the perimeter where a ramp's centre
|
|
-- line crosses the square, plus clearance for the full ramp width.
|
|
local GATE = 90
|
|
local function blocked(x, z)
|
|
for _, jn in ipairs(junctions) do
|
|
-- Project the junction outward onto the square boundary and
|
|
-- compare against this segment's centre.
|
|
local d = Vector3.new(x - jn.ringPt.X, 0, z - jn.ringPt.Z)
|
|
if d.Magnitude <= GATE then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local n = 0
|
|
for _, side in ipairs({ "N", "S", "E", "W" }) do
|
|
local along = -half
|
|
while along < half do
|
|
local segLen = math.min(SEG, half - along)
|
|
local mid = along + segLen / 2
|
|
local x, z, sizeV
|
|
if side == "N" then
|
|
x, z = c.X + mid, c.Z - half
|
|
sizeV = Vector3.new(segLen + 0.5, PERIM_H, PERIM_T)
|
|
elseif side == "S" then
|
|
x, z = c.X + mid, c.Z + half
|
|
sizeV = Vector3.new(segLen + 0.5, PERIM_H, PERIM_T)
|
|
elseif side == "W" then
|
|
x, z = c.X - half, c.Z + mid
|
|
sizeV = Vector3.new(PERIM_T, PERIM_H, segLen + 0.5)
|
|
else
|
|
x, z = c.X + half, c.Z + mid
|
|
sizeV = Vector3.new(PERIM_T, PERIM_H, segLen + 0.5)
|
|
end
|
|
if not blocked(x, z) then
|
|
local seg = part({
|
|
Name = "PAPerimeter",
|
|
Size = sizeV,
|
|
CFrame = CFrame.new(x, baseY, z),
|
|
Color = C_PARAPET,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
seg.Parent = pa
|
|
n = n + 1
|
|
end
|
|
along = along + segLen
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Two ramps: mainline -> ring (entry) and ring -> mainline (exit).
|
|
-- `junctions` was resolved above (the ring wall needed it to place gates);
|
|
-- reuse it so the ramp deck and the wall opening can never disagree.
|
|
--
|
|
-- HELIX RAMPS (2026-08-03). These used to be STRAIGHT sloped decks: a
|
|
-- direct line from the ring gate to the mainline, climbing PA_LIFT studs
|
|
-- over the span. Two things were wrong with that, both measured:
|
|
--
|
|
-- 1. GRADE. Straight ramps came out at 10.6% (entry) and 13.6% (exit).
|
|
-- Japanese expressway ramps are held to ~6% maximum; 13% is a
|
|
-- launch ramp, not a road.
|
|
-- 2. SHAPE. Real Daikoku entry/exit ramps are HELIXES -- they spiral
|
|
-- down around a vertical axis. That is the single most recognisable
|
|
-- thing about the interchange and it was entirely absent.
|
|
--
|
|
-- The radii below are MEASURED, not chosen: an OSM Overpass pull of the
|
|
-- interchange's curved ways, each fitted with a Kasa circle fit. The two
|
|
-- cleanest fits (rms < 1.5 m -- i.e. genuinely circular, not just curved)
|
|
-- are 147 m and 124 m, which at M = 3.571 studs/m give 525 and 443 studs.
|
|
-- See docs/daikoku-reference-spec.md for the full survey.
|
|
--
|
|
-- Construction: leave the mainline TANGENTIALLY (no step change in
|
|
-- heading at speed), wrap the circle while descending, then run the
|
|
-- tangent line from the circle to the ring gate. Solved numerically
|
|
-- offline and verified collision-free against all 823 mainline samples
|
|
-- (min horizontal clearance: 751 studs entry, 570 exit) and against the
|
|
-- PA ring itself (every helix point lies outside RING_R).
|
|
local LIFT = Circuit.PA_LIFT or 0
|
|
local M_PER_STUD = 3.571
|
|
local HELIX_R = {
|
|
PAOnRamp = 147 * M_PER_STUD, -- 525 studs, OSM way 32403901 (rms 1.2)
|
|
PAOffRamp = 124 * M_PER_STUD, -- 443 studs, OSM way 779045459 (rms 0.7)
|
|
}
|
|
-- Turn direction, plan view. +1 = counter-clockwise in Roblox's X/Z
|
|
-- (Z grows "south"), which is what both solved geometries need for the
|
|
-- gate to fall OUTSIDE the helix circle -- the clockwise solutions put
|
|
-- the gate inside the circle, where no tangent exists at all.
|
|
local HELIX_TURN = { PAOnRamp = 1, PAOffRamp = 1 }
|
|
|
|
for _, jn in ipairs(junctions) do
|
|
local helixR = HELIX_R[jn.name]
|
|
local turn = HELIX_TURN[jn.name] or 1
|
|
local yHi = jn.roadY or (ringY + LIFT)
|
|
local yLo = ringY
|
|
|
|
-- Circle centre: perpendicular to the mainline heading, on the turn
|
|
-- side, one radius away. This is what makes the join tangential.
|
|
local nrm = jn.heading + turn * math.pi / 2
|
|
local O = Vector3.new(jn.road.X + math.cos(nrm) * helixR, 0, jn.road.Z + math.sin(nrm) * helixR)
|
|
|
|
-- Tangent from the circle to the ring gate.
|
|
local G = jn.ringPt
|
|
local dGO = Vector3.new(G.X - O.X, 0, G.Z - O.Z)
|
|
local d = dGO.Magnitude
|
|
|
|
local pathPts = {}
|
|
if d > helixR + 1 then
|
|
local tail = math.sqrt(d * d - helixR * helixR)
|
|
local alpha = math.atan2(dGO.Z, dGO.X)
|
|
local beta = math.acos(math.clamp(helixR / d, -1, 1))
|
|
local tangA = alpha - turn * beta
|
|
local th0 = math.atan2(jn.road.Z - O.Z, jn.road.X - O.X)
|
|
local sweep = (tangA - th0) * turn
|
|
sweep = sweep % (math.pi * 2)
|
|
|
|
local arcLen = helixR * sweep
|
|
local total = arcLen + tail
|
|
-- Sample the arc, then the straight tail, as one continuous path
|
|
-- with a single monotonic descent so the grade never changes sign.
|
|
local ARC_STEPS = math.max(16, math.floor(arcLen / 45))
|
|
for s = 0, ARC_STEPS do
|
|
local f = s / ARC_STEPS
|
|
local th = th0 + turn * sweep * f
|
|
local dist = helixR * sweep * f
|
|
table.insert(pathPts, {
|
|
pos = Vector3.new(O.X + math.cos(th) * helixR, 0, O.Z + math.sin(th) * helixR),
|
|
t = dist / total,
|
|
})
|
|
end
|
|
local T = pathPts[#pathPts].pos
|
|
local TAIL_STEPS = math.max(4, math.floor(tail / 45))
|
|
for s = 1, TAIL_STEPS do
|
|
local f = s / TAIL_STEPS
|
|
table.insert(pathPts, {
|
|
pos = T:Lerp(Vector3.new(G.X, 0, G.Z), f),
|
|
t = (arcLen + tail * f) / total,
|
|
})
|
|
end
|
|
else
|
|
-- Degenerate: gate sits inside the helix circle, so no tangent
|
|
-- exists. Fall back to the old straight ramp rather than build
|
|
-- nothing -- a missing ramp strands the player on the apron.
|
|
local STEPS = math.max(8, math.floor((G - jn.road).Magnitude / 40))
|
|
for s = 0, STEPS do
|
|
local f = s / STEPS
|
|
table.insert(pathPts, {
|
|
pos = Vector3.new(jn.road.X, 0, jn.road.Z):Lerp(Vector3.new(G.X, 0, G.Z), f),
|
|
t = f,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- Ease the descent at both ends so a car doesn't nose into the
|
|
-- transition at the top or bottom out at the bottom.
|
|
local function ease(t)
|
|
return t * t * (3 - 2 * t)
|
|
end
|
|
|
|
for s = 1, #pathPts - 1 do
|
|
local A, B = pathPts[s], pathPts[s + 1]
|
|
local y0 = yHi + (yLo - yHi) * ease(A.t)
|
|
local y1 = yHi + (yLo - yHi) * ease(B.t)
|
|
local a = Vector3.new(A.pos.X, y0, A.pos.Z)
|
|
local b = Vector3.new(B.pos.X, y1, B.pos.Z)
|
|
local segMid = (a + b) / 2
|
|
-- Overlap consecutive segments slightly: a sloped deck is a
|
|
-- rotated box, so butt-jointed segments leave a thin wedge gap at
|
|
-- each seam that a wheel can catch on.
|
|
local segLen = (b - a).Magnitude + 3
|
|
local dck = deck(jn.name, Vector3.new(RING_W, 2, segLen), CFrame.lookAt(segMid, b, UP))
|
|
dck.Name = jn.name .. "_" .. s
|
|
|
|
-- Support pillar down to the apron under the raised part of the
|
|
-- ramp, matching the reference photos' elevated structure.
|
|
local h = segMid.Y - (ringY - 1)
|
|
if h > 10 and s % 2 == 0 then
|
|
local pil = part({
|
|
Name = "PARampPillar",
|
|
Size = Vector3.new(6, h, 6),
|
|
CFrame = CFrame.new(segMid.X, segMid.Y - h / 2, segMid.Z),
|
|
Color = C_CONCRETE,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
pil.Parent = pa
|
|
end
|
|
-- Edge kerbs so a car can't slide off the side of a raised ramp.
|
|
--
|
|
-- BUG (2026-08-02, found by test drive + tools/diag_ramp.luau):
|
|
-- the guard used to be `h > 8`, which is a HEIGHT test on a ramp
|
|
-- that starts at apron level. The first 2-3 segments of every ramp
|
|
-- (heightAboveApron 0.5 .. 5.6) therefore got no kerb at all, so
|
|
-- the barrier visibly stopped part-way down the ramp -- the
|
|
-- "side barriers are not actually fully finished" report. The low
|
|
-- end is not a fall hazard, but it still needs an edge or the
|
|
-- structure just ends in mid-air visually.
|
|
--
|
|
-- The other half of that report -- "barriers extend too far into
|
|
-- the main highway" -- is the opposite end: the kerb ran right to
|
|
-- the road end of the ramp, so a kerb stood IN the mainline
|
|
-- driving lane at the merge (measured: a 2.4-stud step 14 studs
|
|
-- right of the centreline, hit as Road_526 -> PARampKerb). A merge
|
|
-- has to be open, so the last segment carries no kerb.
|
|
-- Kerb EVERY segment except the merge mouth. The height test is
|
|
-- gone entirely: `h > 8` then `h > 2` both still failed on the
|
|
-- foot of the ramp (h = 0.5 .. 0.8 at apron level), which is
|
|
-- exactly where the barrier was seen "ending in mid-air" -- the
|
|
-- edge began one segment UP the ramp, leaving the first ~40 studs
|
|
-- of both ramps completely open on both sides. A ramp edge is
|
|
-- continuous in reality regardless of how high it happens to be,
|
|
-- so the only exclusion left is the merge, which must stay open.
|
|
-- The merge mouth is now the LAST path point (the ramp is a helix,
|
|
-- so `STEPS` no longer exists -- the path length varies per ramp).
|
|
local isMergeSeg = (s >= #pathPts - 2)
|
|
if not isMergeSeg then
|
|
local fwd2 = (b - a)
|
|
fwd2 = Vector3.new(fwd2.X, 0, fwd2.Z)
|
|
if fwd2.Magnitude > 0.01 then
|
|
local rightV = fwd2.Unit:Cross(UP)
|
|
for _, sg in ipairs({ -1, 1 }) do
|
|
local kerb = part({
|
|
Name = "PARampKerb",
|
|
Size = Vector3.new(1.6, 3.2, segLen),
|
|
CFrame = CFrame.lookAt(
|
|
segMid + rightV * (sg * RING_W / 2),
|
|
segMid + rightV * (sg * RING_W / 2) + (b - a).Unit,
|
|
UP
|
|
) * CFrame.new(0, 2.6, 0),
|
|
Color = C_PARAPET,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
kerb.Parent = pa
|
|
end
|
|
|
|
-- Red/white chevron delineators.
|
|
--
|
|
-- CORRECTED 2026-08-03 against 4K dusk onboard footage of
|
|
-- the junction (jct_tsubasa_dusk, t=110..140s). These are
|
|
-- NOT road-surface paint, which is what the first pass
|
|
-- built -- a flat red slab lying on the deck. In reality
|
|
-- they are VERTICAL PANELS bolted to the outside face of
|
|
-- the concrete barrier, alternating red and white, running
|
|
-- continuously along the outside of the curve. They are
|
|
-- the main thing that makes a helix legible from inside
|
|
-- the car, and on the deck they were nearly invisible.
|
|
--
|
|
-- Roblox part granularity can't do a diagonal stripe
|
|
-- inside one panel, so alternating whole panels per
|
|
-- segment stands in for the diagonal hatching.
|
|
--
|
|
-- Outward side of the curve. Taking the raw radial
|
|
-- (segMid - O) as the offset direction is WRONG on the
|
|
-- straight tangent tail: there the radial is no longer
|
|
-- perpendicular to travel, so panels skew along the road
|
|
-- (measured: up to 33 studs of along-travel error over the
|
|
-- last ~9 segments, walking the panel line diagonally
|
|
-- across the deck instead of tracking its edge).
|
|
--
|
|
-- Offset along the true perpendicular `rightV` always, and
|
|
-- use the radial ONLY to choose which side is outboard.
|
|
local radial = Vector3.new(segMid.X - O.X, 0, segMid.Z - O.Z)
|
|
if radial.Magnitude > 0.01 and rightV.Magnitude > 0.01 then
|
|
local sgn = (rightV:Dot(radial.Unit) >= 0) and 1 or -1
|
|
local face = segMid + rightV * (sgn * RING_W / 2)
|
|
local chev = part({
|
|
Name = "PARampChevron",
|
|
Size = Vector3.new(0.5, 2.0, segLen),
|
|
CFrame = CFrame.lookAt(face, face + (b - a).Unit, UP) * CFrame.new(0, 2.7, 0),
|
|
Color = (s % 2 == 0) and Color3.fromRGB(198, 46, 40) or Color3.fromRGB(240, 238, 232),
|
|
Material = Enum.Material.SmoothPlastic,
|
|
})
|
|
chev.Parent = pa
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Flare the ramp mouth across the outer gate so the opening in the
|
|
-- ring parapet is fully backed by deck -- the ramp meets the ring at a
|
|
-- tangent and would otherwise leave triangular gaps at the corners.
|
|
local toRing = Vector3.new(jn.ringPt.X - jn.road.X, 0, jn.ringPt.Z - jn.road.Z)
|
|
if toRing.Magnitude > 1 then
|
|
local flareCF = CFrame.lookAt(
|
|
Vector3.new(jn.ringPt.X, ringY, jn.ringPt.Z),
|
|
Vector3.new(jn.road.X, ringY, jn.road.Z),
|
|
UP
|
|
)
|
|
local flare = deck("PARampFlare", Vector3.new(GATE_OUT * 2 + RING_W, 2, RING_W + 24), flareCF)
|
|
flare.Color = Color3.fromRGB(38, 38, 44)
|
|
end
|
|
end
|
|
|
|
-- The PA apron used to be a bare 520x520 slab floating ~46 studs up with
|
|
-- nothing at its edges: endurance testing drove straight off the side and
|
|
-- fell past FallenPartsDestroyHeight (-500), destroying the car and
|
|
-- stranding the player seatless. That is now solved structurally -- the
|
|
-- apron below extends all the way out to the ring road, and the ring's
|
|
-- outer lip walls the whole thing -- so there is no exposed edge to fall
|
|
-- off and no need for the old four-sided perimeter box.
|
|
|
|
-- SPAWN PAD PLACEMENT. This used to sit exactly on PA_CENTER, which was
|
|
-- fine while the apron centre was empty. It is NOT fine after the survey
|
|
-- landed: the OSM stall block is centred near the apron middle, so the pad
|
|
-- punched a 120x120 dark hole straight through the painted 小型 rows
|
|
-- (visible as a black rectangle in the overhead capture).
|
|
--
|
|
-- The offset below is MEASURED, not chosen: a 15-stud grid search over the
|
|
-- whole apron scored every candidate by its clearance to all 804 built PA
|
|
-- obstacles (buildings, stalls, props) while staying inside the ring road.
|
|
-- (-165, -150) is the best cell, with 93 studs of clearance -- comfortably
|
|
-- more than the pad's 60-stud half-width. It lands on the open southwest
|
|
-- circulation area, which is empty tarmac on the real site too.
|
|
local SPAWN_OFF = Vector3.new(-165, 0.3, -150)
|
|
local pad = part({
|
|
Name = "SpawnPad",
|
|
Size = Vector3.new(120, 2.4, 120),
|
|
CFrame = CFrame.new(c + SPAWN_OFF),
|
|
Color = Color3.fromRGB(20, 20, 26),
|
|
Material = Enum.Material.Neon,
|
|
Transparency = 0.25,
|
|
})
|
|
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.
|
|
-- Must sit ON the pad -- it is keyed to the same measured offset, because
|
|
-- CarManager spawns the car onto the character. Leaving this at PA_CENTER
|
|
-- while the pad moved would drop the player into the middle of the 小型
|
|
-- stall rows instead of onto clear tarmac.
|
|
local spawnLoc = Instance.new("SpawnLocation")
|
|
spawnLoc.Name = "PASpawn"
|
|
spawnLoc.Size = Vector3.new(24, 1, 24)
|
|
spawnLoc.CFrame = CFrame.new(c + Vector3.new(SPAWN_OFF.X, 2, SPAWN_OFF.Z))
|
|
spawnLoc.Transparency = 1
|
|
spawnLoc.CanCollide = false
|
|
spawnLoc.Anchored = true
|
|
spawnLoc.Neutral = true
|
|
spawnLoc.Duration = 0
|
|
spawnLoc.Parent = pa
|
|
|
|
-- BUILDINGS ---------------------------------------------------------------
|
|
-- SURVEYED LAYOUT, not art direction. Every position below is read from
|
|
-- OpenStreetMap and converted to studs, so the arrangement matches the real
|
|
-- 大黒ふ頭PA rather than approximating it.
|
|
--
|
|
-- Sources (fetched 2026-08-02 via the OSM API, not Overpass -- Overpass
|
|
-- 504s repeatedly on this bbox):
|
|
-- * way 59608205 amenity=parking 大黒ふ頭パーキングエリア -- the apron
|
|
-- * way 405966005 highway=rest_area 大黒PA (wikidata Q10938704)
|
|
-- * building ways + amenity/shop nodes within 230 m of the apron centroid
|
|
--
|
|
-- Local frame: origin = apron polygon centroid (35.461455 N, 139.680201 E),
|
|
-- +xe = EAST metres, +zn = NORTH metres. Mapped into the game world as
|
|
-- East -> +X and North -> -Z, which is the convention the rest of the map
|
|
-- already uses.
|
|
--
|
|
-- THE BIG CORRECTION: the apron is NOT the axis-aligned square the previous
|
|
-- code assumed. Measured from the polygon, it is a PARALLELOGRAM lying on a
|
|
-- SW-NE diagonal -- principal axis bearing ~37 deg, longest edge 155.8 m at
|
|
-- bearing 45.4 deg, area 1.60 ha. The 181 x 174 m figure quoted before is
|
|
-- just the bounding box of that diagonal lot, which is why buildings placed
|
|
-- on N/S/E/W edges landed in the wrong places. The two named buildings sit
|
|
-- at OPPOSITE ENDS OF THE DIAGONAL, ~175 m apart:
|
|
-- 本館 (main building) at the SOUTH-WEST end, near the neck
|
|
-- フォンターナ・ダイコク at the NORTH-EAST tip
|
|
-- That single fact is most of what makes the lot read as Daikoku.
|
|
local C_BLDG = Color3.fromRGB(214, 210, 200)
|
|
local C_GLASS = Color3.fromRGB(120, 150, 165)
|
|
|
|
-- OSM local metres -> world studs.
|
|
--
|
|
-- Two corrections are folded into this transform, both forced by MEASURED
|
|
-- constraints rather than taste:
|
|
--
|
|
-- 1. ORIGIN SHIFT (+32 m North). The OSM parking polygon's centroid is not
|
|
-- the centre of the SITE -- the buildings all sit north and east of the
|
|
-- stalls, so the full site bbox spans east -87..+111 and north -53..+137.
|
|
-- Centring on the parking centroid pushed 7 of 12 features off the apron.
|
|
-- Solving for the origin that minimises the enclosing radius gives
|
|
-- (+0.0 E, +32.0 N), which is the value used here.
|
|
--
|
|
-- 2. UNIFORM SCALE (0.88). The real site needs a 113.9 m radius after the
|
|
-- shift, but the PA ring road only offers 103.0 m of clear space inside
|
|
-- it (ring centreline PA_RADIUS+130 = 390 studs, ring 44 studs wide).
|
|
-- The ring CANNOT simply be grown: the nearest mainline sample is only
|
|
-- 330 studs from PA_CENTER (measured in-engine), so the ring is already
|
|
-- wider than its clearance to the expressway and enlarging it would push
|
|
-- the loop through the road deck and break the ramp junctions.
|
|
-- Scaling the survey down by 0.88 instead fits it in the existing
|
|
-- envelope. Because the scale is UNIFORM, every distance and angle keeps
|
|
-- its true proportion -- the relative arrangement of the buildings, which
|
|
-- is what makes the lot recognisable as Daikoku, is preserved exactly.
|
|
local OSM_SHIFT_N = 32.0
|
|
local OSM_SCALE = 0.88
|
|
local function osmX(xe: number): number
|
|
return c.X + xe * OSM_SCALE * M
|
|
end
|
|
local function osmZ(zn: number): number
|
|
return c.Z - (zn - OSM_SHIFT_N) * OSM_SCALE * M
|
|
end
|
|
-- OSM edge bearing (degrees, measured as atan2(east, north) the way the
|
|
-- survey reports it) -> Roblox Y rotation. Derivation: we want the part's
|
|
-- LookVector (-sin t, 0, -cos t) to lie along the OSM direction
|
|
-- (cos a East, sin a North) = (cos a, 0, -sin a) in world axes, which
|
|
-- solves to t = a - 90 deg.
|
|
local function osmYaw(angDeg: number): number
|
|
return math.rad(angDeg - 90)
|
|
end
|
|
|
|
-- GENERATED PA STRUCTURES DISABLED (2026-08-02, user call): the placeholder
|
|
-- buildings and props are being replaced with Creator Store assets placed
|
|
-- by hand into workspace.PAProps. Flip this back to true to restore them.
|
|
--
|
|
-- A flag, not a deletion: every position below is surveyed against a real
|
|
-- OSM way (本館, フォンターナ way 88790141, toilets 88790136, kiosk 88790109,
|
|
-- hut 88790185) via osmX/osmZ with OSM_SHIFT_N + OSM_SCALE. That survey is
|
|
-- the useful part -- it tells you exactly where each real structure stands,
|
|
-- which is the reference for placing the store assets. Keeping the calls
|
|
-- live but silent preserves it.
|
|
local DRAW_PA_STRUCTURES = false
|
|
|
|
-- `long` runs along the building's own long axis, `wide` across it.
|
|
-- Footprints are scaled by the same OSM_SCALE as the positions, so the lot
|
|
-- stays a faithful uniform reduction rather than a stretched one.
|
|
local function building(name, long_m, height_m, wide_m, xe, zn, angDeg)
|
|
if not DRAW_PA_STRUCTURES then
|
|
return nil
|
|
end
|
|
local sy = height_m * M
|
|
local cf = CFrame.new(osmX(xe), DECK_Y + sy / 2, osmZ(zn)) * CFrame.Angles(0, osmYaw(angDeg), 0)
|
|
local b = part({
|
|
Name = name,
|
|
Size = Vector3.new(wide_m * OSM_SCALE * M, sy, long_m * OSM_SCALE * M),
|
|
CFrame = cf,
|
|
Color = C_BLDG,
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
b.Parent = pa
|
|
-- Ground-floor glazing so it reads as a rest stop, not a warehouse.
|
|
local glassH = math.min(4 * M, sy * 0.7)
|
|
local glass = part({
|
|
Name = name .. "Glazing",
|
|
Size = Vector3.new(wide_m * OSM_SCALE * M + 0.6, glassH, long_m * OSM_SCALE * M + 0.6),
|
|
CFrame = cf * CFrame.new(0, -sy / 2 + glassH / 2 + 0.2 * M, 0),
|
|
Color = C_GLASS,
|
|
Material = Enum.Material.Glass,
|
|
Transparency = 0.35,
|
|
})
|
|
glass.Parent = pa
|
|
return b
|
|
end
|
|
|
|
-- 本館 (main building) -- SW end of the lot. Three storeys (1F shop/toilet
|
|
-- block, 2F rest area reopened 2025-10-30, 3F closed). Its footprint is not
|
|
-- mapped as a building way in OSM, so the extent is reconstructed from the
|
|
-- tight POI cluster that sits inside it: food_court (-22,-64), Bay Side
|
|
-- Restaurant (-25,-62), bakery Little Marmaid (-31,-62), toilets (-22,-59),
|
|
-- elevator (-34,-51), telephone (-21,-49), Blue Seal (-42,-50), cafe
|
|
-- Cafe de Crie (-43,-49), convenience e-Mart (-40,-45). Those span roughly
|
|
-- x -45..-18, z -66..-43, hence a ~46 x 34 m block centred (-32,-53)
|
|
-- squared up to the 45 deg lot grid.
|
|
building("PAHonkan", 46, 13, 34, -32, -53, 45)
|
|
if DRAW_PA_STRUCTURES then
|
|
makeSign("本館 MAIN BUILDING", CFrame.new(osmX(-32), DECK_Y + 15 * M, osmZ(-53) + 20 * M), pa)
|
|
end
|
|
|
|
-- フォンターナ・ダイコク / ベイサイドテラス -- NE tip. OSM way 88790141,
|
|
-- oriented bbox 28 x 43 m at bearing +43 deg, centre (+13.9, +123.1).
|
|
building("PANibankan", 43, 7, 28, 13.9, 123.1, 43)
|
|
if DRAW_PA_STRUCTURES then
|
|
makeSign("フォンターナ・ダイコク", CFrame.new(osmX(13.9), DECK_Y + 9 * M, osmZ(123.1) + 16 * M), pa)
|
|
end
|
|
|
|
-- Remaining mapped structures, each from its own OSM building way.
|
|
building("PAToiletNorth", 21, 5, 7, -17.5, 109.8, -44) -- way 88790136
|
|
building("PAKioskWest", 16, 5, 9, -87.2, 24.5, 46) -- way 88790109
|
|
building("PAHutWest", 14, 4.5, 10, -71.9, 42.2, -45) -- way 88790185
|
|
|
|
-- Free-standing amenity nodes. Small props, but they are what stops the
|
|
-- apron reading as an empty car park.
|
|
local function prop(name, sx_m, sy_m, sz_m, xe, zn, colour, mat)
|
|
if not DRAW_PA_STRUCTURES then
|
|
return nil
|
|
end
|
|
local p = part({
|
|
Name = name,
|
|
Size = Vector3.new(sx_m * OSM_SCALE * M, sy_m * M, sz_m * OSM_SCALE * M),
|
|
CFrame = CFrame.new(osmX(xe), DECK_Y + (sy_m * M) / 2, osmZ(zn)),
|
|
Color = colour,
|
|
Material = mat or Enum.Material.Metal,
|
|
})
|
|
p.Parent = pa
|
|
return p
|
|
end
|
|
|
|
-- Lawson (+21,+136.7) and the north toilet block (-3.3,+124) flank Fontana.
|
|
prop("PALawson", 14, 4, 10, 21.0, 136.7, Color3.fromRGB(70, 130, 200), Enum.Material.Concrete)
|
|
prop("PAToiletBlockN", 12, 4, 8, -3.3, 124.0, Color3.fromRGB(205, 200, 190), Enum.Material.Concrete)
|
|
-- 噴水 fountain (+5.9,+122.5) -- the photo spot on the north terrace.
|
|
prop("PAFountain", 9, 0.8, 9, 5.9, 122.5, Color3.fromRGB(150, 170, 185), Enum.Material.Slate)
|
|
-- Smoking shelters: north terrace (+1.4,+103.9) and by the main building.
|
|
prop("PASmokingN", 7, 3, 5, 1.4, 103.9, Color3.fromRGB(150, 150, 155))
|
|
prop("PASmokingS", 7, 3, 5, -47.7, -31.5, Color3.fromRGB(150, 150, 155))
|
|
-- 二輪車駐車場: motorcycles park separately, right by the main building.
|
|
prop("PAMotorcycleBay", 20, 0.05, 12, 9.0, -44.9, Color3.fromRGB(70, 70, 78), Enum.Material.Asphalt)
|
|
|
|
-- EV rapid charger: first on any Japanese expressway, installed 2008-10-10.
|
|
-- OSM charging_station node sits at (-58.3, +30.2), on the west flank.
|
|
for k = 0, 1 do
|
|
if not DRAW_PA_STRUCTURES then
|
|
break
|
|
end
|
|
local ev = part({
|
|
Name = "PAEVCharger",
|
|
Size = Vector3.new(1.6 * M, 2.2 * M, 0.9 * M),
|
|
CFrame = CFrame.new(osmX(-58.3) + k * 4 * M, DECK_Y + 1.1 * M, osmZ(30.2)),
|
|
Color = Color3.fromRGB(60, 130, 90),
|
|
Material = Enum.Material.Metal,
|
|
})
|
|
ev.Parent = pa
|
|
end
|
|
|
|
-- 段差: the real PA had bumps retrofitted specifically to keep illegally
|
|
-- lowered 車高短 cars out. Deliberately shallow here (~0.1 m) -- enough to
|
|
-- be visible and to unsettle a stiff car, not enough to beach anything.
|
|
for k = -1, 1 do
|
|
local bump = part({
|
|
Name = "PALowCarBump",
|
|
Size = Vector3.new(26 * M, 0.1 * M, 0.8 * M),
|
|
CFrame = CFrame.new(c.X + k * 28 * M, DECK_Y + 0.05 * M, c.Z + APRON_Z / 2 - 4 * M),
|
|
Color = Color3.fromRGB(200, 190, 90),
|
|
Material = Enum.Material.Concrete,
|
|
})
|
|
bump.Parent = pa
|
|
end
|
|
|
|
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
|
|
end
|
|
|
|
-- Tokyo landmarks, at their true positions relative to the loop -----------------
|
|
local function buildLandmarks(map: Folder)
|
|
local folder = Instance.new("Folder")
|
|
folder.Name = "Landmarks"
|
|
folder.Parent = map
|
|
|
|
-- Tokyo Tower (-3557, -5638): orange/white lattice, twin decks, antenna
|
|
do
|
|
local base = Vector3.new(-3557, 0, -5638)
|
|
local seg = 12
|
|
local H = 333 / 0.28 / 6 -- scaled for readability at driving speed
|
|
for i = 0, seg - 1 do
|
|
local f0 = i / seg
|
|
local w0 = 90 * (1 - f0) ^ 1.6 + 8
|
|
local h = H / seg
|
|
local col = (i % 2 == 0) and Color3.fromRGB(235, 90, 40) or Color3.fromRGB(240, 240, 245)
|
|
local s = part({
|
|
Name = "TokyoTower",
|
|
Size = Vector3.new(w0, h, w0),
|
|
CFrame = CFrame.new(base + Vector3.new(0, f0 * H + h / 2, 0)),
|
|
Color = col,
|
|
Material = Enum.Material.DiamondPlate,
|
|
Transparency = 0.12,
|
|
})
|
|
s.Parent = folder
|
|
end
|
|
for _, dh in ipairs({ 0.38, 0.66 }) do
|
|
local deck = part({
|
|
Name = "TowerDeck",
|
|
Size = Vector3.new(70, 10, 70),
|
|
CFrame = CFrame.new(base + Vector3.new(0, dh * H, 0)),
|
|
Color = Color3.fromRGB(250, 250, 255),
|
|
Material = Enum.Material.Neon,
|
|
Transparency = 0.15,
|
|
})
|
|
deck.Parent = folder
|
|
end
|
|
local ant = part({
|
|
Name = "TowerAntenna",
|
|
Size = Vector3.new(5, H * 0.22, 5),
|
|
CFrame = CFrame.new(base + Vector3.new(0, H + H * 0.11, 0)),
|
|
Color = Color3.fromRGB(220, 90, 60),
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
ant.Parent = folder
|
|
end
|
|
|
|
-- Tokyo Skytree (17523, 14829): far to the north-east, visible on the horizon
|
|
do
|
|
local base = Vector3.new(17523, 0, 14829)
|
|
local H = 634 / 0.28 / 6
|
|
for i = 0, 9 do
|
|
local f = i / 10
|
|
local w = 70 * (1 - f) ^ 1.3 + 10
|
|
local s = part({
|
|
Name = "Skytree",
|
|
Size = Vector3.new(w, H / 10, w),
|
|
CFrame = CFrame.new(base + Vector3.new(0, f * H + H / 20, 0)),
|
|
Color = Color3.fromRGB(215, 225, 240),
|
|
Material = Enum.Material.Neon,
|
|
Transparency = 0.25,
|
|
})
|
|
s.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Imperial Palace (-1173, 4933): dark moat ring + green grounds + stone wall
|
|
do
|
|
local c = Vector3.new(-1173, 0, 4933)
|
|
local grounds = part({
|
|
Name = "PalaceGrounds",
|
|
Size = Vector3.new(1500, 6, 1400),
|
|
CFrame = CFrame.new(c + Vector3.new(0, 3, 0)),
|
|
Color = Color3.fromRGB(24, 52, 32),
|
|
Material = Enum.Material.Grass,
|
|
})
|
|
grounds.Parent = folder
|
|
for i = 0, 23 do
|
|
local a = (i / 24) * math.pi * 2
|
|
local moat = part({
|
|
Name = "PalaceMoat",
|
|
Size = Vector3.new(150, 3, 60),
|
|
CFrame = CFrame.new(c + Vector3.new(math.sin(a) * 800, 5, math.cos(a) * 760)) * CFrame.Angles(0, -a, 0),
|
|
Color = Color3.fromRGB(18, 34, 48),
|
|
Material = Enum.Material.Water,
|
|
})
|
|
moat.Parent = folder
|
|
local wall = part({
|
|
Name = "PalaceWall",
|
|
Size = Vector3.new(150, 26, 20),
|
|
CFrame = CFrame.new(c + Vector3.new(math.sin(a) * 720, 13, math.cos(a) * 690))
|
|
* CFrame.Angles(0, -a, 0),
|
|
Color = Color3.fromRGB(90, 88, 80),
|
|
Material = Enum.Material.Slate,
|
|
})
|
|
wall.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Ginza (2764, -525): bright signage district
|
|
do
|
|
local c = Vector3.new(2764, 0, -525)
|
|
for i = 1, 26 do
|
|
local a = det(i * 3.1) * math.pi * 2
|
|
local r = 260 + det(i * 7.7) * 900
|
|
local h = 260 + det(i * 5.3) * 420
|
|
local p = c + Vector3.new(math.sin(a) * r, h / 2, math.cos(a) * r)
|
|
local b = part({
|
|
Name = "GinzaBlock",
|
|
Size = Vector3.new(150 + det(i) * 90, h, 150 + det(i * 2) * 90),
|
|
CFrame = CFrame.new(p),
|
|
Color = Color3.fromRGB(26, 26, 34),
|
|
Material = Enum.Material.Glass,
|
|
Reflectance = 0.25,
|
|
})
|
|
b.Parent = folder
|
|
local sign = part({
|
|
Name = "GinzaSign",
|
|
Size = Vector3.new(16, h * 0.5, 6),
|
|
CFrame = CFrame.new(p + Vector3.new(80, 0, 0)),
|
|
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
sign.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Shiodome towers (1117, -2847): the glass cluster the C1 threads past
|
|
do
|
|
local c = Vector3.new(1117, 0, -2847)
|
|
for i = 1, 7 do
|
|
local h = 700 + det(i * 11.3) * 500
|
|
local p = c + Vector3.new((det(i * 2.2) - 0.5) * 1100, h / 2, (det(i * 4.4) - 0.5) * 1100)
|
|
local t = part({
|
|
Name = "ShiodomeTower",
|
|
Size = Vector3.new(170, h, 170),
|
|
CFrame = CFrame.new(p),
|
|
Color = Color3.fromRGB(30, 34, 44),
|
|
Material = Enum.Material.Glass,
|
|
Reflectance = 0.35,
|
|
})
|
|
t.Parent = folder
|
|
local crown = part({
|
|
Name = "TowerCrown",
|
|
Size = Vector3.new(175, 8, 175),
|
|
CFrame = CFrame.new(p + Vector3.new(0, h / 2, 0)),
|
|
Color = NEON_BLUE,
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
crown.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Tokyo Station (3448, 3367): red brick Marunouchi facade
|
|
do
|
|
local c = Vector3.new(3448, 0, 3367)
|
|
local hall = part({
|
|
Name = "TokyoStation",
|
|
Size = Vector3.new(900, 120, 220),
|
|
CFrame = CFrame.new(c + Vector3.new(0, 60, 0)),
|
|
Color = Color3.fromRGB(120, 52, 44),
|
|
Material = Enum.Material.Brick,
|
|
})
|
|
hall.Parent = folder
|
|
for _, sx in ipairs({ -320, 0, 320 }) do
|
|
local dome = part({
|
|
Name = "StationDome",
|
|
Shape = Enum.PartType.Ball,
|
|
Size = Vector3.new(150, 150, 150),
|
|
CFrame = CFrame.new(c + Vector3.new(sx, 140, 0)),
|
|
Color = Color3.fromRGB(60, 74, 70),
|
|
Material = Enum.Material.Slate,
|
|
})
|
|
dome.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Roppongi Hills (-8715, -4899) + National Diet (-3712, 1280)
|
|
do
|
|
local rh = Vector3.new(-8715, 0, -4899)
|
|
local mori = part({
|
|
Name = "RoppongiHills",
|
|
Size = Vector3.new(260, 1300, 260),
|
|
CFrame = CFrame.new(rh + Vector3.new(0, 650, 0)),
|
|
Color = Color3.fromRGB(34, 36, 46),
|
|
Material = Enum.Material.Glass,
|
|
Reflectance = 0.3,
|
|
})
|
|
mori.Parent = folder
|
|
|
|
local diet = Vector3.new(-3712, 0, 1280)
|
|
local body = part({
|
|
Name = "NationalDiet",
|
|
Size = Vector3.new(700, 130, 260),
|
|
CFrame = CFrame.new(diet + Vector3.new(0, 65, 0)),
|
|
Color = Color3.fromRGB(150, 148, 140),
|
|
Material = Enum.Material.Granite,
|
|
})
|
|
body.Parent = folder
|
|
local tower = part({
|
|
Name = "DietTower",
|
|
Size = Vector3.new(150, 190, 150),
|
|
CFrame = CFrame.new(diet + Vector3.new(0, 190, 0)),
|
|
Color = Color3.fromRGB(150, 148, 140),
|
|
Material = Enum.Material.Granite,
|
|
})
|
|
tower.Parent = folder
|
|
end
|
|
end
|
|
|
|
-- Procedural night city filling the space around the loop -----------------------
|
|
local function buildSkyline(map: Folder)
|
|
local folder = Instance.new("Folder")
|
|
folder.Name = "Skyline"
|
|
folder.Parent = map
|
|
|
|
-- PA keep-out. Returns true if a `w`-wide box centred at (x,z) clears the
|
|
-- Daikoku PA island entirely. RING_OUTER mirrors buildPA's own geometry:
|
|
-- ringRadius (PA_RADIUS + 130) + half the 44-stud ring width.
|
|
local PA_C = Circuit.PA_CENTER
|
|
local RING_OUTER = Circuit.PA_RADIUS + 130 + 22
|
|
local PA_MARGIN = 60
|
|
local function paKeepout(x: number, z: number, w: number): boolean
|
|
local dx, dz = x - PA_C.X, z - PA_C.Z
|
|
local r = math.sqrt(dx * dx + dz * dz)
|
|
return r - w / 2 > RING_OUTER + PA_MARGIN
|
|
end
|
|
|
|
local samples = Circuit.samples
|
|
local n = #samples
|
|
local placed = 0
|
|
|
|
for i = 1, 900 do
|
|
local a = det(i * 1.7) * math.pi * 2
|
|
local r = 900 + det(i * 2.9) ^ 0.6 * 9000
|
|
local x = math.sin(a) * r - 477
|
|
local z = math.cos(a) * r - 473
|
|
|
|
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.
|
|
-- Use the WIDEST deck (3+3 Shiodome section), otherwise buildings sited
|
|
-- against the 2+2 width would intrude into the widened carriageway.
|
|
local CLEAR = MAX_ROAD_WIDTH / 2 + 60
|
|
local near = math.huge
|
|
for k = 1, n do
|
|
local s = samples[k].pos
|
|
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude
|
|
if d < near then
|
|
near = d
|
|
end
|
|
end
|
|
if near - w / 2 < CLEAR then
|
|
continue
|
|
end
|
|
|
|
-- ...but `samples` is the MAINLINE centreline ONLY. The PA ring road,
|
|
-- its two ramps and the parking apron are generated separately (see
|
|
-- buildPA) and appear nowhere in `samples`, so the test above is blind
|
|
-- to the entire PA island. A 186-wide tower landed at r=428 from
|
|
-- PA_CENTER, spanning radius 335->521 and punching straight through the
|
|
-- ring road band [368,412] -- a skyscraper standing in the road.
|
|
--
|
|
-- Exclude the whole PA disc, not just the ring band: nothing generated
|
|
-- belongs inside the ring at all. Test the building's INNER FACE
|
|
-- (r - w/2) against the ring's outer kerb + the same 60-stud margin.
|
|
if not paKeepout(x, z, w) then
|
|
continue
|
|
end
|
|
|
|
local b = part({
|
|
Name = "Building",
|
|
Size = Vector3.new(w, h, w),
|
|
CFrame = CFrame.new(x, h / 2, z),
|
|
Color = Color3.fromRGB(18 + det(i) * 16, 18 + det(i * 2) * 16, 26 + det(i * 3) * 20),
|
|
Material = Enum.Material.Glass,
|
|
Reflectance = 0.18,
|
|
})
|
|
b.Parent = folder
|
|
placed += 1
|
|
|
|
-- signs hang off the +X face, so they need their own clearance check
|
|
if det(i * 13.7) > 0.72 then
|
|
local sx = x + w / 2 + 6
|
|
local snear = math.huge
|
|
for k = 1, n do
|
|
local s = samples[k].pos
|
|
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(sx, 0, z)).Magnitude
|
|
if d < snear then
|
|
snear = d
|
|
end
|
|
end
|
|
if snear > CLEAR and paKeepout(sx, z, 12) 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
|
|
if placed > 620 then
|
|
break
|
|
end
|
|
end
|
|
|
|
-- ground plane under the whole city
|
|
local ground = part({
|
|
Name = "Ground",
|
|
Size = Vector3.new(26000, 4, 26000),
|
|
CFrame = CFrame.new(-477, -2, -473),
|
|
Color = Color3.fromRGB(14, 14, 18),
|
|
Material = Enum.Material.Asphalt,
|
|
})
|
|
ground.Parent = folder
|
|
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(widthAt(i) + 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(widthAt(i) + 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
|
|
local existing = Workspace:FindFirstChild("Map")
|
|
if existing then
|
|
existing:Destroy()
|
|
end
|
|
|
|
local map = Instance.new("Folder")
|
|
map.Name = "Map"
|
|
|
|
buildRoad(map)
|
|
buildJunctionSigns(map)
|
|
buildPA(map)
|
|
buildLandmarks(map)
|
|
buildSkyline(map)
|
|
buildBridges(map)
|
|
|
|
-- Start/finish across the road at sample 1
|
|
local s1 = Circuit.samples[1]
|
|
local fwd = Circuit.forwardAt(1)
|
|
local line = part({
|
|
Name = "FinishLine",
|
|
Size = Vector3.new(widthAt(1), 0.6, 8),
|
|
CFrame = CFrame.lookAt(s1.pos + Vector3.new(0, 1.4, 0), s1.pos + Vector3.new(0, 1.4, 0) + fwd, UP),
|
|
Color = Color3.fromRGB(250, 250, 255),
|
|
Material = Enum.Material.Neon,
|
|
})
|
|
line.Parent = map
|
|
|
|
map.Parent = Workspace
|
|
return map
|
|
end
|
|
|
|
return MapBuilder
|