TRXTD/scripts/generate-icons.mjs
Tronax 31cb594cb0
feat(pwa): installable app + fix research tree pip overflow
Research tree UI:
- The level pips shared the header row with the upgrade name and fought
  for width; long names like "Festungsmauern" (5 pips) pushed the pips
  out of the upgrade card. Pips now sit in the bottom row next to the
  buy button (space-between), where width is plentiful, and carry a
  "level / max" tooltip
- Tighter modal padding on phones (max-width 560px), scrollable at 92vh

PWA (installable as app in mobile browsers):
- public/manifest.webmanifest: standalone display, any orientation,
  TRXTD theme colors, German lang, start_url "/"
- public/sw.js: network-first service worker with runtime caching.
  index.html is always fetched fresh so new deploys are picked up
  immediately; the cache only serves as offline fallback. API calls
  and cross-origin requests are never intercepted
- Icon set generated by scripts/generate-icons.mjs (pure Node PNG
  encoder, zero image dependencies): gold tower on the game-themed
  dark gradient in 192px, 512px, maskable 512px (motif inside the safe
  zone) and 180px apple-touch-icon
- index.html: manifest link, favicon, apple-touch-icon, theme-color,
  mobile-web-app-capable, apple-mobile-web-app meta tags
- src/main.ts: service worker registration in production builds only
  (dev HMR stays untouched)
- server.mjs: serve .webmanifest as application/manifest+json (Chrome
  requires the correct MIME type for installability)

Verified against the production server: manifest (application/
manifest+json), icons (image/png) and sw.js (text/javascript) respond
with 200, index.html references all PWA tags; npm test 48/48 green.
2026-08-17 14:53:58 +02:00

168 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' : ''})`)
}