MINEFLAPPY-GAME/index.html

2019 lines
71 KiB
HTML
Raw Normal View History

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