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 page around the game: top bar, palette, bottom bar and scenes.
*
* Everything acts through the game's Lua (src/game.js), the way the game's
* console would. The game's online dialogs (the save browser, saving, signing
* in, its settings) are the game's own: the page clicks the game's buttons
* for them, which sit below the simulation where the page does not show them.
* While such a dialog is open the page shows the whole game window and its
* own controls wait.
*/
import { icon } from './icons.js';
import { readTools } from './catalog.js';
import { createPalette } from './palette.js';
import { SCENES, renderGallery, sceneScript } from './scenes.js';
import { STOP } from './scene-meta.js';
import { toast } from '../toast.js';
/** The game's own buttons, in units of its 629 × 424 window (GameView.cpp). */
const GAME_BUTTONS = {
open: [9, 415],
reload: [27, 415],
// The save button is split: its left end saves the open save again, the rest opens the save dialog.
'quick-save': [45, 415],
save: [120, 415],
'vote-up': [207, 415],
'vote-down': [233, 415],
tags: [300, 415],
account: [497, 415],
settings: [588, 415],
render: [604, 415],
'element-search': [620, 399],
};
/** A strip of the game's window without buttons: under its element row, above its bottom bar. */
const PARKING = [306, 407];
const STATE = String.raw`local r = ui.brushRadius()
return table.concat({ ui.activeTool(0), ui.activeTool(1), tostring(sim.paused()), ui.brushID(), r,
sim.gravityMode(), sim.airMode(), sim.edgeMode(), tostring(sim.ambientHeatSim()),
tostring(sim.newtonianGravity()), tostring(ren.decorations()), sim.partCount(),
tostring(napplet_scene_ended == true) }, "\t")`;
const TOOL_ID = /^[A-Z0-9_-]+$/;
const SANDBOX_HINT = 'Pick an element on the left and draw with the mouse, or start from one of the Scenes.';
const $ = (selector) => document.querySelector(selector);
/** Icons into every `data-icon` element; before the game starts, so the page is whole while it loads. */
export function drawIcons(root = document) {
for (const element of root.querySelectorAll('[data-icon]')) {
if (!element.querySelector(':scope > .icon')) element.insertAdjacentHTML('afterbegin', icon(element.dataset.icon));
}
}
/**
* Keys typed into the page's own controls stay there: the game listens on the
* window and would take them as shortcuts. Buttons do not take the focus from
* the game when clicked, so its keys keep working.
*/
export function isolateKeys() {
for (const root of document.querySelectorAll('#topbar, #palette, #bottombar, dialog')) {
for (const type of ['keydown', 'keyup', 'keypress']) root.addEventListener(type, (event) => event.stopPropagation());
}
document.addEventListener('mousedown', (event) => {
if (event.target instanceof Element && event.target.closest('button')) event.preventDefault();
});
for (const control of document.querySelectorAll('select, input[type="range"], input[type="color"]')) {
control.addEventListener('change', () => control.blur());
}
}
export function startUi({ game, canvas, frame, openLink = () => false }) {
const body = document.body;
const lua = (code) => {
try {
return game.lua(code);
} catch (error) {
console.log(error);
toast(`The game said: ${error.message}`, { timeoutMs: 8000 });
return '';
}
};
let onTop = true;
const whenOnTop = (fn) => (...args) => { if (onTop) fn(...args); };
function mouseAt([x, y], type, extra = {}) {
const at = frame.toClient(x, y);
canvas.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: at.x, clientY: at.y, screenX: at.x, screenY: at.y, ...extra }));
}
// Afterwards the game would think the mouse still rests on its hidden button and show its
// tooltip; once its own view is on top again, the mouse goes to a strip without buttons.
let park = 0;
function clickGame(name) {
mouseAt(GAME_BUTTONS[name], 'mousemove');
mouseAt(GAME_BUTTONS[name], 'mousedown', { button: 0, buttons: 1 });
mouseAt(GAME_BUTTONS[name], 'mouseup', { button: 0, buttons: 0 });
park = 1;
}
// Palette
const tools = readTools(game);
const palette = createPalette({
root: $('#palette'),
tools,
pick: whenOnTop((slot, id) => {
if (!TOOL_ID.test(id)) return;
const paint = tools.find((tool) => tool.id === id)?.section === 15;
lua(`ui.activeTool(${slot}, "${id}")${paint ? ' ren.decorations(true)' : ''}`);
refresh();
}),
onPaintColour: ({ r, g, b, a }) => lua(`sim.decoColor(${r}, ${g}, ${b}, ${a})`),
});
$('#slot-0').addEventListener('click', whenOnTop(() => palette.reveal(state.tools[0])));
$('#slot-1').addEventListener('click', whenOnTop(() => palette.reveal(state.tools[1])));
$('#swap').addEventListener('click', whenOnTop(() => {
lua('local a, b = ui.activeTool(0), ui.activeTool(1) ui.activeTool(0, b) ui.activeTool(1, a)');
refresh();
}));
// Transport
$('#pause').addEventListener('click', whenOnTop(() => { lua('sim.paused(not sim.paused())'); refresh(); }));
$('#step').addEventListener('click', whenOnTop(() => { lua('sim.paused(true) sim.frameRender(1)'); refresh(); }));
$('#undo').addEventListener('click', whenOnTop(() => lua('sim.historyRestore()')));
$('#redo').addEventListener('click', whenOnTop(() => lua('sim.historyForward()')));
$('#clear').addEventListener('click', whenOnTop(() => {
lua(`${STOP}\nsim.takeSnapshot() sim.clearSim()`);
showScene(null);
}));
// The game's own dialogs
for (const name of ['open', 'save', 'account', 'settings']) $(`#${name}`).addEventListener('click', whenOnTop(() => clickGame(name)));
const more = $('#more');
const menu = $('#more-menu');
const closeMenu = () => { menu.hidden = true; more.setAttribute('aria-expanded', 'false'); };
more.addEventListener('click', whenOnTop(() => {
menu.hidden = !menu.hidden;
more.setAttribute('aria-expanded', String(!menu.hidden));
}));
document.addEventListener('click', (event) => { if (!event.target.closest?.('.menu-wrap')) closeMenu(); });
menu.addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
if (!action) return;
closeMenu();
if (!onTop) return;
if (action === 'console') lua('ui.console(true)');
else if (action === 'keys') $('#keys').showModal();
else if (action === 'about') $('#about').showModal();
else clickGame(action);
});
// Bottom bar
for (const button of document.querySelectorAll('[data-brush]')) {
button.addEventListener('click', whenOnTop(() => { lua(`ui.brushID(${Number(button.dataset.brush)})`); refresh(); }));
}
const size = $('#brush-size');
const sizeValue = $('#brush-size-value');
size.addEventListener('input', whenOnTop(() => {
sizeValue.textContent = size.value;
lua(`ui.brushRadius(${Number(size.value)}, ${Number(size.value)})`);
}));
const view = $('#view');
view.addEventListener('change', whenOnTop(() => lua(`ren.useDisplayPreset(${Number(view.value)})`)));
const selects = { gravity: 'sim.gravityMode', air: 'sim.airMode', edges: 'sim.edgeMode' };
for (const [id, fn] of Object.entries(selects)) {
$(`#${id}`).addEventListener('change', whenOnTop((event) => { lua(`${fn}(${Number(event.target.value)})`); refresh(); }));
}
const toggles = { ambient: 'sim.ambientHeatSim', newtonian: 'sim.newtonianGravity', decorations: 'ren.decorations' };
for (const [id, fn] of Object.entries(toggles)) {
$(`#${id}`).addEventListener('click', whenOnTop(() => { lua(`${fn}(not ${fn}())`); refresh(); }));
}
// Scenes
const scenesDialog = $('#scenes');
let current = null;
function showScene(scene) {
current = scene;
$('#scene-name').textContent = scene ? scene.title : 'Sandbox';
$('#scene-restart').hidden = !scene;
$('#caption-title').textContent = scene ? `${scene.icon} ${scene.title}`.trim() : 'Sandbox';
$('#caption-hint').textContent = scene?.hint ?? SANDBOX_HINT;
}
function loadScene(scene) {
lua(sceneScript(scene));
view.value = String(scene.view);
showScene(scene);
refresh();
}
renderGallery($('#scene-grid'), (scene) => {
scenesDialog.close();
if (onTop) loadScene(scene);
});
$('#scenes-open').addEventListener('click', whenOnTop(() => scenesDialog.showModal()));
$('#scene-restart').addEventListener('click', whenOnTop(() => { if (current) loadScene(current); }));
// Links leave through the host, which asks the player first.
for (const button of document.querySelectorAll('[data-link]')) {
button.addEventListener('click', () => {
if (!openLink(button.dataset.link)) toast(`This host opens no links. The address: ${button.dataset.link}`, { timeoutMs: 12000 });
});
}
for (const dialog of document.querySelectorAll('dialog')) {
dialog.addEventListener('click', (event) => {
if (event.target === dialog || event.target.closest('[data-close]')) dialog.close();
});
}
// What the game says, a few times a second: the game changes these itself too.
const state = { tools: ['', ''] };
let refreshFailed = false;
function refresh() {
if (!onTop) return;
let text;
try {
text = game.lua(STATE);
} catch (error) {
if (!refreshFailed) console.log('reading the game\'s state:', error);
refreshFailed = true;
return;
}
const [left, right, paused, brush, radius, gravity, air, edges, ambient, newtonian, decorations, parts, ended] = text.split('\t');
// The scene's own scripts noticed it is gone (emptied, a save opened, undone).
if (ended === 'true') {
if (current) showScene(null);
try { game.lua('napplet_scene_ended = nil'); } catch (error) { console.log(error); }
}
state.tools = [left, right];
palette.setPicked(state.tools);
const pause = $('#pause');
const isPaused = paused === 'true';
if (pause.dataset.paused !== String(isPaused)) {
pause.dataset.paused = String(isPaused);
pause.innerHTML = icon(isPaused ? 'play' : 'pause');
pause.title = isPaused ? 'Resume (Space)' : 'Pause (Space)';
pause.setAttribute('aria-label', isPaused ? 'Resume' : 'Pause');
body.classList.toggle('paused', isPaused);
}
for (const button of document.querySelectorAll('[data-brush]')) button.setAttribute('aria-pressed', String(button.dataset.brush === brush));
if (document.activeElement !== size) { size.value = radius; sizeValue.textContent = radius; }
for (const [id, value] of [['gravity', gravity], ['air', air], ['edges', edges]]) {
const select = $(`#${id}`);
if (document.activeElement !== select && select.value !== value) select.value = value;
}
for (const [id, value] of [['ambient', ambient], ['newtonian', newtonian], ['decorations', decorations]]) {
$(`#${id}`).setAttribute('aria-pressed', String(value === 'true'));
}
$('#parts').textContent = Number(parts).toLocaleString('en-US');
}
setInterval(refresh, 250);
// Which window is on top decides what the page shows.
function watch() {
const now = game.gameViewActive();
if (now !== onTop) {
onTop = now;
body.classList.toggle('game-dialog', !onTop);
frame.showWhole(!onTop);
closeMenu();
if (!onTop && document.activeElement instanceof HTMLElement && document.activeElement !== canvas) document.activeElement.blur();
if (onTop) refresh();
}
if (park) {
if (!onTop) park = 1;
else if (++park > 3) {
park = 0;
mouseAt(PARKING, 'mousemove');
}
}
requestAnimationFrame(watch);
}
// The game's intro text explains its own menus, which the page does not show;
// the page's keyboard help replaces it. Turning the information off and on skips it.
try { game.lua('local shown = ren.hud() ren.hud(false) ren.hud(shown)'); } catch (error) { console.log(error); }
showScene(null);
body.classList.remove('starting');
frame.showWhole(!game.gameViewActive());
onTop = game.gameViewActive();
body.classList.toggle('game-dialog', !onTop);
refresh();
requestAnimationFrame(watch);
// For the browser checks (scripts/smoke.mjs, scene-lab.mjs, scene-thumbs.mjs).
return { scenes: SCENES, loadScene, clickGame, lua: (code) => game.lua(code), onTop: () => onTop };
}
