/** * Generates the PWA icon set (pure Node, no image dependencies): * public/icons/icon-192.png (manifest, "any") * public/icons/icon-512.png (manifest, "any") * public/icons/icon-maskable-512.png (manifest, "maskable" – content in safe zone) * public/icons/apple-touch-icon.png (iOS home screen, 180px) * * Run: node scripts/generate-icons.mjs */ import fs from 'node:fs' import path from 'node:path' import zlib from 'node:zlib' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OUT = path.join(ROOT, 'public', 'icons') // ---------------------------------------------------------------- tiny canvas /** analytic-coverage rasterizer: supersample 4x4 point samples per pixel */ function render(size, shapes, background) { const px = new Uint8Array(size * size * 4) const S = 4 // supersampling factor for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { let r = 0 let g = 0 let b = 0 let covered = 0 for (let sy = 0; sy < S; sy++) { for (let sx = 0; sx < S; sx++) { const fx = x + (sx + 0.5) / S const fy = y + (sy + 0.5) / S const hit = topmost(shapes, fx, fy) const bg = background(fx, fy) const c = hit ?? bg r += c[0] g += c[1] b += c[2] if (hit) covered++ } } const n = S * S const i = (y * size + x) * 4 px[i] = Math.round(r / n) px[i + 1] = Math.round(g / n) px[i + 2] = Math.round(b / n) px[i + 3] = 255 } } return px } function topmost(shapes, x, y) { for (let i = shapes.length - 1; i >= 0; i--) { if (shapes[i].hit(x, y)) return shapes[i].color } return null } function rect(x1, y1, x2, y2, color) { return { hit: (x, y) => x >= x1 && x < x2 && y >= y1 && y < y2, color } } function circle(cx, cy, r, color) { return { hit: (x, y) => (x - cx) ** 2 + (y - cy) ** 2 < r * r, color } } // ---------------------------------------------------------------- design // coordinates in a 512x512 design space; `s` scales the motif into the // maskable safe zone, everything stays centered function towerShapes(s = 1) { const t = (v) => 256 + (v - 256) * s const gold = [245, 197, 66] const goldDark = [217, 169, 46] const goldDeep = [196, 148, 32] const dark = [23, 18, 10] return [ rect(t(168), t(396), t(344), t(432), goldDeep), // plinth rect(t(186), t(196), t(326), t(400), gold), // tower body rect(t(186), t(196), t(210), t(400), goldDark), // body shading (left) rect(t(186), t(166), t(218), t(198), gold), // merlon 1 rect(t(237), t(166), t(269), t(198), gold), // merlon 2 rect(t(288), t(166), t(320), t(198), gold), // merlon 3 circle(t(256), t(338), t(20) - t(0), dark), // door arch rect(t(236), t(338), t(276), t(400), dark), // door rect(t(247), t(238), t(265), t(292), dark), // window slit ] } /** vertical gradient background matching the game theme */ function bg(x, y) { const k = y / 511 const top = [30, 42, 61] const bottom = [14, 19, 26] return [ Math.round(top[0] + (bottom[0] - top[0]) * k), Math.round(top[1] + (bottom[1] - top[1]) * k), Math.round(top[2] + (bottom[2] - top[2]) * k), ] } // ---------------------------------------------------------------- PNG encoder const CRC_TABLE = (() => { const table = new Int32Array(256) 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 table[n] = c } return table })() function crc32(buf) { let c = -1 for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) return (c ^ -1) >>> 0 } function chunk(type, data) { const out = Buffer.alloc(12 + data.length) out.writeUInt32BE(data.length, 0) out.write(type, 4, 'ascii') data.copy(out, 8) out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length) return out } function encodePng(size, rgba) { const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(size, 0) ihdr.writeUInt32BE(size, 4) ihdr[8] = 8 // bit depth ihdr[9] = 6 // color type RGBA const raw = Buffer.alloc(size * (size * 4 + 1)) for (let y = 0; y < size; y++) { raw[y * (size * 4 + 1)] = 0 // filter: none Buffer.from(rgba.buffer, y * size * 4, size * 4).copy(raw, y * (size * 4 + 1) + 1) } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]) } // ---------------------------------------------------------------- generate fs.mkdirSync(OUT, { recursive: true }) const targets = [ { file: 'icon-192.png', size: 192, maskable: false }, { file: 'icon-512.png', size: 512, maskable: false }, { file: 'icon-maskable-512.png', size: 512, maskable: true }, { file: 'apple-touch-icon.png', size: 180, maskable: false }, ] for (const { file, size, maskable } of targets) { const k = size / 512 // design space -> pixel space const shapes = towerShapes(maskable ? 0.66 : 0.92).map((sh) => { // scale design coordinates into pixel space by wrapping the hit test const hit = sh.hit return { hit: (x, y) => hit(x / k, y / k), color: sh.color } }) const background = (x, y) => bg(x / k, y / k) const px = render(size, shapes, background) fs.writeFileSync(path.join(OUT, file), encodePng(size, px)) console.log(`erstellt: public/icons/${file} (${size}x${size}${maskable ? ', maskable' : ''})`) }