game4 / client / scripts / make-icons.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// Generates PWA icons (a lit candle on midnight indigo) as PNGs with zero
// dependencies: per-pixel SDF rendering + hand-rolled PNG encoding via node:zlib.
// Adapted from sueta's icon generator.
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "icons");
mkdirSync(outDir, { recursive: true });

const BG = [13, 10, 20];
const WAX = [232, 219, 197];
const FLAME = [255, 179, 71];
const CORE = [255, 236, 179];

const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const smooth = (d) => clamp(0.5 - d, 0, 1); // 1px antialias band around SDF zero

function roundedRect(px, py, cx, cy, hw, hh, r) {
  const qx = Math.abs(px - cx) - hw + r;
  const qy = Math.abs(py - cy) - hh + r;
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
const circle = (px, py, cx, cy, r) => Math.hypot(px - cx, py - cy) - r;

// teardrop flame: circle bottom + narrowing tip via two circles blended
function flame(px, py, cx, cy, s) {
  const body = circle(px, py, cx, cy + 0.22 * s, 0.34 * s);
  const tip = circle(px, py, cx, cy - 0.18 * s, 0.16 * s);
  // smooth-min union
  const k = 0.28 * s;
  const h = clamp(0.5 + (0.5 * (tip - body)) / k, 0, 1);
  return tip * (1 - h) + body * h - k * h * (1 - h);
}

function mix(dst, src, c) {
  dst[0] += (src[0] - dst[0]) * c;
  dst[1] += (src[1] - dst[1]) * c;
  dst[2] += (src[2] - dst[2]) * c;
}

function draw(S, { maskable = false, opaque = false } = {}) {
  const img = Buffer.alloc(S * S * 4);
  const pad = maskable ? 0.1 * S : 0;
  const bgRadius = maskable || opaque ? 0 : 0.22 * S;
  const u = S - 2 * pad;
  const off = pad;
  for (let y = 0; y < S; y++) {
    for (let x = 0; x < S; x++) {
      const px = x + 0.5, py = y + 0.5;
      const col = [0, 0, 0];
      let a = 0;
      const dBg = roundedRect(px, py, S / 2, S / 2, S / 2, S / 2, bgRadius);
      const cBg = maskable || opaque ? 1 : smooth(dBg);
      if (cBg > 0) {
        col[0] = BG[0]; col[1] = BG[1]; col[2] = BG[2];
        a = cBg;
      }
      const cx = off + 0.5 * u;
      // warm halo behind the flame
      const halo = clamp(1 - Math.hypot(px - cx, py - (off + 0.33 * u)) / (0.38 * u), 0, 1);
      if (a > 0 && halo > 0) mix(col, FLAME, halo * halo * 0.35);
      // candle body
      const dWax = roundedRect(px, py, cx, off + 0.72 * u, 0.13 * u, 0.20 * u, 0.045 * u);
      const cWax = smooth(dWax);
      if (cWax > 0) { mix(col, WAX, cWax); a = Math.max(a, cWax); }
      // wick
      const dWick = roundedRect(px, py, cx, off + 0.485 * u, 0.012 * u, 0.035 * u, 0.01 * u);
      const cWick = smooth(dWick);
      if (cWick > 0) mix(col, [60, 45, 35], cWick);
      // flame + bright core
      const dFl = flame(px, py, cx, off + 0.335 * u, 0.22 * u);
      const cFl = smooth(dFl);
      if (cFl > 0) { mix(col, FLAME, cFl); a = Math.max(a, cFl); }
      const dCore = flame(px, py, cx, off + 0.365 * u, 0.115 * u);
      const cCore = smooth(dCore);
      if (cCore > 0) mix(col, CORE, cCore);
      const i = (y * S + x) * 4;
      img[i] = Math.round(col[0]);
      img[i + 1] = Math.round(col[1]);
      img[i + 2] = Math.round(col[2]);
      img[i + 3] = Math.round(a * 255);
    }
  }
  return img;
}

function png(S, rgba) {
  const crcTable = [];
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    crcTable[n] = c >>> 0;
  }
  const crc = (buf) => {
    let c = 0xffffffff;
    for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  };
  const chunk = (type, data) => {
    const len = Buffer.alloc(4);
    len.writeUInt32BE(data.length);
    const td = Buffer.concat([Buffer.from(type), data]);
    const cc = Buffer.alloc(4);
    cc.writeUInt32BE(crc(td));
    return Buffer.concat([len, td, cc]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(S, 0);
  ihdr.writeUInt32BE(S, 4);
  ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
  const raw = Buffer.alloc(S * (S * 4 + 1));
  for (let y = 0; y < S; y++) {
    raw[y * (S * 4 + 1)] = 0; // filter: none
    rgba.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4);
  }
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk("IHDR", ihdr),
    chunk("IDAT", deflateSync(raw, { level: 9 })),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}

const targets = [
  ["icon-192.png", 192, {}],
  ["icon-512.png", 512, {}],
  ["icon-512-maskable.png", 512, { maskable: true }],
  ["apple-touch-icon.png", 180, { opaque: true }],
];
for (const [name, size, opts] of targets) {
  writeFileSync(join(outDir, name), png(size, draw(size, opts)));
  console.log("wrote", name);
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/game4.git