Back to Powder Tool V600Billion
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.

source/src/ui/app.js
/**
 * 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 };
}