257 lines
15 KiB
JavaScript
257 lines
15 KiB
JavaScript
// 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 += `<span class="ovbadge lens"><span class="dot"></span>Thermal lens<span class="scale" style="background:linear-gradient(90deg,${stops.join(',')})" title="0°C to 1600°C"></span><kbd>H</kbd></span>`;
|
||
}
|
||
if (GAME.infoOverlay) h += `<span class="ovbadge info"><span class="dot"></span>Info overlay<kbd>I</kbd></span>`;
|
||
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; }
|
||
}
|
||
}
|
||
}
|
||
}
|