SimCity-style toolbar: colored pictogram icons + grouped UX

- All 16 icons redrawn as rich multi-color SVGs (houses, towers,
  factory, plants with smoke, services with badges/crosses, park,
  fountain, stadium) — unique gradient ids, no collisions
- Toolbar grouped under labeled sections: Tools / Zones / Power /
  Safety / Health / Leisure
- Every button shows price (FREE for zones); unaffordable buildings
  dim and turn red as funds change
- Keyboard-shortcut badges on buttons; bigger 30px icons; hover lift
- Floating status chip above the bar shows active tool, price,
  upkeep and coverage radius (SimCity status-bar feel)
- FIX: Game.ui was a method while input.js read it as a property, so
  tool-change UI updates silently never ran; now a proper getter
This commit is contained in:
PolyCity
2026-08-23 01:34:23 +00:00
parent 1c0eb70f0f
commit bb6c087490
6 changed files with 294 additions and 80 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+1 -1
View File
@@ -87,7 +87,7 @@ export class Input {
this.renderer.clearGhost(); this.renderer.clearGhost();
this.renderer.hideDragRect(); this.renderer.hideDragRect();
this.renderer.setHover(null, false); this.renderer.setHover(null, false);
this.game.ui.onToolChanged?.(tool, sid); this.game.ui?.onToolChanged(tool, sid);
} }
_primaryButton(e) { _primaryButton(e) {
+8 -8
View File
@@ -101,16 +101,16 @@ class Game {
this.speedIdx = 1; this.speedIdx = 1;
this.paused = false; this.paused = false;
this.start(c); this.start(c);
this.ui().toast(`Welcome to ${name}, Mayor!`, 'success'); this.ui?.toast(`Welcome to ${name}, Mayor!`, 'success');
this.ui().resetQuests(); this.ui?.resetQuests();
} }
loadFrom(cityInstance) { loadFrom(cityInstance) {
this.start(cityInstance); this.start(cityInstance);
this.ui().toast('City loaded.', 'success'); this.ui?.toast('City loaded.', 'success');
} }
ui() { return this._parts?.ui; } get ui() { return this._parts?.ui; }
get input() { return this._parts?.input; } get input() { return this._parts?.input; }
get renderer() { return this._parts?.renderer; } get renderer() { return this._parts?.renderer; }
@@ -120,7 +120,7 @@ class Game {
this.speedIdx = idx; this.speedIdx = idx;
this.paused = idx === 0; this.paused = idx === 0;
if (idx > 0) this.lastNonzeroSpeed = idx; if (idx > 0) this.lastNonzeroSpeed = idx;
this.ui()?.setSpeedActive(idx); this.ui?.setSpeedActive(idx);
} }
togglePause() { togglePause() {
@@ -137,16 +137,16 @@ class Game {
queryTile(tile, x, y) { queryTile(tile, x, y) {
const info = this.city.getTileInfo(tile.x, tile.z); const info = this.city.getTileInfo(tile.x, tile.z);
if (info) this.ui()?.showInfoPopup(info, x, y); if (info) this.ui?.showInfoPopup(info, x, y);
} }
checkQuests() { this.ui()?.checkQuests(); } checkQuests() { this.ui?.checkQuests(); }
_syncSoon() { _syncSoon() {
if (this.city.tilesDirty && this._parts) { if (this.city.tilesDirty && this._parts) {
this._parts.renderer.sync(); this._parts.renderer.sync();
this.city.tilesDirty = false; this.city.tilesDirty = false;
this.ui()?.scheduleMinimap(); this.ui?.scheduleMinimap();
} }
} }
+44 -11
View File
@@ -77,25 +77,54 @@ html, body {
/* =================== toolbar =================== */ /* =================== toolbar =================== */
#toolbar { #toolbar {
position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
display: flex; align-items: stretch; gap: 4px; max-width: calc(100vw - 24px); display: flex; align-items: stretch; gap: 6px; max-width: calc(100vw - 24px);
background: var(--panel); backdrop-filter: blur(10px); background: var(--panel); backdrop-filter: blur(10px);
border: 1px solid var(--line); border-radius: 16px; padding: 7px; border: 1px solid var(--line); border-radius: 16px; padding: 6px 8px;
overflow-x: auto; scrollbar-width: none; overflow-x: auto; scrollbar-width: none;
} }
#toolbar::-webkit-scrollbar { display: none; } #toolbar::-webkit-scrollbar { display: none; }
.tb-sep { width: 1px; background: var(--line); margin: 4px 3px; }
/* labeled sections, SimCity-style */
.tb-group { display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 0 3px; }
.tb-group + .tb-group { border-left: 1px solid var(--line); padding-left: 9px; }
.tb-cap { font-size: 8.5px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
color: var(--dim); opacity: .85; user-select: none; }
.tb-row { display: flex; gap: 4px; }
.tl-btn { .tl-btn {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; position: relative;
min-width: 58px; padding: 7px 6px 5px; border-radius: 11px; border: 1px solid transparent; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
background: transparent; color: var(--text); cursor: pointer; transition: all .13s; min-width: 64px; padding: 6px 6px 5px; border-radius: 11px; border: 1px solid transparent;
background: rgba(255,255,255,.03); color: var(--text); cursor: pointer; transition: all .13s;
} }
.tl-btn:hover { background: rgba(255,255,255,.09); border-color: var(--line); } .tl-btn:hover { background: rgba(255,255,255,.09); border-color: var(--line); transform: translateY(-1px); }
.tl-btn.active { background: rgba(53,224,138,.16); border-color: rgba(53,224,138,.65); .tl-btn.active { background: rgba(53,224,138,.16); border-color: rgba(53,224,138,.65);
box-shadow: 0 0 14px rgba(53,224,138,.25) inset; } box-shadow: 0 0 14px rgba(53,224,138,.25) inset; }
.tl-ico svg { width: 23px; height: 23px; } .tl-ico svg { width: 30px; height: 30px; border-radius: 7px; }
.tl-label { font-size: 10.5px; color: var(--dim); display: flex; gap: 4px; align-items: baseline; white-space: nowrap; } .tl-label { font-size: 10.5px; color: var(--text); white-space: nowrap; line-height: 1.1; }
.tl-btn.active .tl-label { color: var(--accent); } .tl-btn.active .tl-label { color: var(--accent); }
.tl-cost { font-size: 9px; opacity: .75; } .tl-cost { font-size: 9px; color: #7ddba3; opacity: .9; line-height: 1; }
.tl-key {
position: absolute; top: 4px; right: 5px; font-size: 8.5px; line-height: 13px;
color: var(--dim); border: 1px solid var(--line); border-radius: 4px; padding: 0 3px;
pointer-events: none;
}
.tl-btn.poor { opacity: .45; filter: saturate(.4); }
.tl-btn.poor .tl-cost { color: #ff7a7a; }
/* floating "current tool" chip above the bar */
#toolReadout {
position: absolute; bottom: 92px; left: 50%; transform: translate(-50%, 6px);
display: flex; align-items: center; gap: 8px;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 6px 12px 6px 8px; pointer-events: none; opacity: 0;
transition: opacity .15s, transform .15s; max-width: min(70vw, 420px);
}
#toolReadout.show { opacity: 1; transform: translate(-50%, 0); }
#toolReadout .tr-ico svg { width: 24px; height: 24px; border-radius: 6px; }
#toolReadout b { font-size: 12.5px; white-space: nowrap; }
#toolReadout .tr-tip { font-size: 11px; color: var(--dim); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
/* =================== toasts =================== */ /* =================== toasts =================== */
#toasts { position: absolute; top: 64px; left: 12px; display: flex; flex-direction: column; gap: 8px; max-width: 340px; } #toasts { position: absolute; top: 64px; left: 12px; display: flex; flex-direction: column; gap: 8px; max-width: 340px; }
@@ -252,7 +281,11 @@ h4 { margin: 12px 0 4px; font-size: 12.5px; color: var(--dim); }
#topbar { padding: 6px 8px; } #topbar { padding: 6px 8px; }
.stat { padding: 3px 8px; font-size: 12.5px; } .stat { padding: 3px 8px; font-size: 12.5px; }
#cityName { font-size: 14px; } #cityName { font-size: 14px; }
.tl-btn { min-width: 48px; } .tl-btn { min-width: 52px; }
.tl-ico svg { width: 26px; height: 26px; }
.tl-key { display: none; }
.tb-cap { font-size: 7.5px; }
#toolReadout { bottom: 96px; }
.tl-label { font-size: 9px; } .tl-label { font-size: 9px; }
#questBox { display: none; } #questBox { display: none; }
#minimapWrap { bottom: 110px; right: 8px; } #minimapWrap { bottom: 110px; right: 8px; }
+161 -21
View File
@@ -1,25 +1,165 @@
/** Inline stroke-style SVG icon set (24×24). */ /**
const S = 'fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"'; * Colorful SimCity-style pictogram icons (filled, multi-color, 32×32).
* Each gradient id is globally unique so any number can coexist inline.
function svg(inner) { */
return `<svg viewBox="0 0 24 24" ${S}>${inner}</svg>`; function svg(inner, vb = '0 0 32 32') {
return `<svg viewBox="${vb}" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
} }
const sky = (id, c1 = '#bfe3ff', c2 = '#8fc7f2') =>
`<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs>`;
export const ICONS = { export const ICONS = {
query: svg('<circle cx="10" cy="10" r="6"/><path d="M14.5 14.5L20 20"/>'), /* ---------- tools ---------- */
bulldoze: svg('<rect x="3" y="12" width="8" height="6" rx="1"/><path d="M11 13h4l4 4v1h-8"/><circle cx="7" cy="19.5" r="1.8"/><circle cx="16.5" cy="19.5" r="1.5"/><path d="M5 12V9h4v3"/>'), query: svg(`${sky('q-sky')}
road: svg('<path d="M9 3L5 21"/><path d="M15 3l4 18"/><path d="M12 4v3"/><path d="M12 11v3"/><path d="M12 18v3"/>'), <rect x="2" y="2" width="28" height="28" rx="5" fill="url(#q-sky)"/>
res: svg('<path d="M4 11l8-7 8 7"/><path d="M6 10v10h12V10"/><path d="M10 20v-5h4v5"/>'), <path d="M2 22h28v3a5 5 0 01-5 5H7a5 5 0 01-5-5z" fill="#79a86b"/>
com: svg('<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M4 8l2-4h12l2 4"/><path d="M9 20v-6h6v6"/><path d="M4 12h16"/>'), <circle cx="14" cy="13" r="7.2" fill="#ffffffcc"/>
ind: svg('<path d="M4 20V10l5 3v-3l5 3v-3l6 3v7z"/><rect x="16.5" y="4" width="3" height="6"/>'), <circle cx="14" cy="13" r="7.2" fill="none" stroke="#1f7fd0" stroke-width="2.4"/>
coal: svg('<circle cx="12" cy="12" r="9"/><path d="M13 6l-5 7h4l-1 5 5-7h-4z"/>'), <path d="M19.5 18.5L26 25" stroke="#1f7fd0" stroke-width="3.4" stroke-linecap="round"/>
solar: svg('<path d="M4 14h16l-2 6H6z"/><path d="M8 14l1.5 6"/><path d="M16 14l-1.5 6"/><path d="M5.5 17h13"/><circle cx="18" cy="5" r="2.4"/>'), <path d="M11 11a3.4 3.4 0 013-2" stroke="#9fd0f5" stroke-width="1.6" stroke-linecap="round" fill="none"/>`),
wind: svg('<path d="M12 21v-8"/><path d="M12 13V4"/><path d="M12 13l7.8 4.5"/><path d="M12 13L4.2 17.5"/><circle cx="12" cy="13" r="1.6"/>'),
police: svg('<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/>'), bulldoze: svg(`
fire: svg('<path d="M12 3c1 3 4.5 4.5 4.5 8.5a4.5 4.5 0 01-9 0c0-2 .9-3.2 1.8-4.3C10.2 9 12 6.5 12 3z"/><path d="M12 21a3 3 0 003-3c0-1.5-1.5-2.5-3-4-1.5 1.5-3 2.5-3 4a3 3 0 003 3z"/>'), <rect x="2" y="20" width="28" height="8" rx="3" fill="#8a6b4a"/>
hospital: svg('<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M12 8v8"/><path d="M8 12h8"/>'), <rect x="2" y="20" width="28" height="8" rx="3" fill="none" stroke="#5f4630" stroke-width="1.2"/>
school: svg('<path d="M3 9l9-4 9 4-9 4z"/><path d="M7 11.5v3.5c0 1.6 2.2 3 5 3s5-1.4 5-3v-3.5"/><path d="M21 9v5"/>'), <rect x="6" y="9" width="13" height="12" rx="2" fill="#f2b632"/>
park: svg('<path d="M12 3l5 7h-3l4 6H6l4-6H7z"/><path d="M12 16v5"/>'), <rect x="6" y="9" width="13" height="5" rx="2" fill="#ffd45e"/>
plaza: svg('<path d="M12 4v4"/><path d="M12 8c-2 0-3.2 1-3.2 3h6.4c0-2-1.2-3-3.2-3z"/><path d="M5 14h14l-1.5 6h-11z"/><path d="M8.5 11.5h7"/>'), <rect x="21" y="13" width="7" height="8" rx="1.5" fill="#e8912b"/>
stadium: svg('<ellipse cx="12" cy="13" rx="8.5" ry="5.5"/><ellipse cx="12" cy="13" rx="4" ry="2.2"/><path d="M3.8 10.5L2 7.5M20.2 10.5L22 7.5"/>') <path d="M21 15l7-6" stroke="#5f4630" stroke-width="2" stroke-linecap="round"/>
<circle cx="10" cy="23.5" r="2.6" fill="#333"/><circle cx="17" cy="23.5" r="2.6" fill="#333"/>
<path d="M4 27h24" stroke="#3d2e1e" stroke-width="1.6" stroke-linecap="round"/>`),
/* ---------- zones ---------- */
road: svg(`${sky('rd-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#rd-sky)"/>
<path d="M9 3h14L29 29H3z" fill="#4a4f57"/>
<path d="M11 3h4l6 26h-4z" fill="#585e68"/>
<path d="M16 5.5v3M16 12v3.4M16 19v3.4" stroke="#ffd23b" stroke-width="1.7" stroke-linecap="round"/>
<path d="M3 29c2-2.4 5-2.8 8-1.6L10 32H3zM29 29c-2-2.4-5-2.8-8-1.6L22 32h7z" fill="#57a04b"/>`),
res: svg(`${sky('r-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#r-sky)"/>
<rect x="2" y="22" width="28" height="8" fill="#57a04b"/>
<rect x="7" y="14" width="11" height="10" fill="#f7f3e8" stroke="#3fae5a" stroke-width="1.4"/>
<path d="M5.5 14L12.5 7l7 7z" fill="#3fae5a"/>
<rect x="11" y="18" width="3.4" height="6" fill="#7a5230"/>
<circle cx="23" cy="17" r="4.4" fill="#2f8f43"/>
<rect x="22" y="19" width="2" height="5.4" fill="#6b4423"/>
<circle cx="25.5" cy="20" r="2.8" fill="#48b05c"/>`),
com: svg(`${sky('c-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#c-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#6d7887"/>
<rect x="6" y="10" width="8" height="16" fill="#3d7be0"/>
<rect x="15" y="5" width="10" height="21" fill="#2f5fc4"/>
${[12, 15, 18].map(y => `<rect x="7.6" y="${y}" width="1.8" height="1.8" fill="#cfe4ff"/><rect x="10.6" y="${y}" width="1.8" height="1.8" fill="#cfe4ff"/>`).join('')}
${[7, 10, 13, 16, 19].map(y => `<rect x="17" y="${y}" width="2" height="2" fill="#bcd8ff"/><rect x="21" y="${y}" width="2" height="2" fill="#bcd8ff"/>`).join('')}
<rect x="19" y="2.6" width="2" height="3" fill="#e8b93c"/>`),
ind: svg(`${sky('i-sky', '#ffe1b0', '#f0b97a')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#i-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#8a7a63"/>
<path d="M5 24V14l6 3.4V14l6 3.4V14l6 3.4V24z" fill="#e8963c"/>
<path d="M5 24V14l6 3.4V14l6 3.4V14l6 3.4V24z" fill="none" stroke="#a35f1d" stroke-width="1.2"/>
<rect x="22" y="5" width="3.6" height="10" fill="#9a9aa5"/>
<ellipse cx="23.8" cy="3.4" rx="3.4" ry="2" fill="#e6e6ec" opacity=".9"/>
<rect x="8" y="18" width="3" height="3" fill="#5f3d16"/><rect x="14" y="18" width="3" height="3" fill="#5f3d16"/>`),
/* ---------- power ---------- */
coal: svg(`${sky('co-sky', '#d8dee8', '#aab4c4')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#co-sky)"/>
<rect x="2" y="23" width="28" height="7" fill="#5c6672"/>
<rect x="5" y="14" width="14" height="9" fill="#57606c"/>
<rect x="7" y="16" width="3" height="3" fill="#ffb03b"/><rect x="12" y="16" width="3" height="3" fill="#ffb03b"/>
<rect x="20" y="8" width="3.4" height="15" fill="#3f4854"/>
<rect x="25" y="11" width="3" height="12" fill="#3f4854"/>
<ellipse cx="21.7" cy="6" rx="3.2" ry="1.9" fill="#eef1f5"/>
<ellipse cx="26.5" cy="9" rx="2.6" ry="1.6" fill="#eef1f5"/>
<path d="M11.5 19l-2.4 3.6h2l-.8 2.6 2.8-3.6h-2z" fill="#ffd23b" stroke="#e8891b" stroke-width=".6"/>`),
solar: svg(`${sky('s-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#s-sky)"/>
<rect x="2" y="23" width="28" height="7" fill="#57a04b"/>
<circle cx="24.5" cy="8" r="4" fill="#ffd23b"/>
<circle cx="24.5" cy="8" r="4" fill="none" stroke="#f0a92e" stroke-width="1.2"/>
<path d="M4 21l4-9h14l-4 9z" fill="#2b5fb8"/>
<path d="M4 21l4-9h14l-4 9z" fill="none" stroke="#16336e" stroke-width="1.2"/>
<path d="M9.2 16.5h13M8 19h13.4M10.8 12l-2.4 9M15 12l-2.2 9M19 12l-2 9" stroke="#7fb0ff" stroke-width="1.1"/>
<path d="M6 21v3M20 21v3" stroke="#3d2e1e" stroke-width="1.6"/>`),
wind: svg(`${sky('w-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#w-sky)"/>
<path d="M2 25c5-4 9-3 14-1s10 1 14-1v7H2z" fill="#57a04b"/>
<rect x="14.8" y="10" width="2.4" height="16" rx="1" fill="#e8ecf2"/>
<path d="M16 10L16 2.6l1.9 6.8z" fill="#f8fafc"/>
<path d="M16 10l6.6 3.8-6.9-.9z" fill="#dde4ee"/>
<path d="M16 10l-6.6 3.8 6.9-.9z" fill="#cdd6e4"/>
<circle cx="16" cy="10" r="2.1" fill="#f8fafc" stroke="#94a3b8" stroke-width=".9"/>`),
/* ---------- civic ---------- */
police: svg(`${sky('p-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#p-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#5c6672"/>
<rect x="6" y="9" width="20" height="15" fill="#274b8f"/>
<rect x="6" y="9" width="20" height="3.4" fill="#1d3a70"/>
<path d="M16 12.2l1.5 3 3.3.4-2.4 2.3.6 3.3-3-1.6-3 1.6.6-3.3-2.4-2.3 3.3-.4z" fill="#ffd23b"/>
<rect x="8" y="19" width="3" height="5" fill="#cfe4ff"/><rect x="21" y="19" width="3" height="5" fill="#cfe4ff"/>`),
fire: svg(`${sky('f-sky', '#ffd9c2', '#f2a37a')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#f-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#5c5050"/>
<rect x="5" y="12" width="22" height="12" fill="#c93b2c"/>
<rect x="5" y="12" width="22" height="3" fill="#a02a1e"/>
<rect x="12" y="16" width="8" height="8" fill="#3a2a26"/>
<path d="M16 4.6c1.2 2.6 3.9 3.8 3.9 7a3.9 3.9 0 11-7.8 0c0-1.6.7-2.7 1.5-3.7.6 1 1.4 1.6 2.4 1.7-.6-1.8-.6-3.4 0-5z" fill="#ff8c1a"/>
<path d="M16 8.4c.7 1.3 2 2 2 3.7a2 2 0 11-4 0c0-1.7 1.3-2.4 2-3.7z" fill="#ffd23b"/>`),
hospital: svg(`${sky('h-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#h-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#6d7887"/>
<rect x="6" y="7" width="20" height="17" rx="1.5" fill="#f4f7fa" stroke="#b9c4cf" stroke-width="1.1"/>
<rect x="14" y="10" width="4" height="11" fill="#e03e3e"/>
<rect x="10.5" y="13.5" width="11" height="4" fill="#e03e3e"/>
<rect x="8" y="20" width="3.2" height="4" fill="#7fb0e8"/><rect x="20.8" y="20" width="3.2" height="4" fill="#7fb0e8"/>`),
school: svg(`${sky('sc-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#sc-sky)"/>
<rect x="2" y="24" width="28" height="6" fill="#57a04b"/>
<rect x="5" y="13" width="22" height="11" fill="#e8963c"/>
<path d="M4 13l12-6.5L28 13z" fill="#b96a20"/>
<rect x="13.6" y="18" width="4.8" height="6" fill="#2b5fb8"/>
<rect x="8" y="17" width="3" height="3" fill="#fff3d6"/><rect x="21" y="17" width="3" height="3" fill="#fff3d6"/>
<path d="M16 6.5V3.4" stroke="#5f4630" stroke-width="1.3"/>
<path d="M16 3.4l4 1.2-4 1.4z" fill="#e03e3e"/>`),
/* ---------- leisure ---------- */
park: svg(`${sky('pk-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#pk-sky)"/>
<path d="M2 24c6-2.4 22-2.4 28 0v6H2z" fill="#57a04b"/>
<circle cx="10.5" cy="14" r="6" fill="#2f8f43"/>
<circle cx="10.5" cy="11" r="4" fill="#48b05c"/>
<rect x="9.5" y="17" width="2" height="6" fill="#6b4423"/>
<circle cx="22" cy="16.5" r="5" fill="#3da355"/>
<circle cx="22" cy="14" r="3.4" fill="#59c06d"/>
<rect x="21.2" y="19" width="1.8" height="4.6" fill="#6b4423"/>`),
plaza: svg(`${sky('pl-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="#cfd8e2"/>
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#pl-sky)" opacity=".35"/>
<rect x="2" y="24" width="28" height="6" fill="#9aa7b5"/>
<ellipse cx="16" cy="19" rx="11" ry="4.6" fill="#8d99a8"/>
<ellipse cx="16" cy="18.2" rx="9" ry="3.6" fill="#3d9be0"/>
<ellipse cx="16" cy="18.2" rx="9" ry="3.6" fill="none" stroke="#2b6ea8" stroke-width="1"/>
<rect x="14.6" y="8" width="2.8" height="9" fill="#c2ccd8"/>
<ellipse cx="16" cy="8" rx="4.4" ry="1.9" fill="#aeb9c8"/>
<path d="M16 10.5c-1.6 1.6-1.6 3.4 0 5 1.6-1.6 1.6-3.4 0-5z" fill="#7fc4f5"/>
<circle cx="11" cy="16.6" r="1.1" fill="#9fd8fa"/><circle cx="21" cy="16.9" r="1.1" fill="#9fd8fa"/>`),
stadium: svg(`${sky('st-sky')}
<rect x="2" y="2" width="28" height="28" rx="5" fill="url(#st-sky)"/>
<ellipse cx="16" cy="18" rx="13.4" ry="9" fill="#b9c2ce"/>
<ellipse cx="16" cy="17.4" rx="11.6" ry="7.6" fill="#e8ecf2"/>
<ellipse cx="16" cy="17.8" rx="8.6" ry="5.2" fill="#3fae5a"/>
<path d="M16 13.2v9.2M10 17.8h12" stroke="#e8f5ec" stroke-width="1.1"/>
${[[-7, -4], [-3.5, -5], [0, -5.6], [3.5, -5], [7, -4]].map(([dx]) => `<circle cx="${16 + dx}" cy="11.6" r="1.15" fill="#5b6b80"/>`).join('')}
<rect x="13.8" y="24" width="4.4" height="2.6" fill="#8d99a8"/>`)
}; };
+70 -29
View File
@@ -13,27 +13,22 @@ const CITY_NAMES = ['Springvale', 'Port Aurora', 'New Meridian', 'Lakeharbor', '
'Sunfield', 'Baycrest', 'Fernwood', 'Stonebrook', 'Havenport']; 'Sunfield', 'Baycrest', 'Fernwood', 'Stonebrook', 'Havenport'];
const TOOLBAR = [ const TOOLBAR = [
{ t: 'tool', id: 'query', icon: 'query', label: 'Inspect', key: 'Q', tip: 'Click tiles for details' }, { t: 'tool', id: 'query', icon: 'query', label: 'Inspect', key: 'Q', tip: 'Click tiles for details', group: 'Tools' },
{ t: 'tool', id: 'bulldoze', icon: 'bulldoze', label: 'Bulldoze', key: 'B', tip: '$3 per tile' }, { t: 'tool', id: 'bulldoze', icon: 'bulldoze', label: 'Bulldoze', key: 'B', tip: '$3 per tile', group: 'Tools', costLabel: '$3' },
{ t: 'tool', id: 'road', icon: 'road', label: 'Road', key: 'R', tip: '$10 · bridges $120' }, { t: 'tool', id: 'road', icon: 'road', label: 'Road', key: 'R', tip: 'Drag an L-shape · bridges $120', group: 'Tools', costLabel: '$10' },
{ sep: true }, { t: 'zone', id: 'zone_res', zname: 'res', icon: 'res', label: 'Homes', key: 'Z', tip: 'Free · drag a rectangle', group: 'Zones' },
{ t: 'zone', id: 'zone_res', zname: 'res', icon: 'res', label: 'Homes', key: 'Z', tip: 'Free · drag a rectangle' }, { t: 'zone', id: 'zone_com', zname: 'com', icon: 'com', label: 'Shops', key: 'X', tip: 'Free · jobs & taxes', group: 'Zones' },
{ t: 'zone', id: 'zone_com', zname: 'com', icon: 'com', label: 'Shops', key: 'X', tip: 'Free · jobs & taxes' }, { t: 'zone', id: 'zone_ind', zname: 'ind', icon: 'ind', label: 'Industry', key: 'C', tip: 'Free · jobs, pollution', group: 'Zones' },
{ t: 'zone', id: 'zone_ind', zname: 'ind', icon: 'ind', label: 'Industry', key: 'C', tip: 'Free · jobs, pollution' }, { t: 'build', sid: STRUCT.COAL, icon: 'coal', label: 'Coal Plant', group: 'Power' },
{ sep: true }, { t: 'build', sid: STRUCT.SOLAR, icon: 'solar', label: 'Solar Farm', group: 'Power' },
{ t: 'build', sid: STRUCT.COAL, icon: 'coal', label: 'Coal Plant' }, { t: 'build', sid: STRUCT.WIND, icon: 'wind', label: 'Wind Turbine', group: 'Power' },
{ t: 'build', sid: STRUCT.SOLAR, icon: 'solar', label: 'Solar Farm' }, { t: 'build', sid: STRUCT.POLICE, icon: 'police', label: 'Police', group: 'Safety' },
{ t: 'build', sid: STRUCT.WIND, icon: 'wind', label: 'Wind Turbine' }, { t: 'build', sid: STRUCT.FIRE, icon: 'fire', label: 'Fire Stn', group: 'Safety' },
{ sep: true }, { t: 'build', sid: STRUCT.HOSPITAL, icon: 'hospital', label: 'Hospital', group: 'Health' },
{ t: 'build', sid: STRUCT.POLICE, icon: 'police', label: 'Police' }, { t: 'build', sid: STRUCT.SCHOOL, icon: 'school', label: 'School', group: 'Health' },
{ t: 'build', sid: STRUCT.FIRE, icon: 'fire', label: 'Fire Stn' }, { t: 'build', sid: STRUCT.PARK, icon: 'park', label: 'Park', group: 'Leisure' },
{ sep: true }, { t: 'build', sid: STRUCT.PLAZA, icon: 'plaza', label: 'Plaza', group: 'Leisure' },
{ t: 'build', sid: STRUCT.HOSPITAL, icon: 'hospital', label: 'Hospital' }, { t: 'build', sid: STRUCT.STADIUM, icon: 'stadium', label: 'Stadium', group: 'Leisure' }
{ t: 'build', sid: STRUCT.SCHOOL, icon: 'school', label: 'School' },
{ sep: true },
{ t: 'build', sid: STRUCT.PARK, icon: 'park', label: 'Park' },
{ t: 'build', sid: STRUCT.PLAZA, icon: 'plaza', label: 'Plaza' },
{ t: 'build', sid: STRUCT.STADIUM, icon: 'stadium', label: 'Stadium' }
]; ];
export class UI { export class UI {
@@ -88,26 +83,52 @@ export class UI {
buildToolbar() { buildToolbar() {
const bar = $('toolbar'); const bar = $('toolbar');
bar.innerHTML = ''; bar.innerHTML = '';
// floating readout: shows the active tool like SimCity's status chip
const chip = el('<div id="toolReadout"></div>');
bar.insertAdjacentElement('beforebegin', chip);
// group items under labeled sections (Tools · Zones · Power · …)
const groups = [];
for (const item of TOOLBAR) { for (const item of TOOLBAR) {
if (item.sep) { bar.appendChild(el('<div class="tb-sep"></div>')); continue; } let g = groups[groups.length - 1];
if (!g || g.name !== item.group) { g = { name: item.group, items: [] }; groups.push(g); }
g.items.push(item);
}
for (const g of groups) {
const box = el(`<div class="tb-group"><div class="tb-cap">${g.name}</div><div class="tb-row"></div></div>`);
const row = box.querySelector('.tb-row');
for (const item of g.items) {
const cost = item.t === 'build' ? BUILDINGS[item.sid].cost
: (item.t === 'zone' ? 0 : null);
const costText = item.costLabel ?? (cost === null ? '' : cost === 0 ? 'FREE' : '$' + fmtNum(cost));
const btn = el(`<button class="tl-btn" data-id="${item.id}"> const btn = el(`<button class="tl-btn" data-id="${item.id}">
${item.key ? `<span class="tl-key">${item.key}</span>` : ''}
<span class="tl-ico">${ICONS[item.icon]}</span> <span class="tl-ico">${ICONS[item.icon]}</span>
<span class="tl-label">${item.label}</span> <span class="tl-label">${item.label}</span>
<span class="tl-cost">${costText}</span>
</button>`); </button>`);
btn.title = `${item.label}${item.key ? ` (${item.key})` : ''}${item.tip ? ' — ' + item.tip : ''}`; btn.title = `${item.label}${item.tip ? ' — ' + item.tip : ''}`;
btn.dataset.kind = item.t === 'build' ? `build_${item.sid}` : item.id;
if (item.t === 'build') btn.dataset.cost = BUILDINGS[item.sid].cost;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
this.game.audio.click(); this.game.audio.click();
if (item.t === 'build') this.game.input.setTool('build', item.sid); if (item.t === 'build') this.game.input.setTool('build', item.sid);
else this.game.input.setTool(item.id); else this.game.input.setTool(item.id);
}); });
btn.dataset.kind = item.t === 'build' ? `build_${item.sid}` : item.id; row.appendChild(btn);
bar.appendChild(btn);
if (item.t === 'build') {
const cost = BUILDINGS[item.sid].cost;
btn.querySelector('.tl-label').insertAdjacentHTML('beforeend',
`<span class="tl-cost">$${fmtNum(cost)}</span>`);
} }
bar.appendChild(box);
} }
// dim unaffordable buildings as funds change
const affordability = (money) => {
bar.querySelectorAll('.tl-btn[data-cost]').forEach(b => {
b.classList.toggle('poor', money < +b.dataset.cost);
});
};
this.city.on('stats', s => affordability(s.money));
affordability(this.city.money);
} }
onToolChanged(tool, sid) { onToolChanged(tool, sid) {
@@ -116,6 +137,26 @@ export class UI {
if (tool === 'build') sel = document.querySelector(`#toolbar [data-kind="build_${sid}"]`); if (tool === 'build') sel = document.querySelector(`#toolbar [data-kind="build_${sid}"]`);
else sel = document.querySelector(`#toolbar [data-kind="${tool}"]`); else sel = document.querySelector(`#toolbar [data-kind="${tool}"]`);
if (sel) sel.classList.add('active'); if (sel) sel.classList.add('active');
// status chip: what am I holding, and how do I use it?
const kind = tool === 'build' ? `build_${sid}` : tool;
const item = TOOLBAR.find(i => (i.t === 'build' ? `build_${i.sid}` : i.id) === kind);
const chip = document.getElementById('toolReadout');
if (chip) {
if (!item || tool === 'none') chip.classList.remove('show');
else {
let tip = item.tip;
if (!tip && item.t === 'build') {
const bmeta = BUILDINGS[item.sid];
tip = `$${fmtNum(bmeta.cost)} · $${bmeta.upkeep || 0}/mo` +
(bmeta.radius >= 10 ? ` · covers ${bmeta.radius} tiles around it` : '');
}
chip.innerHTML = `<span class="tr-ico">${ICONS[item.icon]}</span>
<b>${item.label}</b>${tip ? `<span class="tr-tip">${tip}</span>` : ''}`;
chip.classList.add('show');
}
}
const cursors = { bulldoze: 'not-allowed', query: 'help' }; const cursors = { bulldoze: 'not-allowed', query: 'help' };
this.game.renderer.domElement.style.cursor = this.game.renderer.domElement.style.cursor =
tool === 'none' ? 'grab' : (cursors[tool] || 'crosshair'); tool === 'none' ? 'grab' : (cursors[tool] || 'crosshair');