// ============================================================
// HUNGRY HOLE 3D — the interactive board (drag · eat · grow)
// Exposes: Board
// ============================================================
const { useState: useStateB, useRef: useRefB, useEffect: useEffectB, useMemo: useMemoB, useCallback: useCallbackB } = React;

const TILT = 56; // board tilt degrees

// ambient weather / atmosphere per world — restrained, premium
function Weather({ world, n = 1 }) {
  const id = world.id;
  const layers = [];
  const rnd = (i, m = 1) => ((Math.sin(i * 99.7 + id * 13.3) + 1) / 2) * m;
  if (id === 4 || id === 16) { // snow
    for (let i = 0; i < 14 * n; i++) layers.push(<span key={i} style={{ position: 'absolute', left: `${rnd(i) * 100}%`, top: '-4%',
      width: 4 + rnd(i + 3) * 4, height: 4 + rnd(i + 3) * 4, borderRadius: '50%', background: 'rgba(255,255,255,0.92)', '--sx': `${(rnd(i + 7) - 0.5) * 60}px`,
      animation: `hh-snow ${5 + rnd(i + 1) * 5}s ${rnd(i + 2) * 5}s linear infinite` }} />);
  } else if (id === 15) { // embers
    for (let i = 0; i < 12 * n; i++) layers.push(<span key={i} style={{ position: 'absolute', left: `${rnd(i) * 100}%`, bottom: '10%',
      width: 4, height: 4, borderRadius: '50%', background: i % 2 ? '#FFB627' : '#FF6B2C', '--ex': `${(rnd(i + 5) - 0.5) * 30}px`,
      boxShadow: '0 0 6px #FF7A2C', animation: `hh-ember ${3 + rnd(i + 1) * 3}s ${rnd(i + 2) * 4}s ease-out infinite` }} />);
  } else if (id === 12 || id === 10) { // bubbles
    for (let i = 0; i < 12 * n; i++) layers.push(<span key={i} style={{ position: 'absolute', left: `${rnd(i) * 100}%`, bottom: '6%',
      width: 6 + rnd(i + 3) * 10, height: 6 + rnd(i + 3) * 10, borderRadius: '50%', border: '1.5px solid rgba(255,255,255,0.6)', background: 'rgba(255,255,255,0.12)',
      animation: `hh-bubble ${4 + rnd(i + 1) * 4}s ${rnd(i + 2) * 4}s ease-in infinite` }} />);
  } else if (!world.night) { // clouds (day)
    for (let i = 0; i < 3; i++) layers.push(<span key={i} className="hh-anim" style={{ position: 'absolute', top: `${8 + i * 10}%`, left: `${-20 + i * 26}%`,
      width: 130 - i * 14, height: 32, borderRadius: 40, background: 'rgba(255,255,255,0.55)', filter: 'blur(7px)', animation: `hh-cloud ${30 + i * 8}s linear infinite` }} />);
    if (id === 14) layers.push(<span key="b" style={{ position: 'absolute', top: '22%', left: '0%', fontSize: 18, color: 'rgba(27,39,64,0.5)', animation: 'hh-bird 14s linear infinite' }}>︵</span>);
  } else { // night motes
    for (let i = 0; i < 10; i++) layers.push(<span key={i} style={{ position: 'absolute', left: `${rnd(i) * 100}%`, top: `${rnd(i + 4) * 80}%`,
      width: 3, height: 3, borderRadius: '50%', background: 'rgba(255,255,255,0.7)', boxShadow: `0 0 6px ${world.ring}`, animation: `hh-stars ${3 + rnd(i) * 4}s ease-in-out infinite alternate` }} />);
  }
  return <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 2 }}>{layers}</div>;
}
window.Weather = Weather;

