// What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame.
// Which overlays are on, shown as a coloured frame round the screen and a named badge
let ovShown = '';
function updateOverlayUI() {
const on = (GAME.running && R.lens ? 'lens ' : '') + (GAME.running && GAME.infoOverlay ? 'info' : '');
if (on + SET.thermalPalette === ovShown) return;
ovShown = on + SET.thermalPalette;
const f = $('ovframe'), b = $('ovbadges');
if (!f || !b) return;
f.className = on ? 'on ' + on : '';
let h = '';
if (R.lens) {
// a little key of the lens colours, from freezing to white hot
const tl = THERM[SET.thermalPalette] || THERM.inferno, stops = [0, 20, 150, 700, 1600].map(T => { const j = thermIdx(T); return `rgb(${tl[j]},${tl[j + 1]},${tl[j + 2]})`; });
h += `Thermal lensH`;
}
if (GAME.infoOverlay) h += `Info overlayI`;
b.innerHTML = h;
}
function toggleLens() {
if (!player.lens) { setStatus('The thermal lens unlocks at your first fire.'); playSfx('error'); return; }
R.lens = !R.lens; playSfx('ui');
}
function doInteract() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const fb = filterBlockAt(ax, ay);
if (fb >= 0) {
if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; }
const run = filterRun(fb);
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0, code: blockP[fb] }), f => {
applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${beltDirection(blockP[fb], f.dir ? 128 : 0)}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run;
return;
}
if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; }
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0, code: pieceCode(piece()) }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (!inReach(ax + dx, ay + dy)) continue;
msg = interactAt(ax + dx, ay + dy);
if (msg) break outer;
}
if (msg) setStatus(msg, 'info', /^New (drill bit|bag)/.test(msg));
else if ((moldAt(ax, ay) || {}).kind !== 'ingot') setStatus('Nothing to use there.', 'info'); // an ingot mold needs no F: say nothing
}
// Pick (middle mouse): the piece under the cursor becomes the one you build, with its
// direction, wall material and filter settings. On loose material it picks that to blow out.
function pickUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
const i = ay * W + ax, m = mat[i];
let bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pid = null;
// a sloped belt's band is drawn across its block's edge, so the belt you see can lie in the next block
if (!blockP[bi] && !ghostBlocks.has(bi)) { const sb = slopeBeltAt(i, ax, ay, R.smoothOn); if (sb >= 0) bi = sb; }
const ghost = ghostBlocks.get(bi);
if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; }
else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id;
if (isBelt(code) || isFilter(code)) { GAME.pieceShape = GAME.pieceShape || {}; GAME.pieceShape[P.id] = code; }
if (isWall(code)) { GAME.wallMat = v; GAME.pieceDir.wall = isSlope(code) ? code - BP_SLOPE_BL + 1 : 0; }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; }
else if (P.dirs) GAME.pieceDir[P.id] = v % P.dirs.length;
}
if (pid) {
const P = PIECE_BY_ID[pid];
if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; }
GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui');
setStatus(`Picked: ${P.name}.`, 'info');
return;
}
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
if (t) { GAME.blow = new Set([t]); selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
else setStatus('Nothing built here to pick.', 'info');
}
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx - ((bw - 1) >> 1), by: by - ((bh - 1) >> 1) }; }
// Copy and cut: hold C or X and drag a box with the left button; letting go does it.
function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; }
function startPaste() {
if (!GAME.clip) { setStatus('Hold C and drag over built pieces to copy.', 'info'); playSfx('error'); return; }
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
}
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
function piecesIn(rec, tag) { return rec.ops.filter(o => o.t === tag).length; }
function doUndo() {
GAME.drag = null; GAME.selDrag = null;
const msg = undoLast();
setStatus(msg, /could not|Nothing/.test(msg) ? 'err' : 'info');
playSfx(/Nothing/.test(msg) ? 'error' : 'decon'); hudDirty = true;
}
// right-click take-backs while the button is held count as one thing to undo
function endRmbRecord() { if (GAME.rmbRec) { const r = GAME.rmbRec; GAME.rmbRec = null; const n = r.ops.length; r.label = `taking back ${plural(n, 'piece')}`; undoPush(r); } }
// Belts, filters and screws run the way you drag them; a single click uses the direction R sets
function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1;
if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return P.filter ? (dx * dy > 0 ? 1 : 0) : (dx > 0 ? 1 : 0);
if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
}
function blockLabel(P, rec) {
const put = piecesIn(rec, 'block+'), took = piecesIn(rec, 'block-') + piecesIn(rec, 'filter');
return took ? `replacing ${plural(took, 'piece')}` : `placing ${plural(put, P.name.toLowerCase())}`;
}
// press / release actions (one-shot)
function primaryPress(shift) {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const sel = selModeHeld();
if (sel) { GAME.selDrag = { x: ax, y: ay, mode: sel }; GAME.drag = null; hudDirty = true; return; }
if (GAME.paste && GAME.clip) {
const { bx, by } = stampOrigin(GAME.clip.bw, GAME.clip.bh);
undoRecord('pasting a blueprint', () => pasteGhost(GAME.clip, bx, by)); playSfx('place'); hudDirty = true;
return;
}
if (t === 'vacuum') {
GAME.vacFilter = shift ? -1 : GAME.blow.size ? -2 : grainAt(ax, ay);
hudDirty = true;
} else if (t === 'build') {
const P = piece();
if (P.kind === 'block') { GAME.drag = aimBlock(); }
else {
const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh);
if (!inBuildRange(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);
const check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
let err = '';
undoRecord(rec => piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-') ? `replacing with a ${P.name.toLowerCase()}` : `placing a ${P.name.toLowerCase()}`, () => {
err = check() || '';
// G: take down what you built in the way (refunded) and put this in its place
if (err && GAME.replace && /already/.test(err)) {
err = footprintBlocker(bx, by, bw, bh);
if (!err && !canAfford(pieceCostNow(P), 1)) err = needText(pieceCostNow(P), 1);
if (!err) err = clearFootprint(bx, by, bw, bh);
}
if (!err) err = place();
});
if (err) { setStatus(err + (/already/.test(err) ? `; hold G to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
}
}
}
function primaryRelease(shift) {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (GAME.selDrag) {
const sd = GAME.selDrag; GAME.selDrag = null; hudDirty = true;
if (sd.mode === 'cut') {
let bp = null;
undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); });
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks; click to paste.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
} else {
const bp = copyRegion(sd.x, sd.y, ax, ay);
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks; click to place.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
}
return;
}
if (t === 'build' && GAME.drag) {
const P = piece(), plan = dragPlan(P, GAME.drag, aimBlock(), shift), { code, v, cells: list } = plan;
if (plan.dd >= 0) GAME.pieceDir[P.id] = plan.dd;
const ex = pieceExtra(P);
// blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
let ghosts = 0;
const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => {
const res = placeBlocks(code, v, near, ex, GAME.replace);
const g = { t: 'paste', blocks: [], machines: [], molds: [] };
for (const [bx, by] of far) {
const b = by * BW + bx;
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue;
// held G over a different piece: the ghost replaces it when the bot comes in range
ghostBlocks.set(b, GAME.replace && blockP[b] ? [code, v, ex, 1] : ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++;
}
if (ghosts) undoNote(g);
return res;
});
const hint = /already built/.test(r.status) ? `; hold G to replace it.` : '';
const gtxt = ghosts ? `${r.n} placed; ${ghosts} ghosts waiting for the bot.` : '';
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
}
if (t === 'vacuum') { GAME.vacFilter = 0; hudDirty = true; }
}
function secondaryPress(shift) {
const t = toolId();
GAME.rmbSeen = new Set(); GAME.blowAll = !!shift;
if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; }
else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; }
else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
}
// continuous actions, once per simulation tick
function toolTick() {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (GAME.lmb && !GAME.selDrag && !GAME.paste && !selModeHeld()) {
if (t === 'drill') {
const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune);
if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); }
} else if (t === 'vacuum') {
if (GAME.vacFilter === 0) { GAME.vacFilter = grainAt(ax, ay); if (GAME.vacFilter) hudDirty = true; }
const f = GAME.vacFilter;
if (f !== 0) {
const r = toolVacuum(ax, ay, GAME.brush[1], f === -2 ? m => GAME.blow.has(m) : f < 0 ? () => true : m => m === f);
if (r.got) { playSfx('suck'); hudDirty = true; }
if (r.status) setStatus(r.status);
}
} else if (t === 'ignite') {
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Aim at coal or wood touching air to light it.'); }
else setStatus('Out of reach.');
}
}
if (GAME.rmb && !GAME.rmbConsumed) {
if (t === 'vacuum') {
if (!GAME.blowAll && !GAME.blow.size) { setStatus('Nothing selected: click or Q/E in the bag.'); return; }
const list = GAME.blowAll ? blowAllList() : blowList();
if (!GAME.blowAll && !list.length) { setStatus('None of the selected materials are in the bag.'); return; }
const r = GAME.blowAll && !list.length ? { placed: 0, status: 'Nothing loose in the bag to blow.' } : toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null);
// blown down to nothing: it leaves the selection, so an empty selection picks up what you click again
for (const m of list) if (bag[m] <= 0 && GAME.blow.delete(m)) { lastBagKey = ''; hudDirty = true; }
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
if (!GAME.rmbSeen.has(key) && inBuildRange(ax, ay)) {
GAME.rmbSeen.add(key);
const keep = undoRec; undoRec = GAME.rmbRec;
const msg = removePieceAt(ax, ay);
undoRec = keep;
if (msg) { playSfx('decon'); setStatus(msg, 'info'); hudDirty = true; }
}
}
}
}