From 31cb594cb07888337899a205bbc334f0ab1eefc8 Mon Sep 17 00:00:00 2001 From: Tronax Date: Mon, 17 Aug 2026 14:53:47 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + index.html | 8 ++ public/icons/apple-touch-icon.png | Bin 0 -> 1386 bytes public/icons/icon-192.png | Bin 0 -> 1455 bytes public/icons/icon-512.png | Bin 0 -> 3735 bytes public/icons/icon-maskable-512.png | Bin 0 -> 3278 bytes public/manifest.webmanifest | 32 ++++++ public/sw.js | 47 ++++++++ scripts/generate-icons.mjs | 168 +++++++++++++++++++++++++++++ server/server.mjs | 1 + src/components/ResearchTree.vue | 42 +++++--- src/main.ts | 10 ++ 12 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs 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 0000000000000000000000000000000000000000..0b27d1fd6d3c396b34c81822c208ea64e677f763 GIT binary patch literal 1386 zcmY*ZZBP?e6h%owNE!ozL5YGN7#Ip#Y=jnwvJ^$sV2X5L7(SdtG!Oz(0>L1O?hejS zi%~1KLJ2ReRiZ^jK21<-RZvi~ttcZgiGXOpNrsVCCKjX{kZOPI?z`{ZbM~D3=H7B% zd@POPO>uH^qQz~BNx;8Nj)zRb-<*4-HBL^%khmBqvAECL*PWM`xwx~xv1kq>Aoxlv zw#Kuoem{C>VHm6JkDIq2tp^4<YkBT`poj}G)%2T)-jJzpcO(lTu>K@Q^3MyZ$ve+HamY}atGJ7?{zB*s0~-dsRi}+Fx?PO+ zlDY_pnu5Z)9$4jkD8pSMg;nd8cy7McGq7(M==RJ)G+=a3L8&sNzBN+uy6f_MsP<(S zr>6!?#coylw0nk`FE@49(38d&OXT8ny|*)TcR4xjd#@M4xz=;a`yI9Bpw{NzxncU+wC{&Hq72HbW3@xQk|1|qhT*5`SE5%UqH!3+dco2D zu*vS<@jx;#74GBWP;1+P+9Pb~f?U}nX%;S11extSG!oRl2$Hfbb};%oWG)t6#Wzmw zraK%lBq@;Z;u<>T8{D9QFjE}7nQo`@+N4)t$eiA6oCi-|(tmZ^;=*Ch_**qUX2B}?q(ohBz7{zaQ+rLpHpxpb9-p}TAbmVe{tsU>)c8!F z+$nB!zVZ3jVOBW)&L9xA)z4gtHV^Sxs4CZLwxESiLYWI4#3LS(NxYb|X2$(2>fyTT zK0?|JapM8Crmng|w0$jEE7v{GUNqC?5IUNN>U~w8yq^~Y+HZ)8JToBjP{hO0%Niob z=g4D;urn?=z b@NUj%&kg?4-mGSPhC0PtlRy#xLi1bKWc literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5e5faf4d06e2d52b1945d65ed0b9a3a04318bcba GIT binary patch literal 1455 zcmZ8h2~ZPP7=F7Ol0XVUK*=ad6e^(_L1|Gyn#H0VGBj00<&bDm7zu$0DqUBL=|Z|+JhMV2xV>u zHBaI0Yj()Z;O-<76sBAc5?}!F7;%WCK`<5AP?9~FmBY=|HJ^bVaS(W@t;Bpf0z!#Y zq>4OUqh8(B{W+Q9zjY2BoBqMd8Y!!JHU73Yt!k)*u4$-!kREAc>yD2jI2h5)d88_G4iqW-$fi%rL`WQXdIBKc6$bv+At zy_6U)jdIT%>&?atO&-TKGdxIJe8U+E7}>rWPFFDn=G#~+kik&+)2 zvq?;VFo|18NQp&@w=Kb*V=lgPFxo;p)$g&oeuk_Pji+ln|GX#W-#{4}V_28KjMusY zwI;y7mcfv9AI;(rBQqnVWOva5*7Fb` zgR6Wul_U?43Re=^~t6l0ArG!Ly zoeLtcKBGE>+j9&uAq4!6w52f#TT{RB|7QmtTG^@)zCBakls%dGsiKS2--{DC~S5>4Q}K0LJ&bppuF= zb5EIch6**r3uDkfE?qIkY|}cH&RY!i<_U=0e@>n4^l trVYfyQD!EfKu^WlZ&@3rjQ>QVr&pw*%NS61KP38_)fgm7~_lx4-9!=@e z%+fNU<=w5>?pCW)%%dM&dh{f__3_#i>Op#M{BYT&1l|uFFB^dP2zCH{Bi|SRAAmsr z5Fq73knli$c=W#F6y}<^0>~}w@nSoIA>~@O0K}F^8>yKAkVpl|guVyoIO7cv=)RtW z7~Mcxz3vcMhl6U(sH&z8Jm&%7jHHpF5wS67V?H!D!0M>in5f(yB;1KUqYOy9g{<-R zr?N;KV9DGjMmOe3Uqvb)@6`gtnghs}!pEmW*b|*j6{ubWln-^&PDvZ1DTiY@%a6C>u|mmVBp9tLZ&< z>(}E8*icM)7asf6uQKu6-Rkg1U1A`>^9vmA3&?$QjWFTUU(JqQYPprOjtw|v?&i%$ zSx;5oT7}pewd!yj~M9B{ze4rL*%dgyWTa9aNLF=SxF(3QodlaI@jeiJW@~+`bop=Y)fL6(WtL{hm@03tX#*zgK;;hPOmsu%$R31i@ex3!^%?%6)C!jC-atW=zzlyfkUwQLUT z=8#hHLX4KyxxnZkXS$>%04X8cC}E7|Q~)&zP|e$B4-JM;PDqfXcq9T`XJGnE!UuEp zwUJ-{VGSnth48QoMkimPAnB1oE(Q2MksY|EwBn$vr%aR{@9#HzcjWjhn$r;8;P ztIOlwKY1~riA6cfYpIs*WPDj;sizB_7=v;m>_t%0-lmV?kDpS_JgVU!CNu!7NCQC_vZrpZORCn-PT}My&`q3LF5%JIr!BPM=}0sX?US zioN{8)+%jDmY3 z)B1dv!=nm@K4;=zx}cPU~fuBRBY+&|4rp=I|A5^LnIg(-M2&IZ#fEN z{X&drg#O{nSJeE5XupUPO)Hl)_YBNWzCBRIn}4eMrdrz(J#g>FW{mg+8a&KHb0yj; zlcsN+*DpronD%I7lTv@ghsjd`C2UX#rd|KyE7NbUp4+fyC(Q_5HxhtWW&!7>syj!B z(st%^Mt@2h^y0*XB-WE59XOOGhqU(cea#lSY%}O7H-3#EMnL`4jUvy$L@?PJxUg^0W=T5%4o)&8VBf%Lg$p;D~lo@ zbG05_KIOLXaK6Z7Hm1-{O?<_Y*^j}icqXa?l8i{|w>#~pj2e{xAqW}N^ literal 0 HcmV?d00001 diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..be01791330cd806d34c36c66bff664623631ea95 GIT binary patch literal 3278 zcmbtWc~n$Y82{dzVOV4YVnjlLITkp{}B2+C;OGB->h zSC+`>qzUCZLyBoRW>zjF35vi$2p5JU;6{XqO!vNr&b)C@JN+?d-hKC5e!t)M-S6G? zig&}rc5Ze6AeK)G4hNv|BMmkp{MWIpsvE$5t2{Vp=Cazh`Xebr-*?r`seY9nJGAaj zTUWMw!-=s)k(wno%GL9_1}J`zbV%e@5q7!Wiei2Ej9|%5#;-IrHFe#Z?vP<~KXqOg zFiNJ?-U~ccdHJYzM`CQtgSI`Pb^eEz4Fgo#G1eBOI4K2S1Hj-Xj6`1qWgfsp2_L`o z5u?>NDcBhTfUphTkm9FpLY@BSzX2}1^w`yrGsy6VeO8d`g2Uo?eJ?pLeyU=BxUrvS z$wUhpKLnWX#OB~z^8%qLL0m$w(G&&Kjg!qC)jpJOx)&yj+3~#jf%OX^+4_0#;uy>+ z%J--{C(lk-IH|oQd`?dj>1awh)*MyEHpn|Nl&8BT@lyTa5Dk+yV5KF3#-4NUnR8}v zg?Nq?7a{M89Y}rf+Y9!n+)~9X6~`!;LJDtjIZbr)u|;3CY{bDX_@~Yx&D4*^>eJK# zemn8d;Z8CM%55<%kQe2E5wDy$Bgwq)Osvbr=tRl){qU)nNF8vqASZKPq`PM98}GLOOhWVJJwd+-;=EAP!1k11cMMOHy?JY# zW}~JusY@1ockkC(OFl1(*NTd~fWgPf5uX)Da!@EKUIWeGH3;#K0-D>Gf)H6+s*@q$ zTPb+h!n_o)pSn+|h>4(b)7>Mim@7+X4uVASxG5&Vr5hnH(pFuzN;Sxe`D1B7M%k<& zNN%1T=0zz}D)$7f{aZXW#*XX{!3IAqojDlpUz+|7Be_jyTDha$YSF&e7QMRjwB!KY{`b=`Uf*4I0V zhQ!*DID1K)HT*bHP9WGl?b=J`h2$b*797j}uvE(_rAiJ}?vP%6&ST3!bZ`SDc?Euu zK%&EBC#)BdrQbH9xP46Nk)*`@nM+-3(0z!sSC`2S54S_Lg1C#VL2?=<*A9m7RnvgE z4|>mNdm=&+5h0r|0;Y)zWfU{xEeIVf@Lp)xMoQMcry$T<4h$Y`2S(-L@kBwX6lz1C zQgUY)KuHc~CQXMb4|pB(e&K)eW&zSTGp;nV{QUUn4LK8BbLt8oj!<_0;#AY1F}$Bc zv_&pLUFEdx@$tP?NrkoDO&roKmRZjlw{0PtdackEff%8A7c8{%7`!boqbP7>_R7 z`PiX9CvrT=*~hIb1^hYkb!HZ+WJ_puCq#}^DZ(4_?~THhO`k#fP&K8WMwAY67=KKe zoa1@U1{JLARgS1uC43VEx`REp6Phl6)U!AWs#=@(4_^FZSfU%73@60A{C4Dva2*)( zRTd(nn8H3x+{v{8J^ttoZ1gTntfO^PPkzTmpdUkjWJa{%rj3-Hrwm(EL>fWY@>L6D zYvv!?kiVwdw$JlXxrs=`v!VdX9Cg!Wbu&uWv1a%k34!&&`ve|_P39bi_vDtu2DR=U zNue#+X3|Iy?SA4~VD5z$igYC>N{k=r^+H|3)9)+=5)f=Q|LmhnLS69F{VXx~-5pXK zKI^;O$wheP8tC+iRVNAKfaO~m)OIHzuRel0= zgtczcW1h&5ZfrF!`3QN1Lj{7kF&1uNXto$_{$Q { + 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 */ + }) + }) +}