diff --git a/shots/toolbar.png b/shots/toolbar.png
new file mode 100644
index 0000000..c78220e
Binary files /dev/null and b/shots/toolbar.png differ
diff --git a/src/input.js b/src/input.js
index a79b149..9e8f71f 100644
--- a/src/input.js
+++ b/src/input.js
@@ -87,7 +87,7 @@ export class Input {
this.renderer.clearGhost();
this.renderer.hideDragRect();
this.renderer.setHover(null, false);
- this.game.ui.onToolChanged?.(tool, sid);
+ this.game.ui?.onToolChanged(tool, sid);
}
_primaryButton(e) {
diff --git a/src/main.js b/src/main.js
index 8578ef1..34aa3d6 100644
--- a/src/main.js
+++ b/src/main.js
@@ -101,16 +101,16 @@ class Game {
this.speedIdx = 1;
this.paused = false;
this.start(c);
- this.ui().toast(`Welcome to ${name}, Mayor!`, 'success');
- this.ui().resetQuests();
+ this.ui?.toast(`Welcome to ${name}, Mayor!`, 'success');
+ this.ui?.resetQuests();
}
loadFrom(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 renderer() { return this._parts?.renderer; }
@@ -120,7 +120,7 @@ class Game {
this.speedIdx = idx;
this.paused = idx === 0;
if (idx > 0) this.lastNonzeroSpeed = idx;
- this.ui()?.setSpeedActive(idx);
+ this.ui?.setSpeedActive(idx);
}
togglePause() {
@@ -137,16 +137,16 @@ class Game {
queryTile(tile, x, y) {
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() {
if (this.city.tilesDirty && this._parts) {
this._parts.renderer.sync();
this.city.tilesDirty = false;
- this.ui()?.scheduleMinimap();
+ this.ui?.scheduleMinimap();
}
}
diff --git a/src/style.css b/src/style.css
index 3211e75..9f0b1c6 100644
--- a/src/style.css
+++ b/src/style.css
@@ -77,25 +77,54 @@ html, body {
/* =================== toolbar =================== */
#toolbar {
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);
- 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;
}
#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 {
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
- min-width: 58px; padding: 7px 6px 5px; border-radius: 11px; border: 1px solid transparent;
- background: transparent; color: var(--text); cursor: pointer; transition: all .13s;
+ position: relative;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
+ 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);
box-shadow: 0 0 14px rgba(53,224,138,.25) inset; }
-.tl-ico svg { width: 23px; height: 23px; }
-.tl-label { font-size: 10.5px; color: var(--dim); display: flex; gap: 4px; align-items: baseline; white-space: nowrap; }
+.tl-ico svg { width: 30px; height: 30px; border-radius: 7px; }
+.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-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 { 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; }
.stat { padding: 3px 8px; font-size: 12.5px; }
#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; }
#questBox { display: none; }
#minimapWrap { bottom: 110px; right: 8px; }
diff --git a/src/ui/icons.js b/src/ui/icons.js
index 7ccfc92..ea277c9 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -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"';
-
-function svg(inner) {
- return ``;
+/**
+ * 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, vb = '0 0 32 32') {
+ return ``;
}
+const sky = (id, c1 = '#bfe3ff', c2 = '#8fc7f2') =>
+ ``;
export const ICONS = {
- query: svg(''),
- bulldoze: svg(''),
- road: svg(''),
- res: svg(''),
- com: svg(''),
- ind: svg(''),
- coal: svg(''),
- solar: svg(''),
- wind: svg(''),
- police: svg(''),
- fire: svg(''),
- hospital: svg(''),
- school: svg(''),
- park: svg(''),
- plaza: svg(''),
- stadium: svg('')
+ /* ---------- tools ---------- */
+ query: svg(`${sky('q-sky')}
+
+
+
+
+
+ `),
+
+ bulldoze: svg(`
+
+
+
+
+
+
+
+ `),
+
+ /* ---------- zones ---------- */
+ road: svg(`${sky('rd-sky')}
+
+
+
+
+ `),
+
+ res: svg(`${sky('r-sky')}
+
+
+
+
+
+
+
+ `),
+
+ com: svg(`${sky('c-sky')}
+
+
+
+
+ ${[12, 15, 18].map(y => ``).join('')}
+ ${[7, 10, 13, 16, 19].map(y => ``).join('')}
+ `),
+
+ ind: svg(`${sky('i-sky', '#ffe1b0', '#f0b97a')}
+
+
+
+
+
+
+ `),
+
+ /* ---------- power ---------- */
+ coal: svg(`${sky('co-sky', '#d8dee8', '#aab4c4')}
+
+
+
+
+
+
+
+
+ `),
+
+ solar: svg(`${sky('s-sky')}
+
+
+
+
+
+
+
+ `),
+
+ wind: svg(`${sky('w-sky')}
+
+
+
+
+
+
+ `),
+
+ /* ---------- civic ---------- */
+ police: svg(`${sky('p-sky')}
+
+
+
+
+
+ `),
+
+ fire: svg(`${sky('f-sky', '#ffd9c2', '#f2a37a')}
+
+
+
+
+
+
+ `),
+
+ hospital: svg(`${sky('h-sky')}
+
+
+
+
+
+ `),
+
+ school: svg(`${sky('sc-sky')}
+
+
+
+
+
+
+
+ `),
+
+ /* ---------- leisure ---------- */
+ park: svg(`${sky('pk-sky')}
+
+
+
+
+
+
+
+ `),
+
+ plaza: svg(`${sky('pl-sky')}
+
+
+
+
+
+
+
+
+
+ `),
+
+ stadium: svg(`${sky('st-sky')}
+
+
+
+
+
+ ${[[-7, -4], [-3.5, -5], [0, -5.6], [3.5, -5], [7, -4]].map(([dx]) => ``).join('')}
+ `)
};
diff --git a/src/ui/ui.js b/src/ui/ui.js
index fce7ef0..3b3f025 100644
--- a/src/ui/ui.js
+++ b/src/ui/ui.js
@@ -13,27 +13,22 @@ const CITY_NAMES = ['Springvale', 'Port Aurora', 'New Meridian', 'Lakeharbor', '
'Sunfield', 'Baycrest', 'Fernwood', 'Stonebrook', 'Havenport'];
const TOOLBAR = [
- { t: 'tool', id: 'query', icon: 'query', label: 'Inspect', key: 'Q', tip: 'Click tiles for details' },
- { t: 'tool', id: 'bulldoze', icon: 'bulldoze', label: 'Bulldoze', key: 'B', tip: '$3 per tile' },
- { t: 'tool', id: 'road', icon: 'road', label: 'Road', key: 'R', tip: '$10 · bridges $120' },
- { sep: true },
- { 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' },
- { t: 'zone', id: 'zone_ind', zname: 'ind', icon: 'ind', label: 'Industry', key: 'C', tip: 'Free · jobs, pollution' },
- { sep: true },
- { t: 'build', sid: STRUCT.COAL, icon: 'coal', label: 'Coal Plant' },
- { t: 'build', sid: STRUCT.SOLAR, icon: 'solar', label: 'Solar Farm' },
- { t: 'build', sid: STRUCT.WIND, icon: 'wind', label: 'Wind Turbine' },
- { sep: true },
- { t: 'build', sid: STRUCT.POLICE, icon: 'police', label: 'Police' },
- { t: 'build', sid: STRUCT.FIRE, icon: 'fire', label: 'Fire Stn' },
- { sep: true },
- { t: 'build', sid: STRUCT.HOSPITAL, icon: 'hospital', label: 'Hospital' },
- { 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' }
+ { 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', group: 'Tools', costLabel: '$3' },
+ { t: 'tool', id: 'road', icon: 'road', label: 'Road', key: 'R', tip: 'Drag an L-shape · bridges $120', group: 'Tools', costLabel: '$10' },
+ { t: 'zone', id: 'zone_res', zname: 'res', icon: 'res', label: 'Homes', key: 'Z', tip: 'Free · drag a rectangle', group: 'Zones' },
+ { t: 'zone', id: 'zone_com', zname: 'com', icon: 'com', label: 'Shops', key: 'X', tip: 'Free · jobs & taxes', group: 'Zones' },
+ { t: 'zone', id: 'zone_ind', zname: 'ind', icon: 'ind', label: 'Industry', key: 'C', tip: 'Free · jobs, pollution', group: 'Zones' },
+ { t: 'build', sid: STRUCT.COAL, icon: 'coal', label: 'Coal Plant', group: 'Power' },
+ { t: 'build', sid: STRUCT.SOLAR, icon: 'solar', label: 'Solar Farm', group: 'Power' },
+ { t: 'build', sid: STRUCT.WIND, icon: 'wind', label: 'Wind Turbine', group: 'Power' },
+ { t: 'build', sid: STRUCT.POLICE, icon: 'police', label: 'Police', group: 'Safety' },
+ { t: 'build', sid: STRUCT.FIRE, icon: 'fire', label: 'Fire Stn', group: 'Safety' },
+ { t: 'build', sid: STRUCT.HOSPITAL, icon: 'hospital', label: 'Hospital', group: 'Health' },
+ { t: 'build', sid: STRUCT.SCHOOL, icon: 'school', label: 'School', group: 'Health' },
+ { t: 'build', sid: STRUCT.PARK, icon: 'park', label: 'Park', group: 'Leisure' },
+ { t: 'build', sid: STRUCT.PLAZA, icon: 'plaza', label: 'Plaza', group: 'Leisure' },
+ { t: 'build', sid: STRUCT.STADIUM, icon: 'stadium', label: 'Stadium', group: 'Leisure' }
];
export class UI {
@@ -88,26 +83,52 @@ export class UI {
buildToolbar() {
const bar = $('toolbar');
bar.innerHTML = '';
+
+ // floating readout: shows the active tool like SimCity's status chip
+ const chip = el('
');
+ bar.insertAdjacentElement('beforebegin', chip);
+
+ // group items under labeled sections (Tools · Zones · Power · …)
+ const groups = [];
for (const item of TOOLBAR) {
- if (item.sep) { bar.appendChild(el('')); continue; }
- const btn = el(``);
- btn.title = `${item.label}${item.key ? ` (${item.key})` : ''}${item.tip ? ' — ' + item.tip : ''}`;
- btn.addEventListener('click', () => {
- this.game.audio.click();
- if (item.t === 'build') this.game.input.setTool('build', item.sid);
- else this.game.input.setTool(item.id);
- });
- btn.dataset.kind = item.t === 'build' ? `build_${item.sid}` : item.id;
- bar.appendChild(btn);
- if (item.t === 'build') {
- const cost = BUILDINGS[item.sid].cost;
- btn.querySelector('.tl-label').insertAdjacentHTML('beforeend',
- `$${fmtNum(cost)}`);
- }
+ 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(``);
+ 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(``);
+ 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', () => {
+ this.game.audio.click();
+ if (item.t === 'build') this.game.input.setTool('build', item.sid);
+ else this.game.input.setTool(item.id);
+ });
+ row.appendChild(btn);
+ }
+ 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) {
@@ -116,6 +137,26 @@ export class UI {
if (tool === 'build') sel = document.querySelector(`#toolbar [data-kind="build_${sid}"]`);
else sel = document.querySelector(`#toolbar [data-kind="${tool}"]`);
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 = `${ICONS[item.icon]}
+ ${item.label}${tip ? `${tip}` : ''}`;
+ chip.classList.add('show');
+ }
+ }
+
const cursors = { bulldoze: 'not-allowed', query: 'help' };
this.game.renderer.domElement.style.cursor =
tool === 'none' ? 'grab' : (cursors[tool] || 'crosshair');