diff --git a/Dockerfile b/Dockerfile index 43bfbcd..af9966a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,16 @@ LABEL org.opencontainers.image.title="Mineflappy" \ org.opencontainers.image.description="Minecraft Flappy Bird - single file static game" \ org.opencontainers.image.licenses="MIT" -# Replace nginx's placeholder page with the game +# Replace nginx's placeholder page with the game + PWA shell RUN rm -f /usr/share/nginx/html/index.html -COPY index.html /usr/share/nginx/html/index.html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/index.html +COPY manifest.webmanifest /usr/share/nginx/html/manifest.webmanifest +COPY sw.js /usr/share/nginx/html/sw.js +COPY icon-192.png /usr/share/nginx/html/icon-192.png +COPY icon-512.png /usr/share/nginx/html/icon-512.png +COPY apple-touch-icon.png /usr/share/nginx/html/apple-touch-icon.png +COPY favicon-32.png /usr/share/nginx/html/favicon-32.png +COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/README.md b/README.md index f889f96..048926e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ in code. open index.html # that's it - it runs straight from the filesystem ``` +It fills the whole screen on any device - phone, tablet or desktop - with no +letterboxing and no border, and it can be installed as a standalone app. + --- ## Deploy to Coolify @@ -75,6 +78,24 @@ docker run --rm -p 8080:80 mineflappy | `Dockerfile` | nginx:alpine image that serves `index.html` | | `nginx.conf` | Server config: no-cache on the game, gzip, `/healthz` | | `docker-compose.yml` | Optional compose deployment | +| `manifest.webmanifest` | Web app manifest - name, icons, standalone display | +| `sw.js` | Service worker - caches the app so it works offline | +| `icon-192.png` / `icon-512.png` | App icons (generated from the chicken sprite) | +| `apple-touch-icon.png` / `favicon-32.png` | iOS home screen + browser tab icons | + +--- + +## Install it as an app (PWA) + +The game is a full-screen Progressive Web App: + +- **iPhone / iPad** - Safari -> Share -> **Add to Home Screen** +- **Android** - Chrome -> menu -> **Install app** +- **Desktop Chrome/Edge** - the install icon in the address bar, or the + **INSTALL APP** button in the corner of the game + +Installed, it launches fullscreen with no browser chrome, keeps working +offline, and remembers your score. --- @@ -101,4 +122,5 @@ screen. `P` to pause, `C` on the game-over screen to change bird. In 2-player mode `Space` is player 1 and `↑` is player 2. -Progress is stored in `localStorage` under `mineflappy_v2`. +Progress is stored in `localStorage` under `mineflappy_v2` (per origin, so +`https://mineflappy.apphub.co.za` keeps its own scores). diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 0000000..b10cc90 Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/favicon-32.png b/favicon-32.png new file mode 100644 index 0000000..b8732b4 Binary files /dev/null and b/favicon-32.png differ diff --git a/icon-192.png b/icon-192.png new file mode 100644 index 0000000..0af47da Binary files /dev/null and b/icon-192.png differ diff --git a/icon-512.png b/icon-512.png new file mode 100644 index 0000000..e14a050 Binary files /dev/null and b/icon-512.png differ diff --git a/index.html b/index.html index fa98133..d27bb48 100644 --- a/index.html +++ b/index.html @@ -4,24 +4,26 @@ - + + + + + + + + + Mineflappy — Minecraft Flappy Bird @@ -52,6 +54,29 @@ const cvs = document.getElementById('game'); const ctx = cvs.getContext('2d'); ctx.imageSmoothingEnabled = false; +/* The game is drawn in a fixed 480x640 world, then scaled to fit the + screen. The world is bottom-aligned so the ground always sits on the + bottom edge, and the sky is painted full-bleed behind it. */ +const VIEW = { w: 480, h: 640, scale: 1, ox: 0, oy: 0, dpr: 1 }; +/* The buffer always matches the canvas' real laid-out size, so the + drawing can never drift out of step with what is on screen. */ +function resize() { + const r = cvs.getBoundingClientRect(); + const vw = Math.max(240, Math.round(r.width || window.innerWidth || 480)); + const vh = Math.max(320, Math.round(r.height || window.innerHeight || 640)); + const dpr = Math.min(window.devicePixelRatio || 1, 3); + if (vw === VIEW.w && vh === VIEW.h && dpr === VIEW.dpr) return; + VIEW.w = vw; VIEW.h = vh; VIEW.dpr = dpr; + cvs.width = Math.round(vw * dpr); + cvs.height = Math.round(vh * dpr); + VIEW.scale = Math.min(vw / 480, vh / 640); + VIEW.ox = (vw - 480 * VIEW.scale) / 2; + VIEW.oy = vh - 640 * VIEW.scale; +} +window.addEventListener('resize', resize); +window.addEventListener('orientationchange', resize); +resize(); + /* ---------------- utils ---------------- */ function hash(x, y, s) { let n = (x | 0) * 374761393 + (y | 0) * 668265263 + (s | 0) * 1442695041; @@ -1198,7 +1223,7 @@ function update() { function spawnDrops(n, vmin, vmax) { S.drops = []; for (let i = 0; i < n; i++) { - S.drops.push({ x: vrnd(0, W), y: vrnd(0, H), v: vrnd(vmin, vmax), d: vrnd(-0.6, 0.6) }); + S.drops.push({ x: vrnd(0, VIEW.w), y: vrnd(0, VIEW.h), v: vrnd(vmin, vmax), d: vrnd(-0.6, 0.6) }); } } function updateWeather() { @@ -1227,26 +1252,30 @@ function biomeMix(key) { return Array.isArray(a) ? a.map((v, i) => mix(v, b[i], ease(S.biomeT))) : mix(a, b, ease(S.biomeT)); } function drawSky() { - const grd = ctx.createLinearGradient(0, 0, 0, GROUND_Y); const c = biomeMix('sky'); + const horizon = VIEW.oy + GROUND_Y * VIEW.scale; + const grd = ctx.createLinearGradient(0, 0, 0, Math.max(1, horizon)); grd.addColorStop(0, c[0]); grd.addColorStop(1, c[1]); ctx.fillStyle = grd; - ctx.fillRect(0, 0, W, GROUND_Y); + ctx.fillRect(0, 0, VIEW.w, VIEW.h); } function drawStars() { const a = clamp((S.night - 0.35) / 0.4, 0, 1); if (a <= 0) return; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); for (const s of S.stars) { const tw = 0.65 + 0.35 * Math.sin(S.t / 22 + s.p); ctx.fillStyle = 'rgba(255,255,255,' + (a * tw).toFixed(3) + ')'; - ctx.fillRect(s.x | 0, s.y | 0, s.s, s.s); + ctx.fillRect(Math.round(s.x / 480 * VIEW.w), Math.round(s.y / 640 * VIEW.h), s.s, s.s); } } function drawSun() { const n = S.night; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); if (n < 0.62) { - const x = 388 - n * 180, y = 44 + n * 300, s = 34; + const s = 34 * Math.max(0.75, Math.min(1.5, VIEW.scale)); + const x = VIEW.w * 0.74 - n * VIEW.w * 0.3, y = 40 + n * VIEW.h * 0.4; const fade = clamp((0.62 - n) / 0.2, 0, 1); ctx.save(); ctx.globalAlpha = fade; @@ -1267,7 +1296,8 @@ function drawSun() { } if (n > 0.4) { const a = clamp((n - 0.4) / 0.25, 0, 1); - const x = 90 + n * 90, y = 70 - n * 20, s = 28; + const s = 28 * Math.max(0.75, Math.min(1.5, VIEW.scale)); + const x = VIEW.w * 0.16 + n * VIEW.w * 0.14, y = 60 - n * 24; ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(230,238,255,.25)'; @@ -1296,8 +1326,11 @@ function drawClouds() { } function drawHills(img, y, speed) { const off = -((S.scroll * speed) % 640); - ctx.drawImage(img, Math.round(off), y); - ctx.drawImage(img, Math.round(off + 640), y); + const left = -VIEW.ox / VIEW.scale - 640; + const right = (VIEW.w - VIEW.ox) / VIEW.scale + 640; + for (let x = Math.floor(left / 640) * 640 + off; x < right; x += 640) { + ctx.drawImage(img, Math.round(x), y); + } } function drawPipe(p) { const th = p.theme; @@ -1391,8 +1424,11 @@ function drawParts() { function drawGround() { const tile = GROUND_TILES[S.biomeTo]; const off = -((S.scroll) % 512); - ctx.drawImage(tile, Math.round(off), GROUND_Y); - ctx.drawImage(tile, Math.round(off + 512), GROUND_Y); + const left = -VIEW.ox / VIEW.scale - 512; + const right = (VIEW.w - VIEW.ox) / VIEW.scale + 512; + for (let x = Math.floor(left / 512) * 512 + off; x < right; x += 512) { + ctx.drawImage(tile, Math.round(x), GROUND_Y); + } } function drawWeather() { if (S.weather === 'rain') { @@ -1401,7 +1437,7 @@ function drawWeather() { ctx.beginPath(); for (const d of S.drops) { d.y += d.v; d.x += d.d; - if (d.y > H) { d.y = -10; d.x = vrnd(0, W); } + if (d.y > VIEW.h) { d.y = -10; d.x = vrnd(0, VIEW.w); } ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - 2, d.y + 9); } @@ -1409,19 +1445,25 @@ function drawWeather() { } else if (S.weather === 'snow') { for (const d of S.drops) { d.y += d.v * 0.35; d.x += d.d + Math.sin(S.t / 20 + d.y) * 0.4; - if (d.y > H) { d.y = -6; d.x = vrnd(0, W); } + if (d.y > VIEW.h) { d.y = -6; d.x = vrnd(0, VIEW.w); } ctx.fillStyle = 'rgba(255,255,255,.85)'; ctx.fillRect(d.x | 0, d.y | 0, 3, 3); } } else if (S.weather === 'embers') { for (const d of S.drops) { d.y -= d.v * 0.5; d.x += Math.sin(S.t / 18 + d.y) * 0.5; - if (d.y < -8) { d.y = H + 8; d.x = vrnd(0, W); } + if (d.y < -8) { d.y = VIEW.h + 8; d.x = vrnd(0, VIEW.w); } ctx.fillStyle = 'rgba(232,134,42,' + (0.3 + 0.5 * Math.random()).toFixed(2) + ')'; ctx.fillRect(d.x | 0, d.y | 0, 2, 2); } } } +/* a rect that covers the entire screen, expressed in world units */ +function screenRect() { + const l = -VIEW.ox / VIEW.scale, t = -VIEW.oy / VIEW.scale; + const r = (VIEW.w - VIEW.ox) / VIEW.scale, b = (VIEW.h - VIEW.oy) / VIEW.scale; + return { x: l, y: t, w: r - l, h: b - t }; +} function panel(x, y, w, h) { ctx.fillStyle = 'rgba(12,14,20,.74)'; ctx.fillRect(x, y, w, h); @@ -1439,20 +1481,17 @@ function selIconX(i) { return Math.round((W - total) / 2 + i * SEL_ROW.slot + (SEL_ROW.slot - SEL_ROW.size) / 2); } const BADGE = { x: W - 50, y: 12, s: 40 }; -/* How many canvas pixels make a 44 CSS px touch target at the current scale? */ +/* How many world units make a 44 CSS px touch target? */ function touchPx() { - const r = cvs.getBoundingClientRect(); - if (!r || !r.width) return 44; - return 44 * (W / r.width); + return 44 / Math.max(0.2, VIEW.scale); } function inBadge(x, y) { - const t = touchPx() * 0.5; - return x >= BADGE.x - t && x <= BADGE.x + BADGE.s + t && - y >= BADGE.y - t && y <= BADGE.y + BADGE.s + t; + const b = badgeRect('right'), m = 12; + return x >= b.x - m && x <= b.x + b.s + m && y >= b.y - m && y <= b.y + b.s + m; } function inBadge2(x, y) { - const t = touchPx() * 0.5; - return x >= 10 - t && x <= 50 + t && y >= 32 - t && y <= 72 + t; + const b = badgeRect('left'), m = 12; + return x >= b.x - m && x <= b.x + b.s + m && y >= b.y - m && y <= b.y + b.s + m; } const SETTINGS = [ { id: 'diff', label: 'DIFFICULTY', opts: ['EASY', 'NORMAL', 'HARD'], get: () => ['easy', 'normal', 'hard'].indexOf(S.diff), set: v => { S.diff = ['easy', 'normal', 'hard'][v]; SAVE.diff = S.diff; writeSave(); } }, @@ -1530,42 +1569,50 @@ function drawSettings() { if (S.daily) ptext('CODE ' + S.code, 240, SET_ROW_Y + 78, 2, 'center', '#5BE0E8', 'rgba(0,0,0,.35)'); } +function badgeRect(side) { + const s = VIEW.scale, size = 40 * s; + return { x: side === 'right' ? VIEW.w - size - 10 : 10, y: 12 * s, s: size, r: s }; +} function drawBadge(p, side) { const ch = ROSTER[p.char]; if (!ch.key) return; - const x = side === 'right' ? BADGE.x : 10; - const y = side === 'right' ? BADGE.y : 32, s = BADGE.s; + const s = VIEW.scale, b = badgeRect(side); const ready = p.ab.cd <= 0; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); ctx.fillStyle = ready ? 'rgba(16,19,26,.92)' : 'rgba(16,19,26,.78)'; - ctx.fillRect(x, y, s, s); + ctx.fillRect(b.x, b.y, b.s, b.s); ctx.fillStyle = 'rgba(0,0,0,.5)'; - ctx.fillRect(x + 2, y + 2, s - 4, s - 4); + ctx.fillRect(b.x + 2 * s, b.y + 2 * s, b.s - 4 * s, b.s - 4 * s); ctx.strokeStyle = ready ? ch.tint : 'rgba(255,255,255,.3)'; - ctx.lineWidth = 2; - ctx.strokeRect(x + 1, y + 1, s - 2, s - 2); + ctx.lineWidth = 2 * s; + ctx.strokeRect(b.x + s, b.y + s, b.s - 2 * s, b.s - 2 * s); if (!ready) { ctx.fillStyle = 'rgba(0,0,0,.62)'; - ctx.fillRect(x + 2, y + 2, s - 4, (s - 4) * (p.ab.cd / ch.cd)); + ctx.fillRect(b.x + 2 * s, b.y + 2 * s, b.s - 4 * s, (b.s - 4 * s) * (p.ab.cd / ch.cd)); } - if (ready) ptext(ch.ab[0], x + s / 2, y + s / 2 - 2, 4, 'center', ch.tint); + if (ready) ptext(ch.ab[0], b.x + b.s / 2, b.y + (b.s - 28 * s) / 2, 4 * s, 'center', ch.tint); else { - ptext(ch.ab[0], x + s / 2, y + 13, 3, 'center', 'rgba(255,255,255,.75)'); - ptext(Math.ceil(p.ab.cd / 60) + 'S', x + s / 2, y + s - 10, 2, 'center', '#fff'); + ptext(ch.ab[0], b.x + b.s / 2, b.y + 4 * s, 3 * s, 'center', 'rgba(255,255,255,.75)'); + ptext(Math.ceil(p.ab.cd / 60) + 'S', b.x + b.s / 2, b.y + b.s - 12 * s, 2 * s, 'center', '#fff'); } } - function drawToasts() { + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); + const s = VIEW.scale; for (let i = 0; i < S.toasts.length; i++) { const t = S.toasts[i]; - const y = 68 + i * 38; const inT = clamp((150 - t.life) / 10, 0, 1); - const x = W - 190 + (1 - inT) * 30; + const pw = 184 * s, ph = 32 * s; + const x = VIEW.w - pw - 10 + (1 - inT) * 30 * s; + const y = (68 + i * 38) * s; ctx.fillStyle = 'rgba(16,19,26,.92)'; - ctx.fillRect(x, y, 184, 32); + ctx.fillRect(x, y, pw, ph); ctx.fillStyle = 'rgba(242,199,68,.9)'; - ctx.fillRect(x, y, 4, 32); - ptext(t.t.length > 14 ? t.t.slice(0, 14) : t.t, x + 12, y + 5, 2, 'left', '#F2C744'); - if (t.s) ptext(t.s.length > 28 ? t.s.slice(0, 28) : t.s, x + 12, y + 20, 1, 'left', 'rgba(255,255,255,.75)'); + ctx.fillRect(x, y, 4 * s, ph); + const title = t.t.length > 14 ? t.t.slice(0, 14) : t.t; + const sub = t.s.length > 28 ? t.s.slice(0, 28) : t.s; + ptext(title, x + 12 * s, y + 5 * s, 2 * s, 'left', '#F2C744'); + if (sub) ptext(sub, x + 12 * s, y + 20 * s, 1 * s, 'left', 'rgba(255,255,255,.75)'); } } function drawBuffs(p) { @@ -1577,28 +1624,33 @@ function drawBuffs(p) { if (p.buf.magnet > 0) list.push(['MN', '#5BE0E8']); if (p.buf.fly > 0) list.push(['FL', '#B14EC8']); if (p.buf.slow > 0) list.push(['SL', '#9BB6E8']); - const bw = list.length * 22; + if (!list.length) return; + const s = VIEW.scale, sz = 18 * s, gap = 22 * s; + const total = list.length * gap; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); for (let i = 0; i < list.length; i++) { - const x = Math.round(W / 2 - bw / 2 + i * 22), y = 74; + const x = Math.round(VIEW.w / 2 - total / 2 + i * gap), y = 74 * s; ctx.fillStyle = 'rgba(16,19,26,.8)'; - ctx.fillRect(x, y, 18, 18); + ctx.fillRect(x, y, sz, sz); ctx.fillStyle = list[i][1]; - ctx.fillRect(x + 1, y + 1, 16, 16); - ptext(list[i][0], x + 9, y + 4, 1, 'center', '#101018'); + ctx.fillRect(x + s, y + s, sz - 2 * s, sz - 2 * s); + ptext(list[i][0], x + sz / 2, y + sz / 2 - 3.5 * s, 1 * s, 'center', '#101018'); } } function drawBanner() { if (S.banner <= 0) return; const a = clamp(S.banner / 40, 0, 1); - const y = 150; + const s = VIEW.scale; + const y = 150 * s, hgt = 46 * s; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(12,14,20,.8)'; - ctx.fillRect(0, y, W, 46); + ctx.fillRect(0, y, VIEW.w, hgt); ctx.fillStyle = '#F2C744'; - ctx.fillRect(0, y, W, 3); - ctx.fillRect(0, y + 43, W, 3); - ptext(BIOMES[S.biomeTo].name, W / 2, y + 12, 4, 'center', '#fff'); + ctx.fillRect(0, y, VIEW.w, 3 * s); + ctx.fillRect(0, y + hgt - 3 * s, VIEW.w, 3 * s); + ptext(BIOMES[S.biomeTo].name, VIEW.w / 2, y + 12 * s, 4 * s, 'center', '#fff'); ctx.restore(); } function drawGhost() { @@ -1612,16 +1664,24 @@ function drawGhost() { /* ---------------- render ---------------- */ function render() { + const dpr = VIEW.dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, VIEW.w, VIEW.h); + + drawSky(); + drawStars(); + drawSun(); + + /* ---- the 480x640 world, scaled to fit and bottom aligned ---- */ ctx.save(); + ctx.translate(VIEW.ox, VIEW.oy); + ctx.scale(VIEW.scale, VIEW.scale); if (S.shake > 0) { S.shake *= 0.86; if (S.shake < 0.4) S.shake = 0; ctx.translate(vrnd(-S.shake, S.shake), vrnd(-S.shake, S.shake)); } - drawSky(); - drawStars(); - drawSun(); drawClouds(); ctx.save(); @@ -1639,40 +1699,34 @@ function render() { } drawGround(); ctx.restore(); + ctx.restore(); drawWeather(); /* night tint */ if (S.night > 0) { ctx.fillStyle = 'rgba(12,16,60,' + (S.night * 0.45).toFixed(3) + ')'; - ctx.fillRect(0, 0, W, H); + ctx.fillRect(0, 0, VIEW.w, VIEW.h); } if (S.flash > 0) { ctx.fillStyle = 'rgba(255,255,255,' + (S.flash / 5 * 0.5).toFixed(3) + ')'; - ctx.fillRect(0, 0, W, H); + ctx.fillRect(0, 0, VIEW.w, VIEW.h); } if (S.deny > 0) { ctx.fillStyle = 'rgba(255,80,80,' + (S.deny / 12 * 0.18).toFixed(3) + ')'; - ctx.fillRect(0, 0, W, H); + ctx.fillRect(0, 0, VIEW.w, VIEW.h); } - /* HUD */ - if (S.state !== 'title') { - const p = P1(); - if (S.mode === '2p') ptext('P2 ' + P2().score, 10, 12, 2, 'left', '#5BE0E8', 'rgba(0,0,0,.45)'); - else ptext('BEST ' + SAVE.best, 10, 12, 2, 'left', '#E8F0FF', 'rgba(0,0,0,.45)'); - ptext(String(p.score), W / 2, 26, 6, 'center', '#fff', 'rgba(0,0,0,.45)'); - drawBuffs(p); - if (S.mode === '2p') drawBadge(P2(), 'left'); - drawBadge(p, 'right'); - drawToasts(); - drawBanner(); - } + /* ---- overlays live inside the game world ---- */ + ctx.save(); + ctx.translate(VIEW.ox, VIEW.oy); + ctx.scale(VIEW.scale, VIEW.scale); /* ---- title ---- */ if (S.state === 'title') { + const sr = screenRect(); ctx.fillStyle = 'rgba(10,14,22,.58)'; - ctx.fillRect(0, 0, W, H); + ctx.fillRect(sr.x, sr.y, sr.w, sr.h); ptext('MINECRAFT', W / 2, 30, 5, 'center', '#F4F4F4'); ptext('FLAPPY BIRD', W / 2, 72, 5, 'center', '#8BE04A'); drawSelect(); @@ -1686,7 +1740,8 @@ function render() { /* ---- game over ---- */ if (S.state === 'over') { ctx.fillStyle = 'rgba(10,14,22,.6)'; - ctx.fillRect(0, 0, W, H); + const sr = screenRect(); + ctx.fillRect(sr.x, sr.y, sr.w, sr.h); if (S.mode === '2p') { const w1 = S.died === 1; ptext(w1 ? 'PLAYER 1 WINS' : 'PLAYER 2 WINS', W / 2, 150, 4, 'center', w1 ? '#8BE04A' : '#5BE0E8'); @@ -1710,11 +1765,30 @@ function render() { /* ---- paused ---- */ if (S.state === 'paused') { + const sr = screenRect(); ctx.fillStyle = 'rgba(10,14,22,.6)'; - ctx.fillRect(0, 0, W, H); + ctx.fillRect(sr.x, sr.y, sr.w, sr.h); ptext('PAUSED', W / 2, 280, 6, 'center', '#fff'); ptext('PRESS P OR SPACE TO RESUME', W / 2, 330, 2, 'center', '#BFD8FF'); } + + ctx.restore(); + + /* ---- HUD sits on the real screen, whatever the shape ---- */ + if (S.state !== 'title') { + const p = P1(); + const s = VIEW.scale; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (S.mode === '2p') ptext('P2 ' + P2().score, 10, 12 * s, 2 * s, 'left', '#5BE0E8', 'rgba(0,0,0,.45)'); + else ptext('BEST ' + SAVE.best, 10, 12 * s, 2 * s, 'left', '#E8F0FF', 'rgba(0,0,0,.45)'); + ptext(String(p.score), VIEW.w / 2, 14 * s, 6 * s, 'center', '#fff', 'rgba(0,0,0,.45)'); + drawBuffs(p); + if (S.mode === '2p') drawBadge(P2(), 'left'); + drawBadge(p, 'right'); + drawToasts(); + drawBanner(); + } + drawInstallBtn(); } /* ---------------- input ---------------- */ @@ -1739,31 +1813,36 @@ function cycleSetting(i) { cvs.addEventListener('pointerdown', e => { e.preventDefault(); const r = cvs.getBoundingClientRect(); - const sx = (e.clientX - r.left) / r.width * W; - const sy = (e.clientY - r.top) / r.height * H; + const cx = (e.clientX - r.left) / r.width * VIEW.w; /* screen px */ + const cy = (e.clientY - r.top) / r.height * VIEW.h; + const wx = (cx - VIEW.ox) / VIEW.scale; /* world units */ + const wy = (cy - VIEW.oy) / VIEW.scale; audio(); + if (inInstallBtn(cx, cy)) { doInstall(); return; } + if (S.state === 'play') { - /* ability badges */ - if (cur().key && inBadge(sx, sy)) { useAbility(P1()); return; } - if (S.mode === '2p' && charOf(1).key && inBadge2(sx, sy)) { useAbility(P2()); return; } - if (S.mode === '2p') { flap(sx > W / 2 ? P2() : P1()); return; } + /* ability badges live on the screen */ + if (cur().key && inBadge(cx, cy)) { useAbility(P1()); return; } + if (S.mode === '2p' && charOf(1).key && inBadge2(cx, cy)) { useAbility(P2()); return; } + if (S.mode === '2p') { flap(cx > VIEW.w / 2 ? P2() : P1()); return; } flap(P1()); return; } if (S.state === 'title') { + /* the picker lives inside the game world */ for (let i = 0; i < ROSTER.length; i++) { const ix = selIconX(i); const pad = (SEL_ROW.slot - SEL_ROW.size) / 2; - if (sx >= ix - pad && sx <= ix + SEL_ROW.size + pad && - sy >= SEL_ROW.y - 8 && sy <= SEL_ROW.y + SEL_ROW.size + 8) { + if (wx >= ix - pad && wx <= ix + SEL_ROW.size + pad && + wy >= SEL_ROW.y - 8 && wy <= SEL_ROW.y + SEL_ROW.size + 8) { selectChar(i); return; } } for (let i = 0; i < SETTINGS.length; i++) { const rr = setRowRect(i); const t = touchPx() * 0.4; - if (sx >= rr.x && sx <= rr.x + rr.w && sy >= rr.y - t && sy <= rr.y + rr.h + t) { + if (wx >= rr.x && wx <= rr.x + rr.w && wy >= rr.y - t && wy <= rr.y + rr.h + t) { cycleSetting(i); return; } } @@ -1808,6 +1887,48 @@ document.addEventListener('visibilitychange', () => { if (document.hidden && S.state === 'play') { S.prevState = 'play'; S.state = 'paused'; } }); +/* ---------------- install as an app ---------------- */ +let installPrompt = null; +let installed = false; +window.addEventListener('beforeinstallprompt', e => { + e.preventDefault(); + installPrompt = e; +}); +window.addEventListener('appinstalled', () => { installed = true; installPrompt = null; }); +function installRect() { + const s = Math.max(0.75, Math.min(1.6, VIEW.scale)); + const w = 104 * s, h = 30 * s; + return { x: VIEW.w - w - 14, y: VIEW.h - h - 14, w: w, h: h }; +} +function inInstallBtn(x, y) { + if (!installPrompt || installed) return false; + const r = installRect(); + return x >= r.x - 6 && x <= r.x + r.w + 6 && y >= r.y - 6 && y <= r.y + r.h + 6; +} +function doInstall() { + if (!installPrompt) return; + installPrompt.prompt(); + installPrompt.userChoice.finally(() => { installPrompt = null; }); +} +function drawInstallBtn() { + if (!installPrompt || installed) return; + const r = installRect(), s = r.w / 104; + ctx.setTransform(VIEW.dpr, 0, 0, VIEW.dpr, 0, 0); + ctx.fillStyle = 'rgba(16,19,26,.92)'; + ctx.fillRect(r.x, r.y, r.w, r.h); + ctx.strokeStyle = '#8BE04A'; + ctx.lineWidth = 2 * s; + ctx.strokeRect(r.x + s, r.y + s, r.w - 2 * s, r.h - 2 * s); + ptext('INSTALL APP', r.x + r.w / 2, r.y + r.h / 2 - 7 * s, 2 * s, 'center', '#8BE04A'); +} + +/* offline support */ +if ('serviceWorker' in navigator && location.protocol !== 'file:') { + window.addEventListener('load', () => { + navigator.serviceWorker.register('sw.js').catch(() => {}); + }); +} + /* ---------------- loop ---------------- */ let acc = 0, last = performance.now(); function frame(now) { @@ -1816,6 +1937,7 @@ function frame(now) { if (dt > 0.25) dt = 0.25; acc += dt; while (acc >= STEP) { update(); acc -= STEP; } + resize(); render(); requestAnimationFrame(frame); } diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..25fcea5 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Mineflappy - Minecraft Flappy Bird", + "short_name": "Mineflappy", + "description": "Minecraft Flappy Bird. Six birds, six abilities, five biomes.", + "id": "/", + "start_url": ".", + "scope": ".", + "display": "standalone", + "display_override": ["fullscreen", "standalone", "minimal-ui"], + "orientation": "any", + "background_color": "#0d1420", + "theme_color": "#0d1420", + "categories": ["games", "entertainment"], + "lang": "en", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/nginx.conf b/nginx.conf index da2c361..777b26b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -19,6 +19,26 @@ server { try_files $uri =404; } + # the service worker must always be revalidated or updates never land + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Service-Worker-Allowed "/" always; + try_files $uri =404; + } + + # the web app manifest needs its own content type + location = /manifest.webmanifest { + default_type application/manifest+json; + add_header Cache-Control "public, max-age=604800" always; + try_files $uri =404; + } + + # icons can be cached hard - they only change with a version bump + location ~* \.png$ { + add_header Cache-Control "public, max-age=604800" always; + try_files $uri =404; + } + location = /healthz { access_log off; default_type text/plain; diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..ec4a7b3 --- /dev/null +++ b/sw.js @@ -0,0 +1,47 @@ +/* Mineflappy offline cache. + Cache-first for the app shell, with a background refresh so a new + deploy is picked up on the next visit. */ +const CACHE = 'mineflappy-v1'; +const ASSETS = [ + './', + './index.html', + './manifest.webmanifest', + './icon-192.png', + './icon-512.png', + './apple-touch-icon.png', + './favicon-32.png' +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE) + .then(c => c.addAll(ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + const req = e.request; + if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return; + + e.respondWith( + caches.match(req).then(hit => { + const network = 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)); + } + return res; + }).catch(() => hit || caches.match('./index.html')); + return hit || network; + }) + ); +});