// ── shape motif: original SVG drawn over the hole disc (rim ring + glow). ──
// All paths are generated here (no copied art). Sized in a 100×100 viewBox,
// rendered into a square that exactly overlays the hole. `c` = rim/stroke
// color, `g` = glow color, `spin` toggles the cheap CSS idle rotation.
function ShapeMotif({ shape, c, g, spin }) {
  const sw = 5;                          // base stroke width in viewBox units
  const common = { fill: 'none', stroke: c, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round' };
  const glowFx = { filter: `drop-shadow(0 0 2px ${g})` };
  // build N points around a circle (cx,cy radius rad), star alternates radius
  const ring = (n, rad, rad2, rot = -90) => {
    const pts = [];
    for (let i = 0; i < n; i++) {
      const a = (rot + i * 360 / n) * Math.PI / 180;
      const rr = (rad2 != null && i % 2) ? rad2 : rad;
      pts.push([50 + Math.cos(a) * rr, 50 + Math.sin(a) * rr]);
    }
    return pts;
  };
  const poly = (pts) => pts.map(p => p.join(',')).join(' ');
  let body = null;
  switch (shape) {
    case 'ring':                         // thick concentric ring
      body = <g>
        <circle cx="50" cy="50" r="40" {...common} strokeWidth={sw * 2.4} opacity="0.95" />
        <circle cx="50" cy="50" r="28" stroke={g} strokeWidth={sw * 0.9} fill="none" opacity="0.8" />
      </g>; break;
    case 'spinner': {                     // 3-lobe fidget rotor outline
      const lobes = ring(3, 36, null, -90).map(([x, y], i) =>
        <circle key={i} cx={x} cy={y} r="15" {...common} strokeWidth={sw * 1.4} />);
      body = <g style={spin ? { transformOrigin: '50px 50px', animation: 'hh-spin 6s linear infinite' } : null}>
        {lobes}<circle cx="50" cy="50" r="9" {...common} strokeWidth={sw * 1.4} fill={g} fillOpacity="0.18" />
      </g>; break;
    }
    case 'spiral': case 'vortex': {       // swirling arcs spinning around the well
      const arc = (rad, start, sweep) => {
        const a0 = start * Math.PI / 180, a1 = (start + sweep) * Math.PI / 180;
        const x0 = 50 + Math.cos(a0) * rad, y0 = 50 + Math.sin(a0) * rad;
        const x1 = 50 + Math.cos(a1) * rad, y1 = 50 + Math.sin(a1) * rad;
        const large = sweep > 180 ? 1 : 0;
        return `M${x0} ${y0} A ${rad} ${rad} 0 ${large} 1 ${x1} ${y1}`;
      };
      const arcs = [42, 32, 22].map((rad, i) =>
        <path key={i} d={arc(rad, i * 120, 230)} {...common} strokeWidth={sw * 1.2} opacity={0.9 - i * 0.18} />);
      body = <g style={spin ? { transformOrigin: '50px 50px', animation: `hh-spin ${shape === 'vortex' ? 3.5 : 7}s linear infinite` } : null}>{arcs}</g>;
      break;
    }
    case 'heart': {                       // heart-outline rim
      const d = 'M50 86 C 16 60, 12 30, 32 22 C 44 17, 50 28, 50 33 C 50 28, 56 17, 68 22 C 88 30, 84 60, 50 86 Z';
      body = <path d={d} {...common} strokeWidth={sw * 1.8} fill={g} fillOpacity="0.12" />;
      break;
    }
    case 'lightning': {                   // bolt over the rim
      const d = 'M58 12 L34 52 L48 52 L40 88 L70 44 L54 44 Z';
      body = <g>
        <circle cx="50" cy="50" r="40" {...common} strokeWidth={sw} opacity="0.55" />
        <path d={d} fill={g} stroke={c} strokeWidth={sw * 0.8} strokeLinejoin="round" />
      </g>; break;
    }
    case 'tornado': {                     // stacked funnel rings
      const rows = [[20, 38], [33, 30], [44, 23], [54, 16], [64, 10]];
      body = <g>{rows.map(([cy, rw], i) =>
        <ellipse key={i} cx="50" cy={cy} rx={rw} ry={rw * 0.32} {...common} strokeWidth={sw * 1.1} opacity={0.55 + i * 0.1} />)}
        <line x1="50" y1="22" x2="50" y2="86" stroke={g} strokeWidth={sw * 0.7} opacity="0.5" /></g>;
      break;
    }
    case 'star': {                         // star points around the rim
      body = <g style={spin ? { transformOrigin: '50px 50px', animation: 'hh-spin 14s linear infinite' } : null}>
        <polygon points={poly(ring(10, 44, 22))} {...common} strokeWidth={sw * 1.3} fill={g} fillOpacity="0.1" />
      </g>; break;
    }
    case 'flower': {                       // petal rim
      const petals = ring(8, 34).map(([x, y], i) =>
        <ellipse key={i} cx={x} cy={y} rx="11" ry="17"
          transform={`rotate(${i * 45} ${x} ${y})`} {...common} strokeWidth={sw * 1.1} fill={g} fillOpacity="0.1" />);
      body = <g style={spin ? { transformOrigin: '50px 50px', animation: 'hh-spin 20s linear infinite' } : null}>
        {petals}<circle cx="50" cy="50" r="9" {...common} fill={g} fillOpacity="0.2" />
      </g>; break;
    }
    case 'round': default:                 // clean bowl rim — subtle double ring
      body = <g>
        <circle cx="50" cy="50" r="42" {...common} strokeWidth={sw * 1.4} opacity="0.9" />
      </g>; break;
  }
  return <svg viewBox="0 0 100 100" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 4, ...glowFx }}>{body}</svg>;
}

