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 `${inner}`; +/** + * 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 `${inner}`; } +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(`
${g.name}
`); + 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');