Fullscreen PWA: no border, installs as an app, works offline

- canvas fills the entire screen on any device; the 480x640 world is
  scaled to fit and bottom-aligned so the ground always sits on the
  bottom edge, with the sky painted full-bleed behind it
- the canvas buffer now always matches its laid-out size, so the drawing
  can never drift out of step with the screen
- HUD (score, badge, buffs, toasts, biome banner) moved to screen space
  so it stays pinned to the real screen edges
- sun and stars drawn in screen space so the sky above the play area is
  not empty
- PWA: manifest, service worker (offline), generated app icons and an
  in-game INSTALL APP button
- overlays now dim the whole screen, not just the game strip
This commit is contained in:
Dave 2026-10-23 19:52:18 +02:00
parent 33c156a7dd
commit bc72c5dc30
10 changed files with 329 additions and 92 deletions

View file

@ -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

View file

@ -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).

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 B

BIN
icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -4,24 +4,26 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#14161c">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%236FA83C'/><rect y='6' width='16' height='10' fill='%238B6141'/></svg>">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#0d1420">
<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="Mineflappy">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<title>Mineflappy — Minecraft Flappy Bird</title>
<style>
*{box-sizing:border-box}
html,body{height:100%;margin:0}
html,body{height:100%;margin:0;overflow:hidden;background:#0d1420;overscroll-behavior:none}
body{
background:radial-gradient(120% 90% at 50% 0%,#242833 0%,#12141a 60%,#0a0b0f 100%);
color:#fff;font-family:"Courier New",ui-monospace,monospace;
display:flex;align-items:center;justify-content:center;overflow:hidden;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
}
canvas{
display:block;
width:min(calc(100vw - 20px), calc((100vh - 20px) * 0.75));
height:auto;aspect-ratio:480 / 640;
display:block;position:fixed;inset:0;width:100%;height:100%;
image-rendering:pixelated;image-rendering:crisp-edges;
border:3px solid #4a515f;border-radius:3px;
box-shadow:0 0 0 3px #101216, 0 0 0 6px #262b34, 0 22px 60px rgba(0,0,0,.7);
touch-action:none;cursor:pointer;
}
</style>
@ -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);
}

20
manifest.webmanifest Normal file
View file

@ -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" }
]
}

View file

@ -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;

47
sw.js Normal file
View file

@ -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;
})
);
});