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
This commit is contained in:
2026-10-23 16:05:31 -04:00
co-authored by Claude Opus 5.5
commit b270c4e818
97 changed files with 11710 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
// 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(); } },
]);
}