// the hole itself — flat on the board plane
function HoleVisual({ r, skin }) {
  const HH = window.HH;
  const sk = (HH.SKINS.find(s => s.id === skin)) || HH.SKINS[0];
  const conic = sk.glow === 'conic';
  const hot = conic ? '#fff' : sk.glow;
  const SHAPES = ['round', 'spinner', 'spiral', 'vortex', 'heart', 'lightning', 'tornado', 'star', 'flower', 'ring'];
  const shape = SHAPES.indexOf(sk.shape) >= 0 ? sk.shape : 'round';
  // motif drawn bright so it reads over the dark well; glow tint fills it
  const motifColor = conic ? '#fff' : hot;
  const spin = r >= 18;                     // skip idle rotation on the tiniest previews
  // accretion-disk gradients
  const disk = conic
    ? 'conic-gradient(from 0deg,#FF6B6B,#FFB627,#22C9A0,#22A6F2,#7C5CFC,#FF6B6B)'
    : `conic-gradient(from 0deg, transparent 0deg, ${sk.glow} 40deg, #ffffff 70deg, ${sk.glow} 100deg, transparent 150deg, ${sk.glow}88 250deg, transparent 320deg)`;
  const disk2 = conic
    ? 'conic-gradient(from 180deg,#7C5CFC,#22A6F2,#22C9A0,#FFB627,#FF6B6B,#7C5CFC)'
    : `conic-gradient(from 90deg, transparent 0deg, ${sk.glow}aa 50deg, transparent 120deg, ${sk.glow}66 220deg, #ffffff 280deg, transparent 330deg)`;
  return (
    <div style={{ width: r * 2, height: r * 2, position: 'relative', filter: `drop-shadow(0 0 ${r * 0.5}px ${conic ? 'rgba(255,255,255,0.45)' : sk.glow + '99'})` }}>
      {/* gravitational glow halo */}
      <div style={{ position: 'absolute', inset: -r * 0.55, borderRadius: '50%', pointerEvents: 'none',
        background: `radial-gradient(circle, ${conic ? 'rgba(180,150,255,0.35)' : sk.glow + '55'} 0%, transparent 62%)`, filter: 'blur(3px)' }} />
      {/* outer accretion disk — slow rotation */}
      <div style={{ position: 'absolute', inset: -r * 0.14, borderRadius: '50%', pointerEvents: 'none',
        background: disk, filter: `blur(${Math.max(1.5, r * 0.05)}px) saturate(1.3)`, animation: 'hh-spin 5s linear infinite', opacity: 0.95 }} />
      {/* inner accretion ring — faster, reversed */}
      <div style={{ position: 'absolute', inset: r * 0.06, borderRadius: '50%', pointerEvents: 'none',
        background: disk2, filter: `blur(${Math.max(1, r * 0.035)}px)`, animation: 'hh-spin 3s linear infinite reverse', mixBlendMode: 'screen' }} />
      {/* photon ring — bright thin lensing ring */}
      <div style={{ position: 'absolute', inset: r * 0.16, borderRadius: '50%', pointerEvents: 'none',
        boxShadow: `0 0 ${r * 0.28}px ${r * 0.05}px ${hot}, inset 0 0 ${r * 0.12}px ${r * 0.03}px ${hot}`,
        border: `${Math.max(1, r * 0.04)}px solid ${conic ? '#fff' : hot}` }} />
      {/* event horizon — deep 3D well */}
      <div style={{ position: 'absolute', inset: r * 0.2, borderRadius: '50%',
        background: 'radial-gradient(circle at 50% 42%, #1a1024 0%, #0a0612 38%, #000 72%, #000 100%)',
        boxShadow: 'inset 0 6px 18px rgba(0,0,0,0.95), inset 0 -2px 8px rgba(120,90,200,0.25)' }} />
      {/* shape motif — original SVG overlay, reads bright over the dark well */}
      <ShapeMotif shape={shape} c={motifColor} g={conic ? '#7C5CFC' : sk.glow} spin={spin} />
      {/* lensed hot-spot (Doppler beaming) */}
      <div style={{ position: 'absolute', left: '50%', top: '11%', width: r * 0.6, height: r * 0.34, transform: 'translateX(-50%)',
        borderRadius: '50%', background: `radial-gradient(circle, ${conic ? '#fff' : hot}, transparent 70%)`, filter: 'blur(2px)', pointerEvents: 'none', animation: 'hh-accbeam 4s ease-in-out infinite' }} />
      {sk.googly && <>
        <div style={{ position: 'absolute', left: '26%', top: '24%', width: r * 0.42, height: r * 0.42, borderRadius: '50%', background: '#fff', display: 'grid', placeItems: 'center', zIndex: 3, boxShadow: '0 1px 3px rgba(0,0,0,0.4)' }}>
          <div style={{ width: '44%', height: '44%', borderRadius: '50%', background: '#1B2740' }} /></div>
        <div style={{ position: 'absolute', right: '26%', top: '24%', width: r * 0.42, height: r * 0.42, borderRadius: '50%', background: '#fff', display: 'grid', placeItems: 'center', zIndex: 3, boxShadow: '0 1px 3px rgba(0,0,0,0.4)' }}>
          <div style={{ width: '44%', height: '44%', borderRadius: '50%', background: '#1B2740' }} /></div>
      </>}
    </div>
  );
}

