// 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 if (t === 'drill') { firePing(); GAME.rmbConsumed = true; } else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } } } // Right click with the drill: a resonance ping. The echo is drawn by drawPing (overlays.js). const PING_RECHARGE = 600; // Seconds until the ping is ready again (0: ready). function pingWait() { return GAME.pingTick !== undefined && tick >= GAME.pingTick ? Math.max(0, Math.ceil((PING_RECHARGE - (tick - GAME.pingTick)) / 60)) : 0; } function firePing() { if (pingWait()) { setStatus(`The ping is recharging: ${pingWait()} s.`); playSfx('error'); return; } const p = resonancePing(bot.x + bot.w / 2, bot.y + bot.h / 2); GAME.ping = Object.assign(p, { t0: performance.now() }); GAME.pingTick = tick; playSfx('pulse', 2); const names = p.found.map(m => MD[m].name.toLowerCase()); setStatus(names.length ? `Ping: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ' and more' : ''} nearby.` : `Ping: no veins within ${Math.round(p.r / BS)} blocks.`, 'info'); } // 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; } } } } }