|
<!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 & 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 & 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 & 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> |