// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. function pieceUsesWall(P) { return P.variants === 'wall'; } function setDrillWidth(w) { const n = Math.max(0, Math.min(PULSE_W.length - 1, w)); if (n === GAME.drillW) return; GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui'); const what = ['long beam', 'balanced cone', 'broad fan'][n]; setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info'); } // R with the drill: tune it to the material under the cursor, or back to everything function tuneUnderCursor() { const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); if (ax < 0 || ay < 0 || ax >= W || ay >= H) return; const m = mat[ay * W + ax]; const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0; GAME.drillTune = !to || to === GAME.drillTune ? 0 : to; hudDirty = true; playSfx('ui'); setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only it shatters.` : 'Untuned: the drill shatters any rock it can.', 'info'); } function cycleItem(d) { const t = toolId(); if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; } if (t === 'vacuum') { // what right click blows: each material in the bag, plus an even mix of the ticked ones const l = bagMaterials().map(String); if (mixList().length >= 2) l.push('mix'); if (!l.length) return; let k = l.indexOf(GAME.blowMix ? 'mix' : String(GAME.dropMat)); k = (k + d + l.length) % l.length; if (k < 0) k = 0; if (l[k] === 'mix') GAME.blowMix = true; else { GAME.blowMix = false; GAME.dropMat = +l[k]; } GAME.matChosen = true; } else if (t === 'build') { let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length; GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]); GAME.catPick = GAME.catPick || {}; GAME.catPick[GAME.buildCat] = BUILD_ORDER[k]; GAME.drag = null; } else return; playSfx('ui'); hudDirty = true; } function cycleWall(d) { // prefer materials you actually have, but allow choosing any const have = WALL_LIST.filter(m => bag[m] > 0); const list = have.length ? have.concat(WALL_LIST.filter(m => !have.includes(m))) : WALL_LIST; let k = list.indexOf(GAME.wallMat); k = (k + d + list.length) % list.length; GAME.wallMat = list[k]; } function rotate() { const t = toolId(); if (GAME.paste && GAME.clip) GAME.clip = flipBlueprint(GAME.clip); else if (t === 'build') { const P = piece(); if (pieceUsesWall(P)) cycleWall(1); else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length; else return; } else if (t === 'vacuum') { toggleCollectUnderCursor(); return; } else if (t === 'drill') { tuneUnderCursor(); return; } else return; playSfx('ui'); hudDirty = true; } function toggleCollect(m) { if (!m) return; if (GAME.collect.has(m)) GAME.collect.delete(m); else GAME.collect.add(m); try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ } lastBagKey = ''; hudDirty = true; playSfx('ui'); } function pickFilterUnderCursor() { const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const bar = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H); const m = bar ? bar.m : grainAt(ax, ay); if (!m || !filterable(m)) { setStatus('Aim at something loose to add it to the filter.', 'info'); return; } toggleFilterMat(m); } function toggleFilterMat(m) { const f = GAME.filterCfg, k = f.m.indexOf(m); if (k >= 0) f.m.splice(k, 1); else f.m.push(m); f.m.sort((a, b) => a - b); hudDirty = true; lastBagKey = ''; playSfx('ui'); setStatus(`New filter selection updated.`, 'info'); } function toggleCollectUnderCursor() { const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; } toggleCollect(m); setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked for vacuuming' : 'unticked'}.`, 'info'); } function setBrush(d) { const t = toolId(); if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; } if (t === 'build' || t === 'blueprint' || t === 'ignite') return; GAME.brush[GAME.tool] = Math.max(0, Math.min(t === 'vacuum' ? 6 : 8, GAME.brush[GAME.tool] + d)); hudDirty = true; } // The block variant a piece is placed with: wall material, or direction (a filter's // materials and mode travel separately, as GAME.filterCfg) function pieceV(P) { if (P.code === BP_WALL) return GAME.wallMat; if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7; 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 = `
Rollers turn
` + (list.length ? `
${list.map(m => ``).join('')}
` : '

Find something loose first.

') + `

