Refactor: merge duplicated rules, split the largest files by topic (t21)

- One helper each for wall and mold refunds, tube edges and lanes, pipe text, piece dimensions,
  font lookup, reach circles and surface averaging.
- previews, smooth, notebook, materials, worldgen and selection split into topic files
  (preview-drill, preview-selection, smooth-shaders, notebook-market, notebook-blueprints,
  material-tables, worldgen-helpers, selection-pickers, selection-panels).
- Every suite's output identical apart from timings; seeds make the same worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:35:12 -04:00
co-authored by Claude Opus 5.5
parent 9937683a2c
commit 7fa1056e09
28 changed files with 1036 additions and 953 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ function primaryPress(shift) {
const P = piece();
if (P.kind === 'block') { GAME.drag = aimBlock(); }
else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh);
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by);
+4 -2
View File
@@ -114,13 +114,13 @@ function hoverInfo(ax, ay) {
} else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
t2.push(pipeContentsText(pn));
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
else if (m === PIPE && isPipeCode(b)) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
t2.push(pipeContentsText(pn));
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
}
@@ -184,3 +184,5 @@ function drawCursorLabel(px, py, r) {
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore();
}
function pipeContentsText(pn) { return pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`; }
+47
View File
@@ -0,0 +1,47 @@
// Notebook blueprints: starter designs, saved library and code sharing.
function renderBlueprintTab(body) {
const lib = libLoad();
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
STARTER_DESIGNS.forEach((d, k) => {
const bp = d.bp();
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
});
h += '</div><p class="label grouphead">Your blueprints</p>';
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
h += `<p class="label grouphead">Library</p>`;
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
else {
h += '<div class="cards">';
lib.forEach((it, k) => {
const ok = bpValid(it.bp);
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
});
h += '</div>';
}
body.innerHTML = h;
const sv = $('bp-save'); if (sv) sv.onclick = () => {
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
]);
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
};
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
$('bp-import').onclick = () => {
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
]);
};
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
}
function copyCode(code) {
const done = () => toast('Blueprint code copied.');
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
}
+68
View File
@@ -0,0 +1,68 @@
// Notebook market: prices, orders, claim payments and wallet.
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
function orderLeft(o) { return Math.max(0, o.due - tick); }
const MARKET_GROUPS = [
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]],
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
];
function renderMarketTab(body) {
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.</p>`;
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
if (orders.length) {
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
let rows = '', unknown = 0;
for (const [g, list] of MARKET_GROUPS) {
const known = list.filter(m => seen[m]);
unknown += list.length - known.length;
if (!known.length) continue;
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
for (const m of known) {
const f = priceFactor(m), now = priceNow(m);
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
}
}
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
h += `<p class="label grouphead">Sales, minute by minute</p>`;
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
body.innerHTML = h;
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
const paid = payClaim(v);
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
renderNotebook(); hudDirty = true;
});
}
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
function itemsText(items) {
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
if (e.length > 3) parts.push(`${e.length - 3} more`);
return parts.join(', ');
}
function renderWallet() {
$('w-money').textContent = fmtMoney(money);
const c = $('w-claim');
c.className = 'line' + (debt <= 0 ? ' paid' : '');
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
const h = orders.length ? orders.map(o => {
const left = orderLeft(o);
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
}).join('') : '<span class="none">No orders yet</span>';
const box = $('w-orders');
if (box.innerHTML !== h) box.innerHTML = h;
}
+1 -114
View File
@@ -1,4 +1,4 @@
// The notebook (N): milestones, market, wallet, blueprints, controls.
// Notebook: tab navigation, ledger, materials, pieces and controls.
// ---------- Notebook ----------
let nbTab = 'Ledger';
function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
@@ -55,119 +55,6 @@ function renderNotebook() {
body.innerHTML = controlsHtml();
}
}
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
function orderLeft(o) { return Math.max(0, o.due - tick); }
const MARKET_GROUPS = [
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
['Bulk (builders)', [RSTONE, GRAVEL, BARSB, PLANK, GREENB, BARBR]],
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
];
function renderMarketTab(body) {
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, ash and slag goes for nothing. Rock you dig still sells, builders order it by the bagful, and a Grinder finds ore in it. Build walls around the dock to funnel more in.</p>`;
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
if (orders.length) {
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
let rows = '', unknown = 0;
for (const [g, list] of MARKET_GROUPS) {
const known = list.filter(m => seen[m]);
unknown += list.length - known.length;
if (!known.length) continue;
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
for (const m of known) {
const f = priceFactor(m), now = priceNow(m);
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
}
}
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
h += `<p class="label grouphead">Sales, minute by minute</p>`;
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
body.innerHTML = h;
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
const paid = payClaim(v);
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
renderNotebook(); hudDirty = true;
});
}
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
function itemsText(items) {
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
if (e.length > 3) parts.push(`${e.length - 3} more`);
return parts.join(', ');
}
function renderWallet() {
$('w-money').textContent = fmtMoney(money);
const c = $('w-claim');
c.className = 'line' + (debt <= 0 ? ' paid' : '');
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
const h = orders.length ? orders.map(o => {
const left = orderLeft(o);
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
}).join('') : '<span class="none">No orders yet</span>';
const box = $('w-orders');
if (box.innerHTML !== h) box.innerHTML = h;
}
function renderBlueprintTab(body) {
const lib = libLoad();
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
STARTER_DESIGNS.forEach((d, k) => {
const bp = d.bp();
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
});
h += '</div><p class="label grouphead">Your blueprints</p>';
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
h += `<p class="label grouphead">Library</p>`;
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
else {
h += '<div class="cards">';
lib.forEach((it, k) => {
const ok = bpValid(it.bp);
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
});
h += '</div>';
}
body.innerHTML = h;
const sv = $('bp-save'); if (sv) sv.onclick = () => {
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
]);
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
};
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
$('bp-import').onclick = () => {
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
]);
};
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
}
function copyCode(code) {
const done = () => toast('Blueprint code copied.');
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
}
function controlsHtml() {
const kb = [
['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
+104
View File
@@ -0,0 +1,104 @@
// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan.
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
function crackSeg(ctx, x, y, n, sc, zp, oy) {
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
const pts = [];
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
for (let k = 0; k + 3 < pts.length; k += 4) {
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
}
}
function drawCracks() {
const ctx = R.ctx, zp = R.zp;
if (zp < 2.5) return;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
const cells = [];
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
if (!cells.length) return;
ctx.save(); ctx.lineCap = 'round';
for (let l = 0; l < CRACK_LV.length; l++) {
const L = CRACK_LV[l];
// a soft lit lip just below each crack, then the dark crack itself
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
}
}
ctx.restore();
}
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
function pulseRGB(tune) {
if (!tune) return '150,215,255';
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
}
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
function fanPoints(ox, oy, ang, Wd, sd, n) {
const pts = [];
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
return pts;
}
function drawDrillPulses() {
if (!drillPulses.length) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const q of drillPulses) {
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
if (rr < 3) continue;
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
// a faint band behind the front, then the bright front itself
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
}
ctx.restore();
}
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
// out to where it stops, and a crosshair
function drawDrillAim(ax, ay) {
const ctx = R.ctx, zp = R.zp;
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
ctx.save();
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
// the emitter glows across the bot's face while it fires
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
if (GAME.lmb && hot > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
const k = zp * 0.9;
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore();
}
+80
View File
@@ -0,0 +1,80 @@
// Selection previews: labelled tags, copy and cut boxes, and paste ghosts.
// a small labelled tag drawn on the world (screen pixels)
function drawTag(text, col, x, y, sub) {
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
renderMonoFont();
ctx.save();
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const tw = ctx.measureText(text).width;
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
ctx.restore();
}
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
function drawSelection(mode, ax, ay) {
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
ctx.save();
// what is inside: built blocks, and machines and molds that fit wholly inside
let n = 0;
if (sd) {
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
}
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
for (const mo of molds) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
}
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
if (mode === 'cut') {
// red hatching: this will be taken down
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
const step = Math.max(8, zp * 2.5);
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
ctx.restore();
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
}
// Pasting: the clipboard follows the cursor, snapped to the grid
function drawPastePreview() {
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
drawGrid(aimBlock().bx, aimBlock().by, 7);
ctx.save();
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
ctx.restore();
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
}
+9 -190
View File
@@ -1,4 +1,4 @@
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
// Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
// ---------- Overlay previews ----------
function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; }
function drawGrid(cbx, cby, radius) {
@@ -18,109 +18,6 @@ function previewColor(code, v) {
if (code === BP_FILTER) return '96,100,110';
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
}
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
function crackSeg(ctx, x, y, n, sc, zp, oy) {
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
const pts = [];
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
for (let k = 0; k + 3 < pts.length; k += 4) {
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
}
}
function drawCracks() {
const ctx = R.ctx, zp = R.zp;
if (zp < 2.5) return;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
const cells = [];
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
if (!cells.length) return;
ctx.save(); ctx.lineCap = 'round';
for (let l = 0; l < CRACK_LV.length; l++) {
const L = CRACK_LV[l];
// a soft lit lip just below each crack, then the dark crack itself
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
}
}
ctx.restore();
}
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
function pulseRGB(tune) {
if (!tune) return '150,215,255';
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
}
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
function fanPoints(ox, oy, ang, Wd, sd, n) {
const pts = [];
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
return pts;
}
function drawDrillPulses() {
if (!drillPulses.length) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const q of drillPulses) {
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
if (rr < 3) continue;
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
// a faint band behind the front, then the bright front itself
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
}
ctx.restore();
}
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
// out to where it stops, and a crosshair
function drawDrillAim(ax, ay) {
const ctx = R.ctx, zp = R.zp;
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
ctx.save();
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
// the emitter glows across the bot's face while it fires
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
if (GAME.lmb && hot > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
const k = zp * 0.9;
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore();
}
// Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
function drawAirStreaks(ax, ay, p, r, blowing) {
if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return;
@@ -157,6 +54,11 @@ function drawAirStreaks(ax, ay, p, r, blowing) {
}
ctx.restore();
}
function drawReachCircle(radius, alpha) {
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = `rgba(239,106,85,${alpha})`; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, radius * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
}
function drawPreviews() {
const ctx = R.ctx, zp = R.zp;
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
@@ -165,14 +67,10 @@ function drawPreviews() {
const reachOk = inReach(ax, ay);
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
if (!reachOk && t !== 'drill' && !sel) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
drawReachCircle(REACH, 0.35);
}
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
drawReachCircle(drillReach(GAME.drillW), 0.45);
}
drawDrillPulses();
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
@@ -251,7 +149,7 @@ function drawPreviews() {
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
}
} else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh);
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
@@ -290,82 +188,3 @@ function drawPreviews() {
if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
}
// a small labelled tag drawn on the world (screen pixels)
function drawTag(text, col, x, y, sub) {
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.save();
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const tw = ctx.measureText(text).width;
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
ctx.restore();
}
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
function drawSelection(mode, ax, ay) {
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
ctx.save();
// what is inside: built blocks, and machines and molds that fit wholly inside
let n = 0;
if (sd) {
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
}
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
for (const mo of molds) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
}
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
if (mode === 'cut') {
// red hatching: this will be taken down
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
const step = Math.max(8, zp * 2.5);
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
ctx.restore();
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
}
// Pasting: the clipboard follows the cursor, snapped to the grid
function drawPastePreview() {
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
drawGrid(aimBlock().bx, aimBlock().by, 7);
ctx.save();
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
ctx.restore();
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
}
+90
View File
@@ -0,0 +1,90 @@
// Selection panels: current tool card and bag materials.
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
} else if (sel === 'copy') {
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
} else if (GAME.paste && GAME.clip) {
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
} else if (t === 'drill') {
const b = BIT_TIERS[player.bitTier];
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'build') {
// the build tool in general; the piece itself is described in the menu's hover popup
const P = piece();
const chips = buildVariantChips(P) + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '');
h = '<div class="row"><span class="name grow">Build</span></div>'
+ (chips ? `<div class="row" style="flex-wrap:wrap;gap:4px">${chips}</div>` : '')
+ '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>'
+ '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>';
} else if (t === 'vacuum') {
const all = blowingAll(), list = all ? blowAllList() : blowList();
const sws = ms => ms.slice(0, 4).map(m => `<span class="sw" title="${esc(MD[m].name)}" style="background:${rgbOf(m)}"></span>`).join('') + (ms.length > 4 ? `+${ms.length - 4}` : '');
const count = list.reduce((n, m) => n + bag[m], 0);
h = `<div class="row"><span class="name grow">Pneumatic nozzle</span><span class="chip">width ${GAME.brush[1]}</span></div>
<div class="row vacrow"><b>Pick up</b><span>${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything</span>${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}</div>
<div class="row vacrow"><b>Blow out</b><kbd>Q</kbd><span class="name grow vacchoice">${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}</span><span class="num chip${count ? '' : ' bad'}">${count ? count.toLocaleString('en-US') : 'none'}</span><kbd>E</kbd></div>
<div class="sub">Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.</div>`;
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
function renderBag() {
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap);
const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow];
const missing = [...GAME.blow].filter(m => bag[m] <= 0);
if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m);
const l = bagMaterials().concat(missing);
const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.');
if (key === lastBagKey) return;
lastBagKey = key;
const box = $('baglist'); box.innerHTML = '';
// the hint and Clear sit under the list, outside its scroll, so they never scroll away
const foot = $('bagfoot'); foot.innerHTML = '';
if (t === 'vacuum') {
foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next"));
if (GAME.blow.size) {
const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection');
clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); };
foot.appendChild(clear);
}
}
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; }
for (const m of l) {
if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag'));
const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name });
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}</span>`;
// Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click
// arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen.
b.oncontextmenu = e => e.preventDefault();
b.onclick = e => {
if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; }
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui');
};
row.appendChild(b);
box.appendChild(row);
}
}
+59
View File
@@ -0,0 +1,59 @@
// Selection pickers: filter rules, filter settings and wall materials.
// What a filter can hold: loose things and bars you have found, plus anything it already holds
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
function matList(ms) {
const n = ms.map(m => MD[m].name.toLowerCase());
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
}
// What a filter does, in words: what falls through and what rides along
function filterParts(f) {
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
}
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
const box = $('dlg-body');
function sync() {
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
}
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
function openWallPicker() {
const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material';
const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m]));
const body = `<div class="wpick" role="radiogroup" aria-label="${esc(title)}">${list.map(m => {
const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat;
// two sources build the same wall (glass from shards or from bars): say which
const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name;
const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt";
return `<button class="wp${on ? ' on' : ''}${canAfford(cost, 1) ? '' : ' short'}" role="radio" aria-checked="${on}" data-wall="${m}"><span class="sw" style="background:${rgbOf(WALL_OF[m])}"></span><span><b>${esc(name)}</b><span>you have ${bag[m].toLocaleString('en-US')}${PH[m] === PH_ITEM ? ` ${esc(MD[m].name.toLowerCase())}${bag[m] === 1 ? '' : 's'}` : ''}</span><span>cost: ${esc(costText(cost, 1))}</span><span>${heat}</span></span></button>`;
}).join('')}</div>`;
dialog(title, body, [{ label: 'Cancel' }]);
$('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => {
GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = '';
setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info');
});
}
+2 -148
View File
@@ -1,4 +1,5 @@
// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
// Tool selection: cycling, drill tuning, piece variants and costs.
function pieceSize(P) { return P.kind === 'mold' ? MOLDS[P.mold] : P; }
function pieceUsesWall(P) { return P.variants === 'wall'; }
function setDrillWidth(w) {
const n = Math.max(0, Math.min(PULSE_W.length - 1, w));
@@ -86,64 +87,6 @@ function pieceV(P) {
return GAME.pieceDir[P.id] || 0;
}
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
// What a filter can hold: loose things and bars you have found, plus anything it already holds
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
function matList(ms) {
const n = ms.map(m => MD[m].name.toLowerCase());
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
}
// What a filter does, in words: what falls through and what rides along
function filterParts(f) {
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
}
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
const box = $('dlg-body');
function sync() {
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
}
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
function openWallPicker() {
const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material';
const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m]));
const body = `<div class="wpick" role="radiogroup" aria-label="${esc(title)}">${list.map(m => {
const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat;
// two sources build the same wall (glass from shards or from bars): say which
const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name;
const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt";
return `<button class="wp${on ? ' on' : ''}${canAfford(cost, 1) ? '' : ' short'}" role="radio" aria-checked="${on}" data-wall="${m}"><span class="sw" style="background:${rgbOf(WALL_OF[m])}"></span><span><b>${esc(name)}</b><span>you have ${bag[m].toLocaleString('en-US')}${PH[m] === PH_ITEM ? ` ${esc(MD[m].name.toLowerCase())}${bag[m] === 1 ? '' : 's'}` : ''}</span><span>cost: ${esc(costText(cost, 1))}</span><span>${heat}</span></span></button>`;
}).join('')}</div>`;
dialog(title, body, [{ label: 'Cancel' }]);
$('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => {
GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = '';
setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info');
});
}
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
function pieceCostNow(P, m = GAME.wallMat) {
@@ -151,92 +94,3 @@ function pieceCostNow(P, m = GAME.wallMat) {
if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS);
return P.cost;
}
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
} else if (sel === 'copy') {
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
} else if (GAME.paste && GAME.clip) {
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
} else if (t === 'drill') {
const b = BIT_TIERS[player.bitTier];
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'build') {
// the build tool in general; the piece itself is described in the menu's hover popup
const P = piece();
const chips = buildVariantChips(P) + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '');
h = '<div class="row"><span class="name grow">Build</span></div>'
+ (chips ? `<div class="row" style="flex-wrap:wrap;gap:4px">${chips}</div>` : '')
+ '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>'
+ '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>';
} else if (t === 'vacuum') {
const all = blowingAll(), list = all ? blowAllList() : blowList();
const sws = ms => ms.slice(0, 4).map(m => `<span class="sw" title="${esc(MD[m].name)}" style="background:${rgbOf(m)}"></span>`).join('') + (ms.length > 4 ? `+${ms.length - 4}` : '');
const count = list.reduce((n, m) => n + bag[m], 0);
h = `<div class="row"><span class="name grow">Pneumatic nozzle</span><span class="chip">width ${GAME.brush[1]}</span></div>
<div class="row vacrow"><b>Pick up</b><span>${GAME.blow.size ? 'Selected' : 'What you click'} · Shift: everything</span>${sws(GAME.blow.size ? [...GAME.blow] : GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : [])}</div>
<div class="row vacrow"><b>Blow out</b><kbd>Q</kbd><span class="name grow vacchoice">${all ? '' : sws(list)}${all ? list.length ? `Everything · mix of ${list.length}` : 'Everything · nothing loose' : list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : GAME.blow.size ? 'none in bag' : 'nothing selected'}</span><span class="num chip${count ? '' : ' bad'}">${count ? count.toLocaleString('en-US') : 'none'}</span><kbd>E</kbd></div>
<div class="sub">Left picks up the selection (or what you click if nothing is selected), right blows it out. Hold Ctrl in the bag to pick from everything you've found. Shift: everything.</div>`;
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
function renderBag() {
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap);
const t = toolId(), all = blowingAll(), blowing = all ? blowAllList() : [...GAME.blow];
const missing = [...GAME.blow].filter(m => bag[m] <= 0);
if (GAME.ctrl && t === 'vacuum') for (const m of BAG_ORDER) if (seen[m] && bag[m] <= 0 && !GAME.blow.has(m) && (PH[m] === PH_POWDER || PH[m] === PH_ITEM || PH[m] === PH_LIQUID && player.flask)) missing.push(m);
const l = bagMaterials().concat(missing);
const key = l.map(m => m + ':' + bag[m] + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx + '|' + all + '|' + (t === 'vacuum' && GAME.ctrl) + '|' + missing.join('.');
if (key === lastBagKey) return;
lastBagKey = key;
const box = $('baglist'); box.innerHTML = '';
// the hint and Clear sit under the list, outside its scroll, so they never scroll away
const foot = $('bagfoot'); foot.innerHTML = '';
if (t === 'vacuum') {
foot.appendChild(el('div', { class: 'baghint' }, "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next"));
if (GAME.blow.size) {
const clear = el('button', { class: 'bagclear', 'data-bag-clear': '' }, 'Clear selection');
clear.onclick = () => { GAME.blow.clear(); lastBagKey = ''; hudDirty = true; playSfx('ui'); };
foot.appendChild(clear);
}
}
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Pneumatic nozzle.')); return; }
for (const m of l) {
if (m === missing[0]) box.appendChild(el('div', { class: 'bagsep' }, 'Not in bag'));
const sel = (t === 'vacuum' && blowing.includes(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name });
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}</span>`;
// Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click
// arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen.
b.oncontextmenu = e => e.preventDefault();
b.onclick = e => {
if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; }
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui');
};
row.appendChild(b);
box.appendChild(row);
}
}