Skip to content

Instantly share code, notes, and snippets.

@Xyphis12
Last active September 19, 2026 17:09
Show Gist options
  • Select an option

  • Save Xyphis12/81d467d582eaecf9dc3e05e3fbc11cb4 to your computer and use it in GitHub Desktop.

Select an option

Save Xyphis12/81d467d582eaecf9dc3e05e3fbc11cb4 to your computer and use it in GitHub Desktop.
Spline City - AI Generated Mobile Friendly City Builder

Spline City 🏙️

An AI Generated lightweight, browser-based city-building simulation game featuring freeform spline-based road networks, procedural terrain, and deep simulation mechanics.

All contained within a single file: SplineCity.html.

(I built it to test out Claude Code)

Screenshots

A "thriving" metropolis. image

Features

  • Spline-Based Roads: Build smooth, curving road networks using bezier curves. The game automatically handles complex intersections and roundabouts, bridges roads over water, and — for highways and freeways — lets you bridge a road over them instead of junctioning, real grade-separated overpasses included.
  • Dynamic Zoning: Zone land for Residential, Commercial, Industrial, and Office buildings. Lots dynamically subdivide along road frontages.
  • Procedural Terrain: Infinite map generation using seeded value noise for hills, valleys, and water bodies.
  • Starter Towns: Skip the empty lot and take over a city mid-story — five pre-built starts from a waterfront trade town to a full ring-and-bypass metropolis with overpasses of its own, each already zoned, serviced, and running.
  • Economic Simulation: Manage your city's budget, take out bank loans, set taxes, and balance RCOI (Residential, Commercial, Office, Industrial) demand.
  • City Services: Provide power, water, police, fire coverage, clinics, and schools to increase land value and resident happiness.
  • Traffic & Agents: Citizens and freight physically travel across your road network using pathfinding. Watch out for congestion!
  • Procedural Music: Features "Spline City Radio" with an Auto DJ that mixes tracks dynamically.
  • Save/Load: Save your city state to a JSON file and load it back up anytime.

How to Play

  1. Launch: Simply open SplineCity.html, or use Gist Preview in any modern web browser. No server or installation required.

    📱 Optional: Add to Home Screen on iOS (for an app-like experience)

    Playing straight in Safari works fine, but adding the game to your Home Screen launches it full-screen with no address bar or browser toolbar, and it'll get its own icon like a native app.

    1. Open the game link (or the Gist Preview link above) in Safari on your iPhone/iPad — this only works from Safari, not Chrome or other browsers.
    2. Tap the Share button (the square with an arrow pointing up), usually in the bottom toolbar.
    3. Scroll down and tap Add to Home Screen.
    4. Give it a name if you'd like, then tap Add.
    5. Launch the game from its new icon on your Home Screen — it'll open full-screen, just like a regular app.
  2. Start a City: Choose your map size (Small, Medium, Large) and adjust the terrain sliders (Land vs Water, Mountainous) — or open Advanced options and pick a Starter Town to drop into an already-built city instead of an empty lot.

Spline City — Changelog

v0.9.55 — 2026-09-19 17:08 UTC

  • The Spline City Radio soundtrack is replaced. Seven new tracks replace the old set: Morning Shift (electric-piano lounge, 92 BPM), Rush Hour (house pulse for the commute, 112), Boomtown (hammer-and-marimba build site, 118), Industrial Zone (machine funk, 126), Neon Highway (night-drive synthwave, 104), Skyline (slow glass-and-steel pads, 80) and Groundwork (deep foundation pads, 68). Melodies are fixed patterns instead of random picks, each track has real per-phase arrangement (intro → builds → drops → groove → breakdown → rebuild → final drop → outro), and the seven internal keys are unchanged, so every existing save's radio pick resolves to its replacement. Auto DJ now walks the track order as a day arc, dawn → night, instead of shuffling.
  • New synthesis voices. FM electric piano/mallet, detuned-saw pad, filtered saw bass, resonant sweep stab, woodblock/hammer, metallic clank, clap and rim click, alongside the existing kit.
  • Fixed: the music sped up and slowed down with frame rate. The scheduler queued one step per setTimeout and re-anchored its grid whenever a timer fired late, so a busy frame pushed the beat — measured at 238–241 ms per step against a correct 134 ms with an 80 ms main-thread stall. It now runs a 25 ms timer with a 200 ms lookahead against an absolute audio-clock grid: a stall can only delay when a step is queued, never when it sounds. Re-measured under 80 ms and 150 ms stalls: exactly 133.9 ms every step.
  • Fixed: tempo lurched at every track change. DJ transitions glided the BPM toward a random ±4 target and a held track re-randomized ±3 every loop; both are gone, and a transition now snaps exactly to the new track's tempo. Swing (delayed offbeat 8ths) was removed from the two tracks that used it — it read as lurching.
  • Fixed: the "loud hi-hats" were the vinyl crackle. Silencing the hats barely moved the high-frequency energy; the vinyl texture was firing pops of amplitude 3.2 at ~16 per second, about 24× the hats. Pops are now ~1/sec at 0.74 peak with a softer filter, the hats/shaker/ride/clank are cut and darkened, snares and claps lost their top-end bite, and a −5 dB high-shelf at 7 kHz sits on the master bus.
  • Internals: audio state moved out of the save/load module. New 24b-audio-core.js owns the audio context, master bus, radio state and constants; the move was proven lossless by byte-identical builds before and after.
  • Panning no longer rebuilds the city layer. The cached layer now renders into a padded offscreen buffer — a margin sized to a ~10M-device-pixel budget (360 px, shrinking on large or high-DPI viewports) — and draw() blits the viewport's window out of it. Moving the camera within the margin reuses the cached pixels; only a pan past the margin, a zoom or mode change, or a real city change triggers a full re-render. The dense-city LOD decision counts lots in the real viewport, so detail no longer flips as the margin fills. Effect sources (smoke plumes, turbine blades) are offset by the same window so they stay pinned while panning.
  • Internals: the per-frame agent path no longer allocates. Allocation-free Bezier variants (pointAtInto, tangentAtInto, offsetAtInto), an allocation-free agentPoseInto/scInto, reused scratch polygons in extrudeBox, a shared empty dash array instead of a fresh [] on every setLineDash, a reused Map/open array in findPath, swap-remove instead of splice for finished agents, reused capacity and per-segment car maps, and a zoneLots cache keyed by the city-layer generation.
  • Fixed: housekeeping gaps that left the cached layer stale. census() no longer invalidates the layer on every call; instead each real mutation does — lot clears, construction start/finish, decay, a moved building, a loaded save, a new map and starter towns — and a lane with no segment is guarded in agentPose instead of throwing.
  • Perf harness in the repo. tools/perf.mjs (npm run perf) rebuilds the dev build, drives it in headless Chrome on the largest save and reports frame work time, city-layer paint ops and heap allocation, with a machine-local, gitignored baseline; the performance rules and the enforced thresholds are documented in AGENTS.md. The typecheck baseline dropped from 113 to 111 with the allocation work.

v0.9.54 — 2026-09-19 01:03 UTC

  • Fixed: utility buildings were invisible. The coal plant, water tower, solar farm, wind farm and reservoir all registered their grid effects and jobs correctly, but drew their art off-screen. A half-finished refactor sitting uncommitted in the working tree — switching the utility painters from the small building-band footprint to the full parcel — rode into v0.9.52, and it handed the painters the parcel in world coordinates while they used them as screen pixels. The parcel is now projected to screen before the painters see it, so all five utilities draw on their lot again; verified per utility by pixel-diffing the canvas before and after placement (552–2,929 changed pixels each). The refactor itself was the intended improvement, so it is completed rather than reverted.

v0.9.53 — 2026-09-19 00:27 UTC

  • Fixed: loading a city saved before v0.9.52 could lose most of its buildings, utilities included. Pre-class saves carry no per-lot size, so those lots were re-cut with the new road-driven classes and the old match-by-position step then dropped every lot on a dirt road or small street whose position no longer lined up. The freeze phase now reconstructs a pre-class save's historic normal-lot geometry, so an old city keeps exactly the parcels it was saved with; the new classes apply only to new roads and undeveloped frontage. Verified against saves/Lakeland.json: 679 of 699 developed lots and all 8 utilities restored — the same result v0.9.51 gives — and the v5 round trip and in-session grandfathering still pass.

v0.9.52 — 2026-09-18 23:51 UTC

  • Lots take their size from the road they front. Dirt roads grow large rural parcels (36×42) instead of the standard 15×19; small streets (lane) grow narrow 7.5×12 lots; every other road keeps today's suburban lot. The road tool is the lever — a dirt track makes countryside, a lane makes a rowhouse street.
  • Wall-to-wall terraces. Row lots use zero side inset and their parcels actually meet, so homes and shops on a small street form a continuous terrace with shared party walls. Density is emergent: more lots fit per street, so the existing neighbour-count gate and per-lot population do the rest, and rural parcels stay low.
  • Farmsteads are a place, not a dice roll. Every rural home lot draws as a farmstead — a normal-sized farmhouse, barn, silo and crop rows in a genuinely large yard — instead of the old ~45% per-lot chance.
  • Existing buildings are grandfathered across road upgrades. A developed parcel keeps its size, class and position when the road under it is widened or reshaped; pavement may cover the yard, but a building is demolished only when the widened road would actually cover its drawn footprint. A level-up that would reach the pavement caps at the last storey that fits instead of building into the road. Upgrades that would demolish buildings ask first, with a count.
  • Fixed: large rural parcels are clickable. lotAt used a 12-unit centroid radius, so most of a 36×42 farm could not be selected; it now hit-tests the whole footprint.
  • Save v5 stores each lot's class, frontage, depth and front offset so grandfathered parcels survive save/load; v4 saves load unchanged.
  • Internals: lot subdivision is now two-phase (freeze developed parcels, then cut the remaining frontage), the building footprint is one shared helper used by rendering, demolition and growth, and the typecheck baseline dropped by two.

v0.9.51 — 2026-09-18 12:22 UTC

  • Buildings grew a full second layer of detail, split into its own module. A new 20b-buildings-detail.js holds roof fixtures, facade detail, industrial props, the rural look and the utility art, wired into the existing extruded-box pass at the same zoom gates everything else uses. Every bit of it is deterministic — derived from the per-lot seed and location hash, never Math.random() — so the cached city layer still rebuilds pixel-identically, and the dense-city simple LOD still drops it all.
  • A rural look for low-density homes. A level-1–2 home with few residential neighbours (≤3 within 45 world units) becomes a farmhouse: a smaller gable house up front, a barn and a silver silo behind, crop rows tilled across the back yard, and a post-and-rail fence around the whole parcel. Denser areas keep the current house, so a district reads as a farm town on its outskirts and a suburb at its core. Suburban homes pick up a white picket fence along the back and sides.
  • Apartments, offices and shops got their facades filled in. Courtyard apartments gain balcony ledges and floor-band lines between window rows plus a rooftop HVAC unit; office towers gain floor bands, a diagonal glass sheen over the cool windows, a rooftop HVAC cluster and (most of the time) a lit antenna mast; shops gain a colored sign band near the roofline and a parapet highlight, with roof AC boxes at level 2+.
  • Industry reads as a working plant, not a box. Standard plants at level 2+ get a concrete cooling tower with a vapor wisp and a second storage tank at level 3, plus connector pipes between shed and tanks. High-tech plants stay clean instead — a solar-panel patch on the roof and a cyan trim line, no smoke.
  • Smokestacks smoke and wind farms spin. A new per-frame effects pass, collected alongside the building occluders while the cached layer rebuilds and drawn every frame from wall-clock time, animates rising, fading smoke puffs from standard-industry stacks and the coal plant, and rotates the blades of every wind turbine once you're zoomed in — frozen into the static layer when you're out. It's ambient, so it keeps moving while the sim is paused, and it's capped and culled so it costs a handful of alpha-ellipses a frame.
  • Utility buildings are drawn now, not emoji-stamped. The coal plant (windowed boiler hall, tall stack with a dark plume, coal mound, cooling tower), water tower (lattice legs, tank, conical roof), solar farm (a grid of tilted blue panels), wind farm (three to five turbines) and reservoir (a concrete-rimmed basin with a dam wall and intake house) all replace their old single-emoji roofs.
  • Fixed: shop sign bands and facade details crashed the first shop that had them. The new facade helpers were handed the extruded wall object instead of its .quad polygon, which threw once a shop with a sign band rendered. Caught and fixed by actually driving the renderer.

v0.9.50 — 2026-09-17 04:50 UTC

  • Fixed: a crash in the render pass no longer freezes the game for good. frame() scheduled its next animation frame at the end of its body, so any exception on the way there — a bad draw, a transient state — broke the chain and the canvas went dead until reload. The frame body is now wrapped in try/finally, with the next frame scheduled in the finally, so the loop always survives; the error still reaches the console every frame until the cause is fixed. Verified in-browser: injecting a throwing draw() froze the old build at the throw but the new one keeps running and resumes cleanly once draw() is restored.

v0.9.49 — 2026-09-17 04:18 UTC

  • The light palette is back. The dark restyle is gone: roads are light gray again (dirt is tan), terrain reads bright green, water is a lighter blue over pale sand shores, and sidewalks, junctions and islands move with it. Verified in-browser: new city, growth, save round-trip, zero console errors.
  • The ground plane is pitched again (tilt 0.82). Roundabouts are drawn as tilt-correct ellipses instead of true circles so they sit on the pitched ground like everything else, and car headings are reprojected through the camera so a car lines up with its road instead of visibly skewing off it.
  • Trees everywhere. Open-land scatter, even street rows along both sidewalks of every street (with a few gaps for driveways), and trees on unzoned lots at close zoom — all skipped on the lightweight render path.
  • Flat lowland grows crop rows. A second noise field picks out patches of flat lowland and lays thin alternating tilled rows over them on a fixed world diagonal, faded in at the patch edge.
  • Highways and freeways read as causeways. A permanent soft shadow plus periodic pylons sells the lifted deck even where the road isn't an explicit overpass or bridge, pylons included on overpass decks.
  • Buildings reworked for the light look. Facade, roof-light/roof-dark and shadow options are plumbed through every mass part (including second tiers and sheds), and houses skip the roof fill at close zoom; vacant boarding rules are unchanged.

v0.9.48 — 2026-09-16 02:11 UTC

  • Lots finally have ground around the building. A house used to sit on bare, unmodified terrain right up to its own footprint; a row of shops each floated on its own island with a gap of dirt between them. Every RES/COM/IND/OFF lot now gets an apron filling the rest of its parcel: a lawn with a low picket fence for homes (skipped on the level-3 apartment lots, where a fenced yard doesn't make sense), flat concrete for shops, dark asphalt for industry, and paved plaza with hedges for offices. Two adjacent same-zone lots share an unfeathered, opaque edge at their common boundary, so a whole row of shops reads as one continuous slab of pavement.

    before: buildings on bare terrain with no yard or pavement after: fenced lawns, continuous shop pavement, textured to match the terrain
  • The apron blends into open ground but cuts hard at the road. The outer, non-frontage edge of each apron feathers into a wider, fainter copy of itself so pavement or lawn fades into the terrain instead of ending in a hard ring. The road side doesn't get that treatment: aprons are now drawn in their own pass before roads are laid down, so the road (and its sidewalk) always paints over whatever margin the parcel geometry gives it, instead of the lawn or concrete visibly bleeding onto the pavement.

  • Lot ground now carries the same fine grain as the open terrain. A flat green or gray fill next to the mottled terrain bitmap read as a smoother, different material; each apron now gets the same terrain-detail grain stamped over it, clipped to its own polygon and faded in with zoom on the same curve the open ground uses.

  • The terrain itself lost its visible contour rings and gained that same grain. Elevation bands (water/shore/hill/peak) used to switch color at a hard threshold, which showed up as a ring wherever a slope was shallow — a shoreline especially. Bands now blend across a small elevation window instead of snapping. Separately, a small tileable speckle texture is now multiply-blended over the terrain bitmap once you're zoomed in close enough for the baked bitmap's own blur to be obvious, so the ground reads as grain instead of a smear.

  • Buildings read distinctly from further away and hold up better up close. Rooflines are taller across every zone so the skyline tells level apart from level at a glance. Windows get a mullion frame instead of reading as one flat texture; walls shade with a per-wall gradient (bright near the eave, dark at the base) instead of a flat two-tone fill, plus a thin rim-light down the sun-facing edge; roofs get a gradient instead of a flat fill; shadows are now a soft double cast instead of one hard one; offices get cool glass instead of warm interior light, distinguishing them from homes/shops in silhouette; industrial plants at level 2+ get a second smokestack.

  • Fixed: a car or pedestrian could paint over the roof of a tall building it was actually behind, or over a road it was actually passing under. A tall building's roofline is pushed upward on screen well past its own footprint, and an overpass deck's under-road gap was cut square across the under-road rather than aligned to the deck — both left gaps where a moving agent, redrawn fresh every frame on top of the cached city layer, could paint over pixels it should have stayed behind. Both are now tracked (building silhouettes as occluders, deck gaps as deck-aligned parallelograms) and re-stamped over the agent wherever it's actually behind them.

  • Fixed: loading a save could leave a zoned, developed lot sitting in the roadbed. clearLotsOverlappingRoads() already runs once during load, but at that point every lot is still zone:NONE — its own guard against clearing unzoned lots made the pass a no-op every time. It now runs again after the save's zoning is actually applied, so a lot the road geometry has since encroached on gets cleared for real.

  • Cars got a visual pass to match: a flat contact shadow pinned to the ground regardless of heading, a darker chassis under a lighter inset cabin roof, and a windshield sliver toward the front.

v0.9.47 — 2026-09-16 00:18 UTC

  • Fixed: the Utilities tab didn't actually place anything. Splitting Utilities out from Services (v0.9.45) introduced a new 'util' mode id, but the tap handler and the drag-paint gate in 28-input.js — plus the unzoned-lot outline hint in 18-render-city.js — were still only checking the old mode set. Tapping or dragging over a lot with a power or water source selected silently did nothing.
  • The in-game tutorial catches up with the Utilities split and the new power/water options. The "Zoning & Services" slide is now "Zoning, Utilities & Services" and explains the two separate paid tabs. The "Power, water & land value" slide now explains the tradeoff between Coal, Solar and Wind, and that a Reservoir outclasses a Water tower.

v0.9.46 — 2026-09-16 00:13 UTC

  • Fixed: Small streets were rendering covered in bogus gold pedestrian-crossing lines. Small street's sidewalks are stamped into several short sections along the road so pedestrians can cross mid-block (v0.9.43) — but buildLinks() was pulling every one of those sections' lanes into a junction's connectors, not just the section that actually reaches that junction. The far-end sections got wired in too, producing junction "movements" that stretched almost the full length of the road and were drawn as dashed gold lines in Road/Edit mode. Fixed by checking that a lane actually touches the segment's real end before it's allowed into a node's connector list — interior cuts are already handled correctly by the separate mid-block crossing logic. Beyond the visual, this was also feeding bogus long-distance pedestrian "shortcuts" into pathfinding and inflating the per-junction connector math with pairings that should never have existed.

v0.9.45 — 2026-09-15 23:34 UTC

  • The build menu splits Utilities out from Services. Power and water used to share one "Services" tab with police, fire, clinics, schools and parks. They're now their own Utilities tab; Services keeps the road-reach civics and the park amenity.
  • Three new ways to power a city, each a real tradeoff instead of a reskin. The old power plant is now explicitly the ☀️ Coal power plant ($2,600, 4,200 power/mo) — and it actually pollutes now, fouling nearby air like a mid-size factory, which it never did before. Alongside it: ☀️ Solar farm ($1,500, 1,800 power/mo flat, zero pollution) — cheap and clean but weak wherever you put it — and 🌬️ Wind farm ($2,200, zero pollution, 900–4,000 power/mo depending on distance to water) — worse than solar inland, better than coal on a coastline, so siting it actually matters.
  • New: 🌊 Reservoir ($2,600, 8,500 water/mo) — a high-capacity water source alongside the existing Water tower (1,100/mo), for cities that have outgrown one tower per grid shortfall.
  • Parks got a path. Every park lot now draws a winding sand-colored path between its trees instead of just lawn and trees.
  • Cleaned up wording that assumed there was only ever one power/water building: the grid overlay's "no plant built" message and the water-grid hint now speak generically about power/water sources.

v0.9.44 — 2026-09-15 15:08 UTC

  • Concord Wheel is cut down to something that actually runs. It shipped a version too heavy for most machines: 544 road segments, 392 nodes and 1,341 lots on a large map. It is now a medium map with five rings instead of eight, an outer ring of 260 rather than 416, fewer radials at every step, and a compact freight yard — 135 segments, 84 nodes, 561 lots.
  • The cost that was actually being paid was per road you draw, not per frame. Every edit triggers a full network rebuild, and on the old wheel that took 95ms — a visible hitch on every single road you place. It is now 21ms. The simulation tick came down from 4.7ms to 1.9ms, and building the town from 285ms to 84ms. Profiling put the weight in clearing lots that overlap pavement (57ms) and wiring junction connectors (24ms), both of which scale with how much city there is — which is why trimming the plan was the fix rather than tuning anything.
  • Fixed: two roads were laid on the same strip of ground beside the freight yard. Each cross-town avenue used to stop at a node of its own a few units short of the yard and then hop across to it with a short connector — and that connector ran straight down the outside of the yard's east column, overlapping it for its whole length. The avenues now run from the rim directly onto the yard's own corner nodes, which also removes two junctions that did nothing. The two roundabouts serving the yard had landed on adjacent rows as well, 52 units apart, close enough to merge into one blob of tarmac; they sit on opposite corners now.
  • Every ring now carries exactly as many nodes as it has roads joining it, and no more. Concord was carrying 84 pure bends — ring nodes with a road in and a road out and nothing else. Each one cost a junction footprint that trimmed frontage off both arcs, a set of lane connectors and a visible blob of tarmac, and bought nothing: a ring's block length is set by where its radials are, not by how finely the arc is chopped between them. 160 nodes down to 84, 215 segments to 135. The rings are drawn with the same circle fit as before, so the wheel is identically round — there are simply far fewer junctions scattered around it.
  • Nothing that gives the town its shape was dropped: the one-way anticlockwise plaza ring, four avenues to the centre, the north–south bypass with a regional connection at either end, both cross-town overpasses, the rim roundabout where the national highway arrives, and the freight yard west of the bypass are all still there — there is simply less of everything between them. ~3,500 residents and 7,800 jobs on day 2,000.
  • The opening shot is framed wider, so the whole plan clears the mode bar at the bottom of the screen instead of running under it.
  • Three roads land on the outer ring — the national approach and both cross-town avenues — and each now claims its own node, so two of them can never stack two roundabouts on top of one another.

v0.9.43 — 2026-09-15 13:44 UTC

  • New road type: the Small street ($11/m, narrower and slower than a City street). It carries frontage and sidewalks like any other street, but pedestrians cross it wherever they like — its sidewalks are stamped in sections and every seam between them is a crossing, so someone heading for the building opposite steps off the kerb instead of walking to the corner and back. Measured on a block with lots on both sides: reaching the door directly across costs 90 units of walking on a small street against 247 on a city street. Nothing is painted on it — no crosswalk stripes and no centreline — because those are the same fact: there's no marked crossing precisely because the street is small enough not to need one. It slots into the upgrade chain between Dirt road and City street.
  • Concord Wheel is roughly four times the city it was. The outer ring goes from 192 to 416 on a map that reaches ±880, four concentric rings become eight, and ring node counts now climb with radius so a block is about the same length at every ring instead of the outer ring's blocks stretching eight times as long as the inner one's. ~1,340 lots against the old 298. The opening camera fits the plan to whatever viewport it's on rather than using one fixed zoom.
  • The wheel's centre is no longer a knot. The eight-spoke plaza roundabout is gone; the innermost ring is now a one-way anticlockwise loop around an open green, and only four avenues push all the way in (radials step 4 → 4 → 8 → 16 → 32 outward, each set nesting inside the next). A ring road already does the one job a roundabout does — let everything turn without a signal — and it does it without needing to be enormous to swallow eight avenues.
  • The industrial district reads as part of the city instead of a grid dropped in a field beside it. It's a proper freight yard now — heavier, with a spine down its middle, a loop of streets round the outside and a roundabout where each cross-town avenue lands, so a lorry never has to reverse out of a dead end — and it sits directly west of the bypass rather than floating out on its own.
  • Fixed: Concord Wheel had silently lost both of its overpasses. The bypass ran through a lake on the old terrain seed, which makes that stretch a bridge — and findOverpassSpans() won't deck over a bridge, since a bridge isn't touching what it passes over. So the two cross-town avenues quietly came out at grade. The seed is now picked for this plan: bypass corridor and both crossing avenues on dry ground, with real water still against the wheel's eastern rim for the waterfront zoning band. Both decks verified present.
  • Fixed: the starter towns were built on top of the national highway instead of around it. seedCity() laid one fixed highway sized for the original hand-made city — map edge, to a gate at (-150, -90), to a town node at (-6, -16) — and every starter town then built its own layout over the top. On Concord that gate sits 237 units from the plaza, inside the fourth ring, so what the code called the "approach" was a trunk road driven outward from the middle of the city through every ring on its way to the edge. The small towns had the same leg ending inside their grids. Every town now declares its own gate — the single point on its outskirts where the national road stops and the town's own arterial takes over — and the approach is laid to that point and nothing else.
  • The gate is a roundabout with two town roads leaving it, not a road that simply carries on. Linked by one straight road, the approach inherited the highway's exact bearing and read as the highway continuing in a different colour, with a ribbon of houses down both sides of it. The roundabout gives arriving traffic somewhere to choose, draws a visible line between the trunk road and the streets, and trims both approaches back so the ribbon never forms.
  • Fixed: starter towns shipped with buildings standing in the carriageway. The pass that clears lots overlapping pavement skips unzoned lots by design — and worldgen was calling it before it zoned anything, so during town generation it could only ever be a no-op. Across all five towns the nearest building to the highway's centreline was 3.9 units on Harborfront and 0.7 on Concord; it is now 34–38 on the small towns, with no zoned lot on the corridor and no unresolved at-grade crossing anywhere.
  • Founders Square, Maple Hollow and Foundry District are resized to stand on their own. They had been quietly living on frontage supplied by seedCity()'s leftover stubs: with those gone, Founders' six inner spokes turned out to be about five usable units each — the plaza roundabout ate them whole — and the town dropped to 16 lots. Founders' spokes are now roads rather than avenues at a wider radius, which cut its junction pavement from ~23,600 square units to ~9,900 and raised it to 67 lots; Maple's ring and spurs and Foundry's blocks are spaced to match. Lot counts against the old towns: Harborfront 49 (51), Founders 67 (35), Foundry 48 (41), Maple 48 (44).
  • Concord Wheel also ships with a grid that can carry it — at ~9,000 residents it was handing over 1,085 unpowered lots, and now has headroom on both power and water.
  • The blank-map opening is unchanged: a brand-new city still gets the original seeded highway, town junction and street stubs exactly as before.

v0.9.42 — 2026-09-15 12:06 UTC

  • The intro screen's Advanced options now let you type your own map seed. Leave it blank and a random, land-friendly seed is picked the same way it always was; type a number and that's the seed, exactly; type anything else (a word, a name) and it's hashed into one — so "riverside" is just as valid and just as reproducible a seed as a number, and always regenerates the identical map. A 🎲 button clears it back to random. The field shows whatever seed is actually in play at all times, random rolls included, so a map you like can be copied out of that line and reused later even if you never typed a seed for it in the first place. Disabled while a starter town is selected, same as the land/mountain sliders — starter towns pin their own seed for the same reason those do.
  • A seed typed on purpose is used exactly as given, with no water-avoidance bias applied — that bias exists only to keep a random roll from flooding the seed road, not to second-guess a choice someone made deliberately. If a chosen seed does put water across the entry road, it bridges the same way any player-drawn road would.

v0.9.41 — 2026-09-15 05:24 UTC

  • Roads can now bridge over a highway instead of junctioning with it. Draw across a highway or freeway and the road tray offers a choice: ⊹ Intersect (what always happened, and still the default) or 🌉 Bridge over. Take the bridge and the crossing stops being an intersection entirely — no node is shared, no lane links are built, and nothing on the deck can turn onto what's underneath. Only the stretch that actually clears the road below is elevated: the road is cut into ground/deck/ground pieces, with the deck sized to the width of what it crosses at the angle the two actually meet (a shallow crossing needs a longer span than a square one) plus a short level approach at each end. The deck is charged at the same 3× bridge rate water already costs, and the tray shows the deck highlighted before you commit. Offered only over highways and freeways — bridging a city street would just be an expensive intersection.
  • The road underneath is drawn with a gap on either side of the deck. Rather than two ribbons fighting over the same pixels from directly overhead, the lower road's pavement simply stops short of the bridge and resumes past it, which is what actually reads as one road being above the other. This is rendering only — the road below stays a single unbroken segment with its lanes and its traffic intact, and cars drive under the deck without noticing it. Recomputed on every rebuild, so reshaping either road moves the gap with it.
  • A bridge deck — over water or over a road — is now exempt from crossing resolution entirely. It isn't touching what it passes over, so there's nothing there to join; that exemption is the whole mechanism behind grade separation, and it also fixes a latent case where a water bridge that happened to cross a road would sprout a junction in mid-span.
  • New starter town: Concord Wheel, on a large map. A plaza roundabout, eight radiating avenues, four concentric ring roads whose node counts double outward so block length stays constant at every radius, a two-way bypass running north–south with a regional connection at either end, and an industrial district west of it with its own freight roundabout. Two cross-town avenues reach the ring from the west on overpasses over that bypass, built through the same code path the player's own road tool uses. ~1,650 residents and 3,500 jobs on day 2,400.
  • The four small starter towns are rebuilt from layout rules rather than hand-typed lot tables. Each one now lays its own road pattern and then classifies whatever lots that pattern actually produced, by distance from its own centre and from real water — so the zoning can't drift out of step with the geometry, and a differently-shaped town needs a new layout and classifier instead of a re-surveyed list of coordinates.
    • Harborfront (was Riverside Junction) sits on a terrain seed picked so there's real water just past its east edge — the old one had none, which made a town called Riverside a little hard to justify. Waterfront blocks turn to tourism and nightlife.
    • Founders Square is new: a plaza roundabout with an avenue-and-street spoke wheel, density stepping down from the core.
    • Foundry District (was Ironworks) shears its grid row by row into an offset rail-yard pattern, with an avenue spine and high-tech mixed in with heavy industry, against a steep mountain bias.
    • Maple Hollow (was Maple Heights) is a ring road of true circular arcs around an open green commons, with cul-de-sac spurs.
  • Fixed: the starter towns' entry roads used to run through the town rather than up to it. Two of the new layouts were placed close enough to the highway's entry junction that seedCity()'s own street stubs passed straight through them — in Maple Hollow's case the stub's closest approach landed partway along itself, well inside the ring, paving a road across the commons. Every layout is now positioned so those fixed roads only ever meet it at the shared junction, and each one hooks on by avenue at its far corner so the highway has real distance to cover before downtown starts.
  • Fixed: the small starter towns shipped with no power plant or water tower. Power and water are a city-wide grid, so a town handed over without them can't grow past a single storey and starts decaying on day one. All four get a plant, a tower, a police station and a school again, as the hand-placed tables used to carry. Founders Square is also handed over a storey short of its ceiling rather than at it — a core built to its maximum on day one has nowhere to go but down.
  • Ring roads and the roundabout ring lanes now share one circle-to-Bézier fit (buildRingRoad), so a ring road and a roundabout are curved by identical means at different scales.

v0.9.40 — 2026-09-15 03:55 UTC

  • A road paved through a bailout building used to dodge its clawback entirely. The Demolish tool has always warned before tearing down the Army Base, Prison or Casino and taken the signing bonus back with it; a road drawn straight through the same lot went through a completely different code path (clearLotsOverlappingRoadsclearLot) that never touched the partnerships logic at all, so it cleared the building for free — no fee, no warning, and you kept the money. clearLot now runs through the same forfeitPartnership helper the Demolish tool uses, so either way of losing the building claws back what it paid.
  • Bailouts now have to be re-earned, not just re-clicked. Demolishing the Army Base, Prison or Casino used to put it straight back on the Special menu, available to rebuild for free the instant you wanted it — which made the debt threshold that unlocked it in the first place meaningless the second time around. Losing one now clears its unlocked status entirely; it only reappears once the same trigger (debt, or the Casino's commercial-jobs alternative) is true again, same as the first offer. Non-bailout partnerships (Mall, Bank HQ, Industrial Consortium, Architect's House, National Park) are unaffected — no money was ever at stake for those, so they still go straight back on the menu.
  • The Special menu is now an achievement board, not a menu that only exists once something's unlocked. It used to disappear from the mode bar entirely until your first partnership came through, and each chip vanished outright the moment a building went up or a bailout re-locked — there was no way to tell what you'd already earned, what you'd lost, or what you were even working toward. Every partnership now always shows: greyed out and 🔒 Locked before you've ever qualified, ✓ Built (greyed) while it's standing, and — once a bailout has actually been built and then lost — 🔒 Requirements not met instead of plain "Locked", so it reads differently from something you've simply never touched. Whatever's currently unlocked and placeable now sorts to the top of the list instead of sitting wherever the fixed catalog order happens to put it.
  • Fixed: placing a Special building showed no lot outlines to place it on. Zoning and Services have always outlined empty, buildable lots so you can see where a tap will land; Special mode was left out of that check, so every lot rendered with no outline while a partnership building was selected and there was no visual cue for where it was legal to click.

v0.9.39 — 2026-09-14 05:08 UTC

  • Added a "What's holding it back" readout to the happiness tray. Quality of life and business health are both weighted averages, so the gap between your city and a perfect score can be attributed exactly — each term costs you weight × (1 − value) happiness points. The tray now ranks the worst three and says what each is costing, with the fix underneath; tapping one switches to Move/View and opens the overlay that shows where it's happening. Both halves are rescaled onto the happiness axis first (it's 0.6×QoL + 0.4×business), so a stalled export lane and a crime wave can be compared directly instead of living in separate sections. This replaces the static paragraph that used to sit there explaining what the two numbers were made of.

  • The weights now live in one place (QOL_TERMS / BIZ_TERMS) and both the number and its explanation are summed from it, so the breakdown can't drift from the figure it explains. Happiness itself is unchanged — a large save reads 82.5% before and after.

  • Relocating a partnership building is no longer free. The Demolish tool has always clawed back the signing bonus, but the Move tool never got the same treatment, and since every special renders through the civic-building path with cost: 0, moving one cost nothing at all — you could site an Army Base, collect the cheque, and shift it off your good land for free. Relocation is now billed at 25% of what that partner actually paid (Army Base $8,750, Prison $12,500, Casino $6,250), with a flat $2,000 for the achievement buildings that came with no cheque. You keep the bonus — you're still hosting the building, which is what the deal asked for — but where you put it is now a decision rather than a formality. Both trays say so before you commit.

  • Partnership buildings now cost something to run. They had no upkeep, drew no power or water, and hired nobody, so three of them were $4,500/month of pure profit forever — about a quarter of a mature large city's entire net income. They now carry upkeep ($60–$420/mo), draw on the grid like any other civic building, and staff themselves out of the same workforce everything else competes for (a federal prison hires 40). The inspect card shows both figures.

  • Bailout grants resized to stabilise rather than erase. The Army Base paid $60,000 against a $30,000 trigger and the Prison $90,000 against $45,000, so hitting the threshold didn't just rescue you, it doubled your way out of it. Now $35,000 and $50,000 — still a lifeline, but you have to run a surplus to actually climb out. Their monthly income came down with them (Army $1,400 → $900, Prison $1,100 → $800, Casino $2,000 → $1,500).

  • The Casino no longer pays a city that isn't in trouble. Its trigger has always been "in debt or 35 commercial jobs", which meant a thriving debt-free city got offered $25,000 for nothing. The prosperity pitch is now a separate offer with no cheque — courting a city doing well is an opportunity, not a rescue — and the pitch text says which one you're getting. What a partner actually paid is recorded per city, so a later clawback or relocation bills the real figure. What a partner actually paid is recorded per city and survives demolition, so a rebuilt bailout is yours to keep but never pays twice — without that, taking the Casino free while prosperous, falling into debt and rebuilding it would collect $25,000 on the way back in, repeatably.

  • An offer you turn down is never withdrawn. Declining used to be permanent — the building was gone for the rest of the city's life, which punished you hardest for hesitating during exactly the stretch where hesitating is reasonable. Now either answer leaves the building under Zoning ▸ Special for good, and siting it is what takes the deal. The menu chip shows the bonus still on the table (🪖 Army Base +$35,000), so a deal you passed on stays visible rather than living in a modal you already dismissed. A city runs one of each at a time: the chip hides while the building stands and returns if you demolish it.

  • The signing bonus is paid when the building is sited, not when you accept the modal. Every pitch has always said the money is "to site" the thing; now it means it. This is also what makes declining safe to reverse — the money is still there whenever you take the deal — and it closes a hole that predates this release: you could accept the Army Base, pocket the bonus, never build it, and pay no upkeep, host no pollution and face no possible clawback, because the clawback only ever ran on demolition. Paid once per partner, ever.

  • Achievement buildings finally respond to where you put them. The Mall, Bank HQ, Industrial Consortium, Architect's House and National Park existed only as a sprite and a flat monthly cheque — they weren't in the land-value pass at all, so a mall wasn't a shopping district and a national park wasn't a park. Each now reads as what it is (the Consortium runs plant of its own, so siting it next to housing costs you the way industry does), each lifts the blocks around it as a landmark, and each earns what its surroundings are worth — 60% of the table figure on a dead edge, up to 140% in a thriving district. Bailout buildings stay flat on purpose: a rescue whose size you can't predict isn't much of a rescue.

  • The land-value pass is spatially indexed, and the tick no longer scales with the square of the city. It was the last pass still testing everything against everything — every lot against every industrial source, every park, every shop, every office — while the answer only ever depends on what's nearby. Amenity sources are now bucketed into cells, as is the neighbour count that caps building height. Measured on a large save tiled out to bigger cities: 787 lots 6.25ms → 3.99ms, 1,574 19.3 → 6.9, 3,148 69.9 → 16.5, 6,296 256 → 34.3. Doubling the city used to cost about 3.3×; it now costs about 1.9×. Agents were never the problem — they're hard-capped at 220 cars and a full trip including pathfinding costs 0.04ms.

  • Added a How to Play step for Special Partnerships, which the tutorial had never mentioned despite it being one of the deeper systems in the game.

  • Note for existing saves: a city that took the Army Base or Prison under the old rules has no record of what it was paid, so a clawback bills the new figure rather than the old one. One-time, and in your favour.

v0.9.38 — 2026-09-14 04:06 UTC

  • Added a slower simulation speed, and made it the default. The speed row now has five steps — pause, (0.25×), (0.5×), ▶▶ (1.5×), ▶▶▶ (4×). The new 0.25× tier is half the old starting pace, so a fresh city gives you time to react before the economy runs away with itself; the old default is still one click to the right.
  • Applies to every entry point: new cities, starter towns, and loaded saves (saves never stored the speed, so they pick up the new default too).
  • Added a favicon — the 🏙️ emoji, as an inline SVG data URI, so the single-file build stays self-contained. Republished in place over the original v0.9.38 build at 2026-09-14 04:19 UTC; the version number was deliberately not bumped for a change this small.

v0.9.37 — 2026-09-14 02:54 UTC

  • Effectively a no-op release — one comment reworded. This was meant to ship the bank tray's "No debt" wording, but that had already gone out in v0.9.35 (see the correction there). The only real difference from v0.9.36 is a comment in 23-bank.js losing the word "that". Recorded rather than quietly retired, since the version was already built and promoted.
  • What went wrong: the change was written into src/ during a local-model experiment and left sitting there. The next build — v0.9.35, made to ship an unrelated typeId fix — reassembled everything in src/ and carried it along unnoticed. node build.mjs takes whatever is on disk; there is no staging step. Until this project is under version control, finish and ship one change before starting the next, and diff the built file against the previous version before writing a changelog entry.

v0.9.36 — 2026-09-14 00:53 UTC

  • Completed the v0.9.35 stale-button fix. That entry claimed the commit button could no longer be activated with no draft road; that was only true once clearPending() had run. #trayGo shipped with no disabled attribute, so on a fresh load it sat enabled with nothing pending. The v0.9.35 guards meant clicking it was harmless, but the button was lying about its state. It now starts disabled in the markup.
  • Verified across the full lifecycle: disabled at load, still disabled after a stale click, enabled while a draft road is being drawn, road commits normally, disabled again afterwards.

v0.9.35 — 2026-09-14 00:47 UTC

  • Correction, added after the fact: this release also changed the bank tray, which went unrecorded at the time. With nothing borrowed, the top row used to read "Outstanding loan — $0", which scans like a bill for nothing; it now reads "No debt — nothing owed" and only shows the loan row when there actually is one. The edit was sitting uncommitted in src/ and got swept into this build. v0.9.37 was later bumped believing it was shipping this change; it wasn't.
  • Fixed Cannot read properties of null (reading 'typeId'). The road tray's Build/Save button stayed clickable after the tray closed — the tray only slides out of view, and only refreshTray() ever touched the button's disabled state, so a click on it once the draft road was gone ran commitPending()pendingValid() against a null pending. Present since well before the modular split; not a build regression.
  • commitPending() now returns early with no pending road, pendingValid() reports "nothing to validate" instead of throwing, pendingCost() returns 0, and clearPending() disables the commit button on the way out so the stale control can't be activated at all.

v0.9.34 — 2026-09-13 23:52 UTC

  • Added a spatial grid index for lots. Drawing (and anything else that only cares what's nearby) no longer touches every lot in the city every time — it only looks at the grid cells actually near the camera. Rebuilt automatically whenever the lot layout itself changes (a road's built/edited), never on a plain zoning change.
  • Added density-based detail fallback. Once more than ~260 buildings are visible in one frame, every one of them drops to the flat, no-window, no-roof-trim version regardless of zoom — the same trick that already made zoomed-all-the-way-out cheap, now applied at normal working zoom on a big city too, not just at the extremes.
  • Combined with last version's caching, this kills the "worst zoom band" from the profiling: on the same 787-lot save, the previously-worst mid-zoom view went from 21ms/frame to ~5.8ms/frame even while actively panning (cache miss, worst case) — and stays effectively free (~0.02ms/frame) whenever the camera's just sitting still.

v0.9.33 — 2026-09-13 23:44 UTC

  • Big performance fix: the static city (terrain, roads, buildings) is now cached instead of being fully redrawn from scratch 60 times a second. It's rendered once to an offscreen layer whenever the camera moves or the city actually changes (a road's built, a building levels up, a zone's painted), and every other frame just reuses those pixels — moving cars, pedestrians, traffic lights, and highlights still redraw every frame on top, same as before. Measured on a 787-lot save: ~23ms/frame while the camera sits still (the common case — just watching the city run) dropped to ~0.03ms/frame. Panning/zooming still costs what it always did, since the cache has to rebuild for the new view, but that's a brief cost instead of a permanent one.

v0.9.32 — 2026-09-13 23:02 UTC

  • Feed posts are now tappable. Residents, owners, criminals and firefighters point at the real, worst-affected lot behind their post (the actual highest-crime house for a crime post, the actual worst fire coverage for the firefighter easter egg); tourists point at a real live visitor currently in the city when one exists. Tapping a post jumps the camera there, opens the relevant overlay (traffic, pollution, crime, fire, school...), and opens that lot's or agent's inspect card — so "how bad is it, really" is one tap away.
  • Added anticipation/worry posts. Once a partnership is genuinely close (70%+ of the way to its threshold) but not yet offered, residents and owners start dropping hints — excited buzz for achievement deals ("word is Meridian National has been scouting..."), worried commentary for the bailout ones ("wouldn't shock me if we end up leaning on the feds soon").
  • Demolishing a special building now branches by kind. Achievement buildings (Bank HQ, Industrial Consortium, Architect's House, Mall, National Park) go right back to "buildable" in the Special menu — no need to re-earn them. Bailout buildings (Army Base, Prison, Casino) forfeit for good and require hitting the same threshold again, and demolishing one now asks for confirmation first, spelling out exactly what you're giving up.
  • Added the Casino — part bailout, part commercial win. Triggers on either real financial trouble or a genuinely strong commercial base, pays $25,000 up front, boosts nearby commercial land value, and pollutes the area from the traffic and late nights. Classified as a bailout building: demolish it and you forfeit the money and need to re-trigger it.

v0.9.31 — 2026-09-13 22:42 UTC

  • Army Base and Federal Prison signing bonuses raised to $60,000 and $90,000 — the pitch text is upfront about the trade-off (pollution / unhappy residents) before you accept.
  • Demolishing either one claws back its signing bonus. It was a lease on the land, not free money — tear the building down and the $60k/$90k comes right back out of your cash, on top of the usual demolish fee.

v0.9.30 — 2026-09-13 22:38 UTC

  • Rebalanced the partnership thresholds — Army Base and Federal Prison now need real financial trouble (debt over $30k / $45k, prison worse than the base), and both come with a real downside now: the army base pollutes like a dirty factory, and a prison measurably spooks crime nearby, regardless of how well-policed the area otherwise is. Bank HQ and Industrial Consortium are now genuine achievements — meaningfully harder office/industrial/fire-coverage bars to clear, not a quick unlock.
  • Added three more partnerships, each a positive milestone rather than a bailout:
    • Architect's House — offered once a real number of homes have fully matured.
    • Shopping Mall — offered once your commercial district has matured the same way.
    • National Park designation — offered once the city's stayed genuinely happy (85%+) for months on end, not just a lucky spike.
  • A special building now only builds once — the moment you actually place it, it drops off the Special menu for good, whether or not you later demolish it.
  • Mode bar now has icons, and collapses to icon-only automatically when the screen is too narrow to fit full labels — measured live, not a fixed breakpoint, so it adapts to whatever's actually in the row (like Special appearing).
  • Road types / zone / service / special chip lists collapse now too, the same way the overlay picker in Move/View already did — tap to fold it down to just the current selection and get the vertical space back.

v0.9.29 — 2026-09-13 20:22 UTC

  • Social feed now reacts to service quality, not just crime/pollution in the abstract. Low school coverage gets residents complaining about the commute to class (good coverage gets a happy note back); a new 🥷 criminal persona shows up in the feed bragging about how quiet the patrols have been once crime gets genuinely bad; a 🚒 firefighter posts a dark-humor easter egg about response times once fire coverage is bad enough.
  • Added Special Partnerships — a new one-shot deal system. When your city's actual state fits what a partner is looking for, a popup offers a deal you can only ever accept or decline once:
    • Federal Army Base / Federal Prison — the federal government offers a signing bonus plus a free building once your debt gets bad enough (prison needs it worse than the base does).
    • Bank HQ — a bank wants to relocate once your office district is genuinely healthy (enough office jobs, real demand).
    • Industrial Consortium — an industrial group wants in once your fire coverage is strong enough that their insurers approve.
    • Accepting unlocks that building permanently under a new Special tab in the zoning dock (only appears once you've unlocked at least one) — free to place, and each pays out real monthly income once built. Declining forfeits the offer for good.

v0.9.28 — 2026-09-13 16:58 UTC

  • Fixed a regression from v0.9.27's pinch fix: the junction pavement fill was sampled past each road's trim point to smooth a rare notch on sharply-hooked merges, but the crosswalks are drawn exactly at the trim point — so the fill now always overshot the crosswalks, and dramatically so wherever the junction was already large (a tight-angle intersection especially). Fixed by sampling from the node toward the trim instead of past it, so the fill can never extend beyond where the crosswalk marks the edge, while still catching a sharp curve's bow along that stretch.

v0.9.27 — 2026-09-13 16:55 UTC

Junction geometry pass — everything below was found by building (and reshaping) increasingly awkward intersections and fixing what broke.

  • Reshaping a road could sneak past the 15° minimum angle rule. The angle check only ran when drawing a fresh road; dragging an existing road's curve to swing its endpoint into a knife-edge angle against a neighboring spoke was never validated. Reshaping now gets the same check.
  • Tight-but-legal junctions now actually expand to clear the road edges, instead of the trim distance being artificially capped short and leaving the pavement visually overlapping itself.
  • Roundabouts got noticeably bigger — the ring used to be barely wider than the road feeding it; it now has real visual presence.
  • Roundabouts got a "you're editing this" highlight that actually matches the sidewalk ring instead of floating out past it over bare grass.
  • Pedestrians now walk the sidewalk ring around a roundabout instead of cutting straight across it. Cars were already constrained to the ring; pedestrians were quietly using the same point-to-point paths as a normal intersection.
  • Added a check that refuses to build (or convert to roundabout) a junction that would overlap another one nearby — two intersections can no longer end up visually on top of each other.
  • Fixed zone lots overlapping a junction's own paved footprint (most visible on a roundabout's ring) — the lot-clearing pass only ever checked straight road segments, never a junction's fillet or a roundabout's circle.
  • Fixed zone lots overlapping each other on a curving road — lots were sampled without a margin, so neighboring lots' back corners could creep into each other on a bend; a real hairpin turn now correctly skips zoning the tightest stretch instead of folding a lot's own polygon over itself.
  • Fixed an unpaved notch at a sharply-angled merge where a road hooked right up to the junction — the pavement fill only sampled a single cross-section at the trim point, which could sit inside the curve's real (bowed) edge.
  • Fixed the freeway's starting kink never being smoothed — a ~14° bend was allowed through untreated; the threshold for "give this joint a fillet" is tighter now.
  • A plain 2-way bend (not a real intersection) now reads as one continuous road — lane markings run through it instead of leaving a blank paved gap, no stray stop-bar appears, and the crosswalk still marks once instead of doubling up at an angle.
  • Added a junction geometry diagram explaining the hub/fillet/spline model non-roundabout intersections use.

v0.9.26 — 2026-09-13 07:15 UTC

  • Added a minimum 15° junction angle. A road that would meet another road (or itself, mid-span) at a knife-edge angle is refused at build time instead of producing a degenerate, over-stretched junction corner.
  • Roads now clear the zone/building they're drawn over. Any lot the new pavement passes through is cleared automatically — silently for a bare zoned or empty lot, but a lot with an actual building (built, under construction, or vacant-but-standing) now shows a red hazard highlight while you're still shaping the road, and building it triggers a confirmation dialog listing how many buildings will be demolished before anything is torn down.
  • Overlapping zone spaces resolve by road tier. When two roads' lot strips would occupy the same ground (e.g. a new avenue crossing an old dirt road), the higher-tier road's lots now win the territory, instead of it being whichever road happened to be built first.
  • Fixed a bug where zoned buildings under construction, vacant, or with a chosen specialty would silently reset any time a road was built or edited nearby — lot state now survives the lot-grid regeneration that runs after every road change.

v0.9.25 — 2026-09-12 16:51 UTC

  • Fixed: on iOS (particularly the home-screen standalone launch), a thin strip of map could show through below the bottom dock/trays instead of a solid background — the safe-area inset the layout relied on could come back zero or too small. Added a dedicated solid-color filler pinned to the true bottom edge, plus a safety floor on the dock/tray padding, so nothing shows through regardless of how the browser reports the inset.

v0.9.24 — 2026-09-12 16:38 UTC

  • Fixed: free actions (road downgrades, service-building moves, junction control changes) were incorrectly blocked whenever cash was negative, even though they cost $0 — the affordability check compared cash < fee without first checking that the fee was actually positive.
  • Added a small v0.9.24 version label in the bottom-right corner of the title screen.
  • Renamed the versioning scheme from v9.x to v0.9.x.

v9.24 — 2026-09-12 (superseded by v0.9.24 — same content, renamed)

  • Road upgrades now cost only the price difference between the old and new road type for that stretch, instead of the full new-type price. Downgrades (or any swap to something cheaper) are free.
  • Service/utility buildings can be moved. Tapping a placed police/fire/clinic/school/power/water building in Edit mode opens a service tray with a Move option (fee: 35% of the building's original cost).
  • Moves require confirmation before taking effect — after picking a destination lot, a ghost preview highlights it and a confirmation tray (Cancel/Confirm) appears; nothing relocates until confirmed. Canceling, pressing Escape, or switching modes mid-move correctly aborts it.

v9.23 — 2026-09-12 07:19 UTC

  • Edit-mode selections (roads, junctions) now get the same amber pulsing highlight that View-mode inspection already had, so it's clear what you're about to edit.
  • Downgrading a road is now free.
  • Derelict/vacant buildings keep showing their building model (desaturated, boarded-up windows, a crack, 🏚️ badge) instead of reverting to a bare lot outline.

v9.22 — 2026-09-12 07:07 UTC

  • Clicking to inspect something (agent, lot, road, junction) now highlights it on the map in addition to opening its info card.
  • Clicking a service building or road/junction now auto-switches the active overlay to the one that explains it (e.g. tapping a police station opens the police coverage heatmap).

v9.21 — 2026-09-12 06:49 UTC

  • Removed construction delay from civic/utility buildings — only zoned (RES/COM/IND) buildings take time to build; services place instantly, as originally intended.
  • Added a "Building & growth" step to the How to Play tutorial explaining construction time and the vacancy grace period.

v9.20 — 2026-09-12 06:19 UTC

  • Civic/utility buildings (police, fire, clinic, school, power, water) now require staffing, tying into the city's job market.
  • Zoned buildings take a few days to construct or upgrade, with a visible 🏗️ construction site during that time.
  • A building that loses all demand sits vacant (🏚️) for a grace period instead of disappearing immediately, and reopens instantly if demand returns within that window.
  • Added the "Spline Social" feed — citizens, business owners, and tourists post about their situation, generated from live city state.

v9.19 — 2026-09-11 18:30 UTC

  • Fixed buildings sometimes rendering clipped under roads (roads are now drawn before lots).
  • The seeded starting highway/streets now build bridges over water, matching player-drawn roads.

v9.18 — 2026-09-11 18:14 UTC

  • Fixed the RCI indicator overlapping the map on phone screens.

v9.17 — 2026-09-11 18:14 UTC

  • Decluttered the mobile HUD — right-side elements can now be collapsed.
  • Fixed the bottom HUD not appearing on some mobile browsers (notably iOS home-screen bookmarks).
  • Fixed inaccurate road-click detection so tapping anywhere a road visually occupies reliably selects it.

No timestamps survive for this far back — versions below are undated, in chronological order.

v9.16

v9.15

v9.14

v9.13

v9.12

v9.11

v9.10

v9.9

v9.8

v9.7

v9.6

v9.5

v9.4

v9.3

v9.2

v9

  • Added auto-splitting water bridges, fixed roundabout flow to counterclockwise, scaled road tap hitboxes, and added the Boomtown music track.

v8

  • Improved road selection hit-testing and refined intersection conflict sets for high-density traffic performance.

v7

  • Added graph-based A* pathfinding and dynamic vehicle agent spawning along lane-connector curves.

v6

  • Introduced procedural terrain with water bodies and starting constraints, including a pre-built highway and river network.

v5

  • Added mobile-first touch handling: single-finger tool painting, dual-finger pan/zoom, tap-to-open city hall sheets.

v4

  • Integrated the core simulation loop: R/C/I demand, power/water grids, and land-value-driven building upgrades.

v3

  • Implemented dynamic lot subdivision along curved road meshes, creating polygon zones that align to spline edges.

v2

  • Transpiled the data model to pure, browser-ready JavaScript with a live canvas renderer for node placement and dynamic curve trimming.

v1

  • Designed the decoupled 3-layer architecture (Topology, Bézier Geometry, Lane Graph) to support smooth curve editing and agent pathfinding.

v0 — Spline City

  • Established the foundation: a pure JavaScript canvas demo with interactive Bézier road drawing, G1-smooth node dragging, and dynamic intersection trimming.

Road Network Demo

  • The runnable prototype that first validated raw Bézier spline drawing, node handle manipulation, and lane centerline visualization, before any simulation systems existed.
{"version":3,"nid":47187,"terrainSeed":810552165,"terrainLandBias":0.08,"terrainMountainBias":0,"citySize":"small","terrainSpan":420,"mapEdge":395,"radio":{"mode":"auto","track":"groundwork"},"overlayMenuOpen":true,"cityName":"BigCity","cash":34421.76666666667,"tax":9,"day":2105,"simTime":1347.83458999961,"loan":0,"freePlay":false,"cam":{"x":88.93568872671887,"y":131.23563703227182,"z":2.2409858443148716},"nodes":[{"id":1,"x":-395,"y":-150,"control":0,"outside":true},{"id":2,"x":-150,"y":-90,"control":0,"outside":false},{"id":3,"x":-6,"y":-16,"control":4,"outside":false},{"id":6,"x":-96,"y":34,"control":3,"outside":false},{"id":7,"x":104,"y":20,"control":3,"outside":false},{"id":326,"x":275.05315522555236,"y":54.870253951018846,"control":0,"outside":false},{"id":442,"x":79.04580564785387,"y":-111.42963875897023,"control":0,"outside":false},{"id":614,"x":155.03955043860037,"y":-188.76182523879052,"control":0,"outside":false},{"id":802,"x":-138.67404244148645,"y":198.49397013323284,"control":0,"outside":false},{"id":804,"x":-226.71391451921903,"y":133.88824047619093,"control":0,"outside":false},{"id":1028,"x":-99.56180197188499,"y":148.82291189427133,"control":0,"outside":false},{"id":1030,"x":-173.32244093365708,"y":83.65446205887059,"control":0,"outside":false},{"id":1285,"x":-34.42430643695941,"y":77.88550237335923,"control":0,"outside":false},{"id":1546,"x":-68.03406440703517,"y":107.33121953298313,"control":0,"outside":false},{"id":1548,"x":-138.21920020865346,"y":55.60942084990904,"control":0,"outside":false},{"id":1844,"x":69.46235943802267,"y":109.16185291364903,"control":0,"outside":false},{"id":2145,"x":179.43479883378575,"y":6.747656775326689,"control":0,"outside":false},{"id":2146,"x":144.28896768668622,"y":124.46729550996658,"control":0,"outside":false},{"id":2148,"x":172.24669870239705,"y":30.823927645515926,"control":0,"outside":false},{"id":2485,"x":238.95596448613176,"y":20.919362883028143,"control":0,"outside":false},{"id":2486,"x":212.12420092221706,"y":129.3801536273031,"control":0,"outside":false},{"id":2488,"x":232.91169129678372,"y":45.35184746532225,"control":0,"outside":false},{"id":2857,"x":230.6418969029469,"y":125.97894416145473,"control":0,"outside":false},{"id":2859,"x":71.12395219501938,"y":104.87231006731966,"control":0,"outside":false},{"id":2865,"x":147.35258037862025,"y":114.20584009558544,"control":0,"outside":false},{"id":2871,"x":213.67640845261903,"y":123.10573727201628,"control":0,"outside":false},{"id":3292,"x":202.17469600264914,"y":259.285554706654,"control":0,"outside":false},{"id":3713,"x":211.17787883725012,"y":223.05846187218813,"control":0,"outside":false},{"id":4138,"x":226.611906553709,"y":176.1132942346258,"control":0,"outside":false},{"id":4569,"x":-12.247731428639288,"y":243.86628346235344,"control":0,"outside":false},{"id":4575,"x":18.217819799653405,"y":199.3658491219755,"control":0,"outside":false},{"id":4581,"x":48.39680772000574,"y":152.17180384277697,"control":0,"outside":false},{"id":5068,"x":85.75971527951054,"y":248.73135676055355,"control":0,"outside":false},{"id":5074,"x":107.27796251306167,"y":208.13348037293534,"control":0,"outside":false},{"id":5080,"x":130.41734428763334,"y":161.46319741820133,"control":0,"outside":false},{"id":5619,"x":165.39093363571666,"y":254.2304355443145,"control":0,"outside":false},{"id":5625,"x":181.13932909369018,"y":217.12033214415442,"control":0,"outside":false},{"id":5631,"x":199.4598488771094,"y":170.68747174952574,"control":0,"outside":false},{"id":6220,"x":240.80496798404513,"y":-145.33298329757153,"control":0,"outside":false},{"id":6809,"x":187.06174249120815,"y":-144.1492118109452,"control":0,"outside":false}],"segments":[{"id":4,"startNode":1,"endNode":2,"typeId":"highway","sdx":0.9723873019805175,"sdy":0.2333729524753242,"sh":77.12976079309465,"edx":-0.9723873019805175,"edy":-0.2333729524753242,"eh":77.12976079309465,"isBridge":false},{"id":5,"startNode":2,"endNode":3,"typeId":"highway","sdx":0.9170700562532349,"sdy":0.39872611141444997,"sh":50.15974481593781,"edx":-0.9079593845004517,"edy":-0.4190581774617469,"eh":57.271284253105414,"isBridge":false},{"id":8,"startNode":6,"endNode":3,"typeId":"road","sdx":0.9535826651341416,"sdy":-0.3011313679370973,"sh":39.84971769034255,"edx":-0.8944271909999159,"edy":0.4472135954999579,"eh":40.24922359499622,"isBridge":false},{"id":9,"startNode":3,"endNode":7,"typeId":"avenue","sdx":0.9874406319167053,"sdy":0.15799050110667284,"sh":50.635955604688654,"edx":-0.9965457582448796,"edy":-0.08304547985373997,"eh":48.16637831516918,"isBridge":false},{"id":443,"startNode":3,"endNode":442,"typeId":"road","sdx":0.9748916116876258,"sdy":-0.2226799170583275,"sh":43.46099791057018,"edx":-0.6653223651184903,"edy":0.746556193781244,"eh":43.460997910570185,"isBridge":false},{"id":615,"startNode":442,"endNode":614,"typeId":"street","sdx":0.6653223651184903,"sdy":-0.7465561937812442,"sh":36.86345840790018,"edx":-0.7009074662217945,"edy":0.7132522161161114,"eh":36.86345840790018,"isBridge":false},{"id":809,"startNode":804,"endNode":802,"typeId":"road","sdx":0.80621649049142,"sdy":0.5916206305223797,"sh":34.944126357616014,"edx":-0.8062164904914197,"edy":-0.5916206305223799,"eh":37.19889683781988,"isBridge":false},{"id":1032,"startNode":1030,"endNode":804,"typeId":"road","sdx":-0.7386565840974306,"sdy":0.6740819317928017,"sh":23.461259027071574,"edx":0.763625576949506,"edy":-0.6456593360500056,"eh":19.726505116139286,"isBridge":false},{"id":1035,"startNode":1030,"endNode":1028,"typeId":"street","sdx":0.7494065702626669,"sdy":0.6621101059847574,"sh":31.495752690490708,"edx":-0.7494065702626669,"edy":-0.6621101059847573,"eh":33.52161976078976,"isBridge":false},{"id":1286,"startNode":6,"endNode":1285,"typeId":"street","sdx":0.8143406196826155,"sdy":0.5803872458410281,"sh":25.708819264834638,"edx":-0.8143406196826155,"edy":-0.5803872458410281,"eh":25.708819264834638,"isBridge":false},{"id":1549,"startNode":6,"endNode":1548,"typeId":"road","sdx":-0.9535826651341416,"sdy":0.3011313679370976,"sh":15.312878231830071,"edx":0.8279078092950177,"edy":-0.5608642075478918,"eh":15.23181606713402,"isBridge":false},{"id":1550,"startNode":1548,"endNode":1030,"typeId":"road","sdx":-0.8213469645070008,"sdy":0.5704289297494787,"sh":14.387868446410202,"edx":0.7452455691150642,"edy":-0.6667901031916746,"eh":14.350840453187441,"isBridge":false},{"id":1553,"startNode":1548,"endNode":1546,"typeId":"street","sdx":0.8050205852166239,"sdy":0.5932468772589404,"sh":27.898678120560387,"edx":-0.8050205852166237,"edy":-0.5932468772589408,"eh":29.710100986402605,"isBridge":false},{"id":2149,"startNode":7,"endNode":2148,"typeId":"road","sdx":0.9965457582448796,"sdy":0.08304547985373997,"sh":23.472888611804333,"edx":-0.9775390609401294,"edy":-0.21075432222445628,"eh":22.12825335983927,"isBridge":false},{"id":2152,"startNode":2145,"endNode":2148,"typeId":"dirt","sdx":-0.28607768176208065,"sdy":0.9582064286977173,"sh":8.462812935565752,"edx":0.28607768176208026,"edy":-0.9582064286977175,"eh":8.040554306938692,"isBridge":false},{"id":2489,"startNode":2148,"endNode":2488,"typeId":"road","sdx":0.9762910437799062,"sdy":0.21646200090348736,"sh":20.186166888177585,"edx":-0.971911821721485,"edy":-0.23534530120234898,"eh":19.962017285746946,"isBridge":false},{"id":2490,"startNode":2488,"endNode":326,"typeId":"street","sdx":0.9721998792971742,"sdy":0.23415250307131,"sh":13.82578739730743,"edx":-0.9798473042143406,"edy":-0.19974799229000856,"eh":14.444447249362675,"isBridge":false},{"id":2492,"startNode":2485,"endNode":2488,"typeId":"dirt","sdx":-0.24014734446608713,"sdy":0.9707364487572755,"sh":8.484844443356431,"edx":0.24014734446608585,"edy":-0.9707364487572758,"eh":8.054367315014613,"isBridge":false},{"id":2860,"startNode":7,"endNode":2859,"typeId":"street","sdx":-0.3612066985983801,"sdy":0.9324857751663876,"sh":30.971927912857268,"edx":0.36120669859838084,"edy":-0.9324857751663875,"eh":29.12507868859208,"isBridge":false},{"id":2861,"startNode":2859,"endNode":1844,"typeId":"street","sdx":-0.3612066985983763,"sdy":0.9324857751663892,"sh":2,"edx":0.36120669859837484,"edy":-0.9324857751663898,"eh":1.5379835495791672,"isBridge":false},{"id":2863,"startNode":1285,"endNode":2859,"typeId":"street","sdx":0.9252924707457318,"sdy":0.3792543257251776,"sh":35.90274452542504,"edx":-0.987986904832022,"edy":-0.15453761962849435,"eh":34.944319526086524,"isBridge":false},{"id":2866,"startNode":2148,"endNode":2865,"typeId":"street","sdx":-0.2860776817620788,"sdy":0.9582064286977179,"sh":27.918722827257497,"edx":0.28607768176207915,"edy":-0.9582064286977179,"eh":27.845931157870375,"isBridge":false},{"id":2867,"startNode":2865,"endNode":2146,"typeId":"street","sdx":-0.28607768176208165,"sdy":0.9582064286977171,"sh":3.4269520321853837,"edx":0.28607768176208104,"edy":-0.9582064286977172,"eh":3.5722896232293224,"isBridge":false},{"id":2869,"startNode":2859,"endNode":2865,"typeId":"street","sdx":0.9893387213945677,"sdy":0.14563273790381737,"sh":21.181236323861004,"edx":-0.9931569548041543,"edy":-0.1167872558293031,"eh":24.575520837283392,"isBridge":false},{"id":2872,"startNode":2488,"endNode":2871,"typeId":"street","sdx":-0.24014734446608438,"sdy":0.9707364487572762,"sh":25.67564002398852,"edx":0.24014734446608466,"edy":-0.970736448757276,"eh":25.631263850086107,"isBridge":false},{"id":2873,"startNode":2871,"endNode":2486,"typeId":"street","sdx":-0.2401473444660904,"sdy":0.9707364487572747,"sh":2.0683841916216954,"edx":0.24014734446608688,"edy":-0.9707364487572757,"eh":2.155537492772694,"isBridge":false},{"id":2875,"startNode":2865,"endNode":2871,"typeId":"street","sdx":0.9930198821536549,"sdy":0.11794707986017042,"sh":21.27861142656961,"edx":-0.9879474369456553,"edy":-0.15478973426073966,"eh":21.41572885422399,"isBridge":false},{"id":2876,"startNode":2871,"endNode":2857,"typeId":"street","sdx":0.9875355873396607,"sdy":0.15739588221332687,"sh":5.5058732947441715,"edx":-0.9839354458433819,"edy":-0.1785246157060287,"eh":5.587784535521279,"isBridge":false},{"id":4570,"startNode":802,"endNode":4569,"typeId":"road","sdx":0.8062164904914195,"sdy":0.5916206305223803,"sh":46.6843570214056,"edx":-0.9910405241057234,"edy":-0.13356151983357073,"eh":42,"isBridge":false},{"id":4576,"startNode":1028,"endNode":4575,"typeId":"street","sdx":0.7494065702626668,"sdy":0.6621101059847575,"sh":44.69826982770501,"edx":-0.984197300137414,"edy":-0.17707533538645392,"eh":41.56758157342,"isBridge":false},{"id":4580,"startNode":4575,"endNode":4569,"typeId":"street","sdx":-0.566784407994289,"sdy":0.8238661510552328,"sh":17.257030302733714,"edx":0.5428768143958362,"edy":-0.8398123387944647,"eh":15.610048928938765,"isBridge":false},{"id":4582,"startNode":1546,"endNode":4581,"typeId":"street","sdx":0.8050205852166237,"sdy":0.5932468772589408,"sh":43.13817535201591,"edx":-0.9843991277030438,"edy":-0.17594987177456664,"eh":40.30241705576955,"isBridge":false},{"id":4585,"startNode":1844,"endNode":4581,"typeId":"street","sdx":-0.3612066985983749,"sdy":0.9324857751663898,"sh":15.530696961874364,"edx":0.4969198841831041,"edy":-0.8677964212322211,"eh":15.34028928863635,"isBridge":false},{"id":4586,"startNode":4581,"endNode":4575,"typeId":"street","sdx":-0.502954797907234,"sdy":0.8643127161288867,"sh":17.930342654095977,"edx":0.5642352131196583,"edy":-0.8256140891941063,"eh":17.9377014354391,"isBridge":false},{"id":5069,"startNode":4569,"endNode":5068,"typeId":"street","sdx":0.9936539946145599,"sdy":0.11247994926442675,"sh":21.240513252659046,"edx":-0.9991083448622163,"edy":-0.0422198439916899,"eh":31.403869067385585,"isBridge":false},{"id":5075,"startNode":4575,"endNode":5074,"typeId":"street","sdx":0.9877293640404697,"sdy":0.15617523303075023,"sh":21.067077210827094,"edx":-0.9958982954177545,"edy":-0.09047975013234166,"eh":28.640854551953147,"isBridge":false},{"id":5079,"startNode":5074,"endNode":5068,"typeId":"street","sdx":-0.47150602628868815,"sdy":0.8818628392065576,"sh":14.702825248096897,"edx":0.4511139625942865,"edy":-0.8924663538489733,"eh":14.581836846545379,"isBridge":false},{"id":5081,"startNode":4581,"endNode":5080,"typeId":"street","sdx":0.9873168658448591,"sdy":0.15876210636762322,"sh":20.819644752224896,"edx":-0.9944087371129877,"edy":-0.10559954333875188,"eh":26.4161389478539,"isBridge":false},{"id":5084,"startNode":2146,"endNode":5080,"typeId":"street","sdx":-0.2860776817620823,"sdy":0.958206428697717,"sh":13.061732796810913,"edx":0.40121795847902164,"edy":-0.9159826143513456,"eh":12.65185316269454,"isBridge":false},{"id":5085,"startNode":5080,"endNode":5074,"typeId":"street","sdx":-0.4071588870063585,"sdy":0.9133573455837225,"sh":16.673166557738814,"edx":0.46957829446107807,"edy":-0.8828908343453482,"eh":16.779606333143416,"isBridge":false},{"id":5620,"startNode":5068,"endNode":5619,"typeId":"dirt","sdx":0.998996894813609,"sdy":0.04477950594599412,"sh":25.099012983839767,"edx":-0.9947697275003171,"edy":-0.10214298433541516,"eh":25.54699015276574,"isBridge":false},{"id":5621,"startNode":5619,"endNode":3292,"typeId":"dirt","sdx":0.9942826041752972,"sdy":0.1067806304270086,"sh":11.883449297485633,"edx":-0.9844646713554612,"edy":-0.17558277493246238,"eh":11.568761346282807,"isBridge":false},{"id":5626,"startNode":5074,"endNode":5625,"typeId":"street","sdx":0.9956482612167431,"sdy":0.0931908790390784,"sh":23.44951233165813,"edx":-0.9872365589568465,"edy":-0.15926071913389375,"eh":23.81502807682637,"isBridge":false},{"id":5627,"startNode":5625,"endNode":3713,"typeId":"dirt","sdx":0.9863869521073145,"sdy":0.16444081218615514,"sh":9.800129731583633,"edx":-0.9726296783063209,"edy":-0.23236073006801922,"eh":9.636210476435975,"isBridge":false},{"id":5630,"startNode":5625,"endNode":5619,"typeId":"dirt","sdx":-0.3892320647184641,"sdy":0.921139728702981,"sh":12.899723300615019,"edx":0.38508560674617814,"edy":-0.9228808565990129,"eh":13.076568266087971,"isBridge":false},{"id":5632,"startNode":5080,"endNode":5631,"typeId":"street","sdx":0.9941638500999502,"sdy":0.10788067090282526,"sh":22.03898626113315,"edx":-0.985987846999148,"edy":-0.16681716209666445,"eh":22.294079444694297,"isBridge":false},{"id":5633,"startNode":5631,"endNode":4138,"typeId":"dirt","sdx":0.985209057997817,"sdy":0.17135668075407515,"sh":8.861172986951152,"edx":-0.9738182780434755,"edy":-0.2273278719128835,"eh":8.815730258108337,"isBridge":false},{"id":5635,"startNode":2486,"endNode":5631,"typeId":"street","sdx":-0.24014734446608704,"sdy":0.9707364487572755,"sh":14.354135685898406,"edx":0.33446141051422096,"edy":-0.9424094465129463,"eh":13.831595425648905,"isBridge":false},{"id":5636,"startNode":5631,"endNode":5625,"typeId":"street","sdx":-0.3390395179994078,"sdy":0.9407721324713701,"sh":15.975202689518868,"edx":0.38737737287545176,"edy":-0.9219212390351539,"eh":16.133954966117237,"isBridge":false},{"id":6221,"startNode":2145,"endNode":6220,"typeId":"dirt","sdx":0.28607768176208087,"sdy":-0.9582064286977173,"sh":55.75877551647816,"edx":-0.3742165662321941,"edy":0.9273413403690066,"eh":55.75877551647816,"isBridge":false},{"id":6810,"startNode":7,"endNode":6809,"typeId":"street","sdx":0.3612066985983803,"sdy":-0.9324857751663876,"sh":62.549112403956514,"edx":-0.4515010902892429,"edy":0.892270567410819,"eh":62.54911240395651,"isBridge":false}],"lots":[{"segment":8,"side":1,"startS":12,"zone":2,"level":3,"sub":"commodity"},{"segment":8,"side":1,"startS":26.577500451742416,"zone":2,"level":2},{"segment":8,"side":1,"startS":41.15500090348483,"zone":2,"level":2},{"segment":8,"side":1,"startS":55.73250135522726,"zone":2,"level":2},{"segment":8,"side":1,"startS":70.31000180696967,"zone":2,"level":1},{"segment":8,"side":-1,"startS":12,"zone":2,"level":2},{"segment":8,"side":-1,"startS":26.577500451742416,"zone":2,"level":2},{"segment":8,"side":-1,"startS":41.15500090348483,"zone":2,"level":2},{"segment":8,"side":-1,"startS":55.73250135522726,"zone":2,"level":1},{"segment":9,"side":1,"startS":18.400000000000002,"zone":4,"level":1},{"segment":9,"side":1,"startS":34.8329818785583,"zone":4,"level":0},{"segment":9,"side":1,"startS":51.265963757116594,"zone":4,"level":1},{"segment":9,"side":1,"startS":67.69894563567489,"zone":4,"level":1},{"segment":9,"side":1,"startS":84.13192751423318,"zone":4,"level":1},{"segment":9,"side":-1,"startS":34.8329818785583,"zone":4,"level":1},{"segment":9,"side":-1,"startS":51.265963757116594,"zone":4,"level":1},{"segment":9,"side":-1,"startS":67.69894563567489,"zone":4,"level":1},{"segment":9,"side":-1,"startS":84.13192751423318,"zone":4,"level":1},{"segment":443,"side":1,"startS":50.03233519176377,"zone":11,"level":1},{"segment":443,"side":1,"startS":65.84850278764566,"zone":11,"level":1},{"segment":443,"side":1,"startS":97.48083797940943,"zone":3,"level":1},{"segment":443,"side":1,"startS":113.29700557529131,"zone":3,"level":0},{"segment":443,"side":-1,"startS":34.216167595881885,"zone":10,"level":1},{"segment":443,"side":-1,"startS":50.03233519176377,"zone":10,"level":1},{"segment":443,"side":-1,"startS":65.84850278764566,"zone":7,"level":1},{"segment":443,"side":-1,"startS":97.48083797940943,"zone":3,"level":1,"sub":"standard"},{"segment":615,"side":1,"startS":1.7935754437385003,"zone":3,"level":2,"sub":"standard"},{"segment":615,"side":1,"startS":17.0287209112194,"zone":3,"level":1},{"segment":615,"side":1,"startS":32.2638663787003,"zone":3,"level":2,"sub":"standard"},{"segment":615,"side":1,"startS":47.4990118461812,"zone":3,"level":2,"sub":"standard"},{"segment":615,"side":1,"startS":62.734157313662095,"zone":3,"level":2,"sub":"standard"},{"segment":615,"side":1,"startS":77.969302781143,"zone":3,"level":2},{"segment":615,"side":1,"startS":93.2044482486239,"zone":3,"level":2,"sub":"hightech"},{"segment":615,"side":-1,"startS":1.7935754437385003,"zone":3,"level":1},{"segment":615,"side":-1,"startS":17.0287209112194,"zone":3,"level":2,"sub":"standard"},{"segment":615,"side":-1,"startS":32.2638663787003,"zone":3,"level":1},{"segment":615,"side":-1,"startS":47.4990118461812,"zone":3,"level":2},{"segment":615,"side":-1,"startS":62.734157313662095,"zone":3,"level":1},{"segment":615,"side":-1,"startS":77.969302781143,"zone":3,"level":1},{"segment":615,"side":-1,"startS":93.2044482486239,"zone":3,"level":1},{"segment":809,"side":1,"startS":10.015886318295234,"zone":1,"level":1},{"segment":809,"side":1,"startS":24.18522815923601,"zone":1,"level":1},{"segment":809,"side":1,"startS":38.35457000017679,"zone":1,"level":1},{"segment":809,"side":1,"startS":52.52391184111757,"zone":1,"level":1,"sub":"single"},{"segment":809,"side":1,"startS":66.69325368205834,"zone":1,"level":1},{"segment":809,"side":1,"startS":80.86259552299911,"zone":1,"level":1},{"segment":809,"side":1,"startS":95.0319373639399,"zone":1,"level":1},{"segment":809,"side":-1,"startS":10.015886318295234,"zone":2,"level":2},{"segment":809,"side":-1,"startS":24.18522815923601,"zone":1,"level":1},{"segment":809,"side":-1,"startS":38.35457000017679,"zone":1,"level":1,"sub":"single"},{"segment":809,"side":-1,"startS":52.52391184111757,"zone":1,"level":1},{"segment":809,"side":-1,"startS":66.69325368205834,"zone":1,"level":1},{"segment":809,"side":-1,"startS":80.86259552299911,"zone":1,"level":1},{"segment":809,"side":-1,"startS":95.0319373639399,"zone":1,"level":1},{"segment":1032,"side":1,"startS":8.935464881354909,"zone":2,"level":1},{"segment":1032,"side":-1,"startS":8.935464881354909,"zone":2,"level":2},{"segment":1032,"side":-1,"startS":22.526951320812618,"zone":2,"level":2},{"segment":1032,"side":-1,"startS":36.118437760270325,"zone":2,"level":2},{"segment":1035,"side":1,"startS":23.850453516479757,"zone":1,"level":1},{"segment":1035,"side":1,"startS":38.7654421516046,"zone":1,"level":1},{"segment":1035,"side":1,"startS":53.68043078672945,"zone":1,"level":1},{"segment":1035,"side":1,"startS":68.5954194218543,"zone":1,"level":1},{"segment":1035,"side":1,"startS":83.51040805697914,"zone":1,"level":1},{"segment":1035,"side":-1,"startS":8.935464881354909,"zone":2,"level":2},{"segment":1035,"side":-1,"startS":23.850453516479757,"zone":1,"level":1},{"segment":1035,"side":-1,"startS":38.7654421516046,"zone":1,"level":1},{"segment":1035,"side":-1,"startS":53.68043078672945,"zone":1,"level":1},{"segment":1035,"side":-1,"startS":68.5954194218543,"zone":1,"level":1},{"segment":1035,"side":-1,"startS":83.51040805697914,"zone":1,"level":1},{"segment":1286,"side":1,"startS":12,"zone":1,"level":2,"sub":"single"},{"segment":1286,"side":1,"startS":27.90354357708429,"zone":1,"level":2},{"segment":1286,"side":1,"startS":43.80708715416858,"zone":1,"level":2},{"segment":1286,"side":1,"startS":59.71063073125286,"zone":1,"level":2},{"segment":1286,"side":-1,"startS":12,"zone":2,"level":2},{"segment":1286,"side":-1,"startS":43.80708715416858,"zone":6,"level":1},{"segment":1286,"side":-1,"startS":59.71063073125286,"zone":1,"level":2},{"segment":1549,"side":1,"startS":12,"zone":2,"level":1},{"segment":1549,"side":1,"startS":24.23720697449528,"zone":2,"level":1},{"segment":1549,"side":-1,"startS":12,"zone":2,"level":2},{"segment":1549,"side":-1,"startS":24.23720697449528,"zone":2,"level":2},{"segment":1550,"side":1,"startS":11.111598068904373,"zone":2,"level":2},{"segment":1550,"side":1,"startS":23.566639462381858,"zone":2,"level":1},{"segment":1550,"side":-1,"startS":11.111598068904373,"zone":2,"level":2},{"segment":1553,"side":1,"startS":26.32613338541875,"zone":1,"level":2},{"segment":1553,"side":1,"startS":41.54066870193313,"zone":1,"level":2},{"segment":1553,"side":1,"startS":56.7552040184475,"zone":1,"level":2},{"segment":1553,"side":1,"startS":71.96973933496189,"zone":9,"level":1},{"segment":1553,"side":-1,"startS":41.54066870193313,"zone":1,"level":2},{"segment":1553,"side":-1,"startS":56.7552040184475,"zone":1,"level":2},{"segment":1553,"side":-1,"startS":71.96973933496189,"zone":1,"level":3,"sub":"single"},{"segment":2149,"side":1,"startS":15.875354698844811,"zone":4,"level":1},{"segment":2149,"side":1,"startS":30.763791416072067,"zone":4,"level":1},{"segment":2149,"side":1,"startS":45.652228133299324,"zone":4,"level":1},{"segment":2149,"side":-1,"startS":15.875354698844811,"zone":4,"level":2},{"segment":2149,"side":-1,"startS":30.763791416072067,"zone":2,"level":2},{"segment":2149,"side":-1,"startS":45.652228133299324,"zone":4,"level":1},{"segment":2152,"side":-1,"startS":0,"zone":4,"level":2},{"segment":2489,"side":1,"startS":8.607308618180902,"zone":4,"level":1},{"segment":2489,"side":1,"startS":23.84990721152706,"zone":1,"level":1},{"segment":2489,"side":1,"startS":39.09250580487322,"zone":4,"level":1},{"segment":2489,"side":-1,"startS":23.84990721152706,"zone":4,"level":1},{"segment":2489,"side":-1,"startS":39.09250580487322,"zone":4,"level":1},{"segment":2490,"side":1,"startS":8.046425058031417,"zone":1,"level":2},{"segment":2490,"side":1,"startS":25.625853386069387,"zone":1,"level":1},{"segment":2860,"side":1,"startS":29.676319253466723,"zone":4,"level":1},{"segment":2860,"side":1,"startS":43.47728380808863,"zone":4,"level":1},{"segment":2860,"side":1,"startS":57.278248362710535,"zone":1,"level":1},{"segment":2860,"side":1,"startS":71.07921291733246,"zone":1,"level":1},{"segment":2860,"side":-1,"startS":15.875354698844811,"zone":4,"level":1},{"segment":2860,"side":-1,"startS":29.676319253466723,"zone":4,"level":1},{"segment":2860,"side":-1,"startS":43.47728380808863,"zone":4,"level":1},{"segment":2860,"side":-1,"startS":57.278248362710535,"zone":4,"level":1},{"segment":2860,"side":-1,"startS":71.07921291733246,"zone":1,"level":1},{"segment":2863,"side":1,"startS":0,"zone":1,"level":2},{"segment":2863,"side":1,"startS":14.721426574023837,"zone":1,"level":2},{"segment":2863,"side":1,"startS":29.442853148047675,"zone":1,"level":2},{"segment":2863,"side":1,"startS":44.16427972207151,"zone":1,"level":1,"sub":"single"},{"segment":2863,"side":1,"startS":58.88570629609535,"zone":1,"level":2,"sub":"single"},{"segment":2863,"side":1,"startS":73.60713287011919,"zone":1,"level":1,"sub":"single"},{"segment":2863,"side":1,"startS":88.32855944414302,"zone":1,"level":2,"sub":"single"},{"segment":2863,"side":-1,"startS":0,"zone":1,"level":2},{"segment":2863,"side":-1,"startS":14.721426574023837,"zone":5,"level":1},{"segment":2863,"side":-1,"startS":29.442853148047675,"zone":1,"level":2},{"segment":2863,"side":-1,"startS":44.16427972207151,"zone":1,"level":2},{"segment":2863,"side":-1,"startS":58.88570629609535,"zone":1,"level":1,"sub":"single"},{"segment":2863,"side":-1,"startS":73.60713287011919,"zone":1,"level":1,"sub":"single"},{"segment":2866,"side":1,"startS":23.11860070628821,"zone":4,"level":2},{"segment":2866,"side":1,"startS":37.629892794395516,"zone":1,"level":2},{"segment":2866,"side":1,"startS":52.14118488250282,"zone":1,"level":1},{"segment":2866,"side":1,"startS":66.65247697061014,"zone":1,"level":1},{"segment":2866,"side":-1,"startS":23.11860070628821,"zone":1,"level":1},{"segment":2866,"side":-1,"startS":37.629892794395516,"zone":1,"level":2},{"segment":2866,"side":-1,"startS":52.14118488250282,"zone":1,"level":2},{"segment":2866,"side":-1,"startS":66.65247697061014,"zone":1,"level":2},{"segment":2869,"side":1,"startS":6.13709306193179,"zone":1,"level":2},{"segment":2869,"side":1,"startS":22.33920462763332,"zone":1,"level":2},{"segment":2869,"side":1,"startS":38.54131619333485,"zone":1,"level":2},{"segment":2869,"side":1,"startS":54.74342775903638,"zone":1,"level":2},{"segment":2869,"side":-1,"startS":22.33920462763332,"zone":1,"level":2},{"segment":2869,"side":-1,"startS":38.54131619333485,"zone":1,"level":2},{"segment":2872,"side":1,"startS":24.70675738668025,"zone":1,"level":1},{"segment":2872,"side":1,"startS":41.36708971532908,"zone":1,"level":1},{"segment":2872,"side":1,"startS":58.02742204397791,"zone":1,"level":1},{"segment":2872,"side":-1,"startS":24.70675738668025,"zone":1,"level":1},{"segment":2872,"side":-1,"startS":41.36708971532908,"zone":1,"level":1},{"segment":2872,"side":-1,"startS":58.02742204397791,"zone":1,"level":1},{"segment":2875,"side":1,"startS":5.854967147626414,"zone":1,"level":2},{"segment":2875,"side":1,"startS":19.769195099533594,"zone":1,"level":2},{"segment":2875,"side":1,"startS":33.683423051440776,"zone":1,"level":2},{"segment":2875,"side":1,"startS":47.597651003347956,"zone":1,"level":2},{"segment":2875,"side":-1,"startS":19.769195099533594,"zone":1,"level":2},{"segment":2875,"side":-1,"startS":33.683423051440776,"zone":1,"level":2},{"segment":2876,"side":1,"startS":5.410082590468184,"zone":1,"level":2},{"segment":4570,"side":1,"startS":0,"zone":1,"level":1},{"segment":4570,"side":1,"startS":15.464147951175729,"zone":1,"level":1},{"segment":4570,"side":1,"startS":30.928295902351458,"zone":1,"level":1},{"segment":4570,"side":1,"startS":46.39244385352718,"zone":1,"level":1},{"segment":4570,"side":1,"startS":61.856591804702916,"zone":1,"level":1},{"segment":4570,"side":1,"startS":77.32073975587865,"zone":1,"level":1},{"segment":4570,"side":1,"startS":92.78488770705437,"zone":1,"level":1},{"segment":4570,"side":1,"startS":108.2490356582301,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":0,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":15.464147951175729,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":30.928295902351458,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":46.39244385352718,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":61.856591804702916,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":77.32073975587865,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":92.78488770705437,"zone":1,"level":1},{"segment":4570,"side":-1,"startS":108.2490356582301,"zone":1,"level":1},{"segment":4576,"side":1,"startS":0,"zone":1,"level":1},{"segment":4576,"side":1,"startS":15.323846981062347,"zone":1,"level":1},{"segment":4576,"side":1,"startS":30.647693962124695,"zone":1,"level":1},{"segment":4576,"side":1,"startS":45.971540943187044,"zone":1,"level":1},{"segment":4576,"side":1,"startS":61.29538792424939,"zone":1,"level":1},{"segment":4576,"side":1,"startS":76.61923490531174,"zone":1,"level":1},{"segment":4576,"side":1,"startS":91.94308188637409,"zone":1,"level":1},{"segment":4576,"side":1,"startS":107.26692886743643,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":0,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":15.323846981062347,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":30.647693962124695,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":45.971540943187044,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":61.29538792424939,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":76.61923490531174,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":91.94308188637409,"zone":1,"level":1},{"segment":4576,"side":-1,"startS":107.26692886743643,"zone":1,"level":1},{"segment":4580,"side":1,"startS":24.566029044196956,"zone":1,"level":2},{"segment":4580,"side":-1,"startS":7.2,"zone":1,"level":2},{"segment":4580,"side":-1,"startS":24.566029044196956,"zone":1,"level":2},{"segment":4582,"side":1,"startS":0,"zone":1,"level":2},{"segment":4582,"side":1,"startS":14.85892596068758,"zone":1,"level":2},{"segment":4582,"side":1,"startS":29.71785192137516,"zone":1,"level":2},{"segment":4582,"side":1,"startS":44.57677788206274,"zone":1,"level":2},{"segment":4582,"side":1,"startS":59.43570384275032,"zone":1,"level":2},{"segment":4582,"side":1,"startS":74.2946298034379,"zone":1,"level":2},{"segment":4582,"side":1,"startS":89.15355576412549,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":0,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":14.85892596068758,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":29.71785192137516,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":44.57677788206274,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":59.43570384275032,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":74.2946298034379,"zone":1,"level":2},{"segment":4582,"side":-1,"startS":89.15355576412549,"zone":8,"level":1},{"segment":4582,"side":-1,"startS":104.01248172481306,"zone":1,"level":2},{"segment":4585,"side":-1,"startS":13.642893889403801,"zone":1,"level":2},{"segment":4585,"side":-1,"startS":27.285787778807602,"zone":1,"level":2},{"segment":4586,"side":-1,"startS":7.006268464538395,"zone":1,"level":2},{"segment":4586,"side":-1,"startS":20.947490051854224,"zone":1,"level":2},{"segment":4586,"side":-1,"startS":34.888711639170054,"zone":1,"level":2},{"segment":5069,"side":1,"startS":12,"zone":1,"level":2},{"segment":5069,"side":1,"startS":27.78856599916492,"zone":1,"level":2},{"segment":5069,"side":1,"startS":43.57713199832984,"zone":1,"level":2},{"segment":5069,"side":1,"startS":59.36569799749476,"zone":1,"level":2},{"segment":5069,"side":-1,"startS":27.78856599916492,"zone":1,"level":2},{"segment":5069,"side":-1,"startS":43.57713199832984,"zone":1,"level":2},{"segment":5069,"side":-1,"startS":59.36569799749476,"zone":1,"level":2},{"segment":5069,"side":-1,"startS":75.15426399665968,"zone":1,"level":2},{"segment":5075,"side":1,"startS":7.2,"zone":1,"level":2},{"segment":5075,"side":1,"startS":22.22077841900571,"zone":1,"level":2},{"segment":5075,"side":1,"startS":37.24155683801143,"zone":1,"level":2},{"segment":5075,"side":1,"startS":52.26233525701714,"zone":1,"level":2},{"segment":5075,"side":1,"startS":67.28311367602285,"zone":1,"level":2},{"segment":5075,"side":-1,"startS":7.2,"zone":1,"level":2},{"segment":5075,"side":-1,"startS":37.24155683801143,"zone":1,"level":2},{"segment":5075,"side":-1,"startS":52.26233525701714,"zone":1,"level":2},{"segment":5075,"side":-1,"startS":67.28311367602285,"zone":1,"level":2},{"segment":5079,"side":-1,"startS":7.2,"zone":1,"level":2},{"segment":5079,"side":-1,"startS":22.974618272191524,"zone":1,"level":2},{"segment":5081,"side":1,"startS":20.77783234981082,"zone":1,"level":3},{"segment":5081,"side":1,"startS":34.54939623508324,"zone":1,"level":2},{"segment":5081,"side":1,"startS":48.320960120355664,"zone":1,"level":2},{"segment":5081,"side":1,"startS":62.09252400562809,"zone":9,"level":1},{"segment":5081,"side":-1,"startS":34.54939623508324,"zone":1,"level":3},{"segment":5081,"side":-1,"startS":48.320960120355664,"zone":1,"level":3},{"segment":5081,"side":-1,"startS":62.09252400562809,"zone":1,"level":2},{"segment":5084,"side":-1,"startS":16.4226845634576,"zone":1,"level":2},{"segment":5085,"side":-1,"startS":6.689716539633626,"zone":1,"level":2},{"segment":5085,"side":-1,"startS":19.427054152487628,"zone":1,"level":2},{"segment":5085,"side":-1,"startS":32.16439176534163,"zone":1,"level":2},{"segment":5620,"side":1,"startS":7.2,"zone":1,"level":1},{"segment":5620,"side":1,"startS":20.768875927089237,"zone":1,"level":2},{"segment":5620,"side":1,"startS":34.33775185417848,"zone":1,"level":1},{"segment":5620,"side":1,"startS":47.906627781267716,"zone":1,"level":2},{"segment":5620,"side":1,"startS":61.47550370835695,"zone":1,"level":1},{"segment":5620,"side":-1,"startS":34.33775185417848,"zone":1,"level":1},{"segment":5620,"side":-1,"startS":47.906627781267716,"zone":1,"level":1},{"segment":5620,"side":-1,"startS":61.47550370835695,"zone":1,"level":1},{"segment":5621,"side":1,"startS":4.786824680310852,"zone":1,"level":2},{"segment":5621,"side":-1,"startS":4.786824680310852,"zone":1,"level":2},{"segment":5621,"side":-1,"startS":20.96160972836777,"zone":1,"level":2},{"segment":5626,"side":1,"startS":22.44646940944126,"zone":1,"level":2},{"segment":5626,"side":1,"startS":37.692938818882524,"zone":1,"level":2},{"segment":5626,"side":1,"startS":52.939408228323785,"zone":1,"level":2},{"segment":5626,"side":-1,"startS":37.692938818882524,"zone":1,"level":2},{"segment":5626,"side":-1,"startS":52.939408228323785,"zone":1,"level":2},{"segment":5627,"side":1,"startS":6.233160161994049,"zone":1,"level":2},{"segment":5627,"side":1,"startS":18.42936063051654,"zone":1,"level":2},{"segment":5627,"side":-1,"startS":6.233160161994049,"zone":1,"level":2},{"segment":5627,"side":-1,"startS":18.42936063051654,"zone":1,"level":2},{"segment":5632,"side":1,"startS":20.96285153489159,"zone":1,"level":2},{"segment":5632,"side":1,"startS":35.23598653014956,"zone":1,"level":2},{"segment":5632,"side":1,"startS":49.50912152540752,"zone":1,"level":2},{"segment":5632,"side":-1,"startS":20.96285153489159,"zone":1,"level":2},{"segment":5632,"side":-1,"startS":35.23598653014956,"zone":1,"level":2},{"segment":5632,"side":-1,"startS":49.50912152540752,"zone":1,"level":2},{"segment":5633,"side":1,"startS":5.883424317782843,"zone":1,"level":2},{"segment":5633,"side":-1,"startS":5.883424317782843,"zone":1,"level":2},{"segment":6221,"side":1,"startS":14.917669286574908,"zone":4,"level":1},{"segment":6221,"side":1,"startS":74.58834643287454,"zone":3,"level":1},{"segment":6221,"side":1,"startS":89.50601571944945,"zone":3,"level":2,"sub":"standard"},{"segment":6221,"side":1,"startS":104.42368500602436,"zone":3,"level":2,"sub":"standard"},{"segment":6221,"side":1,"startS":119.34135429259926,"zone":3,"level":2},{"segment":6221,"side":1,"startS":134.25902357917417,"zone":3,"level":1},{"segment":6221,"side":1,"startS":149.1766928657491,"zone":3,"level":2},{"segment":6221,"side":-1,"startS":0,"zone":11,"level":1},{"segment":6221,"side":-1,"startS":14.917669286574908,"zone":4,"level":1},{"segment":6221,"side":-1,"startS":74.58834643287454,"zone":3,"level":2,"sub":"standard"},{"segment":6221,"side":-1,"startS":89.50601571944945,"zone":3,"level":2},{"segment":6221,"side":-1,"startS":104.42368500602436,"zone":3,"level":2,"sub":"standard"},{"segment":6221,"side":-1,"startS":119.34135429259926,"zone":3,"level":1},{"segment":6221,"side":-1,"startS":134.25902357917417,"zone":3,"level":1},{"segment":6221,"side":-1,"startS":149.1766928657491,"zone":3,"level":2,"sub":"hightech"},{"segment":6810,"side":1,"startS":46.460030875498035,"zone":4,"level":1},{"segment":6810,"side":1,"startS":77.04470705215127,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":1,"startS":92.33704514047788,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":1,"startS":107.62938322880449,"zone":3,"level":2,"sub":"hightech"},{"segment":6810,"side":1,"startS":122.9217213171311,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":1,"startS":138.2140594054577,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":1,"startS":153.5063974937843,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":1,"startS":168.79873558211094,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":-1,"startS":31.167692787171426,"zone":4,"level":1},{"segment":6810,"side":-1,"startS":46.460030875498035,"zone":4,"level":1},{"segment":6810,"side":-1,"startS":77.04470705215127,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":-1,"startS":92.33704514047788,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":-1,"startS":107.62938322880449,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":-1,"startS":122.9217213171311,"zone":3,"level":1},{"segment":6810,"side":-1,"startS":138.2140594054577,"zone":3,"level":2,"sub":"hightech"},{"segment":6810,"side":-1,"startS":153.5063974937843,"zone":3,"level":2,"sub":"standard"},{"segment":6810,"side":-1,"startS":168.79873558211094,"zone":3,"level":2,"sub":"standard"}]}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#10141a">
<!-- Without these, "Add to Home Screen" on iOS just bookmarks the page — it
still opens inside Safari with the address bar and bottom toolbar, and
that toolbar can sit on top of (or eat into) anything pinned to the
bottom of the page, which is exactly why the dock could go missing.
With them, the home-screen icon launches full-screen with no browser
chrome at all, so there's nothing left to cover it. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Spline City">
<title>Spline City</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3E%3Ctext y=%27.9em%27 font-size=%2790%27%3E🏙️%3C/text%3E%3C/svg%3E">
<style>
:root{
--ink:#10141a; --panel:#181e26; --panel2:#212934; --line:#2c3541;
--text:#e9edf2; --muted:#8b96a4;
--res:#4caf6d; --com:#3f8fd6; --ind:#e0a325; --off:#9182d8; --danger:#e0604d; --gold:#e8d98a;
--sb: env(safe-area-inset-bottom, 0px); --st: env(safe-area-inset-top, 0px);
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;height:100dvh;overflow:hidden;background:var(--ink);color:var(--text);
font-family:-apple-system,"Segoe UI",system-ui,sans-serif;font-variant-numeric:tabular-nums;
user-select:none;-webkit-user-select:none;overscroll-behavior:none}
canvas{position:fixed;inset:0;width:100%;height:100%;touch-action:none;display:block;cursor:default}
#hud{position:fixed;top:0;left:0;right:0;z-index:5;pointer-events:none;
padding:calc(var(--st) + 8px) 10px 10px;display:flex;gap:7px;align-items:flex-start;
background:linear-gradient(#10141af5,#10141ac0 55%,#10141a00)}
.stat{background:#181e26d9;border:1px solid #ffffff16;border-radius:11px;padding:6px 10px;
line-height:1.15;backdrop-filter:blur(8px);pointer-events:auto}
.stat b{display:block;font-size:14.5px;font-weight:650;letter-spacing:-.01em}
.stat i{display:block;font-size:10px;color:var(--muted);font-style:normal;margin-top:1px}
#cash b{color:#8fd6a3} #cash.low b{color:var(--danger)}
#pop,#happy{cursor:pointer}
#happy.low b{color:var(--danger)} #happy.mid b{color:#e8c15a}
.grow{flex:1}
#rci{display:flex;gap:6px;align-items:flex-end;background:#181e26d9;border:1px solid #ffffff16;
border-radius:11px;padding:7px 9px 20px;pointer-events:auto;backdrop-filter:blur(8px);cursor:pointer}
.dm{width:10px;height:34px;position:relative}
.dm i{position:absolute;inset:auto 0 50% 0;height:1px;background:#ffffff26}
.dm u{position:absolute;left:0;right:0;border-radius:2px;transition:height .5s,bottom .5s,top .5s}
.dm em{position:absolute;bottom:-13px;left:0;right:0;text-align:center;font-size:8.5px;
font-style:normal;color:var(--muted)}
/* iOS standalone home-screen launches can report a stale/zero safe-area-inset-bottom
for a moment (or a fraction-of-a-pixel-short one permanently), which leaves a sliver
of map visible under the dock. #bottomSafe is a solid filler pinned to the true
bottom edge, independent of the dock's own box math, so nothing ever shows through. */
#bottomSafe{position:fixed;left:0;right:0;bottom:0;height:calc(max(var(--sb),14px) + 2px);
background:var(--ink);z-index:5;pointer-events:none}
#dock{position:fixed;left:0;right:0;bottom:0;z-index:6;
background:linear-gradient(#10141a00,#10141a 26%);padding:16px 10px calc(max(var(--sb),14px) + 10px)}
.chips{display:flex;gap:6px;flex-wrap:wrap;scrollbar-width:none;padding-bottom:2px}
.chips::-webkit-scrollbar{display:none}
.chip{flex:0 0 auto;border:1px solid var(--line);background:var(--panel);color:var(--muted);
border-radius:11px;padding:9px 14px;font-size:12.5px;font-weight:600;font-family:inherit;
display:flex;align-items:center;gap:6px;white-space:nowrap}
.chip.on{background:var(--text);color:var(--ink);border-color:var(--text)}
.chip.locked{opacity:.45;cursor:default}
.chip .dot{width:9px;height:9px;border-radius:3px;flex:0 0 auto}
.chip small{font-weight:500;opacity:.65;font-size:10.5px}
/* icon-only fallback for the mode bar — only applied via JS once the row
is measured to genuinely not fit at full width (see fitChipRow) */
#modes.iconOnly{flex-wrap:nowrap}
#modes.iconOnly .chip{padding:9px 10px}
#modes.iconOnly .chipLbl{display:none}
#sub{margin-top:7px;min-height:0}
#hint{margin-top:9px;font-size:11.5px;color:var(--muted);padding:0 3px;min-height:14px}
#tray,#jTray,#rTray,#eTray,#dTray,#saveTray,#musicTray,#bankTray,#iTray,#socialTray,#svcTray,#moveTray{position:fixed;left:0;right:0;bottom:0;z-index:8;background:var(--panel);
border-top:1px solid var(--line);border-radius:18px 18px 0 0;
padding:14px 14px calc(max(var(--sb),14px) + 14px);transform:translateY(115%);
transition:transform .26s cubic-bezier(.3,.85,.35,1);max-height:80vh;overflow-y:auto}
#tray.up,#jTray.up,#rTray.up,#eTray.up,#dTray.up,#saveTray.up,#musicTray.up,#bankTray.up,#iTray.up,#socialTray.up,#svcTray.up,#moveTray.up{transform:none}
#trayTop{display:flex;align-items:baseline;gap:12px;margin-bottom:12px}
#trayTop h3,#jTray h3,#rTray h3,#eTray h3,#dTray h3,#saveTray h3,#musicTray h3,#bankTray h3,#iTray h3,#socialTray h3,#svcTray h3,#moveTray h3{margin:0 0 12px;font-size:15px;font-weight:650;letter-spacing:-.015em}
#fpModal,#demoModal,#partnerModal,#bailoutDemoModal{position:fixed;inset:0;z-index:22;background:#10141aec;display:none;place-items:center;
padding:26px;backdrop-filter:blur(3px)}
#fpModal.show,#demoModal.show,#partnerModal.show,#bailoutDemoModal.show{display:grid}
#fpCard,#demoCard,#partnerCard,#bailoutDemoCard{max-width:330px;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:22px}
#fpCard h2,#demoCard h2,#partnerCard h2,#bailoutDemoCard h2{font-size:20px;margin:0 0 10px;letter-spacing:-.02em}
#fpCard p,#demoCard p,#partnerCard p,#bailoutDemoCard p{font-size:13px;line-height:1.6;color:#bcc5d0;margin:0 0 10px}
#fpCard .acts,#demoCard .acts,#partnerCard .acts,#bailoutDemoCard .acts{margin-top:16px;flex-direction:column;gap:8px}
#fpCard .acts button,#demoCard .acts button,#partnerCard .acts button,#bailoutDemoCard .acts button{width:100%;padding:13px;border-radius:12px;font-family:inherit;font-size:14px;
font-weight:650;border:1px solid var(--line);background:var(--panel2);color:var(--text)}
#fpCard .acts button.go,#partnerCard .acts button.go{background:var(--res);border-color:var(--res);color:#0d1a12}
#demoCard .acts button.danger,#bailoutDemoCard .acts button.danger{background:var(--danger);border-color:var(--danger);color:#2b0e0b}
#trayTop span{font-size:12px;color:var(--muted)}
#trayTop b{margin-left:auto;font-size:16px;font-weight:650}
#trayTop b.no{color:var(--danger)}
.acts{display:flex;gap:8px}
.acts button{flex:1;padding:14px;border-radius:12px;font-family:inherit;font-size:14px;
font-weight:650;border:1px solid var(--line);background:var(--panel2);color:var(--text)}
.acts button.go{background:var(--res);border-color:var(--res);color:#0d1a12}
.acts button.go:disabled{opacity:.4}
.acts button.ghost{flex:0 0 96px;color:var(--muted)}
.acts button.danger{background:var(--danger);border-color:var(--danger);color:#2b0e0b}
.upgrade-btn{width:100%;padding:12px;border-radius:12px;border:1px solid var(--res);
background:var(--res);color:#0d1a12;font-family:inherit;font-size:13.5px;font-weight:650;
display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:8px}
.upgrade-btn small{opacity:.75;font-weight:600}
.upgrade-btn.downgrade{background:var(--panel2);border:1px solid var(--line);color:var(--text)}
.row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;
border-bottom:1px solid #ffffff10;font-size:13px}
.row span{color:var(--muted)} .row b{font-weight:600}
.pos{color:#8fd6a3} .neg{color:var(--danger)}
.sec{margin:16px 0 2px;font-size:11.5px;color:var(--muted);font-weight:650;letter-spacing:.02em}
.row.problem{align-items:flex-start}
.row.tappable{cursor:pointer} .row.tappable:active{background:#ffffff08}
.row span.what{color:var(--text)}
.row span.how{display:block;color:var(--muted);font-size:11px;margin-top:3px;line-height:1.45}
#socialBtn{position:relative}
#socialDot{position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;
background:var(--danger);display:none;font-style:normal}
#socialDot.show{display:block}
.feedPost{display:flex;gap:10px;padding:11px 0;border-bottom:1px solid #ffffff10}
.feedPost.clickable{cursor:pointer}
.feedPost.clickable:active{background:#ffffff08}
.feedPost:last-child{border-bottom:none}
.feedAvatar{flex:0 0 auto;width:34px;height:34px;border-radius:50%;background:var(--panel2);
display:flex;align-items:center;justify-content:center;font-size:16px}
.feedBody{flex:1;min-width:0}
.feedHead{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap;font-size:12.5px}
.feedHead b{font-weight:650}
.feedHead span{color:var(--muted);font-size:11.5px}
.feedText{font-size:13px;line-height:1.5;margin-top:2px;color:#dfe4ea}
.feedMeta{margin-top:5px;font-size:11.5px;color:var(--muted)}
#saveTray .lbl{font-size:11px;color:var(--muted);margin:0 0 6px;font-weight:650;letter-spacing:.02em}
#cityNameInput{width:100%;padding:11px 12px;border-radius:10px;border:1px solid var(--line);
background:var(--panel2);color:var(--text);font-family:inherit;font-size:14px}
#cityNameInput:focus{outline:none;border-color:var(--gold)}
#saveFilePreview{font-size:11px;color:var(--muted);margin-top:6px}
#toasts{position:fixed;left:10px;right:10px;top:calc(var(--st) + 66px);z-index:7;
display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none}
.toast{background:#181e26f2;border:1px solid #ffffff1f;border-left:3px solid var(--res);
border-radius:10px;padding:8px 13px;font-size:12.5px;max-width:330px;backdrop-filter:blur(8px);
animation:pop .3s ease both}
.toast.warn{border-left-color:var(--ind)} .toast.bad{border-left-color:var(--danger)}
@keyframes pop{from{opacity:0;transform:translateY(-7px)}}
#speed{display:flex;gap:4px;pointer-events:auto;flex-wrap:wrap;justify-content:flex-end}
#speed button{width:32px;height:32px;border-radius:9px;border:1px solid #ffffff16;
background:#181e26d9;color:var(--muted);font-size:11px;font-family:inherit;padding:0}
#speed button.on{background:var(--text);color:var(--ink);border-color:var(--text)}
#musicBtn,#saveBtn{margin-left:2px}
#hudRight{display:flex;gap:7px;align-items:flex-start}
#hudToggle{display:none;width:34px;height:34px;flex:0 0 auto;border-radius:10px;
border:1px solid #ffffff16;background:#181e26d9;color:var(--text);font-size:16px;
font-weight:800;font-family:inherit;line-height:1;pointer-events:auto}
/* small screens: the right-hand cluster (demand meters, speed, bank/music/save)
stays out of the way behind a "⋯" toggle instead of crowding the HUD */
@media (max-width:560px){
#hudToggle{display:block}
#hudRight{display:none}
#hudRight.open{display:flex;position:fixed;top:calc(var(--st) + 8px);right:10px;
flex-direction:column;align-items:flex-end;gap:6px;z-index:6}
#hudRight.open #rci,#hudRight.open #speed{justify-content:flex-end}
}
#intro{position:fixed;inset:0;z-index:20;background:var(--ink);display:flex;
overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;
padding:calc(var(--st) + 22px) 24px calc(var(--sb) + 22px)}
#intro>div{max-width:330px;width:100%;margin:auto}
#introVersion{position:absolute;right:10px;bottom:calc(var(--sb) + 8px);
font-size:10px;color:#5a6472;letter-spacing:.02em;opacity:.7;pointer-events:none}
#intro h1{font-size:27px;margin:0 0 10px;letter-spacing:-.035em;line-height:1.05;font-weight:700}
#intro p{font-size:13px;line-height:1.55;color:#bcc5d0;margin:0 0 9px}
#intro ol{font-size:12.5px;line-height:1.55;color:#bcc5d0;padding-left:19px;margin:12px 0 0}
#intro ol li{margin-bottom:5px}
#intro .ctrl{font-size:11px;color:#8b96a4;margin-top:11px;line-height:1.45}
#intro .lbl{font-size:11px;color:#8b96a4;margin:14px 0 6px;font-weight:650;letter-spacing:.02em}
#sizeOpts{display:flex;gap:6px}
#sizeOpts button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;
padding:10px 6px;border-radius:10px;border:1px solid var(--line);background:var(--panel2);
color:var(--muted);font-family:inherit;font-size:12.5px;font-weight:650;line-height:1.15;cursor:pointer}
#sizeOpts button small{font-weight:500;font-size:9.5px;opacity:.7;text-align:center}
#sizeOpts button.on{background:var(--text);color:var(--ink);border-color:var(--text)}
#sizeOpts button.on small{opacity:.6}
#advToggle{width:100%;margin-top:14px;padding:10px;border-radius:10px;cursor:pointer;
border:1px solid var(--line);background:var(--panel2);color:var(--text);font-family:inherit;
font-size:12.5px;font-weight:650;text-align:left}
#advPanel{display:none;margin-top:10px;padding:12px;border-radius:12px;background:var(--panel2);border:1px solid var(--line)}
#advPanel .lbl{margin-top:12px}
#advPanel .lbl:first-child{margin-top:0}
#advPanel input[type=range]{width:100%;accent-color:var(--gold);margin-top:2px}
#landVal,#mtnVal{color:var(--text);font-weight:650;opacity:1;letter-spacing:0}
#starterOpts{margin-top:2px}
#starterOpts button{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:1px;
padding:8px 11px;border-radius:9px;border:1px solid var(--line);background:var(--panel);
color:var(--muted);font-family:inherit;font-size:12px;font-weight:650;cursor:pointer;text-align:left}
#starterOpts button small{font-weight:500;font-size:9.5px;opacity:.7}
#starterOpts button.on{background:var(--text);color:var(--ink);border-color:var(--text)}
#starterOpts button.on small{opacity:.6}
#starterBlurb{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.5;min-height:14px}
.seedRow{display:flex;gap:8px;margin-top:2px}
#seedInput{flex:1;min-width:0;padding:10px 11px;border-radius:10px;border:1px solid var(--line);
background:var(--panel);color:var(--text);font-family:inherit;font-size:13px}
#seedInput:focus{outline:none;border-color:var(--gold)}
#seedRandom{flex:0 0 auto;width:40px;border-radius:10px;border:1px solid var(--line);
background:var(--panel);color:var(--text);font-size:15px;cursor:pointer}
#seedHint{font-size:10.5px;color:var(--muted);margin-top:6px;line-height:1.5;min-height:13px}
.advCheck{display:flex;align-items:center;gap:8px;margin-top:14px;font-size:12.5px;color:#bcc5d0;cursor:pointer}
.advCheck input{width:16px;height:16px;accent-color:var(--gold)}
#start{width:100%;margin-top:16px;padding:14px;border-radius:12px;border:none;cursor:pointer;
background:var(--text);color:var(--ink);font-family:inherit;font-size:14.5px;font-weight:650}
.linkBtn{display:block;background:none;border:none;color:#8fbfe0;font-family:inherit;font-size:12.5px;
font-weight:650;padding:0;margin:12px 0 0;cursor:pointer;text-align:left}
.ghostWide{width:100%;margin-top:10px;padding:12px;border-radius:12px;cursor:pointer;
border:1px solid var(--line);background:transparent;color:var(--text);font-family:inherit;font-size:13px;font-weight:600}
#tutModal{position:fixed;inset:0;z-index:23;background:#10141aec;display:none;place-items:center;
padding:26px;backdrop-filter:blur(3px)}
#tutModal.show{display:grid}
#tutCard{max-width:340px;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:22px}
#tutBody{min-height:220px}
#tutBody .tutIcon{font-size:34px;line-height:1}
#tutBody h2{font-size:18px;margin:10px 0 8px;letter-spacing:-.015em}
#tutBody p{font-size:13px;line-height:1.6;color:#bcc5d0;margin:0 0 8px}
#tutNav{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}
#tutNav button{padding:11px 16px;border-radius:11px;font-family:inherit;font-size:13px;font-weight:650;
border:1px solid var(--line);background:var(--panel2);color:var(--text);cursor:pointer}
#tutNav button.go{background:var(--res);border-color:var(--res);color:#0d1a12}
#tutNav button:disabled{opacity:.35;cursor:default}
#tutDots{display:flex;gap:5px}
#tutDots span{width:6px;height:6px;border-radius:50%;background:var(--line)}
#tutDots span.on{background:var(--gold)}
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
</style>
</head>
<body>
<canvas id="c"></canvas>
<input type="file" id="loadFile" accept=".json,application/json" style="display:none">
<div id="hud">
<div class="stat" id="cash"><b>$0</b><i>funds</i></div>
<div class="stat" id="pop"><b>0</b><i>residents</i></div>
<div class="stat" id="happy"><b>😊 —</b><i>happiness</i></div>
<div class="grow"></div>
<div id="hudRight">
<div id="rci">
<div class="dm"><i></i><u id="dr"></u><em>R</em></div>
<div class="dm"><i></i><u id="dc"></u><em>C</em></div>
<div class="dm"><i></i><u id="di"></u><em>I</em></div>
<div class="dm"><i></i><u id="dof"></u><em>O</em></div>
</div>
<div id="speed">
<button data-s="0">❚❚</button>
<button data-s="0.25" class="on">▷</button>
<button data-s="0.5">▶</button>
<button data-s="1.5">▶▶</button>
<button data-s="4">▶▶▶</button>
<button id="bankBtn">🏦</button>
<button id="musicBtn">🎵</button>
<button id="saveBtn">💾</button>
<button id="socialBtn">📱<i id="socialDot"></i></button>
</div>
</div>
<button id="hudToggle">⋯</button>
</div>
<div id="toasts"></div>
<div id="bottomSafe"></div>
<div id="dock">
<div class="chips" id="modes"></div>
<div class="chips" id="sub"></div>
<div class="chips" id="overlays" style="margin-top:8px;border-top:1px solid var(--line);padding-top:8px"></div>
<div id="hint"></div>
</div>
<div id="tray">
<div id="trayTop"><h3 id="trayTitle" style="margin:0">New road</h3><span id="trayMeta"></span><b id="trayCost"></b></div>
<div class="acts">
<button class="ghost" id="trayCancel">Cancel</button>
<button id="trayFlat">Straighten</button>
<button id="trayBridge" style="display:none">⊹ Intersect</button>
<button class="go" id="trayGo" disabled>Build</button>
</div>
</div>
<div id="jTray">
<h3 id="jTitle">Intersection</h3>
<div class="chips" id="jOpts"></div>
</div>
<div id="rTray">
<h3 id="rTitle">Road</h3>
<div id="rUpgradeWrap"></div>
<div class="sec" style="margin-top:4px">Change to</div>
<div class="chips" id="rTypes"></div>
<div class="acts" style="margin-top:10px">
<button class="ghost" id="rClose">Close</button>
<button id="rReshape">Reshape</button>
<button class="danger" id="rRemove">Remove</button>
</div>
</div>
<div id="eTray">
<h3>City economy</h3>
<div id="eBody"></div>
<div class="acts" style="margin-top:14px"><button id="eClose">Close</button></div>
</div>
<div id="dTray">
<h3>Demand (RCOI)</h3>
<div id="dBody"></div>
<div class="acts" style="margin-top:14px"><button id="dClose">Close</button></div>
</div>
<div id="saveTray">
<h3>Save &amp; load</h3>
<p style="font-size:12px;color:var(--muted);margin:0 0 10px;line-height:1.55">
Reloading this page won't remember your city on its own — save a file and keep it somewhere safe, then load it back whenever you return.
</p>
<div class="lbl">CITY NAME</div>
<input type="text" id="cityNameInput" maxlength="40" placeholder="Name your city">
<div id="saveFilePreview"></div>
<div class="acts" style="margin-top:14px">
<button class="ghost" id="saveClose">Close</button>
<button id="doLoad">Load file</button>
<button class="go" id="doSave">Save</button>
</div>
</div>
<div id="bankTray">
<h3>Bank</h3>
<p style="font-size:12px;color:var(--muted);margin:0 0 12px;line-height:1.55">
The regional bank lends against your city. Interest is charged every month on the balance. Fall far enough behind and it stops lending.
</p>
<div id="bankBody"></div>
<div class="acts" style="margin-top:14px"><button class="ghost" id="bankClose">Close</button></div>
</div>
<div id="iTray">
<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:10px">
<h3 id="iTitle" style="margin:0">—</h3>
<span id="iSub" style="font-size:12px;color:var(--muted)"></span>
</div>
<div id="iBody"></div>
<div class="acts" style="margin-top:12px">
<button class="ghost" id="iClose">Close</button>
<button id="iFollow" style="display:none">◎ Follow</button>
</div>
</div>
<div id="fpModal"><div id="fpCard">
<h2>Your city is deep in debt</h2>
<p id="fpText"></p>
<p>Keep working the books — take loans, raise taxes, grow your tax base — or switch to <b>Free Play</b>: unlimited funds, no upkeep pressure, just build.</p>
<div class="acts">
<button class="go" id="fpAccept">Switch to Free Play</button>
<button id="fpDecline">Keep managing</button>
</div>
</div></div>
<div id="demoModal"><div id="demoCard">
<h2>⚠️ This road runs through buildings</h2>
<p id="demoText"></p>
<p style="color:var(--muted)">They'll be demolished to make room — this can't be undone.</p>
<div class="acts">
<button class="danger" id="demoAccept">Demolish &amp; build</button>
<button id="demoDecline">Cancel</button>
</div>
</div></div>
<div id="partnerModal"><div id="partnerCard">
<div id="partnerFrom" style="font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;font-weight:650"></div>
<h2 id="partnerTitle"></h2>
<p id="partnerText"></p>
<p id="partnerGrant" style="color:var(--muted)"></p>
<p style="font-size:11.5px;color:var(--muted)">No rush — it stays under Zoning ▸ Special either way, and siting it is what takes the deal.</p>
<div class="acts">
<button class="go" id="partnerAccept">Accept the deal</button>
<button id="partnerDecline">Not right now</button>
</div>
</div></div>
<div id="bailoutDemoModal"><div id="bailoutDemoCard">
<h2 id="bailoutDemoTitle"></h2>
<p id="bailoutDemoText"></p>
<div class="acts">
<button class="danger" id="bailoutDemoConfirm">Demolish anyway</button>
<button id="bailoutDemoCancel">Keep it</button>
</div>
</div></div>
<div id="musicTray">
<h3>Spline City Radio</h3>
<div id="radioNow" style="font-size:11.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.5;font-variant-numeric:tabular-nums">Radio off — it starts when the game does.</div>
<div class="chips" id="trackOpts"></div>
<p style="font-size:11px;color:var(--muted);margin:10px 0 0;line-height:1.5">Auto DJ mixes between every track with filter sweeps and tape stops. Pick one to hold it — the procedural engine still runs it through an intro, builds, drops and an outro.</p>
<div class="acts" style="margin-top:12px">
<button class="ghost" id="musicClose">Close</button>
<button id="muteToggle">🔊 Sound on</button>
</div>
</div>
<div id="socialTray">
<h3>📱 Spline Social</h3>
<div id="feedBody"><p style="font-size:12px;color:var(--muted)">Nobody's posted yet — give the city a little time to grow.</p></div>
<div class="acts" style="margin-top:12px"><button class="ghost" id="socialClose">Close</button></div>
</div>
<div id="svcTray">
<h3 id="svcTitle">Service building</h3>
<div id="svcBody" style="font-size:12px;color:var(--muted);margin-bottom:4px"></div>
<div class="acts" style="margin-top:12px">
<button class="ghost" id="svcClose">Close</button>
<button id="svcMove">📍 Move</button>
</div>
</div>
<div id="moveTray">
<h3>Move building</h3>
<div id="moveBody" style="font-size:12.5px;color:var(--muted);line-height:1.55"></div>
<div class="acts" style="margin-top:12px">
<button class="ghost" id="moveCancel">Cancel</button>
<button class="go" id="moveConfirm">Confirm move</button>
</div>
</div>
<div id="intro">
<div>
<h1>Spline City</h1>
<p>A regional highway crosses the western edge into your land. Build roads, zone the land, keep the city running.</p>
<button type="button" class="linkBtn" id="howToPlayBtn">📖 How to play</button>
<div class="lbl">MAP SIZE</div>
<div id="sizeOpts"></div>
<button type="button" id="advToggle">⚙ Advanced options ▾</button>
<div id="advPanel">
<div class="lbl">START FROM</div>
<div class="chips" id="starterOpts"></div>
<div id="starterBlurb"></div>
<div class="lbl">LAND vs WATER <span id="landVal"></span></div>
<input type="range" id="landSlider" min="0" max="100" value="70">
<div class="lbl">MOUNTAINOUS <span id="mtnVal"></span></div>
<input type="range" id="mtnSlider" min="0" max="100" value="50">
<div class="lbl">MAP SEED</div>
<div class="seedRow">
<input type="text" id="seedInput" placeholder="random" maxlength="24" autocomplete="off" spellcheck="false">
<button type="button" id="seedRandom" title="Use a random seed">🎲</button>
</div>
<div id="seedHint"></div>
<label class="advCheck"><input type="checkbox" id="unlimitedMoney"> Start with unlimited money (Free Play)</label>
</div>
<button id="start">Start building</button>
<button type="button" class="ghostWide" id="introLoad">📂 Load a saved city</button>
</div>
<div id="introVersion">v0.9.55</div>
</div>
<div id="tutModal"><div id="tutCard">
<div id="tutBody"></div>
<div id="tutNav">
<button class="ghost" id="tutBack">Back</button>
<span id="tutDots"></span>
<button class="go" id="tutNext">Next</button>
</div>
<button class="ghost" id="tutSkip" style="width:100%;margin-top:10px">Close</button>
</div></div>
<script>
"use strict";
/* ══════════════════════════════════════════════════════════════════════════
SPLINE CITY
topology → geometry → lanes+connectors → lots → agents (A* trips)
plus a goods supply chain, filled-ribbon roads, generated terrain with
water that forces bridges, file-based save/load, and switchable music.
══════════════════════════════════════════════════════════════════════════ */
/* ── math ───────────────────────────────────────────────────────────────── */
const lerpP=(a,b,t)=>({x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t});
const sub=(a,b)=>({x:a.x-b.x,y:a.y-b.y});
const add=(a,b)=>({x:a.x+b.x,y:a.y+b.y});
const mul=(a,k)=>({x:a.x*k,y:a.y*k});
const vlen=a=>Math.sqrt(a.x*a.x+a.y*a.y);
const norm=a=>{const l=vlen(a)||1;return{x:a.x/l,y:a.y/l};};
const dist=(a,b)=>{const dx=a.x-b.x,dy=a.y-b.y;return Math.sqrt(dx*dx+dy*dy);};
const ang=v=>Math.atan2(v.y,v.x);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const pick=a=>a.length?a[(Math.random()*a.length)|0]:null;
const EMPTY_DASH=[];
const _bezT={x:0,y:0};
class Bez{
constructor(p0,p1,p2,p3){this.p0=p0;this.p1=p1;this.p2=p2;this.p3=p3;this.resample();}
pointAt(t){const u=1-t,a=u*u*u,b=3*u*u*t,c=3*u*t*t,d=t*t*t;
return{x:a*this.p0.x+b*this.p1.x+c*this.p2.x+d*this.p3.x,
y:a*this.p0.y+b*this.p1.y+c*this.p2.y+d*this.p3.y};}
tangentAt(t){const u=1-t,a=3*u*u,b=6*u*t,c=3*t*t;
let x=a*(this.p1.x-this.p0.x)+b*(this.p2.x-this.p1.x)+c*(this.p3.x-this.p2.x);
let y=a*(this.p1.y-this.p0.y)+b*(this.p2.y-this.p1.y)+c*(this.p3.y-this.p2.y);
let l=Math.sqrt(x*x+y*y);
if(l<1e-6){x=this.p3.x-this.p0.x;y=this.p3.y-this.p0.y;l=Math.sqrt(x*x+y*y)||1;}
return{x:x/l,y:y/l};}
normalAt(t){const n=this.tangentAt(t);return{x:-n.y,y:n.x};}
offsetAt(t,o){const p=this.pointAt(t),n=this.normalAt(t);return{x:p.x+n.x*o,y:p.y+n.y*o};}
/* Allocation-free variants for the per-frame agent draw path, which would
otherwise build several throwaway {x,y} objects per agent per frame. */
pointAtInto(t,out){const u=1-t,a=u*u*u,b=3*u*u*t,c=3*u*t*t,d=t*t*t;
out.x=a*this.p0.x+b*this.p1.x+c*this.p2.x+d*this.p3.x;
out.y=a*this.p0.y+b*this.p1.y+c*this.p2.y+d*this.p3.y;return out;}
tangentAtInto(t,out){const u=1-t,a=3*u*u,b=6*u*t,c=3*t*t;
let x=a*(this.p1.x-this.p0.x)+b*(this.p2.x-this.p1.x)+c*(this.p3.x-this.p2.x);
let y=a*(this.p1.y-this.p0.y)+b*(this.p2.y-this.p1.y)+c*(this.p3.y-this.p2.y);
let l=Math.sqrt(x*x+y*y);
if(l<1e-6){x=this.p3.x-this.p0.x;y=this.p3.y-this.p0.y;l=Math.sqrt(x*x+y*y)||1;}
out.x=x/l;out.y=y/l;return out;}
offsetAtInto(t,off,out){this.pointAtInto(t,out);this.tangentAtInto(t,_bezT);
out.x+=-_bezT.y*off;out.y+=_bezT.x*off;return out;}
get length(){return this._len;}
tAtDist(s){
if(s<=0)return 0; if(s>=this._len)return 1;
const L=this.lut; let lo=0,hi=L.length-1;
while(lo+1<hi){const m=(lo+hi)>>1; if(L[m]<=s)lo=m;else hi=m;}
const sp=L[hi]-L[lo],f=sp<1e-9?0:(s-L[lo])/sp;
return (lo+f)/(L.length-1);}
distAtT(t){ if(t<=0)return 0; if(t>=1)return this._len;
const f=t*(this.lut.length-1),i=Math.floor(f);
return this.lut[i]+(this.lut[i+1]-this.lut[i])*(f-i);}
splitAt(t){
const a=lerpP(this.p0,this.p1,t),b=lerpP(this.p1,this.p2,t),c=lerpP(this.p2,this.p3,t);
const d=lerpP(a,b,t),e=lerpP(b,c,t),m=lerpP(d,e,t);
return[new Bez(this.p0,a,d,m),new Bez(m,e,c,this.p3)];}
closest(q){
// coarse pass at roughly one sample per 6 world units — a fixed 20
// samples badly undersampled anything longer than a short road, which
// is exactly what let taps "miss" long roads that were clearly under
// the finger/cursor — then narrow in with a shrinking-window search
// (scale-independent, unlike a fixed-gain gradient step)
const N=Math.max(20,Math.ceil(this._len/6));
let bt=0,bd=Infinity;
for(let i=0;i<=N;i++){const t=i/N,p=this.pointAt(t);
const d2=(p.x-q.x)**2+(p.y-q.y)**2; if(d2<bd){bd=d2;bt=t;}}
let win=1/N;
for(let round=0;round<6;round++){
const lo=Math.max(0,bt-win), hi=Math.min(1,bt+win);
for(let i=0;i<=8;i++){const t=lo+(hi-lo)*(i/8),p=this.pointAt(t);
const d2=(p.x-q.x)**2+(p.y-q.y)**2; if(d2<bd){bd=d2;bt=t;}}
win*=0.5;
}
const p=this.pointAt(bt);
return{t:bt,point:p,distance:dist(p,q),arcLength:this.distAtT(bt)};}
bounds(pad){
pad=pad||0;
const xs=[this.p0.x,this.p1.x,this.p2.x,this.p3.x], ys=[this.p0.y,this.p1.y,this.p2.y,this.p3.y];
return{minX:Math.min.apply(null,xs)-pad,maxX:Math.max.apply(null,xs)+pad,
minY:Math.min.apply(null,ys)-pad,maxY:Math.max.apply(null,ys)+pad};}
maxCurvature(){
let m=0;
for(let i=0;i<=8;i++){
const t=i/8,u=1-t;
const dx=3*u*u*(this.p1.x-this.p0.x)+6*u*t*(this.p2.x-this.p1.x)+3*t*t*(this.p3.x-this.p2.x);
const dy=3*u*u*(this.p1.y-this.p0.y)+6*u*t*(this.p2.y-this.p1.y)+3*t*t*(this.p3.y-this.p2.y);
const ddx=6*u*(this.p2.x-2*this.p1.x+this.p0.x)+6*t*(this.p3.x-2*this.p2.x+this.p1.x);
const ddy=6*u*(this.p2.y-2*this.p1.y+this.p0.y)+6*t*(this.p3.y-2*this.p2.y+this.p1.y);
const den=Math.pow(dx*dx+dy*dy,1.5);
if(den>1e-9)m=Math.max(m,Math.abs((dx*ddy-dy*ddx)/den));}
return m;}
resample(){
const N=24;this.lut=new Float32Array(N+1);
let acc=0,prev=this.pointAt(0);
for(let i=1;i<=N;i++){const p=this.pointAt(i/N);acc+=dist(p,prev);this.lut[i]=acc;prev=p;}
this._len=acc;}
}
/* The stretch of a curve between two parameters, as a curve in its own right:
splitAt() twice, with the second cut renormalized into the first split's
parameter space (after cutting at t0, the old t maps to (t-t0)/(1-t0)). */
function subCurve(curve,t0,t1){
const a=clamp(t0,0,1), b=clamp(t1,0,1);
if(b<=a) return curve;
let c=curve;
if(a>1e-4) c=c.splitAt(a)[1];
if(b<1-1e-4) c=c.splitAt(clamp((b-a)/(1-a),0,1))[0];
return c;
}
/* segment/segment and curve/curve intersection */
function segCross(p1,p2,p3,p4){
const d1x=p2.x-p1.x,d1y=p2.y-p1.y,d2x=p4.x-p3.x,d2y=p4.y-p3.y;
const den=d1x*d2y-d1y*d2x;
if(Math.abs(den)<1e-9) return null;
const t=((p3.x-p1.x)*d2y-(p3.y-p1.y)*d2x)/den;
const u=((p3.x-p1.x)*d1y-(p3.y-p1.y)*d1x)/den;
if(t<0||t>1||u<0||u>1) return null;
return {t,u,point:{x:p1.x+d1x*t,y:p1.y+d1y*t}};
}
function aabbHit(a,b){ return !(a.maxX<b.minX||b.maxX<a.minX||a.maxY<b.minY||b.maxY<a.minY); }
/* Signed curvature (1/units) at a single point, via the change in tangent
angle over a short arc-length step — unlike Bez.maxCurvature() (which only
reports the single worst value across the whole curve), this is localized,
so callers can tell exactly where along the curve it gets tight. */
function curveTurnAt(curve,t){
const dt=0.015;
const t0=Math.max(0,t-dt), t1=Math.min(1,t+dt);
const a0=ang(curve.tangentAt(t0)), a1=ang(curve.tangentAt(t1));
let da=a1-a0; while(da>Math.PI)da-=2*Math.PI; while(da<-Math.PI)da+=2*Math.PI;
const ds=Math.max(1e-6, curve.distAtT(t1)-curve.distAtT(t0));
return da/ds;
}
function curveCrossings(A,B,N){
N=N||28;
const out=[];
if(!aabbHit(A.bounds(2),B.bounds(2))) return out;
const pa=[],pb=[];
for(let i=0;i<=N;i++){pa.push(A.pointAt(i/N));pb.push(B.pointAt(i/N));}
for(let i=0;i<N;i++)for(let j=0;j<N;j++){
const r=segCross(pa[i],pa[i+1],pb[j],pb[j+1]);
if(r) out.push({tA:(i+r.t)/N,tB:(j+r.u)/N,point:r.point});
}
return out;
}
function convexHull(points){
const pts=[...points].sort((a,b)=>a.x-b.x||a.y-b.y);
if(pts.length<3) return pts;
const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
const lower=[];
for(const p of pts){
while(lower.length>=2 && cross(lower[lower.length-2],lower[lower.length-1],p)<=0) lower.pop();
lower.push(p);
}
const upper=[];
for(let i=pts.length-1;i>=0;i--){
const p=pts[i];
while(upper.length>=2 && cross(upper[upper.length-2],upper[upper.length-1],p)<=0) upper.pop();
upper.push(p);
}
upper.pop(); lower.pop();
return lower.concat(upper);
}
function ribbonPoly(curve,halfWidth,t0,t1,N){
N=N||18;
const left=[],right=[];
for(let i=0;i<=N;i++){
const t=t0+(t1-t0)*(i/N);
left.push(curve.offsetAt(t,halfWidth));
right.push(curve.offsetAt(t,-halfWidth));
}
right.reverse();
return left.concat(right);
}
/* ── road catalog ───────────────────────────────────────────────────────── */
const ROAD_TYPES=[
{ id:'dirt', name:'Dirt road', oneWay:false, sidewalk:false, frontage:true, lotClass:'rural', width:6.4, costPerM:6, upkeepPerM:0.03, minRadius:7,
lanes:[{offset:-1.5,dir:-1,speed:6},{offset:1.5,dir:1,speed:6}],
surfaceColor:'#9c8460', edgeColor:'#836d4d', ui:'#b08d5b' },
/* crossAnywhere: a street this narrow isn't something anyone walks to the
corner to get across, so pedestrians may switch sides mid-block (see
buildWalkLinks) and no crosswalk — or centreline — is painted on it.
One flag, because those are the same fact: there is no marked crossing
precisely because the street is small enough not to need one. */
{ id:'lane', name:'Small street', oneWay:false, sidewalk:true, frontage:true, lotClass:'row', crossAnywhere:true, width:7, costPerM:11, upkeepPerM:0.06, minRadius:6,
lanes:[{offset:-1.6,dir:-1,speed:7},{offset:1.6,dir:1,speed:7}],
surfaceColor:'#7d8185', edgeColor:'#c6c8ca', ui:'#6f7a87' },
{ id:'street', name:'City street', oneWay:false, sidewalk:true, frontage:true, lotClass:'normal', width:9, costPerM:16, upkeepPerM:0.09, minRadius:8,
lanes:[{offset:-2.1,dir:-1,speed:9},{offset:2.1,dir:1,speed:9}],
surfaceColor:'#76797d', edgeColor:'#c3c5c7', ui:'#8b95a3' },
{ id:'road', name:'Road', oneWay:false, sidewalk:true, frontage:true, lotClass:'normal', width:15, costPerM:28, upkeepPerM:0.15, minRadius:10,
lanes:[{offset:-5.4,dir:-1,speed:12},{offset:-1.8,dir:-1,speed:12},{offset:1.8,dir:1,speed:12},{offset:5.4,dir:1,speed:12}],
surfaceColor:'#717479', edgeColor:'#c0c2c4', ui:'#c7cdd6' },
{ id:'avenue', name:'Avenue', oneWay:false, sidewalk:true, frontage:true, lotClass:'normal', width:23, costPerM:44, upkeepPerM:0.22, minRadius:13,
lanes:[{offset:-9.6,dir:-1,speed:13},{offset:-6.0,dir:-1,speed:13},{offset:-2.4,dir:-1,speed:13},
{offset: 2.4,dir: 1,speed:13},{offset: 6.0,dir: 1,speed:13},{offset: 9.6,dir: 1,speed:13}],
surfaceColor:'#6d7075', edgeColor:'#bdbfc1', ui:'#e8d98a' },
{ id:'oneway', name:'One-way street', oneWay:true, sidewalk:true, frontage:true, lotClass:'normal', width:9, costPerM:20, upkeepPerM:0.1, minRadius:9,
lanes:[{offset:-1.8,dir:1,speed:12},{offset:1.8,dir:1,speed:12}],
surfaceColor:'#76797d', edgeColor:'#c3c5c7', ui:'#5b9bd5' },
{ id:'highway', name:'Highway', oneWay:false, sidewalk:false, frontage:false, width:19, costPerM:48, upkeepPerM:0.24, minRadius:16,
lanes:[{offset:-6.6,dir:-1,speed:18},{offset:-2.8,dir:-1,speed:18},{offset:2.8,dir:1,speed:18},{offset:6.6,dir:1,speed:18}],
surfaceColor:'#65686d', edgeColor:'#a9abae', ui:'#7fd6b0' },
{ id:'freeway', name:'Freeway', oneWay:true, sidewalk:false, frontage:false, width:13, costPerM:52, upkeepPerM:0.24, minRadius:17,
lanes:[{offset:-3.6,dir:1,speed:22},{offset:0,dir:1,speed:22},{offset:3.6,dir:1,speed:22}],
surfaceColor:'#606367', edgeColor:'#a5a7aa', ui:'#e0604d' },
];
const ROAD_TYPE_BY_ID=Object.fromEntries(ROAD_TYPES.map(t=>[t.id,t]));
const UPGRADE_NEXT={ dirt:'lane', lane:'street', street:'road', road:'avenue' };
const UPGRADE_PREV={ lane:'dirt', street:'lane', road:'street', avenue:'road' };
let roadTypeSel='street';
const SETBACK=1.2, LOT_DEPTH=19, LOT_TARGET=15, WALK_MAX_UNITS=10, RESHAPE_FEE=35;
/* How a road's frontage is cut into parcels. `frontage` is the target width
along the road, `depth` the parcel's depth away from it. `normal` is the
historic 15×19 lot, kept as the default for anything unlisted. */
const LOT_CLASSES={
rural : { frontage:36, depth:42 },
normal: { frontage:LOT_TARGET, depth:LOT_DEPTH },
row : { frontage:7.5, depth:12 },
};
/* How far apart mid-block pedestrian crossings sit on a crossAnywhere road.
Roughly one per lot frontage, so "cross wherever you like" is what it looks
like from overhead without stamping a sidewalk lane per metre. */
const PED_CROSS_SPACING=18;
/* Construction & vacancy — zoned (growable) buildings only, not services:
a lot doesn't pop straight to its new level — it spends a few days as a
visible site first. A lot that decays all the way to nothing sits vacant
for a while instead of vanishing, so if demand comes back it just reopens
(no build timer) instead of starting another site from scratch. Civic
buildings (police, fire, power, water, …) still go up instantly — they're
a deliberate placement, not something that grows in on its own. */
const BUILD_DAYS_BASE=4, BUILD_DAYS_PER_LEVEL=3, VACANT_DAYS=30;
const EDGE_MARGIN=0.9, BRIDGE_MULT=3;
/* ── grade separation ───────────────────────────────────────────────────────
Which roads a new road may fly OVER instead of meeting at grade. The set is
exactly the frontage-less through routes: they're the ones whose whole point
is not stopping, and the ones where an extra signalised crossing actually
hurts. Bridging a city street would just be an expensive intersection, so
the option isn't offered there.
A deck has to cover the road below at whatever angle the two actually meet —
square-on needs only that road's own width, a shallow meet needs a much
longer span — plus OVERPASS_PAD of level approach at each end so the ramp
isn't hanging in mid-air the instant it clears the pavement. */
const BRIDGE_OVER_TYPES=new Set(['highway','freeway']);
const OVERPASS_PAD=4;
/* How far past the deck's own edge the road underneath stops being drawn. The
gap is what sells one road as being above the other from straight overhead —
see computeOverpassGaps(). */
const UNDER_GAP_MARGIN=3.2;
/* Cap on how far a near-parallel crossing is allowed to stretch a deck: at a
true 0° there is no crossing to bridge, and without a floor on sin(θ) the
span runs away to infinity. ~15° is already an absurd angle to bridge at. */
const MIN_CROSS_SIN=0.26;
/* ── map boundary: the edge of buildable space ──────────────────────────────
Roads can't cross MAP_EDGE. A road that reaches the edge and dead-ends there
opens a new regional connection (more trade + visitors), the same as the
highway you start with. Its value is set by the chosen city size below. */
let MAP_EDGE=395;
function outsideMapEdge(p,slack){ const s=slack||0; return Math.abs(p.x)>MAP_EDGE+s || Math.abs(p.y)>MAP_EDGE+s; }
function atMapEdge(p){ return Math.abs(p.x)>=MAP_EDGE-7 || Math.abs(p.y)>=MAP_EDGE-7; }
function curveLeavesMap(curve){
for(let i=0;i<=16;i++){ if(outsideMapEdge(curve.pointAt(i/16),0.5)) return true; }
return false;
}
/* ── bank / debt tuning ─────────────────────────────────────────────────── */
const LOAN_MONTHLY_RATE=0.007; // interest charged each month on the balance
const LOAN_MAX=50000; // most the bank will lend in total
const FREEPLAY_DEBT=50000; // total debt (overdraft + loan) that offers Free Play
/* ── terrain: seeded value noise → hills + water, baked to a smooth texture ─
terrainLandBias/terrainMountainBias let the new-game screen tune the mix:
land bias shifts the whole elevation field up/down before the water cut,
mountain bias only stretches what's already above the shoreline, so the
two knobs don't fight each other. Default land bias leans away from water
a bit so freshly generated maps have room to build without so much bridging. */
let terrainSeed=(Math.random()*1e9)|0;
let terrainLandBias=0.08, terrainMountainBias=0;
function latticeRand(ix,iy){
let h=(ix*374761393+iy*668265263+terrainSeed*2654435761)|0;
h=(h^(h>>>13))>>>0; h=Math.imul(h,1274126177)>>>0; h=(h^(h>>>16))>>>0;
return h/4294967296;
}
function smoothstep(t){ return t*t*(3-2*t); }
function valueNoise(x,y,scale){
const sx=x/scale, sy=y/scale;
const ix=Math.floor(sx), iy=Math.floor(sy);
const fx=smoothstep(sx-ix), fy=smoothstep(sy-iy);
const v00=latticeRand(ix,iy), v10=latticeRand(ix+1,iy);
const v01=latticeRand(ix,iy+1), v11=latticeRand(ix+1,iy+1);
const a=v00+(v10-v00)*fx, b=v01+(v11-v01)*fx;
return a+(b-a)*fy;
}
function elevationRaw(x,y){
const octaves=[{scale:260,amp:1},{scale:110,amp:0.5},{scale:46,amp:0.25}];
let e=0,total=0;
for(const o of octaves){ e+=valueNoise(x,y,o.scale)*o.amp; total+=o.amp; }
e=e/total + terrainLandBias;
if(e>WATER_LEVEL) e=WATER_LEVEL+(e-WATER_LEVEL)*(1+terrainMountainBias);
return clamp(e,0,1.3);
}
const WATER_LEVEL=0.38, SHORE_LEVEL=0.41, HILL_LEVEL=0.6, PEAK_LEVEL=0.76;
const TERRAIN_RES=10;
/* Terrain color bands used to just switch at each *_LEVEL, which reads as a
visible ring wherever the elevation contour is smooth and gentle (a shallow
shoreline especially). These blend each band into the next across a small
elevation window straddling the threshold instead of snapping. */
const TERRAIN_BAND=0.015, TERRAIN_BAND_LAND=0.035;
function hexRGB(hex){ const n=parseInt(hex.slice(1),16); return [(n>>16)&255,(n>>8)&255,n&255]; }
function lerpRGB(a,b,t){ return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t]; }
function bandT(lo,hi,e){ return hi<=lo ? (e<lo?0:1) : smoothstep(clamp((e-lo)/(hi-lo),0,1)); }
/* map size is chosen on the intro screen; small is the default (and what old
saves used). MAP_EDGE always sits a little inside TERRAIN_SPAN. */
const CITY_SIZES={
small: {span:420, edge:395, tex:640, label:'Small', blurb:'a compact town'},
medium: {span:660, edge:625, tex:900, label:'Medium', blurb:'room for districts'},
large: {span:920, edge:880, tex:1180, label:'Large', blurb:'a sprawling metropolis'},
};
let citySize='small';
let TERRAIN_SPAN=CITY_SIZES.small.span, TEX_SIZE=CITY_SIZES.small.tex;
function applyCitySize(key){
const s=CITY_SIZES[key]||CITY_SIZES.small;
citySize = (key in CITY_SIZES) ? key : 'small';
TERRAIN_SPAN=s.span; MAP_EDGE=s.edge; TEX_SIZE=s.tex;
}
let terrainGrid=null, terrainCanvas=null;
function generateTerrain(){
const minX=-TERRAIN_SPAN, minY=-TERRAIN_SPAN;
const cols=Math.ceil((TERRAIN_SPAN*2)/TERRAIN_RES)+1, rows=cols;
const data=new Float32Array(cols*rows);
for(let j=0;j<rows;j++)for(let i=0;i<cols;i++) data[j*cols+i]=elevationRaw(minX+i*TERRAIN_RES,minY+j*TERRAIN_RES);
terrainGrid={res:TERRAIN_RES,minX,minY,cols,rows,data};
buildTerrainTexture();
buildTerrainDetailTexture();
}
function sampleTerrain(x,y){
const g=terrainGrid; if(!g) return 0.5;
const fx=(x-g.minX)/g.res, fy=(y-g.minY)/g.res;
const ix=clamp(Math.floor(fx),0,g.cols-2), iy=clamp(Math.floor(fy),0,g.rows-2);
const tx=clamp(fx-ix,0,1), ty=clamp(fy-iy,0,1);
const v00=g.data[iy*g.cols+ix], v10=g.data[iy*g.cols+ix+1];
const v01=g.data[(iy+1)*g.cols+ix], v11=g.data[(iy+1)*g.cols+ix+1];
const a=v00+(v10-v00)*tx, b=v01+(v11-v01)*tx;
return a+(b-a)*ty;
}
function isWater(x,y){ return sampleTerrain(x,y)<WATER_LEVEL; }
function waterFraction(curve){
const N=20; let wet=0;
for(let i=0;i<=N;i++){ const p=curve.pointAt(i/N); if(isWater(p.x,p.y)) wet++; }
return wet/(N+1);
}
/* Find where a curve crosses the shoreline, refined by bisection. Used to
split a new road into alternating land/bridge pieces. */
function findShoreCrossings(curve){
const N=60;
const samples=[];
for(let i=0;i<=N;i++){ const t=i/N, p=curve.pointAt(t); samples.push({t,water:isWater(p.x,p.y)}); }
const raw=[];
for(let i=0;i<N;i++){
if(samples[i].water!==samples[i+1].water){
let lo=samples[i].t, hi=samples[i+1].t, loWater=samples[i].water;
for(let iter=0;iter<16;iter++){
const mid=(lo+hi)/2, p=curve.pointAt(mid), w=isWater(p.x,p.y);
if(w===loWater) lo=mid; else hi=mid;
}
raw.push((lo+hi)/2);
}
}
const minGapT=3/Math.max(curve.length,1);
const filtered=[];
for(const t of raw){
if(t<minGapT||t>1-minGapT) continue;
if(filtered.length && t-filtered[filtered.length-1]<minGapT) continue;
filtered.push(t);
}
return filtered;
}
/* Alongside the color texture, a same-resolution alpha mask marking where the
fine-grain detail overlay (buildTerrainDetailTexture, drawn in
16-camera.js) is allowed to show through: opaque on grass/hill/peak land,
fully transparent over water and the sandy shore band in between. Built in
the same pixel loop as the color texture since both read the same
elevation sample — grass grain reads fine on land but is just wrong
texture on wet sand or open water. */
let terrainLandMask=null;
function buildTerrainTexture(){
const canvas=document.createElement('canvas');
canvas.width=TEX_SIZE; canvas.height=TEX_SIZE;
const tctx=canvas.getContext('2d');
const img=tctx.createImageData(TEX_SIZE,TEX_SIZE);
const maskCanvas=document.createElement('canvas');
maskCanvas.width=TEX_SIZE; maskCanvas.height=TEX_SIZE;
const mctx=/** @type {CanvasRenderingContext2D} */(maskCanvas.getContext('2d'));
const maskImg=mctx.createImageData(TEX_SIZE,TEX_SIZE);
const worldPerPixel=(TERRAIN_SPAN*2)/TEX_SIZE;
const eps=worldPerPixel*1.5;
for(let py=0;py<TEX_SIZE;py++){
const wy=-TERRAIN_SPAN+py*worldPerPixel;
for(let px=0;px<TEX_SIZE;px++){
const wx=-TERRAIN_SPAN+px*worldPerPixel;
const e=sampleTerrain(wx,wy);
const depth=clamp((WATER_LEVEL-e)*10,0,1);
const waterRGB=shadeRGB('#52a9cc',-Math.round(depth*46));
const gx=sampleTerrain(wx+eps,wy)-e, gy=sampleTerrain(wx,wy+eps)-e;
const light=clamp(0.5-(gx*0.6+gy*0.45)*9,0,1);
const tint=Math.round((light-0.5)*64);
let landRGB=lerpRGB(shadeRGB('#6a9e47',tint), shadeRGB('#8aa858',tint),
bandT(HILL_LEVEL-TERRAIN_BAND_LAND,HILL_LEVEL+TERRAIN_BAND_LAND,e));
landRGB=lerpRGB(landRGB, shadeRGB('#a8a189',tint),
bandT(PEAK_LEVEL-TERRAIN_BAND_LAND,PEAK_LEVEL+TERRAIN_BAND_LAND,e));
// crop-row farmland: a second, unrelated noise field (offset well away
// from the elevation one so the two don't correlate) picks out patches
// of flat lowland to read as tilled fields — thin alternating rows on
// a fixed world diagonal, the same idea a plowed field's furrows give,
// faded in at the patch edge instead of a hard mask boundary
const isLowland = e>SHORE_LEVEL+TERRAIN_BAND_LAND*2 && e<HILL_LEVEL-TERRAIN_BAND_LAND*2;
if(isLowland){
const farmSel=valueNoise(wx+8000,wy-6000,190);
if(farmSel>0.6){
const farmAmt=0.55*smoothstep(clamp((farmSel-0.6)/0.08,0,1));
const rows=Math.sin((wx*0.5+wy*0.85)/5.2)*26;
landRGB=[clamp(landRGB[0]+rows*farmAmt,0,255), clamp(landRGB[1]+rows*0.65*farmAmt,0,255), clamp(landRGB[2]+rows*0.35*farmAmt,0,255)];
landRGB=lerpRGB(landRGB, shadeRGB('#9c8a4a',tint), farmAmt*0.4);
}
}
let rgb=lerpRGB(waterRGB, hexRGB('#e6d6a4'),
bandT(WATER_LEVEL-TERRAIN_BAND,WATER_LEVEL+TERRAIN_BAND,e));
rgb=lerpRGB(rgb, landRGB, bandT(SHORE_LEVEL-TERRAIN_BAND,SHORE_LEVEL+TERRAIN_BAND,e));
rgb=[Math.round(rgb[0]),Math.round(rgb[1]),Math.round(rgb[2])];
const idx=(py*TEX_SIZE+px)*4;
img.data[idx]=rgb[0]; img.data[idx+1]=rgb[1]; img.data[idx+2]=rgb[2]; img.data[idx+3]=255;
const landAmt=bandT(SHORE_LEVEL,SHORE_LEVEL+TERRAIN_BAND_LAND,e);
maskImg.data[idx]=255; maskImg.data[idx+1]=255; maskImg.data[idx+2]=255;
maskImg.data[idx+3]=Math.round(landAmt*255);
}
}
tctx.putImageData(img,0,0);
terrainCanvas=canvas;
mctx.putImageData(maskImg,0,0);
terrainLandMask=maskCanvas;
}
/* The baked terrain texture above is one fixed-resolution bitmap stretched
to fill the screen — fine zoomed out, but it's just smoothed blur once
you're close enough that a single texel covers many screen pixels. This
is a small tileable speckle repeated in world space (see drawTerrainDetail
in 16-camera.js) so zooming in reveals grain instead of a bigger blur —
the same trick a detail/normal map gives a 3D ground plane, done as a
second multiply-blended layer since there's no shader stage here.
Per-pixel independent noise rather than the smoothed valueNoise() above:
it needs to tile seamlessly at a tiny size, and interpolated noise leaves
a visible seam wherever the tile wraps. */
let terrainDetailCanvas=null;
function buildTerrainDetailTexture(){
const px=64;
const canvas=document.createElement('canvas');
canvas.width=px; canvas.height=px;
const tctx=/** @type {CanvasRenderingContext2D} */(canvas.getContext('2d'));
const img=tctx.createImageData(px,px);
for(let y=0;y<px;y++){
for(let x=0;x<px;x++){
const g=Math.round((latticeRand(x*7,y*7)-0.5)*90);
const idx=(y*px+x)*4;
img.data[idx]=128+g; img.data[idx+1]=128+g; img.data[idx+2]=128+g; img.data[idx+3]=255;
}
}
tctx.putImageData(img,0,0);
terrainDetailCanvas=canvas;
}
/* ── zones & services ──────────────────────────────────────────────────── */
const Z={NONE:0,RES:1,COM:2,IND:3,OFF:4,PARK:5,POLICE:6,FIRE:7,CLINIC:8,SCHOOL:9,POWER:10,WATER:11,
ARMYBASE:12,PRISON:13,BANKHQ:14,INDUSTRIALHQ:15,ARCHITECTHOUSE:16,MALL:17,NATIONALPARK:18,CASINO:19,
RESERVOIR:20,SOLAR:21,WIND:22};
const ZONE_INFO={
[Z.RES] :{name:"Homes", color:"#4caf6d", cost:0},
[Z.COM] :{name:"Shops", color:"#3f8fd6", cost:0},
[Z.IND] :{name:"Industry", color:"#e0a325", cost:0},
[Z.OFF] :{name:"Offices", color:"#9182d8", cost:0},
[Z.PARK] :{name:"Park", color:"#79b85f", cost:90},
[Z.POLICE]:{name:"Police", color:"#5b7fbd", cost:900},
[Z.FIRE] :{name:"Fire dept", color:"#c0524a", cost:900},
[Z.CLINIC]:{name:"Clinic", color:"#d7dde2", cost:1200},
[Z.SCHOOL]:{name:"School", color:"#c9a05a", cost:1400},
[Z.POWER] :{name:"Coal power plant", color:"#7d7f86", cost:2600},
[Z.SOLAR] :{name:"Solar farm", color:"#e8c15a", cost:1500},
[Z.WIND] :{name:"Wind farm", color:"#8fb8c9", cost:2200},
[Z.WATER] :{name:"Water tower", color:"#4f93b8", cost:1100},
[Z.RESERVOIR]:{name:"Reservoir",color:"#2f6f9e", cost:2600},
[Z.ARMYBASE] :{name:"Army Base", color:"#5a6b3e", cost:0},
[Z.PRISON] :{name:"Federal Prison", color:"#6b6259", cost:0},
[Z.BANKHQ] :{name:"Bank HQ", color:"#2f7d5c", cost:0},
[Z.INDUSTRIALHQ] :{name:"Industrial Consortium", color:"#a85a2b", cost:0},
[Z.ARCHITECTHOUSE] :{name:"Architect's House", color:"#c98fb0", cost:0},
[Z.MALL] :{name:"Shopping Mall", color:"#4fa8d8", cost:0},
[Z.NATIONALPARK] :{name:"National Park", color:"#3f8f52", cost:0},
[Z.CASINO] :{name:"Casino", color:"#c9438f", cost:0},
};
const ZONE_CHIP_ORDER=[Z.RES,Z.COM,Z.IND,Z.OFF,Z.PARK,Z.POWER,Z.SOLAR,Z.WIND,Z.WATER,Z.RESERVOIR,Z.POLICE,Z.FIRE,Z.CLINIC,Z.SCHOOL];
/* the build menu is split three ways: growable zones, power & water utilities,
and the road-reach civic services (plus the park amenity) */
const ZONE_CHIPS=[Z.RES,Z.COM,Z.IND,Z.OFF];
const UTIL_CHIPS=[Z.POWER,Z.SOLAR,Z.WIND,Z.WATER,Z.RESERVOIR];
const SERVICE_CHIPS=[Z.POLICE,Z.FIRE,Z.CLINIC,Z.SCHOOL,Z.PARK];
/* special-partnership buildings — only ever shown once their deal has been
accepted (see PARTNERSHIPS / checkPartnerships below), and always free to
actually place since the partner is footing the bill. */
const SPECIAL_CHIPS=[Z.ARMYBASE,Z.PRISON,Z.BANKHQ,Z.INDUSTRIALHQ,Z.ARCHITECTHOUSE,Z.MALL,Z.NATIONALPARK,Z.CASINO];
const CIVIC_ICON={ [Z.POLICE]:'🚓',[Z.FIRE]:'🚒',[Z.CLINIC]:'🏥',[Z.SCHOOL]:'🏫',[Z.POWER]:'⚡',[Z.WATER]:'💧',
[Z.RESERVOIR]:'🌊',[Z.SOLAR]:'☀️',[Z.WIND]:'🌬️',
[Z.ARMYBASE]:'🪖',[Z.PRISON]:'⛓️',[Z.BANKHQ]:'🏦',[Z.INDUSTRIALHQ]:'🏗️',
[Z.ARCHITECTHOUSE]:'📐',[Z.MALL]:'🛍️',[Z.NATIONALPARK]:'🏞️',[Z.CASINO]:'🎰' };
const CIVIC_UPKEEP={ [Z.POLICE]:15,[Z.FIRE]:15,[Z.CLINIC]:18,[Z.SCHOOL]:22,[Z.POWER]:38,[Z.WATER]:20,
[Z.RESERVOIR]:65,[Z.SOLAR]:12,[Z.WIND]:26,
[Z.ARMYBASE]:420,[Z.PRISON]:380,[Z.CASINO]:260,[Z.MALL]:300,[Z.BANKHQ]:350,[Z.INDUSTRIALHQ]:400,
[Z.NATIONALPARK]:120,[Z.ARCHITECTHOUSE]:60 };
/* Every civic building is staffed, not just budgeted — it draws real jobs
out of the same workforce every other employer competes for. Run the
labor market hot (see S.civicStaffing in census) and services quietly
lose reach until you build more housing to hire from. */
const CIVIC_JOBS={ [Z.POLICE]:10,[Z.FIRE]:8,[Z.CLINIC]:12,[Z.SCHOOL]:18,[Z.POWER]:6,[Z.WATER]:4,
[Z.RESERVOIR]:10,[Z.SOLAR]:2,[Z.WIND]:4,
[Z.ARMYBASE]:34,[Z.PRISON]:40,[Z.CASINO]:26,[Z.MALL]:30,[Z.BANKHQ]:28,[Z.INDUSTRIALHQ]:32,
[Z.NATIONALPARK]:6 };
/* Police, fire, health and schools are physical response services — they reach
ALONG THE ROAD NETWORK (road distance, not a circle). Power and water are a
city-wide grid instead: see POWER_GEN / consumption below. */
const SERVICE_TYPES=[Z.POLICE,Z.FIRE,Z.CLINIC,Z.SCHOOL];
const SERVICE_RADIUS={ [Z.POLICE]:150,[Z.FIRE]:150,[Z.CLINIC]:140,[Z.SCHOOL]:170 };
/* ── power & water: a grid you budget, not a coverage circle ──────────────
Each plant/tower adds capacity; every building draws on it by zone & level
(plus a flat draw for civic buildings). Short the grid and nothing grows
past one storey and the economy takes a brownout hit. */
const POWER_GEN=4200, WATER_GEN=3400, WATER_GEN_HI=8500, CIVIC_POWER=24, CIVIC_WATER=15;
/* Solar is flat and weak wherever you put it — cheap, clean, no siting to think
about. Wind is the opposite trade: it can beat a coal plant, but only with a
coastline to catch, and trails off to barely-worth-it a couple hundred units
inland — so unlike solar it rewards reading the map before you place it. */
const SOLAR_GEN=1800;
const WIND_GEN_MIN=900, WIND_GEN_MAX=4000, WIND_COAST_RADIUS=140;
/**
* @param {Lot} l
*/
function windGenFor(l){
const wr=nearestWaterDist(l.c,WIND_COAST_RADIUS);
const coastBonus = wr===Infinity ? 0 : clamp(1-wr/WIND_COAST_RADIUS,0,1);
return Math.round(WIND_GEN_MIN + (WIND_GEN_MAX-WIND_GEN_MIN)*coastBonus);
}
const POWER_USE={ [Z.RES]:[0,7,20,44], [Z.COM]:[0,10,28,58], [Z.IND]:[0,18,48,100], [Z.OFF]:[0,12,32,66] };
const WATER_USE={ [Z.RES]:[0,6,17,38], [Z.COM]:[0,7,19,40], [Z.IND]:[0,14,38,80], [Z.OFF]:[0,8,20,42] };
const INSTANT_ZONES=new Set([Z.PARK,Z.POLICE,Z.FIRE,Z.CLINIC,Z.SCHOOL,Z.POWER,Z.WATER,Z.RESERVOIR,Z.SOLAR,Z.WIND,
Z.ARMYBASE,Z.PRISON,Z.BANKHQ,Z.INDUSTRIALHQ,Z.ARCHITECTHOUSE,Z.MALL,Z.NATIONALPARK,Z.CASINO]);
/* how much each special building pays out per month, straight off the top —
these are the whole point of taking the deal in the first place */
const SPECIAL_INCOME={ [Z.ARMYBASE]:900, [Z.PRISON]:800, [Z.BANKHQ]:2200, [Z.INDUSTRIALHQ]:1900,
[Z.ARCHITECTHOUSE]:500, [Z.MALL]:1700, [Z.NATIONALPARK]:700, [Z.CASINO]:1500 };
const GROWABLE=new Set([Z.RES,Z.COM,Z.IND,Z.OFF]);
const FREE_ZONES=new Set([Z.RES,Z.COM,Z.IND,Z.OFF]);
const RES_POP =[0,6,18,45];
const COM_JOB =[0,7,20,42];
const COM_RETAIL=[0,16,44,96];
const IND_JOB =[0,10,26,52];
const IND_GOODS =[0,20,52,112];
const OFF_JOB =[0,8,22,46];
const RES_CONSUMPTION=0.35;
const VISITOR_SPEND=0.9;
const OFFICE_RATIO=0.24;
/* ── zone specialties ──────────────────────────────────────────────────────
Once a growable lot develops (and again each time it climbs another storey)
it leans into a specialty based on what's actually around it — l.sub holds
the choice. Purely cosmetic/flavour for offices; RES/COM/IND get real
knock-on effects (see census/computeLandValues/growth). */
const SUB_INFO={
[Z.RES]:{ single:{name:'Single-family homes', icon:'🏠'}, apt:{name:'Apartments', icon:'🏘️'} },
[Z.COM]:{ grocery:{name:'Grocery', icon:'🛒'}, commodity:{name:'General goods', icon:'📦'},
tourism:{name:'Tourism', icon:'🎡'}, nightlife:{name:'Nightlife', icon:'🌃'} },
[Z.IND]:{ standard:{name:'Heavy industry', icon:'🏭'}, hightech:{name:'High-tech', icon:'💻'} },
};
const SUB_LABEL={
[Z.RES]:{ single:'a home', apt:'an apartment' },
[Z.COM]:{ grocery:'a grocery store', commodity:'a general store', tourism:'a tourist shop', nightlife:'a nightclub' },
[Z.IND]:{ standard:'a factory', hightech:'a tech plant' },
};
/* a light hue shift per specialty so blocks read differently at a glance */
const SUB_TINT={
[Z.RES]:{ single:"#4caf6d", apt:"#3fae8f" },
[Z.COM]:{ grocery:"#3f8fd6", commodity:"#5f8fc2", tourism:"#3fb8c9", nightlife:"#a262d6" },
[Z.IND]:{ standard:"#e0a325", hightech:"#5aa8c9" },
};
function weightedPick(weights){
let total=0; for(const k in weights) total+=Math.max(0,weights[k]);
if(total<=0) return Object.keys(weights)[0];
let r=Math.random()*total;
for(const k in weights){ r-=Math.max(0,weights[k]); if(r<=0) return k; }
return Object.keys(weights)[0];
}
/* how many developed lots near l pass filterFn, within rad world units */
function nearbyCount(l,filterFn,rad){
let n=0;
for(const o of lots){ if(o===l||o.level<=0) continue; if(!filterFn(o)) continue; if(dist(o.c,l.c)<rad) n++; }
return n;
}
/* Called whenever a growable lot first develops, and again every time it
climbs a storey — so a block can drift toward a different specialty as its
surroundings change. */
/**
* @param {Lot} l
*/
function assignSpecialty(l){
if(l.zone===Z.RES){
// single-family meets demand on its own; a lot of nearby general-goods
// retail pulls it toward denser apartment living instead.
const commodityNear=nearbyCount(l,o=>o.zone===Z.COM&&o.sub==='commodity',70);
const aptChance=clamp(commodityNear*0.22,0,0.85);
l.sub = Math.random()<aptChance ? 'apt' : 'single';
} else if(l.zone===Z.COM){
const wr=nearestWaterDist(l.c,90);
const tourismScore = wr===Infinity ? 0 : clamp(1-wr/90,0,1);
const apts=nearbyCount(l,o=>o.zone===Z.RES&&o.sub==='apt',70);
const nightlifeScore=clamp(apts*0.18,0,1);
const groceryNear=nearbyCount(l,o=>o.zone===Z.COM&&o.sub==='grocery',80);
const commodityNear=nearbyCount(l,o=>o.zone===Z.COM&&o.sub==='commodity',80);
// grocery/commodity are the fallback trades — they cluster on themselves and
// dominate when nothing more specialized (water, apartments) is pulling
const w={ grocery:1.1+groceryNear*0.9, commodity:1.0+commodityNear*0.8,
tourism:tourismScore*2.8, nightlife:nightlifeScore*2.4 };
l.sub=weightedPick(w);
} else if(l.zone===Z.IND){
// an educated workforce (schools reaching residents) pulls industry
// toward clean high-tech plants instead of belching heavy industry
const hightechChance=clamp((S.education||0)*0.9-0.08,0,0.85);
l.sub = Math.random()<hightechChance ? 'hightech' : 'standard';
}
}
/* ── intersection control ──────────────────────────────────────────────── */
const CONTROL={NONE:0,YIELD:1,STOP:2,SIGNAL:3,ROUNDABOUT:4};
const CONTROL_INFO={
[CONTROL.NONE] :{name:'Priority', cost:0, icon:'●'},
[CONTROL.YIELD] :{name:'Yield', cost:90, icon:'▽'},
[CONTROL.STOP] :{name:'Stop sign', cost:150, icon:'🛑'},
[CONTROL.SIGNAL] :{name:'Traffic light', cost:900, icon:'🚦'},
[CONTROL.ROUNDABOUT]:{name:'Roundabout', cost:2200, icon:'↺'},
};
function controlKindFor(c){
if(c===CONTROL.YIELD)return'yield'; if(c===CONTROL.STOP)return'stop';
if(c===CONTROL.SIGNAL)return'signal'; return null;
}
/* ── network state ──────────────────────────────────────────────────────── */
let nid=1;
/** @type {RoadNetwork} */
const net={nodes:new Map(),segments:new Map(),lanes:new Map(),links:new Map()};
/** @type {Lot[]} */
let lots=[];
/** @type {Agent[]} */
const agents=[];
const CAR_COLORS=["#d9dde2","#8fa8c4","#c98f7a","#7fa98c","#c4b07a","#9b8fb5"];
const PED_COLORS=["#d8c39a","#9ab8d8","#d89ab0","#a0d8b0","#c9b8e0"];
const VISIT_COLORS=["#e8c98a","#e0a89a","#d8b9e0","#f0dcc0"];
const FREIGHT_COLOR="#6d7480";
const S={
cash:40000, pop:0, comJobs:0, indJobs:0, offJobs:0, tax:9, cityName:'',
demand:{r:.4,c:.25,i:.35,o:0}, speed:0.25, running:false,
income:0, upkeep:0, day:0, simTime:0,
visitors:0, goodsProd:0, retailCap:0, consumption:0, retailSold:0,
exportDemand:0, exported:0, connections:0, unmetGoods:0,
loan:0, loanInterestMo:0, freePlay:false,
overlay:null, landValue:0, unpoweredLots:0,
homes:0, jobs:0, jobRatio:1, overemployment:0, // citizen needs
crime:0, crimeLoss:0, // crime rate
congestion:0, stuckRoads:0, // traffic flow
powerGen:0, powerDemand:0, waterGen:0, waterDemand:0, // the grid
powerRatio:1, waterRatio:1, powerOK:true, waterOK:true, gridShort:0, gridLoss:0,
partnerships:{}, // id -> 'accepted' | 'declined' | 'built', each partner only ever asks once
grants:{}, // id -> what that partner actually paid, so a later clawback bills the real figure
happyStreak:0, // consecutive days above the National Park happiness bar
};
let overlayMenuOpen=true;
/* ── topology edits ─────────────────────────────────────────────────────── */
function createNode(pos,opts){
const id=nid++;
net.nodes.set(id,Object.assign({id,position:{x:pos.x,y:pos.y},segments:[],kind:"end",radius:0,
links:[],control:CONTROL.NONE,poly:null,ringLanes:null,outside:false},opts||{}));
return id;
}
/**
* @param {NetNode} n
*/
function nodeKind(n){ return n.segments.length>=3?"intersection":n.segments.length===2?"joint":"end"; }
function addSegment(aId,bId,p1,p2,typeId,isBridge){
const a=net.nodes.get(aId),b=net.nodes.get(bId);
if(!a||!b||aId===bId) return null;
const curve=new Bez(a.position,{x:p1.x,y:p1.y},{x:p2.x,y:p2.y},b.position);
const id=nid++;
net.segments.set(id,{
id,startNode:aId,endNode:bId,typeId,
startDir:norm(sub(p1,a.position)), startHandle:Math.max(1,dist(p1,a.position)),
endDir:norm(sub(p2,b.position)), endHandle:Math.max(1,dist(p2,b.position)),
curve,startTrim:0,endTrim:0,lanes:[], isBridge:!!isBridge,
});
a.segments.push(id); b.segments.push(id);
a.kind=nodeKind(a); b.kind=nodeKind(b);
return id;
}
/**
* @param {Segment} seg
*/
function rebuildSegmentCurve(seg){
const a=net.nodes.get(seg.startNode).position, b=net.nodes.get(seg.endNode).position;
seg.curve=new Bez(a,add(a,mul(seg.startDir,seg.startHandle)),
add(b,mul(seg.endDir,seg.endHandle)),b);
}
function splitSegment(segId,t){
const seg=net.segments.get(segId); if(!seg) return null;
t=clamp(t,0.02,0.98);
const [c1,c2]=seg.curve.splitAt(t);
const midId=createNode(seg.curve.pointAt(t));
const a=net.nodes.get(seg.startNode), b=net.nodes.get(seg.endNode);
a.segments=a.segments.filter(s=>s!==segId);
b.segments=b.segments.filter(s=>s!==segId);
for(const l of seg.lanes) net.lanes.delete(l);
net.segments.delete(segId);
const mk=(sn,en,c)=>{
const id=nid++;
net.segments.set(id,{id,startNode:sn,endNode:en,typeId:seg.typeId,
startDir:norm(sub(c.p1,c.p0)), startHandle:Math.max(1,dist(c.p1,c.p0)),
endDir:norm(sub(c.p2,c.p3)), endHandle:Math.max(1,dist(c.p2,c.p3)),
curve:c,startTrim:0,endTrim:0,lanes:[], isBridge:seg.isBridge||false});
return id;
};
const s1=mk(seg.startNode,midId,c1), s2=mk(midId,seg.endNode,c2);
a.segments.push(s1); b.segments.push(s2);
const mid=net.nodes.get(midId); mid.segments.push(s1,s2); mid.kind="joint";
a.kind=nodeKind(a); b.kind=nodeKind(b);
return midId;
}
function removeSegment(id){
const seg=net.segments.get(id); if(!seg) return;
const a=net.nodes.get(seg.startNode), b=net.nodes.get(seg.endNode);
if(a){ a.segments=a.segments.filter(s=>s!==id); a.kind=nodeKind(a); }
if(b){ b.segments=b.segments.filter(s=>s!==id); b.kind=nodeKind(b); }
for(const l of seg.lanes) net.lanes.delete(l);
net.segments.delete(id);
for(const [k,v] of [...net.nodes]) if(v.segments.length===0 && !v.outside) net.nodes.delete(k);
}
function deleteNode(id){
const n=net.nodes.get(id); if(!n) return;
if(n.outside){ toast("The regional highway connection can't be removed.","warn"); return; }
for(const sid of [...n.segments]) removeSegment(sid);
net.nodes.delete(id);
}
function mergeNodes(keepId,mergeId){
if(keepId===mergeId) return;
const keep=net.nodes.get(keepId), merge=net.nodes.get(mergeId);
if(!keep||!merge) return;
for(const sid of [...merge.segments]){
const seg=net.segments.get(sid); if(!seg) continue;
if(seg.startNode===mergeId) seg.startNode=keepId;
if(seg.endNode===mergeId) seg.endNode=keepId;
if(!keep.segments.includes(sid)) keep.segments.push(sid);
}
keep.kind=nodeKind(keep);
net.nodes.delete(mergeId);
}
/* Re-anchor one end of a segment onto a junction node WITHOUT changing the
direction the road is already heading — it keeps the tangent it had and just
pins the endpoint and shortens the handle. This is what stops the "one half
flips" distortion when a road is split by a crossing. */
/**
* @param {Segment} seg
*/
function straightenEnd(seg,nodeId){
if(!seg) return;
const L=seg.curve.length, h=clamp(L*0.32,2,42);
if(seg.startNode===nodeId){
seg.startDir=norm(seg.curve.tangentAt(0.03));
seg.startHandle=h;
}
if(seg.endNode===nodeId){
seg.endDir=norm(mul(seg.curve.tangentAt(0.97),-1));
seg.endHandle=h;
}
rebuildSegmentCurve(seg);
}
/* Find every real crossing of two DIFFERENT roads and turn it into a shared
junction node sitting exactly on the intersection point. One crossing per
pass; the outer loop keeps going until the network is clean.
A bridge deck is exempt, over water or over a road alike: it isn't touching
whatever it passes over, so there is nothing there to join. That exemption
is the whole mechanism behind grade separation — the deck simply never
becomes a candidate here, which is why no junction appears under it. */
function resolveAllCrossings(){
let resolvedCount=0;
for(let pass=0; pass<500; pass++){
let found=null;
const ids=[...net.segments.keys()];
for(let i=0;i<ids.length && !found;i++){
const A=net.segments.get(ids[i]); if(!A||A.isBridge) continue;
for(let j=i+1;j<ids.length;j++){
const B=net.segments.get(ids[j]); if(!B||B.isBridge) continue;
if(A.startNode===B.startNode||A.startNode===B.endNode||
A.endNode===B.startNode||A.endNode===B.endNode) continue;
for(const cr of curveCrossings(A.curve,B.curve,30)){
if(cr.tA<0.04||cr.tA>0.96||cr.tB<0.04||cr.tB>0.96) continue;
found={aId:A.id,bId:B.id,cr}; break;
}
if(found) break;
}
}
if(!found) break;
const P={x:found.cr.point.x, y:found.cr.point.y};
const midA=splitSegment(found.aId, found.cr.tA);
if(midA==null) continue;
const midB=splitSegment(found.bId, found.cr.tB);
if(midB==null) continue;
net.nodes.get(midA).position={x:P.x,y:P.y};
net.nodes.get(midB).position={x:P.x,y:P.y};
if(midA!==midB) mergeNodes(midA,midB); // keep midA
const node=net.nodes.get(midA);
if(node) for(const sid of [...node.segments]) straightenEnd(net.segments.get(sid), midA);
resolvedCount++;
}
return resolvedCount;
}
/* ── intersection trimming ──────────────────────────────────────────────── */
/**
* @param {Segment} seg
*/
function segDirAt(seg,nodeId){ return seg.startNode===nodeId?seg.startDir:seg.endDir; }
/* A roundabout gets one ring lane per direction for the busiest connected
road, so it doesn't bottleneck an avenue down to a single-file circle —
but kept tight to that road's own width rather than padded out. Roads
stop SLIP_GAP short of the ring's own outer edge; the gap is bridged by a
separate curved slip-lane ribbon (see drawRoundaboutSlips) that tapers
from the road's own width down to a single lane, tangent to the ring —
so the ring's true radius never has to depend on how far the roads are
trimmed back (no circular dependency between the two). */
const RING_TRACK_W=3.0, RING_MARGIN=3.6, RING_ISLAND_GAP=1.2, SLIP_GAP=5.5;
/**
* @param {NetNode} node
*/
function ringLaneCount(node){
let m=1;
for(const sid of node.segments){
const type=ROAD_TYPE_BY_ID[net.segments.get(sid).typeId];
let pos=0,neg=0;
for(const l of type.lanes){ if(l.dir>0) pos++; else neg++; }
m=Math.max(m,pos,neg);
}
return clamp(m,1,3);
}
/**
* @param {NetNode} node
*/
function roundaboutRingOuter(node){
const rc=ringLaneCount(node);
const HW=Math.max(...node.segments.map(sid=>ROAD_TYPE_BY_ID[net.segments.get(sid).typeId].width/2));
// a ring only a hair wider than the road itself reads as an afterthought —
// give it real presence over the roads feeding it, not just enough radius
// to technically fit the lanes.
return Math.max(rc*RING_TRACK_W+RING_MARGIN, HW*1.7+RING_MARGIN);
}
function roundaboutGeom(node){
const rc=ringLaneCount(node);
const outer=roundaboutRingOuter(node);
const radii=[]; for(let k=0;k<rc;k++) radii.push(outer-RING_TRACK_W/2-k*RING_TRACK_W);
const island=Math.max(2, radii[rc-1]-RING_TRACK_W/2-RING_ISLAND_GAP);
return {rc,outer,radii,island};
}
/* How far back each road gets trimmed at a junction — and so how big the
junction itself is. Geometrically, two road edges meeting at angle θ need
a trim of about halfWidth/tan(θ/2) to clear each other; at a plain 90°
crossing that's exactly halfWidth, i.e. the junction's radius is just
max(connected road width)/2, same as the road itself. Only sharp, shallow
angles push it wider, and even then it's capped well short of a giant blob. */
/**
* @param {NetNode} node
*/
function solveTrims(node){
for(const id of node.segments){
const s=net.segments.get(id);
if(s.startNode===node.id) s.startTrim=0; else s.endTrim=0;
}
node.radius=0;
if(node.segments.length<2) return;
if(node.segments.length===2){
const [s0,s1]=node.segments.map(i=>net.segments.get(i));
if(s0.typeId===s1.typeId){
let d=Math.abs(ang(segDirAt(s0,node.id))-ang(segDirAt(s1,node.id)));
if(d>Math.PI)d=2*Math.PI-d;
// only a genuinely straight pass-through skips the junction fillet —
// 0.92π (~166°) used to let a real ~14° kink through untreated, which
// is exactly what left a visible crease in the pavement where two
// hand-authored curves (e.g. the seeded highway) didn't quite share a
// tangent. A small kink still gets a (small) trim + fillet below.
if(d>Math.PI*0.985) return;
}
}
const HW=Math.max(...node.segments.map(sid=>ROAD_TYPE_BY_ID[net.segments.get(sid).typeId].width/2));
const isRoundabout=node.control===CONTROL.ROUNDABOUT && node.segments.length>=3;
const sorted=[...node.segments].sort((x,y)=>
ang(segDirAt(net.segments.get(x),node.id))-ang(segDirAt(net.segments.get(y),node.id)));
let maxTrim=0;
// A roundabout is exactly the tool for resolving a tight angle between two
// roads — its ring geometry (sized below by track count, not by spoke
// angle) already handles that gracefully, so the escalating tan-based trim
// below must not run for one: it would fight the ring size for no reason
// and blow the whole junction out into a tangled mess. Plain hub-and-fillet
// junctions still need it to actually clear tight-but-legal angles.
if(!isRoundabout) for(let i=0;i<sorted.length;i++){
const A=net.segments.get(sorted[i]), B=net.segments.get(sorted[(i+1)%sorted.length]);
let spread=ang(segDirAt(B,node.id))-ang(segDirAt(A,node.id));
while(spread<0) spread+=2*Math.PI;
// MIN_JUNCTION_ANGLE keeps anything built or reshaped from landing below a
// 15° spread, so the true minimum half-angle here is ~7.5° — the floor
// below is just divide-by-zero protection for old save data from before
// that validation existed, not a deliberate cap on how tight this gets.
// Letting tan(half) actually shrink for a tight-but-legal angle (instead
// of flooring half up, which used to shrink the trim exactly when it
// needed to grow) is what makes the junction expand to actually clear
// the road edges instead of leaving them visually overlapping.
const half=Math.max(spread/2,0.09);
maxTrim=Math.max(maxTrim, Math.min(HW*10, HW/Math.max(0.09,Math.tan(Math.min(half,1.4)))+0.5));
}
if(isRoundabout){
// roads stop short of the ring's own edge by SLIP_GAP, leaving room for
// the curved slip-lane pavement that bridges the two
maxTrim=Math.max(maxTrim, roundaboutRingOuter(node)+SLIP_GAP);
}
node.radius=maxTrim;
for(const id of node.segments){
const s=net.segments.get(id);
const t=Math.min(maxTrim, s.curve.length*0.42);
if(s.startNode===node.id) s.startTrim=t; else s.endTrim=t;
}
}
/**
* @param {NetNode} node
*/
function junctionPolygon(node){
const pts=[];
for(const sid of node.segments){
const seg=net.segments.get(sid); if(!seg) continue;
const type=ROAD_TYPE_BY_ID[seg.typeId];
const atStart=seg.startNode===node.id;
const L=seg.curve.length;
const trimDist=atStart?seg.startTrim:seg.endTrim;
const hw=type.width/2+EDGE_MARGIN;
// a single cross-section right at the trim point assumes the curve is
// basically straight from there to the node — usually true, but a road
// that bows or hooks sharply in that short stretch can have its true
// edge duck inside the straight hull chord between two roads, leaving
// an unpaved notch at a steep merge. Sampling several points from the
// node OUT TO the trim (never past it — that's exactly where the
// crosswalk sits, and where the fill has to stop) catches that bow
// without ever extending the fill beyond the crosswalk.
const steps=3;
for(let k=0;k<=steps;k++){
const d=trimDist*(k/steps);
const t=atStart? seg.curve.tAtDist(d) : seg.curve.tAtDist(Math.max(0,L-d));
pts.push(seg.curve.offsetAt(t,hw));
pts.push(seg.curve.offsetAt(t,-hw));
}
}
if(pts.length<3) return null;
const hull=convexHull(pts);
return hull.length>=3 ? hull : null;
}
/* ── lane stamping ──────────────────────────────────────────────────────── */
/**
* @param {Segment} seg
*/
function stampLanes(seg){
for(const l of seg.lanes) net.lanes.delete(l);
seg.lanes=[];
const type=ROAD_TYPE_BY_ID[seg.typeId];
const L=seg.curve.length;
const sT=seg.curve.tAtDist(seg.startTrim);
const eT=seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
if(eT<=sT+1e-4) return;
const mk=(offset,dir,speed,mode,t0,t1)=>{
const a=t0??sT, b=t1??eT;
const id=nid++;
const entryT=dir>0?a:b, exitT=dir>0?b:a;
net.lanes.set(id,{
id,segment:seg.id,offset,dir,speed,mode,
startT:a,endT:b,entryT,exitT,length:(b-a)*L,
entryPos:seg.curve.offsetAt(entryT,offset),
exitPos:seg.curve.offsetAt(exitT,offset),
entryTan:mul(seg.curve.tangentAt(entryT),dir),
exitTan:mul(seg.curve.tangentAt(exitT),dir),
out:[],inc:[],load:0,
});
seg.lanes.push(id);
};
for(const tpl of type.lanes) mk(tpl.offset,tpl.dir,tpl.speed,'car');
if(type.sidewalk){
const hw=type.width/2+1.4;
// a crossAnywhere road's sidewalks are stamped in sections rather than as
// one lane end to end; buildWalkLinks() then makes every seam between two
// sections a place to step off the kerb. Every other road gets one lane a
// side, so crossing it still means walking to a junction.
// at least two sections, however short the segment: a small street is
// crossable in the middle even when it's one arc of a ring road barely a
// lot wide, and rounding that down to a single section would mean the only
// way across was the junction at either end — the exact walk the type
// exists to avoid
const walkable=seg.curve.length-seg.startTrim-seg.endTrim;
const cuts=type.crossAnywhere ? clamp(Math.round(walkable/PED_CROSS_SPACING),2,8) : 1;
for(let i=0;i<cuts;i++){
const a=sT+(eT-sT)*(i/cuts), b=sT+(eT-sT)*((i+1)/cuts);
for(const off of [-hw,hw]){ mk(off,1,1.5,'ped',a,b); mk(off,-1,1.5,'ped',a,b); }
}
}
}
/* Mid-block pedestrian crossings on a crossAnywhere road. stampLanes() has
already cut its sidewalks into sections; each interior seam is a point where
all four sidewalk lanes meet, so wiring every arrival there to every
departure going the same way gives "carry on" and "cross over" as equal
options. Nothing is drawn and no junction node exists — from the sim's side
a small street is simply permeable to people and not to cars.
Called after net.links.clear() in rebuildAll, since these links live on the
segment rather than on any node.
* @param {Segment} seg
*/
function buildWalkLinks(seg){
const type=ROAD_TYPE_BY_ID[seg.typeId];
if(!type.crossAnywhere) return;
/** @type {Lane[]} */
const peds=[];
for(const lid of seg.lanes){ const l=net.lanes.get(lid); if(l&&l.mode==='ped') peds.push(l); }
if(!peds.length) return;
// interior seams only — the two outer boundaries are the junctions at either
// end, where buildLinks() has already connected the sidewalks
const bounds=[...new Set(peds.map(l=>l.startT).concat(peds.map(l=>l.endT)))].sort((a,b)=>a-b);
for(const t of bounds.slice(1,-1)){
const inc=peds.filter(l=>l.exitT===t), out=peds.filter(l=>l.entryT===t);
for(const from of inc) for(const to of out){
// same kerb: carry straight on, never a U-turn on the spot. Crossing to
// the far kerb may double back, and that is the move the whole road type
// exists for — the door you want is as often behind you as ahead of you.
if(to.offset===from.offset && to.dir!==from.dir) continue;
const curve=new Bez(from.exitPos,from.exitPos,to.entryPos,to.entryPos);
const id=nid++;
net.links.set(id,{id,node:null,from:from.id,to:to.id,curve,
length:Math.max(0.05,curve.length),controlKind:null,phaseGroup:null});
from.out.push(id); to.inc.push(id);
}
}
}
/* ── connectors through junctions ───────────────────────────────────────── */
/**
* @param {NetNode} node
*/
function connectPairs(inbound,outbound,node,deadEnd,kind){
let phaseOf=null;
if(kind==='signal'){
const spokes=[...node.segments].sort((a,b)=>
ang(segDirAt(net.segments.get(a),node.id))-ang(segDirAt(net.segments.get(b),node.id)));
const idx=new Map(spokes.map((s,i)=>[s,i%2]));
phaseOf=seg=>idx.get(seg)??0;
}
for(const from of inbound) for(const to of outbound){
if(to.segment===from.segment && !deadEnd) continue;
const d=Math.max(2.5,dist(from.exitPos,to.entryPos)*0.42);
const curve=new Bez(from.exitPos, add(from.exitPos,mul(from.exitTan,d)),
sub(to.entryPos,mul(to.entryTan,d)), to.entryPos);
const id=nid++;
net.links.set(id,{id,node:node.id,from:from.id,to:to.id,curve,length:curve.length,
controlKind:kind, phaseGroup: kind==='signal'? phaseOf(from.segment): null});
from.out.push(id); to.inc.push(id); node.links.push(id);
}
}
/* Each ring circulates counterclockwise as rendered (north-up), matching the
right-hand-traffic convention implied by our lane offsets: heading east,
the +offset lane sits to the south. A ring lane i therefore runs FROM spoke
i BACKWARD to spoke i-1; entries merge onto the lane that starts at their
spoke, exits are sourced from the lane that ends at their spoke.
A roundabout can have more than one concentric ring "track" — see
roundaboutGeom(): entering traffic is spread across tracks by how many
lanes its own road has in that direction, and every track can feed every
exit, so a busy road doesn't get bottled into one shared lane. */
/**
* @param {NetNode} node
*/
function buildRoundabout(node,carIn,carOut){
const segIds=[...node.segments];
const angleOfSeg=new Map(segIds.map(sid=>[sid, ang(segDirAt(net.segments.get(sid),node.id))]));
const spokes=segIds.slice().sort((a,b)=>angleOfSeg.get(a)-angleOfSeg.get(b));
const n=spokes.length;
const {rc,radii}=roundaboutGeom(node);
const mergePoint=(a,R)=>({x:node.position.x+R*Math.cos(a), y:node.position.y+R*Math.sin(a)});
const ringTan=a=>({x:Math.sin(a),y:-Math.cos(a)});
const tracks=[];
for(let k=0;k<rc;k++){
const R=radii[k];
const ringLaneIds=[];
for(let i=0;i<n;i++){
const a0=angleOfSeg.get(spokes[i]);
let a1=angleOfSeg.get(spokes[(i-1+n)%n]);
while(a1>=a0) a1-=2*Math.PI;
const theta=a0-a1, kk=4/3*Math.tan(theta/4);
const p0=mergePoint(a0,R), p3=mergePoint(a1,R);
const t0=ringTan(a0), t3=ringTan(a1);
const p1=add(p0,mul(t0,R*kk)), p2=sub(p3,mul(t3,R*kk));
const curve=new Bez(p0,p1,p2,p3);
const id=nid++;
net.lanes.set(id,{id,kind:'ring',segment:null,offset:0,dir:1,mode:'car',speed:Math.max(6,10-k*1.2),
curve,length:curve.length,out:[],inc:[],load:0});
ringLaneIds.push(id);
}
for(let i=0;i<n;i++){
const cur=net.lanes.get(ringLaneIds[i]);
const nxt=net.lanes.get(ringLaneIds[(i-1+n)%n]);
const curve=new Bez(cur.curve.p3,cur.curve.p3,nxt.curve.p0,nxt.curve.p0);
const id=nid++;
net.links.set(id,{id,node:node.id,from:cur.id,to:nxt.id,curve,length:Math.max(0.05,curve.length),
controlKind:'ringChain',phaseGroup:null});
cur.out.push(id); nxt.inc.push(id); node.links.push(id);
}
tracks.push(ringLaneIds);
}
node.ringLanes=tracks.flat();
node.ringTracks=tracks;
for(let i=0;i<n;i++){
const seg=spokes[i], a0=angleOfSeg.get(seg), tan=ringTan(a0);
// spread this road's own lanes across ring tracks so a multi-lane road
// doesn't have to funnel through a single entry point
const inLanesHere=carIn.filter(l=>l.segment===seg).sort((x,y)=>Math.abs(x.offset)-Math.abs(y.offset));
inLanesHere.forEach((lane,j)=>{
const ringIn=net.lanes.get(tracks[j%rc][i]);
const d=Math.max(2.5,dist(lane.exitPos,ringIn.curve.p0)*0.5);
const curve=new Bez(lane.exitPos, add(lane.exitPos,mul(lane.exitTan,d)),
sub(ringIn.curve.p0,mul(tan,d)), ringIn.curve.p0);
const id=nid++;
net.links.set(id,{id,node:node.id,from:lane.id,to:ringIn.id,curve,length:curve.length,
controlKind:'roundEnter',phaseGroup:null});
lane.out.push(id); ringIn.inc.push(id); node.links.push(id);
});
// any track can feed any exit lane at this spoke
const outLanesHere=carOut.filter(l=>l.segment===seg);
for(let k=0;k<rc;k++){
const ringOutFrom=net.lanes.get(tracks[k][(i+1)%n]);
for(const lane of outLanesHere){
const d=Math.max(2.5,dist(ringOutFrom.curve.p3,lane.entryPos)*0.5);
const curve=new Bez(ringOutFrom.curve.p3, add(ringOutFrom.curve.p3,mul(tan,d)),
sub(lane.entryPos,mul(lane.entryTan,d)), lane.entryPos);
const id=nid++;
net.links.set(id,{id,node:node.id,from:ringOutFrom.id,to:lane.id,curve,length:curve.length,
controlKind:'roundExit',phaseGroup:null});
ringOutFrom.out.push(id); lane.inc.push(id); node.links.push(id);
}
}
}
}
/**
* @param {NetNode} node
*/
function buildLinks(node){
node.links=[]; node.ringLanes=null; node.ringTracks=null; node.pedRingLanes=null;
const carIn=[],carOut=[],pedIn=[],pedOut=[];
for(const sid of node.segments){
const seg=net.segments.get(sid);
const sT=seg.curve.tAtDist(seg.startTrim), eT=seg.curve.tAtDist(Math.max(seg.startTrim,seg.curve.length-seg.endTrim));
for(const lid of seg.lanes){
const lane=net.lanes.get(lid);
// a crossAnywhere road's interior cuts don't reach either junction — only
// the outermost cut's lanes actually touch sT/eT, so only those belong to
// a junction connector here; buildWalkLinks() wires the rest mid-block
const touchesNode = seg.startNode===node.id ? lane.startT<=sT+1e-6 : lane.endT>=eT-1e-6;
if(!touchesNode) continue;
const exitsHere=(seg.startNode===node.id&&lane.dir<0)||(seg.endNode===node.id&&lane.dir>0);
if(lane.mode==='ped') (exitsHere?pedIn:pedOut).push(lane);
else (exitsHere?carIn:carOut).push(lane);
}
}
const deadEnd=node.segments.length===1;
if(node.control===CONTROL.ROUNDABOUT && node.segments.length>=3){
buildRoundabout(node,carIn,carOut);
buildRoundaboutPeds(node,pedIn,pedOut);
} else {
connectPairs(pedIn,pedOut,node,deadEnd,null);
connectPairs(carIn,carOut,node,deadEnd,controlKindFor(node.control));
}
}
/* A roundabout's whole point is that nothing cuts through its middle — cars
ride the paved ring (buildRoundabout above) and pedestrians walk the
sidewalk ring that drawSidewalkFillets() already draws around the outside,
crossing at a spoke only where its sidewalk actually meets that ring. Left
to the generic point-to-point connectPairs(), pedestrians would instead get
a straight line clean across the circulating traffic — exactly the "visual
shortcut" that isn't how a roundabout works. */
/**
* @param {NetNode} node
*/
function buildRoundaboutPeds(node,pedIn,pedOut){
const segIds=[...node.segments];
const angleOfSeg=new Map(segIds.map(sid=>[sid, ang(segDirAt(net.segments.get(sid),node.id))]));
const spokes=segIds.slice().sort((a,b)=>angleOfSeg.get(a)-angleOfSeg.get(b));
const n=spokes.length;
const R=roundaboutGeom(node).outer+2.6; // same radius drawSidewalkFillets() paints the ring at
const mergePoint=a=>({x:node.position.x+R*Math.cos(a), y:node.position.y+R*Math.sin(a)});
const ringTan=a=>({x:Math.sin(a),y:-Math.cos(a)});
const ringLaneIds=[];
for(let i=0;i<n;i++){
const a0=angleOfSeg.get(spokes[i]);
let a1=angleOfSeg.get(spokes[(i-1+n)%n]);
while(a1>=a0) a1-=2*Math.PI;
const theta=a0-a1, kk=4/3*Math.tan(theta/4);
const p0=mergePoint(a0), p3=mergePoint(a1);
const t0=ringTan(a0), t3=ringTan(a1);
const p1=add(p0,mul(t0,R*kk)), p2=sub(p3,mul(t3,R*kk));
const curve=new Bez(p0,p1,p2,p3);
const id=nid++;
net.lanes.set(id,{id,kind:'ring',segment:null,offset:0,dir:1,mode:'ped',speed:1.4,
curve,length:curve.length,out:[],inc:[],load:0});
ringLaneIds.push(id);
}
for(let i=0;i<n;i++){
const cur=net.lanes.get(ringLaneIds[i]);
const nxt=net.lanes.get(ringLaneIds[(i-1+n)%n]);
// walkable both ways around the sidewalk, unlike the one-way car ring
for(const [from,to] of [[cur,nxt],[nxt,cur]]){
const curve=new Bez(from.curve.p3,from.curve.p3,to.curve.p0,to.curve.p0);
const id=nid++;
net.links.set(id,{id,node:node.id,from:from.id,to:to.id,curve,length:Math.max(0.05,curve.length),
controlKind:'ringChain',phaseGroup:null});
from.out.push(id); to.inc.push(id); node.links.push(id);
}
}
for(let i=0;i<n;i++){
const seg=spokes[i], a0=angleOfSeg.get(seg), tan=ringTan(a0);
const ringHere=net.lanes.get(ringLaneIds[i]);
for(const lane of pedIn.filter(l=>l.segment===seg)){
const d=Math.max(1.5,dist(lane.exitPos,ringHere.curve.p0)*0.5);
const curve=new Bez(lane.exitPos, add(lane.exitPos,mul(lane.exitTan,d)),
sub(ringHere.curve.p0,mul(tan,d)), ringHere.curve.p0);
const id=nid++;
net.links.set(id,{id,node:node.id,from:lane.id,to:ringHere.id,curve,length:curve.length,
controlKind:'pedRoundEnter',phaseGroup:null});
lane.out.push(id); ringHere.inc.push(id); node.links.push(id);
}
for(const lane of pedOut.filter(l=>l.segment===seg)){
const d=Math.max(1.5,dist(ringHere.curve.p0,lane.entryPos)*0.5);
const curve=new Bez(ringHere.curve.p0, add(ringHere.curve.p0,mul(tan,d)),
sub(lane.entryPos,mul(lane.entryTan,d)), lane.entryPos);
const id=nid++;
net.links.set(id,{id,node:node.id,from:ringHere.id,to:lane.id,curve,length:curve.length,
controlKind:'pedRoundExit',phaseGroup:null});
ringHere.out.push(id); lane.inc.push(id); node.links.push(id);
}
}
node.pedRingLanes=ringLaneIds;
}
/* ── lot subdivision off road frontage ──────────────────────────────────── */
function centroid(poly){ let x=0,y=0; for(const p of poly){x+=p.x;y+=p.y;} return{x:x/poly.length,y:y/poly.length}; }
/* Save-shaped lots waiting to be re-placed by the next regenerateLots() call.
loadCityData sets this before rebuildAll(); Phase 1 consumes and clears it.
Without it a reload would re-cut lots from the road types and lose every
grandfathered parcel. */
let restoreLots=null;
/* True only for the rebuildAll() inside loadCityData. Clearing is suppressed
then: the saved lots were valid when written, and the old loader relied on
running the pass while every lot was still unzoned (see the note in
24-save-load.js). Restored lots arrive zoned, so without this a reload could
quietly demolish a building that merely clips a junction. */
let loadingCity=false;
/* Build a parcel polygon from stored geometry against the live curve. `inset`
pulls the sampled span in from each end so neighbouring back corners don't
creep into each other on a bend; row lots pass 0 so wall-to-wall parcels
actually meet. Defaults to the historic 12%. */
/**
* @param {Segment} seg
* @param {number} side
* @param {number} startS
* @param {number} frontage
* @param {number} frontOff
* @param {number} depth
* @param {number} [inset]
*/
function lotPolyFrom(seg,side,startS,frontage,frontOff,depth,inset){
const pad=inset==null?frontage*0.12:inset, a=startS+pad, b=startS+frontage-pad;
const front=[],back=[],K=4;
for(let k=0;k<=K;k++){
const t=seg.curve.tAtDist(a+(b-a)*(k/K));
front.push(seg.curve.offsetAt(t,side*frontOff));
back.push(seg.curve.offsetAt(t,side*(frontOff+depth)));
}
return [...front,...back.reverse()];
}
function regenerateLots(){
const source=restoreLots||lots;
restoreLots=null;
lots=[];
const taken=new Map();
const claim=(c,segId,side)=>{
for(let ox=-1;ox<=1;ox++)for(let oy=-1;oy<=1;oy++){
const b=taken.get(`${Math.round(c.x/9)+ox},${Math.round(c.y/9)+oy}`);
if(b) for(const e of b){
// lots on the same frontage are side-by-side by construction — a
// 7.5-wide row lot would otherwise reject its own neighbour under
// the 10-unit cross-road rule below
if(e.seg===segId && e.side===side) continue;
if(dist(e.c,c)<10) return false;
}
}
const k=`${Math.round(c.x/9)},${Math.round(c.y/9)}`;
if(!taken.has(k))taken.set(k,[]);
taken.get(k).push({c,seg:segId,side}); return true;
};
const occupied=new Map();
const addSpan=(segId,side,a,b)=>{
const k=`${segId}:${side}`;
if(!occupied.has(k))occupied.set(k,[]);
occupied.get(k).push([a,b]);
};
// ── Phase 1: freeze developed parcels exactly where they stand ──────────
for(const l of source){
if(!(l.zone!==Z.NONE || l.building || l.vacant)) continue;
const seg=net.segments.get(l.segment); if(!seg) continue;
const s0=seg.startTrim, s1=seg.curve.length-seg.endTrim;
let frontage, depth, frontOff, cls;
if(l.frontOff!=null && l.depth!=null){
cls=l.cls||'normal';
const LC=LOT_CLASSES[cls];
frontage=l.frontage||LC.frontage; depth=l.depth; frontOff=l.frontOff;
} else {
// pre-class save (v4 and older): reconstruct the historic normal-lot
// subdivision so the city keeps exactly the parcels it was saved with.
// The new classes only apply to new roads and undeveloped frontage.
// Without this, an old save loses every lot on a dirt/lane road because
// the re-cut startS values no longer match and the match loop drops it.
const type=ROAD_TYPE_BY_ID[seg.typeId];
if(!type.frontage) continue;
const usable=s1-s0;
const n=Math.max(1,Math.round(usable/LOT_TARGET));
frontage=usable/n; depth=LOT_DEPTH; frontOff=type.width/2+SETBACK; cls='normal';
}
if(frontage>s1-s0){ forfeitPartnership(l); continue; }
const startS=clamp(l.startS, s0, s1-frontage);
const midT=seg.curve.tAtDist(startS+frontage/2);
if(1/Math.max(1e-6,Math.abs(curveTurnAt(seg.curve,midT)))<(frontOff+depth)*1.15){ forfeitPartnership(l); continue; }
const poly=lotPolyFrom(seg,l.side,startS,frontage,frontOff,depth,cls==='row'?0:frontage*0.12);
if(poly.some(p=>isWater(p.x,p.y))){ forfeitPartnership(l); continue; }
const c=centroid(poly);
if(!claim(c,seg.id,l.side)) continue;
addSpan(seg.id,l.side,startS,startS+frontage);
lots.push(Object.assign({
zone:Z.NONE,level:0,grow:0,lv:0,poll:0,util:false,sub:null,building:null,
vacant:false,vacantTimer:0,lastLevel:0,
},l,{segment:seg.id,side:l.side,startS,frontage,frontOff,depth,cls,poly,c}));
}
// ── Phase 2: cut the remaining frontage by the road's class ─────────────
// when two roads' lot strips would overlap, whichever gets claimed first
// wins (see claim() above) — sorting by road tier means a newly-built
// avenue always takes the territory over the dirt road it crosses, not
// whichever happened to be inserted into net.segments first
const segsByTier=[...net.segments.values()].sort((a,b)=>
ROAD_TYPE_BY_ID[b.typeId].costPerM-ROAD_TYPE_BY_ID[a.typeId].costPerM);
for(const seg of segsByTier){
const type=ROAD_TYPE_BY_ID[seg.typeId];
if(!type.frontage || seg.isBridge) continue;
const cls=/** @type {'rural'|'normal'|'row'} */(type.lotClass||'normal'), LC=LOT_CLASSES[cls];
const hw=type.width/2, frontOff=hw+SETBACK;
const s0=seg.startTrim, s1=seg.curve.length-seg.endTrim;
if(s1-s0<8) continue;
for(const side of [1,-1]){
const spans=(occupied.get(`${seg.id}:${side}`)||[]).slice().sort((a,b)=>a[0]-b[0]);
const gaps=[]; let cursor=s0;
for(const [a,b] of spans){ if(a>cursor) gaps.push([cursor,Math.min(a,s1)]); cursor=Math.max(cursor,b); }
if(cursor<s1) gaps.push([cursor,s1]);
for(const [ga,gb] of gaps){
const g=gb-ga; if(g<LC.frontage*0.6) continue;
const n=Math.max(1,Math.round(g/LC.frontage)), w=g/n;
for(let i=0;i<n;i++){
const a=ga+i*w, b=a+w;
// a tight bend can turn sharply enough that the far (back) edge of a
// lot, offset depth out from the road, folds back over itself or its
// neighbor before it even reaches that distance — skip a chunk
// wherever the local turn radius can't clear the lot's own depth.
const midT=seg.curve.tAtDist((a+b)/2);
if(1/Math.max(1e-6,Math.abs(curveTurnAt(seg.curve,midT)))<(frontOff+LC.depth)*1.15) continue;
const poly=lotPolyFrom(seg,side,a,w,frontOff,LC.depth,cls==='row'?0:w*0.12);
if(poly.some(p=>isWater(p.x,p.y))) continue; // no zone may touch water at all
const c=centroid(poly);
if(!claim(c,seg.id,side)) continue;
lots.push({segment:seg.id,side,startS:a,frontage:w,frontOff,depth:LC.depth,cls,poly,c,
zone:Z.NONE,level:0,grow:0,lv:0,poll:0,util:false,sub:null,building:null,
vacant:false,vacantTimer:0,lastLevel:0});
}
}
}
}
rebuildLotGrid();
}
/* A uniform spatial grid over lot centroids so drawing (and anything else
that only cares about "what's near here") doesn't have to touch every lot
in the city every time — only rebuilt when the lot SET changes (i.e.
whenever regenerateLots runs), never on a plain zoning/level change, since
lots never move once placed. */
/* Above this many lots visible in one frame, drop every building to the flat
version regardless of zoom — the same trade that already makes zoomed-out
cheap, just applied whenever there's simply a lot on screen at once. */
const LOD_DENSE_THRESHOLD=260;
const LOT_GRID_CELL=60;
let lotGrid=new Map();
function lotGridKey(x,y){ return Math.floor(x/LOT_GRID_CELL)+','+Math.floor(y/LOT_GRID_CELL); }
function rebuildLotGrid(){
lotGrid=new Map();
for(const l of lots){
const k=lotGridKey(l.c.x,l.c.y);
let arr=lotGrid.get(k); if(!arr){ arr=[]; lotGrid.set(k,arr); }
arr.push(l);
}
}
/* Lots whose grid cell overlaps the current view, padded by one cell so nothing
right at the edge gets missed — drawLot() still does its own precise
per-pixel cull on top of this, so this only needs to be roughly right. */
function lotsInRect(x0,y0,x1,y1){
const gx0=Math.floor(x0/LOT_GRID_CELL)-1, gx1=Math.floor(x1/LOT_GRID_CELL)+1;
const gy0=Math.floor(y0/LOT_GRID_CELL)-1, gy1=Math.floor(y1/LOT_GRID_CELL)+1;
const out=[];
for(let gx=gx0; gx<=gx1; gx++) for(let gy=gy0; gy<=gy1; gy++){
const arr=lotGrid.get(gx+','+gy);
if(arr) for(const l of arr) out.push(l);
}
return out;
}
/* Count only, for the LOD decision — never allocates the array. */
function lotCountInRect(x0,y0,x1,y1){
const gx0=Math.floor(x0/LOT_GRID_CELL)-1, gx1=Math.floor(x1/LOT_GRID_CELL)+1;
const gy0=Math.floor(y0/LOT_GRID_CELL)-1, gy1=Math.floor(y1/LOT_GRID_CELL)+1;
let n=0;
for(let gx=gx0; gx<=gx1; gx++) for(let gy=gy0; gy<=gy1; gy++){
const arr=lotGrid.get(gx+','+gy);
if(arr) n+=arr.length;
}
return n;
}
function visibleLots(){
const tl=wc(0,0), br=wc(VW,VH);
return lotsInRect(tl.x,tl.y,br.x,br.y);
}
/* Every lot whose grid cell is within `radius` of a world point — the
neighbourhood counterpart to visibleLots(), for anything that only cares
what is nearby (density counts, pollution sources, amenity proximity)
instead of scanning every lot in the city. Cell-granular like visibleLots,
so callers still do their own precise distance check. */
/**
* @param {{x:number,y:number}} c
* @param {number} radius
*/
function lotsNear(c,radius){
const pad=Math.ceil(radius/LOT_GRID_CELL);
const gx=Math.floor(c.x/LOT_GRID_CELL), gy=Math.floor(c.y/LOT_GRID_CELL);
const out=[];
for(let x=gx-pad;x<=gx+pad;x++) for(let y=gy-pad;y<=gy+pad;y++){
const arr=lotGrid.get(x+','+y);
if(arr) for(const l of arr) out.push(l);
}
return out;
}
function lotBoundsBox(l,pad){
let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity;
for(const p of l.poly){ minX=Math.min(minX,p.x); maxX=Math.max(maxX,p.x); minY=Math.min(minY,p.y); maxY=Math.max(maxY,p.y); }
return {minX:minX-pad,maxX:maxX+pad,minY:minY-pad,maxY:maxY+pad};
}
/* Does a point list (a lot polygon or a building footprint) sit inside the
paved width of this road, within the stretch of it that's actually built
(not the trimmed-back junction ends)? Checked at every corner plus the
centroid so a road clipping just a corner still counts. */
/**
* @param {{x:number,y:number}[]} pts
* @param {{x:number,y:number}} c
* @param {SegmentLike} seg a real segment, or a throwaway one being previewed
* @param {RoadTypeId} [typeIdOverride] width to test with, if not the segment's own
*/
function polyOverlapsRoad(pts,c,seg,typeIdOverride){
const type=ROAD_TYPE_BY_ID[typeIdOverride||seg.typeId];
const hw=type.width/2+0.4;
let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity;
for(const p of pts){ minX=Math.min(minX,p.x); maxX=Math.max(maxX,p.x); minY=Math.min(minY,p.y); maxY=Math.max(maxY,p.y); }
if(!aabbHit({minX:minX-hw,maxX:maxX+hw,minY:minY-hw,maxY:maxY+hw}, seg.curve.bounds(hw))) return false;
const s0=seg.startTrim??0, s1=seg.curve.length-(seg.endTrim??0);
for(const p of [...pts,c]){
const hit=seg.curve.closest(p);
if(hit.distance<hw && hit.arcLength>s0-1 && hit.arcLength<s1+1) return true;
}
return false;
}
/**
* @param {Lot} l
* @param {SegmentLike} seg a real segment, or a throwaway one being previewed
* @param {RoadTypeId} [typeIdOverride] width to test with, if not the segment's own
*/
function lotOverlapsRoad(l,seg,typeIdOverride){ return polyOverlapsRoad(l.poly,l.c,seg,typeIdOverride); }
/* A junction's own paved footprint — the filleted polygon at a plain
intersection, or the full circle for a roundabout — is real pavement too,
but it isn't a net.segments entry, so lotOverlapsRoad() above never sees
it. Without this, a lot could sit half inside a junction (most visibly a
roundabout's ring) and never get cleared. */
/**
* @param {{x:number,y:number}[]} pts
* @param {{x:number,y:number}} c
* @param {NetNode} node
*/
function polyOverlapsNode(pts,c,node){
const vr=node.visRadius??0;
if(!(vr>0)) return false;
if(dist(c,node.position)>vr+20) return false;
if(node.control===CONTROL.ROUNDABOUT && node.segments.length>=3){
const R=roundaboutGeom(node).outer;
for(const p of [...pts,c]) if(dist(p,node.position)<R) return true;
return false;
}
if(node.poly){
for(const p of [...pts,c]) if(pointInPoly(p,node.poly)) return true;
for(const p of node.poly) if(pointInPoly(p,pts)) return true;
}
return false;
}
/**
* @param {Lot} l
* @param {NetNode} node
*/
function lotOverlapsNode(l,node){ return polyOverlapsNode(l.poly,l.c,node); }
/* Any lot whose footprint the pavement itself covers gets cleared — a road
built through the middle of a block shouldn't leave a house sitting in
the roadbed. Runs after every rebuild, so it also catches a road upgrade
widening into a neighboring lot, not just newly-drawn roads. */
function clearLotsOverlappingRoads(){
if(loadingCity) return;
for(const l of lots){
if(l.zone===Z.NONE && !l.building && !l.vacant) continue;
const quad=standingFootprint(l);
let hit=false;
for(const seg of net.segments.values()){
if(seg.id===l.segment){
// a widened own road may pave the yard; only a covered building clears
if(quad && polyOverlapsRoad(quad,centroid(quad),seg)){ hit=true; break; }
continue;
}
if(lotOverlapsRoad(l,seg)){ hit=true; break; }
}
if(!hit) for(const node of net.nodes.values()){
if(quad){ if(polyOverlapsNode(quad,centroid(quad),node)){ hit=true; break; } }
else if(lotOverlapsNode(l,node)){ hit=true; break; }
}
if(hit) clearLot(l);
}
}
/**
* @param {Lot} l
*/
function clearLot(l){
invalidateCityLayer();
// a road doesn't skip the deal just because it's pavement instead of the
// Demolish tool — same forfeiture, same bailout re-lock (see
// forfeitPartnership in 15-partnerships.js)
const wasInstant=INSTANT_ZONES.has(l.zone);
forfeitPartnership(l);
l.zone=Z.NONE; l.level=0; l.grow=0; l.sub=null;
l.building=null; l.vacant=false; l.vacantTimer=0; l.lastLevel=0;
if(wasInstant) census();
}
/* Used while a road is still being dragged/previewed: which existing lots
would this curve pave over, and which of those have an actual building
(not just a bare zoned or empty lot) worth warning the player about. */
function lotsHitByCurve(curve,typeId){
const fakeSeg={curve,startTrim:0,endTrim:0,typeId};
const hit=[];
for(const l of lots){
if(l.zone===Z.NONE && !l.building && !l.vacant) continue;
if(lotOverlapsRoad(l,fakeSeg,typeId)) hit.push(l);
}
return hit;
}
/** @param {Lot} l */
function lotHasRealBuilding(l){ return l.level>0 || !!l.building || l.vacant; }
function lotCarLane(l){
const seg=net.segments.get(l.segment); if(!seg) return null;
for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car'&&Math.sign(ln.offset)===l.side) return ln; }
for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') return ln; }
return null;
}
/** @param {Lot} l */
function lotPedLane(l){
const seg=net.segments.get(l.segment); if(!seg) return null;
// a crossAnywhere road's sidewalk is several lanes long (see stampLanes), so
// "the sidewalk outside this lot" is the section the lot's own frontage sits
// in, not whichever section happens to be stamped first
const t=seg.curve.tAtDist(l.startS+l.frontage/2);
let first=null;
for(const lid of seg.lanes){
const ln=net.lanes.get(lid);
if(!ln||ln.mode!=='ped'||Math.sign(ln.offset)!==l.side) continue;
if(t>=ln.startT-1e-6 && t<=ln.endT+1e-6) return ln;
if(!first) first=ln;
}
return first;
}
/* ── outside connections ────────────────────────────────────────────────── */
function outsideNodes(){ return [...net.nodes.values()].filter(n=>n.outside&&n.segments.length>0); }
function outsideLanes(departing){
const res=[];
for(const n of outsideNodes()){
for(const sid of n.segments){
const seg=net.segments.get(sid); if(!seg) continue;
for(const lid of seg.lanes){
const lane=net.lanes.get(lid);
if(!lane||lane.mode!=='car') continue;
const departs=(seg.startNode===n.id&&lane.dir>0)||(seg.endNode===n.id&&lane.dir<0);
if(departs===departing) res.push(lane);
}
}
}
return res;
}
/* ── full rebuild ───────────────────────────────────────────────────────── */
function rebuildAll(){
invalidateCityLayer();
for(const seg of net.segments.values()) rebuildSegmentCurve(seg);
const resolved=resolveAllCrossings();
for(const seg of net.segments.values()) rebuildSegmentCurve(seg);
for(const node of net.nodes.values()) solveTrims(node);
for(const node of net.nodes.values()){
node.poly = node.radius>0 ? junctionPolygon(node) : null;
// hit-test footprint only (drawing uses the tight polygon/ring itself,
// see drawJunctions) — just far enough to cover the polygon's own
// corners so a tap anywhere on the drawn shape reliably hits it
let vr=node.radius;
if(node.poly) for(const p of node.poly) vr=Math.max(vr,dist(node.position,p));
if(node.control===CONTROL.ROUNDABOUT && node.segments.length>=3){
// node.radius includes SLIP_GAP — the paved gap left for the slip lane
// between a road's trimmed end and the ring, which is real pavement,
// not empty space — but nothing is ever drawn past the sidewalk ring
// itself (outer+2.6), so that's the true visual footprint, not
// outer+SLIP_GAP. Using node.radius here left the "you're editing
// this" highlight (and the tap hit-area) floating past the sidewalk
// over bare grass.
vr=roundaboutGeom(node).outer+2.6;
}
node.visRadius=vr;
}
computeOverpassGaps();
net.lanes.clear();
for(const seg of net.segments.values()) stampLanes(seg);
net.links.clear();
for(const node of net.nodes.values()) buildLinks(node);
for(const seg of net.segments.values()) buildWalkLinks(seg);
regenerateLots();
clearLotsOverlappingRoads();
agents.length=0;
if(typeof ovInvalidate==='function') ovInvalidate();
refreshHud();
return resolved;
}
/* ── queries ────────────────────────────────────────────────────────────── */
function nearestNode(p,max){
let best=null,bd=max;
for(const n of net.nodes.values()){const d=dist(n.position,p); if(d<bd){bd=d;best=n;}}
return best;
}
/* Used for Edit/Demolish/View tap targeting: a point counts as "on the
intersection" if it falls within that junction's own footprint (radius
scales with the connected roads — see solveTrims/roundaboutGeom), not
some flat pixel radius unrelated to how big it actually is. visRadius is a
close circle around the drawn polygon/ring, plus a little touch slop.
Dead ends (a single connected road) aren't junctions to tap. */
function nodeHitAt(w){
let best=null,bd=Infinity;
for(const n of net.nodes.values()){
if(n.segments.length<2 || !(n.visRadius>0)) continue;
const r=n.visRadius+2.5;
const d=dist(n.position,w);
if(d<=r && d<bd){ bd=d; best=n; }
}
return best;
}
function snapSegment(p,max){
let best=null,bd=max;
for(const seg of net.segments.values()){
const h=seg.curve.closest(p);
if(h.distance<bd){bd=h.distance;best={seg,hit:h};}
}
return best;
}
/* Used only for Edit/Demolish selection: tolerance scales with each road's
own width, so a wide avenue is as easy to tap as a narrow street, and a
short segment between two junctions is no harder to hit than a dead end. */
function snapSegmentWide(p){
let best=null,bestDist=Infinity;
for(const seg of net.segments.values()){
const type=ROAD_TYPE_BY_ID[seg.typeId];
const hit=seg.curve.closest(p);
// cover the full drawn width — sidewalk included — plus a touch-slop
// margin that doesn't shrink away to nothing at high zoom
const halfDrawn = type.sidewalk ? type.width/2+2.4 : type.width/2;
const tol=halfDrawn+Math.max(2,8/cam.z);
if(hit.distance<=tol && hit.distance<bestDist){ bestDist=hit.distance; best={seg,hit}; }
}
return best;
}
function lotAt(p){
for(const l of lots){
const b=lotBoundsBox(l,0);
if(p.x<b.minX||p.x>b.maxX||p.y<b.minY||p.y>b.maxY) continue;
if(pointInPoly(p,l.poly)) return l;
}
return null;
}
function pointInPoly(p,poly){
let inside=false;
for(let i=0,j=poly.length-1;i<poly.length;j=i++){
const a=poly[i],b=poly[j];
if((a.y>p.y)!==(b.y>p.y) && p.x<(b.x-a.x)*(p.y-a.y)/(b.y-a.y)+a.x) inside=!inside;
}
return inside;
}
/* ── A* over the lane graph ─────────────────────────────────────────────── */
const MAX_SPEED=22;
/* Reused across calls: A* runs on every trip spawn, and a fresh Map + open
array per call was pure garbage. Cleared, not reallocated. */
const _fpG=new Map(), _fpCame=new Map(), _fpOpen=[];
function findPath(fromId,toId){
if(fromId===toId) return [{lane:fromId,link:-1}];
const target=net.lanes.get(toId); if(!target) return null;
const g=_fpG, came=_fpCame, open=_fpOpen;
g.clear(); came.clear(); open.length=0;
g.set(fromId,0);
open.push({id:fromId,f:0});
let guard=0;
while(open.length && guard++<6000){
let bi=0; for(let i=1;i<open.length;i++) if(open[i].f<open[bi].f) bi=i;
const cur=open[bi]; open[bi]=open[open.length-1]; open.pop();
if(cur.id===toId){
const path=[]; let id=toId;
while(id!==undefined){
const step=came.get(id);
path.push({lane:id,link:step?step.link:-1});
id=step?step.prev:undefined;
}
path.reverse();
for(let i=0;i<path.length-1;i++) path[i].link=path[i+1].link;
path[path.length-1].link=-1;
return path;
}
const lane=net.lanes.get(cur.id); if(!lane) continue;
const base=g.get(cur.id);
for(const linkId of lane.out){
const link=net.links.get(linkId); if(!link) continue;
const nxt=net.lanes.get(link.to); if(!nxt) continue;
const cost=base + lane.length/Math.max(lane.speed,1) + link.length/8 + nxt.load*0.35;
if(cost < (g.get(nxt.id) ?? Infinity)){
g.set(nxt.id,cost);
came.set(nxt.id,{prev:cur.id,link:linkId});
const p = nxt.curve ? nxt.curve.p3 : nxt.exitPos;
const h=dist(p, target.curve?target.curve.p3:target.exitPos)/MAX_SPEED;
open.push({id:nxt.id,f:cost+h});
}
}
}
return null;
}
/* ── agents: commuters, visitors, freight ───────────────────────────────── */
const _caps={car:0,ped:0};
function agentCaps(){
const jobs=S.comJobs+S.indJobs+S.offJobs;
_caps.car=Math.min(220, 6+Math.floor((S.pop+jobs)/9));
_caps.ped=Math.min(110, 4+Math.floor((S.pop+jobs)/20));
return _caps;
}
/* what a passenger would call each end of a trip, for the inspect panel */
const PLACE_SINGULAR={
[Z.RES]:'a home', [Z.COM]:'a shop', [Z.IND]:'a factory', [Z.OFF]:'an office',
[Z.PARK]:'a park', [Z.POLICE]:'a police station', [Z.FIRE]:'a fire station',
[Z.CLINIC]:'a clinic', [Z.SCHOOL]:'a school', [Z.POWER]:'a coal power plant', [Z.WATER]:'a water tower',
[Z.RESERVOIR]:'a reservoir', [Z.SOLAR]:'a solar farm', [Z.WIND]:'a wind farm',
};
function placeDesc(lot){ return lot ? {kind:'lot',lot} : {kind:'outside',label:'the regional highway'}; }
function placeLabel(desc){
if(!desc) return 'somewhere in the city';
if(desc.kind==='outside') return desc.label;
const l=desc.lot, subMap=SUB_LABEL[l.zone];
if(subMap && l.sub && subMap[l.sub]) return subMap[l.sub];
return PLACE_SINGULAR[l.zone] || 'a building';
}
function makeAgent(mode,path,purpose,trip){
const color = purpose==='freight' ? FREIGHT_COLOR
: purpose==='visit' ? VISIT_COLORS[(Math.random()*VISIT_COLORS.length)|0]
: mode==='ped' ? PED_COLORS[(Math.random()*PED_COLORS.length)|0]
: CAR_COLORS[(Math.random()*CAR_COLORS.length)|0];
// who's aboard: freight runs light, visitors travel in groups, commuters mostly solo/carpool
const seats = purpose==='freight' ? 2 : 4;
const passengers = mode==='ped' ? 1
: purpose==='freight' ? 1
: purpose==='visit' ? 2+((Math.random()*3)|0)
: 1+((Math.random()*Math.random()*2.4)|0);
return { mode, purpose, path, idx:0, d:0, onLink:false,
topSpeed: mode==='ped'? 1.4+Math.random()*0.4 : purpose==='freight'? 7+Math.random()*3 : 9+Math.random()*7,
color, stopTimer:undefined, seats, passengers:Math.min(seats,passengers),
from: trip&&trip.from, to: trip&&trip.to, spawnDay:S.day };
}
let zoneLotCache={gen:-1};
function zoneLots(z){
if(zoneLotCache.gen!==cityLayerGen){
zoneLotCache={gen:cityLayerGen, [Z.RES]:[], [Z.COM]:[], [Z.IND]:[], [Z.OFF]:[]};
for(const l of lots){ const a=zoneLotCache[l.zone]; if(a&&l.level>0) a.push(l); }
}
return zoneLotCache[z]||[];
}
function spawnTrip(carCount,pedCount,caps){
const homes=zoneLots(Z.RES);
const shops=zoneLots(Z.COM);
const inds=zoneLots(Z.IND);
const offs=zoneLots(Z.OFF);
const jobs=[...shops,...inds,...offs];
const wCommute=(S.pop*0.5+(S.comJobs+S.indJobs+S.offJobs)*0.5);
const wVisit=S.visitors*1.3;
const wFreight=S.goodsProd*0.55;
const total=wCommute+wVisit+wFreight;
if(total<=0) return;
let r=Math.random()*total;
let kind = r<wCommute ? 'commute' : (r<wCommute+wVisit ? 'visit' : 'freight');
if(kind==='commute'){
if(!homes.length||!jobs.length) return;
const origin=pick(homes), dest=pick(jobs);
const trip={from:placeDesc(origin), to:placeDesc(dest)};
const units=dist(origin.c,dest.c)/LOT_TARGET;
if(units<=WALK_MAX_UNITS){
if(pedCount>=caps.ped) return;
const a=lotPedLane(origin), b=lotPedLane(dest);
if(!a||!b) return;
const p=findPath(a.id,b.id);
if(p&&p.length>1) agents.push(makeAgent('ped',p,'commute',trip));
} else {
if(carCount>=caps.car) return;
const a=lotCarLane(origin), b=lotCarLane(dest);
if(!a||!b) return;
const p=findPath(a.id,b.id);
if(p&&p.length>1) agents.push(makeAgent('car',p,'commute',trip));
}
} else if(kind==='visit'){
if(carCount>=caps.car||!shops.length) return;
const entries=outsideLanes(true);
const a=pick(entries); if(!a) return;
const shopLot=pick(shops);
const b=lotCarLane(shopLot); if(!b) return;
const p=findPath(a.id,b.id);
if(p&&p.length>1) agents.push(makeAgent('car',p,'visit',{from:{kind:'outside',label:'the regional highway'},to:placeDesc(shopLot)}));
} else {
if(carCount>=caps.car||!inds.length) return;
const indLot=pick(inds);
const a=lotCarLane(indLot); if(!a) return;
let bLot=null, b=null;
if(shops.length && Math.random()<0.5){ bLot=pick(shops); b=lotCarLane(bLot); }
else b=pick(outsideLanes(false));
if(!b) return;
const p=findPath(a.id,b.id);
if(p&&p.length>1) agents.push(makeAgent('car',p,'freight',{from:placeDesc(indLot),to:bLot?placeDesc(bLot):{kind:'outside',label:'the regional highway'}}));
}
}
function currentPhase(node){ return Math.floor(S.simTime/6 + (node.id%2)*0.5)%2; }
function gateBlocks(v,link,dt){
if(link.controlKind==='stop'){
if(v.stopTimer===undefined) v.stopTimer=0.9;
v.stopTimer-=dt;
return v.stopTimer>0;
}
if(link.controlKind==='signal'){
const node=link.node==null?null:net.nodes.get(link.node);
return node ? currentPhase(node)!==link.phaseGroup : false;
}
return false;
}
let spawnAcc=0;
/* how many moving cars a road can carry: lane count × comfortable throughput.
Longer roads hold proportionally more; the /70 keeps short blocks realistic. */
function segCapacity(seg){
let n=0;
for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') n++; }
return Math.max(1,n) * Math.max(2, Math.min(6, seg.curve.length/22));
}
const _segCars=new Map();
/* Swap-remove: agents.splice() allocates the removed-elements array every call,
and this runs on every finished trip. Order is irrelevant to traffic. */
function removeAgentAt(i){ agents[i]=agents[agents.length-1]; agents.pop(); }
function updateAgents(dt){
spawnAcc+=dt;
const caps=agentCaps();
while(spawnAcc>0.2){
spawnAcc-=0.2;
let cars=0; for(const a of agents) if(a.mode==='car') cars++;
spawnTrip(cars,agents.length-cars,caps);
}
for(const l of net.lanes.values()) l.load*=0.94;
// ── congestion: tally cars per road, compare to its lane capacity ──────
const segCars=_segCars; segCars.clear();
for(const v of agents){
if(v.mode!=='car'||v.onLink) continue;
const st=v.path[v.idx]; if(!st) continue;
const ln=net.lanes.get(st.lane); if(!ln||ln.segment==null) continue;
segCars.set(ln.segment,(segCars.get(ln.segment)||0)+1);
}
let overCars=0, allCars=0, stuck=0;
for(const seg of net.segments.values()){
const cars=segCars.get(seg.id)||0;
const cong=cars/segCapacity(seg);
seg.cong = (seg.cong||0)*0.85 + cong*0.15;
allCars+=cars;
if(seg.cong>1){ overCars+=cars; }
if(seg.cong>1.25) stuck++;
}
const instCong = allCars>0 ? overCars/allCars : 0;
S.congestion = (S.congestion||0)*0.9 + instCong*0.1;
S.stuckRoads = stuck;
for(let i=agents.length-1;i>=0;i--){
const v=agents[i];
const step=v.path[v.idx];
if(!step){ removeAgentAt(i); continue; }
const onLinkNow=v.onLink;
const cur = onLinkNow? net.links.get(step.link) : net.lanes.get(step.lane);
if(!cur){ removeAgentAt(i); continue; }
const curLen=cur.length;
const speedCap = v.mode==='ped'? 1.6 : (onLinkNow? 9 : (cur.speed||9));
let mult=1;
if(onLinkNow && cur.controlKind==='yield') mult=0.55;
if(onLinkNow && cur.controlKind==='roundEnter') mult=0.6;
if(!onLinkNow && v.mode==='car' && cur.segment!=null){
const sg=net.segments.get(cur.segment);
if(sg && sg.cong>1) mult *= 1/(1+(sg.cong-1)*1.6); // pile-up on undersized roads
}
const eff=Math.min(v.topSpeed,speedCap)*mult;
v.curSpeed=eff; v.curSpeedCap=Math.min(v.topSpeed,speedCap); // for the inspect panel
if(!onLinkNow){
cur.load=Math.min(1,(cur.load||0)+0.02);
const nextD=v.d+eff*dt;
if(nextD>=curLen && step.link>=0){
const link=net.links.get(step.link);
if(link && gateBlocks(v,link,dt)){ v.d=curLen*0.999; continue; }
}
v.d=nextD;
if(v.d>=curLen){
v.d-=curLen;
if(step.link<0){ removeAgentAt(i); continue; }
v.onLink=true; v.stopTimer=undefined;
}
} else {
v.d+=eff*dt;
if(v.d>=curLen){
v.d-=curLen; v.onLink=false; v.idx++;
if(v.idx>=v.path.length){ removeAgentAt(i); continue; }
}
}
}
}
/**
* @param {Agent} v
*/
function agentPose(v){
const step=v.path[v.idx]; if(!step) return null;
if(v.onLink){
const link=net.links.get(step.link); if(!link) return null;
const t=link.curve.tAtDist(v.d);
return{p:link.curve.pointAt(t),a:ang(link.curve.tangentAt(t))};
}
const lane=net.lanes.get(step.lane); if(!lane) return null;
if(lane.kind==='ring'){
const t=lane.curve.tAtDist(v.d);
return{p:lane.curve.pointAt(t),a:ang(lane.curve.tangentAt(t))};
}
if(lane.segment==null) return null;
const seg=net.segments.get(lane.segment); if(!seg) return null;
const f=clamp(v.d/Math.max(lane.length,.01),0,1);
const t=lane.entryT+(lane.exitT-lane.entryT)*f;
const p=seg.curve.offsetAt(t,lane.offset);
const tan=mul(seg.curve.tangentAt(t),lane.dir);
return{p,a:ang(tan)};
}
/* Allocation-free pose for the per-frame draw loop: writes p into `out`
(x,y) and the heading into out.a, reusing one scratch for the tangent. */
const _poseT={x:0,y:0};
function agentPoseInto(v,out){
const step=v.path[v.idx]; if(!step) return false;
if(v.onLink){
const link=net.links.get(step.link); if(!link) return false;
const t=link.curve.tAtDist(v.d);
link.curve.pointAtInto(t,out); link.curve.tangentAtInto(t,_poseT);
out.a=Math.atan2(_poseT.y,_poseT.x); return true;
}
const lane=net.lanes.get(step.lane); if(!lane) return false;
if(lane.kind==='ring'){
const t=lane.curve.tAtDist(v.d);
lane.curve.pointAtInto(t,out); lane.curve.tangentAtInto(t,_poseT);
out.a=Math.atan2(_poseT.y,_poseT.x); return true;
}
if(lane.segment==null) return false;
const seg=net.segments.get(lane.segment); if(!seg) return false;
const f=clamp(v.d/Math.max(lane.length,.01),0,1);
const t=lane.entryT+(lane.exitT-lane.entryT)*f;
seg.curve.offsetAtInto(t,lane.offset,out); seg.curve.tangentAtInto(t,_poseT);
_poseT.x*=lane.dir; _poseT.y*=lane.dir;
out.a=Math.atan2(_poseT.y,_poseT.x); return true;
}
/* An agent on a segment that passes under an overpass deck (see
computeOverpassGaps/underGaps) shouldn't be drawn while it's inside the
deck's footprint — the deck is already opaque there in the cached city
layer, and without this check the agent, drawn fresh every frame on top
of that layer, paints right over it and reads as floating above the
bridge instead of passing beneath it. */
function agentUnderDeck(v,p){
const step=v.path[v.idx]; if(!step||v.onLink) return false;
const lane=net.lanes.get(step.lane); if(!lane||lane.kind==='ring'||lane.segment==null) return false;
const seg=net.segments.get(lane.segment); if(!seg||!seg.underGaps) return false;
for(const poly of seg.underGaps) if(pointInPoly(p,poly)) return true;
return false;
}
function agentAt(w){
let best=null,bd=16/cam.z;
for(const v of agents){
const pose=agentPose(v); if(!pose) continue;
const d=dist(pose.p,w);
if(d<bd){ bd=d; best=v; }
}
return best;
}
/* non-mutating read of what an agent is doing right now, for the inspect panel */
/**
* @param {Agent} v
*/
function agentStatus(v){
if(!v.onLink) return 'en route';
const step=v.path[v.idx]; const link=step&&net.links.get(step.link);
if(!link) return 'en route';
if(link.controlKind==='stop' && (v.stopTimer??0)>0) return 'stopped at a stop sign';
if(link.controlKind==='signal'){
const node=link.node==null?null:net.nodes.get(link.node);
if(node && currentPhase(node)!==link.phaseGroup) return 'waiting at a red light';
}
if(link.controlKind==='yield') return 'yielding';
return 'en route';
}
/* ── inspect tray: tap a building, car or pedestrian in View mode ───────── */
/* Tapping a service building or a road/junction in Move/View doesn't just
open its card — it switches the overlay submenu to whichever heatmap
actually explains it, so "why is this station's coverage bad" is one tap
away instead of a trip through the overlay menu yourself. */
const ZONE_OVERLAY={ [Z.POLICE]:'police', [Z.FIRE]:'fire', [Z.CLINIC]:'health', [Z.SCHOOL]:'school', [Z.POWER]:'power', [Z.WATER]:'water', [Z.RESERVOIR]:'water', [Z.SOLAR]:'power', [Z.WIND]:'power' };
function showOverlayFor(id){
if(!id) return;
S.overlay=id; overlayMenuOpen=true;
buildDock();
setHint(OVERLAY_HINT[id]||HINTS[mode]);
}
function openInspectAgent(v){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray();
inspectTarget={type:'agent',agent:v};
renderInspect();
$('iTray').classList.add('up');
}
/**
* @param {Lot} l
*/
function openInspectLot(l){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray();
followAgent=null;
inspectTarget={type:'lot',lot:l};
showOverlayFor(ZONE_OVERLAY[l.zone]);
renderInspect();
$('iTray').classList.add('up');
}
function openInspectRoad(segId){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray();
followAgent=null;
inspectTarget={type:'road',segId};
showOverlayFor('traffic');
renderInspect();
$('iTray').classList.add('up');
}
function openInspectJunction(node){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray();
followAgent=null;
inspectTarget={type:'junction',nodeId:node.id};
showOverlayFor('traffic');
renderInspect();
$('iTray').classList.add('up');
}
function closeITray(){
inspectTarget=null; followAgent=null;
const t=$('iTray'); if(t) t.classList.remove('up');
}
function row(label,value){ return `<div style="display:flex;justify-content:space-between;gap:12px;padding:4px 0;border-bottom:1px solid var(--line,#2a2f3a)"><span style="color:var(--muted)">${label}</span><span>${value}</span></div>`; }
const PURPOSE_LABEL={commute:'Commuting',visit:'Visiting',freight:'Freight run'};
function renderInspect(){
if(!inspectTarget) return;
const title=$('iTitle'), sub=$('iSub'), body=$('iBody'), followBtn=$('iFollow');
if(inspectTarget.type==='agent'){
const v=inspectTarget.agent;
const alive=agents.includes(v);
const icon = v.mode==='ped' ? '🚶' : v.purpose==='freight' ? '🚚' : '🚗';
title.textContent = `${icon} ${v.mode==='ped'?'Pedestrian':v.purpose==='freight'?'Delivery truck':'Car'}`;
sub.textContent = alive ? (PURPOSE_LABEL[v.purpose]||'') : 'Trip complete';
let h='';
h+=row('From', placeLabel(v.from));
h+=row('To', placeLabel(v.to));
h+=row('Status', alive?agentStatus(v):'arrived');
const prog = v.path && v.path.length>1 ? clamp(v.idx/(v.path.length-1),0,1) : 0;
h+=row('Trip progress', `${Math.round(prog*100)}%`);
if(v.mode!=='ped') h+=row('Passengers', `${v.passengers} / ${v.seats}`);
if(alive && v.curSpeed!=null) h+=row('Speed', `${Math.round(v.curSpeed*3.6)} km/h`);
body.innerHTML=h;
if(alive){
followBtn.style.display='';
followBtn.textContent = followAgent===v ? '◉ Following' : '◎ Follow';
} else {
followBtn.style.display='none';
if(followAgent===v) followAgent=null;
}
} else if(inspectTarget.type==='lot'){
const l=inspectTarget.lot;
if(!lots.includes(l)){ closeITray(); return; }
const zi=ZONE_INFO[l.zone];
const subInfo = l.sub && SUB_INFO[l.zone] && SUB_INFO[l.zone][l.sub];
const icon = l.building ? '🏗️' : l.vacant ? '🏚️'
: (subInfo&&subInfo.icon) || CIVIC_ICON[l.zone] || (l.zone===Z.PARK?'🌳':l.zone===Z.RES?'🏠':l.zone===Z.COM?'🏬':l.zone===Z.IND?'🏭':l.zone===Z.OFF?'🏢':'▫️');
title.textContent = `${icon} ${l.building?'Under construction':l.vacant?'Vacant':(subInfo ? subInfo.name : (zi?zi.name:'Lot'))}`;
followBtn.style.display='none';
let h='';
if(l.building){
sub.textContent = zi ? zi.name : '';
const pct=Math.round((1-l.building.timer/l.building.total)*100);
h+=row('Becoming', zi ? `${zi.name}${l.building.toLevel>1?` · level ${l.building.toLevel}`:''}` : '');
h+=row('Progress', `${pct}%`);
h+=row('Days left', l.building.timer);
} else if(l.vacant){
sub.textContent = zi ? zi.name : '';
h+=row('Zoned as', zi ? zi.name : '');
h+=row('Vacant for', `${VACANT_DAYS-l.vacantTimer} of ${VACANT_DAYS} days`);
h+='<div style="font-size:11.5px;color:var(--muted);margin:6px 0 2px;line-height:1.5">If demand picks back up before this runs out, it reopens instantly — no waiting on new construction.</div>';
} else if(GROWABLE.has(l.zone)){
sub.textContent = `Level ${l.level} of 3`;
h+=row('Level', `${l.level} / 3`);
if(subInfo) h+=row('Specialty', subInfo.name+(l.level<3?' · still finding its footing':''));
if(l.zone===Z.RES) h+=row('Residents', Math.round((RES_POP[l.level]||0)*(l.sub==='apt'?1.6:1)));
if(l.zone===Z.COM){ h+=row('Jobs', COM_JOB[l.level]||0); h+=row('Retail capacity', COM_RETAIL[l.level]||0); }
if(l.zone===Z.IND){ h+=row('Jobs', IND_JOB[l.level]||0); h+=row('Goods produced', IND_GOODS[l.level]||0); }
if(l.zone===Z.OFF) h+=row('Jobs', OFF_JOB[l.level]||0);
h+=row('Land value', `${Math.round((l.lv||0)*100)}%`);
if(l.zone===Z.RES||l.zone===Z.COM) h+=row('Crime', `${Math.round((l.crime||0)*100)}%`);
h+=row('Pollution', `${Math.round((l.poll||0)*100)}%`);
h+=row('Power & water', l.util ? 'connected' : (l.noPower&&l.noWater?'no power, no water':l.noPower?'no power':l.noWater?'no water':'short'));
} else if(l.zone===Z.PARK){
sub.textContent = 'Amenity';
h+=row('Effect', 'Lifts nearby land value; no upkeep beyond land cost');
} else if(zi){
sub.textContent = 'Civic / utility';
h+=row('Monthly upkeep', `$${CIVIC_UPKEEP[l.zone]||0}`);
if(l.zone===Z.POWER) h+=row('Generates', `${POWER_GEN.toLocaleString()} power/mo`);
else if(l.zone===Z.SOLAR) h+=row('Generates', `${SOLAR_GEN.toLocaleString()} power/mo`);
else if(l.zone===Z.WIND) h+=row('Generates', `${windGenFor(l).toLocaleString()} power/mo`);
else if(l.zone===Z.WATER) h+=row('Generates', `${WATER_GEN.toLocaleString()} water/mo`);
else if(l.zone===Z.RESERVOIR) h+=row('Generates', `${WATER_GEN_HI.toLocaleString()} water/mo`);
else if(SERVICE_RADIUS[l.zone]) h+=row('Service reach', `${SERVICE_RADIUS[l.zone]}m along roads`);
// a partnership building's monthly cheque is the whole reason it's there,
// and for the achievement ones it's the placement readout too — a mall on
// a dead edge and a mall in a thriving district do not pay the same
if(SPECIAL_INCOME[l.zone]){
h+=row('Monthly income', `$${specialIncomeFor(l).toLocaleString()}`);
const dealHere=PARTNERSHIPS.find(p=>p.zone===l.zone);
if(!(dealHere&&dealHere.grant>0))
h+=row('Surrounding land', `${Math.round((l.localLV??0)*100)}% — what it's paid on`);
}
}
body.innerHTML=h;
} else if(inspectTarget.type==='road'){
const seg=net.segments.get(inspectTarget.segId);
if(!seg){ closeITray(); return; }
const type=ROAD_TYPE_BY_ID[seg.typeId];
title.textContent = `🛣️ ${type.name}${seg.isBridge?' (bridge)':''}`;
sub.textContent = `${Math.round(seg.curve.length)}m`;
followBtn.style.display='none';
let laneLoad=0; for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') laneLoad=Math.max(laneLoad,ln.load||0); }
const load=Math.max(seg.cong||0, laneLoad);
let h='';
h+=row('Traffic load', `${Math.round(clamp(load,0,1)*100)}%`);
h+=row('Status', (seg.cong||0)>1 ? 'over capacity' : 'flowing');
h+=row('Monthly upkeep', `$${Math.round(seg.curve.length*type.upkeepPerM*17)}`);
body.innerHTML=h;
} else if(inspectTarget.type==='junction'){
const node=net.nodes.get(inspectTarget.nodeId);
if(!node || node.segments.length<2){ closeITray(); return; }
const ci=CONTROL_INFO[node.control];
title.textContent = `${ci.icon} ${ci.name}`;
sub.textContent = `${node.segments.length}-way junction`;
followBtn.style.display='none';
let h='';
const roadNames=node.segments.map(sid=>{ const s=net.segments.get(sid); return s?ROAD_TYPE_BY_ID[s.typeId].name:null; }).filter(Boolean);
h+=row('Connects', roadNames.join(', '));
if(node.control===CONTROL.ROUNDABOUT){
const g=roundaboutGeom(node);
h+=row('Ring lanes', g.rc);
}
if(node.control===CONTROL.SIGNAL){
h+=row('Signal', currentPhase(node)===0 ? 'A green / B red' : 'B green / A red');
}
body.innerHTML=h;
}
}
/* ── economy ────────────────────────────────────────────────────────────── */
/* ── land value ─────────────────────────────────────────────────────────────
Every growable lot carries a value in 0..1, recomputed each census. Relevant
services and closeness to natural water lift it; pollution and (for homes)
heavy traffic drag it down — weighted by what each zone type actually cares
about. Power AND water must both reach a lot before anything on it grows past
a single storey. Land value then drives how tall a lot can build, how fast it
grows, and how much property tax it pays. */
function nearestWaterDist(c,maxR){
for(let r=7;r<=maxR;r+=7){
for(let a=0;a<8;a++){
const x=c.x+Math.cos(a/8*6.28318)*r, y=c.y+Math.sin(a/8*6.28318)*r;
if(isWater(x,y)) return r;
}
}
return Infinity;
}
function lotFrontTraffic(l){
const seg=net.segments.get(l.segment); if(!seg) return 0;
let m=0;
for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') m=Math.max(m,ln.load||0); }
return clamp(m,0,1);
}
function lotRoadClass(l){
const seg=net.segments.get(l.segment); if(!seg) return 0;
return clamp((ROAD_TYPE_BY_ID[seg.typeId].width-6.4)/16.6,0,1); // dirt→0, avenue→1
}
/* ── service reach: multi-source Dijkstra over the road-node graph ─────────
For each service type we flood the network outward from every building of
that type, giving each junction node its road distance to the nearest one.
A lot's coverage then follows the road, not a circle on the map. */
let SVC_NODE_DIST=null; // {[zt]: Map<nodeId, roadDistance>}
let SVC_ON_SEG=null; // Map<segId, {[zt]: [alongPos,...]}>
/**
* @param {Lot} l
*/
function segAlongOf(l){
const seg=net.segments.get(l.segment);
return seg ? clamp(l.startS + (l.frontage||0)*0.5, 0, seg.curve.length) : 0;
}
function serviceReachFields(){
SVC_NODE_DIST={}; SVC_ON_SEG=new Map();
for(const zt of SERVICE_TYPES){
const D=new Map(), pq=[];
const seed=(nid,d)=>{ if(d < (D.get(nid) ?? Infinity)){ D.set(nid,d); pq.push({id:nid,d}); } };
for(const l of lots){
if(l.zone!==zt || l.level<=0) continue;
const seg=net.segments.get(l.segment); if(!seg) continue;
const along=segAlongOf(l), L=seg.curve.length;
seed(seg.startNode, along);
seed(seg.endNode, L-along);
let e=SVC_ON_SEG.get(seg.id); if(!e){ e={}; SVC_ON_SEG.set(seg.id,e); }
(e[zt]=e[zt]||[]).push(along);
}
let guard=0;
while(pq.length && guard++<20000){
let bi=0; for(let i=1;i<pq.length;i++) if(pq[i].d<pq[bi].d) bi=i;
const cur=pq.splice(bi,1)[0];
if(cur.d > (D.get(cur.id) ?? Infinity)) continue;
const node=net.nodes.get(cur.id); if(!node) continue;
for(const sid of node.segments){
const seg=net.segments.get(sid); if(!seg) continue;
const other=seg.startNode===cur.id?seg.endNode:seg.startNode;
const nd=cur.d + seg.curve.length;
if(nd < (D.get(other) ?? Infinity)){ D.set(other,nd); pq.push({id:other,d:nd}); }
}
}
SVC_NODE_DIST[zt]=D;
}
}
/* road distance from a lot to the nearest service of type zt (Infinity = no path) */
function svcRoadDist(l, zt){
const D=SVC_NODE_DIST && SVC_NODE_DIST[zt]; if(!D) return Infinity;
const seg=net.segments.get(l.segment); if(!seg) return Infinity;
const along=segAlongOf(l), L=seg.curve.length;
let best=Math.min(
(D.get(seg.startNode) ?? Infinity) + along,
(D.get(seg.endNode) ?? Infinity) + (L-along)
);
const on=SVC_ON_SEG.get(seg.id);
if(on && on[zt]) for(const a of on[zt]) best=Math.min(best, Math.abs(along-a));
return best;
}
/* 0..1 coverage, 1 at the door, 0 at the edge of reach — shrinks when the
service is short-staffed (see S.civicStaffing in census) */
function svcCover(l, zt){
const d=svcRoadDist(l, zt);
const r=SERVICE_RADIUS[zt]*(S.civicStaffing??1);
return d<r ? 1 - d/r : 0;
}
/* City-wide average of svcCover(l,zt) across every developed lot — used to
gauge "is this service actually good" for the partnership offers below,
the same way S.crime gauges public safety. */
function avgCoverage(zt){
let sum=0,n=0;
for(const l of lots){ if(l.level<=0) continue; sum+=svcCover(l,zt); n++; }
return n?sum/n:0;
}
/* Amenity sources get bucketed into fixed cells so a proximity test walks the
handful of cells its radius actually covers instead of every source in the
city. Land value is the most expensive thing in a tick and, until this, the
one pass still testing everything against everything — a cost that grows
with the square of the city while the answer only ever depends on what is
nearby. Same idea as the lot grid drawing uses, over a different set. */
/* Cells are deliberately wider than the lot grid's, so the radii this pass
actually uses (58–92) land on a 3×3 walk instead of 5×5, and keys are packed
into one number — a string key here meant building well over a hundred
strings per lot per tick, which cost more than the scan it replaced. */
const SRC_CELL=96;
const srcKey=(gx,gy)=>(gx+512)*1024+(gy+512);
/**
* @param {{c:{x:number,y:number}}[]} list
*/
function srcIndex(list){
const m=new Map();
for(const o of list){
const k=srcKey(Math.floor(o.c.x/SRC_CELL),Math.floor(o.c.y/SRC_CELL));
let a=m.get(k); if(!a){ a=[]; m.set(k,a); } a.push(o);
}
return m;
}
/* Strongest nearby source, 1 at zero distance falling to 0 at the radius. The
cell walk is inlined rather than collecting a list first, since this runs a
few times per lot per tick and the garbage would cost more than the scan. */
function proxIdx(idx,c,rad){
if(idx.size===0) return 0; // no prisons, no landmarks, no shops yet — don't walk cells for nothing
const pad=Math.ceil(rad/SRC_CELL);
const gx=Math.floor(c.x/SRC_CELL), gy=Math.floor(c.y/SRC_CELL);
let b=0;
for(let x=gx-pad;x<=gx+pad;x++) for(let y=gy-pad;y<=gy+pad;y++){
const a=idx.get(srcKey(x,y)); if(!a) continue;
for(const o of a){ const d=dist(o.c,c); if(d<rad){ const v=1-d/rad; if(v>b)b=v; } }
}
return b;
}
/* How much each achievement building lifts the blocks around it. These are
landmarks, so the lift lands on every zone rather than one — which is what
makes where you put one an actual decision instead of a formality. */
const ANCHOR_RADIUS=86;
/* How far out a partnership building looks when working out what its own
surroundings are worth — see specialIncomeFor. */
const LOCAL_LV_RADIUS=76;
const ANCHOR_LIFT={ [Z.MALL]:0.10, [Z.BANKHQ]:0.10, [Z.INDUSTRIALHQ]:0.08,
[Z.ARCHITECTHOUSE]:0.06, [Z.NATIONALPARK]:0.12 };
function computeLandValues(){
serviceReachFields();
// amenity sources (parks, industry, offices, shops) still cluster by line of sight
const parks=[], indSrc=[], comSrc=[], offSrc=[], prisonSrc=[], anchorSrc=[], specials=[];
for(const l of lots){
if(l.level<=0) continue;
// every achievement building is a landmark first — it lifts its whole
// neighbourhood on top of whatever specific flavour it also brings
if(ANCHOR_LIFT[l.zone]!==undefined) anchorSrc.push({c:l.c,lift:ANCHOR_LIFT[l.zone]});
if(SPECIAL_INCOME[l.zone]) specials.push(l);
if(l.zone===Z.PARK) parks.push({c:l.c});
else if(l.zone===Z.IND) indSrc.push({c:l.c,level:l.level,sub:l.sub});
else if(l.zone===Z.COM) comSrc.push({c:l.c});
else if(l.zone===Z.OFF) offSrc.push({c:l.c});
// the army base isn't zoned industrial, but heavy vehicles and equipment
// foul the air just the same — it pollutes like a dirty factory
else if(l.zone===Z.ARMYBASE) indSrc.push({c:l.c,level:3,sub:'army'});
else if(l.zone===Z.PRISON) prisonSrc.push({c:l.c});
// a casino is part bailout, part commercial anchor — traffic and late
// nights foul the air a bit, but the crowds are genuinely good for shops
else if(l.zone===Z.CASINO){ indSrc.push({c:l.c,level:1,sub:'casino'}); comSrc.push({c:l.c}); }
// burning coal fouls the air like a mid-size factory; solar and wind
// (not listed here) contribute nothing to this index at all
else if(l.zone===Z.POWER) indSrc.push({c:l.c,level:2,sub:'coal'});
// the achievement buildings read as what they actually are: a mall is a
// shopping district, a bank HQ and a consortium campus are office towers,
// a national park is a park. The consortium runs plant of its own, so
// siting it next to housing costs you the way industry does.
else if(l.zone===Z.MALL) comSrc.push({c:l.c});
else if(l.zone===Z.BANKHQ) offSrc.push({c:l.c});
else if(l.zone===Z.INDUSTRIALHQ){ offSrc.push({c:l.c}); indSrc.push({c:l.c,level:2,sub:'consortium'}); }
else if(l.zone===Z.NATIONALPARK) parks.push({c:l.c});
}
const parkIdx=srcIndex(parks), indIdx=srcIndex(indSrc), comIdx=srcIndex(comSrc),
offIdx=srcIndex(offSrc), prisonIdx=srcIndex(prisonSrc), anchorIdx=srcIndex(anchorSrc);
const prox=(c,idx,rad)=>proxIdx(idx,c,rad);
/* Strongest landmark nearby, scaled by that landmark's own pull. */
const anchor=c=>{
if(!anchorSrc.length) return 0;
const pad=Math.ceil(ANCHOR_RADIUS/SRC_CELL);
const gx=Math.floor(c.x/SRC_CELL), gy=Math.floor(c.y/SRC_CELL);
let b=0;
for(let x=gx-pad;x<=gx+pad;x++) for(let y=gy-pad;y<=gy+pad;y++){
const a=anchorIdx.get(srcKey(x,y)); if(!a) continue;
for(const o of a){ const d=dist(o.c,c); if(d<ANCHOR_RADIUS){ const v=o.lift*(1-d/ANCHOR_RADIUS); if(v>b)b=v; } }
}
return b;
};
const svc=(l,zt)=>svcCover(l,zt);
const gridUtil = !!(S.powerOK && S.waterOK); // power+water is city-wide now
let sum=0,cnt=0,lowUtil=0, crimeSum=0, crimeCnt=0, eduSum=0, eduCnt=0, pollSum=0, pollCnt=0;
for(const l of lots){
if(!GROWABLE.has(l.zone)){ l.lv=0; l.crime=0; l.poll=lotPollutionRaw(l,indIdx); l.cover=0; continue; }
const c=l.c;
l.util = gridUtil;
l.noPower = !S.powerOK; l.noWater = !S.waterOK;
l.cover = gridUtil ? 1 : 0;
const poll=lotPollutionRaw(l,indIdx); l.poll=poll;
if(l.level>0){ pollSum+=poll; pollCnt++; }
const traf=lotFrontTraffic(l);
// crime: fed by pollution + passing traffic, held down by police cover;
// nightlife districts run a little rowdier than the rest of the block
const police=svc(l,Z.POLICE);
const nightlifeBump = (l.zone===Z.COM && l.sub==='nightlife') ? 0.10 : 0;
// a federal prison is not a good neighbor — inmates transferred, released,
// and visited nearby measurably spook a block regardless of how well
// policed it otherwise is
const prisonBump = prox(c,prisonIdx,90)*0.22;
l.crime = (l.level>0 && (l.zone===Z.RES||l.zone===Z.COM))
? clamp(0.52*poll + 0.5*(1-police) + 0.14*traf - 0.34 + nightlifeBump + prisonBump, 0, 1) : 0;
if(l.crime>0||(l.level>0&&(l.zone===Z.RES||l.zone===Z.COM))){ crimeSum+=l.crime; crimeCnt++; }
// education: how well schools reach the homes that are actually occupied
if(l.zone===Z.RES && l.level>0){ eduSum+=svc(l,Z.SCHOOL); eduCnt++; }
const wr=nearestWaterDist(c,49);
const wprox=wr===Infinity?0:1-wr/56;
const park=prox(c,parkIdx,58);
// a landmark lifts every zone around it — that lift is the whole reward
// for putting one where something is already going on
let v=0.26 + (l.util?0.08:0) + anchor(c); // power+water is itself worth something
if(l.zone===Z.RES){
v+=0.18*svc(l,Z.POLICE)+0.13*svc(l,Z.FIRE)+0.20*svc(l,Z.SCHOOL)+0.18*svc(l,Z.CLINIC);
v+=0.17*park+0.22*wprox;
v-=0.48*poll+0.13*traf;
} else if(l.zone===Z.COM){
v+=0.26*prox(c,indIdx,82)+0.24*traf+0.16*svc(l,Z.POLICE);
v+=0.10*park+0.06*wprox+0.08*prox(c,offIdx,70);
v-=0.20*poll;
} else if(l.zone===Z.IND){
v+=0.24*svc(l,Z.FIRE)+0.16*svc(l,Z.POLICE)+0.20*prox(c,offIdx,92);
v+=0.18*lotRoadClass(l)+0.10*(S.powerOK?1:0);
v-=0.08*poll;
} else { // OFF
v+=0.21*svc(l,Z.SCHOOL)+0.16*svc(l,Z.POLICE)+0.13*svc(l,Z.CLINIC);
v+=0.16*park+0.13*wprox+0.11*prox(c,comIdx,68);
v-=0.30*poll+0.06*traf;
}
if(!l.util) v=Math.min(v,0.30);
l.lv=clamp(v,0,1);
if(l.zone!==Z.NONE){ sum+=l.lv; cnt++; if(!l.util&&l.level>0) lowUtil++; }
}
/* An achievement building earns what its surroundings are worth, so each one
needs to know what it actually landed next to. Measured after the main
pass, since it reads the land values that pass just produced. */
for(const l of specials){
let lsum=0,lcnt=0;
for(const o of lotsNear(l.c,LOCAL_LV_RADIUS)){
if(o===l || o.level<=0 || !GROWABLE.has(o.zone)) continue;
if(dist(o.c,l.c)>LOCAL_LV_RADIUS) continue;
lsum+=o.lv||0; lcnt++;
}
l.localLV = lcnt ? lsum/lcnt : 0;
}
S.landValue=cnt?sum/cnt:0;
S.unpoweredLots=lowUtil;
S.crime = crimeCnt ? crimeSum/crimeCnt : 0;
S.education = eduCnt ? eduSum/eduCnt : 0;
S.avgPollution = pollCnt ? pollSum/pollCnt : 0;
}
/**
* @param {Lot} l
*/
function lotPollutionRaw(l,indIdx){
// high-tech plants run far cleaner than heavy industry at the same level
const selfRate = l.sub==='hightech' ? 0.05 : 0.14;
let p=(l.zone===Z.IND&&l.level>0)?selfRate*l.level:0;
// dirty air accumulates from every source in range rather than taking the
// nearest, so this walks the cells itself instead of reusing proxIdx
const pad=Math.ceil(70/SRC_CELL);
const gx=Math.floor(l.c.x/SRC_CELL), gy=Math.floor(l.c.y/SRC_CELL);
for(let x=gx-pad;x<=gx+pad;x++) for(let y=gy-pad;y<=gy+pad;y++){
const arr=indIdx.get(srcKey(x,y)); if(!arr) continue;
for(const o of arr){
const d=dist(o.c,l.c);
// heavy vehicles, generators and ordnance testing make an army base
// foul the air worse than even heavy industry does
const rate = o.sub==='hightech' ? 0.018 : o.sub==='army' ? 0.09 : o.sub==='casino' ? 0.04 : o.sub==='coal' ? 0.07 : 0.05;
if(d>0.5&&d<70) p+=rate*o.level*(1-d/70);
}
}
const seg=net.segments.get(l.segment);
if(seg){
let load=0;
for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') load=Math.max(load,ln.load||0); }
p+=0.32*load*(ROAD_TYPE_BY_ID[seg.typeId].width/12); // busier & bigger roads foul the air more
}
return clamp(p,0,1);
}
/* ── the happiness terms, as tables rather than two inline sums ───────────
Quality of life and business health are both weighted averages, which means
the gap between a city and a perfect score can be attributed exactly: each
term's share of the shortfall is weight × (1 − value). That is what the
happiness tray reads to say "here is your actual problem" instead of making
you click through eleven overlays and cross-reference by eye. Keep the
weights here and nowhere else — a number that lives in two places drifts. */
const QOL_TERMS=[
{ label:'Land value', weight:0.28, overlay:'value', value:()=>S.landValue||0,
fix:'Services, parks and waterfront lift it; pollution, crime and traffic pull it down.' },
{ label:'Safety', weight:0.20, overlay:'crime', value:()=>1-(S.crime||0),
fix:'Spread police stations out — their reach is road distance, not a circle.' },
{ label:'Clean air', weight:0.16, overlay:'pollution', value:()=>1-(S.avgPollution||0),
fix:'Put distance between industry and homes, or let it specialise high-tech.' },
{ label:'Power & water', weight:0.16, overlay:'power', value:()=>1-(S.gridShort||0),
fix:'Build another plant or tower — the grid is city-wide, not a radius.' },
{ label:'Traffic', weight:0.10, overlay:'traffic', value:()=>1-Math.min(1,S.congestion||0),
fix:'Add lanes on the busiest runs, or give through-traffic a second route.' },
{ label:'Jobs balance', weight:0.10, overlay:null, value:()=>1-Math.min(1,Math.abs(S.jobRatio-1)),
fix:'Zone whichever side is short — homes when jobs outnumber workers, jobs when they do not.' },
];
const BIZ_TERMS=[
{ label:'Shops selling', weight:0.4, overlay:'value', value:()=>S.retailCap>0?clamp(S.retailSold/S.retailCap,0,1):0.5,
fix:'Shops are stocked but nobody is buying — more residents, or fewer shops.' },
{ label:'Exports moving', weight:0.3, overlay:'traffic', value:()=>S.exportDemand>0?clamp(S.exported/S.exportDemand,0,1):0.5,
fix:'Orders are going unshipped — run a road to another map edge, or clear the route.' },
{ label:'Orders filled', weight:0.3, overlay:'industry', value:()=>{ const need=(S.retailSold||0)+(S.exportDemand||0); return need>0?clamp(1-(S.unmetGoods||0)/need,0,1):0.5; },
fix:'Demand outruns what industry makes — zone more industry, or let it grow taller.' },
];
/* Both readouts, worst-first: what each term is costing the city right now. */
function qolShortfall(){
return QOL_TERMS.map(t=>({label:t.label,overlay:t.overlay,fix:t.fix,
have:clamp(t.value(),0,1), lost:t.weight*(1-clamp(t.value(),0,1))})).sort((a,b)=>b.lost-a.lost);
}
function bizShortfall(){
return BIZ_TERMS.map(t=>({label:t.label,overlay:t.overlay,fix:t.fix,
have:clamp(t.value(),0,1), lost:t.weight*(1-clamp(t.value(),0,1))})).sort((a,b)=>b.lost-a.lost);
}
function census(){
let pop=0,cj=0,ij=0,oj=0,retailCap=0,goodsProd=0,civicJobs=0;
let pDem=0,wDem=0,pGen=0,wGen=0;
let tourismW=0,nightlifeW=0;
const counts={r:0,c:0,i:0,o:0};
for(const l of lots){
if(l.zone===Z.RES){counts.r++; pop+=RES_POP[l.level]*(l.sub==='apt'?1.6:1);}
else if(l.zone===Z.COM){counts.c++; cj+=COM_JOB[l.level]; retailCap+=COM_RETAIL[l.level];
if(l.level>0){ if(l.sub==='tourism') tourismW+=l.level; else if(l.sub==='nightlife') nightlifeW+=l.level; } }
else if(l.zone===Z.IND){counts.i++; ij+=IND_JOB[l.level]; goodsProd+=IND_GOODS[l.level];}
else if(l.zone===Z.OFF){counts.o++; oj+=OFF_JOB[l.level];}
if(l.level>0){
if(l.zone===Z.POWER) pGen+=POWER_GEN;
else if(l.zone===Z.SOLAR) pGen+=SOLAR_GEN;
else if(l.zone===Z.WIND) pGen+=windGenFor(l);
else if(l.zone===Z.WATER) wGen+=WATER_GEN;
else if(l.zone===Z.RESERVOIR) wGen+=WATER_GEN_HI;
else if(GROWABLE.has(l.zone)){ pDem+=POWER_USE[l.zone][l.level]; wDem+=WATER_USE[l.zone][l.level]; }
else if(CIVIC_UPKEEP[l.zone]){ pDem+=CIVIC_POWER; wDem+=CIVIC_WATER; }
if(CIVIC_JOBS[l.zone]) civicJobs+=CIVIC_JOBS[l.zone];
}
}
pop=Math.round(pop);
// grid balance — >=1 means the whole city is served
S.powerGen=pGen; S.powerDemand=pDem; S.waterGen=wGen; S.waterDemand=wDem;
S.powerRatio = pDem>0 ? pGen/pDem : (pGen>0?2:1);
S.waterRatio = wDem>0 ? wGen/wDem : (wGen>0?2:1);
S.powerOK = pGen>0 && S.powerRatio>=1;
S.waterOK = wGen>0 && S.waterRatio>=1;
S.gridShort = clamp(1 - Math.min(S.powerRatio, S.waterRatio), 0, 1); // 0 = fine, up to 1 = totally unserved
computeLandValues();
const conns=outsideNodes().length;
// tourism districts pull in extra out-of-town visitors; nightlife a bit less so
const visitorPotential = conns*70 + pop*0.05 + tourismW*20 + nightlifeW*10;
const visitors = Math.min(visitorPotential, retailCap*0.45);
const consumption = pop*RES_CONSUMPTION + visitors*VISITOR_SPEND;
const retailSold = Math.min(retailCap, consumption);
const exportDemand = conns*(80 + pop*0.03);
const goodsNeeded = retailSold + exportDemand;
const toRetail = Math.min(goodsProd, retailSold);
const exported = Math.min(Math.max(0,goodsProd-toRetail), exportDemand);
let roadUpkeep=0;
for(const s of net.segments.values()) roadUpkeep += s.curve.length*ROAD_TYPE_BY_ID[s.typeId].upkeepPerM;
let civicUpkeep=0;
for(const l of lots){ const u=CIVIC_UPKEEP[l.zone]; if(u&&l.level>0) civicUpkeep+=u; }
Object.assign(S,{pop,comJobs:cj,indJobs:ij,offJobs:oj,civicJobs,visitors,goodsProd,retailCap,
consumption,retailSold,exportDemand,exported,connections:conns,
unmetGoods:Math.max(0,goodsNeeded-goodsProd)});
S.upkeep=Math.round(roadUpkeep*17+civicUpkeep);
// ── citizen needs: homes vs jobs (civic staff count as real jobs too) ───
const totalJobs=cj+ij+oj+civicJobs;
const workforce=pop*0.55;
S.homes=pop; S.jobs=totalJobs;
S.jobRatio = totalJobs/Math.max(1,workforce);
// more jobs than local workers → wages rise, outsiders commute in, people move here
S.overemployment = clamp((S.jobRatio-1)*0.6, 0, 0.42);
const underEmp = Math.max(0, 0.78-S.jobRatio); // idle workforce
// a hot labor market makes civic jobs hard to fill too — short-staffed
// stations/plants quietly reach less far (see svcCover) until you build
// more housing to hire from
S.civicStaffing = clamp(1 - Math.max(0,S.jobRatio-1)*0.25, 0.6, 1);
// ── crime: pollution + weak policing (computed per-lot in computeLandValues) ──
const crimeMult = 1 - S.crime*0.34;
S.crimeLoss = 0;
// ── traffic: gridlock drags the whole economy ─────────────────────────
const congMult = 1 - Math.min(0.22, (S.congestion||0)*0.16);
// ── brownouts: a short power or water grid throttles the whole city ────
const gridMult = 1 - S.gridShort*0.4;
S.gridLoss = 0;
// A regional trade stipend cushions the first weeks, then fades as the city
// grows its own tax base — softens the early income/expense curve.
const stipend=2600*Math.exp(-pop/160);
// property tax scales with average land value: well-planned cities are worth more
const lvFactor=0.72+0.62*(S.landValue||0);
const wageBoom=1 + S.overemployment*0.5 - underEmp*0.35;
const base=(pop*3.5+totalJobs*2.9)*(S.tax/10)*lvFactor + exported*1.8 + stipend;
const gross=base*wageBoom;
S.crimeLoss=Math.round(gross*(1-crimeMult));
S.gridLoss=Math.round(gross*crimeMult*(1-gridMult));
S.income=Math.round(gross*crimeMult*congMult*gridMult);
const taxPen=(S.tax-9)/26;
const cap=v=>clamp(v,-1,1);
const natural=0.55*Math.exp(-pop/320);
const jobPull=(totalJobs - workforce)/Math.max(50,pop*0.5);
const crimeFlight=Math.max(0,S.crime-0.34)*1.4;
S.demand.r=cap(natural + jobPull*0.85 + S.overemployment*1.1 - crimeFlight - (S.congestion||0)*0.45 - S.gridShort*0.9 - taxPen);
S.demand.c=cap((consumption-retailCap)/Math.max(28,retailCap*0.6+28) - taxPen*0.8);
if(goodsProd < retailSold*0.6) S.demand.c=Math.min(S.demand.c,0.05);
S.demand.i=cap((goodsNeeded-goodsProd)/Math.max(30,goodsProd*0.6+30) - taxPen*0.7);
const officeNeeded=(cj+ij)*OFFICE_RATIO;
S.demand.o=cap((officeNeeded-oj)/Math.max(18,oj*0.7+18) - taxPen*0.7);
if(counts.r===0) S.demand.r=Math.max(S.demand.r,.5);
if(counts.c===0 && (pop>0||conns>0)) S.demand.c=Math.max(S.demand.c,.3);
if(counts.i===0 && exportDemand>0) S.demand.i=Math.max(S.demand.i,.45);
if(counts.o===0 && (cj+ij)>12) S.demand.o=Math.max(S.demand.o,.25);
// ── happiness: quality of life for residents + how business is doing ───
// both are weighted averages of QOL_TERMS / BIZ_TERMS above, summed here so
// the tray's breakdown can never disagree with the number it explains
const qol = pop>0 ? clamp(QOL_TERMS.reduce((a,t)=>a+t.weight*clamp(t.value(),0,1),0),0,1) : 0.5;
const bizHealth = clamp(BIZ_TERMS.reduce((a,t)=>a+t.weight*clamp(t.value(),0,1),0),0,1);
S.qol=qol; S.bizHealth=bizHealth;
S.happiness = pop>0 ? clamp(0.6*qol+0.4*bizHealth,0,1) : 0.6*bizHealth+0.2;
}
/* How built-up the block around a lot is, which caps how tall it can grow.
Only the immediate neighbours can ever count, so this asks the lot grid for
them instead of walking every lot in the city — growth() samples this 28
times a tick and the old scan grew with the square of the city. */
/**
* @param {Lot} l
*/
function density(l){
let n=0;
for(const o of lotsNear(l.c,38)) if(o!==l && o.level>0 && dist(o.c,l.c)<38) n++;
return n;
}
let servicesHintShown=false, utilityHintShown=false;
/* Starts a construction job on a growable lot instead of jumping straight to
the new level. A lot that's still within its vacancy grace period (see
tick()) skips the timer entirely — it's reopening an existing building,
not building a new one. */
/**
* @param {Lot} l
*/
function startConstruction(l,toLevel){
invalidateCityLayer();
const reopening=l.vacant;
l.vacant=false; l.vacantTimer=0; l.lastLevel=0;
if(reopening){
l.level=toLevel;
if(l.zone===Z.RES||l.zone===Z.COM||l.zone===Z.IND) assignSpecialty(l);
return;
}
const days=BUILD_DAYS_BASE+toLevel*BUILD_DAYS_PER_LEVEL;
l.building={toLevel,timer:days,total:days};
}
/* Would a building of this level clear its own road's pavement? A frozen
parcel keeps its distance from the centreline, so a widened road can leave
room for the current storey but not the next. */
/**
* @param {Lot} l
* @param {number} lvl
*/
function lotFitsOwnRoad(l,lvl){
const quad=lotBuildingQuad(l,lvl); if(!quad) return true;
const c=centroid(quad);
const seg=net.segments.get(l.segment);
if(seg && polyOverlapsRoad(quad,c,seg)) return false;
for(const node of net.nodes.values()) if(polyOverlapsNode(quad,c,node)) return false;
return true;
}
/* Runs every day: finishes any construction whose timer has run out, and
lets an unclaimed vacant building's grace period expire back to a plain
empty lot. */
function advanceConstruction(){
for(const l of lots){
if(l.building){
l.building.timer--;
if(l.building.timer<=0){
// a road widened mid-build can leave the target level standing in
// pavement — clamp to the highest level that still fits
let to=l.building.toLevel;
while(to>l.level && !lotFitsOwnRoad(l,to)) to--;
l.level=to;
l.building=null;
if(l.zone===Z.RES||l.zone===Z.COM||l.zone===Z.IND) assignSpecialty(l);
invalidateCityLayer();
}
} else if(l.vacant){
l.vacantTimer--;
if(l.vacantTimer<=0){ l.vacant=false; l.vacantTimer=0; l.lastLevel=0; invalidateCityLayer(); }
}
}
}
/* An achievement building earns what its surroundings are worth: drop a mall
into a thriving district and it prints, plant it on the city's dead edge and
it barely covers its own upkeep. The bailout buildings stay flat on purpose
— a rescue whose size you can't predict isn't much of a rescue. l.localLV is
the average land value of the developed lots around it, set in
computeLandValues. */
/**
* @param {Lot} l
*/
function specialIncomeFor(l){
const base=SPECIAL_INCOME[l.zone]||0;
const deal=PARTNERSHIPS.find(p=>p.zone===l.zone);
if(deal && deal.grant>0) return base;
return Math.round(base*(0.6+0.8*(l.localLV??0.5)));
}
function growth(){
if(lots.length===0) return;
for(let k=0;k<28;k++){
const l=lots[(Math.random()*lots.length)|0];
if(!GROWABLE.has(l.zone) || l.building) continue;
const d = l.zone===Z.RES?S.demand.r : l.zone===Z.COM?S.demand.c : l.zone===Z.IND?S.demand.i : S.demand.o;
const n=density(l);
const densityCap=n>9?3:n>3?2:1;
const lv=l.lv||0;
// land value sets how tall this lot can go; power+water are mandatory for 2+
let lvCap = lv<0.30?1 : lv<0.55?2 : 3;
if(!l.util) lvCap=1;
const maxLv=Math.min(densityCap,lvCap);
if(!l.util && d>0.05 && !utilityHintShown){
utilityHintShown=true;
toast(!S.powerOK ? 'Build a power plant — nothing grows past one storey without power.'
: 'Build a water tower — nothing grows past one storey without water.','warn');
} else if(l.util && lvCap<densityCap && densityCap>1 && !servicesHintShown){
servicesHintShown=true;
toast('Low land value is holding buildings down — add the services and amenities each zone wants.','warn');
}
// A lot below the height its land value can support feels upward pressure on
// its own, even with the RCI meters flat — good land keeps filling in.
const pull = l.level<maxLv ? Math.max(d, 0.09+0.16*lv) : d;
const badCrime = (l.crime||0)>0.5; // people and shops flee crime-ridden blocks
if(pull>0.05 && l.level<maxLv && !badCrime){
l.grow += (pull*0.5+0.05)*(0.45+0.85*lv);
if(l.grow>1){ l.grow=0; if(lotFitsOwnRoad(l,l.level+1)) startConstruction(l,l.level+1); }
} else if((d<-0.3 || l.level>maxLv || badCrime) && l.level>0){
l.grow -= (l.level>maxLv||badCrime) ? 0.13 : 0.07;
if(l.grow<-1){
l.grow=0;
if(l.level===1){ l.lastLevel=1; l.vacant=true; l.vacantTimer=VACANT_DAYS; }
l.level--;
invalidateCityLayer();
}
} else l.grow*=0.95;
}
}
let milestones=new Set();
function tick(){
S.day++;
advanceConstruction();
census(); growth();
// tracked for the National Park partnership — a genuinely happy city
// sustained for months on end, not just a lucky moment
S.happyStreak = (S.happiness>0.85 && S.pop>0) ? (S.happyStreak||0)+1 : 0;
if(S.freePlay){
S.cash=1e9;
} else {
let specialIncome=0;
for(const l of lots) if(l.level>0 && SPECIAL_INCOME[l.zone]) specialIncome+=specialIncomeFor(l);
S.cash += (S.income-S.upkeep+specialIncome)/30;
S.loanInterestMo = S.loan*LOAN_MONTHLY_RATE;
if(S.loan>0) S.cash -= S.loanInterestMo/30;
checkDebtTrigger();
}
checkPartnerships();
[100,500,2000,6000].forEach(m=>{
if(S.pop>=m && !milestones.has(m)){milestones.add(m); toast(`${S.pop.toLocaleString()} residents.`);}
});
if(S.day%38===0){
if(!S.powerOK && S.powerDemand>0)
toast(`⚡ Power shortage — ${Math.round(S.powerGen).toLocaleString()} generated vs ${Math.round(S.powerDemand).toLocaleString()} needed. Build another power plant.`, S.powerGen===0?'bad':'warn');
else if(!S.waterOK && S.waterDemand>0)
toast(`💧 Water shortage — ${Math.round(S.waterGen).toLocaleString()} supplied vs ${Math.round(S.waterDemand).toLocaleString()} needed. Build another water tower.`, S.waterGen===0?'bad':'warn');
}
if(S.crime>0.42 && S.day%40===0){
toast(`Crime is high (${Math.round(S.crime*100)}%). Spread police stations and cut pollution before residents leave.`,'bad');
}
if(S.stuckRoads>=4 && S.day%40===0){
toast(`${S.stuckRoads} roads are gridlocked. Upgrade the busiest ones to add lanes — check the 🚗 overlay.`,'warn');
}
if(Math.random()<0.3) generateFeedPost();
refreshHud();
}
/* ── Spline Social: a little feed of residents, owners and visitors posting
about whatever's actually going on in the city right now — every post is
generated from a real developed lot and the live stats around it, so the
complaints and brags track your actual crime rate, pollution, specialty
mix and tourism, not just canned flavor text. ────────────────────────── */
const FEED_NAMES=['Jordan','Casey','Morgan','Riley','Devon','Alexis','Sam','Taylor','Jamie','Avery',
'Quinn','Reese','Skylar','Rowan','Blair','Emerson','Finley','Harper','Kai','Sage','Nico','Parker'];
const FEED_AVATARS={resident:['🧑','👩','🧔','👨','🧕','👱','🧑‍🦱','👩‍🦰'],
owner:['🧑‍💼','👩‍💼','👨‍💼','🧑‍🍳'], tourist:['🧳','📸','🕶️','🥾'],
criminal:['🥷','🕶️'], firefighter:['🚒','👨‍🚒']};
function feedHandle(name){ return '@'+name+((Math.random()*90+10)|0); }
function pickDevelopedLot(zones){
const cands=lots.filter(l=>zones.includes(l.zone)&&l.level>0);
return cands.length?pick(cands):null;
}
/* The lot that best embodies a problem — e.g. the single worst-crime house
in the city — so a complaint about it can point somewhere real instead of
a random developed lot that happens not to have the problem at all. */
function worstLot(zones,key){
let best=null,bv=-Infinity;
for(const l of lots){ if(!zones.includes(l.zone)||l.level<=0) continue; const v=l[key]||0; if(v>bv){bv=v;best=l;} }
return bv>0 ? best : null;
}
function bestSvcLot(zones,zt,worst){
let best=null,bv=worst?Infinity:-Infinity;
for(const l of lots){ if(!zones.includes(l.zone)||l.level<=0) continue; const v=svcCover(l,zt); if(worst?v<bv:v>bv){bv=v;best=l;} }
return best;
}
/* A resident/owner post about a nearby partnership opportunity or worry —
only fires when a deal is genuinely close (70%+ of the way there) but not
already resolved, so it reads as "won't be long now" rather than noise
from turn one. Bailout deals (debt-driven) get worried framing instead of
excited framing — nobody's happy the city needs a federal handout. */
function partnershipBuzzLine(city){
const near=PARTNERSHIPS.filter(p=>!S.partnerships[p.id] && p.progress && p.progress()>=0.7 && p.progress()<1);
if(!near.length) return null;
const p=pick(near);
return p.bailout
? `Not gonna lie, I'm a little worried about ${city}'s finances. Wouldn't shock me if we end up leaning on the feds for money soon. 😬`
: `Word is ${p.from} has been quietly scouting ${city}. If we keep this up, don't be surprised if something big gets announced soon. 👀`;
}
function residentPost(){
const l=pickDevelopedLot([Z.RES]); if(!l) return null;
const city=S.cityName||'this town';
const lines=[{t:`Another day in ${city}. Coffee's good, commute's fine. ☕`}];
if((l.crime||0)>0.5) lines.push({t:`Someone tried to break into our place last night. Where are the patrols out here? 😡`,ov:'crime'});
if((l.poll||0)>0.5) lines.push({t:`Can't even hang laundry outside anymore, the air's too thick near us. 🏭`,ov:'pollution'});
if(l.noPower||l.noWater) lines.push({t:`Still waiting on ${l.noPower&&l.noWater?'power AND water':l.noPower?'power':'water'} to catch up with this block. #frustrated`,ov:l.noPower?'power':'water'});
if((S.congestion||0)>0.5) lines.push({t:`That intersection near my place is an absolute parking lot at rush hour. 🚗`,ov:'traffic'});
if(S.happiness>0.7 && (l.lv||0)>0.5) lines.push({t:`Genuinely love it here in ${city}. Can't complain. 🏡✨`});
if(l.sub==='apt') lines.push({t:`Apartment living is growing on me — five minute walk to everything. 🏙️`});
if(l.sub==='single' && (l.lv||0)>0.4) lines.push({t:`Quiet street, good neighbors. Could do without the ${(l.poll||0)>0.3?'smog':'traffic'} though.`,ov:(l.poll||0)>0.3?'pollution':'traffic'});
if(S.overemployment>0.1) lines.push({t:`Got a raise this month — guess the labor market's finally in our favor. 💰`});
if(S.jobRatio<0.7) lines.push({t:`Job hunting's been rough lately. Anyone hiring in ${city}?`});
if(svcCover(l,Z.SCHOOL)<0.15) lines.push({t:`Nearest school is a trek from here. When's ${city} building one closer? 🏫`,ov:'school'});
else if(svcCover(l,Z.SCHOOL)>0.75) lines.push({t:`Kids can walk to school and back on their own now — love that for us. 🎒`,ov:'school'});
const buzz=partnershipBuzzLine(city); if(buzz) lines.push({t:buzz});
const sel=pick(lines);
return {type:'resident', text:sel.t, overlay:sel.ov||null, focus:{kind:'lot',lot:l}};
}
function ownerPost(){
const l=pickDevelopedLot([Z.COM,Z.IND,Z.OFF]); if(!l) return null;
const city=S.cityName||'this town';
const lines=[];
if(l.zone===Z.COM){
if(l.sub==='grocery') lines.push({t:`Shelves are moving fast this week — thanks ${city} for keeping us in business! 🛒`});
if(l.sub==='commodity') lines.push({t:`Restocked the shop, come by before the good stuff's gone. 📦`});
if(l.sub==='tourism') lines.push({t:(S.visitors||0)>40 ? `Best season yet — visitors keep finding us. 🎡` : `Slow week — could use a few more regional connections bringing folks our way.`});
if(l.sub==='nightlife') lines.push({t:`Packed house last night 🌃 — thanks for coming out, ${city}!`});
if(S.retailCap>0 && S.retailSold/S.retailCap<0.5) lines.push({t:`Foot traffic's been thin lately. Anyone actually shopping around here?`});
if(!lines.length) lines.push({t:`Another month in the books at the shop. Steady as she goes.`});
} else if(l.zone===Z.IND){
if(l.sub==='hightech') lines.push({t:`Just automated another line — quieter, cleaner, and the neighbors stopped complaining. 💻`});
else if((l.poll||0)>0.5) lines.push({t:`Regulators are probably going to come knocking eventually lol. Business is booming though.`,ov:'pollution'});
else lines.push({t:`Orders are up again this month — can barely keep pace on the floor.`});
} else {
lines.push({t:`Signed two new leases this month — office market's heating up. 🏢`});
}
if((l.crime||0)>0.5) lines.push({t:`Had a break-in last week. Where's our beat cop? 😤`,ov:'crime'});
if(S.demand.c<-0.3 && l.zone===Z.COM) lines.push({t:`Might have overbuilt retail on this block, ngl.`});
const buzz=partnershipBuzzLine(city); if(buzz) lines.push({t:buzz});
const sel=pick(lines);
return {type:'owner', text:sel.t, overlay:sel.ov||null, focus:{kind:'lot',lot:l}};
}
function touristPost(){
if((S.visitors||0)<5) return null;
const city=S.cityName||'this town';
const spot=pickDevelopedLot([Z.COM]);
// tourists are the one persona actually walking/driving around as a real
// simulated agent — tie the post to a live one when there's one to find,
// so tapping it can jump straight to them instead of a static building
const liveVisitor=agents.find(a=>a.purpose==='visit');
const lines=[
{t:`Stopping through ${city} — that waterfront view is unreal. 📸`},
{t:`Anyone got recommendations near the water? Passing through this weekend.`},
{t:`Regional highway made ${city} a perfect pit stop. On to the next one! 🚗`},
];
if(spot && spot.sub==='nightlife') lines.push({t:`Nightlife here is way better than I expected for a town this size. 🌃`});
if(spot && spot.sub==='tourism') lines.push({t:`Found a cute little spot in ${city} I didn't expect — glad we stopped. 🎡`});
if(S.crime>0.4) lines.push({t:`Heard some sketchy stuff about parts of ${city}, steering clear tonight.`,ov:'crime'});
const sel=pick(lines);
const focus = liveVisitor ? {kind:'agent',agent:liveVisitor} : (spot ? {kind:'lot',lot:spot} : null);
return {type:'tourist', text:sel.t, overlay:sel.ov||null, focus};
}
/* When a service is genuinely bad, the person best positioned to notice
(and least likely to complain about it through official channels) posts
about it instead — a little dark humor that still tracks real coverage
numbers, not just crime/fire in the abstract. Both point at the actual
worst-affected lot in the city, not a random one. */
function criminalPost(){
if(S.crime<0.45) return null;
const city=S.cityName||'this town';
const lines=[
`Not gonna lie, ${city} PD has been real chill lately. Real chill. 😎`,
`Third house this week and not a single patrol car in sight. ${city} really said "self-service." 🙏`,
`Someone asked me if it's safe to walk around ${city} at night. lmao. lmaooo. absolutely not, but for me it's great.`,
`Appreciate the lack of streetlights on my street, ${city} council. Very considerate of you.`,
];
const l=worstLot([Z.RES,Z.COM],'crime');
return {type:'criminal', text:pick(lines), overlay:'crime', focus:l?{kind:'lot',lot:l}:null};
}
function firefighterPost(){
if(avgCoverage(Z.FIRE)>0.35) return null;
const city=S.cityName||'this town';
const lines=[
`Whoever's in charge of station placement in ${city}, we need to talk. We can SEE the fire from the station. We just can't get there in time. 🚒😬`,
`Day 47 of asking ${city} for a second engine. The 12-minute response time isn't a policy choice, it's a math problem.`,
`Someone tell ${city} that "closest hydrant" and "closest station" are supposed to be, like, close to each other.`,
];
const l=bestSvcLot([Z.RES,Z.COM,Z.IND],Z.FIRE,true);
return {type:'firefighter', text:pick(lines), overlay:'fire', focus:l?{kind:'lot',lot:l}:null};
}
/* ── Special partnerships: offers from outsiders, pitched once but never
withdrawn. Each is pitched by the kind of institution that would actually
want in given your city's current state — a struggling budget draws federal
interest, a healthy office district draws a bank, strong fire cover draws
industry that cares about its insurance rates.
Turning one down is "not right now", not "never": the building stays under
Zoning ▸ Special for the rest of the city's life and placing it from there
is how you take the deal. A partner that only asks when you are desperate
would otherwise punish you for hesitating during the exact stretch where
hesitating is reasonable. The signing bonus follows the building rather
than the modal — it is paid when the thing is actually sited, once ever,
which is also what the pitches have always said it was for. */
const PARTNERSHIPS=[
{ id:'army', zone:Z.ARMYBASE, icon:'🪖', from:'U.S. Department of Defense', bailout:true,
title:'A Federal Army Base',
trigger:()=>currentDebt()>30000, progress:()=>currentDebt()/30000,
pitch:city=>`${city} is in real financial trouble, and we've noticed. The Department of Defense will pay $35,000 up front to site a new base, plus steady jobs after that. Be upfront with yourself about the trade-off: heavy vehicles and equipment will foul the air for anyone nearby, it draws real staff out of your workforce, and it won't make residents happy. Demolish it later and you forfeit the $35,000 — this isn't free money, it's a lease.`,
grant:35000 },
{ id:'prison', zone:Z.PRISON, icon:'⛓️', from:'Federal Bureau of Prisons', bailout:true,
title:'A Federal Prison',
trigger:()=>currentDebt()>45000, progress:()=>currentDebt()/45000,
pitch:city=>`${city}'s books are in genuinely bad shape. The Bureau of Prisons will pay $50,000 up front to host a federal facility, plus permanent staffing jobs. Go in with your eyes open: it won't win you any popularity contests, residents nearby will notice, and not happily. Demolish it later and the $50,000 comes right back out of your pocket.`,
grant:50000 },
{ id:'bank', zone:Z.BANKHQ, icon:'🏦', from:'Meridian National Bank',
title:'A Bank Headquarters',
trigger:()=>S.offJobs>=70 && S.demand.o>0.05, progress:()=>S.offJobs/70,
pitch:city=>`This doesn't happen often — ${city}'s office market is genuinely thriving, and it's caught Meridian National's attention. We're looking to relocate our headquarters somewhere with real room to grow, and we'd bring a serious tax base with us.`,
grant:0 },
{ id:'industrial', zone:Z.INDUSTRIALHQ, icon:'🏗️', from:'Titan Industrial Consortium',
title:'An Industrial Consortium',
trigger:()=>S.indJobs>=45 && avgCoverage(Z.FIRE)>0.75, progress:()=>Math.min(S.indJobs/45,avgCoverage(Z.FIRE)/0.75),
pitch:city=>`Frankly, fire coverage like ${city}'s is rare — our underwriters are impressed. Titan Industrial wants to anchor a full consortium campus here. This is the kind of opportunity that doesn't come around twice.`,
grant:0 },
{ id:'architect', zone:Z.ARCHITECTHOUSE, icon:'📐', from:'The Guild of Residential Architects',
title:"An Architect's House",
trigger:()=>lots.filter(l=>l.zone===Z.RES && l.level>=3).length>=20, progress:()=>lots.filter(l=>l.zone===Z.RES&&l.level>=3).length/20,
pitch:city=>`So many fully-developed homes in one city is rare enough that our Guild wants to plant a flag here — a signature architect's residence, built to be seen. It's as much a statement as a building.`,
grant:0 },
{ id:'mall', zone:Z.MALL, icon:'🛍️', from:'Cascade Retail Group',
title:'A Shopping Mall',
trigger:()=>lots.filter(l=>l.zone===Z.COM && l.level>=3).length>=18, progress:()=>lots.filter(l=>l.zone===Z.COM&&l.level>=3).length/18,
pitch:city=>`${city}'s retail scene has matured into something real — enough fully-built shopping districts that Cascade Retail wants to anchor a full mall here. This is a big commitment on our end, and it shows what you've built.`,
grant:0 },
{ id:'park', zone:Z.NATIONALPARK, icon:'🏞️', from:'National Parks Trust',
title:'A National Park designation',
trigger:()=>(S.happyStreak||0)>=90, progress:()=>(S.happyStreak||0)/90,
pitch:city=>`${city} has kept its residents genuinely happy for months on end — that's not easy, and the National Parks Trust noticed. They're offering to designate and help fund a park here. Rare recognition, and it shows.`,
grant:0 },
{ id:'casino', zone:Z.CASINO, icon:'🎰', from:'Golden Horizon Casino Group', bailout:true,
title:'A Casino',
trigger:()=>currentDebt()>18000 || S.comJobs>=35, progress:()=>Math.max(currentDebt()/18000,S.comJobs/35),
pitch:city=>currentDebt()>18000
? `${city}'s finances are what caught our eye, frankly. Golden Horizon will put $25,000 up front to open here, and the crowds we bring are genuinely good for shops nearby. It's not all upside: the traffic and late nights foul the air more than you'd expect, and demolishing it later forfeits the $25,000.`
: `It's ${city}'s foot traffic that caught our eye — the shops here already draw the kind of crowd we build around. No cheque while your books look this healthy: a city doing this well is an opportunity, not a rescue, and the crowds we bring are the payoff. The traffic and late nights do foul the air more than you'd expect.`,
grant:25000, grantIf:()=>currentDebt()>18000 },
];
/* What a partner will actually pay, decided at the moment of the offer. The
casino is pitched for two different reasons and only the bailout one comes
with money — a thriving city courted for its foot traffic has earned an
opportunity, not a rescue, and shouldn't be handed a cheque for it.
Once a partner has settled on a figure for this city it keeps it, even
across a demolition. A bailout partner re-offers after you tear its building
down (that is deliberate — you forfeited the money and have to hit the
threshold again), but the figure must not be re-decided on the way back in:
take the casino free while prosperous, fall into debt, demolish it for a $0
clawback and the second offer would arrive with $25,000 attached. That is a
money printer, and it repeats. The recorded amount closes it while leaving
the honest loop — paid $X, clawed back $X, offered $X again — untouched. */
function grantOffered(p){
const settled=S.grants&&S.grants[p.id];
if(settled!=null) return settled;
return (p.grantIf && !p.grantIf()) ? 0 : p.grant;
}
/* The bonus lands when the building actually goes up, not when you nod at a
modal — every pitch says the money is "to site" the thing, and now it means
it. It also means a partner cannot be paid for a building that never gets
built, and that declining costs you nothing but time. Settled once and
recorded, so placing and re-placing can't farm it. */
function claimPartnerGrant(zone){
const p=PARTNERSHIPS.find(x=>x.zone===zone); if(!p) return;
if(!S.grants) S.grants={};
if(S.grants[p.id]!=null) return; // this partner has already settled up with this city
const g=grantOffered(p);
S.grants[p.id]=g;
if(g>0){ S.cash+=g; toast(`${p.title} sited — ${p.from} pays ${money(g)}.`,'good'); }
}
/* What was actually paid for a building already standing, which is what any
later clawback or relocation fee has to be billed against. Saves written
before grants could vary have no record, so they fall back to the table
figure — which is exactly what they were paid under. */
function grantPaid(p){ const g=S.grants&&S.grants[p.id]; return g==null?p.grant:g; }
let partnerOfferId=null;
function checkPartnerships(){
if(partnerOfferId) return;
if($('fpModal')?.classList.contains('show')) return;
if($('demoModal')?.classList.contains('show')) return;
for(const p of PARTNERSHIPS){
if(S.partnerships[p.id]) continue;
if(p.trigger()){ openPartnerModal(p); return; }
}
}
function openPartnerModal(p){
partnerOfferId=p.id;
const city=S.cityName||'your city';
$('partnerFrom').textContent=`${p.icon} ${p.from}`;
$('partnerTitle').textContent=p.title;
$('partnerText').textContent=p.pitch(city);
const offer=grantOffered(p);
$('partnerGrant').textContent = offer>0 ? `Signing bonus: ${money(offer)}, paid when you site it` : `No signing bonus — the payoff is what it brings once it's built.`;
$('partnerModal').classList.add('show');
}
function closePartnerModal(){ $('partnerModal').classList.remove('show'); partnerOfferId=null; }
function resolvePartnership(accept){
const p=PARTNERSHIPS.find(x=>x.id===partnerOfferId); if(!p){ closePartnerModal(); return; }
// either answer leaves the building on the menu — the difference is only
// whether you are going to use it now, and that is yours to change later
S.partnerships[p.id]=accept?'accepted':'declined';
toast(accept
? `${p.title} unlocked — find it under Zoning ▸ Special.`
: `Passed on ${p.title} for now — it stays under Zoning ▸ Special whenever you want it.`, accept?'good':'warn');
closePartnerModal();
buildDock();
}
/* Shared by every way a partnership building can come down — the Demolish
tool (demolishLot, below) and a road paved straight through it
(clearLot, in 07-lots.js). Either way the signing bonus was a lease on
the land, not a gift, and a bailout has to be re-earned rather than
sitting on the menu for free just because the building died a different
death. No-op for an ordinary lot with no deal attached. */
/**
* @param {Lot} l
*/
function forfeitPartnership(l){
const deal=PARTNERSHIPS.find(p=>p.zone===l.zone); if(!deal) return;
const paid=grantPaid(deal);
if(paid>0){ S.cash-=paid; toast(`${deal.title} demolished — the $${paid.toLocaleString()} signing bonus is forfeit.`,'bad'); }
if(deal.bailout){
// a bailout only showed up because the city needed it — tearing it
// down doesn't earn a free rebuild, it un-earns the deal. Clearing
// the status (instead of leaving it 'accepted') hands it back to
// checkPartnerships(), which re-offers once the trigger is true
// again, same as the first time. The grants record still remembers
// what was paid, so a second acceptance is free but never pays twice.
delete S.partnerships[deal.id];
toast(`${deal.title} is off the table — ${deal.from} only comes back if the city needs it again.`,'warn');
} else {
// no money was ever at stake here, so there's nothing to re-earn —
// every non-bailout partnership building stays on the menu forever
// once it's been offered once, bailout or not.
S.partnerships[deal.id]='accepted';
toast(`${deal.title} can be rebuilt any time from Zoning ▸ Special.`,'good');
}
buildDock();
}
/* The actual demolish of a lot, split out so the Demolish tool can either
run it immediately (an ordinary building) or gate it behind a warning
first (a bailout special — see openBailoutDemoModal). */
/**
* @param {Lot} l
*/
function demolishLot(l){
invalidateCityLayer();
const wasInstant=INSTANT_ZONES.has(l.zone);
const fee=FREE_ZONES.has(l.zone)?0:15;
forfeitPartnership(l);
l.zone=Z.NONE; l.level=0; l.grow=0; l.building=null; l.vacant=false; l.vacantTimer=0; l.lastLevel=0; S.cash-=fee;
if(wasInstant) census();
ovInvalidate(); refreshHud();
}
let pendingBailoutDemo=null;
function openBailoutDemoModal(l,deal){
pendingBailoutDemo=l;
const paid=grantPaid(deal);
$('bailoutDemoTitle').textContent=`Demolish ${deal.title}?`;
$('bailoutDemoText').textContent = (paid>0
? `You'll forfeit the $${paid.toLocaleString()} signing bonus, and `
: ``) + `${deal.from} walks away — it won't be back under Zoning ▸ Special until the city hits the same threshold that brought them here the first time.`;
$('bailoutDemoModal').classList.add('show');
}
function closeBailoutDemoModal(){ $('bailoutDemoModal').classList.remove('show'); pendingBailoutDemo=null; }
/* Available means "this partner has reached out", not "you said yes at the
time" — 'declined' keeps its place on the menu. 'built' is the one status
that hides it, so a city runs one of each at a time. */
function isSpecialUnlocked(zone){
const p=PARTNERSHIPS.find(x=>x.zone===zone);
if(!p) return false;
const st=S.partnerships[p.id];
return st==='accepted' || st==='declined';
}
let feedPosts=[], feedSeq=1, feedUnread=0;
function generateFeedPost(){
const wResident=S.pop, wOwner=(S.comJobs+S.indJobs+S.offJobs), wTourist=(S.visitors||0)*2;
// these two only ever enter the pool once the condition that makes them
// make sense is actually true — a low, flat weight so they show up as an
// occasional aside, not a constant drumbeat, once things are bad enough
const wCriminal = S.crime>0.45 ? 6 : 0;
const wFirefighter = avgCoverage(Z.FIRE)<0.35 ? 4 : 0;
const total=wResident+wOwner+wTourist+wCriminal+wFirefighter;
if(total<=0) return;
let r=Math.random()*total, kind;
if(r<wResident) kind='resident';
else if((r-=wResident)<wOwner) kind='owner';
else if((r-=wOwner)<wTourist) kind='tourist';
else if((r-=wTourist)<wCriminal) kind='criminal';
else kind='firefighter';
const post = kind==='resident'?residentPost() : kind==='owner'?ownerPost() : kind==='tourist'?touristPost()
: kind==='criminal'?criminalPost() : firefighterPost();
if(!post) return;
const name=pick(FEED_NAMES);
feedPosts.unshift({id:feedSeq++, type:post.type, avatar:pick(FEED_AVATARS[post.type]), name,
handle:feedHandle(name), day:S.day, text:post.text, hearts:1+((Math.random()*40)|0),
overlay:post.overlay||null, focus:post.focus||null});
if(feedPosts.length>60) feedPosts.length=60;
feedUnread++;
const dot=$('socialDot'); if(dot) dot.classList.add('show');
if(document.getElementById('socialTray').classList.contains('up')) renderFeed();
}
function feedWhen(day){
const d=S.day-day;
if(d<=0) return 'just now';
if(d===1) return '1d ago';
return `${d}d ago`;
}
function renderFeed(){
const body=$('feedBody');
if(!feedPosts.length){
body.innerHTML=`<p style="font-size:12px;color:var(--muted)">Nobody's posted yet — give the city a little time to grow.</p>`;
return;
}
body.innerHTML=feedPosts.map(p=>`
<div class="feedPost${p.focus?' clickable':''}"${p.focus?` onclick="focusFeedPost(${p.id})"`:''}>
<div class="feedAvatar">${p.avatar}</div>
<div class="feedBody">
<div class="feedHead"><b>${p.name}</b><span>${p.handle} · ${feedWhen(p.day)}</span></div>
<div class="feedText">${p.text}</div>
<div class="feedMeta">❤️ ${p.hearts}${p.focus?' · 📍 tap to see':''}</div>
</div>
</div>`).join('');
}
/* Tapping a post jumps the camera to whoever (or whatever) it's about — a
real lot's actual position for residents/owners/criminal/firefighter
posts, or a live moving agent for a tourist currently passing through —
and opens whichever overlay actually explains the complaint (traffic,
pollution, crime...) so "how bad is it, really" is one tap away. */
function focusFeedPost(id){
const p=feedPosts.find(x=>x.id===id); if(!p||!p.focus) return;
closeSocialTray();
if(p.focus.kind==='agent'){
const v=p.focus.agent;
if(!agents.includes(v)){ toast("That trip's already finished.",'warn'); return; }
mode='move'; buildDock();
if(p.overlay){ S.overlay=p.overlay; setHint(OVERLAY_HINT[p.overlay]||HINTS.move); }
openInspectAgent(v);
return;
}
const l=p.focus.lot;
if(!lots.includes(l)){ toast("That spot's changed since then.",'warn'); return; }
mode='move';
if(p.overlay){ S.overlay=p.overlay; }
buildDock();
setHint(p.overlay?(OVERLAY_HINT[p.overlay]||HINTS.move):HINTS.move);
cam.x=l.c.x; cam.y=l.c.y; cam.z=Math.max(cam.z,4.5);
openInspectLot(l);
}
function openSocialTray(){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
feedUnread=0; const dot=$('socialDot'); if(dot) dot.classList.remove('show');
renderFeed();
$('socialTray').classList.add('up');
$('hudRight').classList.remove('open');
}
function closeSocialTray(){ $('socialTray').classList.remove('up'); }
let freePlayArmed=true, freePlayEverTriggered=false;
function currentDebt(){ return Math.max(0,-S.cash)+S.loan; }
function checkDebtTrigger(){
const debt=currentDebt();
if(freePlayArmed && debt>=FREEPLAY_DEBT){
freePlayArmed=false; freePlayEverTriggered=true;
offerFreePlay(debt);
} else if(!freePlayArmed && debt<FREEPLAY_DEBT*0.7){
freePlayArmed=true; // recovered — re-arm the offer
}
}
/* ── camera ─────────────────────────────────────────────────────────────── */
const cv=/** @type {HTMLCanvasElement} */(document.getElementById('c'));
let ctx=/** @type {CanvasRenderingContext2D} */(cv.getContext('2d'));
/* The static city (terrain, roads, buildings) doesn't change most frames —
only the camera, or the sim advancing a day, actually invalidates it. Cars
and pedestrians move every frame regardless, so they're never part of
this. Rendered once to an offscreen canvas at the current camera/zoom,
then just blitted (one drawImage) every frame until something real
changes — instead of redrawing every building's windows and roof trim
60 times a second even while the camera sits still. */
const cityCv=document.createElement('canvas');
const cityCtx=/** @type {CanvasRenderingContext2D} */(cityCv.getContext('2d'));
// Scratch surface for the ground-detail grain (see drawTerrainDetail below):
// built up in its own buffer so the land mask can punch water/sand back out
// of it — via destination-in — before it's ever composited over the real
// ground, instead of drawing grain everywhere and hoping the blend hides it.
const terrainDetailCv=document.createElement('canvas');
const terrainDetailCtx=/** @type {CanvasRenderingContext2D} */(terrainDetailCv.getContext('2d'));
let cityLayerGen=0, cityLayerGenCached=-1, cityLayerCamX=NaN, cityLayerCamY=NaN, cityLayerCamZ=NaN, cityLayerCamMode=null;
function invalidateCityLayer(){ cityLayerGen++; }
const CITY_BUFFER_BUDGET=10e6; // device px per buffer (cityCv, terrainDetailCv)
let renderPad=360;
let VW=0,VH=0,dpr=1;
// tilt<1 squeezes the ground plane vertically around the screen center,
// faking a camera pitched down toward the horizon instead of straight
// overhead — buildings already lift their roofline on screen for height
// (see drawLot), so pitching the ground to match sells a shallower angle
// instead of a pure bird's-eye view. 1 = exactly today's behavior.
const cam={x:-40,y:-30,z:2.2,tilt:0.82};
function resize(){dpr=Math.min(devicePixelRatio||1,2.5);VW=innerWidth;VH=innerHeight;
renderPad=360;
while((VW+2*renderPad)*(VH+2*renderPad)*dpr*dpr>CITY_BUFFER_BUDGET && renderPad>60) renderPad-=20;
cv.width=VW*dpr;cv.height=VH*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);
cityCv.width=(VW+2*renderPad)*dpr;cityCv.height=(VH+2*renderPad)*dpr;cityCtx.setTransform(dpr,0,0,dpr,0,0);
terrainDetailCv.width=(VW+2*renderPad)*dpr;terrainDetailCv.height=(VH+2*renderPad)*dpr;terrainDetailCtx.setTransform(dpr,0,0,dpr,0,0);
invalidateCityLayer();
// rotating the device or resizing the window can flip whether the mode bar
// fits at full width — re-measure it rather than leaving it stuck in
// whatever fit at the last width
const m=$('modes'); if(m && m.children.length) fitChipRow(m);
}
addEventListener('resize',resize);
const sc=p=>({x:(p.x-cam.x)*cam.z+VW/2,y:(p.y-cam.y)*cam.z*cam.tilt+VH/2});
const scInto=(p,o)=>{o.x=(p.x-cam.x)*cam.z+VW/2;o.y=(p.y-cam.y)*cam.z*cam.tilt+VH/2;return o;};
const wc=(x,y)=>({x:(x-VW/2)/cam.z+cam.x,y:(y-VH/2)/(cam.z*cam.tilt)+cam.y});
/* ── rendering ──────────────────────────────────────────────────────────── */
const COL={sidewalk:"#c9c4b4",lane:"#f4f4f0",
node:"#5b9bd5",joint:"#616d7a",link:"#e0a32566",junction:"#76797d",island:"#7fae5a"};
function drawTerrain(){
ctx.fillStyle='#6a9e47'; ctx.fillRect(0,0,VW,VH);
if(!terrainCanvas) return;
const scale=TEX_SIZE/(TERRAIN_SPAN*2);
const tl=wc(0,0), br=wc(VW,VH);
const sx0=(tl.x+TERRAIN_SPAN)*scale, sy0=(tl.y+TERRAIN_SPAN)*scale;
const sx1=(br.x+TERRAIN_SPAN)*scale, sy1=(br.y+TERRAIN_SPAN)*scale;
const csx0=clamp(sx0,0,TEX_SIZE), csy0=clamp(sy0,0,TEX_SIZE);
const csx1=clamp(sx1,0,TEX_SIZE), csy1=clamp(sy1,0,TEX_SIZE);
if(csx1<=csx0||csy1<=csy0) return;
const wx0=csx0/scale-TERRAIN_SPAN, wy0=csy0/scale-TERRAIN_SPAN;
const wx1=csx1/scale-TERRAIN_SPAN, wy1=csy1/scale-TERRAIN_SPAN;
const d0=sc({x:wx0,y:wy0}), d1=sc({x:wx1,y:wy1});
ctx.imageSmoothingEnabled=true;
if('imageSmoothingQuality' in ctx) ctx.imageSmoothingQuality='high';
ctx.drawImage(terrainCanvas, csx0,csy0,csx1-csx0,csy1-csy0, d0.x,d0.y,d1.x-d0.x,d1.y-d0.y);
drawTerrainDetail(csx0,csy0,csx1,csy1,d0,d1);
}
// Fine speckle overlaid on the terrain bitmap once you're zoomed in close
// enough for its blur to be obvious — see buildTerrainDetailTexture in
// 03-terrain.js for why this is a separate tileable layer rather than a
// bigger baked bitmap. Anchored to world coordinates (not screen pixels) so
// it pans with the ground instead of swimming in place, and faded in with
// zoom so it never shows up as moire at the normal working zoom.
// [csx0,csy0,csx1,csy1]→[d0,d1] is the exact source/dest crop drawTerrain
// just used for the color texture — reused here so the land mask lines up
// with it pixel-for-pixel instead of being cropped independently.
function drawTerrainDetail(csx0,csy0,csx1,csy1,d0,d1){
if(!terrainDetailCanvas || !terrainLandMask || cam.z<1.4) return;
const fade=Math.min(1,(cam.z-1.4)/1.6);
const alpha=0.28*fade;
if(alpha<=0.004) return;
const dw=d1.x-d0.x, dh=d1.y-d0.y;
if(dw<=0||dh<=0) return;
const px=terrainDetailCanvas.width;
const tileWorld=14;
const tilePxX=tileWorld*cam.z, tilePxY=tileWorld*cam.z*cam.tilt;
const origin=sc({x:0,y:0});
const pat=/** @type {CanvasPattern} */(terrainDetailCtx.createPattern(terrainDetailCanvas,'repeat'));
terrainDetailCtx.clearRect(0,0,VW,VH);
terrainDetailCtx.save();
terrainDetailCtx.translate(((origin.x%tilePxX)+tilePxX)%tilePxX, ((origin.y%tilePxY)+tilePxY)%tilePxY);
terrainDetailCtx.scale(tilePxX/px, tilePxY/px);
terrainDetailCtx.fillStyle=pat;
terrainDetailCtx.fillRect(-px, -px, Math.ceil(VW*px/tilePxX)+px, Math.ceil(VH*px/tilePxY)+px);
terrainDetailCtx.restore();
// punch water & sand back out of the grain via the same land mask baked
// alongside the color texture (see buildTerrainTexture) — destination-in
// keeps grain pixels only where the mask is opaque, feathering out through
// its shoreline band instead of a hard cutoff
terrainDetailCtx.save();
terrainDetailCtx.globalCompositeOperation='destination-in';
terrainDetailCtx.drawImage(terrainLandMask, csx0,csy0,csx1-csx0,csy1-csy0, d0.x,d0.y,dw,dh);
terrainDetailCtx.restore();
ctx.save();
ctx.globalAlpha=alpha;
ctx.globalCompositeOperation='overlay';
ctx.drawImage(terrainDetailCv,0,0,VW,VH);
ctx.restore();
}
function drawMapEdge(){
const tl=sc({x:-MAP_EDGE,y:-MAP_EDGE}), br=sc({x:MAP_EDGE,y:MAP_EDGE});
const x0=tl.x,y0=tl.y,x1=br.x,y1=br.y;
// darken everything beyond the buildable rectangle
ctx.save();
ctx.fillStyle='rgba(8,11,15,0.55)';
if(y0>0) ctx.fillRect(0,0,VW,Math.min(y0,VH));
if(y1<VH) ctx.fillRect(0,Math.max(y1,0),VW,VH-Math.max(y1,0));
if(x0>0) ctx.fillRect(0,Math.max(y0,0),Math.min(x0,VW),Math.max(0,Math.min(y1,VH)-Math.max(y0,0)));
if(x1<VW) ctx.fillRect(Math.max(x1,0),Math.max(y0,0),VW-Math.max(x1,0),Math.max(0,Math.min(y1,VH)-Math.max(y0,0)));
// boundary line
ctx.strokeStyle='#e0b84a'; ctx.lineWidth=2; ctx.setLineDash([10,7]); ctx.globalAlpha=0.9;
ctx.strokeRect(x0,y0,x1-x0,y1-y0);
ctx.setLineDash(EMPTY_DASH); ctx.globalAlpha=1;
ctx.restore();
}
/* ── overlays as heat maps ─────────────────────────────────────────────────
Each overlay splats values from lots and roads into a coarse world grid,
which is upscaled and smoothed into a continuous heat map. On top of that,
individual markers still flag specific problem lots (no power/water, a
crime hot spot). */
const OVERLAYS=[
{id:'value', label:'💰 Value'},
{id:'power', label:'⚡ Power'},
{id:'water', label:'💧 Water'},
{id:'police', label:'🚓 Police'},
{id:'fire', label:'🚒 Fire'},
{id:'health', label:'🏥 Health'},
{id:'school', label:'🏫 Schools'},
{id:'traffic', label:'🚗 Traffic'},
{id:'crime', label:'🚨 Crime'},
{id:'industry', label:'🏭 Industry'},
{id:'pollution',label:'☁️ Pollution'},
];
const SVC_OVERLAY={ police:Z.POLICE, fire:Z.FIRE, health:Z.CLINIC, school:Z.SCHOOL };
const SVC_OVERLAY_ZONES={ police:[Z.RES,Z.COM,Z.IND,Z.OFF], fire:[Z.RES,Z.IND], health:[Z.RES,Z.OFF], school:[Z.RES,Z.OFF] };
const SVC_OVERLAY_ICON={ police:'🚓', fire:'🚒', health:'🏥', school:'🏫' };
const OV_RES=160;
const ovCanvas=document.createElement('canvas'); ovCanvas.width=OV_RES; ovCanvas.height=OV_RES;
const _ovVal=new Float32Array(OV_RES*OV_RES), _ovWt=new Float32Array(OV_RES*OV_RES);
let ovKind='', ovStamp=-9999, ovSpan=400;
function ovInvalidate(){ ovStamp=-9999; }
function ovSplat(x,y,val,radWorld){
const c=2*ovSpan/OV_RES, gx=(x+ovSpan)/c, gy=(y+ovSpan)/c, r=radWorld/c, r2=r*r;
const i0=Math.max(0,gx-r|0), i1=Math.min(OV_RES-1,Math.ceil(gx+r));
const j0=Math.max(0,gy-r|0), j1=Math.min(OV_RES-1,Math.ceil(gy+r));
for(let j=j0;j<=j1;j++){ const dy=j+0.5-gy;
for(let i=i0;i<=i1;i++){ const dx=i+0.5-gx, d2=dx*dx+dy*dy;
if(d2>r2) continue;
const w=1-Math.sqrt(d2)/r, idx=j*OV_RES+i;
_ovVal[idx]+=val*w; _ovWt[idx]+=w;
}
}
}
/**
* @param {Segment} seg
*/
function ovReachAt(zt,seg,along){
const D=SVC_NODE_DIST&&SVC_NODE_DIST[zt]; if(!D) return Infinity;
const L=seg.curve.length;
let best=Math.min((D.get(seg.startNode)??Infinity)+along,(D.get(seg.endNode)??Infinity)+(L-along));
const on=SVC_ON_SEG.get(seg.id);
if(on&&on[zt]) for(const a of on[zt]) best=Math.min(best,Math.abs(along-a));
return best;
}
function hsl2rgb(h,s,l){
h=(((h%360)+360)%360)/360;
const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q;
const t=n=>{ n=(n+1)%1; if(n<1/6)return p+(q-p)*6*n; if(n<0.5)return q; if(n<2/3)return p+(q-p)*(2/3-n)*6; return p; };
return [Math.round(t(h+1/3)*255),Math.round(t(h)*255),Math.round(t(h-1/3)*255)];
}
function ovColor(kind,v){
if(kind==='value'||kind==='police'||kind==='fire'||kind==='health'||kind==='school'){
const t=clamp((v-0.12)/0.55,0,1);
const [r,g,b]=hsl2rgb(8+t*118,0.68,0.46);
return [r,g,b,(140+t*80)|0];
}
if(kind==='power'||kind==='water'){ // v encodes the grid ratio, 0.5 = exactly balanced
const [r,g,b]=hsl2rgb(clamp((v-0.34)/0.4,0,1)*125,0.6,0.44);
return [r,g,b,150];
}
if(kind==='crime'){ const [r,g,b]=hsl2rgb(340-v*44,0.78,0.52); return [r,g,b,clamp(v*1.7,0,1)*225|0]; }
if(kind==='pollution'){const [r,g,b]=hsl2rgb(32-v*32,0.62,0.44); return [r,g,b,clamp(v*1.5,0,1)*215|0]; }
if(kind==='traffic'){ const [r,g,b]=hsl2rgb(52-v*52,0.95,0.55); return [r,g,b,(45+v*195)|0]; }
if(kind==='industry'){ const [r,g,b]=hsl2rgb(36,0.85,0.36+v*0.24);return [r,g,b,(60+v*170)|0]; }
return [255,255,255,(v*160)|0];
}
function buildOverlayCanvas(kind){
ovSpan=(typeof MAP_EDGE==='number'?MAP_EDGE:400)+15;
if(SVC_OVERLAY[kind] || !SVC_NODE_DIST) serviceReachFields();
_ovVal.fill(0); _ovWt.fill(0);
const segLoad=seg=>{ let m=0; for(const lid of seg.lanes){ const ln=net.lanes.get(lid); if(ln&&ln.mode==='car') m=Math.max(m,ln.load||0); } return m; };
if(SVC_OVERLAY[kind]){
const zt=SVC_OVERLAY[kind], rad=SERVICE_RADIUS[zt];
for(const seg of net.segments.values()){
const L=seg.curve.length, N=Math.max(2,Math.round(L/9));
for(let k=0;k<=N;k++){
const p=seg.curve.pointAt(k/N), along=L*k/N;
const d=ovReachAt(zt,seg,along);
ovSplat(p.x,p.y, d<rad?1-d/rad:0, 16);
}
}
} else if(kind==='power' || kind==='water'){
const ratio = kind==='power'?S.powerRatio:S.waterRatio;
const enc = clamp(0.5 + (ratio-1)*0.4, 0, 1); // 0.5 = balanced, >0.5 surplus, <0.5 short
for(const l of lots){ if(l.level>0 && (GROWABLE.has(l.zone)||CIVIC_UPKEEP[l.zone])) ovSplat(l.c.x,l.c.y,enc,22); }
} else if(kind==='value'){
for(const l of lots) if(GROWABLE.has(l.zone)&&l.level>0) ovSplat(l.c.x,l.c.y,l.lv||0,20);
} else if(kind==='crime'){
for(const l of lots) if(l.level>0&&(l.zone===Z.RES||l.zone===Z.COM)) ovSplat(l.c.x,l.c.y,l.crime||0,22);
} else if(kind==='industry'){
for(const l of lots) if(l.zone===Z.IND&&l.level>0) ovSplat(l.c.x,l.c.y,Math.min(1,IND_GOODS[l.level]/110),34);
} else if(kind==='pollution'){
for(const l of lots) if(l.zone===Z.IND&&l.level>0) ovSplat(l.c.x,l.c.y,Math.min(1,0.3*l.level),58);
for(const seg of net.segments.values()){
const cong=Math.max(seg.cong||0, segLoad(seg)); if(cong<0.15) continue;
const L=seg.curve.length, N=Math.max(2,Math.round(L/13));
for(let k=0;k<=N;k++){ const p=seg.curve.pointAt(k/N); ovSplat(p.x,p.y,Math.min(1,cong*0.5),22); }
}
} else if(kind==='traffic'){
for(const seg of net.segments.values()){
const g=clamp(Math.max(segLoad(seg)*1.5,(seg.cong||0)*1.15),0,1);
const L=seg.curve.length, N=Math.max(2,Math.round(L/10));
for(let k=0;k<=N;k++){ const p=seg.curve.pointAt(k/N); ovSplat(p.x,p.y,g,10+g*6); }
}
}
const hctx=ovCanvas.getContext('2d');
const img=hctx.createImageData(OV_RES,OV_RES);
for(let idx=0;idx<OV_RES*OV_RES;idx++){
let r=0,g=0,b=0,a=0;
if(_ovWt[idx]>0.02){ const [rr,gg,bb,aa]=ovColor(kind,clamp(_ovVal[idx]/_ovWt[idx],0,1)); r=rr;g=gg;b=bb;a=aa; }
const q=idx*4; img.data[q]=r; img.data[q+1]=g; img.data[q+2]=b; img.data[q+3]=a;
}
hctx.putImageData(img,0,0);
ovKind=kind; ovStamp=S.day;
}
function drawOverlay(){
if(!S.overlay) return;
if(ovKind!==S.overlay || ovStamp<-100 || (S.running && S.day-ovStamp>=3)) buildOverlayCanvas(S.overlay);
const cell=2*ovSpan/OV_RES;
const tl=wc(0,0), br=wc(VW,VH);
const sx0=clamp((tl.x+ovSpan)/cell,0,OV_RES), sy0=clamp((tl.y+ovSpan)/cell,0,OV_RES);
const sx1=clamp((br.x+ovSpan)/cell,0,OV_RES), sy1=clamp((br.y+ovSpan)/cell,0,OV_RES);
if(sx1>sx0 && sy1>sy0){
const d0=sc({x:sx0*cell-ovSpan,y:sy0*cell-ovSpan}), d1=sc({x:sx1*cell-ovSpan,y:sy1*cell-ovSpan});
ctx.save(); ctx.imageSmoothingEnabled=true; ctx.globalAlpha=0.68;
ctx.drawImage(ovCanvas, sx0,sy0,sx1-sx0,sy1-sy0, d0.x,d0.y,d1.x-d0.x,d1.y-d0.y);
ctx.restore();
}
drawOverlayMarkers(S.overlay);
if(S.overlay==='power' || S.overlay==='water') drawGridCard(S.overlay);
}
function drawGridCard(kind){
const gen = kind==='power'?S.powerGen:S.waterGen;
const dem = kind==='power'?S.powerDemand:S.waterDemand;
const ok = kind==='power'?S.powerOK:S.waterOK;
const icon= kind==='power'?'⚡':'💧';
const spare = gen-dem;
const w=Math.min(300, VW-40), h=76, x=(VW-w)/2, y=Math.max(58, VH*0.14);
ctx.save();
ctx.fillStyle='rgba(16,20,26,0.9)'; ctx.strokeStyle= ok?'#3f8f5f':'#d8604d'; ctx.lineWidth=2;
ctx.beginPath(); if(ctx.roundRect) ctx.roundRect(x,y,w,h,12); else ctx.rect(x,y,w,h); ctx.fill(); ctx.stroke();
ctx.fillStyle='#e9edf2'; ctx.textAlign='left'; ctx.textBaseline='alphabetic';
ctx.font='600 15px -apple-system,system-ui,sans-serif';
ctx.fillText(`${icon} ${kind==='power'?'Power grid':'Water grid'}`, x+14, y+24);
ctx.font='13px -apple-system,system-ui,sans-serif'; ctx.fillStyle='#9aa4b0';
ctx.fillText(`${Math.round(gen).toLocaleString()} supplied · ${Math.round(dem).toLocaleString()} used`, x+14, y+44);
ctx.font='600 13px -apple-system,system-ui,sans-serif';
ctx.fillStyle = ok?'#8fd6a3':'#ff8f7f';
ctx.fillText(ok ? `${Math.round(spare).toLocaleString()} to spare`
: (gen===0 ? `no ${kind==='power'?'power source':'water source'} built`
: `short ${Math.round(-spare).toLocaleString()} — build another`), x+14, y+62);
// capacity bar
const bx=x+14, bw=w-28, by=y+h-9;
ctx.fillStyle='#2a323d'; ctx.fillRect(bx,by,bw,4);
ctx.fillStyle = ok?'#4caf6d':'#e0604d';
ctx.fillRect(bx,by, bw*clamp(gen>0?dem/gen:1,0,1), 4);
ctx.restore();
}
function drawOverlayMarkers(k){
const showIcon = cam.z>1.35;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.lineWidth=Math.max(1.3,cam.z*0.55);
if(showIcon) ctx.font=`${Math.round(cam.z*3.4+6)}px sans-serif`;
let drawn=0;
if(k==='value'){
for(const l of lots){
if(drawn>90) break;
if(!GROWABLE.has(l.zone)||l.level<=0||l.util) continue;
const p=sc(l.c);
if(p.x<-14||p.x>VW+14||p.y<-14||p.y>VH+14) continue;
drawn++;
const r=Math.max(5,cam.z*3);
ctx.beginPath(); ctx.arc(p.x,p.y,r,0,7);
ctx.fillStyle='rgba(12,14,18,0.72)'; ctx.fill();
ctx.strokeStyle='#ff6b5a'; ctx.stroke();
if(showIcon){ ctx.fillStyle='#ffe3dc'; ctx.fillText(l.noPower&&l.noWater?'⚡💧':l.noPower?'⚡':'💧', p.x, p.y); }
}
} else if(SVC_OVERLAY[k]){
const zt=SVC_OVERLAY[k], zones=SVC_OVERLAY_ZONES[k], icon=SVC_OVERLAY_ICON[k];
for(const l of lots){
if(drawn>90) break;
if(l.level<=0 || !zones.includes(l.zone)) continue;
if(svcCover(l,zt)>0) continue;
const p=sc(l.c);
if(p.x<-14||p.x>VW+14||p.y<-14||p.y>VH+14) continue;
drawn++;
const r=Math.max(5,cam.z*2.8);
ctx.beginPath(); ctx.arc(p.x,p.y,r,0,7);
ctx.fillStyle='rgba(12,14,18,0.7)'; ctx.fill();
ctx.strokeStyle='#ff6b5a'; ctx.stroke();
if(showIcon){ ctx.fillStyle='#ffe3dc'; ctx.fillText(icon, p.x, p.y); }
}
} else if(k==='crime'){
if(!showIcon) return;
for(const l of lots){
if(drawn>60) break;
if((l.crime||0)<0.5||l.level<=0) continue;
const p=sc(l.c);
if(p.x<-14||p.x>VW+14||p.y<-14||p.y>VH+14) continue;
drawn++;
ctx.fillText('🚨', p.x, p.y-cam.z*2.5);
}
}
}
/* Everything here is "the city as it stands right now" — nothing in this
function depends on performance.now(), agent positions, or anything else
that changes moment to moment, only on the camera and the sim's actual
state. That's what makes it safe to cache: same cam + same city state in,
pixel-identical output out. */
let citySrcX=0, citySrcY=0; // where the viewport window sits inside the padded city buffer
/* Scratches for the per-frame agent draw loop — project the pose and the
look-ahead point without allocating an object per agent per frame. */
const _poseP={x:0,y:0,a:0}, _sp={x:0,y:0}, _aheadW={x:0,y:0}, _ap={x:0,y:0};
/* Renders the city layer into the padded buffer. The temporary VW/VH bump is
what makes every viewport-bound draw call render the margin too, with the
real view centred — see plans/2026-09-19-pan-redraw-design.md. The try/finally
is load-bearing: a throw inside drawCityLayer would otherwise leave VW/VH and
ctx padded permanently while frame() keeps the loop alive. */
function renderCityLayer(){
const rw=VW, rh=VH;
VW=rw+2*renderPad; VH=rh+2*renderPad;
const real=ctx; ctx=cityCtx;
try {
cityCtx.clearRect(0,0,VW,VH);
drawCityLayer();
} finally {
ctx=real; VW=rw; VH=rh;
}
cityLayerCamX=cam.x; cityLayerCamY=cam.y; cityLayerCamZ=cam.z;
cityLayerCamMode=mode; cityLayerGenCached=cityLayerGen;
citySrcX=renderPad; citySrcY=renderPad;
}
// Screen-space silhouettes of tall buildings, rebuilt alongside the cached
// city layer (see drawLot) and reused every frame to keep a moving agent
// from painting on top of a roofline that's supposed to be hiding it.
let occluders=[];
/* Hiding an agent outright the instant its anchor point crosses into a
silhouette made the whole car vanish at once, well before it actually
looked covered. Instead: draw the agent in full, then re-stamp the
already-rendered building pixels (straight from the cached city layer —
same clip trick a matte does) on top wherever a nearby tall building
should be in front of it. Only the truly covered sliver disappears; the
rest of the car stays visible right up to the roofline's real edge. */
function repaintOccludersOver(cx,cy,r){
let any=false;
ctx.save();
ctx.beginPath();
for(const o of occluders){
const minX=o.minX-citySrcX, maxX=o.maxX-citySrcX, minY=o.minY-citySrcY, maxY=o.maxY-citySrcY, frontY=o.frontY-citySrcY;
if(cx+r<minX||cx-r>maxX||cy+r<minY||cy-r>maxY) continue;
if(frontY<=cy-r) continue; // building isn't nearer camera than this agent
for(const poly of o.polys){
ctx.moveTo(poly[0].x-citySrcX,poly[0].y-citySrcY);
for(let i=1;i<poly.length;i++) ctx.lineTo(poly[i].x-citySrcX,poly[i].y-citySrcY);
ctx.closePath();
}
any=true;
}
if(any){ ctx.clip(); ctx.drawImage(cityCv, citySrcX*dpr, citySrcY*dpr, VW*dpr, VH*dpr, 0,0,VW,VH); }
ctx.restore();
}
function drawCityLayer(){
occluders=[];
effectSources=[];
drawTerrain();
drawMapEdge();
// only touch lots whose grid cell is actually near the camera, instead of
// every lot in the whole city — on a large map at a zoomed-out view this
// is most of the win, since it also skips the off-screen ones' screen
// projection and bounds check, not just their (expensive) detail drawing
const visible=visibleLots();
// and once there are enough of them on screen at once, drop every one of
// them to the flat, no-window, no-roof-trim version regardless of zoom —
// that's what makes zoomed-all-the-way-out fast already; this just applies
// the same idea at a normal working zoom on a big city instead of only at
// the extremes
// LOD is decided from the real viewport's lot count, never the padded one —
// the margin's extra lots must not flip a render to the flat version, or the
// layer would change detail as the camera pans.
const vtl=wc(renderPad,renderPad), vbr=wc(VW-renderPad,VH-renderPad);
const simple=lotCountInRect(vtl.x,vtl.y,vbr.x,vbr.y)>LOD_DENSE_THRESHOLD;
// lot aprons (yard/pavement) are flat ground, exactly like the road itself
// — so they go down *before* the road, same reasoning as the comment below
// about buildings going after it. Without this pass a lot's own frontage
// point (see drawLotApron in 20-buildings.js) sits slightly inside the
// road's painted sidewalk width, and drawing the apron on top of that (as
// part of the later per-lot pass, which has to come after the road so
// building roofs can overhang it) would paint pavement or lawn over the
// edge of the sidewalk. Drawing it here means the road pass right after
// always wins that overlap instead.
for(const l of visible) if(l.zone!==Z.NONE) drawLotApronPass(l,simple);
// roads are the flat ground layer, so they have to go down before
// buildings — buildings are drawn as extruded boxes whose roofline is
// pushed upward on screen by their own height, and a road drawn afterward
// would paint straight over (through) any roof that reaches into a
// neighboring road's screen area. overpass decks sit out this pass and
// every junction pass below it — they belong on top of the ground layer,
// not in it (see drawOverpasses)
for(const seg of net.segments.values()) if(!seg.overpass) drawRoadRibbon(seg);
if(cam.z>1.3) for(const seg of net.segments.values()) if(!seg.overpass) drawMarkings(seg,ROAD_TYPE_BY_ID[seg.typeId]);
drawRoundaboutSlips();
drawJunctions();
if(cam.z>1.4) for(const lane of net.lanes.values()) if(lane.kind==='ring' && lane.mode==='car') drawRingDash(lane);
drawSidewalkFillets();
drawJunctionMarkings();
drawOutsideMarkers();
// scenery goes down before the buildings pass below so a tall roofline —
// drawn after, per the reasoning above drawRoadRibbon — naturally paints
// over any tree that happens to fall behind it, same trick as occluders
if(!simple){
drawOpenLandTrees();
for(const seg of net.segments.values()) if(!seg.overpass) drawStreetTrees(seg,ROAD_TYPE_BY_ID[seg.typeId]);
}
drawOverpasses();
for(const l of visible){
if(l.zone===Z.NONE){
if(mode==='zone'||mode==='util'||mode==='utility'||mode==='special'){ tracePoly(l.poly); ctx.strokeStyle="#ffffff1c"; ctx.lineWidth=1; ctx.stroke(); }
else if(cam.z>1.1 && lotSeed(l)<0.4) drawTree(centroid(l.poly), 1.1+(lotSeed(l)*37%1)*0.8);
continue;
}
drawLot(l,simple);
}
}
/* Junction movements only — a small street's mid-block crossings (node null)
are dozens of near-zero-length hops that would bury the junction they're
meant to sit beside. Culled to the viewport and gated by zoom: on a big
city there are thousands of these dashed beziers and only the ones near the
camera, at a zoom where junctions are actually legible, are worth drawing.
Deliberately drawn per-frame on the main canvas rather than cached with the
city layer — the offscreen city context rasterizes these dashed round-cap
strokes in software, where the same overlay costs many times more. */
function drawEditOverlay(){
if(cam.z<1.15) return; // below the zoom where junctions are legible, this is just a hairball
const tl=wc(0,0), br=wc(VW,VH);
const view={minX:tl.x,maxX:br.x,minY:tl.y,maxY:br.y};
for(const link of net.links.values()){
if(link.node==null) continue;
if(link.curve && !aabbHit(link.curve.bounds(3),view)) continue;
strokeCurve(link.curve,1.6,COL.link,[4,4]);
}
for(const n of net.nodes.values()){
const p=sc(n.position);
if(p.x<-20||p.x>VW+20||p.y<-20||p.y>VH+20) continue;
ctx.fillStyle=n.outside?"#7fd6b0":n.kind==="intersection"?COL.node:n.kind==="joint"?COL.joint:"#e0604d";
ctx.beginPath(); ctx.arc(p.x,p.y,n.kind==="intersection"?5.5:4.5,0,7); ctx.fill();
}
}
function draw(){
// redraw the cached static layer only when the camera's actually moved,
// the screen resized, or something about the city itself changed since
// the last frame (see invalidateCityLayer callers) — otherwise reuse the
// pixels from last frame, which is the entire point.
const dxs=(cam.x-cityLayerCamX)*cam.z;
const dys=(cam.y-cityLayerCamY)*cam.z*cam.tilt;
const sx=renderPad+dxs, sy=renderPad+dys;
// cam.tilt is not in the key because it is never assigned; a future tilt control must force a re-render.
if(!isFinite(sx) || !isFinite(sy) ||
cam.z!==cityLayerCamZ || mode!==cityLayerCamMode || cityLayerGen!==cityLayerGenCached ||
sx<0 || sy<0 || sx>2*renderPad || sy>2*renderPad){
renderCityLayer();
} else { citySrcX=Math.round(sx*dpr)/dpr; citySrcY=Math.round(sy*dpr)/dpr; }
ctx.clearRect(0,0,VW,VH);
ctx.drawImage(cityCv, citySrcX*dpr, citySrcY*dpr, VW*dpr, VH*dpr, 0,0,VW,VH);
if(mode==='road' || mode==='edit') drawEditOverlay();
drawControlGlyphs();
drawOverlay();
if(cam.z>1.05){
const showSeats = cam.z>3.4;
for(const v of agents){
if(!agentPoseInto(v,_poseP)) continue;
if(agentUnderDeck(v,_poseP)) continue;
scInto(_poseP,_sp);
const px=_sp.x, py=_sp.y;
if(v.mode==='car'){
const hl=(v.purpose==='freight'?3.2:2.1)*cam.z, hw=(v.purpose==='freight'?1.2:1)*cam.z;
// flat contact shadow in screen space, ahead of the rotate/translate
// below, so it stays pinned to the ground regardless of heading
ctx.fillStyle='rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(px,py+hw*0.35,hl*0.95,hw*0.55,0,0,7); ctx.fill();
// pose.a is the world-space heading; under a pitched ground plane
// (cam.tilt<1) the same heading projects to a different screen
// angle depending on which way it points, so a car drawn with the
// raw world angle would visibly not line up with the road once the
// heading isn't pure east-west. Reproject a short step along the
// world tangent through sc() and rotate by ITS angle instead.
_aheadW.x=_poseP.x+Math.cos(_poseP.a)*0.6; _aheadW.y=_poseP.y+Math.sin(_poseP.a)*0.6;
scInto(_aheadW,_ap);
const screenA=Math.atan2(_ap.y-py, _ap.x-px);
ctx.save(); ctx.translate(px,py); ctx.rotate(screenA);
// darker chassis under a lighter, inset cabin roof — the same
// eave-to-base idea as the buildings, just two flat tones since a
// car's too small on screen for a gradient to read
ctx.fillStyle=shade(v.color,-38);
ctx.fillRect(-hl,-hw,hl*2,hw*2);
const rl=hl*0.64, rw=hw*0.72;
ctx.fillStyle=v.color;
ctx.fillRect(-rl,-rw,rl*2,rw*2);
// windshield sliver toward the front (+x) edge of the cabin
ctx.fillStyle='rgba(185,222,242,0.55)';
ctx.fillRect(rl*0.35,-rw*0.85,rl*0.4,rw*1.7);
if(showSeats && v.purpose!=='freight'){
// 2×2 seat grid: front row = windshield side (+x). Filled = occupied.
const r=Math.max(0.9,cam.z*0.42);
for(let s=0;s<4;s++){
const row=s<2?1:-1, col=(s%2)?1:-1;
const sx=row*rl*0.5, sy=col*rw*0.55;
ctx.beginPath(); ctx.arc(sx,sy,r,0,7);
ctx.fillStyle = s<v.passengers ? '#1b1f26' : 'rgba(27,31,38,0.28)';
ctx.fill();
}
} else if(showSeats && v.purpose==='freight'){
ctx.fillStyle='#1b1f26';
ctx.beginPath(); ctx.arc(hl*0.55,0,Math.max(1,cam.z*0.5),0,7); ctx.fill();
}
ctx.restore();
if(!showSeats && v.passengers>=3){
// far-out zoom: a tiny dot above a full carpool / group
ctx.beginPath(); ctx.arc(px,py-hw-2,Math.max(1,cam.z*0.5),0,7);
ctx.fillStyle='#e8d98a'; ctx.fill();
}
repaintOccludersOver(px,py,hl+2);
} else {
const r=Math.max(1.3,0.6*cam.z);
ctx.beginPath(); ctx.arc(px,py,r,0,7);
ctx.fillStyle=v.color; ctx.fill();
repaintOccludersOver(px,py,r+1);
}
}
}
drawDynamicEffects();
drawInspectHighlight();
if(mode==='road' && anchor && !pending){
const p=sc(anchor.pos);
const pulse=0.5+0.5*Math.sin(performance.now()/220);
ctx.beginPath(); ctx.arc(p.x,p.y,7+pulse*3,0,7);
ctx.strokeStyle='#5b9bd5'; ctx.lineWidth=2; ctx.stroke();
ctx.beginPath(); ctx.arc(p.x,p.y,4,0,7); ctx.fillStyle='#5b9bd5'; ctx.fill();
}
if(pending) drawPending();
}
/* A soft pulsing ring/outline around whatever's currently open in the
inspect tray, so it's obvious at a glance what "this" refers to — agents
are tracked live since they keep moving even while their card is open. */
function drawInspectHighlight(){
// View mode: cyan, whatever's open in the inspect tray. Edit mode: amber,
// whatever's open in the road/junction editor — same idea, so it's always
// obvious at a glance what "this" refers to, view or edit.
if(inspectTarget){
const pulse=0.5+0.5*Math.sin(performance.now()/260);
const col='#5fe0ff';
ctx.save();
ctx.globalAlpha=0.55+0.45*pulse;
if(inspectTarget.type==='lot'){
const l=inspectTarget.lot; if(!lots.includes(l)){ ctx.restore(); return; }
tracePoly(l.poly);
ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,cam.z*1.8); ctx.stroke();
} else if(inspectTarget.type==='road'){
const seg=net.segments.get(inspectTarget.segId); if(!seg){ ctx.restore(); return; }
const w=ROAD_TYPE_BY_ID[seg.typeId].width*cam.z+6+pulse*3;
strokeCurve(seg.curve, w, col, null);
} else if(inspectTarget.type==='junction'){
const node=net.nodes.get(inspectTarget.nodeId); if(!node){ ctx.restore(); return; }
const p=sc(node.position);
ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,cam.z*1.6);
ctx.beginPath(); ctx.arc(p.x,p.y,((node.visRadius||8)+2+pulse*2)*cam.z,0,7); ctx.stroke();
} else if(inspectTarget.type==='agent'){
const v=inspectTarget.agent; if(!agents.includes(v)){ ctx.restore(); return; }
const pose=agentPose(v); if(!pose){ ctx.restore(); return; }
const p=sc(pose.p);
ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,cam.z*1.3);
ctx.beginPath(); ctx.arc(p.x,p.y,8+pulse*3,0,7); ctx.stroke();
}
ctx.restore();
return;
}
if(rSeg!=null || jNode || svcLot){
const pulse=0.5+0.5*Math.sin(performance.now()/260);
const col='#e8b04a';
ctx.save();
ctx.globalAlpha=0.55+0.45*pulse;
if(rSeg!=null){
// the whole connected road, not just the part that was tapped — that's
// what upgrading/downgrading/removing actually affects
for(const id of roadChain(rSeg)){
const s=net.segments.get(id); if(!s) continue;
const w=ROAD_TYPE_BY_ID[s.typeId].width*cam.z+6+pulse*3;
strokeCurve(s.curve, w, col, null);
}
} else if(jNode){
const node=net.nodes.get(jNode.id); if(!node){ ctx.restore(); return; }
const p=sc(node.position);
ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,cam.z*1.6);
ctx.beginPath(); ctx.arc(p.x,p.y,((node.visRadius||8)+2+pulse*2)*cam.z,0,7); ctx.stroke();
} else if(svcLot && lots.includes(svcLot)){
tracePoly(svcLot.poly);
ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,cam.z*1.8); ctx.stroke();
}
ctx.restore();
}
// an in-progress move: amber outline on the building being relocated, plus
// a translucent ghost fill on the destination lot once one's picked — this
// is the visual half of the "confirm before final placement" step
if(moveSource && lots.includes(moveSource)){
const pulse=0.5+0.5*Math.sin(performance.now()/260);
ctx.save(); ctx.globalAlpha=0.55+0.45*pulse;
tracePoly(moveSource.poly);
ctx.strokeStyle='#e8b04a'; ctx.lineWidth=Math.max(2,cam.z*1.8); ctx.stroke();
ctx.restore();
}
if(movePick && lots.includes(movePick)){
const pulse=0.5+0.5*Math.sin(performance.now()/260);
ctx.save();
tracePoly(movePick.poly);
ctx.globalAlpha=0.28+0.14*pulse; ctx.fillStyle='#5fe0ff'; ctx.fill();
ctx.globalAlpha=0.6+0.4*pulse; ctx.strokeStyle='#5fe0ff'; ctx.lineWidth=Math.max(2,cam.z*1.8); ctx.stroke();
ctx.restore();
}
}
function tracePoly(poly){
ctx.beginPath();
const p0=sc(poly[0]); ctx.moveTo(p0.x,p0.y);
for(let i=1;i<poly.length;i++){const p=sc(poly[i]); ctx.lineTo(p.x,p.y);}
ctx.closePath();
}
function fillRibbon(curve,halfWidth,t0,t1,color){
tracePoly(ribbonPoly(curve,halfWidth,t0,t1));
ctx.fillStyle=color; ctx.fill();
}
function drawBridgeRails(seg,sT,eT,hw){
const railOffset=hw+0.35;
ctx.strokeStyle='#c9d0d6'; ctx.lineWidth=Math.max(1.3,cam.z*0.5);
for(const side of [1,-1]){
ctx.beginPath();
const N=16;
for(let i=0;i<=N;i++){
const t=sT+(eT-sT)*(i/N);
const p=sc(seg.curve.offsetAt(t, side*railOffset));
i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y);
}
ctx.stroke();
}
}
/* Where an overpass deck crosses the road below, the road below simply isn't
drawn — a clean gap either side of the deck rather than two ribbons fighting
over the same pixels, which is what actually sells one as being above the
other when you're looking straight down at both.
The gap itself is a slice of the DECK's own ribbon (ribbonPoly on the
deck's curve, spanning enough of the deck's length to clear the under
road's width) rather than a slice of the under road's ribbon — that keeps
the gap's long edges parallel to the deck, angled to match it, instead of
square across the under road the way cutting by the under road's own
t-range would leave them.
This is rendering only. The road underneath stays ONE continuous segment
with its lanes and its traffic intact; nothing about the graph changes, and
cars drive under the deck without noticing it. Recomputed every rebuild,
since reshaping either road moves the crossing. */
function computeOverpassGaps(){
for(const seg of net.segments.values()) seg.underGaps=null;
for(const deck of net.segments.values()){
if(!deck.overpass) continue;
const dt=ROAD_TYPE_BY_ID[deck.typeId];
const deckHw=(dt.sidewalk?dt.width/2+2.4:dt.width/2+EDGE_MARGIN)+UNDER_GAP_MARGIN;
for(const under of net.segments.values()){
if(under.id===deck.id||under.isBridge) continue;
const ut=ROAD_TYPE_BY_ID[under.typeId];
const underHw=(ut.sidewalk?ut.width/2+2.4:ut.width/2+EDGE_MARGIN)+UNDER_GAP_MARGIN;
for(const cr of curveCrossings(deck.curve,under.curve,30)){
const sinT=Math.max(MIN_CROSS_SIN,
Math.abs(Math.sin(ang(deck.curve.tangentAt(cr.tA))-ang(under.curve.tangentAt(cr.tB)))));
const half=underHw/sinT;
const s=deck.curve.distAtT(cr.tA);
const tA0=deck.curve.tAtDist(s-half), tA1=deck.curve.tAtDist(s+half);
if(!under.underGaps) under.underGaps=[];
under.underGaps.push(ribbonPoly(deck.curve, deckHw, tA0, tA1));
}
}
}
}
/* Punches whatever decks-above have blanked out of this segment's ribbon —
each gap is a deck-aligned parallelogram, cut via destination-out so it
clears every layer (sidewalk, edge, surface) already painted in one go. */
function punchUnderGaps(seg){
if(!seg.underGaps||!seg.underGaps.length) return;
ctx.save();
ctx.globalCompositeOperation='destination-out';
ctx.fillStyle='#000';
for(const poly of seg.underGaps){ tracePoly(poly); ctx.fill(); }
ctx.restore();
}
/**
* @param {Segment} seg
*/
/* highway/freeway are limited-access — no frontage, no sidewalk (see
ROAD_TYPES) — so on the ground they always read as a causeway lifted
above the fields either side of it, not a street. A permanent soft
shadow plus periodic pylons sells that even where the deck itself isn't
an explicit overpass (see drawOverpasses for the crossing case). */
const ELEVATED_TYPES={highway:1, freeway:1};
function drawHighwayPylons(seg,sT,eT){
const N=Math.max(1,Math.round(seg.curve.length*(eT-sT)/22));
for(let i=0;i<=N;i++){
const t=sT+(eT-sT)*(i/N);
const p=sc(seg.curve.pointAt(t));
ctx.fillStyle='#00000050';
ctx.fillRect(p.x-1.1*cam.z, p.y, 2.2*cam.z, Math.max(3,cam.z*2.1));
}
}
function drawRoadRibbon(seg){
const type=ROAD_TYPE_BY_ID[seg.typeId];
const L=seg.curve.length;
const sT=seg.curve.tAtDist(seg.startTrim);
const eT=seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
if(eT<=sT+1e-4) return;
const outerHw = type.sidewalk ? type.width/2+2.4 : type.width/2+EDGE_MARGIN;
const elevated = ELEVATED_TYPES[type.id] && !seg.isBridge;
if(elevated){
ctx.save();
ctx.translate(0,Math.max(1.5,cam.z*1.1));
fillRibbon(seg.curve, outerHw+0.8, sT, eT, '#080b0f45');
ctx.restore();
if(cam.z>1.1) drawHighwayPylons(seg,sT,eT);
}
if(seg.isBridge){
ctx.save();
ctx.translate(0,Math.max(2,cam.z*1.7));
fillRibbon(seg.curve, outerHw+1.4, sT, eT, '#080b0f66');
ctx.restore();
}
if(type.sidewalk) fillRibbon(seg.curve, type.width/2+2.4, sT, eT, COL.sidewalk);
fillRibbon(seg.curve, type.width/2+EDGE_MARGIN, sT, eT, type.edgeColor);
fillRibbon(seg.curve, type.width/2, sT, eT, type.surfaceColor);
punchUnderGaps(seg);
if(seg.isBridge) drawBridgeRails(seg, sT, eT, outerHw);
}
/* A tree just outside the sidewalk, evenly spaced along the segment's own
trimmed length (not world-locked, so a short stub still gets the one
tree its midpoint allows). Alternates sides and skips a few slots per
segment so it reads as a street row, not a solid green wall. */
const STREET_TREE_SPACING=15;
function drawStreetTrees(seg,type){
if(!type.sidewalk || cam.z<1.2) return;
const L=seg.curve.length;
const sT=seg.curve.tAtDist(seg.startTrim), eT=seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
if(eT<=sT) return;
const trimmedLen=(eT-sT)*L;
const n=Math.max(1,Math.round(trimmedLen/STREET_TREE_SPACING));
const off=type.width/2+4.2;
for(let i=0;i<=n;i++){
if(((seg.id*7+i*13)%97)/97<0.22) continue; // a few gaps for driveways
const t=sT+(eT-sT)*(i/n);
const side = ((seg.id+i)%2)===0 ? 1 : -1;
drawTree(seg.curve.offsetAt(t, side*off), 1.1+((seg.id*3+i*5)%7)/7*0.6);
}
}
/* Decks go down after the whole ground layer is finished — ribbons, junction
fills, markings — so an overpass reads as passing above everything it
crosses instead of being painted over by whatever happened to come later in
net.segments. A soft drop shadow, offset down-screen, does the height cue. */
function drawOverpasses(){
for(const seg of net.segments.values()){
if(!seg.overpass) continue;
const type=ROAD_TYPE_BY_ID[seg.typeId];
const L=seg.curve.length;
const sT=seg.curve.tAtDist(seg.startTrim);
const eT=seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
if(eT<=sT+1e-4) continue;
const outerHw = type.sidewalk ? type.width/2+2.4 : type.width/2+EDGE_MARGIN;
ctx.save();
ctx.translate(0,Math.max(2,cam.z*1.7));
fillRibbon(seg.curve, outerHw+1.4, sT, eT, '#080b0f66');
ctx.restore();
if(type.sidewalk) fillRibbon(seg.curve, type.width/2+2.4, sT, eT, COL.sidewalk);
fillRibbon(seg.curve, type.width/2+EDGE_MARGIN, sT, eT, type.edgeColor);
fillRibbon(seg.curve, type.width/2, sT, eT, type.surfaceColor);
if(cam.z>1.3) drawMarkings(seg,type);
drawBridgeRails(seg, sT, eT, outerHw);
if(ELEVATED_TYPES[type.id] && cam.z>1.1) drawHighwayPylons(seg,sT,eT);
}
}
/* A ribbon whose half-width tapers linearly from w0 (t=0) to w1 (t=1),
following the curve's own local tangent at every sampled point — used for
roundabout slip lanes, so the pavement itself flares from the road's own
width down to a single ring lane, tangent to the ring, instead of the
road just snapping straight into the circle. */
function taperedRibbon(curve,w0,w1,color){
const N=14, left=[],right=[];
for(let i=0;i<=N;i++){
const t=i/N, w=w0+(w1-w0)*t;
left.push(curve.offsetAt(t,w));
right.push(curve.offsetAt(t,-w));
}
right.reverse();
tracePoly(left.concat(right));
ctx.fillStyle=color; ctx.fill();
}
/* The paved gap between where a road stops (SLIP_GAP short of the ring —
see solveTrims) and the ring's own outer edge: one tapered, tangent-curved
slip lane per lane, entering or exiting the circulating ring. */
function drawRoundaboutSlips(){
for(const node of net.nodes.values()){
if(node.control!==CONTROL.ROUNDABOUT || !node.ringTracks) continue;
for(const lid of node.links){
const link=net.links.get(lid); if(!link) continue;
const isExit = link.controlKind==='roundExit';
if(!isExit && link.controlKind!=='roundEnter') continue;
taperedRibbon(link.curve, isExit?RING_TRACK_W*0.5:2.1, isExit?2.1:RING_TRACK_W*0.5, COL.junction);
}
}
}
function drawJunctions(){
for(const node of net.nodes.values()){
if(!node.poly) continue;
const p=sc(node.position);
if(node.control===CONTROL.ROUNDABOUT && node.ringLanes && node.ringLanes.length){
// a genuine circular road: paved annulus from the outer edge down to the
// island, with dashed lane dividers between concentric ring tracks
// a real circle in world space is an ellipse once the ground plane is
// pitched (cam.tilt<1) — every roundabout arc below squashes its own
// y-radius by cam.tilt to match the flat ribbons and lot polys, which
// already get that for free by going through sc() per point
const g=roundaboutGeom(node);
ctx.beginPath(); ctx.ellipse(p.x,p.y,g.outer*cam.z,g.outer*cam.z*cam.tilt,0,0,7);
ctx.fillStyle=COL.junction; ctx.fill();
ctx.strokeStyle=shade(COL.junction,-16); ctx.lineWidth=Math.max(1,cam.z*0.35); ctx.stroke();
if(g.rc>1){
ctx.strokeStyle="#ffffffa0"; ctx.lineWidth=Math.max(0.8,cam.z*0.5); ctx.setLineDash([2.2*cam.z,2.4*cam.z]);
for(let k=1;k<g.rc;k++){
const r=(g.radii[k-1]+g.radii[k])/2*cam.z;
ctx.beginPath(); ctx.ellipse(p.x,p.y,r,r*cam.tilt,0,0,7); ctx.stroke();
}
ctx.setLineDash(EMPTY_DASH);
}
ctx.beginPath(); ctx.ellipse(p.x,p.y,g.island*cam.z,g.island*cam.z*cam.tilt,0,0,7);
ctx.fillStyle=COL.island; ctx.fill();
ctx.strokeStyle=shade(COL.island,-18); ctx.lineWidth=1.4; ctx.stroke();
continue;
}
// every other junction: fill the polygon formed by connecting the outer
// edge points of every connected road (junctionPolygon) — tight to the
// actual roads, never a fixed blob bigger than what's really there
tracePoly(node.poly);
ctx.fillStyle=COL.junction; ctx.fill();
ctx.strokeStyle=shade(COL.junction,-16); ctx.lineWidth=Math.max(1,cam.z*0.35); ctx.stroke();
}
}
/* The sidewalk/curb line shouldn't just stop dead where a road gets trimmed
back for a junction — carry it around the corner. Regular junctions get a
fillet arc between each pair of adjacent roads' sidewalk edges; roundabouts
get a full sidewalk ring just outside the paved circle. */
function drawSidewalkFillets(){
for(const node of net.nodes.values()){
if(!node.poly) continue;
const p=sc(node.position);
if(node.control===CONTROL.ROUNDABOUT && node.ringLanes && node.ringLanes.length){
const g=roundaboutGeom(node);
ctx.strokeStyle=shade(COL.sidewalk,26); ctx.lineWidth=Math.max(1.5,cam.z*2.2);
const rr=(g.outer+2.6)*cam.z;
ctx.beginPath(); ctx.ellipse(p.x,p.y,rr,rr*cam.tilt,0,0,7); ctx.stroke();
continue;
}
if(node.segments.length<2) continue;
const withAngle=[...node.segments].map(sid=>{
const s=net.segments.get(sid);
return {s, type:ROAD_TYPE_BY_ID[s.typeId], a:ang(segDirAt(s,node.id))};
}).sort((x,y)=>x.a-y.a);
const n=withAngle.length;
for(let i=0;i<n;i++){
const A=withAngle[i], B=withAngle[(i+1)%n];
if(!A.type.sidewalk && !B.type.sidewalk) continue;
const rA = A.type.sidewalk ? A.type.width/2+2.4 : A.type.width/2+EDGE_MARGIN;
const rB = B.type.sidewalk ? B.type.width/2+2.4 : B.type.width/2+EDGE_MARGIN;
let a0=A.a, a1=B.a;
while(a1<a0) a1+=2*Math.PI;
if(a1-a0>Math.PI*1.9) continue; // a straight 2-way pass-through — no real corner
ctx.strokeStyle=shade(COL.sidewalk,26); ctx.lineWidth=Math.max(1.4,cam.z*1.7);
const fr=(rA+rB)/2*cam.z;
ctx.beginPath(); ctx.ellipse(p.x,p.y,fr,fr*cam.tilt,0,a0,a1); ctx.stroke();
}
}
}
/* Where a road meets a junction: a solid stop bar right at the pavement
edge — where drawMarkings' dashed centerline already stops — so the
boundary reads as a clean line instead of dashes just trailing off into
the junction fill. Junctions with sidewalks and 3+ approaches also get
pedestrian crosswalk stripes just past the bar. */
function drawJunctionMarkings(){
if(cam.z<1.15) return;
for(const node of net.nodes.values()){
if(!node.poly) continue;
// a plain 2-way joint between two same-type roads isn't really an
// "intersection" to a driver — it's a bend. drawJointBridgeMarkings()
// below carries the lane markings straight through it instead, so a
// stray stop-bar here would be the only thing left implying a stop.
const isPlainBend = node.segments.length===2 &&
ROAD_TYPE_BY_ID[net.segments.get(node.segments[0]).typeId].id ===
ROAD_TYPE_BY_ID[net.segments.get(node.segments[1]).typeId].id;
for(const sid of node.segments){
const seg=net.segments.get(sid), type=ROAD_TYPE_BY_ID[seg.typeId];
if(type.id==='dirt') continue;
const atStart=seg.startNode===node.id, L=seg.curve.length;
const t=atStart?seg.curve.tAtDist(seg.startTrim):seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
const hw=type.width/2;
const p0=seg.curve.offsetAt(t,-hw), p1=seg.curve.offsetAt(t,hw);
if(!isPlainBend){
const a=sc(p0), b=sc(p1);
ctx.strokeStyle="#ffffffc5"; ctx.lineWidth=Math.max(1.5,cam.z*1.05);
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
// crossings still get marked even on a plain bend — cars treating it
// as one continuous road doesn't mean pedestrians lose their crossing.
// Only draw it once per bend though (from the first segment) — one
// per segment gave two crosswalks at different angles fighting for
// the same small patch of pavement.
if(isPlainBend && sid!==node.segments[0]) continue;
if(type.sidewalk && !type.crossAnywhere && cam.z>=1.35){
const n=Math.max(3,Math.round(type.width/2.2));
ctx.strokeStyle="#ffffffb0"; ctx.lineWidth=Math.max(1.4,cam.z*0.85);
for(let i=0;i<n;i++){
const f0=i/n, f1=(i+0.58)/n;
const c=sc(lerpP(p0,p1,f0)), d=sc(lerpP(p0,p1,f1));
ctx.beginPath(); ctx.moveTo(c.x,c.y); ctx.lineTo(d.x,d.y); ctx.stroke();
}
}
}
}
}
function drawOutsideMarkers(){
for(const n of net.nodes.values()){
if(!n.outside) continue;
const p=sc(n.position);
const seg=net.segments.get(n.segments[0]);
let a=0;
if(seg){ const d=segDirAt(seg,n.id); a=ang(d)+Math.PI; }
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(a);
ctx.fillStyle="#7fd6b0";
const s=Math.max(7,cam.z*4);
ctx.beginPath(); ctx.moveTo(s,0); ctx.lineTo(-s*0.6,-s*0.75); ctx.lineTo(-s*0.6,s*0.75); ctx.closePath(); ctx.fill();
ctx.restore();
if(cam.z>1.1){
ctx.font=`${Math.round(9+cam.z*1.4)}px -apple-system, system-ui, sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle="#7fd6b0";
ctx.fillText("Regional highway", p.x, p.y - Math.max(16,cam.z*9));
}
}
}
function drawRingDash(lane){
ctx.strokeStyle=COL.lane; ctx.globalAlpha=0.5; ctx.lineWidth=1.2; ctx.setLineDash([3,6]);
ctx.beginPath();
for(let i=0;i<=16;i++){ const p=sc(lane.curve.pointAt(i/16)); i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); }
ctx.stroke(); ctx.setLineDash(EMPTY_DASH); ctx.globalAlpha=1;
}
/**
* @param {Segment} seg
*/
function drawMarkings(seg,type){
if(type.id==='dirt'||type.crossAnywhere) return;
// a plain 2-way joint between two same-type roads (a bend, not a real
// intersection) shouldn't show a blank paved gap in the lane markings —
// let each segment's own dash pattern reach a bit past its own trim,
// toward the node, so the two naturally overlap in the middle. Doing it
// this way (instead of a separate bridge curve) keeps the dash phase
// continuous with the rest of that same road, so it doesn't look stitched.
const reach=nodeId=>{
const node=net.nodes.get(nodeId); if(!node||node.segments.length!==2) return 0;
const other=net.segments.get(node.segments.find(sid=>sid!==seg.id));
return (other && other.typeId===seg.typeId) ? type.width/2+2 : 0;
};
const sD=Math.max(0,seg.startTrim-reach(seg.startNode));
const eD=Math.max(0,seg.endTrim-reach(seg.endNode));
const sT=seg.curve.tAtDist(sD), eT=seg.curve.tAtDist(Math.max(sD,seg.curve.length-eD));
if(eT<=sT) return;
const drawDash=(offset,color)=>{
ctx.strokeStyle=color; ctx.lineWidth=Math.max(1,0.7*cam.z); ctx.setLineDash([1.2*cam.z,1.4*cam.z]);
ctx.beginPath();
for(let i=0;i<=14;i++){ const t=sT+(eT-sT)*(i/14); const p=sc(seg.curve.offsetAt(t,offset)); i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); }
ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
};
if(!type.oneWay) drawDash(0,'#f4f4f0');
const byDir={'-1':[],'1':[]};
for(const tpl of type.lanes) byDir[tpl.dir].push(tpl.offset);
for(const dir of ['-1','1']){
const arr=byDir[dir].sort((a,b)=>a-b);
for(let i=0;i<arr.length-1;i++) drawDash((arr[i]+arr[i+1])/2,'#ffffff90');
}
}
function drawControlGlyphs(){
if(cam.z<1.6) return;
for(const node of net.nodes.values()){
if(node.control===CONTROL.NONE || node.control===CONTROL.ROUNDABOUT || node.segments.length<2) continue;
const spokes=[...node.segments].sort((a,b)=>
ang(segDirAt(net.segments.get(a),node.id))-ang(segDirAt(net.segments.get(b),node.id)));
spokes.forEach((sid,i)=>{
const seg=net.segments.get(sid), type=ROAD_TYPE_BY_ID[seg.typeId];
const atStart=seg.startNode===node.id, L=seg.curve.length;
const t=atStart?seg.curve.tAtDist(seg.startTrim):seg.curve.tAtDist(Math.max(seg.startTrim,L-seg.endTrim));
const edge=seg.curve.offsetAt(t,type.width/2+1.6);
const p=sc(edge);
if(node.control===CONTROL.STOP){
ctx.font=`${Math.round(9*cam.z*0.45+8)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('🛑',p.x,p.y);
} else if(node.control===CONTROL.YIELD){
ctx.fillStyle='#e0a325';
ctx.beginPath(); ctx.moveTo(p.x,p.y-6); ctx.lineTo(p.x-6,p.y+5); ctx.lineTo(p.x+6,p.y+5); ctx.closePath(); ctx.fill();
} else if(node.control===CONTROL.SIGNAL){
const on=currentPhase(node)===(i%2);
ctx.beginPath(); ctx.arc(p.x,p.y,5,0,7);
ctx.fillStyle=on?'#5fd67a':'#e0604d'; ctx.fill();
ctx.strokeStyle='#00000066'; ctx.lineWidth=1.4; ctx.stroke();
}
});
}
}
function shadeRGB(hex,amt){
const n=parseInt(hex.slice(1),16);
return [clamp((n>>16)+amt,0,255), clamp(((n>>8)&255)+amt,0,255), clamp((n&255)+amt,0,255)];
}
function shade(hex,amt){ const [r,g,b]=shadeRGB(hex,amt); return `rgb(${r},${g},${b})`; }
function strokeCurve(curve,width,color,dash){
ctx.strokeStyle=color; ctx.lineWidth=Math.max(1,width); ctx.lineCap="round"; ctx.lineJoin="round";
if(dash)ctx.setLineDash(dash);
ctx.beginPath();
const p0=sc(curve.pointAt(0)); ctx.moveTo(p0.x,p0.y);
for(let i=1;i<=22;i++){const p=sc(curve.pointAt(i/22)); ctx.lineTo(p.x,p.y);}
ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
}
/* ── buildings ─────────────────────────────────────────────────────────────
Each lot is drawn as a little extruded block: a footprint sized to its zone
and level, walls shaded by how much they face the camera, a roof with a
crisp edge, lit/dark windows when you're zoomed in, and a few silhouette
details (shop awnings, factory stacks, rooftop units). A per-lot hash gives
each building slightly different height, colour and window pattern so a
block doesn't look stamped out. */
function lotSeed(l){
let h=(Math.round(l.c.x*2.7)*374761393 + Math.round(l.c.y*2.7)*668265263)|0;
h=(h^(h>>>13))>>>0; h=Math.imul(h,1274126177)>>>0;
return ((h^(h>>>16))>>>0)/4294967296;
}
/* Register a building's roof/wall polys so draw() can keep a moving agent
from painting on top of a roofline that's supposed to hide it (see
repaintOccludersOver in 18-render-city.js). base/top/walls are screen
quads from extrudeBox; h is the screen height used for the tall-enough
gate. */
function registerBuildingOccluder(base,top,walls,h){
if(h<=14) return;
const bxs=[base[0].x,base[1].x,base[2].x,base[3].x,top[0].x,top[1].x,top[2].x,top[3].x];
const bys=[base[0].y,base[1].y,base[2].y,base[3].y,top[0].y,top[1].y,top[2].y,top[3].y];
occluders.push({
polys:[top,walls[0].quad,walls[1].quad,walls[2].quad,walls[3].quad],
minX:Math.min(...bxs), maxX:Math.max(...bxs), minY:Math.min(...bys), maxY:Math.max(...bys),
frontY:Math.max(base[0].y,base[1].y,base[2].y,base[3].y),
});
}
function polyPath(pts){ ctx.beginPath(); ctx.moveTo(pts[0].x,pts[0].y); for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x,pts[i].y); ctx.closePath(); }
function slerp(a,b,t){ return {x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t}; }
function bilerp(q,u,v){ return slerp(slerp(q[0],q[1],u), slerp(q[3],q[2],u), v); } // q=[bl,br,tr,tl]
function scalePolyFrom(poly,center,s){ return poly.map(p=>({x:center.x+(p.x-center.x)*s, y:center.y+(p.y-center.y)*s})); }
/* One round tree: a soft ground shadow toward lower-right, a canopy, and a
lit highlight toward the upper-left sun — the same sun every building's
walls and shadows already assume. wp is a world point; size is in world
units so a tree keeps its footprint across zoom like everything else. */
function drawTree(wp,size){
const p=sc(wp), rr=Math.max(2,cam.z*size);
ctx.fillStyle="rgba(20,40,16,0.35)"; ctx.beginPath(); ctx.ellipse(p.x+rr*0.25,p.y+rr*0.45,rr*1.05,rr*0.5,0,0,7); ctx.fill();
ctx.fillStyle="#3f7d33"; ctx.beginPath(); ctx.ellipse(p.x,p.y-rr*0.3,rr,rr*cam.tilt,0,0,7); ctx.fill();
ctx.fillStyle="#6cae4f70"; ctx.beginPath(); ctx.ellipse(p.x-rr*0.35,p.y-rr*0.6,rr*0.55,rr*0.55*cam.tilt,0,0,7); ctx.fill();
}
/* Deterministic per-cell hash for scenery that isn't attached to a lot or a
road — world position in, stable pseudo-random out, so the same patch of
open ground always gets the same trees whether or not it's been redrawn
since (the cached city layer only rebuilds on camera/city changes, but
this keeps it stable even if that assumption ever changes). */
function sceneryHash(gx,gy,salt){
let h=(gx*374761393 + gy*668265263 + salt*2246822519)|0;
h=(h^(h>>>13))>>>0; h=Math.imul(h,1274126177)>>>0;
return ((h^(h>>>16))>>>0)/4294967296;
}
/* Open land the road network hasn't reached: a sparse jittered grid of
trees, skipped over water and skipped near any lot. Lots line both sides
of every road out to LOT_DEPTH (see 02-road-catalog.js), so "no lot
nearby" is a cheap, good-enough stand-in for "not near a road" without
an actual distance-to-curve query over the whole network every redraw. */
const OPEN_TREE_CELL=30;
function drawOpenLandTrees(){
if(cam.z<0.55) return;
const tl=wc(0,0), br=wc(VW,VH);
const gx0=Math.floor(tl.x/OPEN_TREE_CELL)-1, gx1=Math.floor(br.x/OPEN_TREE_CELL)+1;
const gy0=Math.floor(tl.y/OPEN_TREE_CELL)-1, gy1=Math.floor(br.y/OPEN_TREE_CELL)+1;
for(let gx=gx0; gx<=gx1; gx++) for(let gy=gy0; gy<=gy1; gy++){
if(sceneryHash(gx,gy,7)>0.45) continue; // most cells stay bare — a scatter, not a grid
const jx=sceneryHash(gx,gy,11), jy=sceneryHash(gx,gy,13);
const wp={x:(gx+0.2+jx*0.6)*OPEN_TREE_CELL, y:(gy+0.2+jy*0.6)*OPEN_TREE_CELL};
if(outsideMapEdge(wp,4) || isWater(wp.x,wp.y)) continue;
if(lotsNear(wp,22).length) continue; // too close to a road's frontage
drawTree(wp, 1.3+sceneryHash(gx,gy,17)*0.9);
}
}
/* ── lot apron ─────────────────────────────────────────────────────────────
The ring of ground between a parcel's edge and its building footprint used
to show whatever the bare terrain texture underneath happened to be —
every house looked dropped onto raw ground with no yard, and a row of
shops each sat on its own island of grass with a gap of dirt between them.
This fills that ring per zone (lawn, pavement, asphalt) and feathers its
outer edge — a wider, fainter copy of the same fill scaled out from the
lot centroid, the same cheap stand-in for a blur used for the building
shadow below — so the apron blends into the terrain instead of ending in
a hard ring. Two adjacent same-zone lots share an unfeathered, opaque edge
at their common boundary, so a run of shop lots reads as one continuous
slab of concrete rather than a chain of separate patches.
l.poly's first 5 points are the frontage edge (right against the road —
see regenerateLots in 07-lots.js) and the last 5 are the back edge; the
feather only pushes the back/side points outward and leaves the frontage
points exactly where they are, so pavement never blurs into the road it
fronts — that boundary stays a hard cut same as before. */
const LOT_APRON={
[Z.RES]:{fill:'#74a94f', fence:'#f2ecd4', hedge:''},
[Z.COM]:{fill:'#bab6a9', fence:'', hedge:''},
[Z.IND]:{fill:'#8e8b81', fence:'', hedge:''},
[Z.OFF]:{fill:'#b0b4a8', fence:'', hedge:'#4d8a3a'},
};
function apronFeatherPoly(f,center,s){
return f.map((p,i)=> i<5 ? p : {x:center.x+(p.x-center.x)*s, y:center.y+(p.y-center.y)*s});
}
/* Stamps the same fine grain used on the open terrain (see drawTerrainDetail
in 16-camera.js) over a lot's apron, clipped tight to its own polygon —
so a flat fill of solid green or gray doesn't read as a different, smoother
material than the mottled ground right next to it. Reuses the frame's
already-rendered terrainDetailCv rather than resampling anything, and
fades with zoom on the same curve drawTerrainDetail uses so the two never
disagree about when grain should be visible. */
function drawApronGrain(f){
if(cam.z<1.4) return;
const alpha=0.28*Math.min(1,(cam.z-1.4)/1.6);
if(alpha<=0.004) return;
ctx.save();
tracePoly(f); ctx.clip();
ctx.globalAlpha=alpha; ctx.globalCompositeOperation='overlay';
ctx.drawImage(terrainDetailCv,0,0,VW,VH);
ctx.restore();
}
/**
* Runs in its own pass before roads are drawn — see the comment in
* drawCityLayer (18-render-city.js) for why the apron can't just be drawn
* as part of the normal per-lot pass alongside the building. Mirrors the
* two level-0 early-outs at the top of drawLot exactly, so a lot only gets
* an apron once drawLot itself would actually draw a building or a vacant
* shell on it.
* @param {Lot} l
*/
function drawLotApronPass(l,simple){
const f=l.poly;
const sc0=sc(l.c);
if(sc0.x<-70||sc0.x>VW+70||sc0.y<-90||sc0.y>VH+140) return; // off-screen
if(l.level===0 && l.building) return; // ground broken, nothing standing yet
if(l.level===0 && !l.vacant) return; // empty marked lot
const lvl = l.vacant ? (l.lastLevel||1) : l.level;
drawLotApron(l,f,lvl,lotSeed(l),simple);
}
/**
* @param {Lot} l
*/
function drawLotApron(l,f,lvl,rnd,simple){
const spec=LOT_APRON[l.zone]; if(!spec) return;
const outer=apronFeatherPoly(f,l.c,1.28);
ctx.globalAlpha=0.3; ctx.fillStyle=spec.fill; tracePoly(outer); ctx.fill();
ctx.globalAlpha=1; ctx.fillStyle=spec.fill; tracePoly(f); ctx.fill();
if(!simple && cam.z>1.1){
const farm=farmDecoFor(l);
if(farm) drawCropRows(f,farm.seed,l.cls==='rural');
}
drawApronGrain(f);
if(simple || cam.z<1.05) return;
if(l.zone===Z.OFF && cam.z>1.25){
// a few hedge blobs around the plaza edge rather than bare pavement
const ring=scalePolyFrom(f,l.c,0.9);
for(let i=0;i<ring.length;i+=2){
const p=sc(ring[i]);
const rr=Math.max(1.4,cam.z*(1+rnd*0.4));
ctx.fillStyle=spec.hedge; ctx.beginPath(); ctx.arc(p.x,p.y,rr,0,7); ctx.fill();
}
}
}
/* A sub-rectangle of a footprint quad in its own (u along the street, v into
the lot) coordinates — q is [bl,br,tr,tl] exactly as drawLot lays it out. */
function subQuad(q,u0,u1,v0,v1){ return [bilerp(q,u0,v0),bilerp(q,u1,v0),bilerp(q,u1,v1),bilerp(q,u0,v1)]; }
/* One shaded box. Contact shadows, the camera-facing walls with an
eave-to-ground gradient and a rim light on the sun-facing edge, then a flat
roof. base is four screen-space corners, h a screen-space height. Returns
the roof polygon and walls so a caller can put a gable, a tier or a tank on
it. opts: facade(quad) paints windows on a front wall; noShadow for a part
standing on another part's roof; noRoof when the caller draws its own. */
const _topScratch=[], _shadowScratch=[];
function extrudeBox(base,h,col,cvar,opts){
opts=opts||{};
const top=_topScratch; top.length=base.length;
for(let i=0;i<base.length;i++){ const p=base[i], o=top[i]||(top[i]={x:0,y:0}); o.x=p.x; o.y=p.y-h; }
const wallLight=shade(col,-8+cvar), wallMid=shade(col,-34+cvar), wallDark=shade(col,-60+cvar);
if(!opts.noShadow){
// soft double shadow: a wider, fainter cast plus the tighter contact one —
// cheap stand-in for penumbra without an actual blur pass
const sh=_shadowScratch; sh.length=base.length;
for(let i=0;i<base.length;i++){ const p=base[i], o=sh[i]||(sh[i]={x:0,y:0}); o.x=p.x+h*0.34; o.y=p.y+h*0.20; }
ctx.fillStyle="rgba(0,0,0,0.12)"; polyPath(sh); ctx.fill();
for(let i=0;i<base.length;i++){ const p=base[i], o=sh[i]; o.x=p.x+h*0.20; o.y=p.y+h*0.12; }
ctx.fillStyle="rgba(0,0,0,0.24)"; polyPath(sh); ctx.fill();
}
ctx.fillStyle=wallDark; polyPath(base); ctx.fill();
const walls=[];
for(let i=0;i<4;i++){ const a=base[i], b=base[(i+1)%4]; walls.push({my:(a.y+b.y)/2, quad:[a,b,top[(i+1)%4],top[i]]}); }
walls.sort((p,q)=>p.my-q.my);
const span=Math.max(1, walls[3].my-walls[0].my);
for(const w of walls){
const front=(w.my-walls[0].my)/span;
if(front<0.34) continue;
// roofline-to-ground gradient per wall: brighter catching the sky near
// the eave, sinking to wallDark at the base — a stand-in for ambient
// occlusion that a flat two-tone fill didn't have
const topMid=lerpP(w.quad[3],w.quad[2],0.5), botMid=lerpP(w.quad[0],w.quad[1],0.5);
const grad=ctx.createLinearGradient(topMid.x,topMid.y,botMid.x,botMid.y);
grad.addColorStop(0, front>0.72? wallLight : wallMid); grad.addColorStop(1,wallDark);
ctx.fillStyle=grad; polyPath(w.quad); ctx.fill();
if(opts.facade && front>0.55) opts.facade(w.quad);
if(front>0.72){
ctx.strokeStyle=shade(col,26+cvar); ctx.lineWidth=Math.max(0.5,cam.z*0.22);
ctx.beginPath(); ctx.moveTo(w.quad[1].x,w.quad[1].y); ctx.lineTo(w.quad[2].x,w.quad[2].y); ctx.stroke();
}
}
if(!opts.noRoof){
const roofGrad=ctx.createLinearGradient(top[0].x,top[0].y,top[2].x,top[2].y);
roofGrad.addColorStop(0,opts.roofDark||shade(col,-8+cvar)); roofGrad.addColorStop(1,opts.roofLight||shade(col,2+cvar));
polyPath(top); ctx.fillStyle=roofGrad; ctx.fill();
ctx.strokeStyle=shade(col,-42); ctx.lineWidth=Math.max(0.6,cam.z*0.32); ctx.stroke();
}
return {top,walls,wallMid,wallLight,wallDark};
}
/* A storage tank: an upright cylinder standing on screen point c with
radius r and height hh (both screen px), lit from the left. */
function drawTank(c,r,hh,colHex){
ctx.fillStyle="rgba(0,0,0,0.2)"; ctx.beginPath(); ctx.ellipse(c.x+hh*0.18,c.y+r*0.2,r*1.1,r*0.5,0,0,7); ctx.fill();
const g=ctx.createLinearGradient(c.x-r,0,c.x+r,0);
g.addColorStop(0,shade(colHex,-10)); g.addColorStop(0.45,shade(colHex,18)); g.addColorStop(1,shade(colHex,-48));
ctx.fillStyle=g; ctx.fillRect(c.x-r,c.y-hh,r*2,hh);
ctx.beginPath(); ctx.ellipse(c.x,c.y,r,r*0.45,0,0,7); ctx.fill();
ctx.fillStyle=shade(colHex,26); ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,r,r*0.45,0,0,7); ctx.fill();
ctx.strokeStyle=shade(colHex,-40); ctx.lineWidth=Math.max(0.5,cam.z*0.2); ctx.stroke();
}
/**
* @param {Lot} l
*/
function drawFacade(quad,l,rnd){
const cols = l.zone===Z.OFF?4 : 3;
const rows = l.zone===Z.OFF? Math.min(6,l.level*2+1) : Math.min(4,l.level+1);
const salt = Math.floor(rnd*97);
// offices get cool glass reflecting the sky rather than warm lit interiors —
// reads distinctly from homes/shops even in silhouette-only zoom levels
const litCol = l.zone===Z.OFF? "#bfe3ec" : "#f4dc95";
for(let r=0;r<rows;r++){
const vB=(r+0.30)/rows, vT=(r+0.72)/rows;
for(let c=0;c<cols;c++){
const uL=(c+0.24)/cols, uR=(c+0.76)/cols;
const on=((r*7+c*3+salt)%5)<3;
const p=[bilerp(quad,uL,vB),bilerp(quad,uR,vB),bilerp(quad,uR,vT),bilerp(quad,uL,vT)];
ctx.fillStyle = on? litCol : "#1f2530";
polyPath(p); ctx.fill();
// a thin mullion frame separates panes instead of them reading as one
// flat texture once you're close enough to see them individually
ctx.strokeStyle="rgba(10,12,16,0.35)"; ctx.lineWidth=Math.max(0.4,cam.z*0.12); ctx.stroke();
}
}
}
/* Windows on a vacant building: dark holes with a plank nailed across —
nobody's home, and nobody's coming to fix the glass either. */
function drawBoardedFacade(quad,rnd){
const cols=3, rows=2;
for(let r=0;r<rows;r++){
const vB=(r+0.30)/rows, vT=(r+0.72)/rows;
for(let c=0;c<cols;c++){
const uL=(c+0.24)/cols, uR=(c+0.76)/cols;
const p=[bilerp(quad,uL,vB),bilerp(quad,uR,vB),bilerp(quad,uR,vT),bilerp(quad,uL,vT)];
ctx.fillStyle="#12151aee"; polyPath(p); ctx.fill();
if(((r*5+c*3+Math.floor(rnd*11))%3)!==0){
ctx.strokeStyle="#8a6d4c"; ctx.lineWidth=Math.max(1,cam.z*0.35);
ctx.beginPath(); ctx.moveTo(p[0].x,p[0].y); ctx.lineTo(p[2].x,p[2].y); ctx.stroke();
}
}
}
}
/* The footprint band for a lot's building, in lot-local coordinates: d0/d1
run front-to-back, inset is the side margin. Includes the per-lot jitter so
the renderer and the collision code always agree. */
/**
* @param {Lot} l
* @param {number} lvl
*/
function buildingBand(l,lvl){
let d0,d1,inset;
if(l.zone===Z.RES){ const g=[[0.30,0.53],[0.24,0.57],[0.19,0.62]][lvl-1]||[0.24,0.57]; d0=g[0]; d1=g[1]; inset=[0.17,0.11,0.06][lvl-1]||0.11; }
else if(l.zone===Z.COM){ d0=0.12; d1=0.60; inset=0.05; }
else if(l.zone===Z.IND){ d0=0.10; d1=0.64; inset=0.05; }
else { d0=0.21; d1=0.55; inset=0.13; } // OFF
if(l.cls==='row') inset=0; // wall-to-wall: the building fills the frontage
const j=(lotSeed(l)-0.5)*0.05; d0+=j; d1+=j;
return {d0,d1,inset};
}
/* The building's footprint as a world-space [bl,br,tr,tl] quad — the same
quad drawLot extrudes, before screen projection. */
/**
* @param {Lot} l
* @param {number} lvl
*/
function lotBuildingQuad(l,lvl){
const f=l.poly, band=buildingBand(l,lvl), d0=band.d0, d1=band.d1, inset=band.inset;
const eA0=lerpP(f[0],f[9],d0), eA1=lerpP(f[0],f[9],d1);
const eB0=lerpP(f[4],f[5],d0), eB1=lerpP(f[4],f[5],d1);
return [ lerpP(eA0,eB0,inset), lerpP(eB0,eA0,inset), lerpP(eB1,eA1,inset), lerpP(eA1,eB1,inset) ];
}
/* What is standing on this lot, as the renderer would draw it, or null. */
/**
* @param {Lot} l
*/
function standingFootprint(l){
if(UTIL_PAINTER[l.zone]) return utilQuad(l.poly);
const lvl = l.vacant ? (l.lastLevel||1) : l.building ? l.building.toLevel : l.level;
if(lvl<=0) return null;
return lotBuildingQuad(l,lvl);
}
/**
* @param {Lot} l
*/
function drawLot(l,simple){
const info=ZONE_INFO[l.zone], f=l.poly;
const sc0=sc(l.c);
if(sc0.x<-70||sc0.x>VW+70||sc0.y<-90||sc0.y>VH+140) return; // off-screen
const rnd=lotSeed(l);
if(l.level===0 && l.building){
// ground broken, nothing standing yet — an active site, not just a marked lot
tracePoly(f);
ctx.fillStyle="#6b5a3025"; ctx.fill();
ctx.strokeStyle="#e0a325b0"; ctx.lineWidth=Math.max(1,cam.z*0.5); ctx.setLineDash([2.4*cam.z,2*cam.z]); ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
if(cam.z>1.05){
const cp=sc(centroid(f));
ctx.font=`${Math.round(cam.z*4+9)}px sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('🏗️', cp.x, cp.y);
}
return;
}
if(l.level===0 && !l.vacant){
// the dashed parcel outline is a build-tool affordance, not scenery —
// in View/Roads/Edit the zoned-but-empty land is just a faint tint, so
// an undeveloped district doesn't read as a sheet of stickers
tracePoly(f);
ctx.fillStyle=info.color+"20"; ctx.fill();
if(mode==='zone'||mode==='util'||mode==='utility'||mode==='special'){
ctx.strokeStyle=info.color+"88"; ctx.lineWidth=1; ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
}
return;
}
// a vacant lot still shows the shell of whatever was last standing there —
// just dilapidated — rather than reverting to an empty marked lot. lvl is
// "what level to draw" throughout the rest of this function: the real
// level normally, or the last-known level while vacant.
const lvl = l.vacant ? (l.lastLevel||1) : l.level;
// PARK — lawn + a few trees
if(l.zone===Z.PARK){
tracePoly(f); ctx.fillStyle="#5f9c42"; ctx.fill();
if(cam.z>1){
const c=centroid(f);
const t1=rnd*6, t2=rnd*6+3.1;
const p1=sc({x:c.x+Math.cos(t1)*8, y:c.y+Math.sin(t1)*8});
const p2=sc({x:c.x+Math.cos(t2)*8, y:c.y+Math.sin(t2)*8});
const scp=sc(c);
ctx.strokeStyle="#c9b88a"; ctx.lineWidth=Math.max(2,cam.z*1.6);
ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.quadraticCurveTo(scp.x,scp.y,p2.x,p2.y); ctx.stroke();
for(let i=0;i<4;i++){
const t=rnd*9+i*1.9;
drawTree({x:c.x+Math.cos(t)*(6+3*(i%2)), y:c.y+Math.sin(t)*(5+2*(i%2))}, 1.5+rnd*0.7);
}
}
return;
}
// footprint band, per zone / level (lvl, not l.level — see above)
const band=buildingBand(l,lvl);
const d0=band.d0, d1=band.d1;
const base=lotBuildingQuad(l,lvl).map(sc);
const icon=CIVIC_ICON[l.zone];
const subTint=SUB_TINT[l.zone] && l.sub && SUB_TINT[l.zone][l.sub];
// a vacant building fades to a dead, washed-out gray-brown regardless of
// what it used to be zoned as — reads as abandoned at a glance
const col = l.vacant ? '#57534a' : (subTint || info.color);
// taller than the old silhouette (was 2.4/5.6/2.7/3.1/3.7) so the skyline
// reads at a glance instead of needing a close zoom to tell level apart
// from level — pulled back from an even bigger first pass (2.9/7.4/3.5/
//4.0/4.7) because a lot placed close to its road frontage (COM/IND sit
// close by design, and a tight curve can leave any zone with a thin
// margin — see the curve comments in regenerateLots) could lift its roof
// far enough to visibly paint over the road it fronts
const hU = icon?2.7 : l.zone===Z.OFF?6.3 : l.zone===Z.IND?3.05 : l.zone===Z.COM?3.5 : 4.2;
const h=Math.max(3.5, lvl*hU*cam.z*0.5*(0.9+rnd*0.22));
const cvar=Math.round((rnd-0.5)*16);
const isHouse = l.zone===Z.RES && lvl<=2;
const isCourt = l.zone===Z.RES && lvl>=3 && !l.vacant && !simple && l.cls!=='row';
const isTower = l.zone===Z.OFF && lvl>=2 && !l.vacant && !simple;
const isPlant = l.zone===Z.IND && !l.vacant && !simple;
const showWindows = !simple && cam.z>1.35 && h>7 && !icon && !l.vacant;
const facade = showWindows ? (q=>drawFacade(q,l,rnd))
: (!simple && l.vacant && cam.z>1.55 && h>9) ? (q=>drawBoardedFacade(q,rnd)) : null;
const roofLight = icon? shade(col,4) : shade(col, (l.zone===Z.OFF?18:(lvl===3?14:2))+cvar);
const roofDark = icon? shade(col,-14) : shade(col, (l.zone===Z.OFF?2:-8)+cvar);
// Every building is a list of parts — boxes, trees, a tank — each with the
// screen y of its nearest ground edge, drawn far-to-near so a wing or a
// yard tree that sits farther up the screen goes down first and whatever
// stands in front of it paints over it. Cheap painter's sort, per lot.
const parts=[];
const nearYOf=q=>Math.max(q[0].y,q[1].y,q[2].y,q[3].y);
const addTree=(wp,r)=>parts.push({y:sc(wp).y, draw:()=>drawTree(wp,r)});
/** @type {any} */
let main=null; // the part whose roof/walls carry the badges and occluder
const utilPainter=!simple && !l.vacant ? utilPainterFor(l) : null;
if(utilPainter){
// the painters lay out from the parcel quad in SCREEN space (they call
// utilQuad on it), so project the world poly before handing it over.
// standingFootprint's utilQuad(l.poly) is the world-space counterpart.
const holder=utilPainter(parts,base,h,col,cvar,rnd,f.map(sc));
parts.sort((a,b)=>a.y-b.y);
for(const p of parts) p.draw();
if(holder.main) registerBuildingOccluder(holder.base||base,holder.main.top,holder.main.walls,holder.h||h);
return;
}
const farm=isHouse && !l.vacant && !simple ? farmDecoFor(l) : null;
if(isHouse && !farm && !simple && cam.z>1.05){
// one or two trees in the back yard (the band behind the footprint)
const n = rnd>0.45 ? 2 : 1;
for(let i=0;i<n;i++){
const u = i===0 ? 0.22+rnd*0.2 : 0.62+rnd*0.2;
const v = d1+0.16+((rnd*7+i*3)%1)*0.5*(1-d1-0.2);
addTree(lerpP(lerpP(f[0],f[9],v), lerpP(f[4],f[5],v), u), 1.2+((rnd*13+i*5)%1)*0.6);
}
}
if(isHouse && !farm && !simple && cam.z>1.25){
const backPts=fenceScreenPts(f.slice(4,10).concat([f[0]]),3);
parts.push({y:Math.min(backPts[0].y,backPts[backPts.length-1].y), draw:()=>drawFenceLine(backPts,h*0.18,'#f2ecd4')});
}
if(farm){
// farmhouse: gable house up front, barn and silo behind, crops in the
// apron pass and a yard fence — see 20b-buildings-detail.js. A rural
// parcel is wide, so place a normal-sized farmstead inside it instead of
// scaling the art to the whole frontage.
const rural=l.cls==='rural';
const houseQ=subQuad(base, rural?0.06:0.02, rural?0.34:0.55, 0, 1);
const barnQ=subQuad(base, rural?0.54:0.6, rural?0.82:0.98, rural?0.30:0.25, 1);
const siloP=bilerp(base, rural?0.44:0.14, 0.8);
parts.push({y:nearYOf(barnQ), draw:()=>{
const r=extrudeBox(barnQ,h*0.92,col,cvar,{noRoof:true,noShadow:true});
drawGableRoof(r.top,r.walls,h*0.92,col,cvar);
}});
parts.push({y:siloP.y, draw:()=>drawSilo(siloP,Math.max(2,cam.z*1.05),h*1.15)});
parts.push({y:nearYOf(houseQ), draw:()=>{ main=extrudeBox(houseQ,h*0.85,col,cvar,{noRoof:true}); }});
if(cam.z>1.15){
const backPts=fenceScreenPts(f.slice(4,10).concat([f[0]]),3);
const frontPts=fenceScreenPts(f.slice(0,5),3);
parts.push({y:Math.min(backPts[0].y,backPts[backPts.length-1].y), draw:()=>drawFenceLine(backPts,h*0.2,'#d8d0b4')});
parts.push({y:Math.max(frontPts[0].y,frontPts[frontPts.length-1].y), draw:()=>drawFenceLine(frontPts,h*0.2,'#d8d0b4')});
}
} else if(isCourt){
// apartments: three wings around a courtyard that opens toward the
// street, a lawn and a tree in the middle — a block, not a cube
const wing=[subQuad(base,0,0.32,0,1), subQuad(base,0.68,1,0,1), subQuad(base,0.32,0.68,0.62,1)];
const hs=[h, h*(0.92+rnd*0.1), h*1.08];
const court=subQuad(base,0.32,0.68,0,0.62);
parts.push({y:-1e9, draw:()=>{ ctx.fillStyle='#74a94f'; polyPath(court); ctx.fill(); }});
wing.forEach((q,i)=>parts.push({y:nearYOf(q), draw:()=>{
const r=extrudeBox(q,hs[i],col,cvar,{facade,roofLight,roofDark});
if(i===2) main=r;
else if(cam.z>1.6){
const rows=Math.min(4,lvl+1);
drawBalconies(r.walls[3],rows,col);
drawFloorBands(r.walls[3],rows,col);
}
}}));
if(cam.z>1.3){ const cc=bilerp(court,0.5,0.45); addTree(wc(cc.x,cc.y), 1.3); }
if(cam.z>1.6) parts.push({y:nearYOf(wing[2])+0.01, draw:()=>drawHVACCluster(main.top,rnd,col)});
} else if(isTower){
// offices: a glass slab with a set-back upper tier, so the skyline steps
// instead of every office being the same extruded rectangle
const tier=subQuad(base,0.14,0.86,0.14,0.86).map(p=>({x:p.x, y:p.y-h}));
parts.push({y:nearYOf(base), draw:()=>{
main=extrudeBox(base,h,col,cvar,{facade,roofLight,roofDark});
const tierR=extrudeBox(tier,h*(0.35+rnd*0.2),col,cvar,{facade,roofLight,roofDark,noShadow:true});
if(cam.z>1.6){
const rows=Math.min(6,lvl*2+1);
drawFloorBands(main.walls[3],rows,col);
drawGlassSheen(main.walls[3]);
drawHVACCluster(tierR.top,rnd,col);
if(rnd*97%1<0.6) drawAntenna(tierR.top,rnd);
}
}});
} else if(isPlant){
// industry: a long low shed with a sawtooth roof, a tank in the back
// corner once it has grown, and the stacks it always had
const shed=subQuad(base,0,1,0,0.78);
parts.push({y:nearYOf(shed), draw:()=>{
main=extrudeBox(shed,h*0.8,col,cvar,{facade,roofLight,roofDark});
if(cam.z>1.3){
if(l.sub==='hightech'){
drawSolarPatch(main.top,rnd);
ctx.strokeStyle='#7fd6d6'; ctx.lineWidth=Math.max(0.6,cam.z*0.2);
ctx.beginPath(); ctx.moveTo(main.top[3].x,main.top[3].y); ctx.lineTo(main.top[2].x,main.top[2].y); ctx.stroke();
} else {
// sawtooth: alternating light/dark bands across the roof, along u
const t=main.top, n=4;
for(let i=0;i<n;i++){
const u0=i/n, u1=(i+0.5)/n;
ctx.fillStyle=shade(col,-22+cvar);
polyPath([slerp(t[0],t[1],u0),slerp(t[0],t[1],u1),slerp(t[3],t[2],u1),slerp(t[3],t[2],u0)]); ctx.fill();
}
}
}
}});
if(lvl>=2 && cam.z>1.2){
const nTanks=lvl>=3?2:1;
const spots=[[0.8,0.9],[0.62,0.95]];
for(let i=0;i<nTanks;i++){
const tc=bilerp(base,spots[i][0],spots[i][1]);
const rr=Math.max(2,Math.hypot(base[1].x-base[0].x,base[1].y-base[0].y)*0.11*(i?0.8:1));
parts.push({y:tc.y, draw:()=>drawTank(tc,rr,h*0.7*(i?0.85:1), l.sub==='hightech'?'#c9d3d8':'#b9b3a4')});
}
if(l.sub!=='hightech'){
const ct=bilerp(base,0.2,0.86);
parts.push({y:ct.y, draw:()=>drawCoolingTower(ct,Math.max(2.5,cam.z*1.3),h*0.85,'#a3a6a8')});
}
if(cam.z>1.5 && l.sub!=='hightech'){
const p1=bilerp(base,0.76,0.93), p2=bilerp(base,0.74,0.96);
parts.push({y:Math.max(p1.y,p2.y), draw:()=>{
ctx.strokeStyle='#5a5f66'; ctx.lineWidth=Math.max(0.8,cam.z*0.3);
ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(p2.x,p2.y); ctx.stroke();
}});
}
}
} else {
parts.push({y:nearYOf(base), draw:()=>{ main=extrudeBox(base,h,col,cvar,{facade,roofLight,roofDark,noRoof:isHouse&&!simple&&cam.z>1.05&&!l.vacant}); }});
}
parts.sort((a,b)=>a.y-b.y);
for(const p of parts) p.draw();
const top=main.top, walls=main.walls;
if(isHouse && !simple && cam.z>1.05 && !l.vacant){
// gable roof: a ridge parallel to the street, lifted above the eaves,
// two slopes shaded by which one faces the sun (upper-left), and the
// wall colour carried up into the triangular gable ends
const rh=h*0.42;
const m03={x:(top[0].x+top[3].x)/2, y:(top[0].y+top[3].y)/2-rh};
const m12={x:(top[1].x+top[2].x)/2, y:(top[1].y+top[2].y)/2-rh};
ctx.fillStyle=main.wallMid;
polyPath([top[0],top[3],m03]); ctx.fill();
polyPath([top[1],top[2],m12]); ctx.fill();
const slopeA=[top[0],top[1],m12,m03], slopeB=[top[3],top[2],m12,m03];
const aNear=(top[0].y+top[1].y)>(top[3].y+top[2].y);
const near=aNear?slopeA:slopeB, far=aNear?slopeB:slopeA;
ctx.fillStyle=shade(col,14+cvar); polyPath(far); ctx.fill();
ctx.fillStyle=shade(col,-16+cvar); polyPath(near); ctx.fill();
ctx.strokeStyle=shade(col,-42); ctx.lineWidth=Math.max(0.6,cam.z*0.32);
polyPath(near); ctx.stroke(); polyPath(far); ctx.stroke();
ctx.beginPath(); ctx.moveTo(m03.x,m03.y); ctx.lineTo(m12.x,m12.y); ctx.stroke();
}
// a tall building's roofline is pushed up well past its own footprint on
// screen (that's the whole extrusion trick), which means it can visually
// lean out over a road that's actually in front of it — register it as an
// occluder so draw() can keep a car from painting on top of a roof that's
// supposed to be hiding it. Cheap because this list is only rebuilt when
// the cached city layer itself is (see drawCityLayer/occluders).
registerBuildingOccluder(base,top,walls,h);
if(l.vacant){
// one crooked crack down the front face, and the derelict badge on the roof
const front=walls[3].quad;
ctx.strokeStyle="rgba(10,10,10,0.55)"; ctx.lineWidth=Math.max(0.6,cam.z*0.3);
ctx.beginPath();
const cx=lerpP(front[0],front[1],0.35+rnd*0.3);
ctx.moveTo(cx.x,cx.y);
ctx.lineTo(lerpP(cx,front[3],0.5).x+2*cam.z, lerpP(cx,front[3],0.5).y);
ctx.lineTo(front[3].x-1.5*cam.z, front[3].y);
ctx.stroke();
if(cam.z>1.05){
ctx.font=`${Math.round(cam.z*4+7)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('🏚️', top[2].x, top[2].y-3*cam.z);
}
return;
}
if(l.building){
// mid-upgrade: the old building's still standing, just scaffolded
ctx.strokeStyle="#e0a325c0"; ctx.lineWidth=Math.max(1,cam.z*0.4); ctx.setLineDash([2*cam.z,1.6*cam.z]);
polyPath(top); ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
if(cam.z>1.1){
ctx.font=`${Math.round(cam.z*3.4+7)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('🏗️', top[2].x, top[2].y-4*cam.z);
}
}
if(icon){
if(cam.z>1.05){
ctx.font=`${Math.round(cam.z*5+8)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(icon,(top[0].x+top[2].x)/2,(top[0].y+top[2].y)/2);
}
return;
}
const subIcon = SUB_INFO[l.zone] && l.sub && SUB_INFO[l.zone][l.sub] && SUB_INFO[l.zone][l.sub].icon;
if(!simple && subIcon && cam.z>3.4 && !isHouse){
ctx.font=`${Math.round(cam.z*4+6)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(subIcon,(top[0].x+top[2].x)/2,(top[0].y+top[2].y)/2-h*0.18);
}
if(!simple && cam.z>1.4 && lvl>=2 && !isHouse && !isTower && !isPlant){
const rc={x:(top[0].x+top[2].x)/2, y:(top[0].y+top[2].y)/2};
drawACBoxes(top,rnd,col);
}
if(!simple && l.zone===Z.COM && cam.z>1.2){
ctx.strokeStyle=shade(col,26); ctx.lineWidth=Math.max(1.6,cam.z*1.05);
ctx.beginPath(); ctx.moveTo(base[0].x,base[0].y-h*0.10); ctx.lineTo(base[1].x,base[1].y-h*0.10); ctx.stroke();
drawSignBand(walls[3], subTint||info.color, rnd);
ctx.strokeStyle=shade(col,24); ctx.lineWidth=Math.max(0.7,cam.z*0.22);
ctx.beginPath(); ctx.moveTo(top[3].x,top[3].y); ctx.lineTo(top[2].x,top[2].y); ctx.stroke();
}
if(!simple && l.zone===Z.IND && cam.z>1.3 && l.sub!=='hightech'){
// one stack always, a second once the plant's grown past level 1
const stacks=lvl>=2? [0.60,0.86] : [0.74];
for(const t of stacks){
const sp={x:top[0].x+(top[1].x-top[0].x)*t, y:top[0].y+(top[1].y-top[0].y)*t};
ctx.fillStyle=shade(col,-48); ctx.fillRect(sp.x-1.3*cam.z, sp.y-7*cam.z, 2.6*cam.z, 7*cam.z);
if(cam.z>1.6){ ctx.fillStyle=shade(col,-58); ctx.fillRect(sp.x-1.6*cam.z, sp.y-8.4*cam.z, 3.2*cam.z, 1.6*cam.z); }
registerPlume(sp.x, sp.y-7*cam.z, 7*cam.z, rnd+t, '#9aa0a6');
}
}
}
/* ── buildings detail ───────────────────────────────────────────────────────
Richer per-zone art layered onto the extruded-box base in 20-buildings.js:
roof fixtures, facade detail (balconies, floor bands, glass sheen, sign
bands), industrial props (cooling towers, pipes), the farmhouse/silo/crop
rural look, and drawn art for the five utility buildings. Also owns the
animated-effects pass: plume and wind-turbine sources are collected here
while the cached city layer rebuilds — the occluders pattern from
18-render-city.js — then redrawn every frame from wall-clock time. */
let effectSources=[];
/* ── shared small pieces ─────────────────────────────────────────────────── */
/* Gable roof on an already-extruded box (base drawn with noRoof:true): ridge
parallel to the street edge, two slopes shaded by the upper-left sun, wall
colour carried into the gable ends. Mirrors the house gable in
20-buildings.js so barns and farmhouses share one look. */
function drawGableRoof(top,walls,h,colHex,cvar){
const rh=h*0.42;
const m03={x:(top[0].x+top[3].x)/2, y:(top[0].y+top[3].y)/2-rh};
const m12={x:(top[1].x+top[2].x)/2, y:(top[1].y+top[2].y)/2-rh};
ctx.fillStyle=shade(colHex,-34+cvar);
polyPath([top[0],top[3],m03]); ctx.fill();
polyPath([top[1],top[2],m12]); ctx.fill();
const slopeA=[top[0],top[1],m12,m03], slopeB=[top[3],top[2],m12,m03];
const aNear=(top[0].y+top[1].y)>(top[3].y+top[2].y);
const near=aNear?slopeA:slopeB, far=aNear?slopeB:slopeA;
ctx.fillStyle=shade(colHex,14+cvar); polyPath(far); ctx.fill();
ctx.fillStyle=shade(colHex,-16+cvar); polyPath(near); ctx.fill();
ctx.strokeStyle=shade(colHex,-42); ctx.lineWidth=Math.max(0.6,cam.z*0.32);
polyPath(near); ctx.stroke(); polyPath(far); ctx.stroke();
ctx.beginPath(); ctx.moveTo(m03.x,m03.y); ctx.lineTo(m12.x,m12.y); ctx.stroke();
}
/* Posts-and-rails fence along a polyline of screen points. hh is the fence
height in screen px: two rails plus one post per point. */
function drawFenceLine(pts,hh,colHex){
ctx.strokeStyle=shade(colHex,-20);
ctx.lineWidth=Math.max(0.7,cam.z*0.3);
ctx.beginPath();
for(let i=0;i<pts.length-1;i++){ ctx.moveTo(pts[i].x,pts[i].y-hh*0.55); ctx.lineTo(pts[i+1].x,pts[i+1].y-hh*0.55); }
for(let i=0;i<pts.length-1;i++){ ctx.moveTo(pts[i].x,pts[i].y-hh*0.95); ctx.lineTo(pts[i+1].x,pts[i+1].y-hh*0.95); }
ctx.stroke();
ctx.strokeStyle=shade(colHex,-32);
ctx.lineWidth=Math.max(0.9,cam.z*0.4);
ctx.beginPath();
for(const p of pts){ ctx.moveTo(p.x,p.y); ctx.lineTo(p.x,p.y-hh); }
ctx.stroke();
}
/* World-space lot-edge points sampled every `step` world units, projected to
screen — sampling instead of using the raw corners keeps post spacing even
on long edges. */
function fenceScreenPts(worldPts,step){
const pts=[];
for(let i=0;i<worldPts.length-1;i++){
const a=worldPts[i], b=worldPts[i+1];
const n=Math.max(1,Math.ceil(dist(a,b)/step));
for(let j=0;j<n;j++) pts.push(sc(lerpP(a,b,j/n)));
}
pts.push(sc(worldPts[worldPts.length-1]));
return pts;
}
/* ── rural look ───────────────────────────────────────────────────────────── */
/* Is this a farm lot? Low-level home, few developed neighbours, and a
per-lot deterministic gate (~45%). Called from both the apron pass (crop
rows) and drawLot (farm buildings/fence) with identical inputs, so both
always agree. Vacant lots answer no so crops never outlive the farmhouse. */
/**
* @param {Lot} l
*/
function farmDecoFor(l){
if(l.zone!==Z.RES || l.vacant) return null;
const lvl=l.level;
if(lvl>2) return null;
const seed=sceneryHash(Math.round(l.c.x),Math.round(l.c.y),31);
// a rural lot is a farmstead by definition; normal lots keep the old
// "isolated house" look so existing saves don't change
if(l.cls==='rural') return {seed};
if(seed>=0.45) return null;
let n=0;
for(const o of lotsNear(l.c,22)){
if(o===l || o.zone!==Z.RES || o.level<=0) continue;
if(dist(o.c,l.c)>22) continue; // lotsNear is cell-granular — keep the radius honest
return null; // a house with any immediate neighbour is suburban, not a farmstead
}
return {seed};
}
/* Cultivated stripes across the back of the yard, drawn in the apron pass so
they sit under the buildings. Rows run roughly parallel to the street
edge, slightly skewed per lot so fields don't all read as one stripe. */
function drawCropRows(f,seed,rural){
const n=(rural?6:3)+Math.floor((seed*97)%3); // 6–8 rows rural, 3–5 normal
for(let i=0;i<n;i++){
const v=0.74+i*(0.24/n);
const skew=((seed*83)%1-0.5)*0.16*i;
const a=sc(lerpP(lerpP(f[0],f[9],v), lerpP(f[4],f[5],v), 0.06+skew));
const b=sc(lerpP(lerpP(f[0],f[9],v), lerpP(f[4],f[5],v), 0.94-skew));
ctx.strokeStyle=i%2?'#8a7a45':'#9c8b52';
ctx.lineWidth=Math.max(1.2,(0.34/n)*cam.z*cam.tilt*1.1);
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
ctx.strokeStyle='rgba(62,50,22,0.35)';
ctx.lineWidth=Math.max(0.4,cam.z*0.12);
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
}
/* A farm silo: silvery cylinder, dome top, one vertical seam. */
function drawSilo(c,r,hh){
const colHex='#c8ced2';
ctx.fillStyle='rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(c.x+hh*0.16,c.y+r*0.2,r*1.05,r*0.5,0,0,7); ctx.fill();
const g=ctx.createLinearGradient(c.x-r,0,c.x+r,0);
g.addColorStop(0,shade(colHex,-16)); g.addColorStop(0.42,shade(colHex,24)); g.addColorStop(1,shade(colHex,-52));
ctx.fillStyle=g; ctx.fillRect(c.x-r,c.y-hh,r*2,hh);
ctx.beginPath(); ctx.ellipse(c.x,c.y,r,r*0.42,0,0,7); ctx.fill();
ctx.fillStyle=shade(colHex,14);
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,r,r*0.52,0,0,Math.PI,true); ctx.fill();
ctx.strokeStyle=shade(colHex,-40); ctx.lineWidth=Math.max(0.5,cam.z*0.2);
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,r,r*0.52,0,0,7); ctx.stroke();
ctx.strokeStyle=shade(colHex,-24); ctx.lineWidth=Math.max(0.4,cam.z*0.15);
ctx.beginPath(); ctx.moveTo(c.x+r*0.28,c.y-hh); ctx.lineTo(c.x+r*0.28,c.y); ctx.stroke();
}
/* ── roof fixtures & facade detail ────────────────────────────────────────── */
/* Mini extruded box on a roof, no shadow of its own. c is its centre on the
roof quad, s its half-width in px, hh its screen height. */
function drawRoofBox(c,s,hh,colHex){
const base=[{x:c.x-s,y:c.y+s*0.45},{x:c.x+s*0.7,y:c.y+s*0.45},{x:c.x+s*0.7,y:c.y-s*0.45},{x:c.x-s,y:c.y-s*0.45}];
extrudeBox(base,hh,shade(colHex,-16),0,{noShadow:true});
}
/* Rooftop HVAC cluster: one box plus a fan disc, for towers and apartments. */
function drawHVACCluster(top,rnd,colHex){
const c=bilerp(top,0.6+rnd*0.2,0.6+rnd*0.15);
const s=Math.max(1.6,Math.hypot(top[1].x-top[0].x,top[1].y-top[0].y)*0.13);
drawRoofBox(c,s,s*0.42,colHex);
ctx.fillStyle=shade(colHex,-40);
ctx.beginPath(); ctx.ellipse(c.x+s*1.1,c.y,s*0.3,s*0.16,0,0,7); ctx.fill();
ctx.strokeStyle=shade(colHex,-20); ctx.lineWidth=Math.max(0.4,cam.z*0.12);
ctx.stroke();
}
/* Two small AC boxes plus a vent, for shop and low office roofs. */
function drawACBoxes(top,rnd,colHex){
const s=Math.max(1.2,Math.hypot(top[1].x-top[0].x,top[1].y-top[0].y)*0.09);
for(let i=0;i<2;i++){
drawRoofBox(bilerp(top,0.52+i*0.16+rnd*0.06,0.58+i*0.14),s,s*0.36,colHex);
}
const c=bilerp(top,0.35,0.72);
ctx.fillStyle=shade(colHex,-42);
ctx.beginPath(); ctx.ellipse(c.x,c.y,s*0.22,s*0.14,0,0,7); ctx.fill();
}
/* Antenna mast with a red tip light, from a tier's roof quad. */
function drawAntenna(tierTop,rnd){
const c=bilerp(tierTop,0.55+rnd*0.2,0.55+rnd*0.2);
const hh=Math.max(6,cam.z*2.4);
ctx.strokeStyle='#8a8f96'; ctx.lineWidth=Math.max(0.7,cam.z*0.28);
ctx.beginPath(); ctx.moveTo(c.x,c.y); ctx.lineTo(c.x,c.y-hh); ctx.stroke();
ctx.fillStyle='#e0503f'; ctx.beginPath(); ctx.arc(c.x,c.y-hh,Math.max(0.7,cam.z*0.3),0,7); ctx.fill();
}
/* Balcony ledges: one light strip with a dark rail under each window row.
wall is an extrudeBox wall object {my, quad}; the quad is what's painted. */
function drawBalconies(wall,rows,colHex){
const q=wall.quad;
for(let r=0;r<rows;r++){
const vB=(r+0.30)/rows;
const ledge=[bilerp(q,0.08,vB),bilerp(q,0.92,vB),bilerp(q,0.92,vB-0.1),bilerp(q,0.08,vB-0.1)];
ctx.fillStyle=shade(colHex,10); polyPath(ledge); ctx.fill();
ctx.strokeStyle=shade(colHex,-30); ctx.lineWidth=Math.max(0.4,cam.z*0.14);
ctx.beginPath(); ctx.moveTo(ledge[2].x,ledge[2].y); ctx.lineTo(ledge[3].x,ledge[3].y); ctx.stroke();
}
}
/* Horizontal slab lines between facade window rows. */
function drawFloorBands(wall,rows,colHex){
const q=wall.quad;
ctx.strokeStyle=shade(colHex,-34);
ctx.lineWidth=Math.max(0.5,cam.z*0.2);
for(let r=1;r<rows;r++){
const a=bilerp(q,0.1,r/rows), b=bilerp(q,0.9,r/rows);
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
}
/* Diagonal glass reflection across a facade quad. */
function drawGlassSheen(wall){
const q=wall.quad;
const g=ctx.createLinearGradient(q[0].x,q[0].y,q[2].x,q[2].y);
g.addColorStop(0,'rgba(255,255,255,0)');
g.addColorStop(0.45,'rgba(255,255,255,0.3)');
g.addColorStop(0.6,'rgba(255,255,255,0.05)');
g.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=g; polyPath(q); ctx.fill();
}
/* Shop sign band near the top of the front wall, with light dash marks. */
function drawSignBand(wall,tintCol,rnd){
const q=wall.quad;
const band=[bilerp(q,0.18,0.6),bilerp(q,0.82,0.6),bilerp(q,0.82,0.78),bilerp(q,0.18,0.78)];
ctx.fillStyle=tintCol||'#5f8fc2'; polyPath(band); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.55)';
for(let i=0;i<4;i++){
const uL=0.22+i*0.15;
polyPath([bilerp(q,uL,0.71),bilerp(q,uL+0.06,0.71),bilerp(q,uL+0.06,0.73),bilerp(q,uL,0.73)]); ctx.fill();
}
}
/* ── industrial props ─────────────────────────────────────────────────────── */
/* Concrete cooling tower: stacked trapezoids for the hyperboloid waist, a
dark mouth, and a faint static vapor wisp at the top. */
function drawCoolingTower(c,w,hh,colHex){
ctx.fillStyle='rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(c.x+hh*0.14,c.y+w*0.25,w*1.05,w*0.5,0,0,7); ctx.fill();
const g=ctx.createLinearGradient(c.x-w,0,c.x+w,0);
g.addColorStop(0,shade(colHex,-6)); g.addColorStop(0.5,shade(colHex,22)); g.addColorStop(1,shade(colHex,-46));
ctx.fillStyle=g;
const topW=w*0.62, midW=w*0.55, botW=w*0.8;
polyPath([{x:c.x-botW,y:c.y},{x:c.x+botW,y:c.y},{x:c.x+midW,y:c.y-hh*0.5},{x:c.x-midW,y:c.y-hh*0.5}]); ctx.fill();
polyPath([{x:c.x-midW,y:c.y-hh*0.5},{x:c.x+midW,y:c.y-hh*0.5},{x:c.x+topW,y:c.y-hh},{x:c.x-topW,y:c.y-hh}]); ctx.fill();
ctx.fillStyle=shade(colHex,24);
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,topW,topW*0.4,0,0,7); ctx.fill();
ctx.fillStyle=shade(colHex,-36);
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,topW*0.7,topW*0.27,0,0,7); ctx.fill();
ctx.strokeStyle=shade(colHex,-30); ctx.lineWidth=Math.max(0.5,cam.z*0.2);
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh,topW,topW*0.4,0,0,7); ctx.stroke();
ctx.fillStyle='rgba(242,245,248,0.5)';
ctx.beginPath(); ctx.ellipse(c.x,c.y-hh-topW*0.3,topW*0.9,topW*0.24,0,0,7); ctx.fill();
}
/* Solar panel patch on a flat roof (high-tech plants). */
function drawSolarPatch(top,rnd){
for(let r=0;r<3;r++) for(let c2=0;c2<4;c2++){
const q=subQuad(top,0.15+c2*0.18,0.15+(c2+1)*0.18,0.45+r*0.16,0.45+(r+1)*0.16);
const g=ctx.createLinearGradient(q[0].x,q[0].y,q[2].x,q[2].y);
g.addColorStop(0,'#1c4f78'); g.addColorStop(1,'#4d8fd0');
ctx.fillStyle=g; polyPath(q); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.35)'; ctx.lineWidth=Math.max(0.3,cam.z*0.1);
ctx.beginPath(); ctx.moveTo(q[0].x,q[0].y); ctx.lineTo(q[2].x,q[2].y); ctx.stroke();
}
}
/* ── utility buildings ────────────────────────────────────────────────────── */
/* A lot's outer corners as a bilinear quad [front-left, front-right,
back-right, back-left]: the frontage edge runs f[0]→f[4] along the road and
f[5]→f[9] is the back edge. Utility art fills the whole parcel with this
rather than the narrow office-band footprint drawLot hands in, so a solar
field or reservoir reads as a field, not a rooftop stamp. */
function utilQuad(f){ return [f[0],f[4],f[5],f[9]]; }
/* Every utility painter pushes its pieces into drawLot's shared parts list
and returns a holder carrying the main extrusion (for the occluder pass)
or null when nothing is tall enough to matter. They size their own
silhouettes off cam.z rather than the `h` drawLot computes — for civic and
utility zones that h sits on its 3.5px floor at normal zoom, which left
every drawn tower a speck. `f` is the lot polygon; utilQuad(f) turns it
into the full-parcel quad the ground-filling painters lay out on. */
/**
* @param {{y:number,draw:function():void}[]} parts
* @param {{x:number,y:number}[]} base
* @param {number} h
* @param {string} col
* @param {number} cvar
* @param {number} rnd
* @param {{x:number,y:number}[]} f
* @returns {{main:any, base?:any, h?:number}}
*/
function drawPowerPlant(parts,base,h,col,cvar,rnd,f){
/** @type {{main:any, base?:any, h?:number}} */
const holder={main:null};
const lq=utilQuad(f);
const nearYOf=q=>Math.max(q[0].y,q[1].y,q[2].y,q[3].y);
const bricks='#a4644e';
const hall=subQuad(lq,0.05,0.64,0.16,0.98);
const hallH=Math.max(6,cam.z*2.1);
parts.push({y:nearYOf(hall),draw:()=>{
const r=extrudeBox(hall,hallH,bricks,cvar,{roofLight:shade(bricks,10),roofDark:shade(bricks,-16)});
holder.main=r; holder.base=hall; holder.h=hallH;
if(cam.z>1.05){
const wall=r.walls[3];
for(let i=0;i<3;i++){
const vB=0.22+i*0.22, vT=vB+0.12;
polyPath([bilerp(wall.quad,0.12,vB),bilerp(wall.quad,0.88,vB),bilerp(wall.quad,0.88,vT),bilerp(wall.quad,0.12,vT)]);
ctx.fillStyle='#d7e2e8'; ctx.fill();
}
}
}});
// tall banded concrete chimney — the silhouette that says "power plant"
const sp=bilerp(lq,0.82,0.62);
const sw=Math.max(2.4,cam.z*0.75), sh=Math.max(18,cam.z*5.2);
parts.push({y:sp.y,draw:()=>{
const g=ctx.createLinearGradient(sp.x-sw,0,sp.x+sw,0);
g.addColorStop(0,shade('#c9ccd0',-4)); g.addColorStop(0.5,shade('#c9ccd0',22)); g.addColorStop(1,shade('#c9ccd0',-40));
ctx.fillStyle=g;
polyPath([{x:sp.x-sw*0.85,y:sp.y},{x:sp.x+sw*0.85,y:sp.y},{x:sp.x+sw*0.6,y:sp.y-sh},{x:sp.x-sw*0.6,y:sp.y-sh}]); ctx.fill();
ctx.fillStyle=shade('#c9ccd0',-48);
ctx.beginPath(); ctx.ellipse(sp.x,sp.y-sh,sw*0.6,sw*0.24,0,0,7); ctx.fill();
ctx.fillStyle=shade(bricks,-10);
polyPath([{x:sp.x-sw*0.67,y:sp.y-sh*0.8},{x:sp.x+sw*0.67,y:sp.y-sh*0.8},{x:sp.x+sw*0.62,y:sp.y-sh*0.71},{x:sp.x-sw*0.62,y:sp.y-sh*0.71}]); ctx.fill();
}});
registerPlume(sp.x,sp.y-sh,sh,rnd,'#6e7278');
// cooling tower and coal pile, always on — they carry the read at any zoom
const ct=bilerp(lq,0.24,0.85);
parts.push({y:ct.y,draw:()=>drawCoolingTower(ct,Math.max(4,cam.z*1.8),Math.max(9,cam.z*3.2),'#a6a9ad')});
const cm=bilerp(lq,0.5,0.9);
parts.push({y:cm.y,draw:()=>{
ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(cm.x+2*cam.z,cm.y+2,6*cam.z,2.4*cam.z,0,0,7); ctx.fill();
ctx.fillStyle='#33333a'; ctx.beginPath(); ctx.ellipse(cm.x,cm.y,6*cam.z,2.6*cam.z,0,0,7); ctx.fill();
ctx.fillStyle='#4a4a52'; ctx.beginPath(); ctx.ellipse(cm.x-2*cam.z,cm.y-1.5*cam.z,3.4*cam.z,1.5*cam.z,0,0,7); ctx.fill();
}});
return holder;
}
/**
* @param {{y:number,draw:function():void}[]} parts
* @param {{x:number,y:number}[]} base
* @param {number} h
* @param {string} col
* @param {number} cvar
* @param {number} rnd
* @param {{x:number,y:number}[]} f
* @returns {{main:any, base?:any, h?:number}}
*/
function drawWaterTower(parts,base,h,col,cvar,rnd,f){
/** @type {{main:any, base?:any, h?:number}} */
const holder={main:null};
const lq=utilQuad(f);
const c=bilerp(lq,0.5,0.55);
const r=Math.max(4,cam.z*2.0);
const hh=Math.max(20,cam.z*6.2);
const legY=c.y-hh*0.52;
const tank='#cbd6da';
parts.push({y:c.y,draw:()=>{
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(c.x+hh*0.12,c.y+r*0.2,r*1.25,r*0.5,0,0,7); ctx.fill();
ctx.strokeStyle=shade('#8f979c',-18); ctx.lineWidth=Math.max(0.9,cam.z*0.34);
ctx.beginPath();
ctx.moveTo(c.x-r*0.92,c.y); ctx.lineTo(c.x-r*0.6,legY);
ctx.moveTo(c.x+r*0.92,c.y); ctx.lineTo(c.x+r*0.6,legY);
ctx.moveTo(c.x-r*0.92,c.y); ctx.lineTo(c.x+r*0.6,legY);
ctx.moveTo(c.x+r*0.92,c.y); ctx.lineTo(c.x-r*0.6,legY);
ctx.moveTo(c.x-r*0.78,c.y-hh*0.26); ctx.lineTo(c.x+r*0.78,c.y-hh*0.26);
ctx.stroke();
drawTank({x:c.x,y:legY},r,hh*0.5,tank);
ctx.fillStyle=shade(tank,-30); ctx.fillRect(c.x-r,legY-hh*0.2,r*2,Math.max(1.2,cam.z*0.5));
const topY=legY-hh*0.5;
polyPath([{x:c.x-r*0.96,y:topY+3},{x:c.x+r*0.96,y:topY+3},{x:c.x,y:topY-r*0.75}]);
ctx.fillStyle=shade(tank,-16); ctx.fill();
ctx.strokeStyle=shade(tank,-42); ctx.lineWidth=Math.max(0.5,cam.z*0.2); ctx.stroke();
ctx.strokeStyle=shade(tank,-30); ctx.lineWidth=Math.max(0.5,cam.z*0.18);
ctx.beginPath(); ctx.moveTo(c.x,topY-r*0.75); ctx.lineTo(c.x,topY-r*1.05); ctx.stroke();
ctx.fillStyle='#eef3f5'; ctx.beginPath(); ctx.arc(c.x,topY-r*1.05,Math.max(1,cam.z*0.3),0,7); ctx.fill();
}});
return holder;
}
/**
* @param {{y:number,draw:function():void}[]} parts
* @param {{x:number,y:number}[]} base
* @param {number} h
* @param {string} col
* @param {number} cvar
* @param {number} rnd
* @param {{x:number,y:number}[]} f
* @returns {{main:any, base?:any, h?:number}}
*/
function drawSolarFarm(parts,base,h,col,cvar,rnd,f){
/** @type {{main:any, base?:any, h?:number}} */
const holder={main:null};
const lq=utilQuad(f);
const yard=subQuad(lq,0.05,0.95,0.12,0.96);
parts.push({y:Math.max(yard[0].y,yard[1].y),draw:()=>{
polyPath(yard); ctx.fillStyle='#7d7a6e'; ctx.fill();
ctx.strokeStyle='rgba(40,40,36,0.35)'; ctx.lineWidth=Math.max(0.5,cam.z*0.18); ctx.stroke();
const rows=4, cols=3;
const lift=Math.max(1.5,cam.z*0.9);
for(let r=0;r<rows;r++) for(let c2=0;c2<cols;c2++){
const q=subQuad(yard,(c2+0.1)/cols,(c2+0.9)/cols,(r+0.08)/rows,(r+0.82)/rows);
ctx.fillStyle='rgba(20,20,24,0.22)';
polyPath(q.map(p=>({x:p.x+lift*0.5,y:p.y+lift*0.22}))); ctx.fill();
const tilt=q.map((p,i)=> i>=2 ? {x:p.x,y:p.y-lift} : p);
ctx.fillStyle='#24384a'; polyPath(tilt); ctx.fill();
const face=[bilerp(tilt,0.06,0.12),bilerp(tilt,0.94,0.12),bilerp(tilt,0.94,0.9),bilerp(tilt,0.06,0.9)];
const g=ctx.createLinearGradient(face[0].x,face[0].y,face[2].x,face[2].y);
g.addColorStop(0,'#1c4f78'); g.addColorStop(1,'#5aa0dd');
ctx.fillStyle=g; polyPath(face); ctx.fill();
ctx.strokeStyle='rgba(230,245,255,0.5)'; ctx.lineWidth=Math.max(0.4,cam.z*0.16);
ctx.beginPath(); ctx.moveTo(face[0].x,face[0].y); ctx.lineTo(face[2].x,face[2].y); ctx.stroke();
ctx.strokeStyle='#55524a'; ctx.lineWidth=Math.max(0.5,cam.z*0.16);
ctx.beginPath();
ctx.moveTo(q[0].x,q[0].y); ctx.lineTo(tilt[0].x,tilt[0].y);
ctx.moveTo(q[1].x,q[1].y); ctx.lineTo(tilt[1].x,tilt[1].y);
ctx.stroke();
}
const ib=bilerp(yard,0.12,0.94);
const s=Math.max(2,cam.z*0.9);
polyPath([{x:ib.x-s,y:ib.y+s*0.4},{x:ib.x+s,y:ib.y+s*0.4},{x:ib.x+s,y:ib.y-s*0.6},{x:ib.x-s,y:ib.y-s*0.6}]);
ctx.fillStyle='#4a5a44'; ctx.fill();
ctx.strokeStyle='#2f3a2c'; ctx.lineWidth=Math.max(0.4,cam.z*0.14); ctx.stroke();
}});
return holder;
}
/**
* @param {{y:number,draw:function():void}[]} parts
* @param {{x:number,y:number}[]} base
* @param {number} h
* @param {string} col
* @param {number} cvar
* @param {number} rnd
* @param {{x:number,y:number}[]} f
* @returns {{main:any, base?:any, h?:number}}
*/
function drawWindFarm(parts,base,h,col,cvar,rnd,f){
/** @type {{main:any, base?:any, h?:number}} */
const holder={main:null};
const lq=utilQuad(f);
const n=3+Math.floor(rnd*2); // 3–4 turbines
const mh=Math.max(20,cam.z*6.2);
const len=Math.max(5,cam.z*2.3);
for(let i=0;i<n;i++){
const gx=i%2, gy=Math.floor(i/2);
const u=clamp(0.26+gx*0.46+((rnd*13+i*7)%1-0.5)*0.12,0.15,0.85);
const v=clamp(0.32+gy*0.4+((rnd*17+i*5)%1-0.5)*0.12,0.15,0.9);
const p=bilerp(lq,u,v);
const hub={x:p.x,y:p.y-mh};
const seed=rnd*97+i*31;
parts.push({y:p.y,draw:()=>{
ctx.fillStyle='rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(p.x+mh*0.1,p.y+1,len*0.7,len*0.28,0,0,7); ctx.fill();
ctx.fillStyle='#b9b7ac'; ctx.beginPath(); ctx.ellipse(p.x,p.y,len*0.5,len*0.2,0,0,7); ctx.fill();
const bw=Math.max(1.8,cam.z*0.55), tw=Math.max(0.9,cam.z*0.28);
const g=ctx.createLinearGradient(p.x-bw,0,p.x+bw,0);
g.addColorStop(0,'#c9ced2'); g.addColorStop(0.45,'#eef1f3'); g.addColorStop(1,'#9aa0a6');
ctx.fillStyle=g;
polyPath([{x:p.x-bw,y:p.y},{x:p.x+bw,y:p.y},{x:p.x+tw,y:hub.y},{x:p.x-tw,y:hub.y}]); ctx.fill();
ctx.fillStyle='#e3e7ea';
ctx.beginPath(); ctx.ellipse(hub.x,hub.y,Math.max(2.2,cam.z*0.8),Math.max(1.4,cam.z*0.5),0,0,7); ctx.fill();
ctx.strokeStyle='#a7adb2'; ctx.lineWidth=Math.max(0.4,cam.z*0.14); ctx.stroke();
if(cam.z<=1.5) drawTurbineBlades(hub.x,hub.y,seed*6.283,len);
}});
registerTurbine(hub.x,hub.y,len/2.2,seed);
}
return holder;
}
/**
* @param {{y:number,draw:function():void}[]} parts
* @param {{x:number,y:number}[]} base
* @param {number} h
* @param {string} col
* @param {number} cvar
* @param {number} rnd
* @param {{x:number,y:number}[]} f
* @returns {{main:any, base?:any, h?:number}}
*/
function drawReservoir(parts,base,h,col,cvar,rnd,f){
/** @type {{main:any, base?:any, h?:number}} */
const holder={main:null};
const lq=utilQuad(f);
const basin=subQuad(lq,0.04,0.96,0.1,0.92);
const wallH=Math.max(3.5,cam.z*1.7);
parts.push({y:Math.max(basin[0].y,basin[1].y),draw:()=>{
polyPath(lq); ctx.fillStyle='#b6b2a6'; ctx.fill();
const g=ctx.createLinearGradient(basin[0].x,basin[0].y,basin[2].x,basin[2].y);
g.addColorStop(0,'#2f6f9e'); g.addColorStop(0.5,'#5aa8c9'); g.addColorStop(1,'#357aa6');
polyPath(basin); ctx.fillStyle=g; ctx.fill();
ctx.strokeStyle='rgba(226,244,250,0.5)'; ctx.lineWidth=Math.max(0.5,cam.z*0.18);
for(let i=0;i<3;i++){
const a=bilerp(basin,0.18+i*0.1,0.28+i*0.18), b=bilerp(basin,0.5+i*0.08,0.2+i*0.2);
ctx.beginPath(); ctx.moveTo(a.x,a.y);
ctx.quadraticCurveTo(b.x,b.y-2*cam.z,b.x+3*cam.z,b.y); ctx.stroke();
}
polyPath([basin[0],basin[1],{x:basin[1].x,y:basin[1].y-wallH},{x:basin[0].x,y:basin[0].y-wallH}]);
const wg=ctx.createLinearGradient(0,basin[0].y-wallH,0,basin[0].y);
wg.addColorStop(0,'#c3bfb3'); wg.addColorStop(1,'#96917f');
ctx.fillStyle=wg; ctx.fill();
ctx.strokeStyle='rgba(70,66,56,0.5)'; ctx.lineWidth=Math.max(0.5,cam.z*0.2); ctx.stroke();
ctx.strokeStyle='#cfccc0'; ctx.lineWidth=Math.max(1.4,cam.z*0.7);
polyPath(basin); ctx.stroke();
const sn=bilerp(basin,0.72,0.02);
ctx.fillStyle='#9a9584';
ctx.fillRect(sn.x-2*cam.z,sn.y-wallH-1,cam.z*4,wallH+2);
const ic=bilerp(basin,0.18,0.9);
const s=Math.max(3,cam.z*1.3);
extrudeBox([{x:ic.x-s,y:ic.y+s*0.4},{x:ic.x+s,y:ic.y+s*0.4},{x:ic.x+s,y:ic.y-s*0.4},{x:ic.x-s,y:ic.y-s*0.4}],s*0.9,'#b9b3a4',0,{});
}});
return holder;
}
const UTIL_PAINTER={
[Z.POWER]:drawPowerPlant,
[Z.WATER]:drawWaterTower,
[Z.SOLAR]:drawSolarFarm,
[Z.WIND]:drawWindFarm,
[Z.RESERVOIR]:drawReservoir,
};
/**
* @param {Lot} l
*/
function utilPainterFor(l){ return UTIL_PAINTER[l.zone]||null; }
/* ── animated effects ─────────────────────────────────────────────────────── */
/**
* @param {number} x
* @param {number} y
* @param {number} h
* @param {number} seed
* @param {string} color
*/
function registerPlume(x,y,h,seed,color){ effectSources.push({type:'plume',x,y,h,seed,color}); }
/**
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} seed
*/
function registerTurbine(x,y,r,seed){ effectSources.push({type:'turbine',x,y,r,seed}); }
function drawTurbineBlades(hx,hy,angle,len){
ctx.strokeStyle='rgba(240,244,246,0.9)'; ctx.lineWidth=Math.max(0.8,cam.z*0.28);
for(let k=0;k<3;k++){
const a=angle+k*2.094;
ctx.beginPath(); ctx.moveTo(hx,hy);
ctx.lineTo(hx+Math.cos(a)*len, hy+Math.sin(a)*len*cam.tilt);
ctx.stroke();
}
ctx.fillStyle='#dfe3e6'; ctx.beginPath(); ctx.arc(hx,hy,Math.max(0.9,cam.z*0.32),0,7); ctx.fill();
}
let plumeBudget=0;
function drawPlumeSource(s,t){
if(cam.z<=1.1) return;
if(plumeBudget>=12) return; plumeBudget++;
const sx=s.x-citySrcX, sy=s.y-citySrcY;
if(sx<-90||sx>VW+90||sy<-170||sy>VH+90) return;
const cycle=2600+s.seed*1600;
const drift=(s.seed*2-1)*8;
for(let i=0;i<6;i++){
const age=((t+i*cycle/6)%cycle)/cycle;
const x=sx+Math.sin(i*2.1+s.seed*9)*4*age+drift*age;
const y=sy-age*(18+s.h*0.75);
const r=Math.max(1,cam.z*(0.4+age*2.9));
ctx.globalAlpha=0.26*(1-age)*(1-age);
ctx.fillStyle=s.color;
ctx.beginPath(); ctx.ellipse(x,y,r,r*0.62,0,0,7); ctx.fill();
}
ctx.globalAlpha=1;
}
function drawTurbineSource(s,t){
if(cam.z<=1.5) return;
const ang=s.seed*6.283+t/1000*0.419;
drawTurbineBlades(s.x-citySrcX,s.y-citySrcY,ang,s.r*2.2);
}
function drawDynamicEffects(){
if(!effectSources.length) return;
plumeBudget=0;
const t=performance.now();
for(const s of effectSources){
if(s.type==='plume') drawPlumeSource(s,t);
else drawTurbineSource(s,t);
}
}
/* ── pending road placement (also used for reshaping) ────────────────────── */
let pending=null;
/* Skip list shared by crossing detection and span-finding: a road's own old
self while reshaping, and whichever segments its two ends are snapped onto
(those meet it at a junction by construction, they don't "cross" it). */
function pendingSkipIds(){
const skip=new Set();
if(!pending) return skip;
if(pending.editingSegId) skip.add(pending.editingSegId);
if(pending.snapA&&pending.snapA.seg) skip.add(pending.snapA.seg.id);
if(pending.snapB&&pending.snapB.seg) skip.add(pending.snapB.seg.id);
return skip;
}
function mergeSpans(spans){
if(spans.length<2) return spans;
spans.sort((x,y)=>x.t0-y.t0);
const out=[spans[0]];
for(let i=1;i<spans.length;i++){
const last=out[out.length-1];
if(spans[i].t0<=last.t1) last.t1=Math.max(last.t1,spans[i].t1);
else out.push(spans[i]);
}
return out;
}
/* Where along `curve` does it fly over a through road? One span per crossing,
long enough to clear that road's own pavement at the angle the two actually
meet, plus OVERPASS_PAD at each end — then overlapping spans are merged, so
two highways crossed a few metres apart become one continuous deck rather
than two decks with an unbuildable sliver of ground road between them. */
function findOverpassSpans(curve,skipIds){
const spans=[];
for(const seg of net.segments.values()){
if(skipIds&&skipIds.has(seg.id)) continue;
if(!BRIDGE_OVER_TYPES.has(seg.typeId)||seg.isBridge) continue;
for(const cr of curveCrossings(curve,seg.curve,30)){
const sinT=Math.max(MIN_CROSS_SIN,
Math.abs(Math.sin(ang(curve.tangentAt(cr.tA))-ang(seg.curve.tangentAt(cr.tB)))));
const half=(ROAD_TYPE_BY_ID[seg.typeId].width/2+OVERPASS_PAD)/sinT;
const s=curve.distAtT(cr.tA);
spans.push({t0:curve.tAtDist(s-half), t1:curve.tAtDist(s+half)});
}
}
return mergeSpans(spans);
}
/* Every piece a road about to be built breaks into: cut at each shoreline
crossing AND at each overpass span boundary, then ask each piece's own
midpoint what it is. The shoreline walk this replaced could get away with
toggling bridge/not at each crossing in sequence; with two independent
reasons to be elevated, "toggle" no longer describes the answer, so each
piece is classified on its own instead of inherited from its neighbour. */
function roadPieces(curve,spans){
spans=spans||[];
const cuts=findShoreCrossings(curve).slice();
for(const sp of spans){ cuts.push(sp.t0); cuts.push(sp.t1); }
const bounds=[0];
for(const t of cuts.filter(t=>t>0.002&&t<0.998).sort((a,b)=>a-b)){
if(t-bounds[bounds.length-1]>0.004) bounds.push(t);
}
if(1-bounds[bounds.length-1]<0.004 && bounds.length>1) bounds.pop();
bounds.push(1);
const pieces=[];
for(let i=0;i<bounds.length-1;i++){
const t0=bounds[i], t1=bounds[i+1], mid=(t0+t1)/2;
const p=curve.pointAt(mid);
const overpass=spans.some(sp=>mid>=sp.t0&&mid<=sp.t1);
pieces.push({curve:subCurve(curve,t0,t1), overpass, isBridge:overpass||isWater(p.x,p.y)});
}
return pieces;
}
function computeCrossings(){
if(!pending||!pending.curve){ return; }
const skip=pendingSkipIds();
const found=[];
let bridgeable=0;
for(const seg of net.segments.values()){
if(skip.has(seg.id)) continue;
for(const cr of curveCrossings(pending.curve,seg.curve,24)){
if(cr.tA<0.03||cr.tA>0.97) continue;
if(dist(cr.point,pending.a)<6||dist(cr.point,pending.b)<6) continue;
if(found.some(f=>dist(f.point,cr.point)<3)) continue;
const canBridge=BRIDGE_OVER_TYPES.has(seg.typeId)&&!seg.isBridge;
if(canBridge) bridgeable++;
found.push({tA:cr.tA,point:cr.point,canBridge});
}
}
found.sort((x,y)=>x.tA-y.tA);
pending.crossings=found;
pending.bridgeable=bridgeable;
// the toggle can't stay armed once a drag moves the road off the highway
if(!bridgeable) pending.bridgeOver=false;
pending.spans = pending.bridgeOver ? findOverpassSpans(pending.curve,skip) : [];
}
function makePendingCurve(){
pending.curve=new Bez(pending.a,pending.h1,pending.h2,pending.b);
computeCrossings();
}
function defaultHandles(){
const d=sub(pending.b,pending.a), L=vlen(d)||1, u=mul(d,1/L);
let sd=u, ed=mul(u,-1);
if(pending.snapA&&pending.snapA.node){
const n=pending.snapA.node;
if(n.segments.length){
let acc={x:0,y:0};
for(const sid of n.segments){const s=net.segments.get(sid); acc=add(acc,segDirAt(s,n.id));}
const avg=norm(acc); const cand={x:-avg.x,y:-avg.y};
if(cand.x*u.x+cand.y*u.y>0.2) sd=cand;
}
}
pending.h1=add(pending.a,mul(sd,L*0.34));
pending.h2=add(pending.b,mul(ed,L*0.34));
makePendingCurve();
}
/* Sums the cost piece by piece, charging BRIDGE_MULT for whatever is elevated
— over water or over a road, both cost the same to hold up. */
function computeRoadCost(curve,typeId,spans){
const type=ROAD_TYPE_BY_ID[typeId];
let total=0;
for(const pc of roadPieces(curve,spans)) total+=pc.curve.length*type.costPerM*(pc.isBridge?BRIDGE_MULT:1);
return Math.round(total);
}
/* Lays the road down as its pieces: a fresh node at each boundary, so only
the stretch that's actually over water or over another road carries the
bridge flag and the rest sits on the ground. `spans` is optional — seeded
worldgen roads pass nothing and get the plain shoreline behaviour. */
function buildRoadChain(aId,bId,curve,typeId,spans){
const pieces=roadPieces(curve,spans);
let prev=aId, madeBridge=false, madeOverpass=false;
for(let i=0;i<pieces.length;i++){
const pc=pieces[i];
const end = i===pieces.length-1 ? bId : createNode(pc.curve.p3);
const sid=addSegment(prev,end,pc.curve.p1,pc.curve.p2,typeId,pc.isBridge);
if(sid!=null&&pc.overpass) net.segments.get(sid).overpass=true;
if(pc.isBridge) madeBridge=true;
if(pc.overpass) madeOverpass=true;
prev=end;
}
return {madeBridge,madeOverpass};
}
function pendingCost(){
if(!pending) return 0;
if(pending.editingSegId){
const wf=waterFraction(pending.curve);
return Math.round(RESHAPE_FEE*(1+wf*(BRIDGE_MULT-1)));
}
return computeRoadCost(pending.curve,pending.typeId,pending.spans);
}
/* A junction with less than this between two roads reads as a knife-edge
sliver on screen and the trim geometry has to stretch to cover it — rather
than let players build those, we just refuse the road. */
const MIN_JUNCTION_ANGLE=15*Math.PI/180;
function angleDiff(a,b){ let d=Math.abs(a-b); if(d>Math.PI) d=2*Math.PI-d; return d; }
function existingOutwardDirs(snap,excludeSegId){
if(!snap) return [];
if(snap.node){
const node=snap.node;
return node.segments.filter(sid=>sid!==excludeSegId)
.map(sid=>ang(segDirAt(net.segments.get(sid),node.id)));
}
if(snap.seg){
if(snap.seg.id===excludeSegId) return [];
const tan=snap.seg.curve.tangentAt(snap.t);
return [ang(tan), ang(mul(tan,-1))];
}
return [];
}
/* Reshaping an existing road can swing its endpoint tangent around just as
freely as drawing a fresh one can — exclude the road's own old self from
the comparison (it's still sitting in node.segments mid-drag) but still
check it against every OTHER spoke at that junction. */
function sharpJunctionAngle(){
const exclude=pending.editingSegId;
const outA=ang(pending.curve.tangentAt(0));
for(const ex of existingOutwardDirs(pending.snapA,exclude)) if(angleDiff(outA,ex)<MIN_JUNCTION_ANGLE) return true;
const outB=ang(mul(pending.curve.tangentAt(1),-1));
for(const ex of existingOutwardDirs(pending.snapB,exclude)) if(angleDiff(outB,ex)<MIN_JUNCTION_ANGLE) return true;
return false;
}
/* Would either end of this road land inside another, unrelated junction's
own footprint? node.visRadius already tells us how big a junction actually
draws itself (plain fillet or roundabout ring alike) — if a new endpoint
would land closer to some OTHER node than that node's own radius (plus a
little breathing room), the two junctions would visually overlap, exactly
like two circles drawn too close together. A node this road is actually
connecting to (shares a segment with the endpoint) doesn't count — that's
just the road's own junction, governed by the length/angle checks instead. */
function junctionsTooClose(){
if(pending.editingSegId) return false;
const myHW=ROAD_TYPE_BY_ID[pending.typeId].width/2;
const ends=[
{pos:pending.a, node:pending.snapA&&pending.snapA.node},
{pos:pending.b, node:pending.snapB&&pending.snapB.node},
];
for(const end of ends){
for(const n of net.nodes.values()){
if(end.node && (n.id===end.node.id || end.node.segments.some(sid=>{
const s=net.segments.get(sid); return s && (s.startNode===n.id||s.endNode===n.id);
}))) continue;
const otherR = n.visRadius>0 ? n.visRadius
: (n.segments.length ? Math.max(...n.segments.map(sid=>ROAD_TYPE_BY_ID[net.segments.get(sid).typeId].width/2)) : 0);
if(otherR<=0) continue;
if(dist(end.pos,n.position) < otherR+myHW+3) return true;
}
}
return false;
}
/* Every check below reads `pending`, so a call made while no road is being
drawn (a stale tray button, a modal accepted after the draft was dropped)
used to die on `pending.typeId`. There's nothing to validate then — say so
instead of throwing. */
function pendingValid(){
if(!pending) return {ok:false,why:"—"};
const type=ROAD_TYPE_BY_ID[pending.typeId];
if(!pending.curve) return {ok:false,why:"—"};
if(pending.curve.length<12) return {ok:false,why:"Too short"};
const r=1/Math.max(pending.curve.maxCurvature(),1e-6);
if(r<type.minRadius) return {ok:false,why:"Curve too tight for this road"};
if(curveLeavesMap(pending.curve)) return {ok:false,why:"Past the edge of buildable land"};
if(sharpJunctionAngle()) return {ok:false,why:"Meets another road at too sharp an angle"};
if(junctionsTooClose()) return {ok:false,why:"Too close to another intersection"};
if(pendingCost()>S.cash) return {ok:false,why:"Not enough funds"};
return {ok:true};
}
function drawPending(){
const type=ROAD_TYPE_BY_ID[pending.typeId];
const v=pendingValid();
const col=v.ok?"#5b9bd5":"#e0604d";
strokeCurve(pending.curve,type.width*cam.z,col+"44");
strokeCurve(pending.curve,2,col,[7,5]);
if(v.ok && !pending.editingSegId){
// show what this road would pave over before the player commits to it —
// buildings get a hazard-striped red fill, bare zoned/empty lots a
// fainter amber one, so it's obvious which taps matter
const hit=lotsHitByCurve(pending.curve,pending.typeId);
for(const l of hit){
tracePoly(l.poly);
ctx.fillStyle = lotHasRealBuilding(l) ? '#e0604d55' : '#e8b04a3a';
ctx.fill();
ctx.strokeStyle = lotHasRealBuilding(l) ? '#e0604d' : '#e8b04a';
ctx.lineWidth=Math.max(1.5,cam.z*1.4); ctx.stroke();
}
}
// the stretch that would be built as a deck, so it's obvious before
// committing how much of the road leaves the ground and where
if(pending.spans) for(const sp of pending.spans){
strokeCurve(subCurve(pending.curve,sp.t0,sp.t1),(type.width+5)*cam.z,"#e8d98a55");
}
if(pending.crossings) for(const cr of pending.crossings){
const p=sc(cr.point), r=Math.max(5,cam.z*3);
const bridged=pending.bridgeOver&&cr.canBridge;
ctx.strokeStyle=bridged?"#e8d98a":"#7fd6b0"; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.arc(p.x,p.y,r,0,7); ctx.stroke();
ctx.beginPath();
if(bridged){ // an arch, not a crosshair: this one isn't becoming a junction
ctx.moveTo(p.x-r*0.6,p.y+r*0.35);
ctx.quadraticCurveTo(p.x,p.y-r*0.75,p.x+r*0.6,p.y+r*0.35);
} else {
ctx.moveTo(p.x-r*0.45,p.y); ctx.lineTo(p.x+r*0.45,p.y);
ctx.moveTo(p.x,p.y-r*0.45); ctx.lineTo(p.x,p.y+r*0.45);
}
ctx.stroke();
}
ctx.strokeStyle="#ffffff44"; ctx.lineWidth=1; ctx.setLineDash([3,3]);
ctx.beginPath();
let p=sc(pending.a),q=sc(pending.h1); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y);
p=sc(pending.b); q=sc(pending.h2); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y);
ctx.stroke(); ctx.setLineDash(EMPTY_DASH);
const grip=(pt,fill,r)=>{const s=sc(pt);
ctx.beginPath(); ctx.arc(s.x,s.y,r,0,7); ctx.fillStyle=fill; ctx.fill();
ctx.strokeStyle="#0e1216"; ctx.lineWidth=2; ctx.stroke();};
grip(pending.h1,"#e8d98a",9); grip(pending.h2,"#e8d98a",9);
if(!pending.lockEndpoints){ grip(pending.a,col,8); grip(pending.b,col,8); }
else { grip(pending.a,"#616d7a",7); grip(pending.b,"#616d7a",7); }
}
/* Bare zoned/empty lots along the new road's path are cleared silently —
nobody needs a confirmation dialog for grass. An actual building (built,
under construction, or a still-visible vacant one) stops the build here
and asks first; accepting re-enters commitPending with the flag set so it
doesn't ask twice for the same pending road. */
function commitPending(skipDemolishConfirm){
if(!pending) return;
const v=pendingValid();
if(!v.ok){ toast(v.why,"warn"); return; }
if(!pending.editingSegId && !skipDemolishConfirm){
const hit=lotsHitByCurve(pending.curve,pending.typeId).filter(lotHasRealBuilding);
if(hit.length>0){ openDemoModal(hit.length); return; }
}
if(pending.editingSegId){
const seg=net.segments.get(pending.editingSegId);
if(seg){
const aPos=net.nodes.get(seg.startNode).position, bPos=net.nodes.get(seg.endNode).position;
seg.startDir=norm(sub(pending.h1,aPos)); seg.startHandle=Math.max(1,dist(pending.h1,aPos));
seg.endDir=norm(sub(pending.h2,bPos)); seg.endHandle=Math.max(1,dist(pending.h2,bPos));
seg.isBridge = waterFraction(seg.curve) > 0.4 || seg.isBridge;
S.cash-=pendingCost();
}
clearPending();
const resolved=rebuildAll();
setHint(HINTS.edit);
if(resolved>0) toast(`Reshaped — ${resolved} new junction${resolved>1?'s':''} formed.`);
return;
}
const cost=pendingCost();
let aId,bId;
if(pending.snapA&&pending.snapA.node) aId=pending.snapA.node.id;
else if(pending.snapA&&pending.snapA.seg) aId=splitSegment(pending.snapA.seg.id,pending.snapA.t);
else aId=createNode(pending.a);
if(pending.snapB&&pending.snapB.node) bId=pending.snapB.node.id;
else if(pending.snapB&&pending.snapB.seg){
const still=net.segments.get(pending.snapB.seg.id);
bId=still?splitSegment(pending.snapB.seg.id,pending.snapB.t):createNode(pending.b);
}
else bId=createNode(pending.b);
if(aId==null||bId==null||aId===bId){ toast("A road needs two different ends.","warn"); clearPending(); return; }
net.nodes.get(aId).position={x:pending.a.x,y:pending.a.y};
net.nodes.get(bId).position={x:pending.b.x,y:pending.b.y};
const {madeBridge,madeOverpass}=buildRoadChain(aId,bId,pending.curve,pending.typeId,pending.spans);
S.cash-=cost;
clearPending();
const newConns=openEdgeConnections([aId,bId]);
const resolved=rebuildAll();
anchor=null;
if(newConns>0) toast('Reached the map edge — a new regional connection is open. Expect more trade and visitors.');
else if(madeOverpass) toast(`Overpass built — it flies over, so no junction${resolved>0?` there (${resolved} formed elsewhere)`:''}.`);
else if(resolved>0) toast(`Road built — ${resolved} new junction${resolved>1?'s':''} formed.`);
else if(madeBridge) toast('Bridge built across the water.');
setHint(HINTS.road);
}
/* A fresh dead-end sitting on the map edge becomes a regional connection. */
function openEdgeConnections(nodeIds){
let n=0;
for(const id of nodeIds){
const nd=net.nodes.get(id);
if(nd && !nd.outside && nd.segments.length===1 && atMapEdge(nd.position)){ nd.outside=true; n++; }
}
return n;
}
/* Disable the tray's commit button on the way out: the tray only slides
out of view, so leaving the button enabled leaves a live control wired to
a draft that no longer exists. */
function clearPending(){
pending=null;
document.getElementById('tray').classList.remove('up');
const go=/** @type {HTMLButtonElement|null} */(document.getElementById('trayGo'));
if(go) go.disabled=true;
}
/* The bridge toggle only exists while the draft actually crosses something
worth flying over — it's hidden the rest of the time rather than sitting
there greyed out, since for most roads the question never comes up. */
function refreshBridgeBtn(){
const btn=/** @type {HTMLButtonElement|null} */(document.getElementById('trayBridge'));
if(!btn) return;
const on=!!(pending&&pending.bridgeable&&!pending.editingSegId);
btn.style.display=on?'':'none';
btn.classList.toggle('on',!!(pending&&pending.bridgeOver));
btn.textContent=(pending&&pending.bridgeOver)?'🌉 Bridge over':'⊹ Intersect';
}
function refreshTray(){
if(!pending) return;
const type=ROAD_TYPE_BY_ID[pending.typeId];
const v=pendingValid(), cost=pendingCost();
const reshaping=!!pending.editingSegId;
const bridged=pending.bridgeOver?(pending.bridgeable||0):0;
const nx=((pending.crossings&&pending.crossings.length)||0)-bridged;
const wf=waterFraction(pending.curve);
const demo=(!reshaping && v.ok)?lotsHitByCurve(pending.curve,pending.typeId).filter(lotHasRealBuilding).length:0;
refreshBridgeBtn();
document.getElementById('trayMeta').textContent = reshaping
? `Reshape · ${Math.round(pending.curve.length)} m`
: `${type.name} · ${Math.round(pending.curve.length)} m${wf>0?' · bridge':''}${bridged?` · ${bridged} overpass${bridged>1?'es':''}`:''}${nx>0?` · ${nx} junction${nx>1?'s':''}`:''}${demo?` · ⚠️ ${demo} building${demo>1?'s':''} in the way`:''}`;
const cb=document.getElementById('trayCost');
cb.textContent="$"+cost.toLocaleString();
cb.className=v.ok?"":"no";
document.getElementById('trayTitle').textContent = v.ok ? (reshaping?"Reshape road":"New road") : v.why;
document.getElementById('trayGo').textContent = reshaping? "Save":"Build";
document.getElementById('trayGo').disabled=!v.ok;
}
function beginReshape(segId){
const seg=net.segments.get(segId); if(!seg) return;
const aPos=net.nodes.get(seg.startNode).position, bPos=net.nodes.get(seg.endNode).position;
pending={
a:{x:aPos.x,y:aPos.y}, b:{x:bPos.x,y:bPos.y},
h1:add(aPos,mul(seg.startDir,seg.startHandle)),
h2:add(bPos,mul(seg.endDir,seg.endHandle)),
typeId:seg.typeId, editingSegId:segId, lockEndpoints:true,
snapA:{node:net.nodes.get(seg.startNode)}, snapB:{node:net.nodes.get(seg.endNode)},
};
makePendingCurve();
closeRTray();
document.getElementById('tray').classList.add('up');
refreshTray();
setHint("Drag the gold handles to reshape. The endpoints stay put.");
}
/* ── trays ──────────────────────────────────────────────────────────────── */
let jNode=null, rSeg=null;
let svcLot=null, moveSource=null, movePick=null;
const MOVE_FEE_RATE=0.35;
/* A partnership building has no purchase price to take a percentage of, so its
relocation fee comes off the signing bonus instead. Moving one is allowed —
you are still hosting it, which is what the deal actually asked for — but
where you put it is a real decision, not a free one you can undo later. */
const SPECIAL_RELOCATE_RATE=0.25, SPECIAL_RELOCATE_MIN=2000;
/**
* @param {Lot} l
*/
function moveFee(l){
const deal=PARTNERSHIPS.find(p=>p.zone===l.zone);
if(deal) return Math.max(SPECIAL_RELOCATE_MIN,Math.round(grantPaid(deal)*SPECIAL_RELOCATE_RATE));
const zi=ZONE_INFO[l.zone]; return Math.round((zi?zi.cost:0)*MOVE_FEE_RATE);
}
/* Converting a plain junction into a roundabout can suddenly grow its
footprint a lot — check it the same way a freshly-drawn road's endpoints
are checked, so turning one on can't silently swallow a neighboring
junction that was perfectly fine a moment ago. */
/**
* @param {NetNode} node
*/
function wouldOverlapAsRoundabout(node){
const R=roundaboutGeom(node).outer+2.6;
for(const n of net.nodes.values()){
if(n.id===node.id) continue;
if(node.segments.some(sid=>{ const s=net.segments.get(sid); return s && (s.startNode===n.id||s.endNode===n.id); })) continue;
const otherR=n.visRadius>0?n.visRadius:0;
if(otherR<=0) continue;
if(dist(node.position,n.position) < R+otherR+3) return true;
}
return false;
}
/**
* @param {NetNode} node
*/
function openJTray(node){
closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove(); jNode=node;
document.getElementById('jTitle').textContent=`Intersection · ${node.segments.length} roads`;
const el=document.getElementById('jOpts'); el.innerHTML='';
for(const c of [CONTROL.NONE,CONTROL.YIELD,CONTROL.STOP,CONTROL.SIGNAL,CONTROL.ROUNDABOUT]){
if(c===CONTROL.ROUNDABOUT && node.segments.length<3) continue;
const info=CONTROL_INFO[c];
const b=document.createElement('button');
b.className='chip'+(node.control===c?' on':'');
b.innerHTML=`${info.icon} ${info.name}${info.cost?` <small>$${info.cost}</small>`:''}`;
b.onclick=()=>{
if(c===CONTROL.ROUNDABOUT && node.control!==c && wouldOverlapAsRoundabout(node)){
toast('Too close to another intersection for a roundabout here.','warn'); return;
}
if(c!==CONTROL.NONE && node.control!==c){
if(!S.freePlay && info.cost>0 && S.cash<info.cost){ toast('Not enough funds.','warn'); return; }
S.cash-=info.cost;
}
node.control=c; rebuildAll(); openJTray(node); refreshHud();
};
el.appendChild(b);
}
document.getElementById('jTray').classList.add('up');
}
function closeJTray(){ jNode=null; document.getElementById('jTray').classList.remove('up'); }
/* Every segment of one continuous road — walk through 2-way "joint" nodes as
long as the road type stays the same. Lets you retype/upgrade a whole road by
tapping any part of it. */
function roadChain(segId){
const seg=net.segments.get(segId); if(!seg) return [segId];
const type=seg.typeId, chain=new Set([segId]);
const walk=(startSeg,startNode)=>{
let curSeg=startSeg, curNode=startNode;
for(let guard=0;guard<400;guard++){
const node=net.nodes.get(curNode);
if(!node || node.segments.length!==2) break;
const nextId=node.segments.find(s=>s!==curSeg.id);
const next=net.segments.get(nextId);
if(!next || next.typeId!==type || chain.has(nextId)) break;
chain.add(nextId);
curNode = next.startNode===curNode ? next.endNode : next.startNode;
curSeg = next;
}
};
walk(seg,seg.startNode); walk(seg,seg.endNode);
return [...chain];
}
function chainStats(ids){
let len=0, bridge=false;
for(const id of ids){ const s=net.segments.get(id); if(!s) continue; len+=s.curve.length; if(s.isBridge) bridge=true; }
return {len, bridge, count:ids.length};
}
function openRTray(segId){
closeJTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove(); rSeg=segId;
const seg=net.segments.get(segId); if(!seg) return;
const type=ROAD_TYPE_BY_ID[seg.typeId];
const chain=roadChain(segId);
const st=chainStats(chain);
const many=chain.length>1;
document.getElementById('rTitle').textContent =
`${type.name}${st.bridge?' — bridge':''} · ${Math.round(st.len)} m${many?` · whole road (${chain.length} parts)`:''}`;
const wrap=document.getElementById('rUpgradeWrap'); wrap.innerHTML='';
// you only pay for the difference — upgrading pays the delta between the
// new type's cost and what's already there; anything that's a downgrade
// (or a lateral swap to something cheaper) is free, not a fresh build
const feeFor=tid=>{
const newType=ROAD_TYPE_BY_ID[tid];
let f=0;
for(const id of chain){
const s=net.segments.get(id), oldType=ROAD_TYPE_BY_ID[s.typeId];
const delta=newType.costPerM-oldType.costPerM;
if(delta<=0) continue;
const m=s.isBridge?BRIDGE_MULT:1;
f+=Math.max(20,Math.round(s.curve.length*delta*0.55*m));
}
return f;
};
const priceLabel=f=>f>0?`$${f.toLocaleString()}`:'Free';
const nextId=UPGRADE_NEXT[seg.typeId], prevId=UPGRADE_PREV[seg.typeId];
if(nextId){
const b=document.createElement('button'); b.className='upgrade-btn';
b.innerHTML=`⬆ Upgrade${many?' whole road':''} to ${ROAD_TYPE_BY_ID[nextId].name} <small>${priceLabel(feeFor(nextId))}</small>`;
b.onclick=()=>changeRoadType(segId,nextId);
wrap.appendChild(b);
}
if(prevId){
const b=document.createElement('button'); b.className='upgrade-btn downgrade';
b.innerHTML=`⬇ Downgrade${many?' whole road':''} to ${ROAD_TYPE_BY_ID[prevId].name} <small>${priceLabel(feeFor(prevId))}</small>`;
b.onclick=()=>changeRoadType(segId,prevId);
wrap.appendChild(b);
}
const el=document.getElementById('rTypes'); el.innerHTML='';
for(const t of ROAD_TYPES){
const b=document.createElement('button');
b.className='chip'+(seg.typeId===t.id?' on':'');
b.innerHTML=`<span class="dot" style="background:${t.ui}"></span>${t.name}`;
b.onclick=()=>changeRoadType(segId,t.id);
el.appendChild(b);
}
document.getElementById('rTray').classList.add('up');
}
function closeRTray(){ rSeg=null; document.getElementById('rTray').classList.remove('up'); }
/* How many standing buildings on this road chain the new width would cover —
the same footprint test clearLotsOverlappingRoads will apply afterwards. */
function upgradeDemoCount(segId,newTypeId){
let n=0;
for(const id of roadChain(segId)){
const seg=net.segments.get(id); if(!seg) continue;
for(const l of lots){
if(l.segment!==id || !lotHasRealBuilding(l)) continue;
const quad=standingFootprint(l); if(!quad) continue;
if(polyOverlapsRoad(quad,centroid(quad),seg,newTypeId)) n++;
}
}
return n;
}
let pendingUpgrade=null;
function openUpgradeDemoModal(n,segId,newTypeId){
pendingUpgrade={segId,newTypeId};
const text=/** @type {HTMLElement} */(document.getElementById('demoText'));
const accept=/** @type {HTMLElement} */(document.getElementById('demoAccept'));
const modal=/** @type {HTMLElement} */(document.getElementById('demoModal'));
text.textContent=`Widening this road will demolish ${n} building${n>1?'s':''}.`;
accept.textContent='Demolish & upgrade';
modal.classList.add('show');
}
function changeRoadType(segId,newTypeId,skipConfirm){
const seg=net.segments.get(segId); if(!seg||seg.typeId===newTypeId) return;
const chain=roadChain(segId).filter(id=>{ const s=net.segments.get(id); return s && s.typeId!==newTypeId; });
if(!chain.length) return;
if(!skipConfirm){
const n=upgradeDemoCount(segId,newTypeId);
if(n>0){ openUpgradeDemoModal(n,segId,newTypeId); return; }
}
const newType=ROAD_TYPE_BY_ID[newTypeId];
let fee=0;
for(const id of chain){
const s=net.segments.get(id), oldType=ROAD_TYPE_BY_ID[s.typeId];
const delta=newType.costPerM-oldType.costPerM;
if(delta<=0) continue; // downgrade / lateral swap to something cheaper: free
const m=s.isBridge?BRIDGE_MULT:1;
fee+=Math.max(20,Math.round(s.curve.length*delta*0.55*m));
}
if(!S.freePlay && fee>0 && S.cash<fee){ toast('Not enough funds to convert this road.','warn'); return; }
S.cash-=fee;
for(const id of chain){ const s=net.segments.get(id); if(s) s.typeId=newTypeId; }
rebuildAll(); openRTray(segId);
}
/**
* @param {Lot} l
*/
function openSvcEditTray(l){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); cancelMove();
svcLot=l;
const zi=ZONE_INFO[l.zone];
document.getElementById('svcTitle').textContent=`${CIVIC_ICON[l.zone]||''} ${zi?zi.name:'Service building'}`;
const fee=moveFee(l);
const deal=PARTNERSHIPS.find(p=>p.zone===l.zone);
document.getElementById('svcBody').innerHTML=`Relocate this building to an empty lot elsewhere in the city.<br>Move fee: ${fee>0?'$'+fee.toLocaleString():'Free'}${deal&&grantPaid(deal)>0?`<br><span style="color:var(--gold)">Re-siting a partnership building is billed against its signing bonus.</span>`:''}`;
document.getElementById('svcTray').classList.add('up');
}
function closeSvcTray(){ svcLot=null; const t=document.getElementById('svcTray'); if(t) t.classList.remove('up'); }
function cancelMove(){
moveSource=null; movePick=null;
const t=document.getElementById('moveTray'); if(t) t.classList.remove('up');
}
function renderMoveConfirm(){
if(!moveSource||!movePick) return;
const fee=moveFee(moveSource);
const deal=PARTNERSHIPS.find(p=>p.zone===moveSource.zone);
document.getElementById('moveBody').innerHTML=`Move this building to the selected lot?<br>Cost: ${fee>0?'$'+fee.toLocaleString():'Free'}${deal&&grantPaid(deal)>0?`<br><span style="color:var(--gold)">You keep the $${grantPaid(deal).toLocaleString()} signing bonus — the lease is on the building, not the site.</span>`:''}`;
}
/* "Here is your actual problem", in one place. Happiness is 0.6×quality of
life + 0.4×business health, so both sides are rescaled onto that same axis
before ranking — which lets a stalled export lane and a crime wave be
compared directly instead of sitting in separate sections you cross-check
by eye. Each row is worth exactly the happiness points it is costing, and
tapping it opens the overlay that shows where it's happening. */
/* Overlays only exist in Move/View — buildDock() clears S.overlay in every
other mode — so jumping to one from a tray has to switch modes first, the
same way tapping a social post does. */
function focusProblem(ov){ closeETray(); mode='move'; showOverlayFor(ov); }
function renderProblems(){
const all=[...qolShortfall().map(t=>({...t,lost:t.lost*0.6})),
...bizShortfall().map(t=>({...t,lost:t.lost*0.4}))]
.filter(t=>t.lost>0.005).sort((a,b)=>b.lost-a.lost).slice(0,3);
if(!all.length) return `<div style="font-size:11.5px;color:var(--muted);margin:6px 0 2px;line-height:1.5">Nothing much is holding this city back — every part of happiness is running near full.</div>`;
return all.map(t=>`
<div class="row problem${t.overlay?' tappable':''}"${t.overlay?` onclick="focusProblem('${t.overlay}')"`:''}>
<span><span class="what">${t.label}${t.overlay?' · tap':''}</span><span class="how">${t.fix}</span></span>
<b class="neg">−${(t.lost*100).toFixed(1)}</b>
</div>`).join('');
}
function openETray(){
closeJTray(); closeRTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove(); closeDTray();
const jobs=S.comJobs+S.indJobs+S.offJobs+(S.civicJobs||0);
const workers=Math.round(S.pop*0.55);
const short=S.unmetGoods;
const eNet=S.income-S.upkeep-Math.round(S.loanInterestMo||0);
const hap=Math.round((S.happiness??0.5)*100), qol=Math.round((S.qol??0.5)*100), biz=Math.round((S.bizHealth??0.5)*100);
document.getElementById('eBody').innerHTML=`
<div class="sec">City happiness</div>
<div class="row"><span>Overall</span><b class="${hap>=60?'pos':hap<35?'neg':''}">${hap}%</b></div>
<div class="row"><span>Quality of life</span><b>${qol}%</b></div>
<div class="row"><span>Business health</span><b>${biz}%</b></div>
<div class="row"><span>Education</span><b>${Math.round((S.education||0)*100)}%</b></div>
<div class="sec">What's holding it back</div>
${renderProblems()}
<div class="sec">Goods, per month</div>
<div class="row"><span>Industry produces</span><b>${Math.round(S.goodsProd)}</b></div>
<div class="row"><span>Shops can move</span><b>${Math.round(S.retailCap)}</b></div>
<div class="row"><span>Residents &amp; visitors want</span><b>${Math.round(S.consumption)}</b></div>
<div class="row"><span>Outside world orders</span><b>${Math.round(S.exportDemand)}</b></div>
<div class="row"><span>Actually exported</span><b class="${S.exported>0?'pos':''}">${Math.round(S.exported)}</b></div>
<div class="row"><span>Unmet orders</span><b class="${short>0?'neg':'pos'}">${Math.round(short)}</b></div>
<div class="sec">People</div>
<div class="row"><span>Residents</span><b>${S.pop.toLocaleString()}</b></div>
<div class="row"><span>Workforce</span><b>${workers.toLocaleString()}</b></div>
<div class="row"><span>Visitors from off map</span><b>${Math.round(S.visitors).toLocaleString()}</b></div>
<div class="row"><span>Regional connections</span><b>${S.connections}</b></div>
<div class="sec">Jobs vs homes</div>
<div class="row"><span>Shops / Industry / Offices</span><b>${S.comJobs} / ${S.indJobs} / ${S.offJobs}</b></div>
<div class="row"><span>Civic staff (police, fire, clinics…)</span><b>${S.civicJobs||0}</b></div>
<div class="row"><span>Jobs per available worker</span><b class="${S.jobRatio>1.05?'pos':S.jobRatio<0.75?'neg':''}">${(S.jobRatio||0).toFixed(2)}</b></div>
<div class="row"><span>Unfilled jobs</span><b class="${jobs-workers>0?'pos':''}">${Math.max(0,jobs-workers).toLocaleString()}</b></div>
<div class="row"><span>Civic staffing level</span><b class="${S.civicStaffing<0.9?'neg':'pos'}">${Math.round((S.civicStaffing??1)*100)}%</b></div>
${S.overemployment>0.02?`<div class="row"><span>Overemployment wage boom</span><b class="pos">+${Math.round(S.overemployment*50)}% income · homes in demand</b></div>`:''}
${S.jobRatio<0.75?`<div style="font-size:11.5px;color:var(--muted);margin:6px 0;line-height:1.5">Idle workforce — build shops, industry or offices, or growth stalls.</div>`:''}
${S.civicStaffing<0.95?`<div style="font-size:11.5px;color:var(--muted);margin:6px 0;line-height:1.5">The labor market's tight — police, fire, clinics and schools are short-staffed and reaching less far than they should. More housing means more hires.</div>`:''}
<div class="sec">Public safety</div>
<div class="row"><span>Crime rate</span><b class="${S.crime>0.4?'neg':S.crime<0.15?'pos':''}">${Math.round((S.crime||0)*100)}%</b></div>
<div class="row"><span>Lost to crime</span><b class="${S.crimeLoss>0?'neg':''}">${money(-Math.round(S.crimeLoss||0))}/mo</b></div>
<div style="font-size:11.5px;color:var(--muted);margin:6px 0 2px;line-height:1.5">Crime rises with pollution and thin police coverage. Above ~50% on a block, people and shops start leaving.</div>
<div class="sec">Traffic</div>
<div class="row"><span>Roads over capacity</span><b class="${S.stuckRoads>0?'neg':'pos'}">${S.stuckRoads||0}</b></div>
<div class="row"><span>Gridlock drag on income</span><b class="${S.congestion>0.05?'neg':''}">-${Math.round(Math.min(0.22,(S.congestion||0)*0.16)*100)}%</b></div>
<div style="font-size:11.5px;color:var(--muted);margin:6px 0 2px;line-height:1.5">Cars pile up where lanes can't carry the demand. Wider roads (road, avenue, highway) add lanes — upgrade the busy ones. See the 🚗 overlay.</div>
<div class="sec">The grid</div>
<div class="row"><span>⚡ Power</span><b class="${S.powerOK?'pos':'neg'}">${Math.round(S.powerGen).toLocaleString()} / ${Math.round(S.powerDemand).toLocaleString()}${S.powerOK?` (+${Math.round(S.powerGen-S.powerDemand).toLocaleString()})`:` (short ${Math.round(S.powerDemand-S.powerGen).toLocaleString()})`}</b></div>
<div class="row"><span>💧 Water</span><b class="${S.waterOK?'pos':'neg'}">${Math.round(S.waterGen).toLocaleString()} / ${Math.round(S.waterDemand).toLocaleString()}${S.waterOK?` (+${Math.round(S.waterGen-S.waterDemand).toLocaleString()})`:` (short ${Math.round(S.waterDemand-S.waterGen).toLocaleString()})`}</b></div>
${S.gridShort>0.01?`<div class="row"><span>Brownout drag on income</span><b class="neg">-${Math.round(S.gridShort*40)}%</b></div>`:''}
<div style="font-size:11.5px;color:var(--muted);margin:6px 0 2px;line-height:1.5">Power and water are one city-wide grid — capacity vs consumption, not coverage circles. Short it and nothing grows past one storey. Add plants/towers as the city grows.</div>
<div class="sec">Land value</div>
<div class="row"><span>City average</span><b class="${S.landValue>0.5?'pos':S.landValue<0.32?'neg':''}">${Math.round((S.landValue||0)*100)}%</b></div>
<div class="row"><span>Property-tax multiplier</span><b>${(0.72+0.62*(S.landValue||0)).toFixed(2)}×</b></div>
<div style="font-size:11.5px;color:var(--muted);margin:8px 0 2px;line-height:1.5">Open the 💰 Value overlay to see it lot by lot. Services, parks and natural water lift value; pollution and (for homes) heavy traffic lower it.</div>
<div class="sec">Treasury</div>
<div class="row"><span>Income</span><b class="pos">${money(S.income)}</b></div>
<div class="row"><span>Upkeep</span><b class="neg">${money(-S.upkeep)}</b></div>
<div class="row"><span>Loan balance</span><b class="${S.loan>0?'neg':''}">${money(Math.round(S.loan))}</b></div>
<div class="row"><span>Loan interest</span><b class="${S.loan>0?'neg':''}">${money(-Math.round(S.loanInterestMo||0))}</b></div>
<div class="row"><span>Net</span><b class="${eNet>=0?'pos':'neg'}">${eNet>=0?'+':''}${money(eNet)}</b></div>
`;
document.getElementById('eTray').classList.add('up');
}
function closeETray(){ document.getElementById('eTray').classList.remove('up'); }
function openDTray(){
closeJTray(); closeRTray(); closeETray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
document.getElementById('dBody').innerHTML=`
<div class="sec">What demand means</div>
<p style="font-size:12px;color:var(--muted);margin:8px 0;line-height:1.5">Demand (green/red) sets how fast a zone <b>wants</b> to grow. <b>Land value</b> sets how far it <b>can</b> grow — and how quickly. The city-wide <b>power and water</b> grid must keep up or nothing climbs past one storey. City average land value: <b>${Math.round((S.landValue||0)*100)}%</b>.</p>
<div class="sec">Residential (R) · demand <span class="${S.demand.r>0?'pos':'neg'}">${(S.demand.r*100).toFixed(0)}%</span></div>
<div style="font-size:12px;color:var(--muted);margin:8px 0;line-height:1.5">Grows when jobs are available. Land value rises with <b>police, fire, schools, clinics, parks and nearby water</b>, and falls with <b>pollution</b> and <b>heavy traffic</b> on its street.</div>
<div class="sec">Commercial (C) · demand <span class="${S.demand.c>0?'pos':'neg'}">${(S.demand.c*100).toFixed(0)}%</span></div>
<div style="font-size:12px;color:var(--muted);margin:8px 0;line-height:1.5">Grows when residents and visitors need goods. Land value rises with <b>access to industry</b> (stock), <b>busy roads</b> (footfall), <b>police</b> and nearby offices. Mildly hurt by pollution.</div>
<div class="sec">Industrial (I) · demand <span class="${S.demand.i>0?'pos':'neg'}">${(S.demand.i*100).toFixed(0)}%</span></div>
<div style="font-size:12px;color:var(--muted);margin:8px 0;line-height:1.5">Grows to supply shops and export orders. Land value rises with <b>fire and police cover</b>, <b>nearby offices</b> (logistics) and sitting on a <b>wider road</b>. Barely cares about its own pollution.</div>
<div class="sec">Office (O) · demand <span class="${S.demand.o>0?'pos':'neg'}">${(S.demand.o*100).toFixed(0)}%</span></div>
<div style="font-size:12px;color:var(--muted);margin:8px 0;line-height:1.5">Supports shops and industry with management jobs (~24% of their combined jobs). Land value rises with <b>schools, police, clinics, parks, water and nearby shops</b>; pollution hurts it a lot.</div>
`;
document.getElementById('dTray').classList.add('up');
}
function closeDTray(){ document.getElementById('dTray').classList.remove('up'); }
function closeSaveTray(){ document.getElementById('saveTray').classList.remove('up'); }
function closeMusicTray(){ document.getElementById('musicTray').classList.remove('up'); }
function closeBankTray(){ document.getElementById('bankTray').classList.remove('up'); }
function openDemoModal(n){
pendingUpgrade=null;
const accept=/** @type {HTMLElement} */(document.getElementById('demoAccept'));
const text=/** @type {HTMLElement} */(document.getElementById('demoText'));
const modal=/** @type {HTMLElement} */(document.getElementById('demoModal'));
accept.textContent='Demolish & build';
text.textContent=`Building this road will demolish ${n} building${n>1?'s':''} in its path.`;
modal.classList.add('show');
}
function closeDemoModal(){ pendingUpgrade=null; document.getElementById('demoModal').classList.remove('show'); }
/* ── bank / loans / free play ───────────────────────────────────────────── */
function takeLoan(amt){
if(S.freePlay) return;
const room=Math.max(0,LOAN_MAX-S.loan);
const take=Math.min(amt,room);
if(take<=0){ toast('The bank won’t lend any more right now.','warn'); return; }
S.loan+=take; S.cash+=take;
toast(`Borrowed ${money(take)} — loan balance ${money(Math.round(S.loan))}.`);
}
function repayLoan(amt){
if(S.freePlay) return;
const pay=Math.min(amt,S.loan,Math.max(0,S.cash));
if(pay<=0){ toast('No spare funds to repay with.','warn'); return; }
S.loan-=pay; S.cash-=pay;
toast(`Repaid ${money(pay)} — loan balance ${money(Math.round(S.loan))}.`);
}
function activateFreePlay(){
S.freePlay=true; S.loan=0; S.loanInterestMo=0; S.cash=1e9;
document.getElementById('fpModal').classList.remove('show');
closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
toast('Free Play enabled — build without limits.');
refreshHud();
}
function offerFreePlay(debt){
S.speed=0;
document.querySelectorAll('#speed button[data-s]').forEach(o=>o.classList.toggle('on',o.dataset.s==='0'));
document.getElementById('fpText').textContent=
`You're ${money(Math.round(debt))} in the red once loans and overdraft are counted, and the bank has stopped lending.`;
document.getElementById('fpModal').classList.add('show');
}
function openBankTray(){
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
const rate=(LOAN_MONTHLY_RATE*100).toFixed(1);
const owed=Math.round(S.loan);
const room=Math.max(0,LOAN_MAX-owed);
/* A zero balance row reads like a bill; say plainly there isn't one. */
let html=`
${owed>0
? `<div class="row"><span>Outstanding loan</span><b class="neg">${money(owed)}</b></div>`
: `<div class="row"><span>No debt</span><b class="pos">nothing owed</b></div>`}
<div class="row"><span>Interest rate</span><b>${rate}% / month</b></div>
<div class="row"><span>Interest cost</span><b class="${owed>0?'neg':'pos'}">${money(-Math.round(S.loanInterestMo||0))}/mo</b></div>
<div class="row"><span>Credit remaining</span><b>${money(room)}</b></div>
<div class="sec">Borrow</div><div class="chips" id="bankBorrow"></div>
<div class="sec">Repay</div><div class="chips" id="bankRepay"></div>`;
if(freePlayEverTriggered && !S.freePlay)
html+=`<button class="upgrade-btn downgrade" style="margin-top:14px" id="bankFP">Switch to Free Play — unlimited funds</button>`;
document.getElementById('bankBody').innerHTML=html;
const mkChip=(parent,label,enabled,fn)=>{
const b=document.createElement('button');
b.className='chip'; b.textContent=label; b.disabled=!enabled;
if(!enabled) b.style.opacity=.4;
b.onclick=fn; parent.appendChild(b);
};
const bor=document.getElementById('bankBorrow');
for(const amt of [5000,15000,40000])
mkChip(bor,`+ ${money(amt)}`, !S.freePlay && amt<=room,
()=>{ takeLoan(amt); openBankTray(); refreshHud(); });
const rep=document.getElementById('bankRepay');
for(const amt of [5000,15000,40000])
mkChip(rep, (amt>=owed && owed>0)?`Pay off ${money(owed)}`:`− ${money(amt)}`,
!S.freePlay && owed>0 && S.cash>0,
()=>{ repayLoan(amt); openBankTray(); refreshHud(); });
if(freePlayEverTriggered && !S.freePlay){
const fp=document.getElementById('bankFP'); if(fp) fp.onclick=activateFreePlay;
}
document.getElementById('bankTray').classList.add('up');
}
/* ── save / load (downloads a file — no browser storage involved) ────────── */
function serializeCity(){
return {
version:5, nid, terrainSeed, terrainLandBias, terrainMountainBias, citySize, terrainSpan:TERRAIN_SPAN, mapEdge:MAP_EDGE,
radio:{mode:RADIO.mode, track:RADIO.trackKey}, overlayMenuOpen,
cityName:S.cityName||'', cash:S.cash, tax:S.tax, day:S.day, simTime:S.simTime,
loan:S.loan, freePlay:S.freePlay, partnerships:S.partnerships, grants:S.grants, happyStreak:S.happyStreak,
cam:{x:cam.x,y:cam.y,z:cam.z},
nodes:[...net.nodes.values()].map(n=>({id:n.id,x:n.position.x,y:n.position.y,control:n.control,outside:n.outside})),
segments:[...net.segments.values()].map(s=>({
id:s.id,startNode:s.startNode,endNode:s.endNode,typeId:s.typeId,
sdx:s.startDir.x,sdy:s.startDir.y,sh:s.startHandle,
edx:s.endDir.x,edy:s.endDir.y,eh:s.endHandle, isBridge:!!s.isBridge, overpass:!!s.overpass,
})),
lots:lots.filter(l=>l.zone!==Z.NONE).map(l=>({segment:l.segment,side:l.side,startS:l.startS,
frontage:l.frontage,frontOff:l.frontOff,depth:l.depth,cls:l.cls,
zone:l.zone,level:l.level,sub:l.sub,
building:l.building, vacant:l.vacant, vacantTimer:l.vacantTimer, lastLevel:l.lastLevel})),
};
}
/** @param {any} data parsed save-file JSON */
function loadCityData(data){
if(!data||!Array.isArray(data.nodes)||!Array.isArray(data.segments)){
toast("That file doesn't look like a Spline City save.","bad"); return;
}
// restore map size (older saves: infer from mapEdge, else small)
if(data.citySize && CITY_SIZES[data.citySize]) applyCitySize(data.citySize);
else if(data.mapEdge){ MAP_EDGE=data.mapEdge; TERRAIN_SPAN=data.terrainSpan||Math.round(data.mapEdge*1.06); TEX_SIZE = TERRAIN_SPAN>800?1180:TERRAIN_SPAN>560?900:640; }
else applyCitySize('small');
if(data.terrainSeed!=null){ terrainSeed=data.terrainSeed; }
terrainLandBias = data.terrainLandBias!=null ? data.terrainLandBias : 0.08;
terrainMountainBias = data.terrainMountainBias!=null ? data.terrainMountainBias : 0;
generateTerrain();
net.nodes.clear(); net.segments.clear(); net.lanes.clear(); net.links.clear();
lots.length=0; agents.length=0;
nid=data.nid||1;
for(const n of data.nodes){
net.nodes.set(n.id,{id:n.id,position:{x:n.x,y:n.y},segments:[],kind:'end',radius:0,
links:[],control:n.control||CONTROL.NONE,poly:null,ringLanes:null,outside:!!n.outside});
}
for(const s of data.segments){
const a=net.nodes.get(s.startNode), b=net.nodes.get(s.endNode);
if(!a||!b) continue;
net.segments.set(s.id,{
id:s.id,startNode:s.startNode,endNode:s.endNode,typeId:s.typeId,
startDir:{x:s.sdx,y:s.sdy},startHandle:s.sh,
endDir:{x:s.edx,y:s.edy},endHandle:s.eh,
curve:new Bez(a.position,a.position,b.position,b.position),
startTrim:0,endTrim:0,lanes:[], isBridge:!!s.isBridge, overpass:!!s.overpass,
});
a.segments.push(s.id); b.segments.push(s.id);
}
for(const n of net.nodes.values()) n.kind=nodeKind(n);
// hand the saved parcels to regenerateLots so developed lots are re-placed
// at their stored geometry instead of being re-cut from the road types —
// this is what grandfathers them across a save/load. Saves from before the
// lot-class change carry no geometry; Phase 1 reconstructs their historic
// normal lots instead. loadingCity suppresses the overlap-clearing pass for
// this rebuild (see 07-lots.js).
loadingCity = true;
restoreLots = data.lots||[];
rebuildAll();
restoreLots = null;
loadingCity = false;
for(const sl of (data.lots||[])){
const match=lots.find(l=>l.segment===sl.segment&&l.side===sl.side&&Math.abs(l.startS-sl.startS)<0.6);
if(match){ match.zone=sl.zone; match.level=sl.level; match.sub=sl.sub;
match.building=sl.building||null; match.vacant=!!sl.vacant; match.vacantTimer=sl.vacantTimer||0; match.lastLevel=sl.lastLevel||0; }
}
// rebuildAll() above ran clearLotsOverlappingRoads() while every lot was
// still zone:NONE (lots.length=0 just before it, so regenerateLots() had
// nothing to inherit from) — its own zone===NONE guard made that whole
// pass a no-op. Now that the save's zoning is actually applied, run it
// again so a lot the road geometry has since encroached on (or that never
// should have cleared the check to begin with) gets cleared for real,
// instead of standing as a zoned, developed lot sitting in the roadbed.
clearLotsOverlappingRoads();
// older saves predate zone specialties — give already-developed lots one now
for(const l of lots) if(l.level>0 && (l.zone===Z.RES||l.zone===Z.COM||l.zone===Z.IND) && !l.sub) assignSpecialty(l);
S.cityName=data.cityName||''; S.cash=data.cash??S.cash; S.tax=data.tax??S.tax; S.day=data.day||0; S.simTime=data.simTime||0;
S.loan=data.loan||0; S.freePlay=!!data.freePlay; S.loanInterestMo=S.loan*LOAN_MONTHLY_RATE;
S.partnerships=data.partnerships||{};
S.grants=data.grants||{}; // absent in saves from before grants varied — grantPaid falls back to the table
S.happyStreak=data.happyStreak||0;
if(S.freePlay) S.cash=1e9;
freePlayArmed=!S.freePlay; freePlayEverTriggered=freePlayEverTriggered||S.freePlay;
if(data.cam){ cam.x=data.cam.x; cam.y=data.cam.y; cam.z=data.cam.z; }
if(data.radio){
RADIO.mode = data.radio.mode==='single' ? 'single' : 'auto';
if(RADIO_TRACKS[data.radio.track]) RADIO.trackKey=data.radio.track;
if(audioCtx && RADIO.playing){ RADIO.mode==='auto' ? radioSetAuto() : radioSelectTrack(RADIO.trackKey); }
if(typeof buildTrackOpts==='function') buildTrackOpts();
}
if(typeof data.overlayMenuOpen==='boolean') overlayMenuOpen=data.overlayMenuOpen;
S.overlay=null; ovInvalidate();
agents.length=0;
invalidateCityLayer();
census();
refreshHud();
buildDock();
syncCityNameUI();
}
/* City name → filename: strip characters a filesystem would choke on,
collapse whitespace to dashes, fall back to a generic name if blank. */
function citySlug(name){
const base=(name||'').trim() || 'Spline City';
const cleaned=base.replace(/[\\/:*?"<>|]+/g,'').trim().replace(/\s+/g,'-').slice(0,60);
return cleaned || 'spline-city';
}
function syncCityNameUI(){
document.title = S.cityName ? `${S.cityName} — Spline City` : 'Spline City';
const inp=$('cityNameInput');
if(inp && document.activeElement!==inp) inp.value=S.cityName||'';
const prev=$('saveFilePreview');
if(prev) prev.textContent=`Saves as “${citySlug(S.cityName)}.json”`;
}
/* On a phone/tablet that supports the Web Share API with files (iOS Safari,
Android Chrome, …), hand the save off to the native share sheet — AirDrop,
Files, Drive, a note, whatever the OS offers — instead of a bare download
that mobile browsers handle inconsistently. Desktop browsers (and any
phone that doesn't support file sharing) fall back to a normal download. */
async function saveCity(){
const data=serializeCity();
const filename=`${citySlug(S.cityName)}.json`;
const blob=new Blob([JSON.stringify(data)],{type:'application/json'});
let file=null;
try{ file=new File([blob],filename,{type:'application/json'}); }catch(e){ /* File ctor unsupported */ }
if(file && navigator.canShare && navigator.canShare({files:[file]})){
try{
await navigator.share({files:[file], title:S.cityName||'Spline City',
text:`${S.cityName||'My Spline City'} — ${S.pop.toLocaleString()} residents.`});
toast('Shared.');
return;
}catch(err){
if(err && err.name==='AbortError') return; // user backed out of the share sheet — don't also force a download
// any other failure (e.g. share target rejected the file): fall through to a normal download
}
}
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url; a.download=filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(url),1000);
toast('Saved — check your downloads.');
}
/* ══════════════════════════════════════════════════════════════════════════
SPLINE CITY RADIO — one procedural pro-DJ engine for every track
A single step sequencer drives whichever track is loaded through an
8-phase song structure (intro → builds → drops → grooves → breakdown →
outro) and then DJ-mixes into the next one. Two decks, each with its own
filter, so transitions can sweep / tape-stop / crossfade. Ambient textures
(vinyl crackle, soft rain) sit on the master bus. All synthesis — no files.
══════════════════════════════════════════════════════════════════════════ */
let audioCtx=null, musicMaster=null, musicTone=null, musicOn=true;
const midiToFreq=m=>440*Math.pow(2,(m-69)/12);
const STEPS_PER_LOOP=32, TRACK_LOOPS=8, TRACK_STEPS=STEPS_PER_LOOP*TRACK_LOOPS; // 256
const PHASE_NAMES=['Intro','Build-up','The Drop','Groove','Breakdown','Rebuild','Final Drop','Outro'];
const RADIO={
playing:false, mode:'auto', trackKey:'midnightSpline',
step:0, curBpm:88, targetBpm:88, pitch:1, semis:0, fx:'—', timer:null,
decks:null, activeDeck:'a', texture:{gain:null,filter:null,src:null}, voiceDelay:0,
stepTime:0, nextTime:0,
};
/* ── shared synthesis voices ──────────────────────────────────────────── */
/* Voices schedule from audioCtx.currentTime + their own `when` offset +
RADIO.voiceDelay, the scheduler's per-step swing. Tracks stay straight
about time; the pocket is applied here. Durations are in seconds. */
function voiceNow(when){ return (RADIO.stepTime||audioCtx.currentTime)+(when||0)+(RADIO.voiceDelay||0); }
function envTone(dest,freq,dur,opt){
opt=opt||{};
const now=voiceNow(opt.when);
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type=opt.type||'triangle';
o.frequency.setValueAtTime(Math.max(20,freq),now);
if(opt.detune) o.detune.setValueAtTime((Math.random()-0.5)*opt.detune,now);
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(opt.peak||0.07, now+(opt.atk||0.02));
g.gain.exponentialRampToValueAtTime(0.0001, now+dur);
o.connect(g); g.connect(dest);
o.start(now); o.stop(now+dur+0.03);
}
/* electric piano / mallet — sine carrier, fast-decaying FM at the octave */
function ePiano(dest,freq,dur,peak,opt){
opt=opt||{};
const now=voiceNow(opt.when);
const car=audioCtx.createOscillator(), mod=audioCtx.createOscillator(), mg=audioCtx.createGain(), g=audioCtx.createGain();
car.type='sine'; car.frequency.setValueAtTime(Math.max(20,freq),now);
mod.type='sine'; mod.frequency.setValueAtTime(Math.max(20,freq*2),now);
mg.gain.setValueAtTime(freq*(opt.bell?2.4:1.1),now);
mg.gain.exponentialRampToValueAtTime(freq*0.02, now+(opt.bell?0.1:0.3));
mod.connect(mg); mg.connect(car.frequency);
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(peak||0.06, now+0.005);
g.gain.exponentialRampToValueAtTime(0.0001, now+dur);
car.connect(g); g.connect(dest);
car.start(now); mod.start(now); car.stop(now+dur+0.03); mod.stop(now+dur+0.03);
}
/* warm sustained pad — two detuned saws per note through a shared lowpass */
function pad(dest,freqs,dur,peak,cutoff,when){
const now=voiceNow(when);
const f=audioCtx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=cutoff||900; f.Q.value=0.7; f.connect(dest);
const each=(peak||0.03)/Math.max(1,freqs.length)/2;
freqs.forEach(fr=>{
[-7,7].forEach(det=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='sawtooth'; o.frequency.setValueAtTime(Math.max(20,fr),now); o.detune.setValueAtTime(det,now);
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(each, now+dur*0.3);
g.gain.setValueAtTime(each, now+dur*0.75);
g.gain.linearRampToValueAtTime(0.0001, now+dur);
o.connect(g); g.connect(f); o.start(now); o.stop(now+dur+0.03);
});
});
}
/* synth bass — saw through a decaying lowpass */
function bassNote(dest,freq,dur,peak,when){
const now=voiceNow(when);
const o=audioCtx.createOscillator(), f=audioCtx.createBiquadFilter(), g=audioCtx.createGain();
o.type='sawtooth'; o.frequency.setValueAtTime(Math.max(20,freq),now);
f.type='lowpass'; f.Q.value=6;
f.frequency.setValueAtTime(1700,now);
f.frequency.exponentialRampToValueAtTime(200, now+Math.min(dur,0.3));
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(peak||0.09, now+0.008);
g.gain.exponentialRampToValueAtTime(0.0001, now+dur);
o.connect(f); f.connect(g); g.connect(dest); o.start(now); o.stop(now+dur+0.03);
}
/* resonant saw stab with a falling filter sweep */
function sweepStab(dest,freqs,dur,peak,cutoff,when){
const now=voiceNow(when);
const f=audioCtx.createBiquadFilter(); f.type='lowpass'; f.Q.value=8;
f.frequency.setValueAtTime(cutoff||2600,now);
f.frequency.exponentialRampToValueAtTime(140, now+dur);
f.connect(dest);
freqs.forEach(fr=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='sawtooth'; o.frequency.setValueAtTime(Math.max(20,fr),now);
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(peak/Math.max(1,freqs.length), now+0.005);
g.gain.exponentialRampToValueAtTime(0.0001, now+dur);
o.connect(g); g.connect(f); o.start(now); o.stop(now+dur+0.03);
});
}
let _noiseBuf=null;
function noiseBuf(){
if(_noiseBuf) return _noiseBuf;
const n=Math.floor(audioCtx.sampleRate*0.35);
const b=audioCtx.createBuffer(1,n,audioCtx.sampleRate), d=b.getChannelData(0);
for(let i=0;i<n;i++) d[i]=Math.random()*2-1;
_noiseBuf=b; return b;
}
function noiseHit(dest,dur,hp,peak,when){
const now=voiceNow(when);
const src=audioCtx.createBufferSource(); src.buffer=noiseBuf(); src.loop=true;
const f=audioCtx.createBiquadFilter(); f.type='highpass'; f.frequency.value=hp;
const g=audioCtx.createGain();
g.gain.setValueAtTime(peak,now);
g.gain.exponentialRampToValueAtTime(0.0008, now+dur);
src.connect(f); f.connect(g); g.connect(dest);
src.start(now, Math.random()*0.15); src.stop(now+dur+0.03);
}
function rKick(dest,when,intensity){
intensity=intensity||1;
const now=voiceNow(when);
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.frequency.setValueAtTime(128*intensity,now);
o.frequency.exponentialRampToValueAtTime(0.01, now+0.15);
g.gain.setValueAtTime(0.62*intensity,now);
g.gain.exponentialRampToValueAtTime(0.001, now+0.16);
o.connect(g); g.connect(dest); o.start(now); o.stop(now+0.18);
}
function rSnare(dest,when,hp){ noiseHit(dest,0.13,hp||1100,0.19,when); }
function rHat(dest,when,open){ noiseHit(dest, open?0.1:0.028, 5500, open?0.008:0.004, when); }
function rShake(dest,when){ noiseHit(dest,0.05,4400,0.006,when); }
function rRim(dest,when){ noiseHit(dest,0.02,2600,0.015,when); }
function rBrush(dest,when){ noiseHit(dest,0.19,1800,0.03,when); } /* jazz brushed snare */
function rRide(dest,when,intensity){ /* jazz ride ping */
const now=voiceNow(when), amp=intensity||1;
[5300,7900,10800].forEach((f,i)=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='triangle'; o.frequency.value=f;
g.gain.setValueAtTime(0.004*amp/(i+1),now);
g.gain.exponentialRampToValueAtTime(0.0001, now+0.45);
o.connect(g); g.connect(dest); o.start(now); o.stop(now+0.47);
});
}
function rWood(dest,when,freq,peak){ /* woodblock / hammer */
const now=voiceNow(when);
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type='triangle'; o.frequency.setValueAtTime(freq||900,now);
o.frequency.exponentialRampToValueAtTime((freq||900)*0.62, now+0.05);
g.gain.setValueAtTime(peak||0.06,now);
g.gain.exponentialRampToValueAtTime(0.0008, now+0.09);
o.connect(g); g.connect(dest); o.start(now); o.stop(now+0.1);
}
function rClank(dest,when,peak){ /* metallic machine hit */
const now=voiceNow(when);
[1250,1850,2450].forEach((fr,i)=>{
const o=audioCtx.createOscillator(), f=audioCtx.createBiquadFilter(), g=audioCtx.createGain();
o.type='square'; o.frequency.setValueAtTime(fr,now);
f.type='bandpass'; f.frequency.value=fr; f.Q.value=7;
g.gain.setValueAtTime((peak||0.035)*0.45/(i+1),now);
g.gain.exponentialRampToValueAtTime(0.0005, now+0.06+i*0.02);
o.connect(f); f.connect(g); g.connect(dest); o.start(now); o.stop(now+0.13);
});
}
function rClap(dest,when){
for(let i=0;i<3;i++) noiseHit(dest,0.03,1000,0.035,when+i*0.012);
noiseHit(dest,0.12,1200,0.03,when+0.036);
}
function chordStab(dest,freqs,dur,peak,type,cutoff,when){
const now=voiceNow(when);
const f=audioCtx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=cutoff||1600; f.connect(dest);
freqs.forEach(fr=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type=type||'triangle'; o.frequency.setValueAtTime(Math.max(20,fr),now);
o.detune.setValueAtTime((Math.random()-0.5)*6,now);
g.gain.setValueAtTime(0.0001,now);
g.gain.linearRampToValueAtTime(peak,now+0.03);
g.gain.exponentialRampToValueAtTime(0.0001, now+dur);
o.connect(g); g.connect(f); o.start(now); o.stop(now+dur+0.03);
});
}
/* ── track database — each defines a chord progression and a step() painter ──
step(step,phase,now,dest,pMult): step 0..255, phase 0..7, now = audioCtx
time, dest = the active deck's filter node, pMult = pitch multiplier.
Tracks are written straight; RADIO.voiceDelay swings the offbeat 8ths when
the track sets `swing`. Melodies are fixed patterns — no random note choice. */
const RADIO_TRACKS={
midnightSpline:{
name:'Morning Shift', texture:'vinyl', bpm:92, swing:0,
/* Gm9 · C13 · FMaj9 · Dm9 — ii–V–I–vi in F, one bar each */
prog:[
{ch:[50,58,62,65], bass:[43,46,50,45]},
{ch:[52,58,60,64], bass:[36,43,46,40]},
{ch:[48,57,60,64], bass:[41,45,48,43]},
{ch:[50,57,60,65], bass:[38,45,50,48]},
],
hook:[69,0,72,0,74,0,72,0, 70,0,67,0,69,0,0,0,
65,0,69,0,72,0,69,0, 67,0,64,0,65,0,0,0],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/16)%4];
const ch=C.ch.map(m=>midiToFreq(m)*p);
if(phase===4){
if(bar===0) pad(dest,ch,5.5,0.04,1100); /* breakdown: one held chord */
} else if(bar===0 || bar===5 || ((phase===2||phase===3||phase===6)&&bar===12)){
ch.forEach((f,i)=>ePiano(dest,f,1.15,0.03,{when:i*0.014}));
}
/* walking bass on every beat; intro just the downbeat */
if(phase===0 ? bar===0 : (phase!==4 && bar%4===0))
envTone(dest,midiToFreq(C.bass[(bar/4)|0])*p,0.4,{type:'triangle',peak:0.08,atk:0.006});
/* brushes on 2 and 4, ride on the swung eighths */
if(phase>=1 && phase<7){
if(bar===4||bar===12) rBrush(dest);
if(bar%4===0) rRide(dest,0,1);
else if(bar%2===0 && (phase===2||phase===3||phase===6)) rRide(dest,0,0.5);
if(bar===0||bar===8) rKick(dest,0,0.42);
}
/* the hook, stated on the drops and the groove */
if((phase===2||phase===3||phase===4||phase===6) && this.hook[step%32])
ePiano(dest,midiToFreq(this.hook[step%32])*p,0.6,0.05);
}
},
subSpline:{
name:'Rush Hour', texture:'clean', bpm:112, swing:0,
/* Am7 · Fmaj7 · Cmaj7 · G6 — the commute, one chord per bar */
prog:[
{ch:[57,60,64,67], root:45},
{ch:[53,57,60,64], root:41},
{ch:[48,52,55,59], root:36},
{ch:[55,59,62,66], root:43},
],
hook:[76,0,74,0,72,0,74,0, 76,0,79,0,76,0,72,0,
74,0,72,0,69,0,72,0, 74,0,76,0,72,0,69,0],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/16)%4];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(bar===0) pad(dest,ch,phase===4?4.6:2.4,0.03,1200);
/* house stabs on the offbeats */
if((phase===2||phase===3||phase===6) && bar%4===2)
chordStab(dest,ch,0.16,0.03,'triangle',2400);
/* octave-jumping eighth-note bass */
if(phase>0 && phase!==4 && bar%2===0){
const oct=[0,0,12,0,0,12,0,7][(bar/2)|0];
bassNote(dest,root*Math.pow(2,oct/12),0.2,0.085);
}
if(phase>=1 && phase<7){
if(bar%4===0) rKick(dest,0,0.9);
if(bar===4||bar===12) rClap(dest);
if(bar%4===2) rHat(dest,0,false);
if(phase>=2 && bar===14) rHat(dest,0,true);
}
if((phase===2||phase===4||phase===6) && this.hook[step%32])
ePiano(dest,midiToFreq(this.hook[step%32])*p,0.5,0.045);
}
},
boomtown:{
name:'Boomtown', texture:'clean', bpm:118, swing:0,
/* C · F · G · Am — the build site, one chord per bar */
prog:[
{ch:[48,52,55], root:36},
{ch:[53,57,60], root:41},
{ch:[55,59,62], root:43},
{ch:[57,60,64], root:45},
],
riff:[60,64,67,64,62,60,55,57],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/16)%4];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(phase===4){
if(bar===0) pad(dest,ch,4.3,0.04,1300);
} else if(bar===0 || ((phase===2||phase===3||phase===6)&&bar===10)){
chordStab(dest,ch,0.32,0.035,'triangle',2600);
}
/* hammers on the offbeats — the site never stops */
if(phase>0 && phase<7 && bar%4===2)
rWood(dest,0,820+((bar/4)%2)*160, phase>=2?0.07:0.045);
if(phase>0 && phase!==4 && bar%2===0)
envTone(dest,root*((bar%8===6)?1.5:1),0.22,{type:'triangle',peak:0.085,atk:0.005});
if(phase>=1 && phase<7){
if(bar%4===0) rKick(dest,0,(phase===2||phase===6)?1.05:0.8);
if(bar===4||bar===12) rClap(dest);
if((phase===2||phase===6)&&bar%4===2) rShake(dest);
}
/* marimba riff on the drops */
if((phase===2||phase===3||phase===4||phase===6) && step%2===0)
ePiano(dest,midiToFreq(this.riff[(step/2|0)%8]+12)*p,0.3,0.05,{bell:true});
}
},
industrial:{
name:'Industrial Zone', texture:'clean', bpm:126, swing:0,
/* Dm · C — a two-chord machine vamp, one chord per two bars */
prog:[
{ch:[38,45,50], root:38},
{ch:[36,43,48], root:36},
],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/32)%2];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(step%32===0) pad(dest,ch,phase===4?4.0:3.9,0.045,720);
/* clanks on the offbeat 16ths — assembly-line rhythm */
if(phase>=1 && phase<7 && (phase>=2 ? bar%4===3 : (bar===7||bar===15)))
rClank(dest,0,phase>=2?0.05:0.03);
/* filtered saw stabs */
if((phase===2||phase===3||phase===6) && (bar===0||bar===6||bar===10))
sweepStab(dest,ch,0.28,0.1,3000);
/* eighth-note machine bass under the drops */
if((phase===2||phase===3||phase===6) && step%2===0)
bassNote(dest,root,0.11,0.07);
if(phase>0 && phase<7){
if(bar%4===0) rKick(dest,0,(phase===2||phase===6)?1.1:0.85);
if(bar===4||bar===12) rSnare(dest,0,1100);
}
if((phase===4||phase===7) && step%32===8)
ePiano(dest,midiToFreq(62)*p,1.4,0.05);
}
},
neonHighway:{
name:'Neon Highway', texture:'vinyl', bpm:104, swing:0,
/* Am · F · C · G — the ring road at night */
prog:[
{ch:[45,52,57,60], root:45},
{ch:[41,48,53,57], root:41},
{ch:[48,55,60,64], root:48},
{ch:[43,50,55,59], root:43},
],
lead:[69,0,72,0,76,0,74,0, 72,0,69,0,67,0,64,0,
69,0,72,0,74,0,72,0, 67,0,64,0,62,0,64,0],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/16)%4];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(bar===0) pad(dest,ch,phase===4?4.9:2.6,0.04,1000);
/* arpeggiated eighth-note bass */
if(phase>0 && phase!==4 && bar%2===0){
const arp=[0,12,7,12][(bar/2|0)%4];
bassNote(dest,root*Math.pow(2,arp/12),0.22,0.08);
}
if(phase>=1 && phase<7){
if(bar%4===0) rKick(dest,0,0.8);
if(bar===4||bar===12) rSnare(dest,0,1600);
if(bar%4===2) rHat(dest,0,false);
}
if((phase===2||phase===4||phase===6) && this.lead[step%32])
envTone(dest,midiToFreq(this.lead[step%32])*p,0.5,{type:'sine',peak:0.05,atk:0.01,detune:8});
}
},
skyline:{
name:'Skyline', texture:'clean', bpm:80, swing:0,
/* Cmaj9 · G/B · Am7 · Fmaj9 — slow glass-and-steel pads */
prog:[
{ch:[48,52,55,59], root:36},
{ch:[47,50,55,59], root:43},
{ch:[45,52,57,60], root:45},
{ch:[41,48,52,57], root:41},
],
bell:[72,0,0,0,76,0,74,0, 72,0,0,0,67,0,64,0,
69,0,0,0,72,0,71,0, 67,0,0,0,64,0,60,0],
step(step,phase,now,dest,p){
const bar=step%16, C=this.prog[Math.floor(step/16)%4];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(bar===0) pad(dest,ch,phase===4?6.3:3.4,0.045,900);
/* half-note bass */
if(phase>0 && phase!==4 && (bar===0||bar===8))
envTone(dest,root,1.1,{type:'sine',peak:0.075,atk:0.02});
if(phase>=1 && phase<7){
if(bar===4||bar===12) rRim(dest);
if(bar===0) rKick(dest,0,0.45);
}
/* bell motif on the drops and the breakdown */
if((phase===2||phase===3||phase===4||phase===6) && this.bell[step%32])
ePiano(dest,midiToFreq(this.bell[step%32])*p,0.9,0.05,{bell:true});
}
},
groundwork:{
name:'Groundwork', texture:'rain', bpm:68, swing:0,
/* Dm add9 · G · Am7 — foundation pads, one chord per four bars */
prog:[
{ch:[38,41,45,52], root:38},
{ch:[43,47,50,57], root:43},
{ch:[45,48,52,55], root:45},
],
motif:[62,0,0,0,65,0,64,0, 62,0,0,0,57,0,60,0],
step(step,phase,now,dest,p){
const bar=step%64, C=this.prog[Math.floor(step/64)%3];
const ch=C.ch.map(m=>midiToFreq(m)*p), root=midiToFreq(C.root)*p;
if(step%64===0) pad(dest,ch,15,0.055,620);
/* slow mallet line on the quarters */
if((phase===2||phase===3||phase===4||phase===6) && step%4===0 && this.motif[(step/4|0)%16])
ePiano(dest,midiToFreq(this.motif[(step/4|0)%16])*p,1.1,0.05,{bell:true});
/* heartbeat kick under the drops */
if((phase===2||phase===6) && (bar===0||bar===12)) rKick(dest,0,0.4);
if(phase>0 && phase!==4 && bar===32) envTone(dest,root/2,2.4,{type:'sine',peak:0.06,atk:0.5});
if(phase===4 && bar===16) ePiano(dest,midiToFreq(50)*p,1.6,0.045);
}
},
};
/* RADIO_ORDER is a day arc: dawn commute → work → evening → night → reflection */
const RADIO_ORDER=['midnightSpline','subSpline','boomtown','industrial','neonHighway','skyline','groundwork'];
/* ── decks + ambient texture ─────────────────────────────────────────── */
function radioDeck(){
const gain=audioCtx.createGain(), filter=audioCtx.createBiquadFilter();
filter.type='allpass'; filter.connect(gain); gain.connect(musicMaster);
return {gain,filter};
}
function radioStartTexture(){
const n=Math.floor(audioCtx.sampleRate*4);
const b=audioCtx.createBuffer(1,n,audioCtx.sampleRate), d=b.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()<0.00008)?(Math.random()*2-1)*0.9:(Math.random()*2-1)*0.03;
const src=audioCtx.createBufferSource(); src.buffer=b; src.loop=true;
const filter=audioCtx.createBiquadFilter(), gain=audioCtx.createGain(); gain.gain.value=0;
src.connect(filter); filter.connect(gain); gain.connect(musicMaster); src.start();
RADIO.texture={src,filter,gain};
}
function radioSetTexture(kind){
const t=RADIO.texture; if(!t||!t.gain) return;
const now=audioCtx.currentTime;
if(kind==='rain'){ t.filter.type='lowpass'; t.filter.frequency.setValueAtTime(360,now); t.gain.gain.linearRampToValueAtTime(0.15, now+2); }
else if(kind==='vinyl'){ t.filter.type='highpass'; t.filter.frequency.setValueAtTime(2000,now); t.gain.gain.linearRampToValueAtTime(0.018, now+2); }
else { t.gain.gain.linearRampToValueAtTime(0, now+1.2); }
}
/* ── DJ transition ───────────────────────────────────────────────────── */
/* Auto DJ walks RADIO_ORDER in sequence — the order is a day arc, so the
set moves dawn → night instead of hopping around at random. */
function radioNextAuto(){
const i=RADIO_ORDER.indexOf(RADIO.trackKey);
return RADIO_ORDER[(i+1)%RADIO_ORDER.length];
}
function radioTransition(nextKey){
if(!audioCtx) return;
const next=RADIO_TRACKS[nextKey]; if(!next) return;
const now=audioCtx.currentTime;
RADIO.step=0;
RADIO.trackKey=nextKey;
/* snap to the new track's tempo — a glide reads as the music lurching */
RADIO.targetBpm=next.bpm; RADIO.curBpm=next.bpm;
RADIO.semis=Math.round(Math.random()*4-2);
RADIO.pitch=Math.pow(2,RADIO.semis/12);
radioSetTexture(next.texture||'clean');
const outKey=RADIO.activeDeck;
RADIO.activeDeck = outKey==='a'?'b':'a';
const outD=RADIO.decks[outKey], inD=RADIO.decks[RADIO.activeDeck];
inD.filter.type='allpass';
inD.gain.gain.cancelScheduledValues(now);
inD.gain.gain.setValueAtTime(0.0001,now);
inD.gain.gain.linearRampToValueAtTime(1, now+1.6);
const fx=['sweep','tape','fade'][Math.floor(Math.random()*3)];
outD.gain.gain.cancelScheduledValues(now);
outD.gain.gain.setValueAtTime(Math.max(0.0002,outD.gain.gain.value),now);
if(fx==='sweep'){
outD.filter.type='lowpass';
outD.filter.frequency.cancelScheduledValues(now);
outD.filter.frequency.setValueAtTime(12000,now);
outD.filter.frequency.exponentialRampToValueAtTime(180, now+1.5);
outD.gain.gain.linearRampToValueAtTime(0.0001, now+1.7);
RADIO.fx='Filter sweep';
} else if(fx==='tape'){
outD.gain.gain.exponentialRampToValueAtTime(0.0001, now+1.1);
RADIO.fx='Tape stop';
} else {
outD.gain.gain.linearRampToValueAtTime(0.0001, now+1.6);
RADIO.fx='Crossfade';
}
refreshRadioHud();
}
/* ── scheduler ───────────────────────────────────────────────────────── */
/* Lookahead: a 25 ms timer queues every step that falls in the next 200 ms
on an absolute audio-clock grid. A busy main thread can delay when a step
is queued, never when it sounds — the beat is independent of frame rate. */
const RADIO_LOOKAHEAD=0.2;
function radioStepAt(t){
const local=RADIO.step % TRACK_STEPS;
const phase=Math.floor(local/STEPS_PER_LOOP);
const track=RADIO_TRACKS[RADIO.trackKey];
const stepSec=(60/Math.max(40,RADIO.curBpm))/4;
RADIO.stepTime=t;
/* swing: delay the offbeat 8ths (step%4===2) by the track's amount */
RADIO.voiceDelay=(track && track.swing && local%4===2) ? track.swing*stepSec : 0;
if(track){
try{ track.step(local, phase, t, RADIO.decks[RADIO.activeDeck].filter, RADIO.pitch); }catch(e){}
}
RADIO.step++;
if(RADIO.step % TRACK_STEPS === 0 && RADIO.mode==='auto') radioTransition(radioNextAuto());
if(RADIO.step % 4 === 0) refreshRadioHud();
}
function radioTick(){
if(!RADIO.playing || !audioCtx) return;
const now=audioCtx.currentTime;
const stepSec=(60/Math.max(40,RADIO.curBpm))/4;
if(!RADIO.nextTime) RADIO.nextTime = now + 0.06;
if(RADIO.nextTime < now){
/* a long stall (heavy frame, background tab): drop the steps that would
have already sounded, but keep the grid so the tempo never bends */
const missed=Math.ceil((now-RADIO.nextTime)/stepSec);
const before=Math.floor(RADIO.step/TRACK_STEPS);
RADIO.step+=missed; RADIO.nextTime+=missed*stepSec;
if(Math.floor(RADIO.step/TRACK_STEPS)>before && RADIO.mode==='auto') radioTransition(radioNextAuto());
}
while(RADIO.nextTime < now+RADIO_LOOKAHEAD){
radioStepAt(RADIO.nextTime);
RADIO.nextTime += (60/Math.max(40,RADIO.curBpm))/4;
}
RADIO.timer=setTimeout(radioTick, 25);
}
function refreshRadioHud(){
const el=document.getElementById('radioNow'); if(!el) return;
if(audioCtx && RADIO.playing){
const t=RADIO_TRACKS[RADIO.trackKey];
const ph=PHASE_NAMES[Math.floor((RADIO.step%TRACK_STEPS)/STEPS_PER_LOOP)];
const key=RADIO.semis>0?`+${RADIO.semis}`:`${RADIO.semis}`;
el.textContent=`♪ ${t.name} — ${ph} · ${Math.round(RADIO.curBpm)} BPM ${key} semi · ${RADIO.fx}`;
} else {
el.textContent='Radio off — it starts when the game does.';
}
}
function radioSelectTrack(key){
RADIO.mode='single';
if(!RADIO_TRACKS[key]) return;
if(!audioCtx){ RADIO.trackKey=key; startMusic(); }
else radioTransition(key);
if(typeof buildTrackOpts==='function') buildTrackOpts();
}
function radioSetAuto(){
RADIO.mode='auto';
if(!audioCtx) startMusic();
else radioTransition(radioNextAuto());
if(typeof buildTrackOpts==='function') buildTrackOpts();
}
function startMusic(){
if(audioCtx) return;
try{
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
audioCtx.resume().catch(()=>{});
musicMaster=audioCtx.createGain();
musicMaster.gain.value=musicOn?0.42:0;
/* take the sizzle off cymbals and hats without dulling the mix */
musicTone=audioCtx.createBiquadFilter();
musicTone.type='highshelf'; musicTone.frequency.value=7000; musicTone.gain.value=-5;
musicMaster.connect(musicTone); musicTone.connect(audioCtx.destination);
RADIO.decks={a:radioDeck(), b:radioDeck()};
RADIO.decks.a.gain.gain.value=1; RADIO.decks.b.gain.gain.value=0.0001;
RADIO.activeDeck='a';
radioStartTexture();
if(!RADIO_TRACKS[RADIO.trackKey]) RADIO.trackKey='midnightSpline';
const t=RADIO_TRACKS[RADIO.trackKey];
RADIO.step=0; RADIO.semis=0; RADIO.pitch=1;
RADIO.curBpm=RADIO.targetBpm=t.bpm;
RADIO.fx=RADIO.mode==='auto'?'Auto DJ':'On air';
radioSetTexture(t.texture||'clean');
RADIO.playing=true;
radioTick();
if(typeof buildTrackOpts==='function') buildTrackOpts();
}catch(e){ /* Web Audio unavailable — continue without music */ }
}
/* ── input ──────────────────────────────────────────────────────────────────
Every tool works the same way: a TAP acts (place / select / paint one lot),
a DRAG pans the map. Zoning also drag-paints while the finger stays over
lots. The action a tap would perform is deferred to pointerup so a drag that
starts anywhere — including on a building or road — pans instead of acting. */
let mode='road', zoneSel=Z.RES;
let anchor=null, dragTarget=null, panning=false, moved=0, pinch=null;
let tap=null; // {mode, w:{x,y}, painting}
const TAP_SLOP=9; // px of travel before a press becomes a drag
const ptrs=new Map();
/* ── inspect: tap a building, car or pedestrian in View mode to see it ───── */
let inspectTarget=null; // {type:'agent'|'lot'|'road', ...}
let followAgent=null; // camera tracks this agent's position every frame
function snapAt(p){
const n=nearestNode(p,16/cam.z);
if(n) return {node:n,pos:n.position};
const s=snapSegment(p,12/cam.z);
if(s) return {seg:s.seg,t:s.hit.t,pos:s.hit.point};
return null;
}
/* what a clean tap does, per tool — called from pointerup */
function doTap(m,w){
if(m==='road'){
const snap=snapAt(w);
const pos=snap?snap.pos:w;
if(!anchor){ anchor={pos:{x:pos.x,y:pos.y},snap}; setHint("Tap the other end. Tap Roads again to cancel."); }
else{
pending={a:{x:anchor.pos.x,y:anchor.pos.y},b:{x:pos.x,y:pos.y},
snapA:anchor.snap,snapB:snap,typeId:roadTypeSel,crossings:[]};
defaultHandles(); anchor=null;
document.getElementById('tray').classList.add('up');
refreshTray();
setHint("Drag the gold handles to shape the curve.");
}
} else if(m==='edit'){
if(moveSource){
// mid-move: this tap picks (or repicks) the destination lot, not
// something to edit
const l=lotAt(w);
if(l && l!==moveSource && l.zone===Z.NONE){
movePick=l; renderMoveConfirm();
document.getElementById('moveTray').classList.add('up');
} else if(l===moveSource){
// tapped the building itself again — ignore
} else {
toast('Pick an empty lot to relocate it to.','warn');
}
return;
}
// anywhere on a drawn junction always means "edit the junction" — the
// road underneath it (its trimmed-back end) is never reachable there
const n=nodeHitAt(w);
if(n){ openJTray(n); return; }
const l=lotAt(w);
if(l && CIVIC_ICON[l.zone] && l.level>0 && !l.building){ openSvcEditTray(l); return; }
const segHit=snapSegmentWide(w);
if(segHit) openRTray(segHit.seg.id); else { closeJTray(); closeRTray(); closeSvcTray(); }
} else if(m==='zone' || m==='util' || m==='utility' || m==='special'){
const l=lotAt(w);
if(l) paintLot(l);
} else if(m==='demolish'){
const l=lotAt(w);
if(l&&l.zone!==Z.NONE){
const dealBailout=PARTNERSHIPS.find(p=>p.zone===l.zone && p.bailout);
if(dealBailout){ openBailoutDemoModal(l,dealBailout); return; }
demolishLot(l);
} else {
const n=nodeHitAt(w);
if(n){ deleteNode(n.id); S.cash-=40; rebuildAll(); }
else { const segHit=snapSegmentWide(w); if(segHit){ removeSegment(segHit.seg.id); S.cash-=25; rebuildAll(); } }
}
} else if(m==='move'){
const v=agentAt(w);
if(v){ openInspectAgent(v); return; }
const l=lotAt(w);
if(l && l.zone!==Z.NONE){ openInspectLot(l); return; }
const n=nodeHitAt(w);
if(n){ openInspectJunction(n); return; }
const segHit=snapSegmentWide(w);
if(segHit){ openInspectRoad(segHit.seg.id); return; }
closeITray();
}
}
cv.addEventListener('pointerdown',e=>{
followAgent=null;
cv.setPointerCapture(e.pointerId);
ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
moved=0;
if(e.pointerType==='mouse' && e.button===2){ dragTarget=null; tap=null; panning=true; return; }
if(e.pointerType==='mouse' && e.button===1) return;
if(ptrs.size>=2){ dragTarget=null; panning=false; pinch=null; tap=null; return; }
const w=wc(e.clientX,e.clientY);
panning=false; dragTarget=null; tap=null;
if(pending){
const grab=(pt,r)=>dist(pt,w)<r/cam.z;
if(grab(pending.h1,18)) dragTarget='h1';
else if(grab(pending.h2,18)) dragTarget='h2';
else if(!pending.lockEndpoints && grab(pending.a,16)) dragTarget='a';
else if(!pending.lockEndpoints && grab(pending.b,16)) dragTarget='b';
else panning=true;
return;
}
// remember the intent; pointerup resolves it (tap → act, drag → pan)
tap={mode, w:{x:w.x,y:w.y}, painting:false, touch:e.pointerType!=='mouse'};
});
cv.addEventListener('pointermove',e=>{
const p=ptrs.get(e.pointerId); if(!p) return;
const dx=e.clientX-p.x, dy=e.clientY-p.y;
moved+=Math.abs(dx)+Math.abs(dy);
p.x=e.clientX; p.y=e.clientY;
if(ptrs.size>=2){
const [a,b]=[...ptrs.values()];
const d=dist(a,b), mx=(a.x+b.x)/2, my=(a.y+b.y)/2;
if(!pinch) pinch={d,z:cam.z,mx,my};
const before=wc(mx,my);
cam.z=clamp(pinch.z*(d/pinch.d),0.5,9);
const after=wc(mx,my);
cam.x+=before.x-after.x; cam.y+=before.y-after.y;
cam.x-=(mx-pinch.mx)/cam.z; cam.y-=(my-pinch.my)/cam.z;
pinch.mx=mx; pinch.my=my;
return;
}
if(dragTarget && pending){
const w=wc(e.clientX,e.clientY);
if(dragTarget==='h1'||dragTarget==='h2'){ pending[dragTarget]={x:w.x,y:w.y}; }
else{
const snap=snapAt(w);
const pos=snap?snap.pos:w;
const delta=sub(pos,pending[dragTarget]);
pending[dragTarget]={x:pos.x,y:pos.y};
pending[dragTarget==='a'?'h1':'h2']=add(pending[dragTarget==='a'?'h1':'h2'],delta);
pending[dragTarget==='a'?'snapA':'snapB']=snap;
}
makePendingCurve(); refreshTray();
return;
}
// decide what the drag is once it clears the slop. Touch always pans (so you
// can move the map from anywhere); a mouse drag in Zoning/Services still
// paints a run of lots, since the right button pans on desktop.
if(tap && !panning && !tap.painting && moved>TAP_SLOP){
const w=wc(e.clientX,e.clientY);
const hit=lotAt(w);
if(!tap.touch && (tap.mode==='zone'||tap.mode==='util'||tap.mode==='utility'||tap.mode==='special') && hit){
tap.painting=true;
paintLot(hit);
} else {
panning=true;
}
}
if(tap && tap.painting){
const l=lotAt(wc(e.clientX,e.clientY));
if(l) paintLot(l);
return;
}
if(panning){ cam.x-=dx/cam.z; cam.y-=dy/cam.z; }
});
function endPtr(e){
ptrs.delete(e.pointerId);
if(ptrs.size<2) pinch=null;
if(ptrs.size===0){
if(tap && !panning && !tap.painting && moved<=TAP_SLOP) doTap(tap.mode, tap.w);
tap=null; dragTarget=null; panning=false;
}
}
cv.addEventListener('pointerup',endPtr);
cv.addEventListener('pointercancel',endPtr);
cv.addEventListener('wheel',e=>{
e.preventDefault();
const before=wc(e.clientX,e.clientY);
cam.z=clamp(cam.z*(e.deltaY<0?1.12:0.89),0.5,9);
const after=wc(e.clientX,e.clientY);
cam.x+=before.x-after.x; cam.y+=before.y-after.y;
},{passive:false});
cv.addEventListener('contextmenu',e=>{ e.preventDefault(); });
addEventListener('keydown',e=>{
if(e.key==='Escape'){
anchor=null; clearPending(); closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
setHint(HINTS[mode]);
}
});
/**
* @param {Lot} l
*/
function paintLot(l){
if(l.zone===zoneSel) return;
if(SPECIAL_CHIPS.includes(zoneSel) && !isSpecialUnlocked(zoneSel)) return;
invalidateCityLayer();
const cost=ZONE_INFO[zoneSel].cost;
if(cost>0 && !S.freePlay && S.cash<cost){ toast("Not enough funds.","warn"); return; }
S.cash-=cost;
l.zone=zoneSel;
l.level = INSTANT_ZONES.has(zoneSel) ? 1 : 0;
l.grow=0; l.building=null; l.vacant=false; l.vacantTimer=0; l.lastLevel=0;
if(INSTANT_ZONES.has(zoneSel)) census(); // service/plant placed — recompute coverage & grid now
if(SPECIAL_CHIPS.includes(zoneSel)){
// siting it is what actually takes the deal, whichever way you answered
// the modal at the time — so this is where the signing bonus is paid
claimPartnerGrant(zoneSel);
const p=PARTNERSHIPS.find(x=>x.zone===zoneSel);
if(p) S.partnerships[p.id]='built'; // one of each at a time; demolishing puts it back
buildDock();
}
ovInvalidate();
refreshHud();
}
/* ── chrome ─────────────────────────────────────────────────────────────── */
/** Every id here is in the static markup, so this never really returns null.
* @type {(id:string)=>any} */
const $=id=>document.getElementById(id);
const money=v=>(v<0?"-$":"$")+Math.abs(Math.round(v)).toLocaleString();
const HINTS={
move:"Drag anywhere to pan, pinch or scroll to zoom. Tap a building, car or person to inspect it. Overlays live in the submenu below.",
road:"Pick a road type, tap a start, then an end. Drag the map to pan. Water auto-bridges; a map edge opens a regional connection.",
edit:"Tap any part of a road to upgrade, downgrade, reshape or retype the whole run. Drag to pan.",
zone:"Tap a lot to zone it — zoning is free. Drag across lots to paint a block; drag from open ground to pan.",
util:"Place power and water. Tap a lot to build; drag from open ground to pan.",
utility:"Place police, fire, clinics, schools and parks. Tap a lot to build; drag from open ground to pan.",
demolish:"Tap a building to clear it, a road to remove it, or a junction to clear it. Drag to pan.",
special:"Buildings from your partnerships — free to place, and any signing bonus is paid when you place it.",
};
function setHint(t){ $('hint').textContent=t||''; }
const MODES=[
{id:'move',icon:'🔍',label:'Move/View'},{id:'road',icon:'🛣️',label:'Roads'},{id:'zone',icon:'🏘️',label:'Zoning'},
{id:'util',icon:'⚡',label:'Utilities'},{id:'utility',icon:'🚒',label:'Services'},
{id:'edit',icon:'✏️',label:'Edit'},{id:'demolish',icon:'🧨',label:'Demolish'},
];
/* the sub-row (road types / zone chips / etc) collapses the same way the
overlay picker in Move/View already does — one flag shared across every
mode that uses it, since only one is ever showing at a time anyway */
let subMenuOpen=true;
/* icon-only mode kicks in only when the row genuinely doesn't fit at full
width — measured, not guessed from a fixed breakpoint, since which chips
are even in the row (Special appearing/disappearing) changes that. */
function fitChipRow(el){
el.classList.remove('iconOnly');
el.style.flexWrap='nowrap';
const overflow=el.scrollWidth>el.clientWidth+1;
el.style.flexWrap='';
el.classList.toggle('iconOnly',overflow);
}
function buildDock(){
const m=$('modes'); m.innerHTML='';
// Special is always in the mode bar — it doubles as an achievement board.
// A partnership nobody's earned yet still shows up there, locked, so
// there's something to see (and aim for) before the first one unlocks.
const modesList = [...MODES.slice(0,3), {id:'special',icon:'🏛️',label:'Special'}, ...MODES.slice(3)];
for(const md of modesList){
const b=document.createElement('button');
b.className='chip'+(mode===md.id?' on':'');
b.innerHTML=`${md.icon}<span class="chipLbl"> ${md.label}</span>`;
b.onclick=()=>{
mode=md.id; anchor=null; tap=null; clearPending();
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
if(mode==='zone' && !ZONE_CHIPS.includes(zoneSel)) zoneSel=Z.RES;
if(mode==='util' && !UTIL_CHIPS.includes(zoneSel)) zoneSel=Z.POWER;
if(mode==='utility' && !SERVICE_CHIPS.includes(zoneSel)) zoneSel=Z.POLICE;
if(mode==='special'){ const unlocked=SPECIAL_CHIPS.filter(isSpecialUnlocked); if(!unlocked.includes(zoneSel)) zoneSel=unlocked[0]; }
setHint(HINTS[md.id]); buildDock();
};
m.appendChild(b);
}
fitChipRow(m);
const s=$('sub'); s.innerHTML='';
const SUB_LABEL2={road:'Road types',zone:'Zoning',util:'Utilities',utility:'Services',special:'Special'};
if(mode==='road' || mode==='zone' || mode==='util' || mode==='utility' || mode==='special'){
const curName = mode==='road' ? ROAD_TYPE_BY_ID[roadTypeSel]?.name
: (ZONE_INFO[zoneSel]?.name || '—');
const tgl=document.createElement('button');
tgl.className='chip'+(!subMenuOpen?' on':'');
tgl.textContent = subMenuOpen ? `▾ ${SUB_LABEL2[mode]}` : `▸ ${curName}`;
tgl.onclick=()=>{ subMenuOpen=!subMenuOpen; buildDock(); };
s.appendChild(tgl);
}
if(subMenuOpen && mode==='road'){
for(const type of ROAD_TYPES){
const b=document.createElement('button');
b.className='chip'+(roadTypeSel===type.id?' on':'');
b.innerHTML=`<span class="dot" style="background:${type.ui}"></span>${type.name}${type.oneWay?' →':''} <small>$${type.costPerM}/m</small>`;
b.onclick=()=>{ roadTypeSel=type.id; buildDock(); };
s.appendChild(b);
}
} else if(subMenuOpen && (mode==='zone' || mode==='util' || mode==='utility' || mode==='special')){
// Special lists every partnership all the time, unlike Zoning/Utilities/Services —
// it's an achievement board as much as a placement menu, so a locked
// entry stays visible (greyed, unclickable) instead of vanishing, and a
// built one stays visible too, checked off instead of just disappearing.
// Whatever's actually placeable right now floats to the front instead of
// sitting wherever SPECIAL_CHIPS happens to list it — that's the one
// thing worth not making the player hunt for. Array#sort is stable, so
// ties (multiple unlocked, or the untouched rest) keep the fixed order.
const chips = mode==='zone' ? ZONE_CHIPS : mode==='util' ? UTIL_CHIPS : mode==='utility' ? SERVICE_CHIPS
: [...SPECIAL_CHIPS].sort((a,b)=>(isSpecialUnlocked(b)?1:0)-(isSpecialUnlocked(a)?1:0));
for(const z of chips){
const info=ZONE_INFO[z];
const b=document.createElement('button');
const icon=CIVIC_ICON[z]?CIVIC_ICON[z]+' ':'';
let costLabel = info.cost>0 ? `$${info.cost}` : 'Free';
let statusIcon='', unavailable=false;
if(mode==='special'){
const deal=PARTNERSHIPS.find(x=>x.zone===z);
const built = deal && S.partnerships[deal.id]==='built';
const unlocked = isSpecialUnlocked(z);
unavailable = built || !unlocked;
if(built){ statusIcon='✓ '; costLabel='Built'; }
else if(!unlocked){
statusIcon='🔒 ';
// a bailout reads as plain "Locked" the first time, same as
// everything else — it's only once it's actually been sited (and
// the grants record proves that) that losing it again means
// re-clearing the same bar, not just clearing it for the first time
const everBuilt = deal && S.grants && S.grants[deal.id]!=null;
costLabel = everBuilt ? 'Requirements not met' : 'Locked';
}
else{
// an offer you turned down keeps its cheque, and the modal that
// mentioned it is long gone — so the chip has to carry that itself
const pending=deal && (!S.grants || S.grants[deal.id]==null) ? grantOffered(deal) : 0;
if(pending>0) costLabel=`+${money(pending)}`;
}
}
b.className='chip'+(zoneSel===z?' on':'')+(unavailable?' locked':'');
b.disabled=unavailable;
b.innerHTML=`<span class="dot" style="background:${info.color}"></span>${icon}${statusIcon}${info.name} <small>${costLabel}</small>`;
b.onclick=()=>{ if(unavailable) return; zoneSel=z; buildDock(); };
s.appendChild(b);
}
}
const o=$('overlays'); o.innerHTML='';
if(mode==='move'){
o.style.display='';
const activeOv = S.overlay ? OVERLAYS.find(x=>x.id===S.overlay) : null;
const tgl=document.createElement('button');
tgl.className='chip'+(!overlayMenuOpen && activeOv ? ' on':'');
tgl.textContent = overlayMenuOpen ? '▾ Overlays' : (activeOv ? `▸ ${activeOv.label}` : '▸ Overlays');
tgl.onclick=()=>{ overlayMenuOpen=!overlayMenuOpen; buildDock(); };
o.appendChild(tgl);
if(!overlayMenuOpen && activeOv){
const off=document.createElement('button');
off.className='chip'; off.textContent='✕';
off.onclick=()=>{ S.overlay=null; setHint(HINTS[mode]); buildDock(); };
o.appendChild(off);
}
if(overlayMenuOpen){
for(const ov of OVERLAYS){
const b=document.createElement('button');
b.className='chip'+(S.overlay===ov.id?' on':'');
b.textContent=ov.label;
b.onclick=()=>{ S.overlay=(S.overlay===ov.id?null:ov.id); buildDock(); setHint(S.overlay?OVERLAY_HINT[S.overlay]:HINTS[mode]); };
o.appendChild(b);
}
}
} else {
o.style.display='none';
S.overlay=null;
}
}
const OVERLAY_HINT={
value:"Land value — green high, red low. ⚡/💧 markers can't grow past one storey until the grid catches up.",
power:"Power grid — a city-wide budget. Green means supply covers demand; red means brownouts. Build more plants as you grow.",
water:"Water grid — a city-wide budget. Green means supply covers demand; red means shortage. Build more towers or reservoirs as you grow.",
police:"Police reach along the roads. Green covered, red out of range. 🚓 markers flag buildings with no station in reach.",
fire:"Fire cover along the roads. 🚒 markers flag homes and industry with no fire station in reach.",
health:"Clinic reach along the roads. 🏥 markers flag homes and offices with no clinic in reach.",
school:"School reach along the roads. 🏫 markers flag homes and offices with no school in reach.",
traffic:"Traffic — red roads are over capacity and cars pile up. Upgrade them for more lanes.",
crime:"Crime — magenta areas are unsafe (pollution + no police). 🚨 blocks are losing people.",
industry:"Industry output — the brighter the area, the more goods being made for shops and export.",
pollution:"Pollution from factories and busy roads. It drags down nearby land value, homes most of all.",
};
function refreshHud(){
if(S.freePlay){
$('cash').firstElementChild.textContent='$∞';
$('cash').classList.remove('low');
$('cash').lastElementChild.textContent='Free Play';
} else {
const net=S.income-S.upkeep-Math.round(S.loanInterestMo||0);
$('cash').firstElementChild.textContent=money(S.cash);
$('cash').classList.toggle('low',S.cash<1000);
$('cash').lastElementChild.textContent=`${net>=0?'+':''}${money(net)}/mo`;
}
$('pop').firstElementChild.textContent=S.pop.toLocaleString();
let sub=`${(S.comJobs+S.indJobs+S.offJobs+(S.civicJobs||0)).toLocaleString()} jobs · tap`;
if(!S.powerOK && S.powerDemand>0) sub=`⚡ power shortage · tap`;
else if(!S.waterOK && S.waterDemand>0) sub=`💧 water shortage · tap`;
else if(S.crime>0.42) sub=`⚠ crime ${Math.round(S.crime*100)}% · tap`;
else if(S.stuckRoads>=4) sub=`⚠ ${S.stuckRoads} gridlocked roads · tap`;
else if(S.overemployment>0.05) sub=`📈 hiring boom · ${(S.comJobs+S.indJobs+S.offJobs+(S.civicJobs||0)).toLocaleString()} jobs`;
$('pop').lastElementChild.textContent=sub;
const hap=Math.round((S.happiness??0.5)*100);
const hapIcon = hap>=70?'😊':hap>=45?'😐':'😟';
$('happy').firstElementChild.textContent=`${hapIcon} ${hap}%`;
$('happy').classList.toggle('low',hap<35);
$('happy').classList.toggle('mid',hap>=35&&hap<60);
$('happy').lastElementChild.textContent = hap>=70?'happy city · tap':hap>=45?'getting by · tap':'struggling · tap';
bar('dr',S.demand.r,ZONE_INFO[Z.RES].color);
bar('dc',S.demand.c,ZONE_INFO[Z.COM].color);
bar('di',S.demand.i,ZONE_INFO[Z.IND].color);
bar('dof',S.demand.o,ZONE_INFO[Z.OFF].color);
}
function bar(id,v,color){
const el=$(id), h=Math.max(2,Math.abs(v)*17);
el.style.background=color; el.style.opacity=0.35+Math.abs(v)*0.65;
if(v>=0){el.style.bottom='50%';el.style.top='auto';}else{el.style.top='50%';el.style.bottom='auto';}
el.style.height=h+'px';
}
const seenToasts=new Map();
function toast(msg,kind){
const now=performance.now();
if(seenToasts.get(msg)&&now-seenToasts.get(msg)<15000) return;
seenToasts.set(msg,now);
const el=document.createElement('div');
el.className='toast'+(kind?' '+kind:''); el.textContent=msg;
$('toasts').appendChild(el);
setTimeout(()=>{el.style.transition='opacity .4s';el.style.opacity=0;setTimeout(()=>el.remove(),420);},3800);
}
$('trayCancel').onclick=()=>{ clearPending(); setHint(HINTS[mode]); };
$('trayGo').onclick=()=>commitPending();
$('trayFlat').onclick=()=>{ if(pending){ defaultHandles(); refreshTray(); } };
$('trayBridge').onclick=()=>{
if(!pending||!pending.bridgeable) return;
pending.bridgeOver=!pending.bridgeOver;
computeCrossings(); // recompute the deck spans for the new choice
refreshTray(); invalidateCityLayer();
};
$('rClose').onclick=closeRTray;
$('rRemove').onclick=()=>{ if(rSeg==null) return; const chain=roadChain(rSeg); for(const id of chain) removeSegment(id); S.cash-=25*chain.length; closeRTray(); rebuildAll(); };
$('rReshape').onclick=()=>{ if(rSeg!=null) beginReshape(rSeg); };
$('eClose').onclick=closeETray;
$('dClose').onclick=closeDTray;
$('iClose').onclick=closeITray;
$('iFollow').onclick=()=>{
if(!inspectTarget||inspectTarget.type!=='agent') return;
followAgent = followAgent===inspectTarget.agent ? null : inspectTarget.agent;
renderInspect();
};
$('pop').onclick=()=>{ $('eTray').classList.contains('up') ? closeETray() : openETray(); };
$('happy').onclick=()=>{ $('eTray').classList.contains('up') ? closeETray() : openETray(); };
$('rci').onclick=()=>{ $('dTray').classList.contains('up') ? closeDTray() : openDTray(); };
$('saveBtn').onclick=()=>{ closeJTray(); closeRTray(); closeETray(); closeDTray(); closeMusicTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
syncCityNameUI(); document.getElementById('saveTray').classList.add('up'); $('hudRight').classList.remove('open'); };
$('saveClose').onclick=closeSaveTray;
$('doSave').onclick=()=>saveCity();
$('cityNameInput').addEventListener('input',e=>{
S.cityName=e.target.value;
const prev=$('saveFilePreview'); if(prev) prev.textContent=`Saves as “${citySlug(S.cityName)}.json”`;
});
$('cityNameInput').addEventListener('blur',()=>{ document.title = S.cityName ? `${S.cityName} — Spline City` : 'Spline City'; });
$('hudToggle').onclick=()=>{ $('hudRight').classList.toggle('open'); };
$('bankBtn').onclick=()=>{ document.getElementById('bankTray').classList.contains('up') ? closeBankTray() : openBankTray(); $('hudRight').classList.remove('open'); };
$('socialBtn').onclick=()=>{ document.getElementById('socialTray').classList.contains('up') ? closeSocialTray() : openSocialTray(); };
$('socialClose').onclick=closeSocialTray;
$('svcClose').onclick=closeSvcTray;
$('svcMove').onclick=()=>{
if(!svcLot) return;
moveSource=svcLot; movePick=null;
closeSvcTray();
document.getElementById('moveTray').classList.remove('up');
setHint('Tap an empty lot to relocate the building to.');
};
$('moveCancel').onclick=()=>{ cancelMove(); setHint(HINTS[mode]); };
$('moveConfirm').onclick=()=>{
if(!moveSource||!movePick) return;
const fee=moveFee(moveSource);
if(!S.freePlay && fee>0 && S.cash<fee){ toast('Not enough funds to move this.','warn'); return; }
S.cash-=fee;
const zone=moveSource.zone, level=moveSource.level;
movePick.zone=zone; movePick.level=level; movePick.grow=0; movePick.building=null; movePick.vacant=false; movePick.vacantTimer=0; movePick.lastLevel=0;
moveSource.zone=Z.NONE; moveSource.level=0; moveSource.grow=0; moveSource.building=null; moveSource.vacant=false; moveSource.vacantTimer=0; moveSource.lastLevel=0;
cancelMove();
invalidateCityLayer(); census(); ovInvalidate(); refreshHud();
toast('Building moved.','ok');
};
$('bankClose').onclick=closeBankTray;
$('fpAccept').onclick=activateFreePlay;
$('fpDecline').onclick=()=>document.getElementById('fpModal').classList.remove('show');
$('demoAccept').onclick=()=>{ const up=pendingUpgrade; pendingUpgrade=null; closeDemoModal(); if(up) changeRoadType(up.segId,up.newTypeId,true); else if(pending) commitPending(true); };
$('demoDecline').onclick=closeDemoModal;
$('partnerAccept').onclick=()=>resolvePartnership(true);
$('partnerDecline').onclick=()=>resolvePartnership(false);
$('bailoutDemoConfirm').onclick=()=>{ const l=pendingBailoutDemo; closeBailoutDemoModal(); if(l) demolishLot(l); };
$('bailoutDemoCancel').onclick=closeBailoutDemoModal;
$('doLoad').onclick=()=>$('loadFile').click();
$('loadFile').addEventListener('change',e=>{
const file=e.target.files[0]; if(!file) return;
const reader=new FileReader();
reader.onload=ev=>{
try{
const data=JSON.parse(ev.target.result);
loadCityData(data);
if(loadFileFromIntro){ document.getElementById('intro').style.display='none'; S.running=true; startMusic(); }
else closeSaveTray();
toast('City loaded.');
}
catch(err){ toast('Could not read that save file.','bad'); }
loadFileFromIntro=false;
};
reader.readAsText(file);
e.target.value='';
});
document.querySelectorAll('#speed button[data-s]').forEach(b=>{
b.onclick=()=>{S.speed=+b.dataset.s;
document.querySelectorAll('#speed button[data-s]').forEach(o=>o.classList.toggle('on',o===b));};
});
function buildTrackOpts(){
const el=$('trackOpts'); el.innerHTML='';
const auto=document.createElement('button');
auto.className='chip'+(RADIO.mode==='auto'?' on':'');
auto.textContent='🔀 Auto DJ';
auto.onclick=()=>radioSetAuto();
el.appendChild(auto);
for(const key of RADIO_ORDER){
const b=document.createElement('button');
b.className='chip'+((RADIO.mode==='single'&&RADIO.trackKey===key)?' on':'');
b.textContent=RADIO_TRACKS[key].name;
b.onclick=()=>radioSelectTrack(key);
el.appendChild(b);
}
refreshRadioHud();
}
$('musicBtn').onclick=()=>{
if(document.getElementById('musicTray').classList.contains('up')){ closeMusicTray(); return; }
closeJTray(); closeRTray(); closeETray(); closeDTray(); closeSaveTray(); closeBankTray(); closeITray(); closeSocialTray(); closeSvcTray(); cancelMove();
buildTrackOpts();
$('muteToggle').textContent = musicOn? '🔊 Sound on' : '🔇 Sound off';
document.getElementById('musicTray').classList.add('up');
$('hudRight').classList.remove('open');
};
$('musicClose').onclick=closeMusicTray;
$('muteToggle').onclick=()=>{
musicOn=!musicOn;
$('muteToggle').textContent=musicOn?'🔊 Sound on':'🔇 Sound off';
if(musicMaster && audioCtx) musicMaster.gain.linearRampToValueAtTime(musicOn?0.42:0, audioCtx.currentTime+0.4);
};
setInterval(()=>{ if(S.running) toast('Tip: save your city with 💾 so you keep your progress.'); }, 240000);
/* ── seed: a regional highway entering through the western map edge ─────── */
/* Sample points along the fixed seed-road path. Used (cheaply, via elevationRaw
directly — no grid/texture build) to score candidate terrain seeds so the
pre-drawn highway doesn't end up wading through a lake. */
const SEED_ROAD_PROBE=[
{x:-320,y:-132},{x:-225,y:-108},{x:-150,y:-90},{x:-104,y:-70},{x:-58,y:-40},{x:-6,y:-16},
{x:-58,y:22},{x:-42,y:2},{x:-96,y:34},{x:44,y:-8},{x:56,y:16},{x:104,y:20},
];
function seedWaterFraction(seed){
const saved=terrainSeed; terrainSeed=seed;
let wet=0; for(const p of SEED_ROAD_PROBE) if(elevationRaw(p.x,p.y)<WATER_LEVEL) wet++;
terrainSeed=saved;
return wet/SEED_ROAD_PROBE.length;
}
/* Try a handful of random seeds and keep whichever keeps the seed road driest. */
function pickLandFriendlySeed(){
let best=(Math.random()*1e9)|0, bestScore=seedWaterFraction(best);
for(let i=0;i<7 && bestScore>0;i++){
const cand=(Math.random()*1e9)|0, sc=seedWaterFraction(cand);
if(sc<bestScore){ best=cand; bestScore=sc; }
}
return best;
}
/* Builds one seeded road the same water-aware way a player-drawn road does:
split it into land/bridge pieces wherever it crosses the shoreline, instead
of a single addSegment() that has no idea the terrain even has water.
pickLandFriendlySeed() already biases the terrain roll to keep this path
dry, but it only samples a handful of points — this is the actual
guarantee that a crossing (however unlikely) still gets a bridge. */
function seedRoad(aId,bId,p1,p2,typeId){
const a=net.nodes.get(aId), b=net.nodes.get(bId);
const curve=new Bez(a.position,{x:p1.x,y:p1.y},{x:p2.x,y:p2.y},b.position);
buildRoadChain(aId,bId,curve,typeId);
}
function seedCity(){
const outside=createNode({x:-MAP_EDGE,y:-150},{outside:true});
const gate=createNode({x:-150,y:-90});
const town=createNode({x:-6,y:-16});
seedRoad(outside,gate,{x:-320,y:-132},{x:-225,y:-108},'highway');
seedRoad(gate,town,{x:-104,y:-70},{x:-58,y:-40},'highway');
const a=createNode({x:-96,y:34}), b=createNode({x:104,y:20});
seedRoad(a,town,{x:-58,y:22},{x:-42,y:2},'street');
seedRoad(town,b,{x:44,y:-8},{x:56,y:16},'street');
rebuildAll();
}
/* Start a brand-new city at the given size (called from the intro screen). */
function startNewCity(size){
if(size) applyCitySize(size);
net.nodes.clear(); net.segments.clear(); net.lanes.clear(); net.links.clear();
lots.length=0; agents.length=0; nid=1;
milestones.clear(); freePlayArmed=true; freePlayEverTriggered=false;
servicesHintShown=false; utilityHintShown=false;
Object.assign(S,{cash:40000,pop:0,comJobs:0,indJobs:0,offJobs:0,tax:9,cityName:'',
demand:{r:.4,c:.25,i:.35,o:0},speed:0.25,income:0,upkeep:0,day:0,simTime:0,
visitors:0,goodsProd:0,retailCap:0,consumption:0,retailSold:0,exportDemand:0,exported:0,
connections:0,unmetGoods:0,loan:0,loanInterestMo:0,freePlay:false,overlay:null,
landValue:0,unpoweredLots:0,homes:0,jobs:0,jobRatio:1,overemployment:0,
crime:0,crimeLoss:0,congestion:0,stuckRoads:0,education:0,happiness:0.5,qol:0.5,bizHealth:0.5,
partnerships:{},grants:{},happyStreak:0});
// a player-typed seed is honored exactly as given — pickLandFriendlySeed()
// only exists to bias a RANDOM roll away from flooding the seed road, not to
// second-guess a seed someone chose on purpose. If it does cross water, the
// seed road bridges it the same way any player-drawn road would.
terrainSeed = customSeed!=null ? customSeed : pickLandFriendlySeed();
generateTerrain();
seedCity();
census();
invalidateCityLayer();
cam.x=-40; cam.y=-30; cam.z= citySize==='large'?1.5 : citySize==='medium'?1.9 : 2.2;
document.querySelectorAll('#speed button[data-s]').forEach(o=>o.classList.toggle('on',o.dataset.s==='0.25'));
applyStartOptions();
buildDock(); refreshHud();
syncCityNameUI(); syncSeedUI();
}
/* ── starter towns ─────────────────────────────────────────────────────────
Pre-built, already-running cities you can drop into instead of an empty
lot. Each one pins its own terrain seed (+ land/mountain bias) and lays
its own road pattern, then a classify() rule tags every lot that pattern
actually produced — by distance from the town's own core and (for the
waterfront one) distance from real water — instead of a hand-typed list
of coordinates. That keeps the zoning honest to whatever roads got drawn,
and means a differently-shaped town needs only a new layout + classifier,
not a re-surveyed lot list. */
/* Drop a civic building on whatever already-zoned lot sits nearest a target
point — worldgen's stand-in for the player tapping there in Services mode,
and the same fields paintLot() sets for an instant zone. Sited by point
rather than by lot index so it survives any later layout tweak. */
function placeCivic(pt,zone,maxDist){
let best=null,bd=Infinity;
for(const l of lots){ if(l.zone===Z.NONE) continue; const d=dist(l.c,pt); if(d<bd){bd=d;best=l;} }
if(!best||bd>(maxDist||95)) return false;
best.zone=zone; best.level=1; best.grow=0; best.sub=null;
return true;
}
/* Every small starter town ships with the same short list the old hand-placed
lot tables carried: one plant, one tower, a police station and a school.
The utilities in particular are not decoration — power and water are a
city-wide grid, and a town handed over without them can't grow past a single
storey and starts decaying on day one, which reads as a broken save rather
than as a challenge. */
function smallTownCivics(center){
placeCivic({x:center.x-34,y:center.y+20},Z.POWER,80);
placeCivic({x:center.x+34,y:center.y+20},Z.WATER,80);
placeCivic({x:center.x-30,y:center.y-26},Z.POLICE,80);
placeCivic({x:center.x+30,y:center.y-26},Z.SCHOOL,80);
}
/** @returns {NetNode} always called against a coordinate seedCity() is known to have placed a node at */
function $$node(x,y){ let best=null,bd=6; for(const n of net.nodes.values()){ const d=dist(n.position,{x,y}); if(d<bd){bd=d;best=n;} } return /** @type {NetNode} */(best); }
/* ── the regional highway ───────────────────────────────────────────────────
Every starter town is laid out around one fact: the region's traffic arrives
on a single national highway through the western map edge. It is not scenery
and it is not a street. It carries no frontage, nothing may cross it except
where a plan puts a junction there on purpose, and no lot may sit on its
corridor. A town that ignores it ends up with houses fronting a trunk road
and its own grid sliced diagonally in half — which is exactly what "run
over" looks like from overhead.
So every town declares a GATE: the one point on its outskirts where the
national road stops and the town's own arterial takes over. This lays the
approach to that point and NOTHING else. seedCity()'s stubs used to carry on
past it into ground each town had its own plans for, and every layout since
has had to build around the wreckage. */
function seedTownHighway(gate){
const outside=createNode({x:-MAP_EDGE,y:gate.y-90},{outside:true});
const gateId=createNode(gate);
const A=net.nodes.get(outside).position, B=net.nodes.get(gateId).position;
// buildRoadChain, not addSegment: this is laid before anything has asked
// where the water is, so a shoreline becomes a bridge exactly as it would
// under a player-drawn road
buildRoadChain(outside,gateId,new Bez(A,lerpP(A,B,0.34),lerpP(A,B,0.66),B),'highway');
return gateId;
}
/* The four small towns share one gate, just off the western corner of all four
layouts. Close in on purpose: the highway itself should cover the distance,
because it carries no frontage, and the town's own arterial should be the
short last hop. Park the gate far out instead and that arterial becomes a
hundred metres of ribbon development along what still looks like the trunk
road — houses fronting a national highway, only drawn in a different colour. */
const SMALL_TOWN_GATE={x:-95,y:-108};
/* Of several candidate entry points on a town's edge, the one actually nearest
the gate — so the approach meets the plan from outside instead of driving
through its middle to reach a point on the far side.
Pass a `used` set when several roads land on the same ring: each claims its
own node, so two of them can't stack two roundabouts on one another. */
function nearestEntry(gateId,candidates,used){
const g=net.nodes.get(gateId).position;
let best=null, bd=Infinity;
for(const id of candidates){
if(used&&used.has(id)) continue;
const d=dist(net.nodes.get(id).position,g);
if(d<bd){bd=d;best=id;}
}
if(used&&best!=null) used.add(best);
return best;
}
/* The national road doesn't just stop in a T. It finishes on a roundabout at
the town's edge and TWO town roads leave it, for the nearest two entry
points the layout offers. That junction is the whole point of the gate:
somewhere arriving traffic chooses, and a visible line between the trunk
road and the streets. Linked by a single straight road instead, the approach
carries the highway's own bearing straight on into the first block and just
reads as the highway continuing in a different colour — which is how these
towns ended up looking like the highway ran through them.
The roundabout also earns its keep by trimming both approaches back, so the
ribbon of houses that used to line the entry road never forms. */
function linkGateway(gateId,entries,typeId){
const g=net.nodes.get(gateId).position;
const sorted=entries.slice().sort((a,b)=>
dist(net.nodes.get(a).position,g)-dist(net.nodes.get(b).position,g));
linkNodes(gateId,sorted[0],typeId);
if(sorted[1]) linkNodes(gateId,sorted[1],'street');
const gate=net.nodes.get(gateId);
if(gate.segments.length>=3) gate.control=CONTROL.ROUNDABOUT;
return sorted[0];
}
/* Riverside grid, reused as-is by Harborfront — a plain 4×3 street grid, hung
off the highway gate by one avenue into its north-west corner. What makes
Harborfront different is entirely its terrain seed: picked so real water
sits just past the grid's east edge, which regenerateLots() already refuses
to lot over on its own.
The approach is an avenue, not a plain street: traffic coming off a national
road has to step down through something before it is on a residential
block. It lands on the corner FARTHEST from the gate's own bearing so the
avenue crosses open ground rather than running alongside the grid's
western edge. */
function buildStarterRoads(gateId){
const gx0=-40, gy0=-90, spacing=42, cols=4, rows=3;
/** @type {number[][]} */
const grid=[];
for(let r=0;r<rows;r++){ grid.push([]); for(let c=0;c<cols;c++){ grid[r].push(createNode({x:gx0+c*spacing,y:gy0+r*spacing})); } }
for(let r=0;r<rows;r++) for(let c=0;c<cols-1;c++){
const A=net.nodes.get(grid[r][c]), B=net.nodes.get(grid[r][c+1]);
addSegment(grid[r][c],grid[r][c+1],lerpP(A.position,B.position,0.33),lerpP(A.position,B.position,0.66),'street');
}
for(let r=0;r<rows-1;r++) for(let c=0;c<cols;c++){
const A=net.nodes.get(grid[r][c]), B=net.nodes.get(grid[r+1][c]);
addSegment(grid[r][c],grid[r+1][c],lerpP(A.position,B.position,0.33),lerpP(A.position,B.position,0.66),'street');
}
linkGateway(gateId,[grid[0][0],grid[1][0]],'avenue');
rebuildAll();
}
const HARBOR_CENTER={x:23,y:-48};
function classifyHarborfront(l){
const wd=nearestWaterDist(l.c,60);
if(wd<=22) return {zone:Z.COM, level:wd<=13?2:1, sub:weightedPick({tourism:2,nightlife:1})};
const cd=dist(l.c,HARBOR_CENTER);
if(cd<40) return {zone:Z.COM, level:2, sub:weightedPick({grocery:1,commodity:1})};
if(cd<85) return {zone:Z.RES, level:2, sub:weightedPick({single:2,apt:1})};
return {zone:Z.RES, level:1, sub:'single'};
}
/* Founders Square — a plaza roundabout with an avenue+street spoke wheel
radiating out of it, density stepping down from the core the way a real
downtown does. The approach lands on the spoke END nearest the gate, never
on the plaza itself: a road aimed at the middle of a wheel has to cut
through everything between, which is what put an avenue straight across the
spoke ring here before. */
function buildFoundersRoads(gateId){
const plazaId=createNode(FOUNDERS_PLAZA);
const N=6, R1=FOUNDERS_R1, R2=FOUNDERS_R2, mids=[], outs=[];
for(let i=0;i<N;i++){
const a=i/N*Math.PI*2;
const midPos={x:FOUNDERS_PLAZA.x+Math.cos(a)*R1,y:FOUNDERS_PLAZA.y+Math.sin(a)*R1};
const outPos={x:FOUNDERS_PLAZA.x+Math.cos(a)*R2,y:FOUNDERS_PLAZA.y+Math.sin(a)*R2};
const midId=createNode(midPos), outId=createNode(outPos);
addSegment(plazaId,midId,lerpP(FOUNDERS_PLAZA,midPos,0.33),lerpP(FOUNDERS_PLAZA,midPos,0.66),'road');
addSegment(midId,outId,lerpP(midPos,outPos,0.33),lerpP(midPos,outPos,0.66),'street');
mids.push({id:midId,pos:midPos}); outs.push(outId);
}
for(let i=0;i<N;i++){
const A=mids[i], B=mids[(i+1)%N];
addSegment(A.id,B.id,lerpP(A.pos,B.pos,0.33),lerpP(A.pos,B.pos,0.66),'street');
}
net.nodes.get(plazaId).control=CONTROL.ROUNDABOUT;
linkGateway(gateId,outs,'avenue');
rebuildAll();
}
const FOUNDERS_PLAZA={x:10,y:-98};
/* Both radii are set by junction footprints, not by taste. A spoke shorter than
roughly R1=70 is all junction and no kerb: at the original R1 of 34 not one
inner spoke carried a single lot, and the wheel only looked populated
because seedCity's leftover stubs were quietly supplying the frontage it
wasn't. The spokes are roads rather than avenues for the same reason — six
23-wide approaches swell the plaza roundabout and every mid node with it,
and the wheel comes out as one continuous slab of tarmac with houses round
the rim. Swapping to 15-wide roads cut the junction pavement here from
~23,600 square units to ~9,900 and RAISED the lot count. */
const FOUNDERS_R1=72, FOUNDERS_R2=112;
function classifyFounders(l){
const cd=dist(l.c,FOUNDERS_PLAZA);
// handed over at two storeys, not three: a core built to its ceiling on day
// one has nowhere to go but down the moment land value dips, and watching a
// town you just inherited shed three quarters of its population is a worse
// opening than starting it a storey short with somewhere to climb
if(cd<FOUNDERS_R1-20){
const zone=Math.random()<0.4?Z.OFF:Z.COM;
return {zone, level:2, sub:zone===Z.COM?weightedPick({grocery:1,commodity:1,nightlife:2}):undefined};
}
if(cd<FOUNDERS_R2-16){
const zone=Math.random()<0.25?Z.COM:Z.RES;
return {zone, level:2, sub:zone===Z.COM?weightedPick({grocery:1,commodity:1}):weightedPick({single:1,apt:2})};
}
return {zone:Z.RES, level:Math.random()<0.3?2:1, sub:weightedPick({single:2,apt:1})};
}
/* Foundry District — the same grid idea as Riverside's, but sheared row by
row so the blocks read as an offset rail-yard pattern instead of a plain
orthogonal one, with a wide avenue for its spine. Sits at a steep
terrainMountainBias, which only stretches land already above the
shoreline (see elevationRaw) — a free visual knob that can't touch where
the water actually is, so it's safe to crank without re-checking bridges.
Hooks onto grid[0][0], the row-0 corner: the shear pulls each row east of
the one above it, so row 0's corner is the westernmost point of the whole
yard and the one the approach can reach without passing anything else. */
function buildFoundryRoads(gateId){
// the blocks are wider than they look like they need to be because the spine
// is an avenue: 23 wide, so its two junctions ate all but ~5 units of every
// 40-unit segment between them and the spine carried no frontage at all.
// It has to stay an avenue — classifyFoundry() reads lotRoadClass(), and only
// avenue frontage clears the 0.6 that marks a lot as industrial, so a spine
// downgraded to a plain road turns the Foundry District into a housing estate.
const gx0=-38, gy0=-90, spacingX=58, spacingY=46, shear=14, cols=4, rows=3;
/** @type {number[][]} */
const grid=[];
for(let r=0;r<rows;r++){ grid.push([]); for(let c=0;c<cols;c++){ grid[r].push(createNode({x:gx0+c*spacingX+r*shear,y:gy0+r*spacingY})); } }
for(let r=0;r<rows;r++) for(let c=0;c<cols-1;c++){
const A=net.nodes.get(grid[r][c]), B=net.nodes.get(grid[r][c+1]);
addSegment(grid[r][c],grid[r][c+1],lerpP(A.position,B.position,0.33),lerpP(A.position,B.position,0.66),r===1?'avenue':'street');
}
for(let r=0;r<rows-1;r++) for(let c=0;c<cols;c++){
const A=net.nodes.get(grid[r][c]), B=net.nodes.get(grid[r+1][c]);
addSegment(grid[r][c],grid[r+1][c],lerpP(A.position,B.position,0.33),lerpP(A.position,B.position,0.66),'street');
}
linkGateway(gateId,[grid[0][0],grid[1][0]],'avenue');
rebuildAll();
}
const FOUNDRY_CENTER={x:36,y:-50};
function classifyFoundry(l){
if(lotRoadClass(l)>0.6) return {zone:Z.IND, level:Math.random()<0.35?3:2, sub:weightedPick({standard:2,hightech:1})};
if(dist(l.c,FOUNDRY_CENTER)<55) return {zone:Z.IND, level:1, sub:weightedPick({standard:3,hightech:1})};
return {zone:Z.RES, level:Math.random()<0.6?1:2, sub:'single'};
}
/* Maple Hollow — a ring road of curved Bézier arcs (the same tangent-handle
trick buildRoundabout uses for its ring lanes) around an open green
commons, with a few cul-de-sac spurs poking outward. Nothing paves the
inside of the ring, so the commons is just whatever the terrain already
draws there — no park lot needs placing by hand.
The approach enters at whichever ring node actually sits nearest the gate —
aim it at the centre instead and it drives straight across the commons to
reach the far side. */
/* A closed ring road of N true circular arcs, as node ids in order. Each arc
is a cubic with its handles laid along the circle's own tangent at length
R·4/3·tan(Δ/4) — the standard circle-to-Bézier fit, and the same one
buildRoundabout() uses for its ring lanes, so a ring road and a roundabout
are curved by identical means at different scales. */
function buildRingRoad(center,R,N,typeId,ccw){
const step=2*Math.PI/N, k=4/3*Math.tan(step/4);
/** @type {number[]} */
const ring=[];
for(let i=0;i<N;i++){
const a=i*step;
ring.push(createNode({x:center.x+Math.cos(a)*R,y:center.y+Math.sin(a)*R}));
}
for(let i=0;i<N;i++){
const a0=i*step, a1=(i+1)*step;
const A=net.nodes.get(ring[i]), B=net.nodes.get(ring[(i+1)%N]);
const t0={x:-Math.sin(a0),y:Math.cos(a0)}, t1={x:-Math.sin(a1),y:Math.cos(a1)};
const h0=add(A.position,mul(t0,R*k)), h1=sub(B.position,mul(t1,R*k));
// a one-way ring has to be LAID in the direction it flows, since the lane
// directions just follow the segment's own. Angle decreasing is
// anticlockwise as rendered north-up — the same convention buildRoundabout
// circulates its ring lanes by. The returned array stays in angle order
// either way, so radials index into it identically.
if(ccw) addSegment(ring[(i+1)%N],ring[i],h1,h0,typeId);
else addSegment(ring[i],ring[(i+1)%N],h0,h1,typeId);
}
return ring;
}
/* Straight road between two existing nodes, handles at the thirds — the shape
nearly every laid-out road in here wants. */
function linkNodes(aId,bId,typeId){
const A=net.nodes.get(aId), B=net.nodes.get(bId);
return addSegment(aId,bId,lerpP(A.position,B.position,0.33),lerpP(A.position,B.position,0.66),typeId);
}
function buildMapleRoads(gateId){
const N=8;
const ring=buildRingRoad(MAPLE_CENTER,60,N,'street');
const gatewayIdx=ring.indexOf(linkGateway(gateId,ring,'avenue'));
for(const off of [1,2,3,5,6,7]){
const i=(gatewayIdx+off)%N;
const base=net.nodes.get(ring[i]);
const outward=norm(sub(base.position,MAPLE_CENTER));
const stubPos=add(base.position,mul(outward,75));
const stubId=createNode(stubPos);
addSegment(ring[i],stubId,lerpP(base.position,stubPos,0.4),lerpP(base.position,stubPos,0.7),'street');
}
rebuildAll();
}
const MAPLE_CENTER={x:16,y:-90};
function classifyMaple(l){
const cd=dist(l.c,MAPLE_CENTER); // banded against the ring's own radius, 60
if(cd<60) return Math.random()<0.25 ? {zone:Z.PARK, level:1} : {zone:Z.RES, level:1, sub:'single'};
// a handful of corner shops on the ring. Residential-first, but not ONLY
// residential: with no jobs anywhere in it the town never spawns a single
// commuter, and a suburb where no car ever moves reads as broken, not quiet.
if(cd<88) return Math.random()<0.25
? {zone:Z.COM, level:1, sub:weightedPick({grocery:3,commodity:1})}
: {zone:Z.RES, level:Math.random()<0.2?2:1, sub:weightedPick({single:3,apt:1})};
// the cul-de-sac spurs — spread thin enough that the outer homes read as
// farmsteads (see farmDecoFor in 20b-buildings-detail.js) rather than another
// row of identical houses. The ~half left unzoned stays open field.
return Math.random()<0.5 ? {zone:Z.RES, level:1, sub:'single'} : null;
}
/* ── Concord Wheel (large) ──────────────────────────────────────────────────
The big one: a radial plan — plaza roundabout, eight avenues, three
concentric ring roads — with a two-way bypass running north/south past its
western edge and TWO cross-town avenues flying over that bypass on real
decks. Those overpasses are the whole reason this city exists at this size:
a ring plan only holds together if its arterials can reach the centre
without every through route stopping at a light, and grade separation is
what buys that.
Everything west of the bypass is the industrial side, which is also why the
bypass is where it is — freight leaves without ever entering the rings. */
const CONCORD={x:100,y:20};
const CONCORD_BYPASS_X=-300;
const CONCORD_IND={x:-455,y:30};
/* West of the bypass and north of the freight yard: from here the approach
spur crosses the bypass once and reaches the rim without passing anything
else the plan builds. */
const CONCORD_GATE={x:-430,y:-230};
/* Rings step by ~52 — about one lot deep (LOT_DEPTH is 19) on each side of
both rings, so the band between them fills in solid instead of coming out as
rings of houses with open field between them.
A ring carries exactly as many nodes as it has radials landing on it, and no
more. Nodes above that number are pure bends: they cost a junction footprint
that trims frontage off both arcs, a set of lane connectors, and a visible
blob of tarmac, and they buy nothing, because a ring's block length is set by
where its radials are, not by how finely the arc is chopped between them.
Concord carried 84 of them. The counts stay in whole multiples of each
other, which is what lets every radial land exactly ON a node rather than
cutting across the arc beside it; a 90° arc is still a faithful circle at
buildRingRoad's tangent-handle fit, so the ring looks identical.
The outer half is small streets (see the crossAnywhere note in the road
catalog) — at this radius those rings are residential frontage, not through
routes, and a city this wide is only walkable if its side streets are. */
const CONCORD_RINGS=[
// the innermost ring circulates one-way anticlockwise and nothing is built
// inside it — it IS the plaza. A genuine eight-spoke roundabout here would
// have to be enormous to take eight avenues, and the ring already does the
// one job a roundabout does: let everything turn without a signal.
{r:52, n:4, type:'oneway', ccw:true},
{r:104, n:8, type:'road'},
{r:156, n:16, type:'street'},
{r:208, n:16, type:'lane'},
// the rim is an arterial, not another side street: it's the edge of the
// plan, and it's what the regional highway arrives onto
{r:260, n:16, type:'road'},
];
/* One entry per gap between consecutive rings: how many radials cross it, and
what they are. Deliberately far fewer than the ring node count — the ring
nodes a radial skips stay plain two-road joints with no junction footprint
at all, which is what keeps the wheel from reading as one continuous doily
of intersections. Each count divides both neighbouring rings' node counts. */
const CONCORD_RADIALS=[
{k:4, type:'avenue'}, // only four avenues push all the way to the plaza —
{k:8, type:'avenue'}, // eight converging on a 52-unit ring is a knot
{k:16, type:'road'},
{k:16, type:'street'},
];
const CONCORD_R_OUT=CONCORD_RINGS[CONCORD_RINGS.length-1].r;
function buildConcordRoads(gateId){
const rings=CONCORD_RINGS.map(spec=>buildRingRoad(CONCORD,spec.r,spec.n,spec.type,spec.ccw));
const outer=rings[rings.length-1];
for(let g=0;g<CONCORD_RADIALS.length;g++){
const {k,type}=CONCORD_RADIALS[g];
const inStep=CONCORD_RINGS[g].n/k, outStep=CONCORD_RINGS[g+1].n/k;
for(let j=0;j<k;j++) linkNodes(rings[g][j*inStep],rings[g+1][j*outStep],type);
}
// ── the bypass: a real through route, regional connection at either end,
// bending away south-west so both ends come ashore on dry map edge.
const spine=[
createNode({x:CONCORD_BYPASS_X,y:-MAP_EDGE},{outside:true}),
createNode({x:CONCORD_BYPASS_X,y:-400}),
createNode({x:CONCORD_BYPASS_X,y:340}),
createNode({x:-350,y:500}),
createNode({x:-390,y:MAP_EDGE},{outside:true}),
];
for(let i=0;i<spine.length-1;i++){
const A=net.nodes.get(spine[i]).position, B=net.nodes.get(spine[i+1]).position;
// buildRoadChain, not addSegment: the southern leg clips a pond, and this
// is what turns that into a bridge instead of pavement laid on water
buildRoadChain(spine[i],spine[i+1],new Bez(A,lerpP(A,B,0.34),lerpP(A,B,0.66),B),'highway');
}
// Three roads land on the rim — the national approach and both cross-town
// avenues — and each claims its own node so no two roundabouts overlap.
const used=new Set();
// ── the regional highway's own way in. CONCORD_GATE sits west of the bypass
// and clear of the rim, so this spur crosses the bypass exactly once, at an
// interchange, and then stops at a rim roundabout. It never enters the
// rings. The gate used to sit at the small towns' own (-150,-90), which on a
// wheel this size is 237 units from the plaza — inside ring 4 — so the
// "approach" was a trunk road driven outward from the middle of the city
// through every ring on its way to the edge.
const approach=nearestEntry(gateId,outer,used);
linkNodes(gateId,approach,'highway');
net.nodes.get(approach).control=CONTROL.ROUNDABOUT;
// ── the freight yard, and the two cross-town avenues serving it: rim
// straight onto the yard's own corner nodes, over the bypass on real decks,
// built through the same buildRoadChain/findOverpassSpans path the player's
// road tool uses. Each avenue used to stop at a node of its own a few units
// short of the yard and then hop across with a short connector — which laid
// that connector directly alongside the yard's east column, two roads
// sharing one strip of ground, and cost two junctions that did nothing.
for(const yardGate of buildConcordIndustry()){
const from=net.nodes.get(nearestEntry(yardGate,outer,used));
const to=net.nodes.get(yardGate);
const curve=new Bez(from.position,lerpP(from.position,to.position,0.34),
lerpP(from.position,to.position,0.66),to.position);
buildRoadChain(from.id,yardGate,curve,'avenue',findOverpassSpans(curve,null));
if(to.segments.length>=3) to.control=CONTROL.ROUNDABOUT;
}
rebuildAll();
}
/* The freight yard, west of the bypass and reached only from it — which is the
whole reason the bypass is where it is. A plain rectangle of streets read as
a grid dropped in a field next to the city rather than as part of it, so the
yard gets what a real one has: a heavy spine down its middle, a loop of
streets round the outside (the grid's own boundary), and a roundabout where
each cross-town avenue lands, so an articulated lorry never has to reverse
out of a dead end. */
function buildConcordIndustry(){
const ROWS=4, COLS=3, SP=52;
/** @type {number[][]} */
const grid=[];
for(let r=0;r<ROWS;r++){ grid.push([]); for(let c=0;c<COLS;c++){
grid[r].push(createNode({x:CONCORD_IND.x+(c-(COLS-1)/2)*SP, y:CONCORD_IND.y+(r-(ROWS-1)/2)*SP}));
} }
for(let r=0;r<ROWS;r++) for(let c=0;c<COLS-1;c++) linkNodes(grid[r][c],grid[r][c+1],'street');
for(let r=0;r<ROWS-1;r++) for(let c=0;c<COLS;c++) linkNodes(grid[r][c],grid[r+1][c],c===COLS-1?'road':'street');
// the two EAST CORNERS, which is as far apart as the yard goes. The pair
// that used to be picked here were one row apart — 52 units — so the two
// roundabouts that land on them overlapped into a single blob of tarmac.
return [grid[0][COLS-1], grid[ROWS-1][COLS-1]];
}
/* Districts by ring band, with a sector twist on top so the wheel doesn't come
out as eight identical pie slices: offices cluster east of the plaza,
nightlife and shops west of it, and the lakeshore turns commercial wherever
it happens to fall. */
function classifyConcord(l){
if(l.c.x<CONCORD_BYPASS_X)
return {zone:Z.IND, level:Math.random()<0.4?3:2, sub:weightedPick({standard:2,hightech:1})};
const d=dist(l.c,CONCORD);
if(d>CONCORD_R_OUT+22)
return Math.random()<0.45 ? {zone:Z.IND, level:1, sub:weightedPick({standard:1,hightech:2})}
: {zone:Z.RES, level:1, sub:'single'};
if(nearestWaterDist(l.c,60)<=24)
return {zone:Z.COM, level:2, sub:weightedPick({tourism:3,nightlife:1})};
const sector=Math.floor((((ang(sub(l.c,CONCORD))+Math.PI*2)%(Math.PI*2))/(Math.PI*2))*8);
const officeSide=(sector===0||sector===7||sector===1);
// offices also take two wedges out on the rim, where a real city puts the
// business parks it has no room for downtown
if(d>CONCORD_RINGS[2].r && (sector===1||sector===5))
return {zone:Z.OFF, level:Math.random()<0.4?3:2};
if(d<CONCORD_RINGS[0].r){
const zone=officeSide?Z.OFF:(Math.random()<0.55?Z.COM:Z.OFF);
return {zone, level:3, sub:zone===Z.COM?weightedPick({nightlife:2,commodity:1,grocery:1}):undefined};
}
if(d<CONCORD_RINGS[1].r){
const zone=officeSide?(Math.random()<0.6?Z.OFF:Z.COM):(Math.random()<0.5?Z.COM:Z.RES);
return {zone, level:Math.random()<0.5?3:2,
sub:zone===Z.COM?weightedPick({grocery:1,commodity:1,nightlife:1}):zone===Z.RES?'apt':undefined};
}
if(d<CONCORD_RINGS[2].r)
return Math.random()<0.35 ? {zone:Z.COM, level:2, sub:weightedPick({grocery:2,commodity:1,nightlife:1})}
: {zone:Z.RES, level:2, sub:'apt'};
if(d<CONCORD_RINGS[3].r)
return Math.random()<0.14 ? {zone:Z.COM, level:2, sub:weightedPick({grocery:2,commodity:1})}
: {zone:Z.RES, level:2, sub:weightedPick({apt:1,single:2})};
return Math.random()<0.07 ? {zone:Z.PARK, level:1}
: {zone:Z.RES, level:1, sub:'single'};
}
/* A city this size can't run on whatever the zoning rule happened to leave —
it needs the grid and the service coverage actually placed. Sited by target
point rather than by lot index so it survives any later layout tweak. */
/* Coverage has to reach the rim, not just the core: the services are spread
over two bands per kind at different bearings so no wedge of the wheel is
left outside everything. Sited by target point rather than by lot index so
they survive any later change to the ring radii. */
function civicsConcord(){
const ring=(f,a)=>({x:CONCORD.x+Math.cos(a)*CONCORD_R_OUT*f, y:CONCORD.y+Math.sin(a)*CONCORD_R_OUT*f});
const put=(pt,zone)=>placeCivic(pt,zone,95);
const band=(zone,f,n,phase)=>{ for(let i=0;i<n;i++) put(ring(f,i*(Math.PI*2/n)+phase),zone); };
// the grid is city-wide capacity, not coverage (see POWER_GEN), and a wheel
// this size draws roughly eight plants' worth — handed over at exactly
// capacity it would brown out on its first day of growth, which reads as a
// broken save rather than as a challenge
band(Z.POWER,0.62,4,0.4); band(Z.POWER,0.92,4,1.9);
band(Z.WATER,0.66,5,1.5); band(Z.WATER,0.95,4,0.3);
band(Z.POLICE,0.3,4,0.3); band(Z.POLICE,0.62,5,0.9); band(Z.POLICE,0.9,5,0.2);
band(Z.FIRE,0.4,4,0.9); band(Z.FIRE,0.72,5,0.2); band(Z.FIRE,0.94,5,1.1);
band(Z.CLINIC,0.26,3,1.1); band(Z.CLINIC,0.6,4,0.6); band(Z.CLINIC,0.88,4,1.4);
band(Z.SCHOOL,0.34,3,0.2); band(Z.SCHOOL,0.66,5,1.2); band(Z.SCHOOL,0.92,5,0.7);
band(Z.PARK,0.2,5,0.7); band(Z.PARK,0.5,6,0.35); band(Z.PARK,0.8,6,1.0);
// the newer utility types, so the showcase city exercises the drawn utility
// art — solar farms, spinning wind farms (one parked on the eastern water rim
// where the wind actually pays) and a reservoir — not just coal and towers
band(Z.SOLAR,0.72,3,0.8);
band(Z.WIND,0.84,3,0.0);
band(Z.RESERVOIR,0.9,2,0.4);
put({x:CONCORD_IND.x,y:CONCORD_IND.y-140},Z.FIRE);
put({x:CONCORD_IND.x,y:CONCORD_IND.y+140},Z.POLICE);
put({x:CONCORD_IND.x,y:CONCORD_IND.y},Z.POWER);
}
const STARTER_TOWNS=[
{ id:'harborfront', name:'Harborfront', blurb:'balanced trade town on the water',
citySize:'small', terrainSeed:4928, landBias:-0.04, mtnBias:0, tax:16, cash:22000, day:600,
roads:buildStarterRoads, classify:classifyHarborfront, civics:()=>smallTownCivics(HARBOR_CENTER) },
{ id:'founders', name:'Founders Square', blurb:'dense historic downtown core',
citySize:'small', terrainSeed:711571044, landBias:0.08, mtnBias:0, tax:18, cash:26000, day:750,
roads:buildFoundersRoads, classify:classifyFounders, civics:()=>smallTownCivics(FOUNDERS_PLAZA) },
{ id:'foundry', name:'Foundry District', blurb:'industry-heavy, more jobs than homes',
citySize:'small', terrainSeed:711571044, landBias:0.08, mtnBias:0.5, tax:14, cash:25000, day:500,
roads:buildFoundryRoads, classify:classifyFoundry, civics:()=>smallTownCivics(FOUNDRY_CENTER) },
{ id:'maple', name:'Maple Hollow', blurb:'quiet, residential-first suburb',
citySize:'small', terrainSeed:711571044, landBias:0.08, mtnBias:0.15, tax:21, cash:28000, day:900,
roads:buildMapleRoads, classify:classifyMaple, civics:()=>smallTownCivics(MAPLE_CENTER) },
{ id:'concord', name:'Concord Wheel', blurb:'a full ring city, bypass and overpasses',
/* The seed is picked for THIS plan, not decoration: it keeps the bypass
corridor and both cross-town avenues on dry ground — a bypass wading
through a lake is a bridge, and findOverpassSpans() won't deck over a
bridge, so a wet corridor silently costs the city its two overpasses —
while still putting real water against the wheel's eastern rim for the
waterfront band classifyConcord() looks for. */
citySize:'medium', terrainSeed:7879, landBias:-0.02, mtnBias:0.1, tax:19, cash:52000, day:2000,
gate:CONCORD_GATE,
cam:{x:CONCORD.x-120,y:CONCORD.y-10,span:CONCORD_R_OUT*3.0},
roads:buildConcordRoads, classify:classifyConcord, civics:civicsConcord },
];
function buildStarterTown(def){
applyCitySize(def.citySize);
net.nodes.clear(); net.segments.clear(); net.lanes.clear(); net.links.clear();
lots.length=0; agents.length=0; nid=1;
milestones.clear(); freePlayArmed=true; freePlayEverTriggered=false;
servicesHintShown=false; utilityHintShown=false;
Object.assign(S,{cash:def.cash,pop:0,comJobs:0,indJobs:0,offJobs:0,tax:def.tax,cityName:def.name,
demand:{r:.4,c:.25,i:.35,o:0},speed:0.25,income:0,upkeep:0,day:def.day,simTime:def.day*0.64,
visitors:0,goodsProd:0,retailCap:0,consumption:0,retailSold:0,exportDemand:0,exported:0,
connections:0,unmetGoods:0,loan:0,loanInterestMo:0,freePlay:false,overlay:null,
landValue:0,unpoweredLots:0,homes:0,jobs:0,jobRatio:1,overemployment:0,
crime:0,crimeLoss:0,congestion:0,stuckRoads:0,education:0,happiness:0.5,qol:0.5,bizHealth:0.5,
partnerships:{},grants:{},happyStreak:0});
terrainLandBias=def.landBias; terrainMountainBias=def.mtnBias; terrainSeed=def.terrainSeed;
generateTerrain();
def.roads(seedTownHighway(def.gate||SMALL_TOWN_GATE));
for(const l of lots){
const tag=def.classify(l); if(!tag) continue;
l.zone=tag.zone; l.level=tag.level; l.grow=0; l.sub=tag.sub||undefined;
}
// A lot the plan dropped on top of somebody else's pavement is not a lot —
// above all on the national highway's corridor, where a building would be
// standing in the carriageway. rebuildAll() already runs this pass, but it
// skips unzoned lots by design, and during worldgen EVERY lot is unzoned
// until the loop above: run inside def.roads() it could only ever be a
// no-op, which is why these towns shipped with houses on the trunk road.
clearLotsOverlappingRoads();
// services and utilities go on AFTER zoning, so they can overwrite whatever
// the zoning rule put on the lots they want
if(def.civics) def.civics();
census();
invalidateCityLayer();
if(def.cam){
cam.x=def.cam.x; cam.y=def.cam.y;
// a town too wide for any one fixed zoom to suit both a phone and a
// desktop: `span` says how many world units to fit, and the opening shot
// is the whole plan either way
cam.z = def.cam.span ? clamp(Math.min(VW,VH)/def.cam.span,0.3,3) : def.cam.z;
}
else { cam.x=-25; cam.y=-40; cam.z=1.15; }
document.querySelectorAll('#speed button[data-s]').forEach(o=>o.classList.toggle('on',o.dataset.s==='0.25'));
applyStartOptions();
buildDock(); refreshHud();
syncCityNameUI(); syncSeedUI();
}
applyCitySize('small');
generateTerrain();
seedCity();
census();
invalidateCityLayer();
/* ── loop ───────────────────────────────────────────────────────────────── */
let last=performance.now(),acc=0,inspectAcc=0;
function frame(now){
try {
const dt=Math.min((now-last)/1000,0.1); last=now;
if(S.running&&S.speed>0){
S.simTime+=dt*S.speed;
acc+=dt*1000*S.speed;
let g=0;
while(acc>=640&&g++<8){acc-=640;tick();}
updateAgents(dt*Math.min(S.speed,3.2));
}
if(followAgent){
if(!agents.includes(followAgent)) followAgent=null;
else { const pose=agentPose(followAgent); if(pose){ cam.x=pose.p.x; cam.y=pose.p.y; } }
}
if(inspectTarget){
inspectAcc+=dt;
if(inspectAcc>0.35){ inspectAcc=0; renderInspect(); }
}
draw();
} finally {
requestAnimationFrame(frame);
}
}
resize(); buildDock(); refreshHud(); setHint(HINTS.road);
requestAnimationFrame(frame);
let pickedSize='small';
let pickedStarter=null; // null = build from scratch; else a STARTER_TOWNS id
let startUnlimited=false; // "start with unlimited money" advanced toggle
let customSeed=null; // null = pickLandFriendlySeed() chooses; else the player's own
/* Accepts either a plain number (used as-is, matching the range
pickLandFriendlySeed() already picks from) or any other text, which gets
hashed into one instead — so a word like "riverside" is just as valid a
seed to type in and share as a number is, and always maps to the exact
same terrain. */
function parseSeedInput(raw){
raw=(raw||'').trim();
if(!raw) return null;
if(/^\d+$/.test(raw)) return Math.min(999999999,parseInt(raw,10));
let h=0;
for(let i=0;i<raw.length;i++) h=(h*131+raw.charCodeAt(i))>>>0;
return h%1000000000;
}
/* Shows the seed that's actually in play right now, starter town or random
roll alike — so a map the player likes (and never typed a seed for) can
still be copied out of this line and reused later. */
function syncSeedUI(){
const hint=$('seedHint'); if(!hint) return;
hint.textContent = pickedStarter
? `Starter towns pin their own seed (${terrainSeed}).`
: `This map's seed: ${terrainSeed}` + (customSeed==null ? ' — random; type it above to reuse it.' : '');
}
function applyStartOptions(){
if(startUnlimited){
S.freePlay=true; S.loan=0; S.loanInterestMo=0; S.cash=1e9;
freePlayArmed=false; freePlayEverTriggered=true;
refreshHud();
}
}
function buildSizeOpts(){
const el=$('sizeOpts'); if(!el) return; el.innerHTML='';
for(const key of ['small','medium','large']){
const s=CITY_SIZES[key];
const b=document.createElement('button');
b.type='button';
if(pickedSize===key && !pickedStarter) b.classList.add('on');
b.innerHTML=`${s.label}<small>${s.blurb}</small>`;
b.onclick=()=>{ pickedSize=key; pickedStarter=null; startNewCity(key); buildSizeOpts(); buildStarterOpts(); };
el.appendChild(b);
}
}
function buildStarterOpts(){
const el=$('starterOpts'); if(!el) return; el.innerHTML='';
const scratch=document.createElement('button');
scratch.type='button'; scratch.className=pickedStarter?'':'on';
scratch.innerHTML=`Build from scratch<small>an empty lot, your call</small>`;
scratch.onclick=()=>{ pickedStarter=null; startNewCity(pickedSize); buildSizeOpts(); buildStarterOpts(); updateAdvEnabled(); };
el.appendChild(scratch);
for(const t of STARTER_TOWNS){
const b=document.createElement('button');
b.type='button'; b.className=pickedStarter===t.id?'on':'';
b.innerHTML=`${t.name}<small>${t.blurb}</small>`;
b.onclick=()=>{ pickedStarter=t.id; buildStarterTown(t); buildSizeOpts(); buildStarterOpts(); updateAdvEnabled(); };
el.appendChild(b);
}
const active=STARTER_TOWNS.find(t=>t.id===pickedStarter);
$('starterBlurb').textContent = active
? `A pre-built town you're taking over mid-story — tax is already set to ${active.tax}%, day ${active.day}.`
: '';
}
function updateAdvEnabled(){
const disabled=!!pickedStarter; // starter towns pin their own terrain — sliders would break the layout
$('landSlider').disabled=disabled; $('mtnSlider').disabled=disabled; $('seedInput').disabled=disabled; $('seedRandom').disabled=disabled;
$('landSlider').parentElement.style.opacity = 1; // panel itself stays readable
$('landSlider').style.opacity = disabled?0.4:1;
$('mtnSlider').style.opacity = disabled?0.4:1;
$('seedInput').style.opacity = disabled?0.4:1;
$('seedRandom').style.opacity = disabled?0.4:1;
syncSeedUI();
}
function updateTerrainLabels(){
const lv=+$('landSlider').value, mv=+$('mtnSlider').value;
$('landVal').textContent = lv<40?'wetter map':lv>60?'drier map':'balanced';
$('mtnVal').textContent = mv<35?'flat':mv>65?'hilly':'gentle';
}
buildSizeOpts();
buildStarterOpts();
updateAdvEnabled();
updateTerrainLabels();
let advOpen=false;
$('advToggle').onclick=()=>{
advOpen=!advOpen;
$('advPanel').style.display=advOpen?'block':'none';
$('advToggle').textContent = advOpen? '⚙ Advanced options ▲' : '⚙ Advanced options ▾';
};
$('landSlider').addEventListener('input',updateTerrainLabels);
$('mtnSlider').addEventListener('input',updateTerrainLabels);
$('landSlider').addEventListener('change',()=>{
if(pickedStarter) return;
terrainLandBias=(+$('landSlider').value-50)/50*0.2;
startNewCity(pickedSize);
});
$('mtnSlider').addEventListener('change',()=>{
if(pickedStarter) return;
terrainMountainBias=(+$('mtnSlider').value-50)/50*0.6;
startNewCity(pickedSize);
});
$('seedInput').addEventListener('change',()=>{
if(pickedStarter) return;
customSeed=parseSeedInput($('seedInput').value);
startNewCity(pickedSize);
});
$('seedInput').addEventListener('keydown',e=>{ if(e.key==='Enter') $('seedInput').blur(); });
$('seedRandom').addEventListener('click',()=>{
if(pickedStarter) return;
customSeed=null; $('seedInput').value='';
startNewCity(pickedSize);
});
$('unlimitedMoney').addEventListener('change',e=>{ startUnlimited=e.target.checked; applyStartOptions(); });
$('start').onclick=()=>{ $('intro').style.display='none'; S.running=true; startMusic();
toast(pickedStarter ? "Taking over mid-story — check the economy tray for where things stand." : "The highway already brings orders for goods. Zone some industry."); };
/* ── how-to-play tutorial: a short slideshow, not a wall of text up front ─ */
const TUT_STEPS=[
{ icon:'👆', title:'Getting around', body:`In every tool, <b>tap to act</b> and <b>drag to pan</b> the map — pinch or scroll to zoom. <b>🔍 Move/View</b> is always a safe, pan-only tool: nothing you tap there changes the city, it just shows you things.` },
{ icon:'🛣️', title:'Roads', body:`Pick a road type, tap a start, then an end — drag the gold handles to shape the curve before building. Wherever two roads cross, a junction forms automatically. Run a road to <b>any map edge</b> and it opens a new regional connection: more trade, more visitors.` },
{ icon:'🏘️', title:'Zoning, Utilities & Services', body:`<b>Zoning</b> is free — it lays out homes, shops, industry and offices, which grow in on their own as demand allows. <b>Utilities</b> (power and water) and <b>Services</b> (police, fire, clinics, schools and parks) cost money and go up right away.` },
{ icon:'🏗️', title:'Building & growth', body:`Zoned buildings don't pop up instantly — each new building or storey spends a few days as a visible <b>🏗️ construction site</b> first (an upgrade shows scaffolding over the building that's already there). If a building loses all its demand and empties out, it sits <b>🏚️ vacant</b> for a while instead of disappearing — if demand comes back in that window it reopens right away, no waiting on new construction.` },
{ icon:'⚡', title:'Power, water & land value', body:`Power and water are a single city-wide grid — keep generation ahead of consumption or nothing grows past one storey. Weigh power sources: <b>Coal</b> is strong but pollutes nearby lots, <b>☀️ Solar</b> is clean but weak everywhere, and <b>🌬️ Wind</b> is clean and can beat coal near a coastline but trails off inland. For water, a <b>🌊 Reservoir</b> covers far more than a Water tower. Past power and water, height follows <b>land value</b>: services, parks and waterfront raise it; pollution, crime and traffic lower it.` },
{ icon:'✏️', title:'Editing roads', body:`In <b>Edit</b> mode, tap anywhere along a road — the pavement, the sidewalk, any lane — to upgrade, downgrade, reshape or retype the whole run. Tap a junction itself to set stop signs, lights, yields, or turn it into a roundabout. Water auto-bridges at 3× cost.` },
{ icon:'💥', title:'Demolishing', body:`In <b>Demolish</b> mode, tap a building to clear the lot, a road to remove it, or a junction to clear it and everything attached to it.` },
{ icon:'🏦', title:'Money', body:`Short on cash? The 🏦 bank lends up to $50,000. Fall $50k into the red and you're offered unlimited <b>Free Play</b>. Save your city with 💾 — it can name the file after your city and share straight from your phone. Pick the soundtrack with 🎵.` },
{ icon:'🤝', title:'Special Partnerships', body:`Every so often an outside institution offers you a deal — a bank headquarters, a shopping mall, a national park. Each is pitched once, but never withdrawn: whichever way you answer, its building sits under <b>Zoning ▸ Special</b> for the rest of your city's life, and <b>placing it is what takes the deal</b>. So "not right now" really does mean not right now. The ones that arrive when your books are in trouble pay a signing bonus when you site them — read those carefully, though: the money is a lease on the land, not a gift, tearing the building down takes it straight back, and that partner only ever pays once.` },
{ icon:'🔍', title:'Views & info', body:`Switch to <b>Move/View</b> and tap any building, car or pedestrian to inspect it — including where a trip is headed. Tap a junction to see its type and traffic. The <b>Overlays</b> submenu there shows heatmaps for land value, traffic, crime and more. Tap the cash, population or happiness chips up top for a full breakdown.` },
];
let tutStep=0;
function renderTut(){
const s=TUT_STEPS[tutStep];
$('tutBody').innerHTML=`<div class="tutIcon">${s.icon}</div><h2>${s.title}</h2><p>${s.body}</p>`;
$('tutBack').disabled = tutStep===0;
$('tutNext').textContent = tutStep===TUT_STEPS.length-1 ? 'Done' : 'Next';
const dots=$('tutDots'); dots.innerHTML='';
TUT_STEPS.forEach((_,i)=>{ const d=document.createElement('span'); if(i===tutStep) d.className='on'; dots.appendChild(d); });
}
function openTutorial(){ tutStep=0; renderTut(); $('tutModal').classList.add('show'); }
function closeTutorial(){ $('tutModal').classList.remove('show'); }
$('howToPlayBtn').onclick=openTutorial;
$('tutSkip').onclick=closeTutorial;
$('tutBack').onclick=()=>{ if(tutStep>0){ tutStep--; renderTut(); } };
$('tutNext').onclick=()=>{ if(tutStep<TUT_STEPS.length-1){ tutStep++; renderTut(); } else closeTutorial(); };
/* ── load a save straight from the start screen ───────────────────────── */
let loadFileFromIntro=false;
$('introLoad').onclick=()=>{ loadFileFromIntro=true; $('loadFile').click(); };
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment