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.
168 lines
5.5 KiB
JavaScript
168 lines
5.5 KiB
JavaScript
/**
|
||
* 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' : ''})`)
|
||
}
|