Blob Blaster
by FrostTiger901894 lines97.7 KB🛠️ matter.js (physics engine)
<!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">
<title>Blob Blaster</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
touch-action: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#game {
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
canvas {
display: block;
max-width: 100%;
touch-action: none;
}
#rot {
display: none; position: fixed; top: 10px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,.75); color: #fff; font: bold 14px Arial, sans-serif;
padding: 8px 16px; border-radius: 20px; z-index: 10; pointer-events: none; white-space: nowrap;
}
#err {
display: none; position: fixed; left: 8px; right: 8px; bottom: 8px; max-height: 40%; overflow: auto;
background: rgba(160,0,0,.92); color: #fff; font: 12px monospace; padding: 8px; border-radius: 6px; z-index: 20; white-space: pre-wrap;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<div id="rot">Rotate your phone sideways for a bigger view</div>
<div id="err"></div>
<script>
// show a readable message if anything crashes (helps debugging on phones)
window.addEventListener('error', function(e){
var d = document.getElementById('err'); if (!d) return;
d.style.display = 'block'; d.textContent += (e.message || 'error') + ' @' + (e.filename || '').split('/').pop() + ':' + (e.lineno || '') + '\n';
});
// portrait hint (shown briefly)
(function(){
try{
if (window.innerHeight > window.innerWidth * 1.15){
var r = document.getElementById('rot'); r.style.display = 'block';
setTimeout(function(){ r.style.display = 'none'; }, 6000);
}
}catch(e){}
})();
</script>
<script>
// ===================== Blob Blaster =====================
// ---------- storage ----------
function load(k, d){ try{ const v = localStorage.getItem(k); return v === null ? d : v; }catch(e){ return d; } }
function save(k, v){ try{ localStorage.setItem(k, v); }catch(e){} }
const W = 960, H = 540, GY = 480; // GY = ground top
const ANCHOR = { x: 150, y: 385 };
const MAX_DRAG = 90, POWER = 0.17;
const BIRD_R = 18, CANNON_R = 14, EGG_R = 9, SMALL_R = 14;
const TOTAL_LEVELS = 150;
const CLOCK0 = Date.now();
const MATS = {
wood: { color: 0xc98a4b, hp: 30, density: 0.001 },
glass: { color: 0x9fe3ff, hp: 12, density: 0.0008 },
stone: { color: 0x8a8f98, hp: 90, density: 0.003 }
};
// ---------- player data ----------
let coins = Math.max(0, Math.floor(Number(load('bb_coins', 0)) || 0));
let owned = String(load('bb_owned', '0')).split(',').map(Number).filter(n => n >= 0 && n < 12);
if (owned.indexOf(0) < 0) owned.push(0);
let skin = Number(load('bb_skin', 0)) || 0; if (owned.indexOf(skin) < 0) skin = 0;
let soundOn = load('bb_sound', '1') !== '0';
let levelIndex = Math.min(TOTAL_LEVELS - 1, Math.max(0, Number(load('bb_level', 0)) || 0));
let maxUnlocked = Math.min(TOTAL_LEVELS - 1, Math.max(0, Number(load('bb_max', 0)) || 0));
let starsArr = [];
try{ const a = JSON.parse(load('bb_stars', '[]')); if (Array.isArray(a)) starsArr = a.map(v => v | 0); }catch(e){}
function addCoins(n){ coins += Math.max(0, Math.floor(n)); save('bb_coins', coins); }
function getStars(i){ return starsArr[i] | 0; }
function setStars(i, n){
if (n > getStars(i)){ while (starsArr.length <= i) starsArr.push(0); starsArr[i] = n; save('bb_stars', JSON.stringify(starsArr)); }
}
function owns(i){ return owned.indexOf(i) >= 0; }
function equip(i){ if (owns(i)){ skin = i; save('bb_skin', i); return true; } return false; }
function buySkin(i){
if (owns(i)) return 'owned';
const price = SKINS[i].price;
if (coins < price) return 'poor';
coins -= price; save('bb_coins', coins);
owned.push(i); save('bb_owned', owned.join(','));
equip(i);
return 'ok';
}
function resetProgress(){
coins = 0; owned = [0]; skin = 0; levelIndex = 0; maxUnlocked = 0; starsArr = [];
['bb_coins', 'bb_owned', 'bb_skin', 'bb_level', 'bb_max', 'bb_stars'].forEach(k => { try{ localStorage.removeItem(k); }catch(e){} });
}
// ---------- audio (generated, no files) ----------
function getAC(){
const ac = getAC.ac || (getAC.ac = new (window.AudioContext || window.webkitAudioContext)());
if (ac.state === 'suspended' && ac.resume) ac.resume();
return ac;
}
function sfx(freq, dur, type, vol){
if (!soundOn) return;
try{
const ac = getAC();
const o = ac.createOscillator(), g = ac.createGain();
o.type = type || 'square'; o.frequency.value = freq;
g.gain.setValueAtTime(vol || 0.05, ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
o.connect(g); g.connect(ac.destination);
o.start(); o.stop(ac.currentTime + dur);
}catch(e){}
}
function boom(vol){
if (!soundOn) return;
try{
const ac = getAC(), now = ac.currentTime, len = Math.floor(ac.sampleRate * 0.55);
const buf = ac.createBuffer(1, len, ac.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.4);
const src = ac.createBufferSource(); src.buffer = buf;
const f = ac.createBiquadFilter(); f.type = 'lowpass';
f.frequency.setValueAtTime(1100, now); f.frequency.exponentialRampToValueAtTime(90, now + 0.5);
const g = ac.createGain(); g.gain.value = vol || 0.5;
src.connect(f); f.connect(g); g.connect(ac.destination); src.start();
const o = ac.createOscillator(), g2 = ac.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(140, now); o.frequency.exponentialRampToValueAtTime(38, now + 0.35);
g2.gain.setValueAtTime((vol || 0.5) * 0.9, now); g2.gain.exponentialRampToValueAtTime(0.0001, now + 0.4);
o.connect(g2); g2.connect(ac.destination); o.start(); o.stop(now + 0.45);
}catch(e){}
}
// ---------- 12 original birds: shape, face, price and special ability ----------
// shape = 3D head shape, brow = eyebrow thickness, slant = how angry the eyebrows are
const SKINS = [
{ name: 'Blaze', shape: 'round', body: 0xff5a36, belly: 0xffb199, acc: 'crest', ac: 0xc0391b, brow: 3, slant: 3, eye: 5,
price: 0, ab: 'none', abName: 'Classic', abDesc: 'Balanced all-rounder.' },
{ name: 'Midnight', shape: 'tri', body: 0x2b2d42, belly: 0x6c6f8a, acc: 'horns', ac: 0xffd23f, brow: 2, slant: 4, eye: 4.5, browCol: 0xffffff,
price: 2000, ab: 'range', abName: 'Long Shot', abDesc: 'Flies much farther.' },
{ name: 'Sunny', shape: 'square', body: 0xffd23f, belly: 0xfff1a8, acc: 'cap', ac: 0xe63946, brow: 4.5, slant: 2, eye: 5,
price: 5000, ab: 'dash', abName: 'Turbo Dash', abDesc: 'Tap in the air to speed up.', tap: 'DASH!' },
{ name: 'Frosty', shape: 'diamond', body: 0x6ec6ff, belly: 0xdaf1ff, acc: 'bandana', ac: 0xffffff, brow: 1.5, slant: 4, eye: 4.5,
price: 9000, ab: 'heavy', abName: 'Heavy Hitter', abDesc: 'Very heavy, smashes stone.' },
{ name: 'Minty', shape: 'hex', body: 0x4cd9a0, belly: 0xc8f7e4, acc: 'crest', ac: 0x1a9e6b, brow: 1.2, slant: 2, eye: 5,
price: 14000, ab: 'split', abName: 'Triple Split', abDesc: 'Tap in the air: splits in 3.', tap: 'SPLIT!' },
{ name: 'Violet', shape: 'oval', body: 0x9b5de5, belly: 0xdcc6f7, acc: 'glasses', ac: 0x111111, brow: 5, slant: 1, eye: 4.5,
price: 20000, ab: 'bouncy', abName: 'Super Bounce', abDesc: 'Bounces off everything.' },
{ name: 'Bubble', shape: 'round', body: 0xff7eb6, belly: 0xffd1e6, acc: 'bow', ac: 0xe0245e, brow: 1.5, slant: 1, eye: 5.5,
price: 28000, ab: 'egg', abName: 'Egg Bomb', abDesc: 'Tap in the air: drops a bomb egg.', tap: 'EGG!' },
{ name: 'Rusty', shape: 'square', body: 0xa0522d, belly: 0xe0b48c, acc: 'horns', ac: 0xf1e3c8, brow: 5, slant: 5, eye: 4, browCol: 0x222222,
price: 38000, ab: 'dive', abName: 'Rock Dive', abDesc: 'Tap in the air: smash down.', tap: 'DIVE!' },
{ name: 'Ghost', shape: 'tri', body: 0xf4f4f8, belly: 0xffffff, acc: 'star', ac: 0x6c63ff, brow: 1.2, slant: 3, eye: 5,
price: 50000, ab: 'glide', abName: 'Ghost Glide', abDesc: 'Tap in the air: float far ahead.', tap: 'GLIDE!' },
{ name: 'Goldie', shape: 'hex', body: 0xf2b705, belly: 0xffe58a, acc: 'crown', ac: 0xd62828, brow: 3.5, slant: 2, eye: 4.5,
price: 65000, ab: 'gold', abName: 'Gold Rush', abDesc: 'Double points and coins.' },
{ name: 'Ninja', shape: 'diamond', body: 0x1b1b1b, belly: 0x4a4a4a, acc: 'headband', ac: 0xe63946, brow: 2.5, slant: 5, eye: 4, browCol: 0xffffff,
price: 80000, ab: 'seek', abName: 'Shadow Strike', abDesc: 'Tap in the air: dash at a pig.', tap: 'STRIKE!' },
{ name: 'Rainbow', shape: 'round', body: 0xff5a36, belly: 0xffffff, acc: 'rainbow', ac: 0xffd23f, brow: 4, slant: 3, eye: 5,
price: 100000, ab: 'mega', abName: 'Mega Blast', abDesc: 'Tap in the air: huge explosion.', tap: 'BOOM!' }
];
// ===== Soft3D: tiny signed-distance-field ray marcher (pure JS, no libraries) =====
const S3 = (function(){
'use strict';
const sqrt = Math.sqrt, abs = Math.abs, min = Math.min, max = Math.max;
function clamp(x, a, b){ return x < a ? a : x > b ? b : x; }
function smooth(a, b, x){ const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }
function hex(c){ return [(c >> 16) & 255, (c >> 8) & 255, c & 255]; }
function mix3(a, b, t){ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function dark(c, k){ return [c[0] * k, c[1] * k, c[2] * k]; }
// ---------- primitives ----------
function sdEllipsoid(x, y, z, rx, ry, rz){
const ax = x / rx, ay = y / ry, az = z / rz;
const k0 = sqrt(ax * ax + ay * ay + az * az);
const bx = ax / rx, by = ay / ry, bz = az / rz;
const k1 = sqrt(bx * bx + by * by + bz * bz);
return k1 < 1e-6 ? -min(rx, min(ry, rz)) : k0 * (k0 - 1) / k1;
}
// tapered capsule between two points (radius r1 at a, r2 at b)
function roundCone(ax, ay, az, bx, by, bz, r1, r2){
const bax = bx - ax, bay = by - ay, baz = bz - az;
const l2 = bax * bax + bay * bay + baz * baz;
const rr = r1 - r2, a2 = l2 - rr * rr, il2 = 1 / l2;
const k0 = (rr > 0 ? 1 : rr < 0 ? -1 : 0) * rr * rr;
return function(x, y, z){
const pax = x - ax, pay = y - ay, paz = z - az;
const yy = pax * bax + pay * bay + paz * baz, zz = yy - l2;
const cx = pax * l2 - bax * yy, cy = pay * l2 - bay * yy, cz = paz * l2 - baz * yy;
const x2 = cx * cx + cy * cy + cz * cz, y2 = yy * yy * l2, z2 = zz * zz * l2;
const k = k0 * x2;
if ((zz > 0 ? 1 : zz < 0 ? -1 : 0) * a2 * z2 > k) return sqrt(x2 + z2) * il2 - r2;
if ((yy > 0 ? 1 : yy < 0 ? -1 : 0) * a2 * y2 < k) return sqrt(x2 + y2) * il2 - r1;
return (sqrt(x2 * a2 * il2) + yy * rr) * il2 - r1;
};
}
function sphereF(cx, cy, cz, r){ return (x, y, z) => { const a = x - cx, b = y - cy, c = z - cz; return sqrt(a * a + b * b + c * c) - r; }; }
function ellF(cx, cy, cz, rx, ry, rz){ return (x, y, z) => sdEllipsoid(x - cx, y - cy, z - cz, rx, ry, rz); }
function smin(a, b, k){ const h = clamp(0.5 + 0.5 * (b - a) / k, 0, 1); return b + (a - b) * h - k * h * (1 - h); }
function star5(px, py, r, rf){
const k1x = 0.809016994375, k1y = -0.587785252292, k2x = -k1x, k2y = k1y;
px = abs(px);
let d = k1x * px + k1y * py; if (d < 0) d = 0; px -= 2 * d * k1x; py -= 2 * d * k1y;
d = k2x * px + k2y * py; if (d < 0) d = 0; px -= 2 * d * k2x; py -= 2 * d * k2y;
px = abs(px); py -= r;
const bax = rf * (-k1y) - 0, bay = rf * k1x - 1;
const h = clamp((px * bax + py * bay) / (bax * bax + bay * bay), 0, r);
const qx = px - bax * h, qy = py - bay * h;
return sqrt(qx * qx + qy * qy) * ((py * bax - px * bay) >= 0 ? 1 : -1);
}
// sphere-trace helpers on a single SDF
function trace(f, ox, oy, oz, dx, dy, dz, tmax){
let t = 0;
for (let i = 0; i < 90; i++){
const d = f(ox + dx * t, oy + dy * t, oz + dz * t);
if (d < 0.0008) return t;
t += d; if (t > tmax) break;
}
return -1;
}
const surfZ = (f, x, y) => { const t = trace(f, x, y, 3, 0, 0, -1, 6); return t < 0 ? 0 : 3 - t; };
const surfZb = (f, x, y) => { const t = trace(f, x, y, -3, 0, 0, 1, 6); return t < 0 ? 0 : -3 + t; };
const surfY = (f, x, z) => { const t = trace(f, x, 3, z, 0, -1, 0, 6); return t < 0 ? 0 : 3 - t; };
const surfX = (f, y, z) => { const t = trace(f, 3, y, z, -1, 0, 0, 6); return t < 0 ? 0 : 3 - t; };
// ---------- model + renderer ----------
function Model(){ this.parts = []; this.mats = []; this.br = 1.8; this.hv = 1.9; this.id = 0; this.bodyColor = null; }
Model.prototype.mat = function(c, sp, sh, em){ this.mats.push({ c: hex(c), sp: sp, sh: sh, em: !!em }); return this.mats.length - 1; };
Model.prototype.add = function(f, cx, cy, cz, cr, mat){ this.parts.push({ f: f, cx: cx, cy: cy, cz: cz, cr: cr, mat: mat }); };
Model.prototype.map = function(x, y, z){
const P = this.parts; let best = 1e9, id = 0;
for (let i = 0; i < P.length; i++){
const q = P[i], dx = x - q.cx, dy = y - q.cy, dz = z - q.cz;
if (sqrt(dx * dx + dy * dy + dz * dz) - q.cr >= best) continue;
const d = q.f(x, y, z);
if (d < best){ best = d; id = q.mat; }
}
this.id = id; return best;
};
Model.prototype.render = function(N, yaw, pitch){
const cv = document.createElement('canvas'); cv.width = cv.height = N;
const ctx = cv.getContext('2d'); const img = ctx.createImageData(N, N); const dat = img.data;
const D = 8, HV = this.hv, BR = this.br, self = this, mats = this.mats;
yaw = yaw || 0; pitch = pitch || 0;
const cyw = Math.cos(yaw), syw = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
const tmp = [0, 0, 0];
function rot(x, y, z){ const X = x * cyw - z * syw, Z = x * syw + z * cyw; tmp[0] = X; tmp[1] = y * cp + Z * sp; tmp[2] = -y * sp + Z * cp; }
rot(0, 0, D); const rox = tmp[0], roy = tmp[1], roz = tmp[2];
let l = Math.hypot(-0.5, 0.72, 0.75); rot(-0.5 / l, 0.72 / l, 0.75 / l); const Lx = tmp[0], Ly = tmp[1], Lz = tmp[2];
l = Math.hypot(0.75, 0.05, 0.4); rot(0.75 / l, 0.05 / l, 0.4 / l); const Fx = tmp[0], Fy = tmp[1], Fz = tmp[2];
const wp = 2 * HV / N, H = 0.002;
const map = (x, y, z) => self.map(x, y, z);
for (let py = 0; py < N; py++){
const v = (1 - (py + 0.5) / N * 2) * HV;
for (let px = 0; px < N; px++){
const u = ((px + 0.5) / N * 2 - 1) * HV;
const il = 1 / sqrt(u * u + v * v + D * D);
rot(u * il, v * il, -D * il);
const rdx = tmp[0], rdy = tmp[1], rdz = tmp[2];
const b = rox * rdx + roy * rdy + roz * rdz;
const disc = b * b - (rox * rox + roy * roy + roz * roz - BR * BR);
if (disc < 0) continue;
const sq = sqrt(disc); let t = -b - sq; const t1 = -b + sq; if (t < 0) t = 0;
let hit = false, dmin = 1e9, tmin = t;
for (let i = 0; i < 72; i++){
const d = map(rox + rdx * t, roy + rdy * t, roz + rdz * t);
if (d < dmin){ dmin = d; tmin = t; }
if (d < 0.0025){ hit = true; break; }
t += d * 0.95; if (t > t1) break;
}
let alpha = 1;
if (!hit){ if (dmin > wp * 0.85) continue; alpha = 1 - dmin / (wp * 0.85); t = tmin; }
const x = rox + rdx * t, y = roy + rdy * t, z = roz + rdz * t;
map(x, y, z); const mt = mats[self.id];
let r, g, bl;
if (mt.em){ r = mt.c[0]; g = mt.c[1]; bl = mt.c[2]; }
else {
const n0 = map(x + H, y - H, z - H), n1 = map(x - H, y - H, z + H), n2 = map(x - H, y + H, z - H), n3 = map(x + H, y + H, z + H);
let nx = n0 - n1 - n2 + n3, ny = -n0 - n1 + n2 + n3, nz = -n0 + n1 - n2 + n3;
const nl = 1 / (sqrt(nx * nx + ny * ny + nz * nz) || 1); nx *= nl; ny *= nl; nz *= nl;
const a1 = map(x + nx * 0.12, y + ny * 0.12, z + nz * 0.12), a2 = map(x + nx * 0.3, y + ny * 0.3, z + nz * 0.3);
const ao = clamp(0.5 * a1 / 0.12 + 0.5 * a2 / 0.3, 0.3, 1);
const col = (mt.body && self.bodyColor) ? self.bodyColor(x, y, z) : mt.c;
const ndl = nx * Lx + ny * Ly + nz * Lz;
const diff = max(0, ndl) * 0.8 + (ndl * 0.5 + 0.5) * 0.2;
const fill = max(0, nx * Fx + ny * Fy + nz * Fz) * 0.28;
const ndv = max(0, -(nx * rdx + ny * rdy + nz * rdz));
const hx = Lx - rdx, hy = Ly - rdy, hz = Lz - rdz, hl = 1 / (sqrt(hx * hx + hy * hy + hz * hz) || 1);
const ndh = max(0, (nx * hx + ny * hy + nz * hz) * hl);
const spec = Math.pow(ndh, mt.sh) * mt.sp;
const lit = (0.4 + 0.14 * ny) * ao + diff * 0.78 * (0.55 + 0.45 * ao) + fill;
const rim = Math.pow(1 - ndv, 3) * 0.2 * (0.55 + 0.45 * ny);
let edge = 1; if (ndv < 0.2) edge = 0.72 + 0.28 * (ndv / 0.2);
r = (col[0] * lit + 255 * spec + 190 * rim) * edge;
g = (col[1] * lit + 255 * spec + 215 * rim) * edge;
bl = (col[2] * lit + 255 * spec + 255 * rim) * edge;
}
const o = (py * N + px) * 4;
dat[o] = r > 255 ? 255 : r; dat[o + 1] = g > 255 ? 255 : g; dat[o + 2] = bl > 255 ? 255 : bl; dat[o + 3] = alpha * 255;
}
}
ctx.putImageData(img, 0, 0);
return cv;
};
// ---------- body shapes ----------
function bodySDF(shape){
switch (shape){
case 'oval': return (x, y, z) => sdEllipsoid(x, y, z, 1.04, 0.88, 0.97);
case 'square': return (x, y, z) => {
const qx = abs(x) - 0.72, qy = abs(y) - 0.72, qz = abs(z) - 0.72;
const ox = max(qx, 0), oy = max(qy, 0), oz = max(qz, 0);
return sqrt(ox * ox + oy * oy + oz * oz) + min(max(qx, max(qy, qz)), 0) - 0.3;
};
case 'diamond': return (x, y, z) => {
const o = (abs(x) + abs(y) + abs(z) - 0.74) * 0.57735 - 0.22;
const s = sqrt(x * x + y * y + z * z) - 0.8;
return smin(o, s, 0.3);
};
case 'hex': return (x, y, z) => {
// hexagonal prism (hex in the x-z plane, extruded along y), flat face toward +z
const c30 = 0.8660254, s30 = 0.5; // turn the prism 30deg so a corner faces the camera
let px = abs(x * c30 - z * s30), pz = abs(x * s30 + z * c30);
const kx = -0.8660254, kz = 0.5, kw = 0.57735, h = 0.78;
const dd = Math.min(kx * px + kz * pz, 0); px -= 2 * dd * kx; pz -= 2 * dd * kz;
const cx = px - clamp(px, -kw * h, kw * h), cz = pz - h;
const d1 = sqrt(cx * cx + cz * cz) * (pz - h >= 0 ? 1 : -1), d2 = abs(y) - 0.74;
return min(max(d1, d2), 0) + sqrt(max(d1, 0) * max(d1, 0) + max(d2, 0) * max(d2, 0)) - 0.14;
};
case 'tri': return roundCone(0, -0.7, 0, 0, 0.76, 0, 1.02, 0.2);
default: return (x, y, z) => sqrt(x * x + y * y + z * z) - 1;
}
}
// ---------- bird ----------
const FACE = { round: [0.32, 0.22], oval: [0.32, 0.2], square: [0.34, 0.2], tri: [0.3, -0.08], diamond: [0.26, 0.08], hex: [0.33, 0.2] };
function birdModel(s){
const m = new Model(), f = bodySDF(s.shape);
const body = hex(s.body), belly = hex(s.belly), rainbow = s.acc === 'rainbow';
const mBody = m.mat(s.body, 0.4, 30); m.mats[mBody].body = true;
m.bodyColor = function(x, y, z){
const L = sqrt(x * x + y * y + z * z) || 1, dy = y / L, dz = z / L;
let c = body;
if (rainbow){
const R = [255, 70, 70], O = [255, 165, 0], Y = [255, 225, 70], B = [70, 200, 255];
c = mix3(B, Y, smooth(-0.42, -0.3, y)); c = mix3(c, O, smooth(0.0, 0.12, y)); c = mix3(c, R, smooth(0.42, 0.54, y));
}
c = mix3(c, belly, smooth(0.56, 0.7, -0.45 * dy + 0.89 * dz));
const sh = 0.86 + 0.14 * smooth(-1, 0.7, dy);
return [c[0] * sh, c[1] * sh, c[2] * sh];
};
m.add(f, 0, 0, 0, 1.3, mBody);
const mWhite = m.mat(0xffffff, 0.9, 70), mPupil = m.mat(0x14141a, 1.0, 90), mHL = m.mat(0xffffff, 0, 1, true);
const mBrow = m.mat(s.browCol || 0x161616, 0.3, 24);
const mBeak = m.mat(0xffb21e, 0.6, 45), mBeak2 = m.mat(0xe38a10, 0.5, 40);
const mTail = m.mat(0x000000, 0.2, 18); m.mats[mTail].c = dark(body, 0.55);
const mAcc = m.mat(s.ac, 0.7, 50);
const mGold = m.mat(0xffc400, 1.0, 60);
const fc = FACE[s.shape] || FACE.round, ex = fc[0], ey = fc[1], eR = 0.047 * s.eye;
const top = surfY(f, 0, 0);
// eyes + brows
[-1, 1].forEach(sd => {
const x = sd * ex, zS = surfZ(f, x, ey) - 0.02;
m.add(ellF(x, ey, zS, eR, eR * 1.1, eR * 0.8), x, ey, zS, eR * 1.2, mWhite);
const px = x - sd * eR * 0.1, pz = zS + eR * 0.52;
m.add(sphereF(px, ey - eR * 0.02, pz, eR * 0.52), px, ey, pz, eR * 0.6, mPupil);
const hx = px + eR * 0.2, hy = ey + eR * 0.24, hz = pz + eR * 0.4;
m.add(sphereF(hx, hy, hz, eR * 0.15), hx, hy, hz, eR * 0.2, mHL);
const yc = ey + eR * 1.12, sl = 0.14 * s.slant * eR;
const xo = sd * (ex + eR * 1.3), yo = yc + sl, xi = sd * (ex - eR * 0.95), yi = yc - sl;
const thick = 0.03 + 0.018 * s.brow;
const zo = surfZ(f, xo, yo) + thick * 0.3, zi = surfZ(f, xi, yi) + thick * 0.3;
m.add(roundCone(xo, yo, zo, xi, yi, zi, thick * 0.8, thick), (xo + xi) / 2, (yo + yi) / 2, (zo + zi) / 2, eR * 2 + thick, mBrow);
});
// beak (upper + lower)
const by = ey - eR * 1.55, bz = surfZ(f, 0, by);
m.add(roundCone(0, by + 0.03, bz - 0.12, 0, by, bz + 0.44, 0.21, 0.075), 0, by, bz + 0.15, 0.55, mBeak);
m.add(roundCone(0, by - 0.1, bz - 0.1, 0, by - 0.17, bz + 0.28, 0.145, 0.055), 0, by - 0.12, bz + 0.1, 0.4, mBeak2);
// tail feathers
const tz = surfZb(f, 0, 0.1);
[-1, 0, 1].forEach(k => {
const ax = k * 0.14, ay = 0.05 + (k === 0 ? 0.08 : 0), az = tz + 0.15;
const bx = k * 0.5, by2 = ay + 0.32, bz2 = tz - 0.5;
m.add(roundCone(ax, ay, az, bx, by2, bz2, 0.14, 0.035), (ax + bx) / 2, (ay + by2) / 2, (az + bz2) / 2, 0.55, mTail);
});
// accessories
switch (s.acc){
case 'crest': [-1, 0, 1].forEach(k => {
const x0 = k * 0.17, z0 = -0.1, y0 = surfY(f, x0, z0) - 0.06;
const bx = x0 + k * 0.2, by3 = y0 + 0.52 - 0.08 * abs(k), bz3 = z0 - 0.3;
m.add(roundCone(x0, y0, z0, bx, by3, bz3, 0.1, 0.025), x0, y0 + 0.2, z0, 0.5, mAcc);
}); break;
case 'horns': {
const hw = surfX(f, top - 0.25, 0);
[-1, 1].forEach(sd => {
const x0 = sd * hw * 0.78, y0 = surfY(f, x0, 0) - 0.08;
m.add(roundCone(x0, y0, 0, x0 + sd * 0.4, y0 + 0.55, -0.1, 0.17, 0.025), x0 + sd * 0.15, y0 + 0.25, 0, 0.6, mAcc);
}); break; }
case 'cap': {
const cy = top - 0.46;
m.add((x, y, z) => max(abs(f(x, y, z) - 0.05) - 0.05, cy - y), 0, top - 0.3, 0, 1.4, mAcc);
const zf = surfZ(f, 0, cy) + 0.1;
m.add(ellF(0, cy + 0.02, zf, 0.46, 0.07, 0.4), 0, cy, zf, 0.6, mAcc);
break; }
case 'bandana': case 'headband': {
const yb = top - 0.5;
m.add((x, y, z) => max(abs(f(x, y, z) - 0.045) - 0.05, abs(y - yb) - 0.15), 0, yb, 0, 1.4, mAcc);
const bz2 = surfZb(f, 0, yb);
m.add(roundCone(0.05, yb, bz2 + 0.05, 0.5, yb - 0.25, bz2 - 0.55, 0.08, 0.05), 0.25, yb - 0.1, bz2 - 0.25, 0.5, mAcc);
m.add(roundCone(-0.05, yb, bz2 + 0.05, -0.35, yb - 0.5, bz2 - 0.5, 0.08, 0.05), -0.2, yb - 0.25, bz2 - 0.25, 0.5, mAcc);
break; }
case 'glasses': {
const mG = m.mat(s.ac, 0.8, 60);
[-1, 1].forEach(sd => {
const cx = sd * ex, zS = surfZ(f, cx, ey) + 0.03, R = eR * 1.55, r = 0.045;
m.add((x, y, z) => { const a = x - cx, b2 = y - ey, c = z - zS; const q = sqrt(a * a + b2 * b2) - R; return sqrt(q * q + c * c) - r; }, cx, ey, zS, R + 0.15, mG);
});
const zb = surfZ(f, 0, ey) + 0.04;
m.add(roundCone(-ex + eR * 1.5, ey + 0.02, zb, ex - eR * 1.5, ey + 0.02, zb, 0.04, 0.04), 0, ey, zb, ex, mG);
break; }
case 'bow': {
const kx = 0.45, kz = 0.2, yk = surfY(f, kx, kz) - 0.02;
m.add(sphereF(kx, yk + 0.04, kz, 0.13), kx, yk + 0.04, kz, 0.2, mAcc);
m.add(roundCone(kx, yk + 0.04, kz, kx + 0.42, yk + 0.3, kz, 0.05, 0.19), kx + 0.2, yk + 0.18, kz, 0.4, mAcc);
m.add(roundCone(kx, yk + 0.04, kz, kx - 0.1, yk + 0.5, kz, 0.05, 0.19), kx - 0.05, yk + 0.28, kz, 0.4, mAcc);
break; }
case 'star': {
const sy = top - 0.27, sz = surfZ(f, 0, sy) + 0.03, hwS = surfX(f, sy, 0), rS = min(0.25, hwS * 0.6);
m.add((x, y, z) => max(star5(x, y - sy, rS, 0.45), abs(z - sz) - 0.05) - 0.02, 0, sy, sz, rS + 0.15, mAcc);
break; }
case 'crown': {
const yc = top - 0.22;
m.add((x, y, z) => max(abs(f(x, y, z) - 0.07) - 0.06, abs(y - yc) - 0.13), 0, yc, 0, 1.4, mGold);
for (let k = 0; k < 5; k++){
const th = (k - 2) * 0.6, sx = Math.sin(th), cz = Math.cos(th);
const t = trace(f, sx * 3, yc, cz * 3, -sx, 0, -cz, 6); if (t < 0) continue;
const px = sx * (3 - t), pz = cz * (3 - t);
m.add(roundCone(px, yc + 0.05, pz, px + sx * 0.05, yc + 0.46, pz + cz * 0.05, 0.1, 0.02), px, yc + 0.25, pz, 0.35, mGold);
}
break; }
}
m.br = 1.78;
return m;
}
// ---------- pig ----------
function pigModel(){
const m = new Model();
const g = 0x74d44c;
const mSkin = m.mat(g, 0.4, 28), mSnout = m.mat(0x93e268, 0.5, 30), mNostril = m.mat(0x24491a, 0.2, 10);
const mEar = m.mat(0x55b035, 0.3, 20), mWhite = m.mat(0xffffff, 0.9, 70), mPupil = m.mat(0x14141a, 1, 90), mHL = m.mat(0xffffff, 0, 1, true);
const mBrow = m.mat(0x1d3d12, 0.2, 10);
m.add(ellF(0, 0, 0, 1.04, 0.98, 1.0), 0, 0, 0, 1.3, mSkin);
m.add(ellF(0, -0.18, 0.92, 0.46, 0.34, 0.3), 0, -0.18, 0.92, 0.6, mSnout);
[-1, 1].forEach(sd => {
m.add(ellF(sd * 0.16, -0.18, 1.18, 0.07, 0.12, 0.05), sd * 0.16, -0.18, 1.18, 0.2, mNostril);
m.add(roundCone(sd * 0.5, 0.7, 0.1, sd * 0.86, 1.1, -0.05, 0.24, 0.08), sd * 0.68, 0.9, 0.03, 0.5, mEar);
const ex = sd * 0.4, ey = 0.3, ez = 0.78, er = 0.27;
m.add(ellF(ex, ey, ez, er, er * 1.08, er * 0.85), ex, ey, ez, er * 1.2, mWhite);
const px = ex - sd * 0.03, pz = ez + 0.17;
m.add(sphereF(px, ey, pz, 0.13), px, ey, pz, 0.18, mPupil);
m.add(sphereF(px + 0.05, ey + 0.06, pz + 0.1, 0.035), px, ey, pz, 0.3, mHL);
m.add(roundCone(sd * 0.72, ey + 0.4, ez - 0.05, sd * 0.14, ey + 0.28, ez + 0.1, 0.055, 0.075), sd * 0.43, ey + 0.34, ez, 0.45, mBrow);
});
m.br = 1.6;
return m;
}
// ---------- cannonball ----------
function cannonModel(){
const m = new Model();
const mB = m.mat(0x2d2f3a, 0.9, 55), mFuse = m.mat(0x8a5a2b, 0.2, 10), mSpark = m.mat(0xffb43a, 0, 1, true), mBand = m.mat(0x555a6b, 0.8, 50);
m.add(sphereF(0, 0, 0, 1), 0, 0, 0, 1.2, mB);
m.add(roundCone(0.5, 0.82, 0, 0.82, 1.22, 0, 0.1, 0.07), 0.66, 1.0, 0, 0.4, mFuse);
m.add(sphereF(0.86, 1.3, 0, 0.14), 0.86, 1.3, 0, 0.2, mSpark);
m.add(sphereF(0.86, 1.3, 0, 0.07), 0.86, 1.3, 0, 0.1, mSpark);
m.br = 1.7;
return m;
}
// ---------- egg bomb ----------
function eggModel(){
const m = new Model();
const mE = m.mat(0xfff0cf, 0.6, 40), mS = m.mat(0xe0453a, 0.5, 35);
m.add(ellF(0, 0, 0, 0.74, 0.94, 0.74), 0, 0, 0, 1.2, mE);
m.add((x, y, z) => max(sdEllipsoid(x, y, z, 0.77, 0.97, 0.77), abs(y + 0.05) - 0.14), 0, 0, 0, 1.2, mS);
m.br = 1.3;
return m;
}
return { Model, birdModel, pigModel, cannonModel, eggModel, hex };
})();
// ---------- Worlds (change every 10 levels) ----------
const WORLDS = [
{ name: 'Meadow', far: 0x8fd6a0, mid: 0x5fc06a, grass: 0x58b04a, dirt: 0x8a5a34, dirt2: 0x73492a, tree: 0x2e9e4f },
{ name: 'Autumn', far: 0xe3b07a, mid: 0xd98c3a, grass: 0xc8922e, dirt: 0x7a4a2c, dirt2: 0x663b22, tree: 0xd2552b },
{ name: 'Desert', far: 0xf1d49a, mid: 0xe8b96a, grass: 0xe2c27a, dirt: 0xc08a4a, dirt2: 0xa87438, tree: 0x5f9a4a },
{ name: 'Snow', far: 0xdcebf7, mid: 0xbcd8ee, grass: 0xf2f8ff, dirt: 0x6f7f92, dirt2: 0x5c6c7e, tree: 0x3b7a5a }
];
// ---------- Day / Afternoon / Evening / Night (changes every 5 minutes) ----------
const PHASES = [
{ name: 'DAY', top: 0x4aa8ff, bot: 0xcdeeff, mul: 0xffffff, sun: [790, 85], sunCol: 0xffe066, moon: 0, stars: 0 },
{ name: 'AFTERNOON', top: 0x5b9bd8, bot: 0xffd9a0, mul: 0xffe9cc, sun: [830, 200], sunCol: 0xffb347, moon: 0, stars: 0 },
{ name: 'EVENING', top: 0x3b2a6b, bot: 0xff8a5c, mul: 0xe6b8c8, sun: [860, 330], sunCol: 0xff5e3a, moon: 0, stars: 0 },
{ name: 'NIGHT', top: 0x070b24, bot: 0x24306b, mul: 0x7b8bd0, sun: [860, 640], sunCol: 0xff5e3a, moon: 1, stars: 1 }
];
function currentPhase(){ return Math.floor((Date.now() - CLOCK0) / 300000) % PHASES.length; }
function gradRect(g, top, bot, w, h){
const c1 = Phaser.Display.Color.ValueToColor(top), c2 = Phaser.Display.Color.ValueToColor(bot);
const steps = 48, bh = Math.ceil(h / steps);
for (let i = 0; i < steps; i++){
const c = Phaser.Display.Color.Interpolate.ColorWithColor(c1, c2, steps - 1, i);
g.fillStyle(Phaser.Display.Color.GetColor(c.r, c.g, c.b), 1).fillRect(0, i * bh, w, bh + 1);
}
}
// ---------- 150 generated levels ----------
function mulberry32(a){
return function(){
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function getLevel(n){
// difficulty ramps from level 1 (easy) to 150 (brutal): more/taller structures, stronger materials, fewer birds
const r = mulberry32(n * 9301 + 49297);
const d = n / 149;
const slots = n < 4 ? 1 : n < 14 ? 2 : n < 45 ? 3 : 4;
const xs = [null, [720], [640, 800], [590, 720, 850], [530, 650, 770, 890]][slots];
const maxF = Math.min(4, 1 + Math.floor(n / 22));
const stoneP = n < 8 ? 0 : Math.min(0.62, 0.06 + n / 220);
const glassP = Math.max(0.08, 0.3 - n / 400);
const boss = n % 10 === 9; // every 10th level: stone fortress
const pick = () => { const v = r(); return v < stoneP ? 'stone' : (v < stoneP + glassP ? 'glass' : 'wood'); };
const stackP = n >= 5 ? Math.min(0.55, 0.15 + n / 300) : 0;
const structs = [];
xs.forEach((cx, i) => {
if (i > 0 && r() < stackP){
const h = 2 + (r() < 0.4 + d * 0.4 ? 1 : 0) + (n >= 60 && r() < 0.4 ? 1 : 0), mats = [];
for (let j = 0; j < h; j++){ const m = boss && j === 0 ? 'stone' : pick(); mats.push(m === 'glass' ? 'wood' : m); }
structs.push({ type: 'stack', cx, mats });
} else {
const f = Math.max(1, maxF - (r() < 0.4 - d * 0.3 ? 1 : 0)), mats = [];
for (let j = 0; j < f; j++) mats.push(boss && j === 0 ? 'stone' : pick());
structs.push({ type: 'tower', cx, mats });
}
});
let birds;
if (n < 14) birds = 3 + slots + (n % 4 === 0 ? 1 : 0);
else birds = Math.max(4, 2 + slots + (maxF >= 3 ? 1 : 0) + (n % 4 === 0 ? 1 : 0) - (n >= 60 ? 1 : 0) - (n >= 110 ? 1 : 0));
birds = Math.min(8, birds);
return { birds, structs, allPigs: n >= 20 };
}
// ---------- Realistic backdrop: procedural cinematic landscape (original art, no copyrighted assets) ----------
if (typeof CanvasRenderingContext2D !== 'undefined' && !CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r){
r = Math.min(r, w / 2, h / 2); this.moveTo(x + r, y); this.arcTo(x + w, y, x + w, y + h, r); this.arcTo(x + w, y + h, x, y + h, r);
this.arcTo(x, y + h, x, y, r); this.arcTo(x, y, x + w, y, r); this.closePath();
};
}
const BG = (function(){
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const sm = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const lerp = (a, b, t) => a + (b - a) * t;
const mixc = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
const mulc = (a, m) => [a[0] * m[0], a[1] * m[1], a[2] * m[2]];
const css = (c, a) => 'rgba(' + (clamp(c[0], 0, 255) | 0) + ',' + (clamp(c[1], 0, 255) | 0) + ',' + (clamp(c[2], 0, 255) | 0) + ',' + (a === undefined ? 1 : a) + ')';
let SEED = 1;
function hash(x, y){
let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(SEED, 1442695041)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16;
return (h >>> 0) / 4294967295;
}
function vnoise(x, y){
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
return lerp(lerp(hash(xi, yi), hash(xi + 1, yi), u), lerp(hash(xi, yi + 1), hash(xi + 1, yi + 1), u), v);
}
function fbm(x, y, o){
let s = 0, a = 0.5, f = 1, n = 0;
for (let i = 0; i < o; i++){ s += a * vnoise(x * f, y * f); n += a; a *= 0.5; f *= 2.03; }
return s / n;
}
function cv(w, h){ const c = document.createElement('canvas'); c.width = w; c.height = h; return { c: c, x: c.getContext('2d') }; }
// ---- phase lighting ----
const PH = [
{ top: [30, 98, 196], mid: [86, 152, 224], hor: [192, 222, 242], sunCol: [255, 244, 214], glow: 0.55, cLit: [255, 255, 255], cSh: [140, 160, 192], haze: [186, 212, 232], land: [1, 1, 1], star: 0 },
{ top: [58, 108, 184], mid: [150, 168, 198], hor: [255, 204, 146], sunCol: [255, 208, 140], glow: 0.9, cLit: [255, 226, 190], cSh: [128, 120, 146], haze: [240, 194, 148], land: [1, 0.88, 0.74], star: 0 },
{ top: [24, 20, 64], mid: [138, 64, 104], hor: [255, 124, 74], sunCol: [255, 116, 56], glow: 1.1, cLit: [255, 150, 104], cSh: [64, 36, 78], haze: [226, 116, 88], land: [0.76, 0.52, 0.56], star: 0.15 },
{ top: [3, 6, 20], mid: [9, 17, 48], hor: [36, 50, 98], sunCol: [170, 190, 255], glow: 0.18, cLit: [96, 112, 160], cSh: [12, 18, 40], haze: [32, 44, 88], land: [0.26, 0.33, 0.56], star: 1 }
];
// per-world: cloud cover, mountain ranges, foliage palettes, ground
const WD = [
{ cover: 0.5, ranges: [
{ base: 372, amp: 170, f: 0.0055, lit: [132, 142, 164], sh: [56, 66, 90], snow: 0.58, haze: 0.52 },
{ base: 412, amp: 70, f: 0.011, lit: [96, 132, 92], sh: [44, 70, 54], snow: 0, haze: 0.32 }],
leaf: [[52, 120, 54], [64, 136, 58], [38, 98, 48], [78, 144, 62]], trunk: [88, 62, 40], kind: 'broad',
soil: [120, 86, 54], soil2: [84, 58, 36], grass: [76, 132, 52], grass2: [112, 164, 70], back: [40, 84, 46] },
{ cover: 0.44, ranges: [
{ base: 376, amp: 160, f: 0.006, lit: [168, 130, 120], sh: [84, 62, 78], snow: 0.7, haze: 0.5 },
{ base: 414, amp: 68, f: 0.012, lit: [170, 104, 52], sh: [92, 54, 40], snow: 0, haze: 0.3 }],
leaf: [[198, 88, 34], [224, 146, 44], [172, 54, 32], [150, 130, 44], [210, 112, 38]], trunk: [96, 64, 44], kind: 'broad',
soil: [118, 78, 48], soil2: [80, 52, 34], grass: [158, 130, 52], grass2: [196, 160, 66], back: [110, 62, 36] },
{ cover: 0.64, ranges: [
{ base: 384, amp: 130, f: 0.007, lit: [240, 184, 124], sh: [132, 82, 62], snow: 0, haze: 0.46, mesa: 1 },
{ base: 418, amp: 80, f: 0.011, lit: [226, 168, 108], sh: [124, 78, 56], snow: 0, haze: 0.3, mesa: 1 }],
leaf: [[104, 138, 70], [128, 150, 78], [92, 120, 62]], trunk: [110, 82, 50], kind: 'desert',
soil: [214, 172, 108], soil2: [176, 134, 78], grass: [228, 196, 132], grass2: [240, 214, 156], back: [200, 150, 90] },
{ cover: 0.4, ranges: [
{ base: 366, amp: 190, f: 0.005, lit: [168, 182, 204], sh: [78, 92, 118], snow: 0.28, haze: 0.5 },
{ base: 410, amp: 84, f: 0.01, lit: [210, 222, 238], sh: [128, 148, 176], snow: 0.0, haze: 0.3 }],
leaf: [[34, 88, 66], [44, 104, 72], [28, 74, 58]], trunk: [74, 52, 38], kind: 'pine', snowy: 1,
soil: [118, 130, 148], soil2: [86, 98, 118], grass: [232, 240, 250], grass2: [250, 252, 255], back: [180, 198, 218] }
];
function paintSky(P, sunPos, cover, drift){
const SW = 320, SH = 180, K = 3, hy = 138;
const o = cv(SW, SH), img = o.x.createImageData(SW, SH), d = img.data;
const sx = sunPos[0] / K, sy = sunPos[1] / K, side = sx > SW / 2 ? 1 : -1;
for (let y = 0; y < SH; y++){
const t = clamp(y / hy, 0, 1);
const base = t < 0.55 ? mixc(P.top, P.mid, sm(0, 0.55, t)) : mixc(P.mid, P.hor, sm(0.5, 1, t));
for (let x = 0; x < SW; x++){
let c = base;
const dx = x - sx, dy = y - sy, dist = Math.sqrt(dx * dx + dy * dy);
// clouds in perspective
const v = hy - y + 46;
if (hy - y > 3){
const px = x * 0.03 + drift, py = Math.pow(hy - y + 10, 0.72) * 0.13;
let dn = fbm(px, py, 5);
dn = clamp((dn - cover) / (1 - cover), 0, 1);
const dens = sm(0, 0.55, dn) * sm(3, 38, hy - y);
if (dens > 0.01){
const d2 = clamp((fbm(px + side * 0.16, py - 0.12, 4) - cover) / (1 - cover), 0, 1);
const lit = clamp(0.55 + (dn - d2) * -3.2 + (1 - dn) * 0.35, 0, 1);
let cc = mixc(P.cSh, P.cLit, lit);
cc = mixc(cc, P.hor, 0.5 * (1 - sm(0, 90, hy - y)) + 0.05);
// silver lining near the sun
const lin = Math.exp(-dist / 30) * (1 - dn) * 0.9 * P.glow;
cc = [cc[0] + P.sunCol[0] * lin * 0.5, cc[1] + P.sunCol[1] * lin * 0.5, cc[2] + P.sunCol[2] * lin * 0.5];
c = mixc(c, cc, clamp(dens * 0.95, 0, 0.96));
}
}
const g = (Math.exp(-dist / 46) * 0.55 + Math.exp(-dist / 11) * 0.7) * P.glow;
const i = (y * SW + x) * 4;
d[i] = clamp(c[0] + P.sunCol[0] * g, 0, 255); d[i + 1] = clamp(c[1] + P.sunCol[1] * g, 0, 255);
d[i + 2] = clamp(c[2] + P.sunCol[2] * g, 0, 255); d[i + 3] = 255;
}
}
o.x.putImageData(img, 0, 0);
return o.c;
}
function celestial(ctx, P, pi, sunPos, rnd){
if (P.star > 0.1){
for (let i = 0; i < 230; i++){
const x = rnd() * 960, y = Math.pow(rnd(), 1.5) * 330, b = (0.25 + rnd() * 0.75) * P.star * (1 - y / 460);
ctx.fillStyle = 'rgba(' + (210 + rnd() * 45 | 0) + ',' + (220 + rnd() * 35 | 0) + ',255,' + b.toFixed(2) + ')';
const r = rnd() < 0.08 ? 1.7 : 0.9;
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.fill();
}
}
const x = sunPos[0], y = sunPos[1];
ctx.save(); ctx.globalCompositeOperation = 'lighter';
if (pi === 3){
let g = ctx.createRadialGradient(x, y, 0, x, y, 170);
g.addColorStop(0, 'rgba(150,170,255,0.30)'); g.addColorStop(1, 'rgba(150,170,255,0)');
ctx.fillStyle = g; ctx.fillRect(x - 170, y - 170, 340, 340);
} else {
let g = ctx.createRadialGradient(x, y, 0, x, y, 190);
g.addColorStop(0, css(P.sunCol, 0.6)); g.addColorStop(0.25, css(P.sunCol, 0.2)); g.addColorStop(1, css(P.sunCol, 0));
ctx.fillStyle = g; ctx.fillRect(x - 190, y - 190, 380, 380);
}
ctx.restore();
if (pi === 3){
// moon with maria + craters, lit from the side
const r = 31, g = ctx.createRadialGradient(x - 9, y - 8, 4, x, y, r);
g.addColorStop(0, '#fbfbf2'); g.addColorStop(0.7, '#d9d9cc'); g.addColorStop(1, '#a6a79f');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.fill();
ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.clip();
[[-9, -4, 9], [8, 8, 7], [6, -12, 5], [-12, 10, 5], [0, 14, 3.5], [14, -2, 4]].forEach(m => {
const cg = ctx.createRadialGradient(x + m[0], y + m[1], 0, x + m[0], y + m[1], m[2]);
cg.addColorStop(0, 'rgba(110,110,104,0.55)'); cg.addColorStop(1, 'rgba(110,110,104,0)');
ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(x + m[0], y + m[1], m[2], 0, 6.283); ctx.fill();
});
const sh = ctx.createLinearGradient(x - r, y, x + r, y);
sh.addColorStop(0, 'rgba(0,0,0,0)'); sh.addColorStop(1, 'rgba(10,14,40,0.42)');
ctx.fillStyle = sh; ctx.fillRect(x - r, y - r, r * 2, r * 2);
ctx.restore();
} else if (pi === 0 || pi === 2){
const r = pi === 2 ? 30 : 20;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(255,255,248,1)'); g.addColorStop(0.7, css(mixc(P.sunCol, [255, 255, 255], 0.5), 1)); g.addColorStop(1, css(P.sunCol, 0));
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.fill();
}
}
function mountains(ctx, P, L, wd, sunSide, seedOff){
const Wd = 960, h = new Float32Array(Wd + 2);
for (let x = -1; x <= Wd; x++){
const n = fbm(x * L.f + seedOff, 3.7, 5), r = 1 - Math.abs(2 * n - 1);
let hh = (0.3 + 0.7 * Math.pow(r, 1.5)) * L.amp * (0.65 + 0.7 * fbm(x * L.f * 0.35 + seedOff, 9.1, 2));
if (L.mesa){ const s = L.amp / 3.2, q = hh / s, fl = Math.floor(q); hh = (fl + sm(0.78, 1, q - fl)) * s; }
h[x + 1] = hh;
}
const lay = cv(Wd, 490), img = lay.x.createImageData(Wd, 490), d = img.data;
for (let x = 0; x < Wd; x++){
const yTop = L.base - h[x + 1], xa = Math.max(0, x - 9), xb = Math.min(Wd - 1, x + 9), slope = (h[xa + 1] - h[xb + 1]) / (xb - xa) * 3.2;
const face = clamp(0.5 + slope * sunSide * 0.42, 0, 1);
for (let y = Math.max(0, Math.ceil(yTop)); y < 486; y++){
const dd = y - yTop;
const n2 = fbm(x * 0.018 + 5, y * 0.022, 4), tex = fbm(x * 0.09, y * 0.09, 3);
const fd = Math.exp(-dd / 45), light = clamp((0.5 + (face - 0.5) * fd) * 0.7 + n2 * 0.45 - 0.05 + (1 - sm(0, 1, dd / L.amp)) * 0.12, 0, 1);
let c = mixc(L.sh, L.lit, light);
const tx = 0.78 + 0.44 * tex; c = [c[0] * tx, c[1] * tx, c[2] * tx];
if (L.snow){
const elev = (L.base - y) / L.amp;
const sn = sm(L.snow, L.snow + 0.06, elev + (fbm(x * 0.05, y * 0.05, 3) - 0.5) * 0.3);
if (sn > 0) c = mixc(c, mixc([120, 140, 184], [250, 252, 255], clamp(light * 1.15, 0, 1)), sn * 0.95);
}
if (!L.mesa){ const el = (L.base - y) / L.amp, fo = sm(0.34, 0.08, el + (tex - 0.5) * 0.25); if (fo > 0) c = mixc(c, mulc(wd.back, [1.15 * (0.8 + 0.5 * tex), 1.15 * (0.8 + 0.5 * tex), 1.15 * (0.8 + 0.5 * tex)]), fo * 0.85); }
c = mulc(c, P.land);
const hz = clamp(L.haze * 0.55 + 0.3 * sm(0.3, 1, (y - (L.base - L.amp)) / (L.amp + 30)), 0, 0.62);
c = mixc(c, P.haze, hz);
const i = (y * Wd + x) * 4;
d[i] = c[0]; d[i + 1] = c[1]; d[i + 2] = c[2]; d[i + 3] = clamp((dd) * 255, 0, 255);
}
}
lay.x.putImageData(img, 0, 0);
ctx.drawImage(lay.c, 0, 0);
}
function tree(ctx, P, wd, x, yb, s, rnd, rowT, sunSide){
const tint = c => mixc(mulc(c, P.land), P.haze, (1 - rowT) * 0.38);
// contact shadow
ctx.fillStyle = 'rgba(0,0,0,' + (0.22 * P.land[1]).toFixed(2) + ')';
ctx.beginPath(); ctx.ellipse(x - sunSide * s * 0.35, yb, s * 0.62, s * 0.12, 0, 0, 6.283); ctx.fill();
if (wd.kind === 'desert'){
const t = rnd();
if (t < 0.3){ // saguaro
const w = s * 0.2, hgt = s * 1.4, g = ctx.createLinearGradient(x - w, 0, x + w, 0);
const b = tint([84, 130, 70]);
g.addColorStop(0, css(mulc(b, [0.7, 0.7, 0.7]))); g.addColorStop(0.55, css(mixc(b, [190, 220, 140], 0.25))); g.addColorStop(1, css(mulc(b, [0.55, 0.58, 0.55])));
ctx.fillStyle = g;
ctx.beginPath(); ctx.roundRect(x - w, yb - hgt, w * 2, hgt, w); ctx.fill();
ctx.beginPath(); ctx.roundRect(x - w * 3, yb - hgt * 0.62, w * 1.7, w * 1.4, w * 0.7); ctx.fill();
ctx.beginPath(); ctx.roundRect(x - w * 3, yb - hgt * 0.62 - hgt * 0.26, w * 1.4, hgt * 0.32, w * 0.7); ctx.fill();
ctx.beginPath(); ctx.roundRect(x + w * 1.3, yb - hgt * 0.5, w * 1.7, w * 1.4, w * 0.7); ctx.fill();
ctx.beginPath(); ctx.roundRect(x + w * 1.6, yb - hgt * 0.5 - hgt * 0.22, w * 1.4, hgt * 0.28, w * 0.7); ctx.fill();
} else if (t < 0.7){ // dry shrub
for (let i = 0; i < 4; i++){
const cx = x + (rnd() - 0.5) * s * 0.7, cy = yb - s * (0.12 + rnd() * 0.18), r = s * (0.2 + rnd() * 0.16);
const c = tint(wd.leaf[(rnd() * wd.leaf.length) | 0]);
const g = ctx.createRadialGradient(cx - sunSide * r * 0.3, cy - r * 0.4, r * 0.1, cx, cy, r);
g.addColorStop(0, css(mixc(c, [255, 240, 180], 0.3))); g.addColorStop(1, css(mulc(c, [0.55, 0.55, 0.55])));
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 6.283); ctx.fill();
}
} else { // boulder
const r = s * (0.35 + rnd() * 0.25), c = tint([168, 118, 84]);
const g = ctx.createRadialGradient(x - sunSide * r * 0.4, yb - r * 0.8, r * 0.1, x, yb - r * 0.4, r * 1.1);
g.addColorStop(0, css(mixc(c, [255, 224, 180], 0.4))); g.addColorStop(1, css(mulc(c, [0.45, 0.4, 0.42])));
ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(x, yb - r * 0.42, r, r * 0.62, 0, 0, 6.283); ctx.fill();
}
return;
}
if (wd.kind === 'pine'){
const tw = s * 0.07;
ctx.fillStyle = css(tint(wd.trunk)); ctx.fillRect(x - tw, yb - s * 0.3, tw * 2, s * 0.3);
const base = tint(wd.leaf[(rnd() * wd.leaf.length) | 0]);
for (let k = 0; k < 4; k++){
const wy = yb - s * (0.18 + k * 0.3), half = s * (0.52 - k * 0.1), hh = s * 0.5;
const g = ctx.createLinearGradient(x - half, 0, x + half, 0);
g.addColorStop(sunSide > 0 ? 0 : 1, css(mulc(base, [0.5, 0.55, 0.55])));
g.addColorStop(sunSide > 0 ? 1 : 0, css(mixc(base, [190, 220, 200], 0.18)));
ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(x, wy - hh); ctx.lineTo(x + half, wy); ctx.lineTo(x - half, wy); ctx.closePath(); ctx.fill();
// snow on branches
ctx.fillStyle = css(tint([236, 244, 255]), 0.92);
ctx.beginPath(); ctx.moveTo(x, wy - hh); ctx.lineTo(x + half * 0.62, wy - hh * 0.38); ctx.quadraticCurveTo(x, wy - hh * 0.3, x - half * 0.62, wy - hh * 0.38); ctx.closePath(); ctx.fill();
}
return;
}
// broadleaf
const tw = s * 0.07;
ctx.fillStyle = css(tint(wd.trunk)); ctx.fillRect(x - tw, yb - s * 0.55, tw * 2, s * 0.55);
const blobs = 5;
for (let i = 0; i < blobs; i++){
const a = i / blobs * 6.283 + rnd(), rr = i === 0 ? 0 : s * 0.28;
const cx = x + Math.cos(a) * rr * 0.9, cy = yb - s * 0.85 + Math.sin(a) * rr * 0.55, r = s * (i === 0 ? 0.5 : 0.36);
const c = tint(wd.leaf[(rnd() * wd.leaf.length) | 0]);
const g = ctx.createRadialGradient(cx - sunSide * r * 0.38, cy - r * 0.42, r * 0.08, cx, cy, r);
g.addColorStop(0, css(mixc(c, [255, 250, 190], 0.34))); g.addColorStop(0.55, css(c)); g.addColorStop(1, css(mulc(c, [0.42, 0.45, 0.45])));
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 6.283); ctx.fill();
}
}
function foothills(ctx, P, wd, rnd, sunSide, seedOff){
// dark backing so the gaps between trees read as forest depth
const hy = []; for (let x = 0; x <= 960; x += 8) hy.push(424 + 26 * fbm(x * 0.006 + seedOff, 1.3, 3) - 16);
ctx.beginPath(); ctx.moveTo(0, 490);
for (let i = 0; i < hy.length; i++) ctx.lineTo(i * 8, hy[i]);
ctx.lineTo(960, 490); ctx.closePath();
const bg = ctx.createLinearGradient(0, 400, 0, 486);
bg.addColorStop(0, css(mixc(mulc(wd.back, P.land), P.haze, 0.35))); bg.addColorStop(1, css(mulc(wd.back, [P.land[0] * 0.6, P.land[1] * 0.6, P.land[2] * 0.6])));
ctx.fillStyle = bg; ctx.fill();
const rows = 7;
for (let r = 0; r < rows; r++){
const rt = r / (rows - 1), s = lerp(15, 40, rt), yBase = lerp(432, 480, Math.pow(rt, 0.9));
const step = s * (wd.kind === 'desert' ? 1.4 : 0.62);
for (let x = -s + rnd() * step; x < 960 + s; x += step * (0.7 + rnd() * 0.6)){
// keep the area right behind the slingshot fairly clear for readability
if (Math.abs(x - 150) < 40 && rt > 0.5) continue;
const hx = hy[clamp(Math.round(x / 8), 0, hy.length - 1)];
const yb = Math.max(yBase + (rnd() - 0.5) * 4, hx + s * 0.9 * (1 - rt));
if (yb > 486) continue;
tree(ctx, P, wd, x, yb, s * (0.8 + rnd() * 0.45), rnd, rt, sunSide);
}
}
}
function ground(ctx, P, wd, rnd, sunSide){
const Wd = 960, y0 = 478, Hh = 62;
const lay = cv(Wd, Hh), img = lay.x.createImageData(Wd, Hh), d = img.data;
for (let x = 0; x < Wd; x++){
const edge = 2 + 4.5 * fbm(x * 0.07, 2.2, 3);
for (let y = 0; y < Hh; y++){
let c;
const dy = y - edge;
if (dy < 0) { const i0 = (y * Wd + x) * 4; d[i0 + 3] = 0; continue; }
const n = fbm(x * 0.06, y * 0.09, 4), fine = vnoise(x * 0.7, y * 0.7);
if (dy < 11 + 5 * fbm(x * 0.2, 5.1, 2)){
c = mixc(wd.grass, wd.grass2, clamp(n * 1.3 + fine * 0.25 - dy * 0.03, 0, 1));
c = mixc(c, [255, 255, 235], clamp(0.12 - dy * 0.012, 0, 0.12));
} else {
c = mixc(wd.soil2, wd.soil, clamp(n * 1.5 - 0.1 + fine * 0.2, 0, 1));
if (fine > 0.9) c = mixc(c, [255, 255, 255], 0.22); // pebbles
if (fbm(x * 0.12, y * 0.3, 2) > 0.7) c = mulc(c, [0.8, 0.8, 0.8]); // strata
c = mulc(c, [1 - (y / Hh) * 0.42, 1 - (y / Hh) * 0.42, 1 - (y / Hh) * 0.42]);
if (dy < 18) c = mulc(c, [0.7 + 0.03 * (dy - 11), 0.7 + 0.03 * (dy - 11), 0.7 + 0.03 * (dy - 11)]); // shadow under the grass lip
}
c = mulc(c, P.land);
const i = (y * Wd + x) * 4;
d[i] = c[0]; d[i + 1] = c[1]; d[i + 2] = c[2]; d[i + 3] = clamp(dy * 255, 0, 255);
}
}
lay.x.putImageData(img, 0, 0);
ctx.drawImage(lay.c, 0, y0);
// grass blades along the lip
for (let i = 0; i < 900; i++){
const x = rnd() * Wd, hgt = 4 + rnd() * 9, lean = (rnd() - 0.5) * 4;
const c = mulc(mixc(wd.grass, wd.grass2, rnd()), P.land);
ctx.strokeStyle = css(mixc(c, [255, 255, 220], 0.12 * rnd())); ctx.lineWidth = 1.1;
ctx.beginPath(); ctx.moveTo(x, y0 + 4); ctx.quadraticCurveTo(x + lean * 0.4, y0 + 4 - hgt * 0.6, x + lean, y0 + 3 - hgt); ctx.stroke();
}
}
function render(worldIdx, phaseIdx){
const P = PH[phaseIdx], wd = WD[worldIdx % WD.length], ph = PHASES[phaseIdx];
SEED = 11 + worldIdx * 7;
const rnd = mulberry32(900 + worldIdx * 31 + phaseIdx);
const out = cv(960, 540), ctx = out.x;
const sunPos = phaseIdx === 3 ? [170, 96] : ph.sun, sunSide = sunPos[0] > 480 ? 1 : -1;
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(paintSky(P, sunPos, wd.cover, worldIdx * 3.1), 0, 0, 960, 540);
celestial(ctx, P, phaseIdx, sunPos, rnd);
wd.ranges.forEach((L, i) => mountains(ctx, P, L, wd, sunSide, 40 + i * 17 + worldIdx * 5));
foothills(ctx, P, wd, rnd, sunSide, worldIdx * 3);
ground(ctx, P, wd, rnd, sunSide);
// bloom on the sun side + horizon glow
if (phaseIdx < 3){
ctx.save(); ctx.globalCompositeOperation = 'lighter';
const g = ctx.createRadialGradient(sunPos[0], sunPos[1], 0, sunPos[0], sunPos[1], 420);
g.addColorStop(0, css(P.sunCol, 0.16 * P.glow)); g.addColorStop(1, css(P.sunCol, 0));
ctx.fillStyle = g; ctx.fillRect(0, 0, 960, 540); ctx.restore();
}
// vignette
const vg = ctx.createRadialGradient(480, 270, 250, 480, 270, 620);
vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,' + (phaseIdx === 3 ? 0.5 : 0.36) + ')');
ctx.fillStyle = vg; ctx.fillRect(0, 0, 960, 540);
// cinematic grade: teal shadows / warm highlights, then subtle grain
ctx.save(); ctx.globalCompositeOperation = 'soft-light';
const gg = ctx.createLinearGradient(0, 0, 0, 540);
gg.addColorStop(0, 'rgba(60,110,170,0.22)'); gg.addColorStop(1, 'rgba(255,170,90,0.25)');
ctx.fillStyle = gg; ctx.fillRect(0, 0, 960, 540); ctx.restore();
const gi = ctx.getImageData(0, 0, 960, 540), gd = gi.data;
for (let i = 0; i < gd.length; i += 4){
const n = (((i * 2654435761) >>> 24) - 128) * 0.045;
gd[i] += n; gd[i + 1] += n; gd[i + 2] += n;
}
ctx.putImageData(gi, 0, 0);
return out.c;
}
function wisp(){
const o = cv(256, 84), img = o.x.createImageData(256, 84), d = img.data;
SEED = 77;
for (let y = 0; y < 84; y++) for (let x = 0; x < 256; x++){
const e = 1 - Math.pow(Math.hypot((x - 128) / 128, (y - 42) / 42), 1.4);
const a = clamp(sm(0.0, 0.5, fbm(x * 0.03, y * 0.05, 4) - 0.22 + e * 0.5) * clamp(e * 1.6, 0, 1), 0, 1);
const i = (y * 256 + x) * 4; d[i] = d[i + 1] = d[i + 2] = 255; d[i + 3] = a * 150;
}
o.x.putImageData(img, 0, 0);
return o.c;
}
// realistic wooden slingshot: 64 x 112 texture, fork tips at y=5, anchor point (32, 15)
function sling(){
const o = cv(64, 112), ctx = o.x;
function limb(x1, y1, x2, y2, w, c1, c2){
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = w + 2; ctx.beginPath(); ctx.moveTo(x1 + 2, y1 + 1); ctx.lineTo(x2 + 2, y2 + 1); ctx.stroke();
ctx.strokeStyle = c1; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.strokeStyle = c2; ctx.lineWidth = w * 0.38; ctx.beginPath(); ctx.moveTo(x1 - w * 0.2, y1); ctx.lineTo(x2 - w * 0.2, y2); ctx.stroke();
}
limb(32, 108, 32, 34, 13, '#5a3a1e', '#8a6038');
limb(32, 34, 18, 6, 9, '#5a3a1e', '#8e6a42');
limb(32, 34, 46, 6, 9, '#4a2f18', '#7a5532');
// bark grain
ctx.strokeStyle = 'rgba(30,16,6,0.45)'; ctx.lineWidth = 1;
for (let i = 0; i < 9; i++){ const y = 44 + i * 7; ctx.beginPath(); ctx.moveTo(27 + (i % 3), y); ctx.lineTo(37 - (i % 2), y + 3); ctx.stroke(); }
// leather wrap
const g = ctx.createLinearGradient(24, 0, 40, 0);
g.addColorStop(0, '#3c2a1c'); g.addColorStop(0.5, '#7a5238'); g.addColorStop(1, '#2e2016');
ctx.fillStyle = g; ctx.beginPath(); ctx.roundRect(24, 30, 16, 12, 3); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; for (let i = 0; i < 4; i++){ ctx.beginPath(); ctx.moveTo(24, 32 + i * 3); ctx.lineTo(40, 34 + i * 3); ctx.stroke(); }
// rubber band stubs on the fork tips
ctx.fillStyle = '#2a1a12'; ctx.beginPath(); ctx.arc(18, 6, 4.4, 0, 6.283); ctx.arc(46, 6, 4.4, 0, 6.283); ctx.fill();
return o.c;
}
return { render: render, wisp: wisp, sling: sling, PH: PH };
})();
// ---------- texture helpers (3D renders become Phaser textures) ----------
function mkTex(scene, key, w, h, fn){
if (scene.textures.exists(key)) return;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
fn(g);
g.generateTexture(key, w, h);
g.destroy();
}
const MODELS = {};
function getModel(kind, i){
const k = kind + (i === undefined ? '' : i);
if (!MODELS[k]){
MODELS[k] = kind === 'bird' ? S3.birdModel(SKINS[i]) : kind === 'pig' ? S3.pigModel() : kind === 'cball' ? S3.cannonModel() : S3.eggModel();
}
return MODELS[k];
}
function scaledCanvas(cv, n){
const c = document.createElement('canvas'); c.width = c.height = n;
const x = c.getContext('2d'); x.imageSmoothingEnabled = true; x.imageSmoothingQuality = 'high';
x.drawImage(cv, 0, 0, n, n);
return c;
}
function putTex(scene, key, cv){
const T = scene.textures;
if (T.exists(key)) T.remove(key);
T.addCanvas(key, cv);
}
// in-game sprites are drawn 1:1 with their physics size (diameter x 1.9), so no sprite scaling is needed
function makeBirdTex(scene, i){
const big = getModel('bird', i).render(140, 0.5, -0.04);
putTex(scene, 'birdM_' + i, big); // shop / HUD size
putTex(scene, 'bird_' + i, scaledCanvas(big, 68)); // in-game (radius 18)
}
function makeSmallBirdTex(scene, i){ // clones of the Split bird (radius 14)
if (scene.textures.exists('birdS_' + i)) return;
putTex(scene, 'birdS_' + i, scaledCanvas(scene.textures.get('birdM_' + i).getSourceImage(), 53));
}
function makeLargeBirdTex(scene, i){ // menu hero
if (scene.textures.exists('birdL_' + i)) return;
putTex(scene, 'birdL_' + i, getModel('bird', i).render(240, 0.5, -0.04));
}
function makePigTex(scene){
const big = getModel('pig').render(140, 0.35, -0.04);
putTex(scene, 'pigM', big);
putTex(scene, 'pig', scaledCanvas(big, 68));
}
function makeCannonTex(scene){ putTex(scene, 'cball', scaledCanvas(getModel('cball').render(106, 0.5, -0.05), 53)); }
function makeEggTex(scene){ putTex(scene, 'egg', scaledCanvas(getModel('egg').render(68, 0, -0.05), 34)); }
const HERO_FRAMES = 9;
function makeHeroFrame(scene, i, k){ // turntable frame for the loading screen
const yaw = -0.62 + k * (1.24 / (HERO_FRAMES - 1));
putTex(scene, 'turn_' + i + '_' + k, getModel('bird', i).render(180, yaw, -0.05));
}
function makeAllTexturesSync(scene){
if (scene.textures.exists('cball')) return;
makePigTex(scene); SKINS.forEach((s, i) => makeBirdTex(scene, i)); makeCannonTex(scene); makeEggTex(scene);
}
// ---------- UI helpers ----------
const TXT = { fontFamily: 'Arial', fontStyle: 'bold', color: '#ffffff', stroke: '#000000', strokeThickness: 5 };
function makeButton(scene, x, y, w, h, label, color, cb, fs){
const g = scene.add.graphics();
g.fillStyle(0x000000, 0.35).fillRoundedRect(-w / 2 + 3, -h / 2 + 5, w, h, 14);
g.fillStyle(color).fillRoundedRect(-w / 2, -h / 2, w, h, 14);
g.fillStyle(0xffffff, 0.22).fillRoundedRect(-w / 2 + 4, -h / 2 + 3, w - 8, h * 0.42, 11);
g.lineStyle(3, 0xffffff, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 14);
const t = scene.add.text(0, 0, label, {
fontFamily: 'Arial', fontSize: (fs || 24) + 'px', fontStyle: 'bold',
color: '#ffffff', stroke: '#000000', strokeThickness: 4
}).setOrigin(0.5);
const c = scene.add.container(x, y, [g, t]);
c.setSize(w, h);
c.setInteractive({ useHandCursor: true });
c.on('pointerdown', () => c.setScale(0.94));
c.on('pointerout', () => c.setScale(1));
c.on('pointerup', () => { c.setScale(1); sfx(440, 0.08, 'square', 0.05); cb(); });
c.label = t;
return c;
}
function starPoints(cx, cy, R, r){
const pts = [];
for (let i = 0; i < 10; i++){
const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 === 0 ? R : r;
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr });
}
return pts;
}
function drawStar(g, cx, cy, R, on){
g.fillStyle(on ? 0xffc928 : 0x8a6a3a, 1).fillPoints(starPoints(cx, cy, R, R * 0.48), true);
g.lineStyle(1.5, on ? 0xb87800 : 0x5a4222, 1).strokePoints(starPoints(cx, cy, R, R * 0.48), true);
}
function drawCoin(g, cx, cy, r){
g.fillStyle(0xb87800, 1).fillCircle(cx, cy + 1.5, r);
g.fillStyle(0xffc928, 1).fillCircle(cx, cy, r);
g.fillStyle(0xffe680, 1).fillCircle(cx - r * 0.25, cy - r * 0.25, r * 0.55);
g.lineStyle(1.5, 0xb87800, 1).strokeCircle(cx, cy, r * 0.62);
}
function drawSky(scene, top, bot){
const g = scene.add.graphics();
gradRect(g, top, bot, W, H);
return g;
}
// ===================== Game scene =====================
class Game extends Phaser.Scene {
constructor(){ super('Game'); }
// ----- blocks -----
rectKey(mat, w, h){
const key = `r_${mat}_${w}_${h}`;
mkTex(this, key, w, h, g => {
const c = MATS[mat].color;
g.fillStyle(c).fillRect(0, 0, w, h);
g.fillStyle(0xffffff, 0.28).fillRect(1, 1, w - 2, 3).fillRect(1, 1, 3, h - 2); // 3D bevel: light edges
g.fillStyle(0x000000, 0.22).fillRect(1, h - 4, w - 2, 3).fillRect(w - 4, 1, 3, h - 2); // shadow edges
g.lineStyle(2, 0x000000, 0.35).strokeRect(1, 1, w - 2, h - 2);
if (mat === 'wood'){
g.lineStyle(1, 0x000000, 0.15);
if (w > h) for (let x = 12; x < w; x += 14) g.lineBetween(x, 4, x, h - 4);
else for (let y = 12; y < h; y += 14) g.lineBetween(4, y, w - 4, y);
}
if (mat === 'glass') g.fillStyle(0xffffff, 0.35).fillRect(5, 5, Math.max(4, w / 3), 3);
if (mat === 'stone') g.fillStyle(0x000000, 0.12).fillRect(w / 2, 0, w / 2, h);
});
return key;
}
addBlock(mat, x, y, w, h){
const o = this.matter.add.image(x, y, this.rectKey(mat, w, h), null, {
friction: 0.7, frictionStatic: 1, restitution: 0.05, density: MATS[mat].density
});
o.hp = o.maxHp = MATS[mat].hp;
o.kind = 'block'; o.col = MATS[mat].color;
this.objs.push(o);
}
addPig(x, y){
const o = this.matter.add.image(x, y, 'pig', null, {
shape: { type: 'circle', radius: BIRD_R }, friction: 0.5, restitution: 0.1, density: 0.001
});
o.hp = o.maxHp = 25 + Math.min(110, levelIndex * 0.8);
o.kind = 'pig'; o.col = 0x6fd34f;
this.objs.push(o);
}
buildTower(cx, floors){
let base = GY;
floors.forEach((mat, i) => {
this.addBlock(mat, cx - 40, base - 41, 20, 80);
this.addBlock(mat, cx + 40, base - 41, 20, 80);
this.addBlock(mat, cx, base - 91, 120, 20);
if (i === 0 || this.allPigs) this.addPig(cx, base - 19);
base -= 100;
});
this.addPig(cx, base - 19);
}
buildStruct(s){
if (s.type === 'stack'){
let base = GY;
s.mats.forEach(m => { this.addBlock(m, s.cx, base - 21, 40, 40); base -= 40; });
this.addPig(s.cx, base - 19);
} else {
this.buildTower(s.cx, s.mats);
}
}
// ----- scenery: realistic procedural backdrop (see bg.js), crossfaded per phase -----
bgKey(w, p){ return 'bg_' + w + '_' + p; }
ensureBg(w, p){
const k = this.bgKey(w, p);
if (!this.textures.exists(k)) this.textures.addCanvas(k, BG.render(w, p));
return k;
}
drawScene(){
this.world = Math.floor(levelIndex / 10) % WORLDS.length;
const k = this.ensureBg(this.world, currentPhase());
this.skyA = this.add.image(0, 0, k).setOrigin(0).setDepth(-100);
this.skyB = this.add.image(0, 0, k).setOrigin(0).setDepth(-99).setAlpha(0);
// slow drifting haze wisps for a bit of life
if (!this.textures.exists('wisp')) this.textures.addCanvas('wisp', BG.wisp());
const rnd = mulberry32(levelIndex * 131 + 7);
this.wisps = [];
for (let i = 0; i < 3; i++){
const w = this.add.image(rnd() * W, 60 + rnd() * 130, 'wisp').setDepth(-98).setAlpha(0.35).setScale(1.2 + rnd());
this.tweens.add({ targets: w, x: w.x + 160 + rnd() * 100, duration: 30000 + rnd() * 20000, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
this.wisps.push(w);
}
// wooden slingshot (texture fork tips at y=5, anchor at (32,15))
if (!this.textures.exists('sling')) this.textures.addCanvas('sling', BG.sling());
this.add.image(ANCHOR.x - 32, ANCHOR.y - 10, 'sling').setOrigin(0).setDepth(-1);
// light colour grade on top of everything (multiply)
this.ovColor = 0xffffff;
this.overlay = this.add.rectangle(W / 2, H / 2, W, H, 0xffffff).setBlendMode(Phaser.BlendModes.MULTIPLY).setDepth(9);
this.phaseIdx = -1;
this.nextBgPhase = -1;
}
setPhase(p, instant){
const P = PHASES[p], key = this.ensureBg(this.world, p), dur = instant ? 1 : 4000;
this.phaseIdx = p;
this.phaseText.setText(P.name);
if (instant){
this.skyA.setTexture(key); this.skyB.setAlpha(0);
} else {
this.skyB.setTexture(key).setAlpha(0);
this.tweens.add({ targets: this.skyB, alpha: 1, duration: dur, onComplete: () => { this.skyA.setTexture(key); this.skyB.setAlpha(0); } });
}
const MUL = [0xffffff, 0xfff0e0, 0xeed4cc, 0xa8b4e8];
const from = Phaser.Display.Color.ValueToColor(this.ovColor), to = Phaser.Display.Color.ValueToColor(MUL[p]);
this.tweens.addCounter({
from: 0, to: 100, duration: dur,
onUpdate: t => {
const c = Phaser.Display.Color.Interpolate.ColorWithColor(from, to, 100, t.getValue());
this.ovColor = Phaser.Display.Color.GetColor(c.r, c.g, c.b);
this.overlay.setFillStyle(this.ovColor, 1);
}
});
this.wisps.forEach(w => w.setTint(p === 3 ? 0x6a78b0 : p === 2 ? 0xffb090 : p === 1 ? 0xffe0c0 : 0xffffff));
}
// ----- scene -----
create(){
makeAllTexturesSync(this);
this.objs = []; this.toKill = []; this.fx = []; this.shots = [];
this.score = 0;
this.state = 'aim';
this.armed = false; this.dragging = false; this.paused = false;
this.bird = null; this.winTimer = null; this.leaving = false;
const lvl = getLevel(levelIndex);
this.allPigs = lvl.allPigs;
// ammo: a cannonball replaces every 3rd bird
this.ammo = [];
for (let i = 0; i < lvl.birds; i++) this.ammo.push(i % 3 === 2 ? 'cannon' : 'bird');
this.drawScene();
this.matter.add.rectangle(W / 2, GY + 30, 3000, 60, { isStatic: true, friction: 1 });
lvl.structs.forEach(st => this.buildStruct(st));
this.gfx = this.add.graphics().setDepth(11);
this.buildHUD();
this.spawnNext();
this.matter.world.on('collisionstart', ev => this.onCollide(ev));
this.input.on('pointerdown', (p, over) => {
if (this.paused) return;
if (over && over.length) return; // a button was tapped
if (this.state === 'end') return;
if (this.state === 'aim' && this.bird &&
Phaser.Math.Distance.Between(p.x, p.y, ANCHOR.x, ANCHOR.y) < 70){
this.dragging = true; this.moveBird(p);
} else if (this.state === 'flying'){
this.useAbility();
}
});
this.input.on('pointermove', p => { if (this.dragging && !this.paused) this.moveBird(p); });
const release = () => { if (this.dragging){ this.dragging = false; this.launch(); } };
this.input.on('pointerup', release);
this.input.on('pointerupoutside', release);
}
// ----- HUD -----
buildHUD(){
const st = { fontFamily: 'Arial', fontSize: '24px', color: '#ffffff', stroke: '#000000', strokeThickness: 4, fontStyle: 'bold' };
const panel = this.add.graphics().setDepth(10);
panel.fillStyle(0x000000, 0.38).fillRoundedRect(8, 8, 330, 54, 16);
panel.lineStyle(2, 0xffffff, 0.35).strokeRoundedRect(8, 8, 330, 54, 16);
this.ammoC = this.add.container(0, 0).setDepth(10);
this.pigIcon = this.add.image(300, 35, 'pigM').setDisplaySize(40, 40).setDepth(10);
this.pigText = this.add.text(322, 35, '', Object.assign({}, st, { fontSize: '20px' })).setOrigin(0, 0.5).setDepth(10);
this.scoreText = this.add.text(W - 16, 12, '', st).setOrigin(1, 0).setDepth(10);
this.coinIcon = this.add.graphics().setDepth(10); drawCoin(this.coinIcon, 0, 0, 9);
this.coinText = this.add.text(W - 16, 40, '', Object.assign({}, st, { fontSize: '20px', color: '#ffe680' })).setOrigin(1, 0).setDepth(10);
this.lvlText = this.add.text(W / 2, 10, 'Level ' + (levelIndex + 1), st).setOrigin(0.5, 0).setDepth(10);
this.phaseText = this.add.text(W / 2, 40, '', Object.assign({}, st, { fontSize: '14px', strokeThickness: 3 })).setOrigin(0.5, 0).setDepth(10);
this.lastPhaseCheck = 0;
this.setPhase(currentPhase(), true);
this.hint = this.add.text(W / 2, H - 38, '', Object.assign({}, st, { fontSize: '30px', color: '#ffe680', strokeThickness: 6 })).setOrigin(0.5).setDepth(12).setVisible(false);
this.msgC = null;
// pause + restart buttons
this.pauseBtn = this.roundBtn(W - 38, 100, 22, 0x3a86ff, g => { g.fillStyle(0xffffff, 1).fillRect(-7, -9, 5, 18).fillRect(3, -9, 5, 18); }, () => this.setPaused(true));
this.restartBtn = this.roundBtn(W - 90, 100, 22, 0xff8a1f, g => {
g.lineStyle(4, 0xffffff, 1); g.beginPath(); g.arc(0, 0, 9, -Math.PI * 0.2, Math.PI * 1.4, false); g.strokePath();
g.fillStyle(0xffffff, 1).fillTriangle(5, -13, 14, -4, 3, -3);
}, () => this.scene.restart());
}
roundBtn(x, y, r, color, icon, cb){
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35).fillCircle(2, 4, r);
g.fillStyle(color, 1).fillCircle(0, 0, r);
g.fillStyle(0xffffff, 0.25).fillCircle(-r * 0.2, -r * 0.3, r * 0.6);
g.lineStyle(3, 0xffffff, 0.8).strokeCircle(0, 0, r);
const ic = this.add.graphics(); icon(ic);
const c = this.add.container(x, y, [g, ic]).setDepth(10);
c.setSize(r * 2, r * 2); c.setInteractive({ useHandCursor: true });
c.on('pointerdown', () => c.setScale(0.92));
c.on('pointerout', () => c.setScale(1));
c.on('pointerup', () => { c.setScale(1); sfx(440, 0.08, 'square', 0.05); cb(); });
return c;
}
updateAmmoHUD(showCurrent){
this.ammoC.removeAll(true);
const list = (showCurrent ? [this.cur] : []).concat(this.ammo);
list.forEach((kind, i) => {
const cur = showCurrent && i === 0;
const size = cur ? 40 : 30;
const x = 30 + i * 33 + (cur ? 0 : 3), y = 35;
if (cur) this.ammoC.add(this.add.circle(x, y, 22, 0xffe680, 0.35));
const img = this.add.image(x, y, kind === 'cannon' ? 'cball' : 'birdM_' + skin).setDisplaySize(kind === 'cannon' ? size * 0.78 : size, kind === 'cannon' ? size * 0.78 : size);
this.ammoC.add(img);
});
}
setPaused(on){
if (this.paused === on) return;
this.paused = on;
if (on){
this.pauseStart = this.time.now;
this.matter.world.pause(); this.time.paused = true;
const d = [];
d.push(this.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.6).setDepth(30).setInteractive());
d.push(this.add.text(W / 2, 130, 'PAUSED', Object.assign({}, TXT, { fontSize: '56px', color: '#ffd23f' })).setOrigin(0.5).setDepth(31));
const close = () => { d.forEach(o => o.destroy()); this.setPaused(false); };
d.push(makeButton(this, W / 2, 240, 300, 64, 'RESUME', 0x2fb344, close, 30).setDepth(31));
d.push(makeButton(this, W / 2, 320, 300, 64, 'RESTART', 0xff8a1f, () => { this.paused = false; this.time.paused = false; this.scene.restart(); }, 30).setDepth(31));
d.push(makeButton(this, W / 2, 400, 300, 64, 'MENU', 0x3a86ff, () => { this.paused = false; this.time.paused = false; this.scene.start('Menu'); }, 30).setDepth(31));
} else {
this.launchTime += this.time.now - this.pauseStart;
this.matter.world.resume(); this.time.paused = false;
}
}
// ----- shots -----
mkShot(x, y, key, R, dens, opts){
const o = this.matter.add.image(x, y, key, null, Object.assign({
shape: { type: 'circle', radius: R }, density: dens, restitution: 0.3, friction: 0.6, frictionAir: 0.003
}, opts || {}));
o.setDepth(4);
o.setCollisionGroup(-1);
return o;
}
spawnNext(){
this.shots.forEach(s => { if (s && s.active) s.destroy(); });
this.shots = [];
this.cur = this.ammo.shift();
const isC = this.cur === 'cannon', sk = SKINS[skin];
let dens = isC ? 0.02 : 0.004, rest = 0.3, fric = 0.6;
if (!isC && sk.ab === 'heavy') dens = 0.011;
if (!isC && sk.ab === 'bouncy'){ rest = 0.9; fric = 0.15; }
this.bird = this.mkShot(ANCHOR.x, ANCHOR.y, isC ? 'cball' : 'bird_' + skin, isC ? CANNON_R : BIRD_R, dens, { restitution: rest, friction: fric });
this.bird.setStatic(true);
this.bird.isCannon = isC;
this.shots.push(this.bird);
this.abilityUsed = false;
this.state = 'aim';
this.updateAmmoHUD(true);
}
moveBird(p){
let dx = p.x - ANCHOR.x, dy = p.y - ANCHOR.y;
const d = Math.hypot(dx, dy);
if (d > MAX_DRAG){ dx = dx / d * MAX_DRAG; dy = dy / d * MAX_DRAG; }
let y = ANCHOR.y + dy;
if (y > GY - 16) y = GY - 16;
this.bird.setPosition(ANCHOR.x + dx, y);
}
launchPow(){
let p = POWER;
if (this.bird && !this.bird.isCannon && SKINS[skin].ab === 'range') p *= 1.4;
return p;
}
launch(){
const dx = ANCHOR.x - this.bird.x, dy = ANCHOR.y - this.bird.y;
if (Math.hypot(dx, dy) < 15){ this.bird.setPosition(ANCHOR.x, ANCHOR.y); return; }
this.state = 'flying';
this.armed = true;
this.launchTime = this.time.now;
this.abilityUsed = false;
this.bird.setStatic(false);
const p = this.launchPow();
this.bird.setVelocity(dx * p, dy * p);
this.updateAmmoHUD(false);
sfx(this.bird.isCannon ? 140 : 520, 0.15, 'sawtooth', 0.06);
}
// ----- abilities (tap while flying) -----
useAbility(){
const b = this.bird, sk = SKINS[skin];
if (!b || !b.active || b.isCannon || this.abilityUsed || !sk.tap) return;
this.abilityUsed = true;
const vx = b.body.velocity.x, vy = b.body.velocity.y, sp = Math.hypot(vx, vy) || 1;
switch (sk.ab){
case 'dash': {
const k = Math.max(sp * 1.9, 22) / sp;
b.setVelocity(vx * k, vy * k);
this.puff(b.x, b.y, 0xffe066, 10);
sfx(800, 0.2, 'sawtooth', 0.06); break; }
case 'split': {
makeSmallBirdTex(this, skin);
[-0.3, 0.3].forEach(a => {
const c = Math.cos(a), s = Math.sin(a);
const o = this.mkShot(b.x + (-vy / sp) * a * 90, b.y + (vx / sp) * a * 90, 'birdS_' + skin, SMALL_R, 0.004);
o.setVelocity(vx * c - vy * s, vx * s + vy * c);
this.shots.push(o);
});
this.puff(b.x, b.y, 0xffffff, 8);
sfx(700, 0.15, 'triangle', 0.06); break; }
case 'egg': {
const e = this.mkShot(b.x, b.y + 22, 'egg', EGG_R, 0.006, { restitution: 0.1 });
e.isEgg = true; e.born = this.time.now; e.setVelocity(vx * 0.25, 6);
this.shots.push(e);
b.setVelocity(vx * 1.15, Math.min(vy, 0) - 5);
sfx(500, 0.15, 'square', 0.05); break; }
case 'dive': {
try{ Phaser.Physics.Matter.Matter.Body.setDensity(b.body, 0.012); }catch(e){}
b.setVelocity(vx * 0.12, 25);
b.diving = true;
sfx(200, 0.25, 'sawtooth', 0.07); break; }
case 'glide': {
this.noGravity(b, true);
b.setVelocity(vx * 1.05, vy * 0.12);
this.time.delayedCall(1500, () => { if (b.active) this.noGravity(b, false); });
this.puff(b.x, b.y, 0xbfb8ff, 8);
sfx(600, 0.3, 'sine', 0.05); break; }
case 'seek': {
let best = null, bd = 1e9;
this.objs.forEach(o => {
if (!o.active || o.kind !== 'pig') return;
const d = Math.hypot(o.x - b.x, o.y - b.y);
if (d < bd){ bd = d; best = o; }
});
if (best){
const sp2 = Math.max(sp * 1.3, 21);
this.noGravity(b, true);
b.setVelocity((best.x - b.x) / bd * sp2, (best.y - b.y) / bd * sp2);
this.time.delayedCall(900, () => { if (b.active) this.noGravity(b, false); });
}
this.puff(b.x, b.y, 0x777777, 8);
sfx(900, 0.2, 'sawtooth', 0.06); break; }
case 'mega': {
const x = b.x, y = b.y;
b.destroy();
this.explode(x, y, 150, 95); break; }
}
}
noGravity(o, on){
if (o.setIgnoreGravity) o.setIgnoreGravity(on);
else if (o.body) o.body.ignoreGravity = on;
}
// ----- explosions ("bomb" feeling) -----
puff(x, y, color, n){
for (let i = 0; i < n; i++){
const c = this.add.circle(x, y, 3 + Math.random() * 4, color, 0.9).setDepth(12);
this.tweens.add({ targets: c, x: x + Phaser.Math.Between(-30, 30), y: y + Phaser.Math.Between(-30, 30), alpha: 0, scale: 0.2, duration: 350, onComplete: () => c.destroy() });
}
}
blast(x, y, s){
const D = 12;
const flash = this.add.circle(x, y, 26 * s, 0xffffff, 0.95).setDepth(D);
this.tweens.add({ targets: flash, scale: 2.4, alpha: 0, duration: 220, onComplete: () => flash.destroy() });
const fire = this.add.circle(x, y, 28 * s, 0xff9a1f, 0.9).setDepth(D);
this.tweens.add({ targets: fire, scale: 2.3, alpha: 0, duration: 420, ease: 'Cubic.out', onComplete: () => fire.destroy() });
const ring = this.add.circle(x, y, 18 * s, 0xffffff, 0).setStrokeStyle(Math.max(3, 5 * s), 0xffe9a8, 0.9).setDepth(D);
this.tweens.add({ targets: ring, scale: 4.2, alpha: 0, duration: 380, onComplete: () => ring.destroy() });
const cols = [0xffd23f, 0xff7a1a, 0xff4a1a, 0x444444];
const n = Math.round(10 + 12 * s);
for (let i = 0; i < n; i++){
const a = Math.random() * Math.PI * 2, dist = (30 + Math.random() * 70) * s;
const p = this.add.circle(x, y, (3 + Math.random() * 5) * Math.max(0.7, s), cols[i % 4], 1).setDepth(D);
this.tweens.add({ targets: p, x: x + Math.cos(a) * dist, y: y + Math.sin(a) * dist - 12, alpha: 0, scale: 0.3, duration: 350 + Math.random() * 300, ease: 'Cubic.out', onComplete: () => p.destroy() });
}
for (let i = 0; i < 5; i++){
const sm = this.add.circle(x + Phaser.Math.Between(-14, 14) * s, y, (12 + Math.random() * 10) * s, 0x555555, 0.55).setDepth(D - 1);
this.tweens.add({ targets: sm, y: y - 40 - Math.random() * 40 * s, scale: 2, alpha: 0, duration: 700 + Math.random() * 400, onComplete: () => sm.destroy() });
}
}
explode(x, y, r, dmg){
this.blast(x, y, r / 60);
this.cameras.main.shake(230, Math.min(0.013, r / 12000 + 0.004));
boom(0.55);
this.objs.forEach(o => {
if (!o.active || o.hp === undefined || o.hp <= 0) return;
const dx = o.x - x, dy = o.y - y, d = Math.hypot(dx, dy);
if (d > r + 20) return;
const f = Math.max(0, 1 - d / (r + 20));
o.hp -= dmg * f * (o.kind === 'pig' ? 1.1 : 1);
o.setAlpha(Math.max(0.55, o.hp / o.maxHp));
if (o.hp <= 0) this.toKill.push(o);
else if (o.body && !o.body.isStatic){
const k = 9 * f / Math.sqrt(o.body.mass + 0.5), nx = dx / (d || 1), ny = dy / (d || 1);
o.setVelocity(o.body.velocity.x + nx * k, o.body.velocity.y + ny * k - 1.5 * f);
}
});
}
popText(x, y, txt, color){
const t = this.add.text(x, y, txt, { fontFamily: 'Arial', fontSize: '22px', fontStyle: 'bold', color: color || '#ffffff', stroke: '#000000', strokeThickness: 4 }).setOrigin(0.5).setDepth(13);
this.tweens.add({ targets: t, y: y - 46, alpha: 0, duration: 850, onComplete: () => t.destroy() });
}
// ----- collisions / damage -----
onCollide(ev){
if (!this.armed) return;
for (const pr of ev.pairs){
const a = pr.bodyA, b = pr.bodyB;
const rel = Math.hypot(a.velocity.x - b.velocity.x, a.velocity.y - b.velocity.y);
this.special(a.gameObject, rel); this.special(b.gameObject, rel);
if (rel < 3) continue;
this.hurt(a, b, rel); this.hurt(b, a, rel);
}
}
special(o, rel){
if (!o || !o.active) return;
if (o.isCannon && !o.boomed && this.time.now - this.launchTime > 60){
o.boomed = true;
this.fx.push(() => { if (!o.active) return; const x = o.x, y = o.y; o.destroy(); this.explode(x, y, 125, 75); });
} else if (o.isEgg && !o.boomed && this.time.now - o.born > 120){
o.boomed = true;
this.fx.push(() => { if (!o.active) return; const x = o.x, y = o.y; o.destroy(); this.explode(x, y, 92, 55); });
} else if (o.diving && !o.dived && rel > 4){
o.dived = true;
this.fx.push(() => { if (o.active) this.explode(o.x, o.y + 8, 72, 32); });
}
}
hurt(me, other, rel){
const o = me.gameObject;
if (!o || o.hp === undefined || o.hp <= 0) return;
const f = other.isStatic ? 1 : Phaser.Math.Clamp(other.mass / me.mass, 0.3, 3);
o.hp -= rel * f * 2;
o.setAlpha(Math.max(0.55, o.hp / o.maxHp));
if (o.hp <= 0) this.toKill.push(o);
}
kill(o){
if (!o.active) return;
const pig = o.kind === 'pig';
const pts = (pig ? 100 : 50) * (SKINS[skin].ab === 'gold' ? 2 : 1);
this.score += pts;
this.popText(o.x, o.y - 12, '+' + pts, pig ? '#ffd23f' : '#ffffff');
if (pig){
this.blast(o.x, o.y, 0.8); // pig pops like a small bomb
this.cameras.main.shake(110, 0.004);
boom(0.28);
} else {
for (let i = 0; i < 8; i++){
const r = this.add.rectangle(o.x, o.y, 6, 6, o.col).setDepth(8);
this.tweens.add({ targets: r, x: o.x + Phaser.Math.Between(-50, 50), y: o.y + Phaser.Math.Between(-60, 30), alpha: 0, angle: 180, duration: 500, onComplete: () => r.destroy() });
}
sfx(260, 0.18, 'triangle', 0.07);
}
o.destroy();
}
pigsAlive(){ return this.objs.filter(o => o.active && o.kind === 'pig').length; }
// ----- turn flow -----
endTurn(){
if (this.state !== 'flying') return;
this.state = 'settling';
this.time.delayedCall(1000, () => this.resolveTurn(0));
}
resolveTurn(waited){
if (this.state !== 'settling') return;
if (this.pigsAlive() === 0){ this.win(); return; }
if (this.ammo.length > 0){ this.spawnNext(); return; }
// out of birds: give falling blocks time to land, a pig may still get crushed
const moving = this.objs.some(o => o.active && o.body && !o.body.isStatic && o.body.speed > 0.35);
if (moving && waited < 8000){ this.time.delayedCall(300, () => this.resolveTurn(waited + 300)); return; }
this.lose();
}
endPanel(title, color, stars, lines, buttons){
const d = [], pw = 540, px = W / 2 - pw / 2;
d.push(this.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.45).setDepth(20));
const g = this.add.graphics().setDepth(21);
g.fillStyle(0x000000, 0.4).fillRoundedRect(px + 4, 90 + 6, pw, 340, 26);
g.fillStyle(0x2a2f5c, 0.96).fillRoundedRect(px, 90, pw, 340, 26);
g.lineStyle(4, 0xffffff, 0.7).strokeRoundedRect(px, 90, pw, 340, 26);
d.push(g);
d.push(this.add.text(W / 2, 135, title, Object.assign({}, TXT, { fontSize: '40px', color: color })).setOrigin(0.5).setDepth(22));
if (stars >= 0){
const sg = this.add.graphics().setDepth(22);
for (let i = 0; i < 3; i++) drawStar(sg, W / 2 - 70 + i * 70, 212, 30, i < stars);
d.push(sg);
}
d.push(this.add.text(W / 2, 285, lines, Object.assign({}, TXT, { fontSize: '24px', align: 'center', strokeThickness: 4 })).setOrigin(0.5).setDepth(22));
const total = buttons.reduce((a, b) => a + b.w, 0) + (buttons.length - 1) * 16;
let x = W / 2 - total / 2;
buttons.forEach(b => {
d.push(makeButton(this, x + b.w / 2, 380, b.w, 58, b.label, b.color, b.cb, 24).setDepth(23));
x += b.w + 16;
});
this.msgObjs = d;
}
win(){
if (this.state === 'end') return;
this.state = 'end'; this.won = true;
this.hint.setVisible(false);
const left = this.ammo.length;
const bonus = left * 100;
this.score += bonus;
addCoins(this.score);
const stars = left >= 2 ? 3 : left === 1 ? 2 : 1;
setStars(levelIndex, stars);
maxUnlocked = Math.max(maxUnlocked, Math.min(TOTAL_LEVELS - 1, levelIndex + 1)); save('bb_max', maxUnlocked);
const last = levelIndex === TOTAL_LEVELS - 1;
this.endPanel(last ? 'YOU WIN!' : 'LEVEL COMPLETE!', '#ffd23f', stars,
'Score: ' + this.score + (bonus ? ' (bird bonus +' + bonus + ')' : '') + '\n+' + this.score + ' coins',
[{ label: last ? 'PLAY AGAIN' : 'NEXT LEVEL', w: 200, color: 0x2fb344, cb: () => this.goNext() },
{ label: 'REPLAY', w: 130, color: 0xff8a1f, cb: () => this.retry() },
{ label: 'MENU', w: 110, color: 0x3a86ff, cb: () => this.toMenu() }]);
sfx(660, 0.3, 'triangle', 0.08);
}
lose(){
if (this.state === 'end') return;
this.state = 'end'; this.won = false;
this.hint.setVisible(false);
addCoins(this.score);
this.endPanel('OUT OF BIRDS!', '#ff7a7a', -1, 'Score: ' + this.score + '\n+' + this.score + ' coins',
[{ label: 'TRY AGAIN', w: 210, color: 0x2fb344, cb: () => this.retry() },
{ label: 'MENU', w: 120, color: 0x3a86ff, cb: () => this.toMenu() }]);
sfx(120, 0.4, 'sawtooth', 0.08);
}
goNext(){
if (this.leaving) return; this.leaving = true;
levelIndex++;
if (levelIndex >= TOTAL_LEVELS) levelIndex = 0;
save('bb_level', levelIndex);
this.scene.restart();
}
retry(){ if (this.leaving) return; this.leaving = true; this.scene.restart(); }
toMenu(){ if (this.leaving) return; this.leaving = true; this.scene.start('Menu'); }
// ----- per-frame -----
update(time){
if (this.paused) return;
if (time - this.lastPhaseCheck > 1000){
this.lastPhaseCheck = time;
const ph = currentPhase();
if (ph !== this.phaseIdx) this.setPhase(ph, false);
// pre-render the next phase's backdrop shortly before the switch so there is no hitch
const left = 300000 - ((Date.now() - CLOCK0) % 300000), nx = (ph + 1) % PHASES.length;
if (left < 45000 && this.nextBgPhase !== nx){ this.nextBgPhase = nx; this.ensureBg(this.world, nx); }
}
while (this.fx.length) this.fx.shift()();
while (this.toKill.length) this.kill(this.toKill.pop());
this.objs.forEach(o => { if (o.active && o.y > 700) this.kill(o); });
this.scoreText.setText('Score: ' + this.score);
this.coinText.setText(String(coins));
this.coinIcon.setPosition(this.coinText.x - this.coinText.width - 14, this.coinText.y + 12);
this.pigText.setText(String(this.pigsAlive()));
// slingshot bands + aim dots
const g = this.gfx;
g.clear();
const b = this.bird;
if (b && b.active && this.state === 'aim'){
g.lineStyle(6, 0x3b1d0a);
g.lineBetween(ANCHOR.x - 14, ANCHOR.y - 5, b.x, b.y);
g.lineBetween(ANCHOR.x + 14, ANCHOR.y - 5, b.x, b.y);
if (this.dragging){
const pw = this.launchPow();
let vx = (ANCHOR.x - b.x) * pw, vy = (ANCHOR.y - b.y) * pw;
let px = b.x, py = b.y;
g.fillStyle(0xffffff, 0.85);
for (let s = 0; s < 150; s++){
vx *= 0.997; vy = vy * 0.997 + 0.278;
px += vx; py += vy;
if (py > GY || px > W + 40) break;
if (s % 4 === 0) g.fillCircle(px, py, 3);
}
}
}
// tap hint
const sk = SKINS[skin];
const showHint = this.state === 'flying' && b && b.active && !b.isCannon && !this.abilityUsed && sk.tap;
this.hint.setVisible(!!showHint);
if (showHint) this.hint.setText('TAP: ' + sk.tap);
// win detection + end of flight
if ((this.state === 'aim' || this.state === 'flying' || this.state === 'settling') && this.pigsAlive() === 0 && !this.winTimer){
this.winTimer = this.time.delayedCall(700, () => { this.winTimer = null; this.win(); });
}
if (this.state === 'flying'){
const el = time - this.launchTime;
let moving = false;
this.shots.forEach(s => {
if (!s.active) return;
if (s.x > 1100 || s.x < -100 || s.y > 700){ s.destroy(); return; }
if (s.body.speed > 0.3) moving = true;
});
const alive = this.shots.filter(s => s.active).length;
if (alive === 0 || (el > 1500 && !moving) || el > 8000) this.endTurn();
}
}
}
// ===================== Loading screen (real progress, rotating 3D bird) =====================
class Loading extends Phaser.Scene {
constructor(){ super('Loading'); }
create(){
const g = this.add.graphics();
gradRect(g, 0x3f9bff, 0xd6f1ff, W, H);
g.fillStyle(0xffffff, 0.92);
[[110, 130], [840, 120], [470, 96], [690, 190]].forEach(([x, y]) => {
g.fillCircle(x, y, 30).fillCircle(x + 32, y + 8, 24).fillCircle(x - 32, y + 8, 22);
});
g.fillStyle(0x86d17a).fillEllipse(180, H - 20, 620, 230).fillEllipse(800, H - 10, 700, 200);
g.fillStyle(0xc9a26b).fillTriangle(30, H, 250, 250, 520, H);
g.fillStyle(0xa8834f).fillTriangle(250, 250, 320, 340, 190, 340);
g.fillStyle(0x5aa84a).fillRect(0, H - 28, W, 28);
// flying planks
for (let i = 0; i < 9; i++){
const r = this.add.rectangle(Phaser.Math.Between(40, 920), Phaser.Math.Between(90, 420), Phaser.Math.Between(60, 110), 14, 0xc98a4b)
.setAngle(Phaser.Math.Between(0, 180)).setStrokeStyle(2, 0x6b4423);
this.tweens.add({ targets: r, angle: r.angle + 360, y: r.y + Phaser.Math.Between(-25, 25), duration: Phaser.Math.Between(2500, 4500), repeat: -1, yoyo: true });
}
this.add.text(W / 2, 46, 'BLOB BLASTER', Object.assign({}, TXT, { fontSize: '64px', strokeThickness: 9, color: '#ffd23f' })).setOrigin(0.5).setDepth(20);
const tips = ['Tip: Tap in the air to use your bird\'s special power!', 'Tip: Every 3rd shot is a cannonball. It explodes on impact!',
'Tip: Glass breaks easily, stone needs a strong hit.', 'Tip: Earn coins every level and unlock new birds.'];
this.add.text(W / 2, H - 82, Phaser.Utils.Array.GetRandom(tips), Object.assign({}, TXT, { fontSize: '20px', strokeThickness: 4 })).setOrigin(0.5).setDepth(20);
this.add.rectangle(W / 2, H - 46, 504, 24, 0x000000, 0.6).setStrokeStyle(3, 0xffffff).setDepth(20);
this.bar = this.add.rectangle(W / 2 - 250, H - 46, 2, 18, 0xffb703).setOrigin(0, 0.5).setDepth(21);
this.pct = this.add.text(W / 2, H - 46, 'Loading... 0%', Object.assign({}, TXT, { fontSize: '14px', strokeThickness: 3 })).setOrigin(0.5).setDepth(22);
// work list: each item builds some 3D textures (so the bar is real)
const T = [];
T.push(() => { makePigTex(this); this.showPigs(); });
for (let k = 0; k < HERO_FRAMES; k++) T.push(() => { makeHeroFrame(this, 0, k); if (k === HERO_FRAMES - 1) this.showHero(); });
T.push(() => { makeBirdTex(this, 10); this.showBird('birdM_10', 190, 330, 170, -10, 1000); });
T.push(() => { makeBirdTex(this, 2); this.showBird('birdM_2', 770, 320, 160, 12, 1100); });
SKINS.forEach((s, i) => { if (i !== 10 && i !== 2) T.push(() => makeBirdTex(this, i)); });
T.push(() => makeCannonTex(this));
T.push(() => makeEggTex(this));
this.tasks = T; this.idx = 0; this.shown = 0; this.t0 = this.time.now;
this.time.addEvent({ delay: 24, loop: true, callback: () => { if (this.idx < this.tasks.length) this.tasks[this.idx++](); } });
}
float(o, dy, d){ this.tweens.add({ targets: o, y: o.y + dy, duration: d, yoyo: true, repeat: -1, ease: 'Sine.inOut' }); }
pop(o, sc){
o.setScale(sc * 0.4).setAlpha(0);
this.tweens.add({ targets: o, scale: sc, alpha: 1, duration: 380, ease: 'Back.out' });
}
showPigs(){
const a = this.add.image(W / 2 + 270, 420, 'pigM').setDisplaySize(150, 150).setDepth(5);
const b = this.add.image(W / 2 - 300, 440, 'pigM').setDisplaySize(90, 90).setAngle(-12).setDepth(5);
const c = this.add.image(120, 160, 'pigM').setDisplaySize(56, 56).setAngle(18).setDepth(5);
[a, b, c].forEach(o => this.pop(o, o.scale));
this.float(a, 12, 1200); this.float(b, -10, 950); this.float(c, 8, 1100);
}
showBird(key, x, y, size, ang, d){
const o = this.add.image(x, y, key).setDisplaySize(size, size).setAngle(ang).setDepth(6);
this.pop(o, o.scale); this.float(o, -14, d);
}
showHero(){
const frames = [];
for (let k = 0; k < HERO_FRAMES; k++) frames.push({ key: 'turn_0_' + k });
if (!this.anims.exists('heroTurn')) this.anims.create({ key: 'heroTurn', frames: frames, frameRate: 8, yoyo: true, repeat: -1 });
const hero = this.add.sprite(W / 2, 270, 'turn_0_4').setDisplaySize(390, 390).setDepth(7);
this.pop(hero, hero.scale);
hero.play('heroTurn');
this.float(hero, -12, 1300);
}
update(time){
const target = Math.min(1, this.idx / this.tasks.length) * 100;
this.shown += (target - this.shown) * 0.15;
this.bar.setSize(Math.max(2, this.shown * 5), 18);
this.pct.setText('Loading... ' + Math.floor(this.shown) + '%');
if (!this.leaving && this.idx >= this.tasks.length && this.shown > 99 && time - this.t0 > 2800){
this.leaving = true;
this.scene.start('Menu');
}
}
}
// ===================== Main menu =====================
class Menu extends Phaser.Scene {
constructor(){ super('Menu'); }
create(){
makeLargeBirdTex(this, skin);
const g = this.add.graphics();
g.fillGradientStyle(0x1b1f4b, 0x1b1f4b, 0xff7b54, 0xff7b54, 1).fillRect(0, 0, W, H);
g.fillStyle(0x2a1b3d).fillEllipse(200, H, 700, 200).fillEllipse(780, H, 800, 240);
g.fillStyle(0xffffff, 0.1).fillEllipse(300, 430, 320, 46);
const ch = this.add.image(300, 262, 'birdL_' + skin).setDisplaySize(330, 330);
this.tweens.add({ targets: ch, y: 248, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
this.tweens.add({ targets: ch, angle: 4, duration: 2100, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
const sk = SKINS[skin];
this.add.text(300, 440, sk.name, Object.assign({}, TXT, { fontSize: '30px' })).setOrigin(0.5);
this.add.text(300, 470, sk.abName + ': ' + sk.abDesc, Object.assign({}, TXT, { fontSize: '14px', strokeThickness: 3, color: '#ffe680' })).setOrigin(0.5);
this.add.text(20, 12, 'BLOB BLASTER', Object.assign({}, TXT, { fontSize: '34px', color: '#ffd23f' }));
const cg = this.add.graphics(); drawCoin(cg, 34, 70, 12);
this.add.text(54, 70, String(coins), Object.assign({}, TXT, { fontSize: '24px', color: '#ffe680', strokeThickness: 4 })).setOrigin(0, 0.5);
makeButton(this, W - 105, 42, 170, 48, 'SETTINGS', 0x3a3f6b, () => this.scene.start('Settings'), 22);
makeButton(this, 105, 506, 190, 50, 'BIRD SHOP', 0x3a86ff, () => this.scene.start('Skins'), 22);
makeButton(this, 320, 506, 190, 50, 'LEVELS', 0x2fb344, () => this.scene.start('LevelSelect'), 22);
this.add.text(W - 190, 380, 'Level ' + (levelIndex + 1) + ' / ' + TOTAL_LEVELS, Object.assign({}, TXT, { fontSize: '24px' })).setOrigin(0.5);
const start = makeButton(this, W - 190, 450, 320, 90, 'START', 0xff7b00, () => this.scene.start('Game'), 46);
this.tweens.add({ targets: start, scale: 1.05, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
}
}
// ===================== Settings =====================
class Settings extends Phaser.Scene {
constructor(){ super('Settings'); }
init(){ this.confirm = false; }
create(){
const g = this.add.graphics();
g.fillGradientStyle(0x1b1f4b, 0x1b1f4b, 0x3a3f6b, 0x3a3f6b, 1).fillRect(0, 0, W, H);
g.fillStyle(0x000000, 0.35).fillRoundedRect(250, 70, 460, 400, 20);
this.add.text(W / 2, 110, 'SETTINGS', Object.assign({}, TXT, { fontSize: '44px', color: '#ffd23f' })).setOrigin(0.5);
makeButton(this, W / 2, 200, 340, 60, 'Sound: ' + (soundOn ? 'ON' : 'OFF'), soundOn ? 0x2fb344 : 0xc0392b, () => {
soundOn = !soundOn; save('bb_sound', soundOn ? '1' : '0'); this.scene.restart();
}, 26);
const rb = makeButton(this, W / 2, 285, 340, 60, 'Reset Progress', 0x8e44ad, () => {
if (!this.confirm){ this.confirm = true; rb.label.setText('Tap again to confirm'); return; }
resetProgress(); this.scene.restart();
}, 24);
this.add.text(W / 2, 342, 'Drag the bird back and release to fire.\nTap in the air to use its special power!', Object.assign({}, TXT, { fontSize: '15px', strokeThickness: 3, align: 'center' })).setOrigin(0.5);
makeButton(this, W / 2, 410, 240, 60, 'BACK', 0xff7b00, () => this.scene.start('Menu'), 28);
}
}
// ===================== Bird shop =====================
class Skins extends Phaser.Scene {
constructor(){ super('Skins'); }
init(d){ this.sel = (d && d.sel !== undefined) ? d.sel : skin; this.note = (d && d.note) || ''; }
create(){
const g = this.add.graphics();
g.fillGradientStyle(0x1b1f4b, 0x1b1f4b, 0x3a3f6b, 0x3a3f6b, 1).fillRect(0, 0, W, H);
this.add.text(W / 2, 26, 'BIRD SHOP', Object.assign({}, TXT, { fontSize: '36px', color: '#ffd23f' })).setOrigin(0.5);
makeButton(this, 70, 28, 110, 40, 'BACK', 0xff7b00, () => this.scene.start('Menu'), 20);
const cg = this.add.graphics(); drawCoin(cg, W - 120, 28, 12);
this.add.text(W - 20, 28, String(coins), Object.assign({}, TXT, { fontSize: '26px', color: '#ffe680', strokeThickness: 4 })).setOrigin(1, 0.5);
const cw = 140, ch = 118, gap = 12, cols = 6, x0 = (W - (cols * cw + (cols - 1) * gap)) / 2 + cw / 2;
SKINS.forEach((s, i) => {
const x = x0 + (i % cols) * (cw + gap), y = 112 + Math.floor(i / cols) * (ch + gap);
const have = owns(i), equipped = i === skin, selected = i === this.sel;
const bg = this.add.graphics();
bg.fillStyle(equipped ? 0x2fb344 : 0x000000, equipped ? 0.55 : 0.35).fillRoundedRect(-cw / 2, -ch / 2, cw, ch, 14);
bg.lineStyle(selected ? 5 : 2, selected ? 0xffd23f : 0xffffff, selected ? 1 : 0.35).strokeRoundedRect(-cw / 2, -ch / 2, cw, ch, 14);
const img = this.add.image(0, -12, 'birdM_' + i).setDisplaySize(84, 84);
if (!have) img.setTint(0x55556a);
const nm = this.add.text(0, 33, s.name, Object.assign({}, TXT, { fontSize: '16px', strokeThickness: 3 })).setOrigin(0.5);
const kids = [bg, img, nm];
if (have){
kids.push(this.add.text(0, 50, equipped ? 'IN USE' : 'OWNED', Object.assign({}, TXT, { fontSize: '12px', strokeThickness: 3, color: equipped ? '#b8ffb8' : '#ffffff' })).setOrigin(0.5));
} else {
const pg = this.add.graphics(); drawCoin(pg, -22, 50, 7); kids.push(pg);
kids.push(this.add.text(-10, 50, String(s.price), Object.assign({}, TXT, { fontSize: '13px', strokeThickness: 3, color: '#ffe680' })).setOrigin(0, 0.5));
const lk = this.add.graphics(); // little padlock
lk.fillStyle(0xdddddd, 1).fillRoundedRect(cw / 2 - 26, -ch / 2 + 8, 16, 12, 3);
lk.lineStyle(3, 0xdddddd, 1); lk.beginPath(); lk.arc(cw / 2 - 18, -ch / 2 + 8, 5, Math.PI, 0, false); lk.strokePath();
kids.push(lk);
}
const c = this.add.container(x, y, kids);
c.setSize(cw, ch); c.setInteractive({ useHandCursor: true });
c.on('pointerup', () => { sfx(600, 0.08, 'triangle', 0.05); this.scene.restart({ sel: i }); });
});
// detail panel
const s = SKINS[this.sel], have = owns(this.sel);
const p = this.add.graphics();
p.fillStyle(0x000000, 0.4).fillRoundedRect(30, 312, W - 60, 212, 20);
p.lineStyle(2, 0xffffff, 0.35).strokeRoundedRect(30, 312, W - 60, 212, 20);
const big = this.add.image(140, 420, 'birdM_' + this.sel).setDisplaySize(190, 190);
if (!have) big.setTint(0x55556a);
this.add.text(260, 334, s.name, Object.assign({}, TXT, { fontSize: '36px' }));
this.add.text(260, 382, s.abName, Object.assign({}, TXT, { fontSize: '24px', color: '#ffe680', strokeThickness: 4 }));
this.add.text(260, 416, s.abDesc, Object.assign({}, TXT, { fontSize: '18px', strokeThickness: 3, wordWrap: { width: 400 } }));
if (this.note) this.add.text(260, 480, this.note, Object.assign({}, TXT, { fontSize: '18px', strokeThickness: 3, color: '#b8ffb8' }));
const bx = W - 190, by = 430;
if (this.sel === skin){
makeButton(this, bx, by, 280, 70, 'IN USE', 0x5b6b5b, () => {}, 28);
} else if (have){
makeButton(this, bx, by, 280, 70, 'EQUIP', 0x2fb344, () => { equip(this.sel); this.scene.restart({ sel: this.sel, note: s.name + ' equipped!' }); }, 30);
} else {
const afford = coins >= s.price;
makeButton(this, bx, by, 280, 70, (afford ? 'BUY ' : 'LOCKED ') + s.price, afford ? 0xff7b00 : 0x666b7a, () => {
const r = buySkin(this.sel);
if (r === 'ok'){ sfx(900, 0.25, 'triangle', 0.08); this.scene.restart({ sel: this.sel, note: s.name + ' unlocked!' }); }
else this.scene.restart({ sel: this.sel, note: 'Need ' + (s.price - coins) + ' more coins. Play levels to earn them!' });
}, 28);
this.add.text(bx, by + 52, afford ? 'Tap to buy' : 'Not enough coins yet', Object.assign({}, TXT, { fontSize: '14px', strokeThickness: 3, color: afford ? '#b8ffb8' : '#ffb0b0' })).setOrigin(0.5);
}
}
}
// ===================== Level select =====================
class LevelSelect extends Phaser.Scene {
constructor(){ super('LevelSelect'); }
init(d){ this.page = (d && d.page !== undefined) ? d.page : Math.floor(levelIndex / 18); }
create(){
const PER = 18, pages = Math.ceil(TOTAL_LEVELS / PER);
const g = this.add.graphics();
gradRect(g, 0x3f9bff, 0xcdeeff, W, H);
g.fillStyle(0xffffff, 0.9);
[[110, 120], [850, 100], [500, 40]].forEach(([x, y]) => { g.fillCircle(x, y, 28).fillCircle(x + 30, y + 8, 22).fillCircle(x - 30, y + 8, 20); });
g.fillStyle(0x6fcf5a).fillEllipse(200, H, 760, 260).fillEllipse(800, H, 800, 230);
g.fillStyle(0x4fae45).fillEllipse(520, H + 40, 900, 200);
// panel
g.fillStyle(0x000000, 0.22).fillRoundedRect(114, 86, 740, 392, 34);
g.fillStyle(0xf4dcc0, 1).fillRoundedRect(110, 80, 740, 392, 34);
g.lineStyle(5, 0xffffff, 0.9).strokeRoundedRect(110, 80, 740, 392, 34);
g.fillStyle(0xe6c298, 1).fillRoundedRect(128, 98, 704, 356, 24);
// ribbon
g.fillStyle(0xd9722f, 1).fillRoundedRect(W / 2 - 110, 58, 220, 50, 16);
g.fillStyle(0xff9a4a, 0.5).fillRoundedRect(W / 2 - 104, 62, 208, 18, 10);
g.lineStyle(4, 0xffffff, 0.9).strokeRoundedRect(W / 2 - 110, 58, 220, 50, 16);
this.add.text(W / 2, 83, 'LEVELS', Object.assign({}, TXT, { fontSize: '32px', strokeThickness: 5 })).setOrigin(0.5);
const tw = 92, th = 76, gx = 26, cols = 6;
const x0 = (W - (cols * tw + (cols - 1) * gx)) / 2 + tw / 2;
for (let j = 0; j < PER; j++){
const i = this.page * PER + j;
if (i >= TOTAL_LEVELS) break;
const x = x0 + (j % cols) * (tw + gx), y = 168 + Math.floor(j / cols) * 108;
const open = i <= maxUnlocked, cur = i === levelIndex;
const main = !open ? 0x9aa0a8 : cur ? 0xffb300 : 0x7ed957, dark = !open ? 0x6e747c : cur ? 0xc97d00 : 0x4aa535;
const tg = this.add.graphics();
tg.fillStyle(0x000000, 0.25).fillRoundedRect(-tw / 2 + 2, -th / 2 + 6, tw, th, 14);
tg.fillStyle(dark, 1).fillRoundedRect(-tw / 2, -th / 2 + 5, tw, th - 5, 14);
tg.fillStyle(main, 1).fillRoundedRect(-tw / 2, -tGame Source: Blob Blaster
Creator: FrostTiger90
Libraries: matter
Complexity: complex (1894 lines, 97.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: blob-blaster-frosttiger90" to link back to the original. Then publish at arcadelab.ai/publish.