Files
deepgrain-foundry/src/ui/settings.js
T

125 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, `<div>${esc(it.label)}</div>${it.d ? `<div class="d">${esc(it.d)}</div>` : ''}`));
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?', '<p class="note">This removes every world and its autosave from this browser. Exported world files are not affected.</p>', [
{ 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.'); }
} },
]);
}