diff --git a/.gitignore b/.gitignore
index c74ea3d..4fd20b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,4 @@ data/
*.db-journal
*.db-wal
*.db-shm
+*.tar.gz
diff --git a/index.html b/index.html
index 85e719e..185a94c 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,14 @@
+
+
+
+
+
+
+
+
TRXTD – Tower Defense
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..0b27d1f
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..5e5faf4
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..ed85045
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..be01791
Binary files /dev/null and b/public/icons/icon-maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..8bd56c8
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -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"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..daf5cf4
--- /dev/null
+++ b/public/sw.js
@@ -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('/')),
+ ),
+ )
+})
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
new file mode 100644
index 0000000..3778d53
--- /dev/null
+++ b/scripts/generate-icons.mjs
@@ -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' : ''})`)
+}
diff --git a/server/server.mjs b/server/server.mjs
index 736ef43..80ee93a 100644
--- a/server/server.mjs
+++ b/server/server.mjs
@@ -447,6 +447,7 @@ const MIME = {
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
diff --git a/src/components/ResearchTree.vue b/src/components/ResearchTree.vue
index 7989992..3ac1e93 100644
--- a/src/components/ResearchTree.vue
+++ b/src/components/ResearchTree.vue
@@ -58,19 +58,21 @@ function close(): void {
{{ def.icon }}
{{ def.name }}
-
-
-
{{ def.desc }}
{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}
-
+
+
+
+
+
+
@@ -178,6 +180,7 @@ h2 {
display: flex;
align-items: center;
gap: 6px;
+ min-width: 0;
}
.u-icon {
font-size: 18px;
@@ -185,11 +188,18 @@ h2 {
.u-name {
font-weight: 700;
font-size: 13.5px;
- flex: 1;
+}
+.u-bottom {
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
}
.pips {
display: flex;
gap: 3px;
+ flex-shrink: 0;
}
.pips i {
width: 8px;
@@ -211,7 +221,6 @@ h2 {
font-weight: 700;
}
.buy {
- margin-top: 4px;
background: var(--panel-inset);
border: 1px solid #3d6f9e;
color: var(--text);
@@ -221,7 +230,6 @@ h2 {
padding: 6px 10px;
cursor: pointer;
font-family: inherit;
- align-self: flex-start;
}
.buy:hover:not(:disabled) {
border-color: var(--accent);
@@ -235,4 +243,12 @@ h2 {
color: #ff8a7a;
font-size: 13px;
}
+
+/* Phones: tighter modal padding, one upgrade column */
+@media (max-width: 560px) {
+ .card {
+ padding: 16px 14px;
+ max-height: 92vh;
+ }
+}
diff --git a/src/main.ts b/src/main.ts
index c1cd731..7c5f85e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,3 +5,13 @@ import { initAuth } from './game/auth'
createApp(App).mount('#app')
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 */
+ })
+ })
+}