// GameCanvas — plays a single hole on a 360×540 canvas.
// Owns sim state + strokes; reports events up to the shell.
const { useState, useEffect, useRef, useCallback } = React;

function GameCanvas({
  defKey,          // change to reset (new hole / redo)
  def,             // hole definition
  palette,         // green palette
  intro,           // {title, sub, color, tag} → plays card transition on reset
  sound,           // SoundEngine
  hintOn, hardMode,
  surveyArmed, surveysLeft,
  initState,       // optional resume: {x, y, strokes, seq}
  onUpdate,        // ({strokes}) after a stroke fires
  onSurveyFired,   // () survey consumed
  onBallStopped,   // ({x, y, strokes, seq}) ball at rest, not holed — persist point
  onFinish,        // ({strokes, pickedUp, seq})
  onPhase,         // (phase) aim | rolling | holed | transition
}) {
  const B = window.BBP;
  const canvasRef = useRef(null);
  const stateRef = useRef(null);
  const pointerRef = useRef({ down: false, startX: 0, startY: 0, curX: 0, curY: 0 });
  const transitionRef = useRef({ start: 0, title: "", sub: "", color: "#ffeb3b", tag: "" });
  const timeRef = useRef({ last: 0, acc: 0 });
  const strokesRef = useRef(0);
  const seqRef = useRef([]);
  const [phase, setPhase] = useState("transition");

  const propsRef = useRef({});
  propsRef.current = { def, hintOn, hardMode, surveyArmed, surveysLeft, onUpdate, onSurveyFired, onBallStopped, onFinish };

  const resetHole = useCallback(() => {
    const init = initState || null;
    stateRef.current = {
      ball: { x: init ? init.x : def.ball.x, y: init ? init.y : def.ball.y, vx: 0, vy: 0 },
      sim: { pcd: 0, lcd: 0, settle: 0, frames: 0 },
      tracers: [],
      surveyTracers: [],
      currentPath: [],
      rolling: false,
      holed: false,
      survey: null,
      floats: [],
      lipFlash: 0,
      replayTick: 0,
    };
    strokesRef.current = init ? init.strokes : 0;
    seqRef.current = init ? init.seq.slice() : [];
    pointerRef.current.down = false;
    if (intro) {
      transitionRef.current = { start: performance.now(), ...intro };
      setPhase("transition");
    } else {
      setPhase("aim");
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defKey]);

  useEffect(() => { resetHole(); }, [resetHole]);
  useEffect(() => { if (onPhase) onPhase(phase); }, [phase, onPhase]);

  const finishHole = (pickedUp) => {
    const finalStrokes = pickedUp ? def.par + 4 : strokesRef.current;
    stateRef.current.holed = true;
    setPhase("holed");
    propsRef.current.onFinish({ strokes: finalStrokes, pickedUp, seq: seqRef.current.slice() });
  };

  const fireShot = (vx, vy, power) => {
    const s = stateRef.current;
    s.ball.vx = vx;
    s.ball.vy = vy;
    s.sim = { pcd: 0, lcd: 0, settle: 0, frames: 0 };
    s.rolling = true;
    s.currentPath = [{ x: s.ball.x, y: s.ball.y }];
    sound.putt(power);
    strokesRef.current++;
    seqRef.current.push("P");
    propsRef.current.onUpdate({ strokes: strokesRef.current });
    setPhase("rolling");
  };

  const fireSurvey = (vx, vy) => {
    const s = stateRef.current;
    s.survey = {
      b: { x: s.ball.x, y: s.ball.y, vx, vy },
      sim: { pcd: 0, lcd: 0, settle: 0, frames: 0 },
      path: [{ x: s.ball.x, y: s.ball.y }],
    };
    sound.survey();
    seqRef.current.push("S");
    propsRef.current.onSurveyFired();
    setPhase("rolling");
  };

  // ============ GAME LOOP ============
  useEffect(() => {
    let raf;
    timeRef.current.last = performance.now();
    timeRef.current.acc = 0;

    const stepGame = () => {
      const s = stateRef.current;
      if (!s) return;
      const P = propsRef.current;

      s.floats = s.floats.filter((f) => --f.frames > 0);
      if (s.lipFlash > 0) s.lipFlash--;

      if (s.survey) {
        const ev = B.stepBall(s.survey.b, P.def, s.survey.sim);
        const lp = s.survey.path[s.survey.path.length - 1];
        if (ev & B.EV_TELEPORTED) s.survey.path.push({ x: s.survey.b.x, y: s.survey.b.y, teleport: true });
        else if (!lp || Math.hypot(lp.x - s.survey.b.x, lp.y - s.survey.b.y) > 1.8) {
          s.survey.path.push({ x: s.survey.b.x, y: s.survey.b.y });
        }
        if (ev & B.EV_BOUNCED) sound.wall();
        if (ev & B.EV_LIPPED) { sound.lip(); s.lipFlash = 16; }
        if (ev & B.EV_TELEPORTED) sound.portal();
        if (ev & (B.EV_HOLED | B.EV_STOPPED)) {
          if (ev & B.EV_HOLED) {
            s.floats.push({ x: P.def.hole.x, y: P.def.hole.y - 18, text: "IT WOULD DROP!", color: "#9fe8ff", frames: 110 });
            sound.lip();
          }
          s.surveyTracers.push({ points: s.survey.path });
          if (s.surveyTracers.length > 6) s.surveyTracers.shift();
          s.survey = null;
          setPhase("aim");
        }
        return;
      }

      if (s.rolling) {
        const ev = B.stepBall(s.ball, P.def, s.sim);
        const lp = s.currentPath[s.currentPath.length - 1];
        if (ev & B.EV_TELEPORTED) s.currentPath.push({ x: s.ball.x, y: s.ball.y, teleport: true });
        else if (!lp || Math.hypot(lp.x - s.ball.x, lp.y - s.ball.y) > 1.8) {
          s.currentPath.push({ x: s.ball.x, y: s.ball.y });
        }
        if (ev & B.EV_BOUNCED) sound.wall();
        if (ev & B.EV_TELEPORTED) sound.portal();
        if (ev & B.EV_LIPPED) {
          sound.lip();
          s.lipFlash = 16;
          s.floats.push({ x: P.def.hole.x, y: P.def.hole.y - 16, text: "LIP OUT!", color: "#ffeb3b", frames: 80 });
        }

        if (ev & (B.EV_HOLED | B.EV_STOPPED)) {
          const holed = (ev & B.EV_HOLED) !== 0;
          s.rolling = false;
          s.tracers.push({ points: s.currentPath, holed });
          s.currentPath = [];

          if (holed) {
            sound.holed();
            finishHole(false);
          } else if (strokesRef.current >= P.def.par + 4) {
            sound.pickup();
            finishHole(true);
          } else {
            P.onBallStopped({ x: s.ball.x, y: s.ball.y, strokes: strokesRef.current, seq: seqRef.current.slice() });
            setPhase("aim");
          }
        }
      }
    };

    const loop = (now) => {
      const t = timeRef.current;
      let dt = now - t.last;
      t.last = now;
      if (dt > 200) dt = 200;
      t.acc += dt;
      let n = 0;
      while (t.acc >= 16.667 && n < 3) {
        if (phase === "rolling") stepGame();
        else {
          const s = stateRef.current;
          if (s) {
            s.floats = s.floats.filter((f) => --f.frames > 0);
            if (s.lipFlash > 0) s.lipFlash--;
            if (phase === "holed") s.replayTick++;
          }
        }
        t.acc -= 16.667;
        n++;
      }
      if (n === 3) t.acc = 0;

      if (phase === "transition") {
        const el = now - transitionRef.current.start;
        drawTransition(el);
        if (el > 2400) setPhase("aim");
      } else {
        draw();
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, defKey, hintOn, hardMode, surveyArmed, surveysLeft, palette]);

  // ============ INPUT ============
  const getPt = (e) => {
    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    return {
      x: (e.clientX - rect.left) * (B.W / rect.width),
      y: (e.clientY - rect.top) * (B.H / rect.height),
    };
  };

  const onPointerDown = (e) => {
    const s = stateRef.current;
    if (!s) return;
    sound.ensure();
    if (phase !== "aim" || s.rolling || s.holed) return;
    const { x, y } = getPt(e);
    pointerRef.current = { down: true, startX: x, startY: y, curX: x, curY: y };
    e.currentTarget.setPointerCapture(e.pointerId);
  };

  const onPointerMove = (e) => {
    if (!pointerRef.current.down) return;
    const { x, y } = getPt(e);
    pointerRef.current.curX = x;
    pointerRef.current.curY = y;
  };

  const onPointerUp = () => {
    const p = pointerRef.current;
    const s = stateRef.current;
    if (!p.down || !s || s.rolling || s.holed || phase !== "aim") { p.down = false; return; }
    p.down = false;
    const dx = p.startX - p.curX;
    const dy = p.startY - p.curY;
    const dist = Math.hypot(dx, dy);
    if (dist < 6) return;
    const power = Math.min(dist / 10, B.MAX_POWER);
    const angle = Math.atan2(dy, dx);
    const vx = Math.cos(angle) * power;
    const vy = Math.sin(angle) * power;

    if (surveyArmed && surveysLeft > 0) {
      fireSurvey(vx, vy);
    } else {
      fireShot(vx, vy, power);
    }
  };

  // ============ DRAWING ============
  const drawTracer = (ctx, points, color, width) => {
    if (points.length < 2) return;
    ctx.strokeStyle = color;
    ctx.lineWidth = width;
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.beginPath();
    let moving = false;
    for (const pt of points) {
      if (pt.teleport) {
        ctx.stroke();
        ctx.beginPath();
        moving = false;
        continue;
      }
      if (!moving) { ctx.moveTo(pt.x, pt.y); moving = true; }
      else ctx.lineTo(pt.x, pt.y);
    }
    ctx.stroke();
  };

  const drawBallSprite = (ctx, x, y, r) => {
    ctx.fillStyle = "rgba(0,0,0,0.4)";
    ctx.beginPath();
    ctx.ellipse(x + 1, y + 2.5, r, r * 0.55, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#ffffff";
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#dcdcdc";
    ctx.beginPath();
    ctx.arc(x + 1.2, y + 1.2, Math.max(1, r - 2), 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(x - 2, y - 2, 1, 1);
  };

  const traceRoundedRect = (ctx) => {
    const { x, y, w, h, r } = B.GREEN;
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.arcTo(x + w, y, x + w, y + r, r);
    ctx.lineTo(x + w, y + h - r);
    ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
    ctx.lineTo(x + r, y + h);
    ctx.arcTo(x, y + h, x, y + h - r, r);
    ctx.lineTo(x, y + r);
    ctx.arcTo(x, y, x + r, y, r);
  };

  const draw = () => {
    const canvas = canvasRef.current;
    if (!canvas || !stateRef.current) return;
    const ctx = canvas.getContext("2d");
    const s = stateRef.current;
    const p = pointerRef.current;
    const W = B.W, H = B.H;

    ctx.fillStyle = "#0d0d18";
    ctx.fillRect(0, 0, W, H);

    ctx.fillStyle = "#f4f4d6";
    const stars = [[24, 14], [82, 24], [156, 10], [230, 22], [300, 14], [340, 28]];
    for (const [x, y] of stars) ctx.fillRect(x, y, 2, 2);
    ctx.fillStyle = "#a8a880";
    ctx.fillRect(60, 18, 1, 1);
    ctx.fillRect(210, 8, 1, 1);
    ctx.fillRect(280, 26, 1, 1);

    for (let x = 0; x < W; x += 4) {
      for (let y = 0; y < H; y += 4) {
        if (!window.BBP || !isOnGreenCached(x + 2, y + 2)) {
          const v = (x * 73 + y * 19) % 7;
          ctx.fillStyle = v === 0 ? "#3d2817" : v < 3 ? "#2d1d10" : "#1e1409";
          ctx.fillRect(x, y, 4, 4);
        }
      }
    }

    ctx.save();
    ctx.beginPath();
    traceRoundedRect(ctx);
    ctx.clip();

    ctx.fillStyle = palette.base;
    ctx.fillRect(B.GREEN.x, B.GREEN.y, B.GREEN.w, B.GREEN.h);
    ctx.fillStyle = palette.dither;
    for (let x = B.GREEN.x; x < B.GREEN.x + B.GREEN.w; x += 4) {
      for (let y = B.GREEN.y; y < B.GREEN.y + B.GREEN.h; y += 4) {
        if (((x / 4) + (y / 4)) % 2 === 0) ctx.fillRect(x, y, 2, 2);
      }
    }
    ctx.fillStyle = palette.dark;
    for (let i = 0; i < 90; i++) {
      ctx.fillRect(B.GREEN.x + ((i * 37) % B.GREEN.w), B.GREEN.y + ((i * 53) % B.GREEN.h), 2, 2);
    }
    ctx.fillStyle = palette.light;
    for (let i = 0; i < 50; i++) {
      ctx.fillRect(B.GREEN.x + ((i * 91) % B.GREEN.w), B.GREEN.y + ((i * 41) % B.GREEN.h), 1, 1);
    }

    // Survey tracers (cyan intel)
    s.surveyTracers.forEach((tr, i) => {
      const fromEnd = s.surveyTracers.length - 1 - i;
      let a = fromEnd === 0 ? 0.55 : 0.28;
      if (hardMode) a = fromEnd === 0 ? 0.5 : fromEnd === 1 ? 0.2 : 0;
      if (a > 0.01) drawTracer(ctx, tr.points, `rgba(140, 225, 255, ${a})`, 1.5);
    });

    // Putt tracers
    s.tracers.forEach((tr, i) => {
      const fromEnd = s.tracers.length - 1 - i;
      let alpha;
      if (hardMode) {
        alpha = fromEnd === 0 ? 1.0 : fromEnd === 1 ? 0.45 : fromEnd === 2 ? 0.18 : 0;
      } else {
        alpha = fromEnd === 0 ? 0.82 : 0.32;
      }
      if (alpha <= 0.01) return;
      const color = fromEnd === 0
        ? `rgba(255, 235, 120, ${alpha})`
        : `rgba(245, 245, 230, ${alpha * 0.7})`;
      drawTracer(ctx, tr.points, color, fromEnd === 0 ? 2.2 : 1.5);
    });

    if (s.currentPath.length > 1) drawTracer(ctx, s.currentPath, "rgba(255, 235, 120, 1)", 2.2);
    if (s.survey && s.survey.path.length > 1) drawTracer(ctx, s.survey.path, "rgba(140, 225, 255, 0.9)", 1.8);

    // Replay murmuration on the holed screen
    if (phase === "holed") {
      const all = [...s.surveyTracers, ...s.tracers];
      all.forEach((tr, i) => {
        const pts = tr.points.filter((q) => !q.teleport);
        if (pts.length < 2) return;
        const idx = (s.replayTick * 2 + i * 13) % pts.length;
        const q = pts[idx];
        const isSurvey = i < s.surveyTracers.length;
        ctx.fillStyle = isSurvey ? "rgba(140,225,255,0.9)" : "rgba(255,235,120,0.95)";
        ctx.beginPath();
        ctx.arc(q.x, q.y, 3, 0, Math.PI * 2);
        ctx.fill();
      });
    }

    // Ghost preview (flat-green hint)
    if (hintOn && phase === "aim" && p.down) {
      const dx = p.startX - p.curX, dy = p.startY - p.curY;
      const dragDist = Math.hypot(dx, dy);
      if (dragDist > 6) {
        const power = Math.min(dragDist / 10, B.MAX_POWER);
        const angle = Math.atan2(dy, dx);
        const ghost = B.simulateGhost(s.ball.x, s.ball.y, Math.cos(angle) * power, Math.sin(angle) * power);
        ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
        for (let i = 0; i < ghost.length; i += 4) {
          ctx.fillRect(ghost[i].x - 1, ghost[i].y - 1, 2, 2);
        }
      }
    }

    // Hole + flag
    ctx.fillStyle = "#070707";
    ctx.beginPath();
    ctx.arc(def.hole.x, def.hole.y, B.HOLE_R, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = "#1f1f1f";
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.arc(def.hole.x, def.hole.y, B.HOLE_R, 0, Math.PI * 2);
    ctx.stroke();
    ctx.fillStyle = "#e8e8e8";
    ctx.fillRect(def.hole.x - 1, def.hole.y - 24, 2, 24);
    ctx.fillStyle = "#e84d3c";
    ctx.fillRect(def.hole.x + 1, def.hole.y - 24, 11, 8);
    ctx.fillStyle = "#b83225";
    ctx.fillRect(def.hole.x + 1, def.hole.y - 16, 11, 1);

    if (s.lipFlash > 0) {
      const t = 1 - s.lipFlash / 16;
      ctx.strokeStyle = `rgba(255, 235, 120, ${1 - t})`;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(def.hole.x, def.hole.y, B.HOLE_R + t * 10, 0, Math.PI * 2);
      ctx.stroke();
    }

    // Live aim indicator
    if (phase === "aim" && p.down) {
      const dx = p.startX - p.curX, dy = p.startY - p.curY;
      const dragDist = Math.hypot(dx, dy);
      if (dragDist > 4) {
        const power = Math.min(dragDist / 10, B.MAX_POWER);
        const t = power / B.MAX_POWER;
        const col = `rgb(${Math.round(255 * t + 230 * (1 - t))},${Math.round(80 * t + 230 * (1 - t))},${Math.round(60 * t + 100 * (1 - t))})`;
        const angle = Math.atan2(dy, dx);
        const len = 20 + power * 5;
        const ex = s.ball.x + Math.cos(angle) * len;
        const ey = s.ball.y + Math.sin(angle) * len;

        ctx.strokeStyle = col;
        ctx.lineWidth = 2;
        ctx.setLineDash([4, 3]);
        ctx.beginPath();
        ctx.moveTo(s.ball.x, s.ball.y);
        ctx.lineTo(ex, ey);
        ctx.stroke();
        ctx.setLineDash([]);

        ctx.fillStyle = col;
        ctx.save();
        ctx.translate(ex, ey);
        ctx.rotate(angle);
        ctx.beginPath();
        ctx.moveTo(7, 0);
        ctx.lineTo(-4, -5);
        ctx.lineTo(-4, 5);
        ctx.closePath();
        ctx.fill();
        ctx.restore();

        for (let i = 0; i < 6; i++) {
          const filled = power >= (i + 0.5) * (B.MAX_POWER / 6);
          ctx.fillStyle = filled ? col : "rgba(255,255,255,0.18)";
          ctx.fillRect(s.ball.x - 15 + i * 5, s.ball.y - 14, 3, 3);
        }
        if (surveyArmed && surveysLeft > 0) {
          ctx.fillStyle = "rgba(140, 225, 255, 0.9)";
          ctx.font = "8px 'Press Start 2P', monospace";
          ctx.textAlign = "center";
          ctx.fillText("SURVEY", s.ball.x, s.ball.y - 24);
        }
      }
    }

    // Balls
    if (!s.holed) drawBallSprite(ctx, s.ball.x, s.ball.y, B.BALL_R);
    if (s.survey) drawBallSprite(ctx, s.survey.b.x, s.survey.b.y, B.BALL_R - 1.5);

    ctx.restore();

    // Night / dusk shroud
    if (def.night || def.dusk) {
      const R = def.night ? B.NIGHT_RADIUS : B.DUSK_RADIUS;
      const cx = s.survey ? s.survey.b.x : s.ball.x;
      const cy = s.survey ? s.survey.b.y : s.ball.y;
      const grad = ctx.createRadialGradient(cx, cy, R * 0.4, cx, cy, R);
      grad.addColorStop(0, "rgba(0,0,0,0)");
      grad.addColorStop(0.75, def.night ? "rgba(0,0,0,0.72)" : "rgba(0,0,0,0.5)");
      grad.addColorStop(1, def.night ? "rgba(0,0,0,0.96)" : "rgba(0,0,0,0.78)");
      ctx.fillStyle = grad;
      ctx.fillRect(0, 0, W, H);
      const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, R * 0.5);
      glow.addColorStop(0, "rgba(255, 230, 140, 0.13)");
      glow.addColorStop(1, "rgba(255, 230, 140, 0)");
      ctx.fillStyle = glow;
      ctx.fillRect(0, 0, W, H);
    }

    // Floating texts
    for (const f of s.floats) {
      const a = Math.min(1, f.frames / 50);
      const rise = (130 - f.frames) * 0.25;
      ctx.globalAlpha = a;
      ctx.font = "8px 'Press Start 2P', monospace";
      ctx.textAlign = "center";
      ctx.fillStyle = f.color;
      ctx.fillText(f.text, Math.max(56, Math.min(W - 56, f.x)), f.y - rise);
      ctx.globalAlpha = 1;
    }

    // Scanlines
    ctx.fillStyle = "rgba(0,0,0,0.08)";
    for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
  };

  // onGreen test for the rough border (static — cache as bitmap once)
  const roughCacheRef = useRef(null);
  const isOnGreenCached = (x, y) => {
    let cache = roughCacheRef.current;
    if (!cache) {
      cache = {};
      roughCacheRef.current = cache;
    }
    const k = x * 1000 + y;
    if (cache[k] === undefined) {
      const { x: gx, y: gy, w, h, r } = B.GREEN;
      let v;
      if (x < gx || x > gx + w || y < gy || y > gy + h) v = false;
      else if (x < gx + r && y < gy + r) v = Math.hypot(x - (gx + r), y - (gy + r)) <= r;
      else if (x > gx + w - r && y < gy + r) v = Math.hypot(x - (gx + w - r), y - (gy + r)) <= r;
      else if (x < gx + r && y > gy + h - r) v = Math.hypot(x - (gx + r), y - (gy + h - r)) <= r;
      else if (x > gx + w - r && y > gy + h - r) v = Math.hypot(x - (gx + w - r), y - (gy + h - r)) <= r;
      else v = true;
      cache[k] = v;
    }
    return cache[k];
  };

  const drawTransition = (elapsed) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    const tr = transitionRef.current;
    const W = B.W, H = B.H;

    ctx.fillStyle = "#0a0a12";
    ctx.fillRect(0, 0, W, H);

    const starSeeds = [[30, 60], [80, 90], [130, 40], [200, 100], [270, 70], [310, 130], [60, 160], [240, 180], [100, 220], [300, 240], [40, 300], [320, 320], [150, 370], [250, 410], [80, 450]];
    starSeeds.forEach(([x, y], i) => {
      const tw = Math.sin(elapsed / 300 + i) * 0.5 + 0.5;
      ctx.fillStyle = `rgba(244, 244, 214, ${0.35 + tw * 0.55})`;
      ctx.fillRect(x, y, 2, 2);
    });

    const progress = Math.min(elapsed / 2000, 1);
    const eased = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;
    const ballX = -20 + (W + 40) * eased;
    const ballY = 440 - Math.sin(progress * Math.PI) * 300;

    for (let i = 0; i < 12; i++) {
      const pr = Math.max(0, progress - i * 0.018);
      const pe = pr < 0.5 ? 2 * pr * pr : 1 - Math.pow(-2 * pr + 2, 2) / 2;
      ctx.fillStyle = `rgba(255, 255, 255, ${0.35 * (1 - i / 12)})`;
      ctx.beginPath();
      ctx.arc(-20 + (W + 40) * pe, 440 - Math.sin(pr * Math.PI) * 300, B.BALL_R, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = "#ffffff";
    ctx.beginPath();
    ctx.arc(ballX, ballY, B.BALL_R + 1, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#dcdcdc";
    ctx.beginPath();
    ctx.arc(ballX + 1.2, ballY + 1.2, B.BALL_R - 1, 0, Math.PI * 2);
    ctx.fill();

    if (elapsed > 400) {
      const titleProg = Math.min((elapsed - 400) / 400, 1);
      ctx.save();
      ctx.globalAlpha = titleProg;
      ctx.fillStyle = "#12121c";
      ctx.fillRect(30, 228, W - 60, 86);
      ctx.strokeStyle = tr.color;
      ctx.lineWidth = 2;
      ctx.strokeRect(30, 228, W - 60, 86);
      ctx.fillStyle = "#888888";
      ctx.font = "8px 'Press Start 2P', monospace";
      ctx.textAlign = "center";
      ctx.fillText(tr.tag || "", W / 2, 252);
      ctx.fillStyle = tr.color;
      ctx.font = "14px 'Press Start 2P', monospace";
      ctx.fillText(tr.title, W / 2, 280);
      ctx.fillStyle = "#9a9a85";
      ctx.font = "8px 'Press Start 2P', monospace";
      ctx.fillText(tr.sub, W / 2, 302);
      ctx.restore();
    }

    ctx.fillStyle = "rgba(0,0,0,0.1)";
    for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
  };

  return (
    <canvas
      ref={canvasRef}
      width={B.W}
      height={B.H}
      className="bb-canvas"
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
    ></canvas>
  );
}

window.GameCanvas = GameCanvas;
