SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* The element palette: the game's tools by section, a search over all of
* them, and which tool each mouse button draws with. Clicking a tool gives it
* to the left button, right-clicking to the right one, as in the game.
*/
import { ERASER, SECTIONS, inkFor } from './catalog.js';
const PAINT = 15;
export function createPalette({ root, tools, pick, onPaintColour }) {
const byId = new Map(tools.map((tool) => [tool.id, tool]));
const sectionsNav = root.querySelector('#sections');
const grid = root.querySelector('#tools');
const search = root.querySelector('#tool-search');
const paint = root.querySelector('#paint');
const slots = [root.querySelector('#slot-0'), root.querySelector('#slot-1')];
let section = 8;
let picked = ['', ''];
const present = SECTIONS.filter((s) => tools.some((tool) => tool.section === s.id));
for (const s of present) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = s.name;
button.dataset.section = String(s.id);
button.addEventListener('click', () => {
section = s.id;
search.value = '';
render();
});
sectionsNav.append(button);
}
search.addEventListener('input', () => render());
search.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { search.value = ''; render(); }
if (event.key === 'Enter') grid.querySelector('.tool')?.click();
});
grid.addEventListener('click', (event) => {
const button = event.target.closest('.tool');
if (button) pick(0, button.dataset.id);
});
grid.addEventListener('contextmenu', (event) => {
const button = event.target.closest('.tool');
if (!button) return;
event.preventDefault();
pick(1, button.dataset.id);
});
grid.addEventListener('auxclick', (event) => {
const button = event.target.closest('.tool');
if (button && event.button === 1) pick(1, button.dataset.id);
});
const colour = root.querySelector('#paint-colour');
const alpha = root.querySelector('#paint-alpha');
const sendColour = () => {
const value = Number.parseInt(colour.value.slice(1), 16);
onPaintColour({ r: value >> 16, g: (value >> 8) & 255, b: value & 255, a: Number(alpha.value) });
};
colour.addEventListener('input', sendColour);
alpha.addEventListener('input', sendColour);
function matches(tool, words) {
const text = `${tool.code} ${tool.label} ${tool.description}`.toLowerCase();
return words.every((word) => text.includes(word));
}
function render() {
const query = search.value.trim().toLowerCase();
const words = query.split(/\s+/).filter(Boolean);
// Hits in the code or name first, then those only in the description; the game's order within each.
const rank = (tool) => (`${tool.code} ${tool.label}`.toLowerCase().includes(words[0]) ? 0 : 1);
const shown = words.length
? tools.filter((tool) => matches(tool, words)).sort((a, b) => rank(a) - rank(b))
: tools.filter((tool) => tool.section === section);
for (const button of sectionsNav.children) button.setAttribute('aria-pressed', String(!words.length && Number(button.dataset.section) === section));
paint.hidden = words.length > 0 || section !== PAINT;
grid.replaceChildren(...shown.map(toolButton));
if (!shown.length) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = `Nothing called “${query}”.`;
grid.append(empty);
}
mark();
}
function toolButton(tool) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tool';
button.dataset.id = tool.id;
button.title = `${tool.label} (${tool.code}): ${tool.description}`;
button.style.setProperty('--c', tool.colour);
button.style.setProperty('--ink', inkFor(tool.colour));
const code = document.createElement('span');
code.className = 'code';
code.textContent = tool.code;
const name = document.createElement('span');
name.className = 'name';
name.textContent = tool.label;
const badges = document.createElement('span');
badges.className = 'badges';
button.append(code, name, badges);
return button;
}
function mark() {
for (const button of grid.querySelectorAll('.tool')) {
const left = button.dataset.id === picked[0];
const right = button.dataset.id === picked[1];
button.classList.toggle('left', left);
button.classList.toggle('right', right);
const badges = button.querySelector('.badges');
const want = `${left ? 'L' : ''}${right ? 'R' : ''}`;
if (badges.dataset.shown !== want) {
badges.dataset.shown = want;
badges.innerHTML = `${left ? '<i class="l">L</i>' : ''}${right ? '<i class="r">R</i>' : ''}`;
}
}
picked.forEach((id, slot) => {
const tool = byId.get(id);
slots[slot].style.setProperty('--c', tool?.colour ?? '#333');
slots[slot].querySelector('b').textContent = tool ? tool.label : id.replace(/^DEFAULT_[A-Z]+_/, '') || '…';
});
}
render();
return {
/** What the game says each button draws with now (it changes them too: sampling, its own menus). */
setPicked(ids) {
if (ids[0] === picked[0] && ids[1] === picked[1]) return;
picked = [ids[0] ?? '', ids[1] ?? ''];
mark();
},
/** Show the section of a tool, e.g. after it was picked in the game. */
reveal(id) {
const tool = byId.get(id);
if (!tool || search.value) return;
if (tool.section !== section) { section = tool.section; render(); }
},
get eraser() { return byId.has(ERASER) ? ERASER : ''; },
};
}
