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:
parent
914af42210
commit
31cb594cb0
12 changed files with 296 additions and 13 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -40,3 +40,4 @@ data/
|
|||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.tar.gz
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-192.png
Normal file
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
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
BIN
public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
32
public/manifest.webmanifest
Normal file
32
public/manifest.webmanifest
Normal 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
47
public/sw.js
Normal 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
168
scripts/generate-icons.mjs
Normal 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' : ''})`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -58,12 +58,10 @@ function close(): void {
|
|||
<div class="u-top">
|
||||
<span class="u-icon">{{ def.icon }}</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 class="u-desc">{{ def.desc }}</div>
|
||||
<div class="u-effect">{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}</div>
|
||||
<div class="u-bottom">
|
||||
<button
|
||||
class="buy"
|
||||
:disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)"
|
||||
|
|
@ -71,6 +69,10 @@ function close(): void {
|
|||
>
|
||||
{{ 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
10
src/main.ts
10
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 */
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue