Progress bar and steps while generating, loading, opening claims and travelling; saving chip (t30)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 18:54:06 -04:00
co-authored by Claude Opus 5.5
parent 4d5fca6dae
commit 66543bbd4b
13 changed files with 162 additions and 34 deletions
+7
View File
@@ -252,6 +252,13 @@ above, including:
career and come along; buildings stay on their claim and wait (they don't run) until you career and come along; buildings stay on their claim and wait (they don't run) until you
travel back from the notebook's Claims page. travel back from the notebook's Claims page.
- **Long waits show what's happening (t30, 2026-10-24).** Generating, loading, opening a claim
and travelling show the loading overlay with a title, a moving bar and the current step
("Laying rock and ore", "Unpacking", "Building the world"...). Generation runs as steps that
give the browser a frame every 30 ms, producing exactly the same world as before. A small
"Saving…" chip shows bottom right only when a save takes longer than 150 ms. The owner found big
worlds froze on a 0% bar with no sign of progress.
- **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every - **Clay bricks come from a Brick press (t28, 2026-10-24).** A hopper block like the Saw: every
8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about 8 clay lumps on top become a Green brick below, and a Green brick fires into a Brick above about
900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd 900°C (any fire). 2 Bricks build a brick wall block; builders order them. The owner found it odd
+8 -8
View File
@@ -324,9 +324,9 @@ Weather: clouds from steam, rain and snow, and the world water count.
simTick: one simulation step, in order. simTick: one simulation step, in order.
- functions: simTick - functions: simTick
### src/sim/worldgen.js (326 lines) ### src/sim/worldgen.js (344 lines)
World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks.
- functions: makeNoise, fbm, clearHardSpecks, generateWorld - functions: makeNoise, fbm, clearHardSpecks, generateWorld, generateWorldAsync
- variables: SPECK_MIN - variables: SPECK_MIN
### src/sim/save.js (126 lines) ### src/sim/save.js (126 lines)
@@ -376,19 +376,19 @@ The title screen art.
- functions: buildTitleCells, drawTitle - functions: buildTitleCells, drawTitle
- variables: FONT5, TITLE - variables: FONT5, TITLE
### src/ui/saves.js (212 lines) ### src/ui/saves.js (224 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library. Saves in IndexedDB, world files (export / import) and the blueprint library.
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
- variables: DB, worldIdTime, worldIdCounter, worldWrites - variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
### src/ui/claims.js (141 lines) ### src/ui/claims.js (145 lines)
Shared player state; the world save remains a complete standalone fallback. Shared player state; the world save remains a complete standalone fallback.
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
- variables: career, claimBusy - variables: career, claimBusy
### src/ui/session.js (133 lines) ### src/ui/session.js (157 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu. Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (124 lines) ### src/ui/settings.js (124 lines)
The settings screen. The settings screen.
@@ -442,7 +442,7 @@ Previews drawn under the cursor: build ghosts, drill aim and pulses, selections,
Events from the simulation (emit) turned into sounds, toasts and cards. Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents - functions: drainEvents
### src/ui/loop.js (126 lines) ### src/ui/loop.js (127 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
+13
View File
@@ -4,6 +4,19 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-24: t30: progress while generating, loading and saving (Claude Code, Codex)
- `worldgen.js`: `worldSteps` generator yields `[fraction, label]`; `generateWorld` (sync, tests)
and `generateWorldAsync` (yields every 30 ms) run it. Small and Medium mat/temp/flags hashes
match the old generator exactly.
- `session.js` `showLoading(on, p, label, title)` with `#loadstep`; `GAME.loading` stops the
frame loop's sim and drawing while worlds swap. `loadSavedWorld` for Continue and the world
list; `worldNewest`/`unpackBuffers`/`startFromSave` report progress; travel and new claims too.
`#saving` chip after 150 ms of saving.
- Tests: t_world progress labels and identical worlds; play.py overlay, responsiveness during
async generation, Continue labels, saving chip. All pass except t_smooth half-resolution
(fails under load, as before). Screenshots `shots/t30-loading.png`, `shots/t30-saving.png`.
## 2026-10-24: t3 follow-up: claim names; t30 added (Claude Code, Codex) ## 2026-10-24: t3 follow-up: claim names; t30 added (Claude Code, Codex)
- New claims get a camp name from their seed ("Cinder Gulch"; `claimName` in `src/ui/claims.js`, - New claims get a camp name from their seed ("Cinder Gulch"; `claimName` in `src/ui/claims.js`,
+2 -1
View File
@@ -41,6 +41,7 @@
</div> </div>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<div id="perf"></div> <div id="perf"></div>
<div id="saving" hidden role="status"><i></i>Saving…</div>
<div id="title"> <div id="title">
<div class="titlewrap"> <div class="titlewrap">
@@ -71,7 +72,7 @@
<div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div> <div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div>
</div> </div>
<div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div> <div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadstep" role="status"></span><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div>
<div id="m-pause" class="modal small" hidden> <div id="m-pause" class="modal small" hidden>
<div class="plate"> <div class="plate">
+23 -5
View File
@@ -60,7 +60,8 @@ function clearHardSpecks(live) {
return n; return n;
} }
// ---------- World generation ---------- // ---------- World generation ----------
function generateWorld(seedIn, w, h, onProgress) { function* worldSteps(seedIn, w, h) {
yield [0, 'Shaping the land'];
worldSeed = seedIn >>> 0; worldSeed = seedIn >>> 0;
alloc(w, h); alloc(w, h);
RS = (worldSeed ^ 0x9E3779B9) | 1; RS = (worldSeed ^ 0x9E3779B9) | 1;
@@ -146,6 +147,7 @@ function generateWorld(seedIn, w, h, onProgress) {
for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1; for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1;
} }
computeAmbient(); computeAmbient();
yield [0, 'Laying rock and ore'];
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
const fy = depthU(y); // fraction of the underground depth (negative in the sky) const fy = depthU(y); // fraction of the underground depth (negative in the sky)
for (let x = 0; x < w; x++) { for (let x = 0; x < w; x++) {
@@ -231,9 +233,11 @@ function generateWorld(seedIn, w, h, onProgress) {
temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y]; temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y];
if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255; if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255;
} }
if (onProgress && (y & 63) === 0) onProgress(y / h * 0.7); if ((y & 63) === 0) yield [y / h * 0.7, 'Laying rock and ore'];
} }
yield [0.7, 'Carving caves'];
clearHardSpecks(false); clearHardSpecks(false);
yield [0.71, 'Snow and trees'];
// (tree bases are collected after the trees below) // (tree bases are collected after the trees below)
// snow lies on the high ground wherever the air is below freezing // snow lies on the high ground wherever the air is below freezing
for (let x = 1; x < w - 1; x++) { for (let x = 1; x < w - 1; x++) {
@@ -244,6 +248,7 @@ function generateWorld(seedIn, w, h, onProgress) {
const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1; const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1;
for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } } for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } }
} }
yield [0.72, 'Snow and trees'];
// trees // trees
for (let x = 12; x < w - 12;) { for (let x = 12; x < w - 12;) {
const sy = surfY[x]; const sy = surfY[x];
@@ -267,6 +272,7 @@ function generateWorld(seedIn, w, h, onProgress) {
x += 16 + Math.floor(rng() * 20); x += 16 + Math.floor(rng() * 20);
} else x += 4; } else x += 4;
} }
yield [0.73, 'Springs and water'];
// springs: in cave side walls just above the cave floor // springs: in cave side walls just above the cave floor
{ {
const cands = []; const cands = [];
@@ -296,7 +302,7 @@ function generateWorld(seedIn, w, h, onProgress) {
void picks; void picks;
} }
fillNoise(); fillNoise();
if (onProgress) onProgress(0.75); yield [0.75, 'Settling loose ground'];
// settle loose material // settle loose material
awake.fill(3); awake.fill(3);
for (let k = 0; k < 260; k++) { for (let k = 0; k < 260; k++) {
@@ -304,8 +310,9 @@ function generateWorld(seedIn, w, h, onProgress) {
simMove(); simMove();
let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; } let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; }
if (!any) break; if (!any) break;
if (onProgress && (k & 31) === 0) onProgress(0.75 + 0.25 * k / 260); if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
} }
yield [0.99, 'Finishing'];
// re-ambient after settling, then start clean // re-ambient after settling, then start clean
for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; } for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; }
tick = 0; tick = 0;
@@ -322,5 +329,16 @@ function generateWorld(seedIn, w, h, onProgress) {
stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0; stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0;
EV.length = 0; EV.length = 0;
fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2); fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
if (onProgress) onProgress(1); yield [1, 'Finishing'];
}
function generateWorld(seedIn, w, h, onProgress) {
for (const [f, label] of worldSteps(seedIn, w, h)) if (onProgress) onProgress(f, label);
}
async function generateWorldAsync(seedIn, w, h, onProgress) {
let last = Date.now();
for (const [f, label] of worldSteps(seedIn, w, h)) {
if (onProgress) onProgress(f, label);
if (Date.now() - last >= 30) { await new Promise(r => setTimeout(r, 0)); last = Date.now(); }
}
} }
+7
View File
@@ -369,3 +369,10 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; } #continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; }
.vacchoice { display: flex; align-items: center; gap: 4px; text-align: left; min-width: 0; background: transparent; border: 0; padding: 0; cursor: pointer; } .vacchoice { display: flex; align-items: center; gap: 4px; text-align: left; min-width: 0; background: transparent; border: 0; padding: 0; cursor: pointer; }
#saving { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: 54px; z-index: 10; padding: 5px 9px; border-radius: 12px; background: rgba(11, 9, 8, 0.86); color: var(--ash); font-size: 12px; pointer-events: none; }
#saving i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: saving-pulse 1.5s ease-in-out infinite alternate; }
@keyframes saving-pulse { to { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) { #saving i { animation: none; } }
#loadstep { color: var(--ash); font-size: 13px; }
#hud:has(~ #saving:not([hidden])) .br { padding-bottom: 72px; }
+8 -4
View File
@@ -82,11 +82,13 @@ async function openNewClaim() {
carried.money -= 5000; carried.money -= 5000;
let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed); let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed);
const name = claimName(seed, (await listWorlds()).map(w => w.name)); const name = claimName(seed, (await listWorlds()).map(w => w.name));
await setupNewWorld(seed, w, h, name, carried); await setupNewWorld(seed, w, h, name, carried, 'Opening a new claim');
debt = CLAIM_DEBT * 2 ** (n - 1); debt = CLAIM_DEBT * 2 ** (n - 1);
career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n }; career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n };
GAME.claimNo = n; GAME.claimNo = n;
showLoading(true, 0.9, 'Saving');
if (!await saveWorld('auto', true)) throw new Error('Save failed'); if (!await saveWorld('auto', true)) throw new Error('Save failed');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame(); showLoading(false); closeAllModals(); enterGame();
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 }); showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
return true; return true;
@@ -105,12 +107,14 @@ async function travel(worldId) {
try { try {
const entry = (await listWorlds()).find(w => w.id === worldId); const entry = (await listWorlds()).find(w => w.id === worldId);
if (!entry || entry.career !== GAME.careerId) return false; if (!entry || entry.career !== GAME.careerId) return false;
const progress = (f, s) => showLoading(true, f, s, 'Travelling to ' + entry.name);
progress(0, 'Saving');
if (!await saveWorld('main', true)) throw new Error('Save failed'); if (!await saveWorld('main', true)) throw new Error('Save failed');
const sv = await worldNewest(worldId); const sv = await worldNewest(worldId, progress);
if (!sv || !await startFromSave(sv)) return false; if (!sv || !await startFromSave(sv, progress)) return false;
closeAllModals(); arrived = true; return true; closeAllModals(); arrived = true; return true;
} catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; } } catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; }
finally { claimBusy = false; if (!arrived) GAME.paused = paused; } finally { showLoading(false); claimBusy = false; if (!arrived) GAME.paused = paused; }
} }
async function renderClaimsTab(body) { async function renderClaimsTab(body) {
body.innerHTML = '<p class=note>Loading claims…</p>'; body.innerHTML = '<p class=note>Loading claims…</p>';
+3 -2
View File
@@ -16,6 +16,7 @@ function frame(now) {
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now; const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return; if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now; const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
if (GAME.loading) { acc = 0; return; }
if (!GAME.running) { pollPad(); return; } if (!GAME.running) { pollPad(); return; }
const t0 = performance.now(); const t0 = performance.now();
const pad = pollPad(); const pad = pollPad();
@@ -110,7 +111,7 @@ function boot(hotData) {
const [w, h] = $('nw-size').value.split('x').map(Number); const [w, h] = $('nw-size').value.split('x').map(Number);
startNewWorld(seed, w, h, $('nw-name').value); startNewWorld(seed, w, h, $('nw-name').value);
}; };
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) await startFromSave(sv); else toast('No saved world found.'); }; $('btn-continue').onclick = async () => { auInit(); playSfx('ui'); await loadSavedWorld(); };
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); }; $('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); }; $('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches; const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
@@ -118,7 +119,7 @@ function boot(hotData) {
$('title').hidden = true; $('title').hidden = true;
migrateWorlds().then(() => refreshContinue()).then(async () => { migrateWorlds().then(() => refreshContinue()).then(async () => {
$('title').hidden = false; if (!TITLE.raf) drawTitle(); $('title').hidden = false; if (!TITLE.raf) drawTitle();
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) await startFromSave(sv); } if (hotData && hotData.resume) { await loadSavedWorld(); }
}).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); }); }).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
+19 -7
View File
@@ -40,12 +40,13 @@ function putWorldEntry(entry) {
await DB.put('worlds', list); await DB.put('worlds', list);
}); });
} }
async function worldNewest(id) { async function worldNewest(id, onProgress) {
if (onProgress) onProgress(0.05, 'Reading the save');
const a = await DB.get(`${id}:main`), b = await DB.get(`${id}:auto`); 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; const sv = a && b ? (a.savedAt >= b.savedAt ? a : b) : a || b;
if (!sv) return null; if (!sv) return null;
const entry = (await listWorlds()).find(w => w.id === id); const entry = (await listWorlds()).find(w => w.id === id);
return { ...sv, buffers: await unpackBuffers(sv.buffers), worldId: id, worldName: entry ? entry.name : 'World' }; return { ...sv, buffers: await unpackBuffers(sv.buffers, onProgress), worldId: id, worldName: entry ? entry.name : 'World' };
} }
function deleteWorld(id) { function deleteWorld(id) {
return worldWrite(async () => { return worldWrite(async () => {
@@ -98,7 +99,10 @@ async function migrateWorlds() {
}); });
} catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; } } catch (e) { toast('Could not move the old save. It has been kept. Reload to try again.'); throw e; }
} }
let savingCount = 0;
async function saveWorld(slot, quiet) { async function saveWorld(slot, quiet) {
savingCount++;
const indicator = setTimeout(() => { if (!GAME.loading) $('saving').hidden = false; }, 150);
try { try {
const s = serializeWorld(), 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}`; } if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
@@ -116,10 +120,10 @@ async function saveWorld(slot, quiet) {
} catch (e) { } catch (e) {
if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.'); if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.');
return false; return false;
} finally { clearTimeout(indicator); if (!--savingCount) $('saving').hidden = true; }
} }
} async function newestSave(onProgress) {
async function newestSave() { try { for (const entry of await listWorlds()) { const sv = await worldNewest(entry.id, onProgress); if (sv) return sv; } 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; } 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 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); }
@@ -137,9 +141,11 @@ async function packBuffers(buffers) {
} }
return packed; return packed;
} }
async function unpackBuffers(buffers) { async function unpackBuffers(buffers, onProgress) {
let n = 0; const total = Object.keys(buffers).length;
const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array }; const raw = {}, types = { Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array };
for (const k in buffers) { for (const k in buffers) {
if (onProgress) onProgress(0.1 + 0.6 * n++ / total, 'Unpacking');
const b = buffers[k]; const b = buffers[k];
if (ArrayBuffer.isView(b)) { raw[k] = b; continue; } if (ArrayBuffer.isView(b)) { raw[k] = b; continue; }
if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k); if (!b || b.gz !== true) throw new Error('Invalid saved buffer: ' + k);
@@ -148,6 +154,7 @@ async function unpackBuffers(buffers) {
if (!bytes) throw new Error('This browser cannot decompress saved worlds.'); if (!bytes) throw new Error('This browser cannot decompress saved worlds.');
raw[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); raw[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
} }
if (onProgress) onProgress(0.7, 'Unpacking');
return raw; return raw;
} }
async function exportWorld() { async function exportWorld() {
@@ -177,22 +184,27 @@ async function exportWorld() {
catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); } catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); }
} }
async function importWorldFile(file) { async function importWorldFile(file) {
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
progress(0.05, 'Reading the save');
try { try {
const obj = JSON.parse(await file.text()); const obj = JSON.parse(await file.text());
if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.'); if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.');
const bufs = {}; const bufs = {};
const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array }; const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array };
let n = 0; const total = Object.keys(obj.buffers).length;
for (const k in obj.buffers) { for (const k in obj.buffers) {
progress(0.1 + 0.6 * n++ / total, 'Unpacking');
const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64); const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64);
if (b.gz) bytes = await gz(bytes, true); if (b.gz) bytes = await gz(bytes, true);
const C = T[b.type] || Uint8Array; const C = T[b.type] || Uint8Array;
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT); bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
} }
const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40); const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return; if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name }, progress)) return;
if (!await saveWorld('auto')) return; if (!await saveWorld('auto')) return;
toast('World imported.'); toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); } } catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
finally { showLoading(false); }
} }
// ---------- Blueprint library ---------- // ---------- Blueprint library ----------
+30 -6
View File
@@ -1,6 +1,12 @@
// Starting, loading and quitting a game; modal dialogs; the pause menu. // Starting, loading and quitting a game; modal dialogs; the pause menu.
// ---------- Start / stop ---------- // ---------- Start / stop ----------
function showLoading(on, p) { function showLoading(on, p, label, title) {
GAME.loading = on;
if (on) {
$('saving').hidden = true;
if (title) $('loading').querySelector('.label').textContent = title;
$('loadstep').textContent = label || '';
}
$('loading').hidden = !on; $('loading').hidden = !on;
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; } if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
} }
@@ -15,11 +21,11 @@ function enterGame() {
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar(); hudDirty = true; renderHotbar();
} }
async function setupNewWorld(seed, w, h, name, carried) { async function setupNewWorld(seed, w, h, name, carried, title = 'Generating world') {
auInit(); showLoading(true, 0); auInit(); showLoading(true, 0, 'Shaping the land', title);
await new Promise(resolve => setTimeout(resolve, 30)); await new Promise(resolve => setTimeout(resolve, 30));
for (const k of Object.keys(stats)) stats[k] = 0; for (const k of Object.keys(stats)) stats[k] = 0;
generateWorld(seed, w, h, null); await generateWorldAsync(seed, w, h, (f, s) => showLoading(true, f * 0.9, s, title));
GAME.worldId = newWorldId(); GAME.worldName = name; GAME.worldId = newWorldId(); GAME.worldName = name;
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
if (carried) applyCareerState(carried); if (carried) applyCareerState(carried);
@@ -30,30 +36,48 @@ async function startNewWorld(seed, w, h, name = '') {
await setupNewWorld(seed, w, h, cleanWorldName(name, `World ${worlds.length + 1}`)); await setupNewWorld(seed, w, h, cleanWorldName(name, `World ${worlds.length + 1}`));
career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 }; career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 };
GAME.careerId = career.id; GAME.claimNo = 1; GAME.careerId = career.id; GAME.claimNo = 1;
showLoading(true, 0.9, 'Saving');
await saveWorld('auto'); await saveWorld('auto');
showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame(); showLoading(false); closeAllModals(); enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', '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 }); showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', '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 });
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
} }
async function startFromSave(sv) { async function startFromSave(sv, onProgress) {
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
auInit(); auInit();
GAME.paused = true; GAME.paused = true;
try { try {
for (const k of Object.keys(stats)) stats[k] = 0; for (const k of Object.keys(stats)) stats[k] = 0;
onProgress(0.75, 'Building the world');
await new Promise(r => setTimeout(r, 30));
deserializeWorld(sv.meta, sv.buffers); deserializeWorld(sv.meta, sv.buffers);
} }
catch (e) { catch (e) {
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' }); toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
showLoading(false);
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle(); GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
return false; return false;
} }
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || ''; GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0; GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.blow.clear(); GAME.drag = null; GAME.blow.clear();
onProgress(0.95, 'Your gear and notebook');
await loadCareer(); await loadCareer();
showLoading(false);
enterGame(); enterGame();
return true; return true;
} }
async function loadSavedWorld(id) {
const progress = (f, s) => showLoading(true, f, s, 'Loading world');
progress(0, 'Reading the save');
try {
const sv = id ? await worldNewest(id, progress) : await newestSave(progress);
if (sv) return await startFromSave(sv, progress);
toast('No saved world found.'); return false;
} catch (e) { toast('Could not reach browser storage.'); return false; }
finally { showLoading(false); }
}
async function quitToTitle() { async function quitToTitle() {
await saveWorld('main', true); await saveWorld('main', true);
GAME.running = false; closeAllModals(); GAME.running = false; closeAllModals();
@@ -119,7 +143,7 @@ async function renderWorlds(editId) {
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); }); button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
button('Cancel', () => renderWorlds()); button('Cancel', () => renderWorlds());
} else { } else {
button('Load', async () => { const sv = await worldNewest(w.id); if (sv && await startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); }); button('Load', async () => { const ok = await loadSavedWorld(w.id); if (ok) closeAllModals(); });
button('Rename', () => renderWorlds(w.id)); 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>`, [ 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: 'Cancel', cls: 'ghost' },
+1 -1
View File
@@ -38,7 +38,7 @@ const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN
SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS]; SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS];
const GAME = { const GAME = {
ctrl: false, ctrl: false,
running: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {}, running: false, loading: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {},
brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 }, brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, 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 }, 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, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 }, lmb: false, rmb: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
+32
View File
@@ -26,6 +26,24 @@ with sync_playwright() as p:
catch (e) { return await DB.get('abort-test') === undefined; } catch (e) { return await DB.get('abort-test') === undefined; }
return false; return false;
}""")) }"""))
check('async generation preserves the world and yields frames with labels', pg.evaluate("""async () => {
const hash = a => { let h = 2166136261; for (const v of a) h = Math.imul(h ^ v, 16777619); return h; };
generateWorld(424242, 2048, 1024); const want = hash(mat);
let frames = 0, active = true, labels = [];
const frameCount = () => { if (active) { frames++; requestAnimationFrame(frameCount); } };
requestAnimationFrame(frameCount);
showLoading(true, 0, '', 'Generating world');
try {
await generateWorldAsync(424242, 2048, 1024, (f, s) => { showLoading(true, f, s); labels.push($('loadstep').textContent); });
return hash(mat) === want && frames > 1 && labels.includes('Settling loose ground') && labels.every(Boolean);
} finally { active = false; showLoading(false); }
}"""))
# Record progress at the browser's own frame boundaries, without slowing the operation.
pg.add_init_script("""
window.__loads = [];
const watch = () => { if (typeof GAME !== 'undefined' && GAME.loading) __loads.push([$('loading').querySelector('.label').textContent, $('loadstep').textContent, $('loadbar').style.width]); requestAnimationFrame(watch); };
requestAnimationFrame(watch);
""")
# Migrate a real legacy save before starting the play-through. # Migrate a real legacy save before starting the play-through.
pg.evaluate("generateWorld(12345, 512, 384, null)") pg.evaluate("generateWorld(12345, 512, 384, null)")
pg.evaluate("async () => { const s = serializeWorld(); await DB.put('main', { ...s, savedAt: Date.now(), playTime: 3661 }); }") pg.evaluate("async () => { const s = serializeWorld(); await DB.put('main', { ...s, savedAt: Date.now(), playTime: 3661 }); }")
@@ -39,7 +57,18 @@ with sync_playwright() as p:
pg.reload(); pg.wait_for_function("!$('title').hidden && !$('btn-continue').hidden") pg.reload(); pg.wait_for_function("!$('title').hidden && !$('btn-continue').hidden")
check('legacy save becomes World 1 and retires its old key', pg.evaluate("async () => { const w = await listWorlds(); return w.length === 1 && w[0].name === 'World 1' && await DB.get('main') === undefined; }")) check('legacy save becomes World 1 and retires its old key', pg.evaluate("async () => { const w = await listWorlds(); return w.length === 1 && w[0].name === 'World 1' && await DB.get('main') === undefined; }"))
pg.click('#btn-continue'); pg.wait_for_function('GAME.running') pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue paints loading steps', pg.evaluate("__loads.some(([t, s]) => t === 'Loading world' && s === 'Building the world') && !GAME.loading && $('loading').hidden"))
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId')) check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate("""() => {
window.__pack = packBuffers;
packBuffers = async b => { await new Promise(r => setTimeout(r, 600)); return __pack(b); };
window.__saving = saveWorld('main', true);
}""")
pg.locator('#saving').wait_for(state='visible')
pg.screenshot(path=str(out / 'saving-progress.png'))
check('slow save shows the saving chip', pg.is_visible('#saving'))
pg.evaluate('async () => { await __saving; packBuffers = __pack; }')
check('saving chip hides when the save ends', not pg.is_visible('#saving'))
pg.evaluate('quitToTitle()') pg.evaluate('quitToTitle()')
names = {} names = {}
for seed in [222, 333]: for seed in [222, 333]:
@@ -53,6 +82,7 @@ with sync_playwright() as p:
pg.screenshot(path=str(out / 'new-world.png')) pg.screenshot(path=str(out / 'new-world.png'))
pg.fill('#nw-seed', str(seed)); pg.press('#nw-name' if seed == 222 else '#nw-seed', 'Enter') pg.fill('#nw-seed', str(seed)); pg.press('#nw-name' if seed == 222 else '#nw-seed', 'Enter')
pg.wait_for_function('GAME.running', timeout=20000) pg.wait_for_function('GAME.running', timeout=20000)
check(f'new world {seed} paints a moving generation bar', pg.evaluate("__loads.some(([t, s, w]) => t === 'Generating world' && parseFloat(w) > 0 && parseFloat(w) < 90) && !GAME.loading"))
check(f'new world {seed} shows the Welcome card', pg.evaluate("[...$('cards').children].some(c => c.textContent.includes('Welcome'))")) check(f'new world {seed} shows the Welcome card', pg.evaluate("[...$('cards').children].some(c => c.textContent.includes('Welcome'))"))
check(f'pause menu shows world {seed} name', names[seed] in pg.evaluate("() => { openPause(); return $('pause-meta').textContent; }")) check(f'pause menu shows world {seed} name', names[seed] in pg.evaluate("() => { openPause(); return $('pause-meta').textContent; }"))
pg.evaluate('quitToTitle()') pg.evaluate('quitToTitle()')
@@ -1058,6 +1088,8 @@ with sync_playwright() as p:
check('the size menu offers small, medium, large and huge', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536,6144x2048', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')")) check('the size menu offers small, medium, large and huge', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536,6144x2048', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')"))
check('Medium remains the default size', pg2.input_value('#nw-size') == '3072x1280') check('Medium remains the default size', pg2.input_value('#nw-size') == '3072x1280')
pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '6144x2048'); pg2.click('#btn-generate') pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '6144x2048'); pg2.click('#btn-generate')
pg2.wait_for_function("GAME.loading && parseFloat($('loadbar').style.width) > 10 && parseFloat($('loadbar').style.width) < 85", timeout=30000)
pg2.screenshot(path=str(out / 'generation-progress.png'))
pg2.wait_for_function('GAME.running === true', timeout=90000) pg2.wait_for_function('GAME.running === true', timeout=90000)
check('huge world generates', pg2.evaluate('W === 6144 && H === 2048'), pg2.evaluate('[W, H]')) check('huge world generates', pg2.evaluate('W === 6144 && H === 2048'), pg2.evaluate('[W, H]'))
pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800) pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800)
+9
View File
@@ -6,6 +6,15 @@ const { check, done } = suite();
const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); }; const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); };
const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m); const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m);
const { createHash } = require('node:crypto');
const worldHash = () => { const hash = createHash('sha256'); for (const k of ['mat', 'temp', 'flags']) { const a = run(k); hash.update(new Uint8Array(a.buffer, a.byteOffset, a.byteLength)); } return hash.digest('hex'); };
run('generateWorld(4242, 512, 384)');
const generatedHash = worldHash();
const progress = [];
run('generateWorld')(4242, 512, 384, (f, label) => progress.push([f, label]));
check('generation progress rises to 1 with named steps', progress.length > 10 && progress[0][0] === 0 && progress.at(-1)[0] === 1 && progress.every(([f, label], i) => f >= 0 && f <= 1 && (!i || f >= progress[i - 1][0]) && typeof label === 'string' && label.length > 0));
check('generation callback keeps the same world', worldHash() === generatedHash);
// 1. acorns sprout on soil under open sky and grow into a tree // 1. acorns sprout on soil under open sky and grow into a tree
fresh(); fresh();
box(100, 100, 140, 103, run('DIRT')); box(100, 100, 140, 103, run('DIRT'));