🎮ArcadeLab

Blob Blaster

by FrostTiger90
1894 lines97.7 KB🛠️ matter.js (physics engine)
▶ Play
<!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, -t

Game 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.