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/save/thumbnail.js
/**
 * Draw a save the way the online browser shows it: a small picture of its
 * walls and particles, each in its element's colour with decoration mixed in.
 * Upstream's server renders these with the game itself; here the page does
 * it, from the save alone, because the saves now live on relays.
 *
 * It is a likeness, not the game's renderer: no glow, no fire, no pressure.
 * Opening a save shows the real thing (the game renders its own preview).
 */
import palette from './elements.js';
import { encodePng } from './png.js';
import { readSave } from './ops.js';

const ELEMENT_COLOURS = new Map(palette.elements.map(([id, , colour]) => [id, colour]));
const WALL_COLOURS = new Map(palette.walls.map(([id, , colour]) => [id, colour]));
const UNKNOWN = 0x808080;

/** Render at full size into an RGB buffer. */
export function drawSave(save) {
  const { width, height, blockWidth, walls } = save;
  const rgb = new Uint8Array(width * height * 3);
  for (let by = 0; by < save.blockHeight; by++) {
    for (let bx = 0; bx < blockWidth; bx++) {
      const wall = walls[by * blockWidth + bx];
      if (!wall) continue;
      const colour = WALL_COLOURS.get(wall) ?? UNKNOWN;
      for (let y = by * 4; y < by * 4 + 4; y++) {
        for (let x = bx * 4; x < bx * 4 + 4; x++) {
          // Walls are drawn as a dotted pattern in the game; half the pixels keeps that look.
          if (((x + y) & 1) === 0) continue;
          const i = (y * width + x) * 3;
          rgb[i] = colour >>> 16; rgb[i + 1] = (colour >>> 8) & 0xff; rgb[i + 2] = colour & 0xff;
        }
      }
    }
  }
  save.forEachParticle((x, y, type, dcolour) => {
    let colour = ELEMENT_COLOURS.get(type) ?? UNKNOWN;
    const alpha = dcolour >>> 24;
    if (alpha) {
      const mix = (shift) => {
        const base = (colour >>> shift) & 0xff, deco = (dcolour >>> shift) & 0xff;
        return Math.round((deco * alpha + base * (255 - alpha)) / 255);
      };
      colour = (mix(16) << 16) | (mix(8) << 8) | mix(0);
    }
    const i = (y * width + x) * 3;
    rgb[i] = colour >>> 16; rgb[i + 1] = (colour >>> 8) & 0xff; rgb[i + 2] = colour & 0xff;
  });
  return rgb;
}

/**
 * Shrink to `width` x `height`, keeping the brightest pixel of each block.
 * Averaging would fade a one-pixel wire or wall into the black around it;
 * keeping the brightest keeps thin builds visible and their colours true.
 */
export function fit(rgb, sourceWidth, sourceHeight, width, height) {
  if (width >= sourceWidth && height >= sourceHeight) return { rgb, width: sourceWidth, height: sourceHeight };
  const out = new Uint8Array(width * height * 3);
  for (let y = 0; y < height; y++) {
    const y0 = Math.floor((y * sourceHeight) / height), y1 = Math.max(y0 + 1, Math.floor(((y + 1) * sourceHeight) / height));
    for (let x = 0; x < width; x++) {
      const x0 = Math.floor((x * sourceWidth) / width), x1 = Math.max(x0 + 1, Math.floor(((x + 1) * sourceWidth) / width));
      let best = -1, at = 0;
      for (let j = y0; j < y1; j++) {
        for (let i = x0; i < x1; i++) {
          const k = (j * sourceWidth + i) * 3;
          const light = rgb[k] * 299 + rgb[k + 1] * 587 + rgb[k + 2] * 114;
          if (light > best) { best = light; at = k; }
        }
      }
      const o = (y * width + x) * 3;
      out[o] = rgb[at]; out[o + 1] = rgb[at + 1]; out[o + 2] = rgb[at + 2];
    }
  }
  return { rgb: out, width, height };
}

/**
 * PNG thumbnail of a .cps save, sized for the online browser's buttons
 * (upstream SaveButton asks for about 108 x 68 and stretches whatever it
 * gets to that). Throws on anything that is not a readable save.
 */
export async function thumbnailPng(bytes, { maxWidth = 108, maxHeight = 68 } = {}) {
  const save = readSave(bytes);
  const full = drawSave(save);
  const scale = Math.min(1, maxWidth / save.width, maxHeight / save.height);
  const small = fit(full, save.width, save.height, Math.max(1, Math.round(save.width * scale)), Math.max(1, Math.round(save.height * scale)));
  return { png: await encodePng(small.width, small.height, small.rgb), width: small.width, height: small.height, size: [save.width, save.height] };
}