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 */ + }) + }) +}