// scatter objects for a world; difficulty-scaled per level
function makeObjects(world, level, countOverride, arena) {
  const props = world.props;
  const goal = (window.HH.LEVELS.flat().find(l => l[0] === level) || [])[2] || 'sweep';
  const D = window.HH.DIFFICULTY(level);
  const count = countOverride || (arena ? Math.min(64, Math.round(D.count * 1.5)) : D.count);
  const arr = [];
  let seed = level * 9301 + 49297;
  const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
  // grid for arena (city blocks); radial-ish for the disc preview
  const cols = Math.ceil(Math.sqrt(count));
  for (let i = 0; i < count; i++) {
    let t = props[(rnd() * props.length) | 0];
    if (rnd() < D.bigBias) { const big = props.filter(p => ((PROP_DEF[p] || {}).tier || 1) >= 2); if (big.length) t = big[(rnd() * big.length) | 0]; }
    let x, y;
    if (arena) {
      const gx = i % cols, gy = (i / cols) | 0;
      x = 0.06 + (gx + 0.5 + (rnd() - 0.5) * 0.7) / cols * 0.88;
      y = 0.06 + (gy + 0.5 + (rnd() - 0.5) * 0.7) / cols * 0.88;
    } else {
      const ang = rnd() * Math.PI * 2, rad = 0.18 + rnd() * 0.74;
      x = 0.5 + Math.cos(ang) * rad * 0.46; y = 0.5 + Math.sin(ang) * rad * 0.46;
    }
    const gold = goal === 'treasure' && rnd() < 0.5;
    const avoid = goal === 'avoid' ? rnd() < 0.28 : rnd() < D.hazard;
    arr.push({
      id: 'o' + i, type: t, x, y,
      tier: (PROP_DEF[t] || {}).tier || 1, gold, avoid, eaten: false,
      size: 30 + ((PROP_DEF[t] || {}).tier || 1) * 8 + rnd() * 6,
      rot: (rnd() - 0.5) * 10,
    });
  }
  return arr;
}

