Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/share/scorecard.ts
// The shareable PNG: a wanted poster pinned over the victory fireworks.
import type { RunSummary } from './types.js';

export const CARD_W = 1200;
export const CARD_H = 675;

// Same app-owned palette and slab face as the HUD (src/styles.css).
const PAPER = '#f3e3c3';
const PAPER_DARK = '#e2c99a';
const INK = '#3b2412';
const RUST = '#b5541c';
const BRASS = '#c9a227';
const ROPE = '#c99a4f';
const SAUCE = '#d62828';
const SLAB = "'Rockwell', 'Rockwell Nova', 'Roboto Slab', 'Clarendon', 'Georgia', serif";

const n = (v: number) => v.toLocaleString('en-US');

/** Set a font, shrinking it until the text fits the width. */
function fitFont(ctx: CanvasRenderingContext2D, text: string, style: string, size: number, maxWidth: number): void {
  let px = size;
  ctx.font = `${style} ${px}px ${SLAB}`;
  while (px > 12 && ctx.measureText(text).width > maxWidth) {
    px -= 2;
    ctx.font = `${style} ${px}px ${SLAB}`;
  }
}

function drawScene(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement): void {
  // Cover-crop the snapshot, then darken toward the edges so the poster reads.
  const scale = Math.max(CARD_W / scene.width, CARD_H / scene.height);
  const sw = CARD_W / scale;
  const sh = CARD_H / scale;
  ctx.fillStyle = '#2a1a10';
  ctx.fillRect(0, 0, CARD_W, CARD_H);
  if (scene.width > 0 && scene.height > 0) {
    ctx.drawImage(scene, (scene.width - sw) / 2, (scene.height - sh) / 2, sw, sh, 0, 0, CARD_W, CARD_H);
  }
  const shade = ctx.createLinearGradient(0, 0, CARD_W, 0);
  shade.addColorStop(0, 'rgba(59, 36, 18, 0.55)');
  shade.addColorStop(0.6, 'rgba(59, 36, 18, 0)');
  ctx.fillStyle = shade;
  ctx.fillRect(0, 0, CARD_W, CARD_H);
  const vignette = ctx.createRadialGradient(CARD_W / 2, CARD_H / 2, CARD_H * 0.45, CARD_W / 2, CARD_H / 2, CARD_W * 0.7);
  vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
  vignette.addColorStop(1, 'rgba(20, 10, 5, 0.45)');
  ctx.fillStyle = vignette;
  ctx.fillRect(0, 0, CARD_W, CARD_H);
}

function drawStar(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
  const badge = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.1, x, y, r);
  badge.addColorStop(0, '#ffe48a');
  badge.addColorStop(1, BRASS);
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = badge;
  ctx.fill();
  ctx.lineWidth = 4;
  ctx.strokeStyle = INK;
  ctx.stroke();
  ctx.beginPath();
  for (let i = 0; i < 10; i++) {
    const a = -Math.PI / 2 + (i * Math.PI) / 5;
    const rr = i % 2 === 0 ? r * 0.62 : r * 0.26;
    ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
  }
  ctx.closePath();
  ctx.fillStyle = INK;
  ctx.fill();
}

function drawPoster(ctx: CanvasRenderingContext2D, run: RunSummary, name: string): void {
  const w = 500;
  const h = 590;
  const x = 70;
  const y = (CARD_H - h) / 2;
  ctx.save();
  ctx.translate(x + w / 2, y + h / 2);
  ctx.rotate((-1.5 * Math.PI) / 180);
  ctx.translate(-w / 2, -h / 2);

  ctx.fillStyle = 'rgba(59, 36, 18, 0.55)';
  ctx.fillRect(8, 10, w, h);
  const paper = ctx.createLinearGradient(0, 0, 0, h);
  paper.addColorStop(0, PAPER);
  paper.addColorStop(1, PAPER_DARK);
  ctx.fillStyle = paper;
  ctx.fillRect(0, 0, w, h);
  ctx.lineWidth = 5;
  ctx.strokeStyle = INK;
  ctx.strokeRect(0, 0, w, h);
  ctx.setLineDash([12, 8]);
  ctx.lineWidth = 4;
  ctx.strokeStyle = ROPE;
  ctx.strokeRect(16, 16, w - 32, h - 32);
  ctx.setLineDash([]);

  const cx = w / 2;
  const inner = w - 80;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'alphabetic';
  drawStar(ctx, cx, 78, 40);

  ctx.fillStyle = INK;
  fitFont(ctx, 'THE WEST IS BRINED', '900', 46, inner);
  ctx.fillText('THE WEST IS BRINED', cx, 170);

  const sub = `${name} peeled the Onion King`;
  ctx.fillStyle = RUST;
  fitFont(ctx, sub, 'italic', 24, inner);
  ctx.fillText(sub, cx, 208);

  const stats: [string, string][] = [
    ['REWARD PAID', `$${n(run.score)}`],
    ['PICKLES BRINED', n(run.brined)],
    ['ONIONS PEELED', n(run.onions)],
    ['TEARS CRIED', n(run.tears)],
  ];
  stats.forEach(([label, value], i) => {
    const col = i % 2;
    const row = Math.floor(i / 2);
    const sx = 40 + col * (inner / 2) + inner / 4;
    const sy = 268 + row * 108;
    ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
    ctx.fillRect(sx - inner / 4 + 6, sy - 26, inner / 2 - 12, 92);
    ctx.fillStyle = RUST;
    ctx.font = `700 15px ${SLAB}`;
    ctx.fillText(label, sx, sy);
    ctx.fillStyle = INK;
    fitFont(ctx, value, '900', 46, inner / 2 - 28);
    ctx.fillText(value, sx, sy + 50);
  });

  ctx.fillStyle = INK;
  ctx.font = `900 22px ${SLAB}`;
  ctx.fillText('PICKLE RUSTLERS', cx, h - 64);
  ctx.fillStyle = RUST;
  ctx.font = `italic 17px ${SLAB}`;
  ctx.fillText(run.wins > 1 ? `★ West tamed ${run.wins}×` : 'Dead or brined', cx, h - 38);

  if (run.newBest) {
    ctx.save();
    ctx.translate(w - 92, 88);
    ctx.rotate((-12 * Math.PI) / 180);
    ctx.lineWidth = 4;
    ctx.strokeStyle = SAUCE;
    ctx.strokeRect(-78, -26, 156, 48);
    ctx.fillStyle = SAUCE;
    ctx.font = `900 26px ${SLAB}`;
    ctx.textBaseline = 'middle';
    ctx.fillText('NEW BEST!', 0, -1);
    ctx.restore();
  }
  ctx.restore();
}

export function drawScoreCard(run: RunSummary, scene: HTMLCanvasElement, name: string): HTMLCanvasElement {
  const canvas = document.createElement('canvas');
  canvas.width = CARD_W;
  canvas.height = CARD_H;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('2D canvas unavailable');
  drawScene(ctx, scene);
  drawPoster(ctx, run, name);
  return canvas;
}

export function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
  return new Promise((resolve, reject) =>
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('PNG encoding failed'))), 'image/png'),
  );
}