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.
This commit is contained in:
Tronax 2026-08-17 14:53:47 +02:00
parent 914af42210
commit 31cb594cb0
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
12 changed files with 296 additions and 13 deletions

1
.gitignore vendored
View file

@ -40,3 +40,4 @@ data/
*.db-journal *.db-journal
*.db-wal *.db-wal
*.db-shm *.db-shm
*.tar.gz

View file

@ -4,6 +4,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="TRXTD Tower Defense im Browser" /> <meta name="description" content="TRXTD Tower Defense im Browser" />
<meta name="theme-color" content="#0e131a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="TRXTD" />
<title>TRXTD Tower Defense</title> <title>TRXTD Tower Defense</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,32 @@
{
"name": "TRXTD Tower Defense",
"short_name": "TRXTD",
"description": "Tower Defense im Browser verteidige deine Basis gegen 20 Wellen!",
"lang": "de",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0e131a",
"theme_color": "#0e131a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

47
public/sw.js Normal file
View file

@ -0,0 +1,47 @@
/**
* TRXTD service worker makes the game installable as an app (PWA) and
* keeps it playable offline.
*
* Strategy: network-first with runtime caching. The deployed build uses
* hashed asset names, so index.html must always come from the network to
* pick up new deploys; the cache is only a fallback when offline.
* API calls and WebSocket upgrades are never intercepted.
*/
const CACHE = 'trxtd-v1'
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
)
})
self.addEventListener('fetch', (event) => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== self.location.origin) return // CDN/external resources
if (url.pathname.startsWith('/api/')) return // auth & game API: network only
if (url.pathname.startsWith('/ws')) return
event.respondWith(
fetch(req)
.then((res) => {
if (res && res.status === 200 && res.type === 'basic') {
const copy = res.clone()
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {})
}
return res
})
.catch(() =>
caches.match(req).then((hit) => hit || caches.match('/')),
),
)
})

168
scripts/generate-icons.mjs Normal file
View file

@ -0,0 +1,168 @@
/**
* 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' : ''})`)
}

View file

@ -447,6 +447,7 @@ const MIME = {
'.js': 'text/javascript; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png', '.png': 'image/png',
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.jpeg': 'image/jpeg',

View file

@ -58,19 +58,21 @@ function close(): void {
<div class="u-top"> <div class="u-top">
<span class="u-icon">{{ def.icon }}</span> <span class="u-icon">{{ def.icon }}</span>
<span class="u-name">{{ def.name }}</span> <span class="u-name">{{ def.name }}</span>
<span class="pips">
<i v-for="n in def.maxLevel" :key="n" :class="{ on: levelOf(def.id) >= n }" />
</span>
</div> </div>
<div class="u-desc">{{ def.desc }}</div> <div class="u-desc">{{ def.desc }}</div>
<div class="u-effect">{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}</div> <div class="u-effect">{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}</div>
<button <div class="u-bottom">
class="buy" <button
:disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)" class="buy"
@click="buy(def)" :disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)"
> @click="buy(def)"
{{ nextCost(def) === null ? 'MAX' : `💎 ${nextCost(def)}` }} >
</button> {{ nextCost(def) === null ? 'MAX' : `💎 ${nextCost(def)}` }}
</button>
<span class="pips" :title="`${levelOf(def.id)} / ${def.maxLevel}`">
<i v-for="n in def.maxLevel" :key="n" :class="{ on: levelOf(def.id) >= n }" />
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -178,6 +180,7 @@ h2 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0;
} }
.u-icon { .u-icon {
font-size: 18px; font-size: 18px;
@ -185,11 +188,18 @@ h2 {
.u-name { .u-name {
font-weight: 700; font-weight: 700;
font-size: 13.5px; font-size: 13.5px;
flex: 1; }
.u-bottom {
margin-top: 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
} }
.pips { .pips {
display: flex; display: flex;
gap: 3px; gap: 3px;
flex-shrink: 0;
} }
.pips i { .pips i {
width: 8px; width: 8px;
@ -211,7 +221,6 @@ h2 {
font-weight: 700; font-weight: 700;
} }
.buy { .buy {
margin-top: 4px;
background: var(--panel-inset); background: var(--panel-inset);
border: 1px solid #3d6f9e; border: 1px solid #3d6f9e;
color: var(--text); color: var(--text);
@ -221,7 +230,6 @@ h2 {
padding: 6px 10px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
align-self: flex-start;
} }
.buy:hover:not(:disabled) { .buy:hover:not(:disabled) {
border-color: var(--accent); border-color: var(--accent);
@ -235,4 +243,12 @@ h2 {
color: #ff8a7a; color: #ff8a7a;
font-size: 13px; font-size: 13px;
} }
/* Phones: tighter modal padding, one upgrade column */
@media (max-width: 560px) {
.card {
padding: 16px 14px;
max-height: 92vh;
}
}
</style> </style>

View file

@ -5,3 +5,13 @@ import { initAuth } from './game/auth'
createApp(App).mount('#app') createApp(App).mount('#app')
void initAuth() void initAuth()
// PWA: register the service worker (installable app + offline play).
// Dev mode is excluded so vite's HMR is never bypassed by a cached shell.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* offline support is optional ignore registration errors */
})
})
}