Several worlds: a Worlds list with load, rename and delete; old save migrates to World 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 00:20:18 -04:00
co-authored by Claude Opus 5.5
parent c434faefc4
commit aadcbc6efb
11 changed files with 274 additions and 35 deletions
+8
View File
@@ -47,6 +47,7 @@
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
<button class="btn" id="btn-new">New world <small>generate</small></button>
<div class="plate newworld" id="newworld" hidden>
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
@@ -101,6 +102,13 @@
</div>
</div>
<div id="m-worlds" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>Worlds</h2><button class="iconbtn" data-close aria-label="Close worlds">Close <kbd>Esc</kbd></button></div>
<div class="mbody" id="worlds-list"></div>
</div>
</div>
<div id="m-dialog" class="modal small" hidden>
<div class="plate">
<div class="mhead"><h2 id="dlg-title"></h2></div>
+10
View File
@@ -347,3 +347,13 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; }
@media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } }
/* saved worlds */
.world-row { padding: 12px 0; border-bottom: 1px solid var(--soot-3); }
.world-row:last-child { border-bottom: none; }
.world-info { overflow-wrap: anywhere; }
.world-info .note { margin: 4px 0; }
.world-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.world-actions input { flex: 1; min-width: 120px; max-width: 100%; }
#m-dialog { z-index: 21; }
#continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; }
+4 -2
View File
@@ -108,9 +108,11 @@ function boot(hotData) {
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
$('touchnote').hidden = !touchOnly;
refreshContinue().then(async () => {
$('title').hidden = true;
migrateWorlds().then(() => refreshContinue()).then(async () => {
$('title').hidden = false; if (!TITLE.raf) drawTitle();
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); }
});
}).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
requestAnimationFrame(frame);
}
try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ }
+80 -9
View File
@@ -15,16 +15,88 @@ const DB = {
},
async op(mode, fn) {
const db = await this.open();
return new Promise((res, rej) => { const tx = db.transaction('saves', mode); const st = tx.objectStore('saves'); const rq = fn(st); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); });
return new Promise((res, rej) => {
const tx = db.transaction('saves', mode); let rq;
tx.oncomplete = () => res(rq && rq.result);
tx.onabort = tx.onerror = () => rej(tx.error || new Error('Storage transaction failed.'));
try { rq = fn(tx.objectStore('saves')); } catch (e) { tx.abort(); rej(e); }
});
},
put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
get(k) { return this.op('readonly', s => s.get(k)); },
del(k) { return this.op('readwrite', s => s.delete(k)); },
};
let worldIdTime = 0, worldIdCounter = 0, worldWrites = Promise.resolve();
function newWorldId() {
const now = Date.now(); worldIdCounter = now === worldIdTime ? worldIdCounter + 1 : 0; worldIdTime = now;
return 'w' + now + (worldIdCounter ? '-' + worldIdCounter : '');
}
function worldWrite(fn) { const p = worldWrites.then(fn); worldWrites = p.catch(() => {}); return p; }
async function listWorlds() { return (await DB.get('worlds') || []).sort((a, b) => b.savedAt - a.savedAt); }
function putWorldEntry(entry) {
return worldWrite(async () => {
const list = await listWorlds(), i = list.findIndex(w => w.id === entry.id);
if (i < 0) list.push(entry); else list[i] = { ...list[i], ...entry };
await DB.put('worlds', list);
});
}
async function worldNewest(id) {
const a = await DB.get(`${id}:main`), b = await DB.get(`${id}:auto`);
const sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return null;
const entry = (await listWorlds()).find(w => w.id === id);
return { ...sv, worldId: id, worldName: entry ? entry.name : 'World' };
}
function deleteWorld(id) {
return worldWrite(async () => {
const list = (await listWorlds()).filter(w => w.id !== id);
await DB.op('readwrite', st => { st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds'); });
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
});
}
function renameWorld(id, name) {
return worldWrite(async () => {
const list = await listWorlds(), entry = list.find(w => w.id === id);
if (!entry) return;
entry.name = String(name).trim().slice(0, 40) || entry.name;
await DB.put('worlds', list);
if (GAME.worldId === id) GAME.worldName = entry.name;
});
}
async function migrateWorlds() {
try {
if (await DB.get('worlds') !== undefined) return;
const a = await DB.get('main'), b = await DB.get('auto'), sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return;
const id = newWorldId();
await DB.op('readwrite', st => { if (a) st.put(a, `${id}:main`); if (b) st.put(b, `${id}:auto`); });
// Verify every byte before retiring the only old copies.
for (const [slot, old] of [['main', a], ['auto', b]]) if (old) {
const copy = await DB.get(`${id}:${slot}`);
if (!copy || JSON.stringify(copy.meta) !== JSON.stringify(old.meta) || copy.savedAt !== old.savedAt || copy.playTime !== old.playTime) throw new Error('Save copy failed.');
for (const k in old.buffers) {
const x = old.buffers[k], y = copy.buffers[k];
if (!y || x.constructor !== y.constructor || x.length !== y.length || !x.every((v, i) => v === y[i])) throw new Error('Save copy failed.');
}
}
await DB.op('readwrite', st => {
st.put([{ id, name: 'World 1', seed: sv.meta.seed, w: sv.meta.W, h: sv.meta.H, createdAt: sv.savedAt, savedAt: sv.savedAt, playTime: sv.playTime || 0 }], 'worlds');
st.delete('main'); st.delete('auto');
});
} catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; }
}
async function saveWorld(slot, quiet) {
try {
const s = serializeWorld();
await DB.put(slot, { meta: s.meta, buffers: s.buffers, savedAt: Date.now(), playTime: GAME.playTime });
const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
const id = GAME.worldId, name = GAME.worldName;
await worldWrite(async () => {
const list = await listWorlds();
let entry = list.find(w => w.id === id);
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); }
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H });
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers: s.buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); });
});
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true;
} catch (e) {
@@ -33,11 +105,8 @@ async function saveWorld(slot, quiet) {
}
}
async function newestSave() {
try {
const a = await DB.get('main'), b = await DB.get('auto');
if (a && b) return a.savedAt >= b.savedAt ? a : b;
return a || b || null;
} catch (e) { return null; }
try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id); if (sv) return sv; } return null; }
catch (e) { return null; }
}
function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); }
function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; }
@@ -84,7 +153,9 @@ async function importWorldFile(file) {
const C = T[b.type] || Uint8Array;
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
}
startFromSave({ meta: obj.meta, buffers: bufs });
const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
if (!startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return;
if (!await saveWorld('auto')) return;
toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
}
+47 -12
View File
@@ -15,13 +15,18 @@ function enterGame() {
function startNewWorld(seed, w, h) {
auInit();
showLoading(true, 0);
setTimeout(() => {
generateWorld(seed, w, h, null);
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
showLoading(false);
enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
saveWorld('auto', true);
setTimeout(async () => {
try {
const worlds = await listWorlds();
GAME.worldId = newWorldId(); GAME.worldName = `World ${worlds.length + 1}`;
await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 });
generateWorld(seed, w, h, null);
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
showLoading(false);
enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
await saveWorld('auto');
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
}, 30);
}
function startFromSave(sv) {
@@ -32,6 +37,7 @@ function startFromSave(sv) {
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
return false;
}
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0;
enterGame();
@@ -46,20 +52,21 @@ async function quitToTitle() {
}
async function refreshContinue() {
const sv = await newestSave();
$('btn-worlds').hidden = !(await listWorlds()).length;
const b = $('btn-continue');
if (sv && sv.meta && sv.meta.v === SAVE_VERSION) {
b.hidden = false;
const d = new Date(sv.savedAt);
$('continue-meta').textContent = `seed ${sv.meta.seed} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
$('continue-meta').textContent = `${sv.worldName} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
} else b.hidden = true;
}
// ---------- Modals ----------
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); }
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].some(id => !$(id).hidden); }
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-worlds', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function dialog(title, bodyHtml, buttons) {
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
const foot = $('dlg-foot'); foot.innerHTML = '';
@@ -82,6 +89,34 @@ $('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
function openPause() {
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`;
$('pause-meta').textContent = `${GAME.worldName} · seed ${worldSeed} · ${W}×${H}`;
openModal('m-pause');
}
// ---------- World list ----------
async function renderWorlds(editId) {
const list = await listWorlds(), body = $('worlds-list'); body.innerHTML = '';
if (!list.length) body.innerHTML = '<p class="note">No worlds saved yet. Start a new world.</p>';
for (const w of list) {
const row = el('div', { class: 'world-row', 'data-world': w.id });
const mins = Math.floor((w.playTime || 0) / 60);
row.innerHTML = `<div class="world-info"><strong>${esc(w.name)}</strong><div class="note">seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played</div><div class="note">${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}</div></div>`;
const actions = el('div', { class: 'world-actions' });
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
if (editId === w.id) {
const input = el('input', { type: 'text', maxlength: 40, 'aria-label': 'World name' }); input.value = w.name; actions.appendChild(input);
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
button('Cancel', () => renderWorlds());
} else {
button('Load', async () => { const sv = await worldNewest(w.id); if (sv && startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); });
button('Rename', () => renderWorlds(w.id));
button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Delete', cls: 'danger', fn: async () => { try { await deleteWorld(w.id); closeModal('m-dialog'); await renderWorlds(); await refreshContinue(); } catch (e) { toast('Could not delete the world.'); } } },
]), 'danger');
}
row.appendChild(actions); body.appendChild(row);
}
if (editId) { const input = body.querySelector('input'); if (input) { input.focus(); input.select(); } }
}
$('btn-worlds').onclick = async () => { try { await renderWorlds(); openModal('m-worlds'); } catch (e) { toast('Could not reach browser storage.'); } };
+9 -3
View File
@@ -49,7 +49,7 @@ const SET_TABS = {
{ 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 },
{ 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';
@@ -109,8 +109,14 @@ function toggleFullscreen() {
} 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>', [
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 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(); } },
{ 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.'); }
} },
]);
}
+1 -1
View File
@@ -42,7 +42,7 @@ const GAME = {
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
lastAutosave: 0, playTime: 0, lastHud: 0, worldName: '',
lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '',
};
const keys = new Set();
let hudDirty = true;