// The settings screen. // ---------- Settings UI ---------- const SET_TABS = { Display: [ { k: 'zoom', label: 'Zoom', d: 'Screen pixels per grain. Mouse wheel also zooms.', type: 'range', min: 2, max: 12, step: 1, fmt: v => v + ' px' }, { k: 'integerScale', label: 'Pixel-perfect scaling', d: 'Keeps every grain the same size. Turn off for smooth zoom on high-DPI screens.', type: 'toggle' }, { k: 'renderScale', label: 'Render resolution', d: 'Lower is faster on big or high-DPI screens.', type: 'select', opts: [[0.5, '50%'], [0.75, '75%'], [1, '100%']] }, { k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] }, { k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen }, { k: 'cursorLabel', label: 'Name next to the cursor', d: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] }, { k: 'minimap', label: 'Show minimap', type: 'toggle' }, { k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' }, { k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'cameraLead', label: 'Camera looks toward the pointer', d: 'The view drifts toward the mouse or right stick. Turn off to keep the bot centred.', type: 'toggle' }, { k: 'shake', label: 'Screen shake', type: 'toggle' }, ], Graphics: [ { k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] }, { k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] }, { k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] }, { k: 'ambient', label: 'Darkness underground', d: 'Lowest brightness in unlit caves.', type: 'range', min: 0, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' }, { k: 'daylight', label: 'Daylight', type: 'range', min: 0.5, max: 1.2, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'glow', label: 'Bloom on hot material', type: 'select', opts: [['off', 'Off'], ['low', 'Low'], ['high', 'High']] }, { k: 'incandescence', label: 'Heat glow', d: 'Hot material glows red, orange and white by temperature.', type: 'toggle' }, { k: 'grain', label: 'Grain texture', type: 'range', min: 0, max: 1.5, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'particles', label: 'Sparks and debris', type: 'toggle' }, { k: 'thermalPalette', label: 'Thermal lens colors', type: 'select', opts: [['inferno', 'Inferno'], ['ironbow', 'Ironbow'], ['gray', 'Grayscale']] }, { note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' }, ], Simulation: [ { k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v }, { k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] }, { k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v }, { note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' }, ], Audio: [ { k: 'volMaster', label: 'Master volume', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'volMusic', label: 'Music', d: 'Generated chiptune that changes with depth.', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'volSfx', label: 'Effects', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'muted', label: 'Mute everything', type: 'toggle' }, { k: 'muteUnfocused', label: 'Mute when the tab is hidden', type: 'toggle' }, ], Controls: [ { k: 'deadzone', label: 'Gamepad stick deadzone', type: 'range', min: 0.05, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' }, { k: 'aimSpeed', label: 'Gamepad aim reach', d: 'How far the right stick reaches from the bot.', type: 'range', min: 0.4, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { html: () => controlsHtml() }, ], Game: [ { k: 'fog', label: 'Fog of war', d: 'Places you have not seen yet are black. Off shows the whole world.', type: 'toggle' }, { k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] }, { k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] }, { k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true }, { k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true }, { k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() }, { k: 'wipe', label: 'Delete all worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete all worlds', danger: true, fn: confirmWipe }, ], }; let setTab = 'Display'; function openSettings(tab) { if (tab) setTab = tab; renderSettings(); openModal('m-settings'); } function renderSettings() { const tabs = $('set-tabs'); tabs.innerHTML = ''; for (const name of Object.keys(SET_TABS)) { const b = el('button', { role: 'tab', class: name === setTab ? 'on' : '', 'aria-selected': name === setTab }, name); b.onclick = () => { playSfx('ui'); setTab = name; renderSettings(); }; tabs.appendChild(b); } const body = $('set-body'); body.innerHTML = ''; for (const it of SET_TABS[setTab]) { if (it.note) { body.appendChild(el('p', { class: 'note' }, esc(it.note))); continue; } if (it.html) { const d = el('div'); d.innerHTML = it.html(); body.appendChild(d); continue; } if (it.ingame && !GAME.running) continue; const row = el('div', { class: 'set' }); row.appendChild(el('div', null, `
${esc(it.label)}
${it.d ? `
${esc(it.d)}
` : ''}`)); const ctl = el('div', { class: 'ctl' }); const id = 'set-' + it.k; if (it.type === 'range') { const inp = el('input', { type: 'range', id, min: it.min, max: it.max, step: it.step, 'aria-label': it.label }); inp.value = SET[it.k]; const v = el('span', { class: 'v' }, esc(it.fmt(SET[it.k]))); inp.oninput = () => { SET[it.k] = parseFloat(inp.value); v.textContent = it.fmt(SET[it.k]); applySettings(); }; ctl.appendChild(inp); ctl.appendChild(v); } else if (it.type === 'toggle') { const t = el('button', { class: 'toggle' + (SET[it.k] ? ' on' : ''), id, role: 'switch', 'aria-checked': !!SET[it.k], 'aria-label': it.label }); t.onclick = () => { playSfx('ui'); SET[it.k] = !SET[it.k]; t.classList.toggle('on', SET[it.k]); t.setAttribute('aria-checked', SET[it.k]); applySettings(); }; ctl.appendChild(t); } else if (it.type === 'select') { const s = el('select', { id, 'aria-label': it.label }); for (const [val, lab] of it.opts) { const o = el('option', { value: String(val) }, esc(lab)); if (String(SET[it.k]) === String(val)) o.selected = true; s.appendChild(o); } s.onchange = () => { const raw = s.value; SET[it.k] = isNaN(parseFloat(raw)) ? raw : parseFloat(raw); applySettings(); }; ctl.appendChild(s); } else if (it.type === 'button') { const b = el('button', { class: 'btn' + (it.danger ? ' danger' : ''), id }, esc(it.text)); b.onclick = () => { playSfx('ui'); it.fn(); }; ctl.appendChild(b); } row.appendChild(ctl); body.appendChild(row); } } function applySettings() { saveSettings(); document.documentElement.style.setProperty('--ui', SET.uiScale); if (R.canvas) { resizeRender(); R.snapNext = true; } auVolumes(); if (!SET.showPerf) $('perf').textContent = ''; } function toggleFullscreen() { try { if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => toast('Fullscreen is not available in this view.')); else document.exitFullscreen(); } catch (e) { toast('Fullscreen is not available in this view.'); } } function confirmWipe() { dialog('Delete all worlds?', '

This removes every world and its autosave from this browser. Exported world files are not affected.

', [ { label: 'Cancel', cls: 'ghost' }, { label: 'Delete all worlds', cls: 'danger', fn: async () => { try { await worldWrite(() => DB.op('readwrite', st => st.clear())); GAME.worldId = ''; GAME.worldName = ''; closeModal('m-dialog'); toast('All worlds deleted.'); await refreshContinue(); } catch (e) { toast('Could not reach browser storage.'); } } }, ]); }