Files
deepgrain-foundry/src/ui/settings.js
T
44r0n7andClaude Opus 5.5 b270c4e818 Deepgrain Foundry: move from a claude.ai artifact to a local repository
The game as of artifact version 24, ready for local development with
coding agents.

- Source split from three large files into 52 small ones under src/sim,
  src/render and src/ui, joined in the order in src/manifest.json by
  build.py. The joined code is unchanged apart from comments; every
  headless suite gives the same output, and screenshots in both looks
  are pixel-identical to before.
- Light tidying with no change in behaviour: one movement scan loop,
  shared hazard-stripe and belt shader helpers, unused code removed, a
  shared check helper for the test suites.
- World files can be exported as an ordinary download outside claude.ai.
- Tests: broken old scripts removed, experiments moved to tools/,
  test/run_all.py added. Tools: shot.py (repeatable screenshots),
  where.py, map.py.
- Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox),
  docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the
  owner's requests and an agent log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
2026-10-23 16:05:31 -04:00

117 lines
9.2 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: 'Shows what you are pointing at right beside the cursor.', 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: '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: '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: '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 saved worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete…', 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() {
// choosing Smooth again after it was too slow: try once more, at half resolution
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
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 saved worlds?', '<p class="note">This removes the main save and the autosave from this browser. Exported world files are not affected.</p>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } },
]);
}