{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fruit-ninja",
  "type": "registry:component",
  "title": "Fruit Ninja",
  "description": "Slice the flying fruit, dodge the bombs, beat the clock. A juicy 60-second arcade run.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/fruit-ninja.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                              Theme helpers                                  */\n/* -------------------------------------------------------------------------- */\n\nconst THEME_TOKENS = [\n  \"background\",\n  \"foreground\",\n  \"primary\",\n  \"primary-foreground\",\n  \"secondary\",\n  \"secondary-foreground\",\n  \"accent\",\n  \"accent-foreground\",\n  \"muted\",\n  \"muted-foreground\",\n  \"border\",\n  \"ring\",\n  \"destructive\",\n] as const;\ntype ThemeTokens = Record<(typeof THEME_TOKENS)[number], string>;\n\nfunction readTheme(el: HTMLElement | null): ThemeTokens {\n  const target = el ?? (typeof document !== \"undefined\" ? document.documentElement : null);\n  const cs = target ? getComputedStyle(target) : null;\n  const out = {} as ThemeTokens;\n  for (const t of THEME_TOKENS) out[t] = cs ? cs.getPropertyValue(`--${t}`).trim() : \"\";\n  return out;\n}\n\nfunction useGameTheme(ref: React.RefObject<HTMLElement | null>): ThemeTokens {\n  const [theme, setTheme] = React.useState<ThemeTokens>(() => readTheme(null));\n  React.useEffect(() => {\n    const read = () => setTheme(readTheme(ref.current));\n    read();\n    const mo = new MutationObserver(read);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    });\n    return () => mo.disconnect();\n  }, [ref]);\n  return theme;\n}\n\nfunction usePrefersReducedMotion(): boolean {\n  const [reduce, setReduce] = React.useState(false);\n  React.useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduce(mq.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduce;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Game constants                                 */\n/* -------------------------------------------------------------------------- */\n\n// Logical (design) coordinate space. All drawing & physics happen in this\n// space; the canvas is scaled to its rendered CSS size via setTransform.\nconst LOGW = 360;\nconst LOGH = 480;\n\nconst GRAVITY = 900; // px/s²\nconst ARCADE_TIME = 60; // seconds — arcade mode is a score attack against the clock\nconst BOMB_PENALTY = 10; // points lost for slicing a bomb (never ends the game)\nconst COMBO_WINDOW = 0.45; // seconds — slices within this window stack a combo\nconst TRAIL_LIFE = 0.14; // seconds a blade point lingers\nconst MIN_SLICE_LEN = 5; // logical px — shorter pointer moves don't slice (taps)\n\n// A small leaf — shared by several fruit. Drawn in the fruit's local (already\n// translated/rotated) space.\nfunction drawLeaf(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  size: number,\n  angle: number,\n) {\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.rotate(angle);\n  ctx.fillStyle = \"#4caf50\";\n  ctx.beginPath();\n  ctx.ellipse(0, 0, size, size * 0.46, 0, 0, Math.PI * 2);\n  ctx.fill();\n  ctx.restore();\n}\n\n// Fixed, vibrant fruit types — guaranteed to read on any background (theme\n// tokens like --accent can collapse into it). Each has a skin (`rind`), a\n// flesh color for the cut faces & juice, and a `draw` for its signature\n// detail. The blade trail, combo text and bombs stay theme-driven (see below).\ninterface FruitDef {\n  rind: string;\n  flesh: string;\n  draw: (ctx: CanvasRenderingContext2D, r: number) => void;\n}\n\nconst FRUITS: FruitDef[] = [\n  {\n    // Watermelon — green rind with dark stripes, pink flesh.\n    rind: \"#2e9e4f\",\n    flesh: \"#fb5d6a\",\n    draw: (ctx, r) => {\n      ctx.save();\n      ctx.beginPath();\n      ctx.arc(0, 0, r, 0, Math.PI * 2);\n      ctx.clip();\n      ctx.strokeStyle = \"#1b6b34\";\n      ctx.lineWidth = r * 0.13;\n      ctx.lineCap = \"round\";\n      for (const dx of [-0.5, 0, 0.5]) {\n        ctx.beginPath();\n        ctx.moveTo(dx * r, -r);\n        ctx.lineTo(dx * r * 1.35, r);\n        ctx.stroke();\n      }\n      ctx.restore();\n    },\n  },\n  {\n    // Orange — pebbled skin, little leaf on top.\n    rind: \"#f59331\",\n    flesh: \"#ffb866\",\n    draw: (ctx, r) => {\n      drawLeaf(ctx, r * 0.15, -r * 0.92, r * 0.42, -0.6);\n    },\n  },\n  {\n    // Apple — brown stem + leaf.\n    rind: \"#e23b3b\",\n    flesh: \"#fbe9d0\",\n    draw: (ctx, r) => {\n      ctx.strokeStyle = \"#7c4a1e\";\n      ctx.lineWidth = r * 0.11;\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      ctx.moveTo(0, -r * 0.72);\n      ctx.lineTo(r * 0.06, -r * 1.12);\n      ctx.stroke();\n      drawLeaf(ctx, r * 0.42, -r * 0.95, r * 0.4, -0.4);\n    },\n  },\n  {\n    // Lemon — pale flesh, tiny nub.\n    rind: \"#f4cf26\",\n    flesh: \"#fcf3a8\",\n    draw: (ctx, r) => {\n      ctx.fillStyle = \"#d9b41f\";\n      ctx.beginPath();\n      ctx.arc(r * 0.82, -r * 0.55, r * 0.1, 0, Math.PI * 2);\n      ctx.fill();\n    },\n  },\n  {\n    // Lime — green skin, small leaf.\n    rind: \"#7cc043\",\n    flesh: \"#d6efb0\",\n    draw: (ctx, r) => {\n      drawLeaf(ctx, r * 0.1, -r * 0.92, r * 0.36, -0.7);\n    },\n  },\n  {\n    // Grape / plum — purple, little stem dot.\n    rind: \"#9b51e0\",\n    flesh: \"#d9b8f2\",\n    draw: (ctx, r) => {\n      ctx.fillStyle = \"#4e342e\";\n      ctx.beginPath();\n      ctx.arc(0, -r * 0.92, r * 0.1, 0, Math.PI * 2);\n      ctx.fill();\n    },\n  },\n  {\n    // Blueberry — deep blue, crown notch.\n    rind: \"#3d6fe0\",\n    flesh: \"#9cc0ff\",\n    draw: (ctx, r) => {\n      ctx.fillStyle = \"#1f3f8a\";\n      ctx.beginPath();\n      ctx.arc(0, -r * 0.45, r * 0.16, 0, Math.PI * 2);\n      ctx.fill();\n    },\n  },\n];\n\n/* -------------------------------------------------------------------------- */\n/*                              Game state types                               */\n/* -------------------------------------------------------------------------- */\n\ntype Kind = \"fruit\" | \"bomb\";\n\ninterface Fruit {\n  id: number;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  color: string; // bomb body color (fruit use their FruitDef.rind)\n  def: number; // index into FRUITS, or -1 for a bomb\n  rot: number;\n  vrot: number;\n  kind: Kind;\n  sliced: boolean;\n}\n\ninterface Half {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  rind: string;\n  flesh: string;\n  rot: number;\n  vrot: number;\n  cut: number; // slice angle — orients the flat edge\n  side: 1 | -1;\n  life: number;\n}\n\ninterface Particle {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  color: string;\n  life: number;\n  maxLife: number;\n}\n\ninterface FloatText {\n  x: number;\n  y: number;\n  vy: number;\n  life: number;\n  maxLife: number;\n  text: string;\n  color: string;\n  size: number;\n}\n\ninterface TrailPoint {\n  x: number;\n  y: number;\n  age: number;\n}\n\ninterface NinjaState {\n  fruits: Fruit[];\n  halves: Half[];\n  particles: Particle[];\n  texts: FloatText[];\n  trail: TrailPoint[];\n  score: number;\n  timeLeft: number;\n  combo: number;\n  comboTimer: number;\n  spawnTimer: number;\n  elapsed: number;\n  flash: number; // red bomb-hit flash, in seconds remaining\n  nextId: number;\n}\n\nfunction initState(): NinjaState {\n  return {\n    fruits: [],\n    halves: [],\n    particles: [],\n    texts: [],\n    trail: [],\n    score: 0,\n    timeLeft: ARCADE_TIME,\n    combo: 0,\n    comboTimer: 0,\n    spawnTimer: 0.8,\n    elapsed: 0,\n    flash: 0,\n    nextId: 1,\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Geometry helper                                */\n/* -------------------------------------------------------------------------- */\n\n/** Shortest distance from point (px,py) to segment (ax,ay)-(bx,by). */\nfunction segPointDist(\n  ax: number,\n  ay: number,\n  bx: number,\n  by: number,\n  px: number,\n  py: number,\n): number {\n  const dx = bx - ax;\n  const dy = by - ay;\n  const len2 = dx * dx + dy * dy;\n  let t = len2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;\n  t = Math.max(0, Math.min(1, t));\n  const cx = ax + t * dx;\n  const cy = ay + t * dy;\n  return Math.hypot(px - cx, py - cy);\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Props                                          */\n/* -------------------------------------------------------------------------- */\n\nexport interface FruitNinjaProps {\n  className?: string;\n  width?: number;\n  paused?: boolean;\n  autoFocus?: boolean;\n  /**\n   * When true (the default), the game listens for keys on `window` so it works\n   * without being focused first — ideal for single-game pages (404s, loading /\n   * empty states). Set false when multiple games share a page, or the game sits\n   * in scrollable content, so it only responds while focused. Ignores keys aimed\n   * at form fields.\n   */\n  captureGlobalKeys?: boolean;\n  persistHighScore?: boolean | string;\n  onScoreChange?: (score: number) => void;\n  onGameOver?: (r: { score: number; won: boolean }) => void;\n  onStart?: () => void;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Component                                      */\n/* -------------------------------------------------------------------------- */\n\nexport function FruitNinja({\n  className,\n  width,\n  paused = false,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: FruitNinjaProps) {\n  const wrapperRef = React.useRef<HTMLDivElement | null>(null);\n  const playfieldRef = React.useRef<HTMLDivElement | null>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n  const theme = useGameTheme(wrapperRef);\n  const reduce = usePrefersReducedMotion();\n\n  const [status, setStatus] = React.useState<\"idle\" | \"playing\" | \"gameover\">(\"idle\");\n  const [score, setScore] = React.useState(0);\n  const [timeLeft, setTimeLeft] = React.useState(ARCADE_TIME);\n  const [high, setHigh] = React.useState(0);\n  const [offscreen, setOffscreen] = React.useState(false);\n\n  const storageKey =\n    typeof persistHighScore === \"string\"\n      ? persistHighScore\n      : persistHighScore === false\n        ? null\n        : \"gamekitui:fruit-ninja:hi\";\n\n  // Mutable refs for the rAF loop — never cause re-renders.\n  const stateRef = React.useRef<NinjaState | null>(null);\n  const statusRef = React.useRef(status);\n  const pausedRef = React.useRef(paused);\n  const offscreenRef = React.useRef(offscreen);\n  const themeRef = React.useRef(theme);\n  const reduceRef = React.useRef(reduce);\n  const shownSecRef = React.useRef(ARCADE_TIME); // last whole-second pushed to React\n  const onScoreChangeRef = React.useRef(onScoreChange);\n  const onGameOverRef = React.useRef(onGameOver);\n  const submitHighRef = React.useRef<(s: number) => void>(() => undefined);\n\n  statusRef.current = status;\n  pausedRef.current = paused;\n  offscreenRef.current = offscreen;\n  themeRef.current = theme;\n  reduceRef.current = reduce;\n  onScoreChangeRef.current = onScoreChange;\n  onGameOverRef.current = onGameOver;\n\n  // Load high score.\n  React.useEffect(() => {\n    if (!storageKey) return;\n    try {\n      const raw = window.localStorage.getItem(storageKey);\n      if (raw) setHigh(Number(raw) || 0);\n    } catch {\n      /* ignore */\n    }\n  }, [storageKey]);\n\n  const submitHigh = React.useCallback(\n    (s: number) => {\n      setHigh((prev) => {\n        if (s <= prev) return prev;\n        if (storageKey) {\n          try {\n            window.localStorage.setItem(storageKey, String(s));\n          } catch {\n            /* ignore */\n          }\n        }\n        return s;\n      });\n    },\n    [storageKey],\n  );\n  submitHighRef.current = submitHigh;\n\n  // Pause when scrolled offscreen or tab hidden.\n  React.useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([e]) => {\n      if (e) setOffscreen(!e.isIntersecting);\n    });\n    io.observe(el);\n    const onVis = () => setOffscreen(document.hidden);\n    document.addEventListener(\"visibilitychange\", onVis);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, []);\n\n  const start = React.useCallback(() => {\n    stateRef.current = initState();\n    shownSecRef.current = ARCADE_TIME;\n    setScore(0);\n    setTimeLeft(ARCADE_TIME);\n    setStatus(\"playing\");\n    onStart?.();\n    onScoreChangeRef.current?.(0);\n    // Focus the wrapper so keyboard controls work after the Start button unmounts.\n    wrapperRef.current?.focus();\n  }, [onStart]);\n\n  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,\n  // then scale the drawing context so all rendering happens in LOGW×LOGH space.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const field = playfieldRef.current;\n    if (!canvas || !field) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const resize = () => {\n      const rect = field.getBoundingClientRect();\n      const w = Math.max(1, rect.width);\n      const h = Math.max(1, rect.height);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      const scale = w / LOGW;\n      ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(field);\n    return () => ro.disconnect();\n  }, []);\n\n  // Ref bridge so the pointer effect can call into the loop's slice logic.\n  const sliceAlongRef = React.useRef<\n    (ax: number, ay: number, bx: number, by: number) => void\n  >(() => undefined);\n\n  // Main rAF loop + drawing + physics.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const rand = (a: number, b: number) => a + Math.random() * (b - a);\n\n    // ------------------------------------------------------------------\n    // Spawning\n    // ------------------------------------------------------------------\n\n    const spawnFruit = (gs: NinjaState, kind: Kind) => {\n      const r = rand(22, 30);\n      const x = rand(60, LOGW - 60);\n      // Launch upward hard enough to arc near the top of the field.\n      const apex = LOGH * rand(0.62, 0.92) * (reduceRef.current ? 0.82 : 1);\n      const vy = -Math.sqrt(2 * GRAVITY * apex);\n      const vx = ((LOGW / 2 - x) / LOGH) * 240 + rand(-120, 120);\n      const def = kind === \"bomb\" ? -1 : Math.floor(Math.random() * FRUITS.length);\n      const color =\n        kind === \"bomb\" ? themeRef.current.foreground || \"#1e1e1e\" : FRUITS[def]!.rind;\n      gs.fruits.push({\n        id: gs.nextId++,\n        x,\n        y: LOGH + r,\n        vx,\n        vy,\n        r,\n        color,\n        def,\n        rot: rand(0, Math.PI * 2),\n        vrot: rand(-3, 3),\n        kind,\n        sliced: false,\n      });\n    };\n\n    const maybeSpawnWave = (gs: NinjaState, dt: number) => {\n      gs.spawnTimer -= dt;\n      if (gs.spawnTimer > 0) return;\n      const diff = Math.min(gs.elapsed / 45, 1); // ramps difficulty over ~45s\n      gs.spawnTimer = (1.05 - 0.55 * diff) * (reduceRef.current ? 1.35 : 1) + rand(0, 0.3);\n      let wave = 1;\n      if (Math.random() < 0.3 + 0.4 * diff) wave++;\n      if (Math.random() < 0.12 + 0.3 * diff) wave++;\n      for (let i = 0; i < wave; i++) {\n        // Bombs only after a short grace period, then grow in frequency.\n        const bombChance = gs.elapsed > 6 ? Math.min(0.08 + 0.14 * diff, 0.24) : 0;\n        spawnFruit(gs, Math.random() < bombChance ? \"bomb\" : \"fruit\");\n      }\n    };\n\n    // ------------------------------------------------------------------\n    // Slicing FX\n    // ------------------------------------------------------------------\n\n    const spawnJuice = (gs: NinjaState, f: Fruit, color: string, count: number) => {\n      for (let i = 0; i < count; i++) {\n        const a = rand(0, Math.PI * 2);\n        const sp = rand(40, 240);\n        gs.particles.push({\n          x: f.x,\n          y: f.y,\n          vx: Math.cos(a) * sp,\n          vy: Math.sin(a) * sp - 60,\n          r: rand(2, 5),\n          color,\n          life: 0,\n          maxLife: rand(0.4, 0.85),\n        });\n      }\n    };\n\n    const sliceFruit = (\n      gs: NinjaState,\n      f: Fruit,\n      ax: number,\n      ay: number,\n      bx: number,\n      by: number,\n    ) => {\n      f.sliced = true;\n\n      if (f.kind === \"bomb\") {\n        // Arcade mode: a bomb costs points + breaks the combo, but never ends\n        // the run. Red flash + shrapnel for feedback.\n        gs.score = Math.max(0, gs.score - BOMB_PENALTY);\n        gs.combo = 0;\n        gs.comboTimer = 0;\n        gs.flash = 0.3;\n        setScore(gs.score);\n        onScoreChangeRef.current?.(gs.score);\n        spawnJuice(gs, f, themeRef.current.destructive || \"#ef4444\", reduceRef.current ? 8 : 22);\n        gs.texts.push({\n          x: f.x,\n          y: f.y - f.r,\n          vy: -55,\n          life: 0,\n          maxLife: 0.9,\n          text: `-${BOMB_PENALTY}`,\n          color: themeRef.current.destructive || \"#ef4444\",\n          size: 22,\n        });\n        return;\n      }\n\n      // Combo: slices that land inside the rolling window stack up.\n      if (gs.comboTimer > 0) gs.combo += 1;\n      else gs.combo = 1;\n      gs.comboTimer = COMBO_WINDOW;\n\n      const gain = gs.combo >= 2 ? 2 : 1; // combo slices are worth a bonus point\n      gs.score += gain;\n      setScore(gs.score);\n      onScoreChangeRef.current?.(gs.score);\n\n      if (gs.combo >= 2) {\n        gs.texts.push({\n          x: f.x,\n          y: f.y - f.r,\n          vy: -55,\n          life: 0,\n          maxLife: 0.9,\n          text: `Combo x${gs.combo}`,\n          color: themeRef.current.primary || \"#3b82f6\",\n          size: Math.min(16 + gs.combo * 2, 30),\n        });\n      }\n\n      // Two halves fly apart along the blade's perpendicular.\n      const fd = FRUITS[f.def]!;\n      const cut = Math.atan2(by - ay, bx - ax);\n      const px = Math.cos(cut + Math.PI / 2);\n      const py = Math.sin(cut + Math.PI / 2);\n      const kick = rand(60, 130);\n      for (const side of [1, -1] as const) {\n        gs.halves.push({\n          x: f.x,\n          y: f.y,\n          vx: f.vx * 0.4 + px * kick * side,\n          vy: f.vy * 0.4 + py * kick * side - 40,\n          r: f.r,\n          rind: fd.rind,\n          flesh: fd.flesh,\n          rot: f.rot,\n          vrot: f.vrot + side * rand(1, 4),\n          cut,\n          side,\n          life: 0,\n        });\n      }\n      spawnJuice(gs, f, fd.flesh, reduceRef.current ? 5 : 12);\n    };\n\n    // Slice every unsliced fruit the blade segment passes through.\n    const sliceAlong = (ax: number, ay: number, bx: number, by: number) => {\n      const gs = stateRef.current;\n      if (!gs || statusRef.current !== \"playing\") return;\n      if (Math.hypot(bx - ax, by - ay) < MIN_SLICE_LEN) return;\n      for (const f of gs.fruits) {\n        if (f.sliced) continue;\n        if (segPointDist(ax, ay, bx, by, f.x, f.y) <= f.r) {\n          sliceFruit(gs, f, ax, ay, bx, by);\n        }\n      }\n    };\n    sliceAlongRef.current = sliceAlong;\n\n    // ------------------------------------------------------------------\n    // Physics step\n    // ------------------------------------------------------------------\n\n    const step = (dt: number) => {\n      const gs = stateRef.current;\n      if (!gs) return;\n\n      gs.elapsed += dt;\n      if (gs.comboTimer > 0) gs.comboTimer = Math.max(0, gs.comboTimer - dt);\n      if (gs.flash > 0) gs.flash = Math.max(0, gs.flash - dt);\n\n      // Countdown — the only way the game ends.\n      gs.timeLeft = Math.max(0, gs.timeLeft - dt);\n      const sec = Math.ceil(gs.timeLeft);\n      if (sec !== shownSecRef.current) {\n        shownSecRef.current = sec;\n        setTimeLeft(sec);\n      }\n      if (gs.timeLeft <= 0) {\n        const finalScore = gs.score;\n        submitHighRef.current(finalScore);\n        onGameOverRef.current?.({ score: finalScore, won: finalScore > 0 });\n        setStatus(\"gameover\");\n        return;\n      }\n\n      maybeSpawnWave(gs, dt);\n\n      // Age & cull the blade trail.\n      for (const p of gs.trail) p.age += dt;\n      gs.trail = gs.trail.filter((p) => p.age < TRAIL_LIFE);\n\n      // Fruits — arc under gravity; missed fruit just falls away (no penalty).\n      for (const f of gs.fruits) {\n        f.vy += GRAVITY * dt;\n        f.x += f.vx * dt;\n        f.y += f.vy * dt;\n        f.rot += f.vrot * dt;\n      }\n      // Drop sliced fruit (halves take over) and any that fell off the bottom.\n      gs.fruits = gs.fruits.filter((f) => !f.sliced && !(f.y - f.r > LOGH && f.vy > 0));\n\n      // Halves.\n      for (const h of gs.halves) {\n        h.vy += GRAVITY * dt;\n        h.x += h.vx * dt;\n        h.y += h.vy * dt;\n        h.rot += h.vrot * dt;\n        h.life += dt;\n      }\n      gs.halves = gs.halves.filter((h) => h.y - h.r <= LOGH + 40);\n\n      // Particles.\n      for (const p of gs.particles) {\n        p.vy += GRAVITY * 0.6 * dt;\n        p.x += p.vx * dt;\n        p.y += p.vy * dt;\n        p.life += dt;\n      }\n      gs.particles = gs.particles.filter((p) => p.life < p.maxLife);\n\n      // Floating texts.\n      for (const t of gs.texts) {\n        t.y += t.vy * dt;\n        t.life += dt;\n      }\n      gs.texts = gs.texts.filter((t) => t.life < t.maxLife);\n    };\n\n    // ------------------------------------------------------------------\n    // Drawing\n    // ------------------------------------------------------------------\n\n    const drawFruit = (f: Fruit) => {\n      ctx.save();\n      ctx.translate(f.x, f.y);\n      ctx.rotate(f.rot);\n\n      // Body.\n      ctx.fillStyle = f.color;\n      ctx.beginPath();\n      ctx.arc(0, 0, f.r, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Per-fruit signature detail (stripes, leaf, stem…).\n      if (f.kind === \"fruit\") FRUITS[f.def]!.draw(ctx, f.r);\n\n      if (f.kind === \"bomb\") {\n        // Danger ring + fuse make bombs read clearly on any theme.\n        const danger = themeRef.current.destructive || \"#ef4444\";\n        ctx.lineWidth = 3;\n        ctx.strokeStyle = danger;\n        ctx.beginPath();\n        ctx.arc(0, 0, f.r - 1.5, 0, Math.PI * 2);\n        ctx.stroke();\n        // Fuse.\n        ctx.lineWidth = 2;\n        ctx.lineCap = \"round\";\n        ctx.beginPath();\n        ctx.moveTo(f.r * 0.5, -f.r * 0.5);\n        ctx.lineTo(f.r * 0.9, -f.r * 1.05);\n        ctx.stroke();\n        // Spark.\n        ctx.fillStyle = danger;\n        ctx.beginPath();\n        ctx.arc(f.r * 0.95, -f.r * 1.1, 3, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      // Shine.\n      ctx.globalAlpha = 0.4;\n      ctx.fillStyle = \"#ffffff\";\n      ctx.beginPath();\n      ctx.arc(-f.r * 0.32, -f.r * 0.32, f.r * 0.28, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.globalAlpha = 1;\n\n      ctx.restore();\n    };\n\n    const drawHalf = (h: Half) => {\n      const alpha = Math.max(0, 1 - h.life / 1.4);\n      ctx.save();\n      ctx.translate(h.x, h.y);\n      ctx.rotate(h.rot);\n      ctx.globalAlpha = alpha;\n      // Exposed flesh fills the half disc; flat edge aligned with the cut.\n      ctx.fillStyle = h.flesh;\n      ctx.beginPath();\n      ctx.arc(0, 0, h.r, h.cut, h.cut + Math.PI);\n      ctx.closePath();\n      ctx.fill();\n      // Skin around the curved edge.\n      ctx.strokeStyle = h.rind;\n      ctx.lineWidth = h.r * 0.2;\n      ctx.beginPath();\n      ctx.arc(0, 0, h.r - h.r * 0.1, h.cut, h.cut + Math.PI);\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n      ctx.restore();\n    };\n\n    const drawTrail = (gs: NinjaState) => {\n      if (gs.trail.length < 2) return;\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      for (let i = 1; i < gs.trail.length; i++) {\n        const p0 = gs.trail[i - 1]!;\n        const p1 = gs.trail[i]!;\n        const a = 1 - p1.age / TRAIL_LIFE;\n        if (a <= 0) continue;\n        // Colored glow under a white core.\n        ctx.strokeStyle = themeRef.current.primary || \"#3b82f6\";\n        ctx.globalAlpha = a * 0.35;\n        ctx.lineWidth = 12 * a + 2;\n        ctx.beginPath();\n        ctx.moveTo(p0.x, p0.y);\n        ctx.lineTo(p1.x, p1.y);\n        ctx.stroke();\n\n        ctx.strokeStyle = \"#ffffff\";\n        ctx.globalAlpha = a * 0.85;\n        ctx.lineWidth = 5 * a + 1;\n        ctx.beginPath();\n        ctx.moveTo(p0.x, p0.y);\n        ctx.lineTo(p1.x, p1.y);\n        ctx.stroke();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const draw = () => {\n      const t = themeRef.current;\n      const gs = stateRef.current;\n\n      ctx.clearRect(0, 0, LOGW, LOGH);\n      ctx.fillStyle = t.background || \"#fff\";\n      ctx.fillRect(0, 0, LOGW, LOGH);\n\n      if (!gs) return;\n\n      // Particles behind, then fruit/halves, then text, then trail on top.\n      for (const p of gs.particles) {\n        const a = Math.max(0, 1 - p.life / p.maxLife);\n        ctx.globalAlpha = a;\n        ctx.fillStyle = p.color;\n        ctx.beginPath();\n        ctx.arc(p.x, p.y, p.r * a, 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n\n      for (const h of gs.halves) drawHalf(h);\n      for (const f of gs.fruits) {\n        if (f.sliced) continue; // halves replace it the same frame\n        drawFruit(f);\n      }\n\n      for (const tx of gs.texts) {\n        const a = Math.max(0, 1 - tx.life / tx.maxLife);\n        ctx.globalAlpha = a;\n        ctx.fillStyle = tx.color;\n        ctx.font = `bold ${tx.size}px ui-sans-serif, system-ui, -apple-system, sans-serif`;\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(tx.text, tx.x, tx.y);\n      }\n      ctx.globalAlpha = 1;\n\n      drawTrail(gs);\n\n      // Bomb-hit flash.\n      if (gs.flash > 0) {\n        ctx.globalAlpha = (gs.flash / 0.3) * 0.35;\n        ctx.fillStyle = t.destructive || \"#ef4444\";\n        ctx.fillRect(0, 0, LOGW, LOGH);\n        ctx.globalAlpha = 1;\n      }\n    };\n\n    let raf = 0;\n    let last = 0;\n\n    const frame = (now: number) => {\n      raf = requestAnimationFrame(frame);\n      if (last === 0) last = now;\n      const rawDt = Math.min((now - last) / 1000, 0.05);\n      last = now;\n\n      const active =\n        statusRef.current === \"playing\" && !pausedRef.current && !offscreenRef.current;\n\n      if (active) step(rawDt);\n      draw();\n    };\n\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  // Pointer / touch — drag the blade across fruit to slice.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    const toLogical = (clientX: number, clientY: number) => {\n      const rect = canvas.getBoundingClientRect();\n      return {\n        x: ((clientX - rect.left) / rect.width) * LOGW,\n        y: ((clientY - rect.top) / rect.height) * LOGH,\n      };\n    };\n\n    let down = false;\n    let lastX = 0;\n    let lastY = 0;\n\n    const pushTrail = (x: number, y: number) => {\n      const gs = stateRef.current;\n      if (!gs) return;\n      gs.trail.push({ x, y, age: 0 });\n      if (gs.trail.length > 24) gs.trail.shift();\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (statusRef.current !== \"playing\") return;\n      down = true;\n      canvas.setPointerCapture?.(e.pointerId);\n      const p = toLogical(e.clientX, e.clientY);\n      lastX = p.x;\n      lastY = p.y;\n      pushTrail(p.x, p.y);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (!down || statusRef.current !== \"playing\") return;\n      const p = toLogical(e.clientX, e.clientY);\n      pushTrail(p.x, p.y);\n      sliceAlongRef.current(lastX, lastY, p.x, p.y);\n      lastX = p.x;\n      lastY = p.y;\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      down = false;\n      canvas.releasePointerCapture?.(e.pointerId);\n    };\n\n    canvas.addEventListener(\"pointerdown\", onPointerDown);\n    canvas.addEventListener(\"pointermove\", onPointerMove);\n    canvas.addEventListener(\"pointerup\", onPointerUp);\n    canvas.addEventListener(\"pointercancel\", onPointerUp);\n    return () => {\n      canvas.removeEventListener(\"pointerdown\", onPointerDown);\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerup\", onPointerUp);\n      canvas.removeEventListener(\"pointercancel\", onPointerUp);\n    };\n  }, []);\n\n  // Keyboard — start / restart only (gameplay is pointer-driven).\n  React.useEffect(() => {\n    const onKeyDown = (e: KeyboardEvent) => {\n      const t = e.target as HTMLElement | null;\n      if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;\n      if (e.key === \" \" || e.key === \"Enter\") {\n        if (statusRef.current === \"idle\" || statusRef.current === \"gameover\") {\n          e.preventDefault();\n          start();\n        }\n      }\n    };\n    const target: Window | HTMLElement | null = captureGlobalKeys\n      ? window\n      : wrapperRef.current;\n    target?.addEventListener(\"keydown\", onKeyDown as EventListener);\n    return () => target?.removeEventListener(\"keydown\", onKeyDown as EventListener);\n  }, [start, captureGlobalKeys]);\n\n  // Auto-focus.\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  const isOver = status === \"gameover\";\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"Fruit Ninja game\"\n      style={width ? { maxWidth: width } : undefined}\n      className={cn(\n        \"relative flex w-full select-none flex-col gap-2 outline-none\",\n        !captureGlobalKeys &&\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className,\n      )}\n    >\n      {/* Score bar */}\n      <div className=\"flex items-center justify-between gap-3 text-sm\">\n        <span className=\"inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums\">\n          Score {score}\n        </span>\n        <span\n          className={cn(\n            \"inline-flex items-center rounded-md border px-2 py-0.5 font-medium tabular-nums\",\n            timeLeft <= 10 && status === \"playing\"\n              ? \"bg-destructive text-white\"\n              : \"bg-muted text-muted-foreground\",\n          )}\n        >\n          {timeLeft}s\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">Best {high}</span>\n      </div>\n\n      {/* aria-live region */}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {status === \"playing\"\n          ? `Score ${score}, ${timeLeft} seconds left`\n          : status === \"gameover\"\n            ? `Time's up. Final score ${score}`\n            : \"Fruit Ninja arcade. Press Start to play.\"}\n      </div>\n\n      {/* Canvas container */}\n      <div\n        ref={playfieldRef}\n        className=\"relative aspect-[3/4] w-full overflow-hidden rounded-lg border bg-background\"\n      >\n        <canvas\n          ref={canvasRef}\n          className=\"absolute inset-0 block h-full w-full touch-none cursor-crosshair\"\n        />\n\n        {/* Overlays */}\n        {status !== \"playing\" && (\n          <div className=\"absolute inset-0 grid place-items-center bg-background/80 backdrop-blur-[1px]\">\n            <div className=\"flex flex-col items-center gap-3 px-6 text-center\">\n              {isOver ? (\n                <>\n                  <p className=\"text-lg font-semibold\">Time&apos;s up!</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    You scored {score}\n                    {score >= high && score > 0 ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-lg font-semibold\">Fruit Ninja</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Swipe to slice the fruit. Dodge the bombs. 60-second arcade run.\n                  </p>\n                </>\n              )}\n              <button\n                type=\"button\"\n                onClick={start}\n                className={cn(\n                  \"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm\",\n                  \"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                )}\n              >\n                {isOver ? \"Play again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default FruitNinja;\n",
      "type": "registry:component",
      "target": "@/components/games/fruit-ninja.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "620px",
    "surface": "canvas"
  }
}
