MINEFLAPPY-GAME/index.html
Dave 1acd790806 Pipe barriers now reach the top of the screen
On a tall phone the game world sits in the lower part of the display, so
the upper pipe column used to stop in mid-air with dead sky above it -
and the bird could actually fly up past the wall into that gap.

The upper column is now drawn from above the top edge of the screen down
to the gap, and its collision box matches, so the ceiling is always
where the player expects it on any screen shape.
2026-10-23 20:16:39 +02:00

2018 lines
71 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<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="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;overflow:hidden;background:#0d1420;overscroll-behavior:none}
body{
color:#fff;font-family:"Courier New",ui-monospace,monospace;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
}
canvas{
display:block;position:fixed;inset:0;width:100%;height:100%;
image-rendering:pixelated;image-rendering:crisp-edges;
touch-action:none;cursor:pointer;
}
</style>
</head>
<body>
<canvas id="game" width="480" height="640"></canvas>
<script>
"use strict";
/* ============================================================
MINEFLAPPY — a Minecraft-flavoured Flappy Bird
Everything is drawn in code: no images, no audio files.
============================================================ */
/* ---------------- config ---------------- */
const W = 480, H = 640;
const GROUND_H = 80, GROUND_Y = H - GROUND_H;
const STEP = 1 / 60;
const GRAVITY = 0.42, FLAP_V = -6.5, MAX_V = 10;
const BLOCK = 16, SCALE = 2;
const PLAYER_X = 112;
const GRID_W = 24, GRID_H = 20;
const CHICKEN_PX = GRID_W * SCALE; /* 48 */
const CHICKEN_PY = GRID_H * SCALE; /* 40 */
const SLOW = 0.55;
const SAVE_KEY = 'mineflappy_v2';
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));
/* cap the backbuffer: a 3x phone screen is ~3M pixels a frame, which
is what makes mid-range phones lag. 2x (or less) looks the same here. */
let dpr = Math.min(window.devicePixelRatio || 1, 2);
const budget = 2.6e6;
if (vw * vh * dpr * dpr > budget) dpr = Math.max(0.75, Math.sqrt(budget / (vw * vh)));
if (vw === VIEW.w && vh === VIEW.h && Math.abs(dpr - VIEW.dpr) < 0.01) 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;
n = (n ^ (n >> 13)) * 1274126177;
return ((n ^ (n >> 16)) >>> 0) / 4294967296;
}
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const ease = t => t * t * (3 - 2 * t);
function aabb(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function hex2rgb(h) {
return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
}
function mix(a, b, t) {
const A = hex2rgb(a), B = hex2rgb(b);
return 'rgb(' + Math.round(A[0] + (B[0] - A[0]) * t) + ',' +
Math.round(A[1] + (B[1] - A[1]) * t) + ',' +
Math.round(A[2] + (B[2] - A[2]) * t) + ')';
}
/* Two RNGs: `rnd` drives the pipe layout (seeded, so a daily code always
produces the same world) and `vrnd` drives pure decoration. */
let rngState = 1;
function setSeed(s) { rngState = (s >>> 0) || 1; }
function rnd(a, b) {
rngState = (rngState + 0x6D2B79F5) >>> 0;
let t = rngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return a + ((t ^ (t >>> 14)) >>> 0) / 4294967296 * (b - a);
}
const vrnd = (a, b) => a + Math.random() * (b - a);
/* ============================================================
PIXEL FONT — 5x7 glyphs, packed as hex columns
============================================================ */
const FONT = "03e5149453e100427f4000242615149463214145473941814127f105274545453963c4a49493070171090503836494949369064949291eA7e0909097eB7f49494936C3e41414141D7f4141413eE7f49494941F7f09090901G3e4141493aH7f0808087fI00417f4100J2040413f01K7f08142241L7f40404040M7f020c027fN7f020c107fO3e4141413eP7f09090906Q3e4151215eR7f09192946S4649494931T01017f0101U3f4040403fV1f2040201fW7f2018207fX6314081463Y0304780403Z6151494543 0000000000!00005f0000.0000600000,0040300000:0000360000-0808080808+08083e0808'0000030000?0201510906/4030080601%532b043231(001c224100)0041221c00*2a1c3e1c2a=1414141414>0041221408<0814224100#147f147f14\"0003000300@3e415d551e&324d4d5228";
const GLYPH = {};
(function buildFont() {
for (let i = 0; i < FONT.length; i += 11) {
const ch = FONT[i], hex = FONT.substr(i + 1, 10);
const c = document.createElement('canvas');
c.width = 5; c.height = 7;
const g = c.getContext('2d');
g.fillStyle = '#fff';
for (let x = 0; x < 5; x++) {
const bits = parseInt(hex.substr(x * 2, 2), 16);
for (let y = 0; y < 7; y++) if (bits & (1 << y)) g.fillRect(x, y, 1, 1);
}
GLYPH[ch] = c;
}
})();
/* drawImage ignores fillStyle, so glyphs are pre-tinted per colour */
const TINT = {};
function tinted(colour) {
if (TINT[colour]) return TINT[colour];
const atlas = {};
for (const ch of Object.keys(GLYPH)) {
const c = document.createElement('canvas');
c.width = 5; c.height = 7;
const g = c.getContext('2d');
g.drawImage(GLYPH[ch], 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = colour;
g.fillRect(0, 0, 5, 7);
atlas[ch] = c;
}
TINT[colour] = atlas;
return atlas;
}
function drawStr(str, px, py, s, colour) {
const atlas = tinted(colour || '#fff');
for (let i = 0; i < str.length; i++) {
const g = atlas[str[i].toUpperCase()];
if (g) ctx.drawImage(g, px + i * 6 * s, py, 5 * s, 7 * s);
}
}
function ptext(str, x, y, s, align, colour, shadow) {
str = String(str);
const w = str.length * 6 * s - s;
let px = align === 'center' ? Math.round(x - w / 2)
: align === 'right' ? Math.round(x - w) : Math.round(x);
if (shadow !== null) drawStr(str, px + s, y + s, s, shadow || 'rgba(0,0,0,.42)');
drawStr(str, px, y, s, colour || '#fff');
}
/* ---------------- block palettes ---------------- */
const PAL = {
grass: { base: '#6FA83C', vars: ['#5C8F31', '#7FB84B', '#4E7C29'] },
dirt: { base: '#8B6141', vars: ['#6F4C31', '#A0724E', '#7C5537'] },
stone: { base: '#7E7E7E', vars: ['#6B6B6B', '#8E8E8E', '#606060'] },
cobble: { base: '#82817E', vars: ['#6E6D6B', '#96958F', '#5C5B59'] },
sand: { base: '#DCD3A0', vars: ['#CBC08A', '#E8E0B8', '#C0B57E'] },
cactus: { base: '#4C7A31', vars: ['#3B6126', '#5C8F3C', '#2F4D1E'] },
plank: { base: '#B08B4F', vars: ['#9A7742', '#C39E5F', '#8A6A3A'] },
nether: { base: '#6E3636', vars: ['#5A2C2C', '#844444', '#4A2222'] },
ice: { base: '#9AC7E8', vars: ['#86B4D8', '#ADD8F0', '#74A0C4'] },
snow: { base: '#E6EEF4', vars: ['#CFDCE6', '#F4FAFF', '#BCCBD8'] }
};
/* ---------------- sprite factory ---------------- */
function makeSprite(rows, pal, outline) {
const w = rows[0].length, h = rows.length;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d');
const solid = (x, y) => x >= 0 && x < w && y >= 0 && y < h && pal[rows[y][x]] !== undefined;
g.fillStyle = outline || '#1b1b1f';
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
if (!solid(x, y)) continue;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
if (!solid(x + dx, y + dy)) g.fillRect(x + dx, y + dy, 1, 1);
}
}
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const col = pal[rows[y][x]];
if (col === undefined) continue;
g.fillStyle = col;
g.fillRect(x, y, 1, 1);
}
return c;
}
/* ---------------- sprites ---------------- */
const CHICKEN = [
'..............WWWW......',
'.............WWWWWW.....',
'............WWWWWWWW....',
'...WWW......WWWWWKKW....',
'..WWWWW....WWWWWWWWWYYY.',
'.WWWWWWWWWWWWWWWWWWWWYYY',
'.WWWWWWWWWWWWWWWWWWWWRRR',
'.WWWWWWWWWWWWWWWWWWWWRRR',
'.WWWWWWWWWWWWWWWWWWWW...',
'..WWWWWWWWWWWWWWWWW.....',
'..WWWWWWWWWWWWWWWWW.....',
'...WWWWWWWWWWWWWWWW.....',
'...WWWWWWWWWWWWWWW......',
'....WWWWWWWWWWWWWW......',
'.....WWWWWWWWWWWWW......',
'......WWWWWWWWWWWW......',
'.......WWWWWWWW.........',
'........YY.YY...........',
'........YY.YY...........',
'........YY.YY...........'
];
const CHICKEN_PAL = {"W":"#F2F2F2","Y":"#F2C744","R":"#D9453F","K":"#2A2A2E"};
const ENDERMAN = [
'.........DDDDDD.........',
'........DDDDDDDD........',
'........DDDDDDDD........',
'........DDPPDPPD........',
'........DDDDDDDD........',
'........DDDDDDDD........',
'........DDDDDDDD........',
'.........DDDDDD.........',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'......DD.DDDDDD.DD......',
'..........DD.DD.........',
'..........DD.DD.........',
'..........DD.DD.........',
'..........DD.DD.........',
'..........DD.DD.........'
];
const ENDERMAN_PAL = {"D":"#221E2B","P":"#8B3FD1"};
const ALLAY = [
'........................',
'........................',
'........................',
'..wwwww...........wwwww.',
'.wwwwww..........wwwwww.',
'.wwwwwwWWWWWWWWWWWwwwwww',
'.wwwwWWWWWWWWWWWWWWwwww.',
'.wwwWWWWWWWWWWWWWWWWwww.',
'......WWCCCWWWCCCW......',
'......WCCCWWWCCCWW......',
'......WCCCWWCCCWWW......',
'......WWcccWWWcccWW.....',
'.......WWWWWWWWWW.......',
'........WWWWWWWW........',
'.........WWWWWW.........',
'..........WWWW..........',
'...........WWW..........',
'............W...........',
'............W...........',
'............W...........'
];
const ALLAY_PAL = {"W":"#F0F0F4","w":"#C6C6DC","C":"#5BE0E8","c":"#3AA8B8"};
const BLAZE = [
'...........OO...........',
'..........OYYO..........',
'.........OYYYYO.........',
'........OYYYYYYO........',
'.....YYYYYYYYYYYYYY.....',
'.....YYYYYYYYYYYYYY.....',
'.....YYYYYYYYYYYYYY.....',
'..YYYYYYYYYYYYYYYYYYYY..',
'..YYYYYYKKKYYYYKKKYYYY..',
'..YYYYYYKKKYYYYKKKYYYY..',
'..YYYYYYYYYYYYYYYYYYYY..',
'..YYYYYYYYYYYYYYYYYYYY..',
'....YYYYYYYYYYYYYYYY....',
'.....YYYYYYYYYYYYYY.....',
'......YYYYYYYYYYYY......',
'.......YYY.YYY.YYY......',
'.......YYY.YYY.YYY......',
'.......YYY.YYY.YYY......',
'.......YYY.YYY.YYY......',
'.......YYY.YYY.YYY......'
];
const BLAZE_PAL = {"Y":"#F2C744","O":"#E8862A","K":"#3A2A10"};
const PHANTOM = [
'wwwwwww..........wwwwwww',
'wwwwwww..........wwwwwww',
'wwwwwww..........wwwwwww',
'wwwww...GGGGGGGGG...wwww',
'wwww....GGGGGGGGGGG....w',
'www.....GGGGGGGGGGG.....',
'ww.......CCCGCCC.......w',
'w........CCCGCCC........',
'.........CCCGCCC........',
'..........GGGGGGGGGGG...',
'.........GGKKKKKGG......',
'..........GGGGGGGGG.....',
'..........GGGGGGG.......',
'...........GGGGG........',
'...........GGG..........',
'............G...........',
'............G...........',
'.............G..........',
'.............G..........',
'.............G..........'
];
const PHANTOM_PAL = {"G":"#C6CBD6","w":"#8E96A8","C":"#5BD4E0","K":"#2A2E3A"};
const WOLF = [
'.....GGGG....GGGG.......',
'.....GggG....GggG.......',
'.....WWWW....WWWW.......',
'.....WWWWWWWWWWWW.......',
'.....WWWWWWWWWWWW.......',
'.....WWWWWWWKKWWW.......',
'.....WWWWWWWKKWWW.......',
'.....WWWWWWWWWWWW.......',
'.....WWWWWWWWWWWW.......',
'.....WWWWWWWWWWWWWWWWK..',
'.....WWWWWWWWWWWWWWWWK..',
'.....WWWWWWWWWWWWWW.....',
'.....WWWWWWWWWWWWW......',
'.....WWWWWWWWWWWW.......',
'.....WWWWWWWWWWWW.......',
'.....BBBBBBBBBBBB.......',
'.....BBBBBBBBBBBB.......',
'....GGGGGGGGGGGGGGGG....',
'....GGGGGGGGGGGGGGGG....',
'....GGGGGGGGGGGGGGGG....'
];
const WOLF_PAL = {"W":"#DCDCDE","G":"#A8A8B4","g":"#D89AA0","K":"#22222A","B":"#3A7BD5"};
const GOLD = [
'....YYYYYY....',
'..YYYYYYYYYY..',
'.OOOOOOOOOOOO.',
'OOOOOOOOOOOOOO',
'.YYYYYYYYYYYY.',
'..YYYYYYYYYY..'
];
const GOLD_PAL = {"Y":"#F7D64A","O":"#C9971C"};
const BUBBLE = [
'....CCCCCC....',
'..CCCCHHCCCC..',
'.CCCCCCCCCCCC.',
'CCCCCCCCCCCCCC',
'CCCCCCCCCCCCCC',
'CCCCCCCCCCCCCC',
'CCCCCCCCCCCCCC',
'CCCCCCCCCCCCCC',
'.CCCCCCCCCCCC.',
'..CCCCHHCCCC..',
'....CCCCCC....'
];
const BUBBLE_PAL = {"C":"#4FC3F7","H":"#C8EEFF"};
const WINGS = [
'PPPPPPPP........',
'PPPPPPPPPPPP....',
'PPPPPPPPPPPPPPPP',
'PPPPPPPPPPPPPP..',
'PPPPPPPPPPPP....',
'PPPPPPPPPP......',
'PPPPPPPP........',
'PPPPPP..........',
'PPPP............',
'PP..............'
];
const WINGS_PAL = {"P":"#B14EC8"};
const CUBE = [
'...WWWWWWWW...',
'..WWWWWWWWWW..',
'.WWWWWWWWWWWW.',
'WWWWWKKKKWWWWW',
'WWWWWKKKKWWWWW',
'WWWWWWWWWWWWWW',
'.WWWWWWWWWWWW.',
'..WWWWWWWWWW..',
'...WWWWWWWW...'
];
const CUBE_PAL = {"W":"#E8C86A","K":"#4A3A10"};
const LOCK = [
'..LLLLLLLL..',
'..LLLLLLLL..',
'...llllll...',
'llllllllllll',
'llllllllllll',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL',
'LLLLLLLLLLLL'
];
const LOCK_PAL = {"L":"#8B8B8B","l":"#B8B8B8"};
const WING = ['..WWWW..', '.WWWWWW.', 'WWWWWWWW', '.WWWWWW.', '..WWWW..', '..WWWW..'];
const GEM = [
'.....CCCCCC.....', '....CCDDDDCC....', '...CDDDDDDDDC...', '..CDDDDDDDDDDC..',
'.CDDDDDDDDDDDDC.', 'CDDDDDDDDDDDDDDC', 'CDDDDDDDDDDDDDDC', '.CDDDDDDDDDDDDC.',
'..CDDDDDDDDDDC..', '...CDDDDDDDDC...', '....CCDDDDCC....', '.....CCCCCC.....',
'................', '................', '................', '................'
];
const SPR = {
wing: makeSprite(WING, { W: '#DADADA' }, '#9a9a9a'),
diamond: makeSprite(GEM, { C: '#A6F5FF', D: '#31C3E8' }, '#12495a'),
emerald: makeSprite(GEM, { C: '#8BFFC0', D: '#17A455' }, '#0d4a26'),
gold: makeSprite(GOLD, GOLD_PAL, '#6a5208'),
bubble: makeSprite(BUBBLE, BUBBLE_PAL, '#124a66'),
wings: makeSprite(WINGS, WINGS_PAL, '#3a1250'),
cube: makeSprite(CUBE, CUBE_PAL, '#5a4410'),
lock: makeSprite(LOCK, LOCK_PAL, '#3a3a3a')
};
/* ============================================================
THE ROSTER — each bird has one unique ability
============================================================ */
const ROSTER = [
{
id: 'chicken', name: 'CHICKEN', rows: CHICKEN, pal: CHICKEN_PAL, outline: '#2b2018',
ab: 'DOUBLE FLAP', d1: 'EVERY 4TH FLAP IS DOUBLE', d2: 'STRENGTH', key: null, cd: 0, tint: '#F2F2F2',
unlock: { score: 0, runs: 0 }, how: 'SCORE 0'
},
{
id: 'enderman', name: 'ENDERMAN', rows: ENDERMAN, pal: ENDERMAN_PAL, outline: '#100d15',
ab: 'TELEPORT', d1: 'BLINK 70PX FORWARD', d2: 'F KEY - 4S COOLDOWN', key: 'F', cd: 240, tint: '#8B3FD1',
unlock: { score: 10, runs: 0 }, how: 'SCORE 10'
},
{
id: 'allay', name: 'ALLAY', rows: ALLAY, pal: ALLAY_PAL, outline: '#3a3a48',
ab: 'ALLAY MAGNET', d1: 'NEARBY GEMS FLY TO YOU', d2: 'ALWAYS ON', key: null, cd: 0, tint: '#5BE0E8',
unlock: { score: 20, runs: 0 }, how: 'SCORE 20'
},
{
id: 'blaze', name: 'BLAZE', rows: BLAZE, pal: BLAZE_PAL, outline: '#5a3a08',
ab: 'MELT', d1: 'BURN THE NEXT PIPE WIDER', d2: 'F KEY - 5S COOLDOWN', key: 'F', cd: 300, tint: '#E8862A',
unlock: { score: 30, runs: 0 }, how: 'SCORE 30'
},
{
id: 'phantom', name: 'PHANTOM', rows: PHANTOM, pal: PHANTOM_PAL, outline: '#4a5060',
ab: 'PHASE', d1: 'PASS THROUGH ANYTHING 1S', d2: 'F KEY - 7S COOLDOWN', key: 'F', cd: 420, tint: '#5BD4E0',
unlock: { score: 50, runs: 0 }, how: 'SCORE 50'
},
{
id: 'wolf', name: 'WOLF', rows: WOLF, pal: WOLF_PAL, outline: '#2a2a30',
ab: 'HOWL', d1: 'SLOW TIME DOWN FOR 2S', d2: 'F KEY - 8S COOLDOWN', key: 'F', cd: 480, tint: '#9BB6E8',
unlock: { score: 0, runs: 5 }, how: '5 RUNS'
}
];
for (const c of ROSTER) c.sprite = makeSprite(c.rows, c.pal, c.outline);
/* ============================================================
BIOMES, DIFFICULTY, PICKUPS
============================================================ */
const BIOMES = [
{ name: 'PLAINS', themes: ['dirt'], ground: ['grass', 'dirt', 'dirt', 'stone', 'stone'],
sky: ['#6FA3F5', '#C2DEFA'], far: '#4E7C4E', near: '#3F6B44', farEdge: '#66A05E', nearEdge: '#568C52' },
{ name: 'DESERT', themes: ['sand'], ground: ['sand', 'sand', 'sand', 'sand', 'stone'],
sky: ['#5FA8E8', '#F0DCA8'], far: '#C8A860', near: '#B08C48', farEdge: '#E0C078', nearEdge: '#C8A058' },
{ name: 'MINESHAFT', themes: ['cobble', 'plank'], ground: ['stone', 'cobble', 'stone', 'stone', 'stone'],
sky: ['#4A4A52', '#7A7A84'], far: '#3E3E44', near: '#56565E', farEdge: '#5A5A62', nearEdge: '#6E6E76' },
{ name: 'NETHER', themes: ['nether'], ground: ['nether', 'nether', 'nether', 'nether', 'nether'],
sky: ['#5A1E1E', '#B0422A'], far: '#4A2020', near: '#6A2A20', farEdge: '#7A3630', nearEdge: '#8E4030' },
{ name: 'ICE PEAKS', themes: ['ice'], ground: ['snow', 'ice', 'stone', 'stone', 'stone'],
sky: ['#9FC8E8', '#E4F0FA'], far: '#C6DCEC', near: '#A8C8DE', farEdge: '#E2F0FA', nearEdge: '#C8E0F0' }
];
const BIOME_AT = [0, 15, 30, 50, 75];
const DIFFS = {
easy: { label: 'EASY', gap: [200, 152], speed: [2.4, 3.4] },
normal: { label: 'NORMAL', gap: [162, 118], speed: [2.7, 4.3] },
hard: { label: 'HARD', gap: [142, 102], speed: [3.0, 5.0] }
};
const PICKUPS = {
gem: { sprite: 'diamond', points: 3, label: 'DIAMOND' },
emerald:{ sprite: 'emerald', points: 1, label: 'EMERALD' },
gold: { sprite: 'gold', points: 5, label: 'GOLD' },
bubble: { sprite: 'bubble', points: 0, label: 'SHIELD' },
cube: { sprite: 'cube', points: 0, label: 'LUCKY CUBE' },
wings: { sprite: 'wings', points: 0, label: 'FLYING' }
};
const LUCKY = [
{ id: 'slow', name: 'SLOW TIME', desc: 'EVERYTHING SLOWS DOWN', col: '#9BB6E8' },
{ id: 'dbl', name: 'DOUBLE SCORE', desc: 'POINTS COUNTED TWICE', col: '#F2C744' },
{ id: 'tiny', name: 'TINY', desc: 'YOU ARE SMALLER', col: '#8BFFC0' },
{ id: 'mega', name: 'MEGA FLAP', desc: 'NEXT 5 FLAPS ARE HUGE', col: '#E8862A' },
{ id: 'magnet',name: 'MAGNET', desc: 'GEMS FLY TO YOU', col: '#5BE0E8' }
];
/* ---------------- block painting ---------------- */
/* Every block texture is pre-rendered once and then blitted. Drawing the
speckles per block per frame was the single biggest cost on mobile. */
const BLOCK_TEX = {};
(function buildBlockTextures() {
for (const kind of Object.keys(PAL)) {
const p = PAL[kind];
const variants = [];
for (let v = 0; v < 4; v++) {
const c = document.createElement('canvas');
c.width = BLOCK; c.height = BLOCK;
const g = c.getContext('2d');
g.fillStyle = p.base;
g.fillRect(0, 0, BLOCK, BLOCK);
for (let i = 0; i < 4; i++) {
const h1 = hash(v * 37 + i * 7, i * 13, 5);
const h2 = hash(i * 11, v * 37 + i * 5, 9);
const h3 = hash(i * 31, v * 17 + i * 3, 13);
g.fillStyle = p.vars[Math.floor(h3 * p.vars.length) % p.vars.length];
g.fillRect(Math.floor(h1 * 13), Math.floor(h2 * 13), 2, 2);
}
g.fillStyle = 'rgba(0,0,0,.16)';
g.fillRect(0, BLOCK - 2, BLOCK, 2);
g.fillRect(BLOCK - 2, 0, 2, BLOCK);
g.fillStyle = 'rgba(255,255,255,.10)';
g.fillRect(0, 0, BLOCK, 1);
g.fillRect(0, 0, 1, BLOCK);
variants.push(c);
}
BLOCK_TEX[kind] = variants;
}
})();
function block(g, bx, by, kind, seed) {
const t = BLOCK_TEX[kind];
const v = Math.floor(hash(seed, by, 3) * 4) % 4;
g.drawImage(t[v], bx, by);
}
/* one scrolling ground tile per biome */
const GROUND_TILES = BIOMES.map((b, bi) => {
const TW = 512;
const c = document.createElement('canvas');
c.width = TW; c.height = GROUND_H;
const g = c.getContext('2d');
for (let x = 0; x < TW; x += BLOCK) {
for (let row = 0; row < 5; row++) {
/* vary the texture per column so the ground does not look striped */
block(g, x, row * BLOCK, b.ground[row], bi * 31 + row + 1 + x);
}
}
if (b.ground[0] === 'grass') {
g.fillStyle = '#4E7C29';
for (let x = 0; x < TW; x += BLOCK) g.fillRect(x, 12, BLOCK, 4);
}
return c;
});
/* distant hills per biome (tiled) */
function buildHills(width, baseY, amp, color, edge, step, seed) {
const c = document.createElement('canvas');
c.width = width; c.height = amp + 44;
const g = c.getContext('2d');
const n = width / step;
for (let i = 0; i < n; i++) {
const h = Math.floor((0.45 + hash(i, seed, 91) * 0.55) * amp);
const x = i * step;
g.fillStyle = color;
g.fillRect(x, baseY - h, step, h + 44);
g.fillStyle = edge;
g.fillRect(x, baseY - h, step, 3);
}
return c;
}
const HILLS = BIOMES.map((b, i) => ({
far: buildHills(640, 120, 78, b.far, b.farEdge, 16, 7 + i * 3),
near: buildHills(640, 150, 92, b.near, b.nearEdge, 16, 13 + i * 5)
}));
/* ---------------- audio ---------------- */
let actx = null;
function audio() {
try {
if (!actx) {
const C = window.AudioContext || window.webkitAudioContext;
if (!C) return null;
actx = new C();
}
if (actx.state === 'suspended') actx.resume();
} catch (e) { actx = null; }
return actx;
}
function tone(freq, dur, type, vol, slideTo, delay) {
const a = audio(); if (!a) return;
const t0 = a.currentTime + (delay || 0);
const o = a.createOscillator(), g = a.createGain();
o.type = type || 'square';
o.frequency.setValueAtTime(freq, t0);
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), t0 + dur);
g.gain.setValueAtTime(vol || 0.05, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(a.destination);
o.start(t0); o.stop(t0 + dur);
}
function noise(dur, vol) {
const a = audio(); if (!a) return;
const len = Math.floor(a.sampleRate * dur);
const buf = a.createBuffer(1, len, a.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const s = a.createBufferSource(); s.buffer = buf;
const g = a.createGain(); g.gain.value = vol || 0.08;
s.connect(g); g.connect(a.destination); s.start();
}
const SFX = {
flap() { tone(430, 0.09, 'square', 0.05, 620); },
power() { tone(520, 0.12, 'square', 0.06, 980); tone(780, 0.10, 'square', 0.04, 1200, 0.06); },
point(n) { const f = Math.min(1600, 880 * Math.pow(1.06, n || 0)); tone(f, 0.07, 'square', 0.05); tone(f * 1.5, 0.10, 'square', 0.05, null, 0.07); },
gem() { [1046, 1318, 1568].forEach((f, i) => tone(f, 0.09, 'square', 0.05, null, i * 0.06)); },
hit() { noise(0.22, 0.09); tone(200, 0.28, 'sawtooth', 0.06, 60); },
cluck() { tone(900, 0.07, 'square', 0.045, 420); tone(700, 0.08, 'square', 0.04, 300, 0.08); },
deny() { tone(150, 0.16, 'square', 0.05, 90); },
tp() { tone(180, 0.22, 'sawtooth', 0.05, 1200); tone(1200, 0.10, 'square', 0.04, 240, 0.12); },
melt() { noise(0.35, 0.10); tone(140, 0.30, 'sawtooth', 0.05, 60); },
phase() { [784, 1046, 1318, 1568].forEach((f, i) => tone(f, 0.14, 'triangle', 0.05, null, i * 0.05)); },
howl() { tone(330, 0.55, 'sawtooth', 0.055, 660); tone(660, 0.45, 'sine', 0.04, 300, 0.18); },
select(){ tone(660, 0.06, 'square', 0.045); tone(880, 0.08, 'square', 0.045, null, 0.06); },
toast() { [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.12, 'triangle', 0.05, null, i * 0.07)); },
thunder(){ noise(0.9, 0.07); tone(60, 0.7, 'sawtooth', 0.05, 40); }
};
/* ---------------- ambient music ---------------- */
const MUSIC = { on: true, timer: null, step: 0 };
function startMusic() {
if (!MUSIC.on) return;
const a = audio(); if (!a) return;
if (MUSIC.timer) return;
const chords = [
[220, 261.63, 329.63, 392.00],
[174.61, 220.00, 261.63, 329.63],
[196.00, 246.94, 293.66, 392.00],
[196.00, 246.94, 293.66, 440.00]
];
const delay = a.createDelay(1.5);
delay.delayTime.value = 0.38;
const fb = a.createGain(); fb.gain.value = 0.32;
delay.connect(fb); fb.connect(delay); delay.connect(a.destination);
MUSIC.timer = setInterval(() => {
const ch = chords[MUSIC.step % chords.length];
const bass = ch[0] / 2;
MUSIC.step++;
const t = a.currentTime;
ch.forEach((f, i) => {
const o = a.createOscillator(), g = a.createGain(), fl = a.createBiquadFilter();
o.type = 'triangle';
o.frequency.value = f;
o.detune.value = (i % 2 ? 5 : -5);
fl.type = 'lowpass'; fl.frequency.value = 620; fl.Q.value = 0.7;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.020, t + 1.3);
g.gain.linearRampToValueAtTime(0.0001, t + 3.9);
o.connect(fl); fl.connect(g); g.connect(a.destination); g.connect(delay);
o.start(t); o.stop(t + 4);
});
const bo = a.createOscillator(), bg = a.createGain();
bo.type = 'sine'; bo.frequency.value = bass;
bg.gain.setValueAtTime(0, t);
bg.gain.linearRampToValueAtTime(0.05, t + 0.4);
bg.gain.linearRampToValueAtTime(0.0001, t + 3.8);
bo.connect(bg); bg.connect(a.destination);
bo.start(t); bo.stop(t + 4);
}, 4000);
}
function stopMusic() {
if (MUSIC.timer) { clearInterval(MUSIC.timer); MUSIC.timer = null; }
}
/* ---------------- save data ---------------- */
const SAVE = {
best: 0, bests: {}, unlocked: { chicken: true }, runs: 0, diff: 'normal',
music: true, daily: false, ghosts: {}
};
function loadSave() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (raw) {
const s = JSON.parse(raw);
Object.assign(SAVE, s);
SAVE.unlocked = Object.assign({ chicken: true }, s.unlocked || {});
SAVE.bests = s.bests || {};
SAVE.ghosts = s.ghosts || {};
}
} catch (e) {}
if (!SAVE.unlocked.chicken) SAVE.unlocked.chicken = true;
try {
if (!SAVE.best) {
const old = parseInt(localStorage.getItem('mineflappy_best') || '0', 10) || 0;
if (old) { SAVE.best = old; SAVE.bests.chicken = old; }
}
} catch (e) {}
}
function writeSave() {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(SAVE)); } catch (e) {}
}
loadSave();
/* ---------------- state ---------------- */
function makePlayer(x, y, charIdx) {
return {
x, y, vy: 0, rot: 0, wing: 0, alive: true, score: 0, char: charIdx,
ab: { cd: 0, phase: 0, howl: 0, flaps: 0, squash: 0 },
buf: { slow: 0, dbl: 0, tiny: 0, mega: 0, magnet: 0, fly: 0 },
shield: false, hit: false
};
}
const S = {
state: 'title', prevState: 'title',
t: 0, overT: 0, frame: 0,
sel: 0, mode: '1p', died: -1,
players: [makePlayer(PLAYER_X, 250, 0), makePlayer(PLAYER_X + 40, 250, 1)],
pipes: [], pickups: [], parts: [], clouds: [], stars: [], drops: [], toasts: [],
scroll: 0, speed: 2.7, gap: 162, diff: 'normal',
shake: 0, deny: 0, hitstop: 0, flash: 0,
combo: 0, night: 0, camY: 0,
biome: 0, biomeFrom: 0, biomeTo: 0, biomeT: 1, banner: 0,
weather: 'clear', weatherT: 300, thunderT: 0,
seed: 0, code: '', rec: [], ghost: null, ghostBest: 0,
music: SAVE.music, daily: SAVE.daily
};
S.diff = SAVE.diff || 'normal';
const P1 = () => S.players[0];
const P2 = () => S.players[1];
const activePlayers = () => S.mode === '2p' ? 2 : 1;
const cur = () => ROSTER[S.sel];
const charOf = i => ROSTER[S.players[i].char];
for (let i = 0; i < 7; i++) {
S.clouds.push({
x: vrnd(0, 900), y: vrnd(20, 160), s: vrnd(0.7, 1.6),
slabs: Array.from({ length: 3 + Math.floor(vrnd(0, 4)) }, () => ({
x: Math.floor(vrnd(0, 4)), w: 1 + Math.floor(vrnd(0, 3))
}))
});
}
for (let i = 0; i < 70; i++) {
S.stars.push({ x: vrnd(0, W), y: vrnd(0, 400), s: vrnd(1, 2.4), p: vrnd(0, 6.28) });
}
/* ---------------- daily code ---------------- */
function todayCode() {
const d = new Date();
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
}
function codeToSeed(code) {
let h = 2166136261;
for (let i = 0; i < code.length; i++) { h ^= code.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
S.code = todayCode();
/* ---------------- world ---------------- */
function biomeFor(score) {
let b = 0;
for (let i = 0; i < BIOME_AT.length; i++) if (score >= BIOME_AT[i]) b = i;
return b;
}
function addPipe() {
const th = THEMES_FOR();
const prev = S.pipes[S.pipes.length - 1];
let gapY = prev ? prev.gapY : rnd(110, 300);
gapY = clamp(gapY + rnd(-70, 70), 56, GROUND_Y - S.gap - 34);
const pipe = {
x: Math.round(W + 40), gapY: Math.round(gapY), gap: Math.round(S.gap),
theme: th, scored: [false, false], melted: false,
seed: Math.floor(rnd(0, 99999))
};
S.pipes.push(pipe);
const roll = rnd(0, 1);
let kind = null;
if (roll < 0.30) kind = 'gem';
else if (roll < 0.42) kind = 'gold';
else if (roll < 0.52) kind = 'bubble';
else if (roll < 0.60) kind = 'cube';
else if (roll < 0.68) kind = 'wings';
else if (roll < 0.78) kind = 'emerald';
if (kind) {
S.pickups.push({
x: pipe.x + pipe.theme.w / 2, y: clamp(gapY + S.gap / 2 + rnd(-26, 26), gapY + 26, gapY + S.gap - 26),
t: 0, kind, taken: false
});
}
}
function THEMES_FOR() {
const b = BIOMES[S.biomeTo];
const body = b.themes[Math.floor(rnd(0, b.themes.length))];
const t = THEMES.find(x => x.body === body) || THEMES[0];
return t;
}
const THEMES = [
{ body: 'dirt', top: 'cobble', bottom: 'grass', w: 64 },
{ body: 'cactus', top: 'cactus', bottom: 'cactus', w: 48 },
{ body: 'sand', top: 'sand', bottom: 'sand', w: 64 },
{ body: 'cobble', top: 'stone', bottom: 'cobble', w: 64 },
{ body: 'plank', top: 'plank', bottom: 'plank', w: 48 },
{ body: 'nether', top: 'nether', bottom: 'nether', w: 64 },
{ body: 'ice', top: 'ice', bottom: 'ice', w: 64 }
];
/* ---------------- run control ---------------- */
function reset() {
if (!SAVE.unlocked[cur().id]) {
S.deny = 12; SFX.deny();
toast('LOCKED', 'UNLOCK: ' + cur().how);
return;
}
const p2char = ROSTER[(S.sel + 1) % ROSTER.length];
S.state = 'play';
S.overT = 0; S.shake = 0; S.deny = 0; S.hitstop = 0; S.flash = 0;
S.pipes = []; S.pickups = []; S.parts = []; S.toasts = [];
S.speed = DIFFS[S.diff].speed[0]; S.gap = DIFFS[S.diff].gap[0]; S.scroll = 0;
S.combo = 0; S.night = 0; S.biome = 0; S.biomeFrom = 0; S.biomeTo = 0; S.biomeT = 1; S.banner = 0;
S.frame = 0; S.rec = []; S.ghostBest = 0;
S.players[0] = makePlayer(PLAYER_X, 250, S.sel);
S.players[1] = makePlayer(PLAYER_X + 46, 250,
SAVE.unlocked[p2char.id] ? (S.sel + 1) % ROSTER.length : S.sel);
if (S.daily) { setSeed(codeToSeed(S.code)); S.ghost = SAVE.ghosts[S.code] || null; }
else { setSeed((Math.random() * 4294967295) >>> 0); S.ghost = null; }
addPipe();
addPipe();
S.pipes[1].x = W + 40 + 250;
for (const p of S.pickups) if (p.x > W) p.x = S.pipes[1].x + S.pipes[1].theme.w / 2;
startMusic();
}
function burst(x, y, colour, n) {
for (let i = 0; i < n; i++) {
S.parts.push({
x, y, vx: vrnd(-3, 3), vy: vrnd(-3.4, 1.2),
c: colour, life: vrnd(16, 42), size: Math.random() < 0.5 ? 3 : 2
});
}
}
function toast(title, sub) {
S.toasts.push({ t: title, s: sub || '', life: 150 });
if (S.toasts.length > 3) S.toasts.shift();
SFX.toast();
}
function adv(id, title, sub) {
if (S.advs && S.advs[id]) return;
(S.advs = S.advs || {})[id] = 1;
toast(title, sub);
}
/* ---------------- input actions ---------------- */
function flap(p) {
if (!p || !p.alive) return;
const slow = S.slowNow ? SLOW : 1;
p.vy = FLAP_V * slow;
p.wing = -6;
p.ab.squash = 1;
SFX.flap();
if (p.char === 0) {
p.ab.flaps++;
if (p.ab.flaps % 4 === 0) {
p.vy = FLAP_V * 1.34 * slow;
burst(p.x + 24, p.y + 22, '#FFFFFF', 10);
SFX.power();
}
}
if (p.buf.mega > 0) {
p.buf.mega--;
p.vy = FLAP_V * 1.45 * slow;
burst(p.x + 24, p.y + 22, '#F2C744', 10);
SFX.power();
}
}
function useAbility(p) {
if (!p || !p.alive || S.state !== 'play') return;
const ch = ROSTER[p.char];
if (!ch.key) { S.deny = 12; SFX.deny(); return; }
if (p.ab.cd > 0) { S.deny = 12; SFX.deny(); return; }
if (ch.id === 'enderman') {
const nx = Math.min(p.x + 70, 220);
if (pipeBlocks({ x: nx + 12, y: p.y + 8, w: 26, h: 26 })) { S.deny = 12; SFX.deny(); return; }
burst(p.x + 24, p.y + 20, '#8B3FD1', 14);
p.x = nx;
burst(p.x + 24, p.y + 20, '#8B3FD1', 14);
SFX.tp();
} else if (ch.id === 'blaze') {
const pipe = S.pipes.find(pp => pp.x + pp.theme.w > p.x);
if (!pipe) { S.deny = 12; SFX.deny(); return; }
pipe.gapY = Math.max(24, pipe.gapY - 23);
pipe.gap += 46;
pipe.melted = true;
burst(pipe.x + pipe.theme.w / 2, pipe.gapY + 4, '#E8862A', 16);
burst(pipe.x + pipe.theme.w / 2, pipe.gapY + pipe.gap - 4, '#F2C744', 16);
SFX.melt();
} else if (ch.id === 'phantom') {
p.ab.phase = 72;
burst(p.x + 24, p.y + 20, '#5BD4E0', 16);
SFX.phase();
} else if (ch.id === 'wolf') {
p.ab.howl = 120;
burst(p.x + 24, p.y + 20, '#9BB6E8', 16);
SFX.howl();
}
p.ab.cd = ch.cd;
}
function lucky(p, kind) {
const l = LUCKY[Math.floor(vrnd(0, LUCKY.length))];
p.buf[l.id] = l.id === 'mega' ? 5 : (l.id === 'dbl' ? 480 : 300);
toast(l.name, l.desc);
burst(p.x + 24, p.y + 20, l.col, 18);
SFX.gem();
}
/* ---------------- collisions & scoring ---------------- */
function pipeHit(box) {
for (const p of S.pipes) {
/* the upper column reaches up past the top of the screen */
const top = { x: p.x, y: -1e5, w: p.theme.w, h: p.gapY + 1e5 };
const bot = { x: p.x, y: p.gapY + p.gap, w: p.theme.w, h: GROUND_Y - (p.gapY + p.gap) };
if (aabb(box, top) || aabb(box, bot)) return p;
}
return null;
}
function pipeBlocks(box) { return !!pipeHit(box); }
function hitBox(p) {
const inset = p.buf.tiny > 0 ? 16 : 12;
return { x: p.x + inset, y: p.y + (p.buf.tiny > 0 ? 12 : 8), w: 48 - inset * 2, h: p.buf.tiny > 0 ? 16 : 26 };
}
function die(p) {
if (!p.alive) return;
p.alive = false;
p.hit = true;
S.hitstop = 14;
S.shake = 16;
S.flash = 5;
SFX.hit();
setTimeout(SFX.cluck, 140);
const cols = ['#F2F2F2', '#F2C744', '#D9453F', '#CFCFCF', ROSTER[p.char].tint];
for (let i = 0; i < 30; i++) {
S.parts.push({
x: p.x + vrnd(6, 42), y: p.y + vrnd(2, 36),
vx: vrnd(-3.6, 1.4), vy: vrnd(-5, 0.8),
c: cols[Math.floor(vrnd(0, cols.length))],
life: vrnd(40, 95), size: Math.random() < 0.4 ? 4 : 2
});
}
/* block debris from the wall that was hit */
const near = S.pipes.find(pp => Math.abs(pp.x - p.x) < 90);
if (near) {
for (let i = 0; i < 16; i++) {
S.parts.push({
x: near.x + vrnd(0, near.theme.w), y: vrnd(60, GROUND_Y - 20),
vx: vrnd(-2.6, 2.6), vy: vrnd(-4, 0),
c: PAL[near.theme.body].base, life: vrnd(30, 70), size: 3
});
}
}
S.combo = 0;
S.died = S.players.indexOf(p);
endRun();
}
function endRun() {
if (S.state !== 'play') return;
S.state = 'dying';
S.overT = 0;
}
function gameOver() {
S.state = 'over';
S.overT = 0;
const p = P1();
const id = ROSTER[p.char].id;
SAVE.runs = (SAVE.runs || 0) + 1;
if (p.score > (SAVE.bests[id] || 0)) SAVE.bests[id] = p.score;
if (p.score > SAVE.best) SAVE.best = p.score;
/* unlock birds */
for (const c of ROSTER) {
if (SAVE.unlocked[c.id]) continue;
const okScore = c.unlock.score && p.score >= c.unlock.score;
const okRuns = c.unlock.runs && SAVE.runs >= c.unlock.runs;
if (okScore || okRuns) {
SAVE.unlocked[c.id] = true;
toast('BIRD UNLOCKED', c.name);
}
}
/* keep the best ghost for this layout */
if (S.daily && S.rec.length && p.score > (S.ghost ? S.ghost.s : 0)) {
SAVE.ghosts[S.code] = { y: S.rec, s: p.score };
const keys = Object.keys(SAVE.ghosts);
if (keys.length > 4) {
keys.sort((a, b) => (SAVE.ghosts[a].s < SAVE.ghosts[b].s ? -1 : 1));
for (let i = 0; i < keys.length - 4; i++) delete SAVE.ghosts[keys[i]];
}
}
writeSave();
}
/* ---------------- update ---------------- */
function moveWorld(speed, spacing) {
S.scroll += speed;
for (let i = S.pipes.length - 1; i >= 0; i--) {
const p = S.pipes[i];
p.x -= speed;
for (let k = 0; k < activePlayers(); k++) {
const pl = S.players[k];
if (!p.scored[k] && p.x + p.theme.w < pl.x) {
p.scored[k] = true;
pl.score += (pl.buf.dbl > 0 ? 2 : 1);
S.combo++;
SFX.point(S.combo);
if (S.combo === 10) adv('combo10', 'ON A ROLL', '10 PIPES IN A ROW');
if (pl.score === 10) adv('p10', 'GETTING GOOD', '10 POINTS');
if (pl.score === 25) adv('p25', 'NICE ONE', '25 POINTS');
if (pl.score === 50) adv('p50', 'HALF CENTURY', '50 POINTS');
}
}
if (p.x < -p.theme.w - 20) S.pipes.splice(i, 1);
}
const last = S.pipes[S.pipes.length - 1];
if (!last || last.x < W - (spacing || 190)) addPipe();
for (let i = S.pickups.length - 1; i >= 0; i--) {
const g = S.pickups[i];
g.x -= speed;
g.t++;
if (g.x < -30) { S.pickups.splice(i, 1); continue; }
for (let k = 0; k < activePlayers(); k++) {
const pl = S.players[k];
if (!pl.alive) continue;
const magnet = (pl.char === 2 || pl.buf.magnet > 0);
if (magnet) {
const dx = pl.x + 24 - g.x, dy = pl.y + 20 - g.y;
const d = Math.hypot(dx, dy);
if (d < 175 && d > 1) {
const pull = Math.min(d, 1.1 + (175 - d) / 20);
g.x += dx / d * pull;
g.y += dy / d * pull;
}
}
if (pl.buf.fly > 0 && Math.abs(g.x - (pl.x + 24)) < 90 && Math.abs(g.y - (pl.y + 20)) < 60) {
g.taken = true;
collect(pl, g);
break;
}
if (aabb(hitBox(pl), { x: g.x - 9, y: g.y - 9, w: 18, h: 18 })) {
g.taken = true;
collect(pl, g);
break;
}
}
if (g.taken) S.pickups.splice(i, 1);
}
}
function collect(pl, g) {
const def = PICKUPS[g.kind];
if (g.kind === 'bubble') {
pl.shield = true;
toast('SHIELD UP', 'ABSORBS ONE HIT');
burst(g.x, g.y, '#4FC3F7', 16);
SFX.gem();
} else if (g.kind === 'cube') {
lucky(pl, g.kind);
} else if (g.kind === 'wings') {
pl.buf.fly = 180;
toast('FLYING', '3 SECONDS - PICK UP EVERYTHING');
burst(g.x, g.y, '#B14EC8', 20);
SFX.phase();
} else {
const pts = def.points * (pl.buf.dbl > 0 ? 2 : 1);
pl.score += pts;
burst(g.x, g.y, g.kind === 'gold' ? '#F7D64A' : (g.kind === 'emerald' ? '#8BFFC0' : '#A6F5FF'), 12);
SFX.gem();
if (g.kind === 'diamond') adv('dia', 'FIRST DIAMOND', 'A SHINY ONE');
if (g.kind === 'gold') adv('gold', 'GOLD RUSH', 'FIRST GOLD INGOT');
}
}
function updateParts() {
for (let i = S.parts.length - 1; i >= 0; i--) {
const p = S.parts[i];
p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.life--;
if (p.life <= 0) S.parts.splice(i, 1);
}
for (let i = S.toasts.length - 1; i >= 0; i--) {
S.toasts[i].life--;
if (S.toasts[i].life <= 0) S.toasts.splice(i, 1);
}
}
function update() {
S.t++;
S.frame++;
if (S.hitstop > 0) { S.hitstop--; return; }
if (S.deny > 0) S.deny--;
if (S.flash > 0) S.flash--;
if (S.banner > 0) S.banner--;
for (const cl of S.clouds) {
cl.x -= 0.22 * cl.s;
if (cl.x < -200) { cl.x = 960; cl.y = vrnd(20, 160); }
}
if (S.state === 'title') {
moveWorld(1.1, 250);
updateParts();
return;
}
if (S.state === 'paused') return;
if (S.state === 'over') { S.overT++; updateParts(); return; }
if (S.state === 'dying') {
S.overT++;
for (let k = 0; k < activePlayers(); k++) {
const p = S.players[k];
if (p.alive) continue; /* the survivor freezes */
p.vy = Math.min(MAX_V + 2, p.vy + GRAVITY);
p.y += p.vy;
p.rot = Math.min(1.6, p.rot + 0.09);
p.wing = Math.min(4, p.wing + 0.7);
if (p.y + CHICKEN_PY > GROUND_Y) {
p.y = GROUND_Y - CHICKEN_PY;
gameOver();
}
}
updateParts();
return;
}
/* ---------- playing ---------- */
const anyHowl = S.players.slice(0, activePlayers()).some(p => p.ab.howl > 0 || p.buf.slow > 0);
S.slowNow = anyHowl;
const slow = anyHowl ? SLOW : 1;
/* biome + night follow the score */
const sc = P1().score;
const nb = biomeFor(sc);
if (nb !== S.biome) {
S.biomeFrom = S.biomeTo;
S.biomeTo = nb;
S.biomeT = 0;
S.biome = nb;
S.banner = 150;
toast(BIOMES[nb].name, 'NEW BIOME');
}
if (S.biomeT < 1) S.biomeT = Math.min(1, S.biomeT + 1 / 90);
S.night = clamp((sc - 12) / 70, 0, 1);
/* difficulty ramp */
const D = DIFFS[S.diff];
const diff = Math.min(1, sc / 32);
S.speed = D.speed[0] + diff * (D.speed[1] - D.speed[0]);
S.gap = D.gap[0] + diff * (D.gap[1] - D.gap[0]);
/* weather */
updateWeather();
/* players */
for (let k = 0; k < activePlayers(); k++) {
const p = S.players[k];
if (!p.alive) continue;
p.vy = Math.min(MAX_V, p.vy + GRAVITY * slow);
p.y += p.vy * slow;
if (p.y < -8) { p.y = -8; p.vy = 0; }
p.wing = Math.min(3, p.wing + 0.62);
const targetRot = p.vy < 0 ? -0.40 : Math.min(1.48, p.vy * 0.115);
p.rot += (targetRot - p.rot) * 0.22;
p.ab.squash = Math.max(0, p.ab.squash - 0.09);
if (p.ab.phase > 0) p.ab.phase--;
if (p.ab.howl > 0) p.ab.howl--;
if (p.ab.cd > 0) p.ab.cd--;
for (const key in p.buf) if (p.buf[key] > 0) p.buf[key]--;
}
moveWorld(S.speed * slow);
/* collision */
for (let k = 0; k < activePlayers(); k++) {
const p = S.players[k];
if (!p.alive) continue;
if (p.buf.fly > 0) continue; /* flying: cannot be stopped */
const struck = p.ab.phase <= 0 ? pipeHit(hitBox(p)) : null;
if (struck) {
if (p.shield) {
p.shield = false;
/* drop the bird into the gap it was aiming for, so it survives */
p.y = struck.gapY + struck.gap / 2 - CHICKEN_PY / 2;
p.vy = -2.5;
S.shake = 8; S.flash = 4;
burst(p.x + 24, p.y + 20, '#4FC3F7', 20);
SFX.melt();
adv('shield', 'SAVED BY THE SHIELD', 'THE BUBBLE POPPED');
} else {
die(p);
return;
}
}
if (p.y + CHICKEN_PY > GROUND_Y) {
p.y = GROUND_Y - CHICKEN_PY;
if (p.shield) { p.shield = false; S.shake = 8; burst(p.x + 24, p.y + 20, '#4FC3F7', 16); }
else { die(p); return; }
}
}
/* ghost recording */
if (S.frame % 3 === 0) S.rec.push(Math.round(P1().y * 10) / 10);
/* camera drift */
const target = clamp((P1().y - 300) * -0.10, -26, 26);
S.camY += (target - S.camY) * 0.06;
updateParts();
}
function spawnDrops(n, vmin, vmax) {
S.drops = [];
for (let i = 0; i < n; i++) {
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() {
const b = BIOMES[S.biomeTo];
const forced = b.name === 'NETHER' ? 'embers' : (b.name === 'ICE PEAKS' ? 'snow' : null);
if (forced) {
if (S.weather !== forced) { S.weather = forced; spawnDrops(50, 5, 10); }
} else {
S.weatherT--;
if (S.weatherT <= 0) {
S.weatherT = vrnd(320, 720);
S.weather = vrnd(0, 1) < 0.40 ? 'rain' : 'clear';
if (S.weather === 'rain') { S.thunderT = vrnd(300, 700); spawnDrops(70, 6, 11); }
else S.drops = [];
}
}
if (S.weather === 'rain') {
S.thunderT--;
if (S.thunderT <= 0) { S.thunderT = vrnd(300, 700); S.flash = 8; SFX.thunder(); }
}
}
/* ---------------- rendering ---------------- */
function biomeMix(key) {
const a = BIOMES[S.biomeFrom][key], b = BIOMES[S.biomeTo][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 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, 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(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 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;
const cx = x + s / 2, cy = y + s / 2;
const gr = ctx.createRadialGradient(cx, cy, 3, cx, cy, 78);
gr.addColorStop(0, 'rgba(255,246,190,.55)');
gr.addColorStop(0.5, 'rgba(255,246,190,.18)');
gr.addColorStop(1, 'rgba(255,246,190,0)');
ctx.fillStyle = gr;
ctx.fillRect(x - 80, y - 80, s + 160, s + 160);
ctx.fillStyle = mix('#FFF3A8', '#FF8A3A', n / 0.62);
ctx.fillRect(x, y, s, s);
ctx.fillStyle = mix('#FFEE86', '#FF6A2A', n / 0.62);
ctx.fillRect(x + 4, y + 4, s - 8, s - 8);
ctx.fillStyle = mix('#FFFCE0', '#FFB05A', n / 0.62);
ctx.fillRect(x + 10, y + 10, s - 20, s - 20);
ctx.restore();
}
if (n > 0.4) {
const a = clamp((n - 0.4) / 0.25, 0, 1);
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)';
ctx.fillRect(x - 10, y - 10, s + 20, s + 20);
ctx.fillStyle = '#E8EEFF';
ctx.fillRect(x, y, s, s);
ctx.fillStyle = '#C6CFE8';
ctx.fillRect(x + 5, y + 6, 6, 5);
ctx.fillRect(x + 15, y + 15, 7, 6);
ctx.fillRect(x + 8, y + 18, 4, 4);
ctx.restore();
}
}
function drawClouds() {
for (const cl of S.clouds) {
const u = 13 * cl.s;
ctx.fillStyle = 'rgba(255,255,255,.94)';
for (const sl of cl.slabs) {
ctx.fillRect(Math.round(cl.x + sl.x * u), Math.round(cl.y), Math.round(sl.w * u), Math.round(u * 0.95));
}
ctx.fillStyle = 'rgba(206,224,244,.85)';
for (const sl of cl.slabs) {
ctx.fillRect(Math.round(cl.x + sl.x * u), Math.round(cl.y + u * 0.95), Math.round(sl.w * u), 3);
}
}
}
function drawHills(img, y, speed) {
const off = -((S.scroll * speed) % 640);
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;
const cols = th.w / BLOCK;
const topH = p.gapY;
const botY = p.gapY + p.gap;
const botH = GROUND_Y - botY;
/* the column starts above the top of the screen so the barrier always
reaches the top edge, whatever shape the device is */
const startY = Math.floor((-VIEW.oy / VIEW.scale) / BLOCK) * BLOCK;
for (let col = 0; col < cols; col++) {
const bx = Math.round(p.x + col * BLOCK);
for (let y = startY; y < topH; y += BLOCK) {
block(ctx, bx, y, (y + BLOCK > topH - BLOCK) ? th.top : th.body, p.seed + col);
}
for (let y = botY; y < botY + botH; y += BLOCK) {
let kind = (y < botY + BLOCK) ? th.bottom : th.body;
if (p.melted && y < botY + 20) kind = 'nether';
block(ctx, bx, y, kind, p.seed + col + 977);
}
}
ctx.fillStyle = 'rgba(0,0,0,.18)';
ctx.fillRect(p.x, topH - 4, th.w, 4);
ctx.fillRect(p.x, botY, th.w, 4);
if (p.melted) {
ctx.fillStyle = 'rgba(232,134,42,.55)';
ctx.fillRect(p.x, topH - 3, th.w, 3);
ctx.fillRect(p.x, botY, th.w, 3);
}
}
function drawPickup(g) {
const img = SPR[PICKUPS[g.kind].sprite];
const bob = Math.sin(g.t / 9) * 3;
const x = Math.round(g.x - img.width / 2);
const y = Math.round(g.y - img.height / 2 + bob);
ctx.fillStyle = 'rgba(0,0,0,.16)';
ctx.fillRect(x + 2, y + 3, img.width, img.height);
ctx.drawImage(img, x, y);
if (g.kind === 'cube') {
const a = S.t / 12;
ctx.save();
ctx.globalAlpha = 0.35 + 0.35 * Math.sin(a);
ctx.strokeStyle = '#F7D64A';
ctx.lineWidth = 2;
ctx.strokeRect(x - 3, y - 3, img.width + 6, img.height + 6);
ctx.restore();
}
}
function drawPlayer(p, ghost) {
const ch = ROSTER[p.char];
ctx.save();
ctx.translate(Math.round(p.x) + CHICKEN_PX / 2, Math.round(p.y) + CHICKEN_PY / 2);
ctx.rotate(p.rot);
if (ghost) ctx.globalAlpha = 0.35;
else {
if (p.ab.phase > 0) ctx.globalAlpha = 0.45;
if (p.ab.squash > 0) ctx.scale(1 + p.ab.squash * 0.14, 1 - p.ab.squash * 0.14);
}
ctx.drawImage(ch.sprite, -CHICKEN_PX / 2, -CHICKEN_PY / 2, CHICKEN_PX, CHICKEN_PY);
if (p.char === 0 && !ghost) ctx.drawImage(SPR.wing, -12, -5 + p.wing, 16, 12);
if (p.buf && p.buf.fly > 0 && !ghost) {
ctx.drawImage(SPR.wings, -CHICKEN_PX / 2 - 4, -CHICKEN_PY / 2 - 6, 30, 20);
ctx.drawImage(SPR.wings, CHICKEN_PX / 2 - 26, -CHICKEN_PY / 2 - 6, 30, 20);
}
ctx.restore();
if (ghost) return;
if (p.shield) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.12 * Math.sin(S.t / 6);
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x + 24, p.y + 20, 30, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
if (p.ab.phase > 0) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.15 * Math.sin(S.t / 4);
ctx.strokeStyle = '#5BD4E0';
ctx.lineWidth = 2;
ctx.strokeRect(p.x + 4, p.y + 4, CHICKEN_PX - 8, CHICKEN_PY - 8);
ctx.restore();
}
}
function drawParts() {
for (const p of S.parts) {
ctx.fillStyle = p.c;
ctx.fillRect(Math.round(p.x), Math.round(p.y), p.size, p.size);
}
}
function drawGround() {
const tile = GROUND_TILES[S.biomeTo];
const off = -((S.scroll) % 512);
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') {
ctx.strokeStyle = 'rgba(190,215,245,.55)';
ctx.lineWidth = 1;
ctx.beginPath();
for (const d of S.drops) {
d.y += d.v; d.x += d.d;
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);
}
ctx.stroke();
} 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 > 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 = 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);
ctx.fillStyle = 'rgba(255,255,255,.16)';
ctx.fillRect(x, y, w, 2); ctx.fillRect(x, y + h - 2, w, 2);
ctx.fillRect(x, y, 2, h); ctx.fillRect(x + w - 2, y, 2, h);
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.fillRect(x + 3, y + 3, w - 6, h - 6);
}
/* ---------------- character select + settings ---------------- */
const SEL_ROW = { y: 302, slot: 56, size: 30 };
function selIconX(i) {
const total = ROSTER.length * SEL_ROW.slot;
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 world units make a 44 CSS px touch target? */
function touchPx() {
return 44 / Math.max(0.2, VIEW.scale);
}
function inBadge(x, y) {
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 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(); } },
{ id: 'mode', label: 'PLAYERS', opts: ['1 PLAYER', '2 PLAYERS'], get: () => S.mode === '2p' ? 1 : 0, set: v => { S.mode = v ? '2p' : '1p'; } },
{ id: 'daily', label: 'DAILY', opts: ['OFF', 'ON'], get: () => S.daily ? 1 : 0, set: v => { S.daily = !!v; SAVE.daily = S.daily; writeSave(); } },
{ id: 'music', label: 'MUSIC', opts: ['ON', 'OFF'], get: () => S.music ? 0 : 1, set: v => { S.music = !v; SAVE.music = S.music; writeSave(); if (S.music) startMusic(); else stopMusic(); } }
];
const SET_ROW_Y = 364;
function setRowRect(i) {
return { x: 40, y: SET_ROW_Y + i * 20, w: 400, h: 18 };
}
function drawSelect() {
const ch = cur();
const px = Math.round(W / 2 - GRID_W * 2), py = 118;
ctx.fillStyle = 'rgba(10,14,22,.35)';
ctx.fillRect(px - 8, py - 8, GRID_W * 4 + 16, GRID_H * 4 + 16);
ctx.drawImage(ch.sprite, px, py, GRID_W * 4, GRID_H * 4);
/* the portrait is the big "change bird" button */
ctx.strokeStyle = 'rgba(255,255,255,.22)';
ctx.lineWidth = 2;
ctx.strokeRect(px - 8, py - 8, GRID_W * 4 + 16, GRID_H * 4 + 16);
ptext(ch.name, W / 2, 210, 3, 'center', '#fff');
ptext(ch.ab, W / 2, 234, 2, 'center', '#F2C744');
ptext(ch.d1, W / 2, 252, 2, 'center', '#BFD8FF');
ptext(ch.d2, W / 2, 268, 2, 'center', '#BFD8FF');
const best = SAVE.bests[ch.id] || 0;
ptext(best ? 'BEST ' + best : ch.how, W / 2, 284, 2, 'center', best ? '#8BE04A' : 'rgba(255,255,255,.55)');
for (let i = 0; i < ROSTER.length; i++) {
const x = selIconX(i), y = SEL_ROW.y;
const on = i === S.sel;
const locked = !SAVE.unlocked[ROSTER[i].id];
ctx.fillStyle = 'rgba(139,139,139,.55)';
ctx.fillRect(x - 4, y - 4, SEL_ROW.size + 8, SEL_ROW.size + 8);
ctx.fillStyle = 'rgba(0,0,0,.45)';
ctx.fillRect(x - 4, y - 4, SEL_ROW.size + 8, 2);
ctx.fillRect(x - 4, y - 4, 2, SEL_ROW.size + 8);
ctx.fillRect(x - 4, y + SEL_ROW.size + 2, SEL_ROW.size + 8, 2);
ctx.fillRect(x + SEL_ROW.size + 2, y - 4, 2, SEL_ROW.size + 8);
if (locked) {
ctx.save();
ctx.globalAlpha = 0.35;
ctx.drawImage(ROSTER[i].sprite, x, y, SEL_ROW.size, SEL_ROW.size);
ctx.restore();
ctx.drawImage(SPR.lock, x + 6, y + 4, 12, 13);
} else {
ctx.drawImage(ROSTER[i].sprite, x, y, SEL_ROW.size, SEL_ROW.size);
}
if (on) {
ctx.strokeStyle = locked ? 'rgba(255,255,255,.4)' : '#F2C744';
ctx.lineWidth = 2;
ctx.strokeRect(x - 5, y - 5, SEL_ROW.size + 10, SEL_ROW.size + 10);
}
}
/* on-screen arrows, so the picker works without a keyboard */
const ay = SEL_ROW.y + SEL_ROW.size / 2 - 10;
arrowBtn(ARROW_L, ay);
arrowBtn(ARROW_R, ay);
ptext('TAP A BIRD TO CHANGE', W / 2, 340, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
}
const ARROW_L = 40, ARROW_R = 440;
function arrowBtn(x, y) {
ctx.fillStyle = 'rgba(16,19,26,.85)';
ctx.fillRect(x - 11, y - 4, 22, 26);
ctx.strokeStyle = 'rgba(255,255,255,.28)';
ctx.lineWidth = 2;
ctx.strokeRect(x - 11, y - 4, 22, 26);
ptext(x === ARROW_L ? '<' : '>', x, y + 3, 3, 'center', '#fff');
}
function inArrow(x, y) {
return (x > ARROW_L - 16 && x < ARROW_L + 16 && y > 296 && y < 336) ||
(x > ARROW_R - 16 && x < ARROW_R + 16 && y > 296 && y < 336);
}
function drawSettings() {
panel(40, SET_ROW_Y - 10, 400, 104);
for (let i = 0; i < SETTINGS.length; i++) {
const s = SETTINGS[i], y = SET_ROW_Y + i * 20;
ptext(s.label, 52, y, 2, 'left', 'rgba(255,255,255,.7)');
const v = s.get();
const widths = s.opts.map(o => o.length * 12 - 2);
const total = widths.reduce((a, b) => a + b, 0) + 10 * (s.opts.length - 1);
let cur = 306 - total / 2;
for (let k = 0; k < s.opts.length; k++) {
const on = k === v, w = widths[k];
if (on) {
ctx.fillStyle = 'rgba(242,199,68,.22)';
ctx.fillRect(cur - 6, y - 3, w + 12, 18);
}
ptext(s.opts[k], cur + w / 2, y, 2, 'center', on ? '#F2C744' : 'rgba(255,255,255,.45)');
cur += w + 10;
}
}
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 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(b.x, b.y, b.s, b.s);
ctx.fillStyle = 'rgba(0,0,0,.5)';
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 * 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(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], b.x + b.s / 2, b.y + (b.s - 28 * s) / 2, 4 * s, 'center', ch.tint);
else {
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 inT = clamp((150 - t.life) / 10, 0, 1);
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, pw, ph);
ctx.fillStyle = 'rgba(242,199,68,.9)';
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) {
const list = [];
if (p.shield) list.push(['SH', '#4FC3F7']);
if (p.buf.dbl > 0) list.push(['2X', '#F2C744']);
if (p.buf.tiny > 0) list.push(['TI', '#8BFFC0']);
if (p.buf.mega > 0) list.push(['MG', '#E8862A']);
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']);
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(VIEW.w / 2 - total / 2 + i * gap), y = 74 * s;
ctx.fillStyle = 'rgba(16,19,26,.8)';
ctx.fillRect(x, y, sz, sz);
ctx.fillStyle = list[i][1];
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 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, VIEW.w, hgt);
ctx.fillStyle = '#F2C744';
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() {
if (!S.ghost || !S.ghost.y) return;
const i = Math.floor(S.frame / 3);
if (i >= S.ghost.y.length) return;
const y = S.ghost.y[i];
const p = { x: PLAYER_X, y: y, char: S.sel, rot: 0, buf: {}, alive: true, wing: 0, ab: {} };
drawPlayer(p, true);
}
/* ---------------- 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));
}
drawClouds();
ctx.save();
ctx.translate(0, S.camY);
drawHills(HILLS[S.biomeTo].far, GROUND_Y - HILLS[S.biomeTo].far.height + 22, 0.18);
drawHills(HILLS[S.biomeTo].near, GROUND_Y - HILLS[S.biomeTo].near.height + 40, 0.38);
for (const p of S.pipes) drawPipe(p);
for (const g of S.pickups) drawPickup(g);
drawParts();
if (S.state === 'title') {
/* the title screen shows the portrait instead */
} else {
if (S.ghost) drawGhost();
for (let k = 0; k < activePlayers(); k++) drawPlayer(S.players[k], false);
}
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, 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, 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, VIEW.w, VIEW.h);
}
/* ---- 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(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();
drawSettings();
if (Math.floor(S.t / 30) % 2 === 0) ptext('PRESS SPACE TO PLAY', W / 2, 478, 3, 'center', '#fff');
ptext('FLAP: SPACE / W / UP / TAP', W / 2, 502, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
ptext('ABILITY: F KEY OR TAP THE BADGE', W / 2, 520, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
ptext('2P: P1 SPACE P2 ARROW UP', W / 2, 538, 2, 'center', 'rgba(255,255,255,.45)', 'rgba(0,0,0,.35)');
}
/* ---- game over ---- */
if (S.state === 'over') {
ctx.fillStyle = 'rgba(10,14,22,.6)';
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');
panel(90, 212, 300, 130);
ptext('P1 ' + P1().score, W / 2, 232, 3, 'center', '#fff');
ptext('P2 ' + P2().score, W / 2, 264, 3, 'center', '#5BE0E8');
ptext('BEST ' + SAVE.best, W / 2, 300, 2, 'center', '#F2C744');
} else {
ptext('GAME OVER', W / 2, 150, 6, 'center', '#FF6B6B');
panel(90, 212, 300, 150);
ptext('SCORE ' + P1().score, W / 2, 232, 3, 'center', '#fff');
ptext('BEST ' + SAVE.best, W / 2, 264, 3, 'center', '#F2C744');
ptext(cur().name + ' BEST ' + (SAVE.bests[ROSTER[P1().char].id] || 0), W / 2, 296, 2, 'center', ROSTER[P1().char].tint);
if (P1().score > 0 && P1().score >= SAVE.best) ptext('NEW BEST!', W / 2, 320, 2, 'center', '#8BE04A');
}
if (S.overT > 45 && Math.floor(S.overT / 22) % 2 === 0) {
ptext('TAP OR SPACE TO RETRY', W / 2, 392, 2, 'center', '#fff');
ptext('PRESS C TO CHANGE BIRD', W / 2, 412, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
}
}
/* ---- paused ---- */
if (S.state === 'paused') {
const sr = screenRect();
ctx.fillStyle = 'rgba(10,14,22,.6)';
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 ---------------- */
/* step to the next unlocked bird (used by the on-screen arrows) */
function cycleBird(dir) {
for (let i = 1; i <= ROSTER.length; i++) {
const n = (S.sel + dir * i + ROSTER.length * 2) % ROSTER.length;
if (SAVE.unlocked[ROSTER[n].id]) { selectChar(n); return; }
}
SFX.deny();
}
function selectChar(i) {
const n = (i + ROSTER.length) % ROSTER.length;
if (n === S.sel) return;
S.sel = n;
SFX.select();
}
function primaryAction() {
audio();
if (S.state === 'title') { reset(); return; }
if (S.state === 'play') { flap(P1()); return; }
if (S.state === 'paused') { S.state = S.prevState; return; }
if (S.state === 'over' && S.overT > 45) reset();
}
function cycleSetting(i) {
const s = SETTINGS[i];
s.set((s.get() + 1) % s.opts.length);
SFX.select();
}
cvs.addEventListener('pointerdown', e => {
e.preventDefault();
const r = cvs.getBoundingClientRect();
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 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 */
/* tapping the big portrait cycles to the next bird */
const bpx = W / 2 - GRID_W * 2, bpy = 118;
if (wx >= bpx - 10 && wx <= bpx + GRID_W * 4 + 10 &&
wy >= bpy - 10 && wy <= bpy + GRID_H * 4 + 10) {
cycleBird(1);
return;
}
/* the on-screen arrows */
if (inArrow(wx, wy)) {
cycleBird(wx < W / 2 ? -1 : 1);
return;
}
for (let i = 0; i < ROSTER.length; i++) {
const ix = selIconX(i);
const pad = (SEL_ROW.slot - SEL_ROW.size) / 2;
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 (wx >= rr.x && wx <= rr.x + rr.w && wy >= rr.y - t && wy <= rr.y + rr.h + t) {
cycleSetting(i); return;
}
}
}
primaryAction();
});
window.addEventListener('keydown', e => {
const k = e.code;
if (k === 'Space' || k === 'KeyW') {
e.preventDefault();
audio();
if (S.state === 'play') flap(P1());
else primaryAction();
} else if (k === 'ArrowUp') {
e.preventDefault();
if (S.state === 'play') { if (S.mode === '2p') flap(P2()); else flap(P1()); }
else primaryAction();
} else if (k === 'ArrowLeft' || k === 'KeyA') {
e.preventDefault();
if (S.state === 'title') selectChar(S.sel - 1);
} else if (k === 'ArrowRight' || k === 'KeyD') {
e.preventDefault();
if (S.state === 'title') selectChar(S.sel + 1);
} else if (k === 'KeyF') {
e.preventDefault(); useAbility(P1());
} else if (k === 'KeyG') {
e.preventDefault(); useAbility(P2());
} else if (k === 'KeyP' && (S.state === 'play' || S.state === 'paused')) {
e.preventDefault();
if (S.state === 'play') { S.prevState = 'play'; S.state = 'paused'; }
else S.state = 'play';
} else if (k === 'KeyC' && S.state === 'over') {
e.preventDefault(); S.state = 'title'; S.pipes = []; S.pickups = [];
} else if (S.state === 'title') {
if (k === 'KeyK') cycleSetting(0);
else if (k === 'KeyT') cycleSetting(1);
else if (k === 'KeyY') cycleSetting(2);
else if (k === 'KeyM') cycleSetting(3);
}
});
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) {
let dt = (now - last) / 1000;
last = now;
if (dt > 0.25) dt = 0.25;
acc += dt;
while (acc >= STEP) { update(); acc -= STEP; }
resize();
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.__game = S;
window.__save = SAVE;
</script>
</body>
</html>