function Board({ world, level = 1, skin = 'classic', interactive = true, onUpdate, onEmpty, small = false,
  scoreMult = 1, reachBoost = 0, bombSignal = 0, growSignal = 0 }) {
  const stageRef = useRefB(null);
  const planeRef = useRefB(null);
  const PLANE = small ? 300 : 1120;            // world size (px) — large for camera-follow arena
  const VIEW = small ? 300 : 470;              // visible rotated-plane window
  const arena = !small;
  const [objects, setObjects] = useStateB(() => makeObjects(world, level, small ? 12 : undefined, arena));
  const [hole, setHole] = useStateB({ x: 0.5, y: 0.62, r: small ? 26 : 34, tier: 1 });
  const holeRef = useRefB(hole); holeRef.current = hole;
  const reachRef = useRefB(0); reachRef.current = reachBoost;
  const multRef = useRefB(1); multRef.current = scoreMult;
  const objectsRef = useRefB(objects); objectsRef.current = objects;
  const target = useRefB({ x: 0.5, y: 0.62 });
  const seekId = useRefB(null);
  const [fx, setFx] = useStateB([]);          // gulp puffs, rings
  const [floats, setFloats] = useStateB([]);
  const [combo, setCombo] = useStateB(0);
  const comboTimer = useRefB(null);
  const [score, setScore] = useStateB(0);
  const eatenCount = useRefB(0);
  const fxId = useRefB(0);
  const dragging = useRefB(false);
  const touched = useRefB(false);

  // report up
  useEffectB(() => { onUpdate && onUpdate({ score, combo, eaten: eatenCount.current, total: objects.length, holeTier: hole.tier, remaining: objects.filter(o => !o.eaten).length }); }, [score, combo, hole.tier, objects]);

  const pushFx = (x, y, kind, color) => { const id = fxId.current++; setFx(f => [...f, { id, x, y, kind, color }]); setTimeout(() => setFx(f => f.filter(p => p.id !== id)), 700); };
  const pushFloat = (x, y, text, color) => { const id = fxId.current++; setFloats(f => [...f, { id, x, y, text, color }]); setTimeout(() => setFloats(f => f.filter(p => p.id !== id)), 850); };

  // ---- main loop: ease hole to target, detect eats, grow ----
  useEffectB(() => {
    let stop = false;
    const tick = () => {
      if (stop) return;
      const h = holeRef.current;
      // ---- auto-seek: when not yet touched (or non-interactive), aim at nearest edible object ----
      const auto = !interactive || !touched.current;
      if (auto) {
        const objs = objectsRef.current;
        const cur = objs.find(o => o.id === seekId.current && !o.eaten && o.tier <= h.tier);
        if (!cur) {
          // pick nearest uneaten object we can eat (fallback: any uneaten)
          let best = null, bd = Infinity;
          const edible = objs.filter(o => !o.eaten && o.tier <= h.tier);
          const pool = edible.length ? edible : objs.filter(o => !o.eaten);
          pool.forEach(o => { const d = Math.hypot(o.x - h.x, o.y - h.y); if (d < bd) { bd = d; best = o; } });
          if (best) { seekId.current = best.id; target.current = { x: best.x, y: best.y }; }
        } else {
          target.current = { x: cur.x, y: cur.y };
        }
      }
      const tg = target.current;
      // weighty spring ease
      const nx = h.x + (tg.x - h.x) * 0.16;
      const ny = h.y + (tg.y - h.y) * 0.16;

      // eat detection in plane space
      let ate = false, grew = false;
      setObjects(prev => {
        let changed = false;
        const next = prev.map(o => {
          if (o.eaten) return o;
          const dx = (o.x - nx) * PLANE, dy = (o.y - ny) * PLANE;
          const dist = Math.hypot(dx, dy);
          const reach = h.r + o.size * 0.18 + reachRef.current;
          if (dist < reach) {
            if (o.tier > h.tier) { // too big — resist/wobble
              if (!o.wobble) { changed = true; return { ...o, wobble: true }; }
              return o;
            }
            changed = true; ate = true;
            // score & combo (frenzy multiplier)
            const base = (1 + o.tier * (o.gold ? 4 : 1)) * (multRef.current || 1);
            eatenCount.current += 1;
            return { ...o, eaten: true, _val: base };
          }
          if (o.wobble && dist >= reach) { changed = true; return { ...o, wobble: false }; }
          return o;
        });
        return changed ? next : prev;
      });

      if (nx !== h.x || ny !== h.y) setHole(hh => ({ ...hh, x: nx, y: ny }));
    };
    const iv = setInterval(tick, 33);
    return () => { stop = true; clearInterval(iv); };
  }, [PLANE, interactive]);

  // handle eaten side-effects (score, fx, combo, grow) — watch objects flagged eaten without _done
  useEffectB(() => {
    const fresh = objects.filter(o => o.eaten && !o._done);
    if (!fresh.length) return;
    let addScore = 0, anyAvoid = false;
    fresh.forEach(o => {
      addScore += o._val * 10;
      const px = (o.x - 0.5) * PLANE, py = (o.y - 0.5) * PLANE;
      pushFx(o.x, o.y, 'gulp', world.accent);
      pushFloat(o.x, o.y, (o.avoid ? '✕' : '+') + (o.avoid ? '' : o._val * 10), o.avoid ? window.HH.C.coral : '#fff');
      if (o.avoid) anyAvoid = true;
    });
    setScore(s => s + addScore);
    // combo
    setCombo(c => {
      const nc = c + fresh.length;
      clearTimeout(comboTimer.current);
      comboTimer.current = setTimeout(() => setCombo(0), 1600);
      return nc;
    });
    // grow at thresholds
    const ec = eatenCount.current;
    setHole(h => {
      let r = h.r, tier = h.tier, did = false;
      const threshold = small ? 4 : 5;
      const targetTier = Math.min(3, 1 + Math.floor(ec / threshold));
      if (targetTier > tier) { tier = targetTier; r = (small ? 26 : 34) + (tier - 1) * (small ? 9 : 13); did = true; }
      if (did) pushFx(h.x, h.y, 'grow', window.HH.C.mint);
      return { ...h, r, tier };
    });
    // mark done
    setObjects(prev => prev.map(o => o.eaten && !o._done ? { ...o, _done: true } : o));
  }, [objects]);

  // empty board → notify (level sweep complete)
  useEffectB(() => {
    if (objects.length && objects.every(o => o.eaten)) { const t = setTimeout(() => onEmpty && onEmpty(score), 700); return () => clearTimeout(t); }
  }, [objects, score]);

  // ---- (auto-seek handled inside the main animation loop above) ----

  // reset when level/world changes
  useEffectB(() => {
    setObjects(makeObjects(world, level, small ? 12 : undefined, arena));
    setHole({ x: 0.5, y: 0.62, r: small ? 26 : 34, tier: 1 });
    target.current = { x: 0.5, y: 0.62 };
    setScore(0); setCombo(0); eatenCount.current = 0; touched.current = false;
  }, [world.id, level]);

  // ---- power-up: BLAST — devour everything in a wide ring ----
  useEffectB(() => {
    if (!bombSignal) return;
    const h = holeRef.current;
    let banked = 0, n = 0;
    setObjects(prev => prev.map(o => {
      if (o.eaten) return o;
      const d = Math.hypot((o.x - h.x) * PLANE, (o.y - h.y) * PLANE);
      if (d < 150) { n++; banked += (1 + o.tier) * (multRef.current || 1); eatenCount.current += 1;
        pushFx(o.x, o.y, 'gulp', world.accent); return { ...o, eaten: true, _val: 0, _done: true }; }
      return o;
    }));
    if (n) { setScore(s => s + banked * 10); pushFx(h.x, h.y, 'grow', window.HH.C.coral);
      setFx(f => [...f, { id: fxId.current++, x: h.x, y: h.y, kind: 'blast', color: world.accent }]);
      setTimeout(() => setFx(f => f.filter(p => p.kind !== 'blast')), 600); }
  }, [bombSignal]);

  // ---- power-up: MEGA GROW — jump a size ----
  useEffectB(() => {
    if (!growSignal) return;
    setHole(h => { const tier = Math.min(3, h.tier + 1); const r = (small ? 26 : 34) + (tier - 1) * (small ? 9 : 13); pushFx(h.x, h.y, 'grow', window.HH.C.mint); return { ...h, tier, r }; });
  }, [growSignal]);

  // ---- pointer drag → set target in plane coords ----
  const onPointer = useCallbackB((e) => {
    if (!interactive || !dragging.current) return;
    const plane = planeRef.current; if (!plane) return;
    const rect = plane.getBoundingClientRect();
    const cx = e.clientX ?? (e.touches && e.touches[0].clientX);
    const cy = e.clientY ?? (e.touches && e.touches[0].clientY);
    // map screen → plane: x linear; y compressed by tilt (divide by cos)
    let px = (cx - rect.left) / rect.width;
    let yRel = (cy - (rect.top + rect.height / 2)) / rect.height;
    yRel = yRel / Math.cos(TILT * Math.PI / 180) * 0.62; // perspective compensation
    let py = 0.5 + yRel;
    target.current = { x: Math.max(0.08, Math.min(0.92, px)), y: Math.max(0.1, Math.min(0.92, py)) };
  }, [interactive]);

  useEffectB(() => {
    if (!interactive) return;
    const down = (e) => { dragging.current = true; touched.current = true; onPointer(e); };
    const up = () => { dragging.current = false; };
    const stage = stageRef.current;
    stage.addEventListener('pointerdown', down);
    window.addEventListener('pointermove', onPointer);
    window.addEventListener('pointerup', up);
    return () => { stage.removeEventListener('pointerdown', down); window.removeEventListener('pointermove', onPointer); window.removeEventListener('pointerup', up); };
  }, [interactive, onPointer]);

  // camera ease-back as hole grows
  const camScale = 1 - (hole.tier - 1) * 0.06;

  const toPlane = (x, y) => ({ left: x * PLANE, top: y * PLANE });

  return (
    <div ref={stageRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden', touchAction: 'none', cursor: interactive ? 'grab' : 'default',
      background: world.night ? `linear-gradient(180deg, ${world.sky[0]}, ${world.sky[1]})` : `linear-gradient(180deg, ${world.sky[0]} 0%, ${world.sky[1]} 62%)` }}>
      {/* sun / ambient bloom */}
      <div style={{ position: 'absolute', top: '6%', left: '64%', width: 160, height: 160, borderRadius: '50%',
        background: world.night ? 'radial-gradient(circle, rgba(33,230,255,0.4), transparent 70%)' : 'radial-gradient(circle, rgba(255,240,200,0.9), transparent 70%)', filter: 'blur(8px)' }} />
      {/* horizon depth band */}
      <div style={{ position: 'absolute', left: 0, right: 0, top: small ? '34%' : '38%', height: 90, pointerEvents: 'none',
        background: `radial-gradient(120% 100% at 50% 100%, ${shade(world.ground, world.night ? 0.04 : -0.02)}55, transparent 72%)`, filter: 'blur(8px)' }} />
      {/* ambient weather */}
      <Weather world={world} n={small ? 0.6 : 1} />
      {/* vignette */}
      <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 44,
        boxShadow: `inset 0 0 ${small ? 70 : 110}px ${world.night ? 'rgba(0,0,0,0.5)' : 'rgba(20,30,60,0.18)'}` }} />

      {/* tilted board */}
      <div style={{ position: 'absolute', left: '50%', top: small ? '52%' : '56%', transform: `translate(-50%,-50%) perspective(900px) scale(${camScale})`, transition: 'transform .7s cubic-bezier(.65,0,.35,1)' }}>
        <div ref={planeRef} style={{ width: PLANE, height: PLANE, position: 'relative', transform: `rotateX(${TILT}deg)`, transformStyle: 'preserve-3d' }}>
          {/* ground disc — layered depth + grass/biome texture */}
          <div style={{ position: 'absolute', inset: '-11%', borderRadius: '50%', background: shade(world.groundDark, -0.12),
            boxShadow: `0 26px 50px rgba(0,0,0,0.4)` }} />
          <div style={{ position: 'absolute', inset: '-9%', borderRadius: '50%',
            background: `radial-gradient(circle at 42% 34%, ${shade(world.ground, 0.1)} 0%, ${world.ground} 52%, ${shade(world.groundDark, -0.02)} 100%)`,
            boxShadow: `inset 0 0 70px ${shade(world.groundDark, -0.08)}` }} />
          {/* biome texture (grass blades / speckle) */}
          <div style={{ position: 'absolute', inset: '-9%', borderRadius: '50%', opacity: world.night ? 0.18 : 0.3, mixBlendMode: world.night ? 'screen' : 'overlay', pointerEvents: 'none',
            backgroundImage: `radial-gradient(2px 5px at 20% 30%, ${shade(world.ground,0.2)}, transparent), radial-gradient(2px 6px at 62% 24%, ${shade(world.ground,0.16)}, transparent), radial-gradient(2px 5px at 38% 66%, ${shade(world.ground,0.2)}, transparent), radial-gradient(2px 6px at 78% 62%, ${shade(world.ground,0.16)}, transparent), radial-gradient(2px 5px at 50% 48%, ${shade(world.ground,0.18)}, transparent), radial-gradient(2px 6px at 28% 82%, ${shade(world.ground,0.18)}, transparent)`,
            backgroundSize: '46px 46px' }} />
          {/* decorative border ring — beveled */}
          <div style={{ position: 'absolute', inset: '-9%', borderRadius: '50%', border: `11px solid ${world.ring}`,
            boxShadow: `0 0 0 5px ${shade(world.ring,-0.16)}, inset 0 3px 6px rgba(255,255,255,0.4), inset 0 -4px 8px rgba(0,0,0,0.25)` }} />
          {/* sun sheen */}
          <div style={{ position: 'absolute', inset: '0%', borderRadius: '50%', pointerEvents: 'none', background: `radial-gradient(circle at 38% 30%, ${world.night ? 'rgba(120,90,200,0.18)' : 'rgba(255,255,255,0.34)'}, transparent 55%)` }} />

          {/* shadows (on plane) */}
          {objects.map(o => !o.eaten && (
            <div key={o.id + 's'} style={{ position: 'absolute', left: o.x * PLANE, top: o.y * PLANE, width: o.size * 0.7, height: o.size * 0.28,
              transform: 'translate(-50%,-30%)', borderRadius: '50%', background: 'rgba(0,0,0,0.18)', filter: 'blur(2px)' }} />
          ))}

          {/* the hole (flat on plane) */}
          <div style={{ position: 'absolute', left: hole.x * PLANE, top: hole.y * PLANE, transform: 'translate(-50%,-50%)', zIndex: 5 }}>
            <HoleVisual r={hole.r} skin={skin} />
          </div>

          {/* growth ring fx (on plane) */}
          {fx.filter(f => f.kind === 'grow').map(f => (
            <div key={f.id} style={{ position: 'absolute', left: f.x * PLANE, top: f.y * PLANE, transform: 'translate(-50%,-50%)',
              width: 20, height: 20, borderRadius: '50%', border: `3px solid ${f.color}`, animation: 'hh-ring .6s cubic-bezier(.22,1,.36,1) forwards', zIndex: 6 }} />
          ))}
          {/* blast ring (power-up) */}
          {fx.filter(f => f.kind === 'blast').map(f => (
            <div key={f.id} style={{ position: 'absolute', left: f.x * PLANE, top: f.y * PLANE, transform: 'translate(-50%,-50%)',
              width: 20, height: 20, borderRadius: '50%', border: `5px solid ${f.color}`, boxShadow: `0 0 30px ${f.color}`,
              animation: 'hh-shockwave .6s cubic-bezier(.16,1,.3,1) forwards', zIndex: 7 }} />
          ))}

          {/* objects (billboarded upright) */}
          {objects.map(o => (
            <div key={o.id} style={{ position: 'absolute', left: o.x * PLANE, top: o.y * PLANE, zIndex: Math.round(o.y * 100) + 10,
              transform: `translate(-50%,-100%) rotateX(${-TILT}deg) rotate(${o.rot}deg)`, transformOrigin: 'center bottom',
              animation: o.eaten ? 'hh-eat .34s cubic-bezier(.5,0,.75,0) forwards' : o.wobble ? 'hh-wobble .3s ease-in-out infinite' : 'none',
              pointerEvents: 'none' }}>
              <Prop type={o.type} size={o.size} gold={o.gold} avoid={o.avoid} />
            </div>
          ))}

          {/* gulp puffs (billboarded) */}
          {fx.filter(f => f.kind === 'gulp').map(f => (
            <div key={f.id} style={{ position: 'absolute', left: f.x * PLANE, top: f.y * PLANE, zIndex: 200,
              transform: `translate(-50%,-50%) rotateX(${-TILT}deg)` }}>
              {[0,1,2,3,4].map(i => <span key={i} style={{ position: 'absolute', width: 8, height: 8, borderRadius: '50%', background: 'rgba(255,255,255,0.9)',
                '--px': `${Math.cos(i/5*6.28)*22}px`, '--py': `${Math.sin(i/5*6.28)*22}px`, animation: 'hh-puff .5s ease-out forwards' }} />)}
              <span style={{ position: 'absolute', left: -7, top: -7, width: 14, height: 14, color: f.color,
                background: `radial-gradient(circle, ${f.color}, transparent 65%)`, borderRadius: '50%', animation: 'hh-sparkle .55s ease-out forwards' }} />
            </div>
          ))}
        </div>
      </div>

      {/* score floats (screen-space, above board) */}
      {floats.map(f => (
        <div key={f.id} style={{ position: 'absolute', left: '50%', top: small ? '46%' : '50%',
          transform: `translate(calc(-50% + ${(f.x - 0.5) * PLANE * camScale}px), ${(f.y - 0.5) * PLANE * 0.55 * camScale}px)`, zIndex: 300,
          color: f.color, fontFamily: 'Fredoka, sans-serif', fontWeight: 700, fontSize: 18, textShadow: '0 2px 6px rgba(0,0,0,0.35)',
          animation: 'hh-float .85s ease-out forwards', pointerEvents: 'none' }}>{f.text}</div>
      ))}
    </div>
  );
}

Object.assign(window, { Board, HoleVisual, makeObjects });