`; 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 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 `${esc(costText(cost, 1))}`; } function pieceCostNow(P) { if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls); if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS); return P.cost; } function renderSelCard() { const t = toolId(), c = $('selcard'); let h = ''; const status = performance.now() - GAME.statusT < 3200 ? GAME.status : ''; const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(); c.hidden = false; const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length; if (sel === 'cut') { h = `
✂ CutHold X and drag a box
Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. Ctrl+Z puts it back.
`; } else if (sel === 'copy') { h = `
⧉ CopyHold C and drag a box
Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.
`; } else if (GAME.paste && GAME.clip) { h = `
PasteBlueprint${GAME.clip.bw}×${GAME.clip.bh} blocks
Click to place a ghost; your bot builds ghosts from your bag when it is close. R flips it. Right click or Esc stops pasting, V starts again.
Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}
`; if (ng) h += `
${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.
`; } else if (t === 'drill') { const b = BIT_TIERS[player.bitTier]; const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune; h = `
QResonance drill${pw.name} pulseE
${esc(b.name.toLowerCase())}hardness ≤ ${b.hard}reach ${drillReach(GAME.drillW)}${tn ? `tuned: ${esc(MD[tn].name.toLowerCase())} · R` : 'untuned · R tunes'}
Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. Q/E width: narrow bores a long tunnel fast, wide clears a big face up close. R over a material tunes to it, so only that shatters. Built blocks are never harmed.
`; } 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 ? 'Replace · G' : '') + (ng ? `${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.` : ''); h = '
Build
' + (chips ? `
${chips}
` : '') + '
Place pieces from the menu below. Drag for a line, Shift-drag for a box; out of reach becomes ghosts.
' + '
Q/E piece · Shift+Q/E tab · R turn or material · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
'; } else if (t === 'vacuum') { ensureDropMat(); const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0; const list = [...GAME.collect]; const mixL = mixList(); const blowRow = GAME.blowMix ? `
Q${mixL.map(k => ``).join('')}Ticked mix${mixL.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}E
` : m ? `
Q${esc(MD[m].name)}${bag[m] ? bag[m].toLocaleString('en-US') : 'none left'}E
` : `
Vacuumbag empty
`; const sucking = f < 0 ? 'everything' : list.length ? 'only the ticked materials' : f > 0 ? 'only ' + esc(MD[f].name.toLowerCase()) : 'what you start on'; h = `${blowRow}
Left sucks up ${sucking}${list.length && f >= 0 ? ' (untick them all to pick by what you click on)' : ''}. Standing still, Shift takes everything.
Right blows ${GAME.blowMix ? 'an even mix of the ticked materials' : m ? 'only ' + esc(MD[m].name.toLowerCase()) : 'the chosen material'} out. ${mixL.length > 1 || GAME.blowMix ? 'Q/E switch between single materials and the ticked mix.' : 'Tick 2+ materials to blow them as a mix.'}
nozzle ${GAME.brush[1]}${list.map(k => `${esc(MD[k].name)}`).join('')}${list.length ? '' : 'Nothing ticked. R ticks what is under the cursor.'}
`; } else if (t === 'ignite') { h = `
Igniterup to 640°C
Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.
`; } h += `
${esc(status)}
`; 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 l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m)); const t = toolId(); const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '')).join(',') + '|' + GAME.dropMat + '|' + GAME.blowMix + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx; if (key === lastBagKey) return; lastBagKey = key; const box = $('baglist'); box.innerHTML = ''; if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; } for (const m of l) { const sel = (t === 'vacuum' && (GAME.blowMix ? GAME.collect.has(m) : m === GAME.dropMat)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m)); const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') }); const tick = el('button', { class: 'tick' + (GAME.collect.has(m) ? ' on' : ''), title: GAME.collect.has(m) ? 'Ticked: the vacuum takes only ticked materials, and they make up the Ticked mix' : 'Tick to vacuum only ticked materials', 'aria-label': `Tick ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m) }); tick.onclick = () => toggleCollect(m); const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` }); b.innerHTML = `${esc(MD[m].name)}${bag[m].toLocaleString('en-US')}`; b.onclick = () => { 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.dropMat = m; GAME.matChosen = true; GAME.blowMix = false; if (t !== 'vacuum') selectTool(1); } hudDirty = true; lastBagKey = ''; playSfx('ui'); }; row.appendChild(tick); row.appendChild(b); box.appendChild(row); } if (t === 'vacuum' && mixList().length > 1) { // the ticked mix is a choice of its own, like any material const mx = mixList(), row = el('div', { class: 'bagrow mixrow' + (GAME.blowMix ? ' on' : '') }); const b = el('button', { class: 'pick', title: 'Blow an even mix of the ticked materials' }); b.innerHTML = `${mx.slice(0, 4).map(k => ``).join('')}Ticked mix${mx.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}`; b.onclick = () => { GAME.blowMix = true; GAME.matChosen = true; hudDirty = true; lastBagKey = ''; playSfx('ui'); }; row.appendChild(b); box.appendChild(row); } if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click a material to blow only that, or Ticked mix to blow an even mix of the ticked ones. Ticked ones always come along when you vacuum.')); }