1817 lines
62 KiB
HTML
1817 lines
62 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="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%236FA83C'/><rect y='6' width='16' height='10' fill='%238B6141'/></svg>">
|
||
|
|
<title>Mineflappy — Minecraft Flappy Bird</title>
|
||
|
|
<style>
|
||
|
|
*{box-sizing:border-box}
|
||
|
|
html,body{height:100%;margin:0}
|
||
|
|
body{
|
||
|
|
background:radial-gradient(120% 90% at 50% 0%,#242833 0%,#12141a 60%,#0a0b0f 100%);
|
||
|
|
color:#fff;font-family:"Courier New",ui-monospace,monospace;
|
||
|
|
display:flex;align-items:center;justify-content:center;overflow:hidden;
|
||
|
|
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
|
||
|
|
}
|
||
|
|
canvas{
|
||
|
|
display:block;
|
||
|
|
width:min(calc(100vw - 20px), calc((100vh - 20px) * 0.75));
|
||
|
|
height:auto;aspect-ratio:480 / 640;
|
||
|
|
image-rendering:pixelated;image-rendering:crisp-edges;
|
||
|
|
border:3px solid #4a515f;border-radius:3px;
|
||
|
|
box-shadow:0 0 0 3px #101216, 0 0 0 6px #262b34, 0 22px 60px rgba(0,0,0,.7);
|
||
|
|
touch-action:none;cursor:pointer;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</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;
|
||
|
|
|
||
|
|
/* ---------------- 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 ---------------- */
|
||
|
|
function block(g, bx, by, kind, seed) {
|
||
|
|
const p = PAL[kind];
|
||
|
|
g.fillStyle = p.base;
|
||
|
|
g.fillRect(bx, by, BLOCK, BLOCK);
|
||
|
|
for (let i = 0; i < 4; i++) {
|
||
|
|
const h1 = hash(bx + i * 7, by, seed);
|
||
|
|
const h2 = hash(bx, by + i * 13, seed + 11);
|
||
|
|
const h3 = hash(bx + i * 31, by + i * 5, seed + 23);
|
||
|
|
g.fillStyle = p.vars[Math.floor(h3 * p.vars.length) % p.vars.length];
|
||
|
|
g.fillRect(bx + Math.floor(h1 * 13), by + Math.floor(h2 * 13), 2, 2);
|
||
|
|
}
|
||
|
|
g.fillStyle = 'rgba(0,0,0,.16)';
|
||
|
|
g.fillRect(bx, by + BLOCK - 2, BLOCK, 2);
|
||
|
|
g.fillRect(bx + BLOCK - 2, by, 2, BLOCK);
|
||
|
|
g.fillStyle = 'rgba(255,255,255,.10)';
|
||
|
|
g.fillRect(bx, by, BLOCK, 1);
|
||
|
|
g.fillRect(bx, by, 1, BLOCK);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 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++) {
|
||
|
|
block(g, x, row * BLOCK, b.ground[row], bi * 31 + row + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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
|
||
|
|
};
|
||
|
|
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) {
|
||
|
|
const top = { x: p.x, y: 0, w: p.theme.w, h: p.gapY };
|
||
|
|
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, W), y: vrnd(0, 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 grd = ctx.createLinearGradient(0, 0, 0, GROUND_Y);
|
||
|
|
const c = biomeMix('sky');
|
||
|
|
grd.addColorStop(0, c[0]);
|
||
|
|
grd.addColorStop(1, c[1]);
|
||
|
|
ctx.fillStyle = grd;
|
||
|
|
ctx.fillRect(0, 0, W, GROUND_Y);
|
||
|
|
}
|
||
|
|
function drawStars() {
|
||
|
|
const a = clamp((S.night - 0.35) / 0.4, 0, 1);
|
||
|
|
if (a <= 0) return;
|
||
|
|
for (const s of S.stars) {
|
||
|
|
const tw = 0.65 + 0.35 * Math.sin(S.t / 22 + s.p);
|
||
|
|
ctx.fillStyle = 'rgba(255,255,255,' + (a * tw).toFixed(3) + ')';
|
||
|
|
ctx.fillRect(s.x | 0, s.y | 0, s.s, s.s);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function drawSun() {
|
||
|
|
const n = S.night;
|
||
|
|
if (n < 0.62) {
|
||
|
|
const x = 388 - n * 180, y = 44 + n * 300, s = 34;
|
||
|
|
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 x = 90 + n * 90, y = 70 - n * 20, s = 28;
|
||
|
|
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);
|
||
|
|
ctx.drawImage(img, Math.round(off), y);
|
||
|
|
ctx.drawImage(img, Math.round(off + 640), 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;
|
||
|
|
for (let col = 0; col < cols; col++) {
|
||
|
|
const bx = Math.round(p.x + col * BLOCK);
|
||
|
|
const wseed = Math.floor(bx / BLOCK) * 13;
|
||
|
|
for (let y = 0; y < topH; y += BLOCK) {
|
||
|
|
block(ctx, bx, y, (y + BLOCK > topH - BLOCK) ? th.top : th.body, wseed + y);
|
||
|
|
}
|
||
|
|
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, wseed + y + 7);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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);
|
||
|
|
ctx.drawImage(tile, Math.round(off), GROUND_Y);
|
||
|
|
ctx.drawImage(tile, Math.round(off + 512), 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 > H) { d.y = -10; d.x = vrnd(0, 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 > H) { d.y = -6; d.x = vrnd(0, W); }
|
||
|
|
ctx.fillStyle = 'rgba(255,255,255,.85)';
|
||
|
|
ctx.fillRect(d.x | 0, d.y | 0, 3, 3);
|
||
|
|
}
|
||
|
|
} else if (S.weather === 'embers') {
|
||
|
|
for (const d of S.drops) {
|
||
|
|
d.y -= d.v * 0.5; d.x += Math.sin(S.t / 18 + d.y) * 0.5;
|
||
|
|
if (d.y < -8) { d.y = H + 8; d.x = vrnd(0, W); }
|
||
|
|
ctx.fillStyle = 'rgba(232,134,42,' + (0.3 + 0.5 * Math.random()).toFixed(2) + ')';
|
||
|
|
ctx.fillRect(d.x | 0, d.y | 0, 2, 2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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: 308, slot: 44, size: 24 };
|
||
|
|
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 };
|
||
|
|
function inBadge(x, y) {
|
||
|
|
return x >= BADGE.x - 4 && x <= BADGE.x + BADGE.s + 4 && y >= BADGE.y - 4 && y <= BADGE.y + BADGE.s + 4;
|
||
|
|
}
|
||
|
|
function inBadge2(x, y) {
|
||
|
|
return x >= 6 && x <= 54 && y >= 28 && y <= 76;
|
||
|
|
}
|
||
|
|
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);
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ptext('< ARROWS OR CLICK TO CHOOSE >', W / 2, 336, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
|
||
|
|
}
|
||
|
|
|
||
|
|
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 drawBadge(p, side) {
|
||
|
|
const ch = ROSTER[p.char];
|
||
|
|
if (!ch.key) return;
|
||
|
|
const x = side === 'right' ? BADGE.x : 10;
|
||
|
|
const y = side === 'right' ? BADGE.y : 32, s = BADGE.s;
|
||
|
|
const ready = p.ab.cd <= 0;
|
||
|
|
ctx.fillStyle = ready ? 'rgba(16,19,26,.92)' : 'rgba(16,19,26,.78)';
|
||
|
|
ctx.fillRect(x, y, s, s);
|
||
|
|
ctx.fillStyle = 'rgba(0,0,0,.5)';
|
||
|
|
ctx.fillRect(x + 2, y + 2, s - 4, s - 4);
|
||
|
|
ctx.strokeStyle = ready ? ch.tint : 'rgba(255,255,255,.3)';
|
||
|
|
ctx.lineWidth = 2;
|
||
|
|
ctx.strokeRect(x + 1, y + 1, s - 2, s - 2);
|
||
|
|
if (!ready) {
|
||
|
|
ctx.fillStyle = 'rgba(0,0,0,.62)';
|
||
|
|
ctx.fillRect(x + 2, y + 2, s - 4, (s - 4) * (p.ab.cd / ch.cd));
|
||
|
|
}
|
||
|
|
if (ready) ptext(ch.ab[0], x + s / 2, y + s / 2 - 2, 4, 'center', ch.tint);
|
||
|
|
else {
|
||
|
|
ptext(ch.ab[0], x + s / 2, y + 13, 3, 'center', 'rgba(255,255,255,.75)');
|
||
|
|
ptext(Math.ceil(p.ab.cd / 60) + 'S', x + s / 2, y + s - 10, 2, 'center', '#fff');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function drawToasts() {
|
||
|
|
for (let i = 0; i < S.toasts.length; i++) {
|
||
|
|
const t = S.toasts[i];
|
||
|
|
const y = 68 + i * 38;
|
||
|
|
const inT = clamp((150 - t.life) / 10, 0, 1);
|
||
|
|
const x = W - 190 + (1 - inT) * 30;
|
||
|
|
ctx.fillStyle = 'rgba(16,19,26,.92)';
|
||
|
|
ctx.fillRect(x, y, 184, 32);
|
||
|
|
ctx.fillStyle = 'rgba(242,199,68,.9)';
|
||
|
|
ctx.fillRect(x, y, 4, 32);
|
||
|
|
ptext(t.t.length > 14 ? t.t.slice(0, 14) : t.t, x + 12, y + 5, 2, 'left', '#F2C744');
|
||
|
|
if (t.s) ptext(t.s.length > 28 ? t.s.slice(0, 28) : t.s, x + 12, y + 20, 1, 'left', 'rgba(255,255,255,.75)');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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']);
|
||
|
|
const bw = list.length * 22;
|
||
|
|
for (let i = 0; i < list.length; i++) {
|
||
|
|
const x = Math.round(W / 2 - bw / 2 + i * 22), y = 74;
|
||
|
|
ctx.fillStyle = 'rgba(16,19,26,.8)';
|
||
|
|
ctx.fillRect(x, y, 18, 18);
|
||
|
|
ctx.fillStyle = list[i][1];
|
||
|
|
ctx.fillRect(x + 1, y + 1, 16, 16);
|
||
|
|
ptext(list[i][0], x + 9, y + 4, 1, 'center', '#101018');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function drawBanner() {
|
||
|
|
if (S.banner <= 0) return;
|
||
|
|
const a = clamp(S.banner / 40, 0, 1);
|
||
|
|
const y = 150;
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalAlpha = a;
|
||
|
|
ctx.fillStyle = 'rgba(12,14,20,.8)';
|
||
|
|
ctx.fillRect(0, y, W, 46);
|
||
|
|
ctx.fillStyle = '#F2C744';
|
||
|
|
ctx.fillRect(0, y, W, 3);
|
||
|
|
ctx.fillRect(0, y + 43, W, 3);
|
||
|
|
ptext(BIOMES[S.biomeTo].name, W / 2, y + 12, 4, 'center', '#fff');
|
||
|
|
ctx.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() {
|
||
|
|
ctx.save();
|
||
|
|
if (S.shake > 0) {
|
||
|
|
S.shake *= 0.86;
|
||
|
|
if (S.shake < 0.4) S.shake = 0;
|
||
|
|
ctx.translate(vrnd(-S.shake, S.shake), vrnd(-S.shake, S.shake));
|
||
|
|
}
|
||
|
|
|
||
|
|
drawSky();
|
||
|
|
drawStars();
|
||
|
|
drawSun();
|
||
|
|
drawClouds();
|
||
|
|
|
||
|
|
ctx.save();
|
||
|
|
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();
|
||
|
|
|
||
|
|
drawWeather();
|
||
|
|
|
||
|
|
/* night tint */
|
||
|
|
if (S.night > 0) {
|
||
|
|
ctx.fillStyle = 'rgba(12,16,60,' + (S.night * 0.45).toFixed(3) + ')';
|
||
|
|
ctx.fillRect(0, 0, W, H);
|
||
|
|
}
|
||
|
|
if (S.flash > 0) {
|
||
|
|
ctx.fillStyle = 'rgba(255,255,255,' + (S.flash / 5 * 0.5).toFixed(3) + ')';
|
||
|
|
ctx.fillRect(0, 0, W, H);
|
||
|
|
}
|
||
|
|
if (S.deny > 0) {
|
||
|
|
ctx.fillStyle = 'rgba(255,80,80,' + (S.deny / 12 * 0.18).toFixed(3) + ')';
|
||
|
|
ctx.fillRect(0, 0, W, H);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* HUD */
|
||
|
|
if (S.state !== 'title') {
|
||
|
|
const p = P1();
|
||
|
|
if (S.mode === '2p') ptext('P2 ' + P2().score, 10, 12, 2, 'left', '#5BE0E8', 'rgba(0,0,0,.45)');
|
||
|
|
else ptext('BEST ' + SAVE.best, 10, 12, 2, 'left', '#E8F0FF', 'rgba(0,0,0,.45)');
|
||
|
|
ptext(String(p.score), W / 2, 26, 6, 'center', '#fff', 'rgba(0,0,0,.45)');
|
||
|
|
drawBuffs(p);
|
||
|
|
if (S.mode === '2p') drawBadge(P2(), 'left');
|
||
|
|
drawBadge(p, 'right');
|
||
|
|
drawToasts();
|
||
|
|
drawBanner();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- title ---- */
|
||
|
|
if (S.state === 'title') {
|
||
|
|
ctx.fillStyle = 'rgba(10,14,22,.58)';
|
||
|
|
ctx.fillRect(0, 0, W, 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)';
|
||
|
|
ctx.fillRect(0, 0, W, 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') {
|
||
|
|
ctx.fillStyle = 'rgba(10,14,22,.6)';
|
||
|
|
ctx.fillRect(0, 0, W, H);
|
||
|
|
ptext('PAUSED', W / 2, 280, 6, 'center', '#fff');
|
||
|
|
ptext('PRESS P OR SPACE TO RESUME', W / 2, 330, 2, 'center', '#BFD8FF');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------- input ---------------- */
|
||
|
|
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 sx = (e.clientX - r.left) / r.width * W;
|
||
|
|
const sy = (e.clientY - r.top) / r.height * H;
|
||
|
|
audio();
|
||
|
|
|
||
|
|
if (S.state === 'play') {
|
||
|
|
/* ability badges */
|
||
|
|
if (cur().key && inBadge(sx, sy)) { useAbility(P1()); return; }
|
||
|
|
if (S.mode === '2p' && charOf(1).key && inBadge2(sx, sy)) { useAbility(P2()); return; }
|
||
|
|
if (S.mode === '2p') { flap(sx > W / 2 ? P2() : P1()); return; }
|
||
|
|
flap(P1());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (S.state === 'title') {
|
||
|
|
for (let i = 0; i < ROSTER.length; i++) {
|
||
|
|
const ix = selIconX(i);
|
||
|
|
if (sx >= ix - 8 && sx <= ix + 32 && sy >= SEL_ROW.y - 10 && sy <= SEL_ROW.y + 30) {
|
||
|
|
selectChar(i); return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (let i = 0; i < SETTINGS.length; i++) {
|
||
|
|
const rr = setRowRect(i);
|
||
|
|
if (sx >= rr.x && sx <= rr.x + rr.w && sy >= rr.y - 2 && sy <= rr.y + rr.h) {
|
||
|
|
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'; }
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------------- 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; }
|
||
|
|
render();
|
||
|
|
requestAnimationFrame(frame);
|
||
|
|
}
|
||
|
|
requestAnimationFrame(frame);
|
||
|
|
|
||
|
|
window.__game = S;
|
||
|
|
window.__save = SAVE;
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|