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