Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Bean, Pause, Play, RotateCcw, Sparkles, Sprout, Target, Trophy, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { BasketArt, GardenMark, ItemArt } from "./art";
import { keyIsForGame, readBest, useBlips, usePauseOnBlur, writeBest } from "./kit";
import {
  COLS,
  HARVEST_POINTS,
  MAX_ENERGY,
  MAX_LEVEL,
  MERGE_POINTS,
  PLANT_NAMES,
  HARVEST_SEEDS,
  SEED_REWARD,
  ROWS,
  dropResult,
  findPair,
  goalLabel,
  initialState,
  reducer,
  type Fx,
  type Item,
} from "./logic";

export type { GardenState, Goal, Item } from "./logic";

export interface MergeGardenProps {
  /** 1 = fresh patch; 2–3 start with blossoms / a tree already planted. */
  initialLevel?: number;
  /** Seed for spawns and the starting patch (deterministic for tests). */
  seed?: number;
  onGameOver?: (score: number) => void;
  onMerge?: (level: number, score: number) => void;
  storageKey?: string;
  title?: string;
  className?: string;
}

const LEAF_COLORS = ["#4ade80", "#86efac", "#f9a8d4", "#fde047", "#a78bfa"];

function today() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

export function MergeGarden({ initialLevel = 1, seed, onGameOver, onMerge, storageKey = "merge-garden:best", title = "Merge Garden", className }: MergeGardenProps) {
  const reduce = useReducedMotion() ?? false;
  const [s, dispatch] = React.useReducer(reducer, undefined, () => initialState(seed ?? 20260924, initialLevel));
  const rootRef = React.useRef<HTMLDivElement>(null);
  const boardWrapRef = React.useRef<HTMLDivElement>(null);
  const boardRef = React.useRef<HTMLDivElement>(null);
  const runRef = React.useRef(0);
  const [boardSize, setBoardSize] = React.useState(360);
  const [muted, setMuted] = React.useState(true);
  const [best, setBest] = React.useState(0);
  const [newBest, setNewBest] = React.useState(false);
  const [drag, setDrag] = React.useState<{ from: number; x0: number; y0: number; dx: number; dy: number; over: number | null } | null>(null);
  const [cursor, setCursor] = React.useState(14);
  const [picked, setPicked] = React.useState<number | null>(null);
  const [kbd, setKbd] = React.useState(false);
  const [hint, setHint] = React.useState<[number, number] | null>(null);
  const idleRef = React.useRef(0);
  const blip = useBlips(muted);

  // board sizing
  React.useEffect(() => {
    const el = boardWrapRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setBoardSize(Math.max(240, Math.floor(Math.min(width, height))));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // best score + daily goals (client-only values)
  React.useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate from localStorage after mount
    setBest(readBest(storageKey));
    const day = today();
    let done = false;
    try {
      done = window.localStorage.getItem(`${storageKey}:daily:${day}`) === "1";
    } catch {
      /* ignore */
    }
    dispatch({ type: "setDay", day, done });
  }, [storageKey]);

  React.useEffect(() => {
    if (!s.dailyDone || !s.day) return;
    try {
      window.localStorage.setItem(`${storageKey}:daily:${s.day}`, "1");
    } catch {
      /* ignore */
    }
  }, [s.dailyDone, s.day, storageKey]);

  // keep the best score in sync as you play (a calm game may never end)
  React.useEffect(() => {
    if (s.score > 0 && s.score > readBest(storageKey)) {
      writeBest(storageKey, s.score);
      // eslint-disable-next-line react-hooks/set-state-in-effect -- mirror persisted best
      setBest(s.score);
    }
  }, [s.score, storageKey]);
  // best at the start of this run, to celebrate a new record at the end
  const runBestRef = React.useRef<number | null>(null);

  const cbRef = React.useRef({ onGameOver, onMerge });
  React.useEffect(() => {
    cbRef.current = { onGameOver, onMerge };
  }, [onGameOver, onMerge]);
  const prevStatus = React.useRef(s.status);
  React.useEffect(() => {
    if (s.status === "playing" && prevStatus.current === "ready") runBestRef.current = readBest(storageKey);
    if (s.status === "over" && prevStatus.current !== "over") {
      setNewBest(s.score > (runBestRef.current ?? 0));
      cbRef.current.onGameOver?.(s.score);
      blip(196, 0.6, "sine", 0.05, -60);
    }
    prevStatus.current = s.status;
  }, [s.status, s.score, blip, storageKey]);

  // effects → sounds + callbacks
  const seenFx = React.useRef(new Set<number>());
  React.useEffect(() => {
    for (const f of s.fx) {
      if (seenFx.current.has(f.id)) continue;
      seenFx.current.add(f.id);
      if (f.kind === "merge" || f.kind === "water") {
        blip(330 * Math.pow(1.26, f.level), 0.16, "sine", 0.06, 120);
        cbRef.current.onMerge?.(f.level, s.score);
      } else if (f.kind === "harvest") blip(660, 0.5, "triangle", 0.06, 660);
      else if (f.kind === "spawn") blip(240, 0.07, "sine", 0.04, 60);
      else if (f.kind === "daily") blip(523, 0.8, "triangle", 0.06, 523);
    }
  }, [s.fx, s.score, blip]);

  // idle hint clock
  React.useEffect(() => {
    if (s.status !== "playing") return;
    const t = window.setInterval(() => {
      idleRef.current += 1;
      if (idleRef.current === 7) setHint(findPairRef.current());
    }, 1000);
    return () => window.clearInterval(t);
  }, [s.status]);
  const cellsRef = React.useRef(s.cells);
  React.useEffect(() => {
    cellsRef.current = s.cells;
  }, [s.cells]);
  const findPairRef = React.useRef(() => findPair(cellsRef.current));

  const poke = () => {
    idleRef.current = 0;
    if (hint) setHint(null);
  };

  usePauseOnBlur(React.useCallback(() => dispatch({ type: "pause" }), []));

  const restart = React.useCallback(() => {
    runRef.current += 1;
    runBestRef.current = readBest(storageKey);
    dispatch({ type: "restart", seed: seed !== undefined ? seed + runRef.current : Math.floor(Math.random() * 2 ** 31), level: initialLevel });
    setNewBest(false);
    setPicked(null);
    rootRef.current?.focus({ preventScroll: true });
  }, [initialLevel, seed, storageKey]);

  const spawn = () => {
    poke();
    dispatch({ type: "spawn" });
  };

  // keyboard
  const stateRef = React.useRef({ cursor, picked, status: s.status, cells: s.cells });
  React.useEffect(() => {
    stateRef.current = { cursor, picked, status: s.status, cells: s.cells };
  }, [cursor, picked, s.status, s.cells]);
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (!keyIsForGame(e, rootRef.current)) return;
      const st = stateRef.current;
      const onButton = e.target instanceof HTMLButtonElement;
      const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -COLS, ArrowDown: COLS };
      if (e.code in moves) {
        e.preventDefault();
        setKbd(true);
        setCursor((c) => {
          const d = moves[e.code];
          const n = c + d;
          if (d === -1 && c % COLS === 0) return c;
          if (d === 1 && c % COLS === COLS - 1) return c;
          return n < 0 || n >= COLS * ROWS ? c : n;
        });
        return;
      }
      if (e.code === "Enter" || e.code === "Space") {
        if (onButton) return;
        e.preventDefault();
        setKbd(true);
        idleRef.current = 0;
        setHint(null);
        if (st.status === "ready") return dispatch({ type: "start" });
        if (st.status === "paused") return dispatch({ type: "resume" });
        if (st.status === "over") return restart();
        if (st.picked === null) {
          if (st.cells[st.cursor]) setPicked(st.cursor);
        } else {
          if (st.picked === st.cursor) dispatch({ type: "tap", cell: st.cursor });
          else dispatch({ type: "drop", from: st.picked, to: st.cursor });
          setPicked(null);
        }
      } else if (e.code === "KeyB") {
        e.preventDefault();
        idleRef.current = 0;
        dispatch({ type: "spawn" });
      } else if (e.code === "Escape") {
        e.preventDefault();
        if (st.picked !== null) setPicked(null);
        else dispatch({ type: st.status === "paused" ? "resume" : "pause" });
      } else if (e.code === "KeyP") dispatch({ type: st.status === "paused" ? "resume" : "pause" });
      else if (e.code === "KeyM") setMuted((m) => !m);
      else if (e.code === "KeyR") restart();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [restart]);

  // pointer drag
  const cellAt = (x: number, y: number) => {
    const r = boardRef.current?.getBoundingClientRect();
    if (!r) return null;
    const col = Math.floor(((x - r.left) / r.width) * COLS);
    const row = Math.floor(((y - r.top) / r.height) * ROWS);
    if (col < 0 || row < 0 || col >= COLS || row >= ROWS) return null;
    return row * COLS + col;
  };
  const onItemDown = (e: React.PointerEvent<HTMLDivElement>, i: number) => {
    if (s.status !== "playing") {
      if (s.status === "ready" || s.status === "paused") dispatch({ type: "resume" });
      return;
    }
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setKbd(false);
    setPicked(null);
    poke();
    setDrag({ from: i, x0: e.clientX, y0: e.clientY, dx: 0, dy: 0, over: i });
  };
  const onItemMove = (e: React.PointerEvent) => {
    if (!drag) return;
    setDrag({ ...drag, dx: e.clientX - drag.x0, dy: e.clientY - drag.y0, over: cellAt(e.clientX, e.clientY) });
  };
  const onItemUp = (e: React.PointerEvent) => {
    if (!drag) return;
    const moved = Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) > 8;
    const to = cellAt(e.clientX, e.clientY);
    if (!moved) dispatch({ type: "tap", cell: drag.from });
    else if (to !== null && to !== drag.from) dispatch({ type: "drop", from: drag.from, to });
    setDrag(null);
  };

  const cell = boardSize / COLS;
  const running = s.status === "playing";
  const goalsDone = s.goals.filter((g) => g.progress >= g.target).length;
  const topLevel = s.cells.reduce((m, c) => (c && c.kind === "plant" ? Math.max(m, c.level) : m), 0);

  return (
    <div
      ref={rootRef}
      tabIndex={0}
      data-status={s.status}
      aria-label={`${title} game`}
      className={cn("relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset", className)}
    >
      <header className="relative z-20 flex h-14 shrink-0 items-center gap-3 border-b bg-background/80 px-3 backdrop-blur sm:px-5">
        <GardenMark className="size-8 shrink-0" />
        <div className="min-w-0 leading-none">
          <p className="truncate font-serif text-lg font-semibold tracking-tight italic">{title}</p>
          <p className="mt-0.5 hidden text-[11px] text-muted-foreground sm:block">
            {s.merges} merges · best bloom {PLANT_NAMES[topLevel] || "—"}
          </p>
        </div>
        <div className="ml-auto flex items-center gap-2 sm:gap-5">
          <Stat label="Score" value={s.score} />
          <Stat label="Best" value={Math.max(best, s.score)} className="hidden sm:flex" />
          <div className="flex items-center gap-1">
            <IconButton label={running ? "Pause (P)" : "Resume (P)"} onClick={() => dispatch({ type: running ? "pause" : "resume" })} disabled={s.status === "over"}>
              {running ? <Pause className="size-4" /> : <Play className="size-4" />}
            </IconButton>
            <IconButton label={muted ? "Unmute (M)" : "Mute (M)"} onClick={() => setMuted((m) => !m)} pressed={!muted}>
              {muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
            </IconButton>
            <IconButton label="New garden (R)" onClick={restart}>
              <RotateCcw className="size-4" />
            </IconButton>
          </div>
        </div>
      </header>

      <div className="relative flex min-h-0 flex-1">
        <GardenBackdrop />

        {/* daily goals */}
        <aside className="relative z-10 hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-r bg-background/70 p-5 backdrop-blur lg:flex">
          <div className="flex items-center justify-between">
            <h3 className="flex items-center gap-2 text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">
              <Target className="size-3.5" aria-hidden /> Daily goal
            </h3>
            <span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300">
              {goalsDone}/{s.goals.length}
            </span>
          </div>
          <ul className="space-y-2.5">
            {s.goals.map((g) => {
              const done = g.progress >= g.target;
              return (
                <li key={g.id} className={cn("rounded-2xl border bg-card p-3 transition-colors", done && "border-emerald-500/40 bg-emerald-500/5")}>
                  <div className="flex items-center gap-3">
                    <span className={cn("grid size-10 shrink-0 place-items-center rounded-xl", done ? "bg-emerald-500 text-white" : "bg-muted")}>
                      {done ? <Check className="size-5" /> : g.type === "make" ? <ItemArt kind="plant" level={g.level ?? 1} reduce className="size-9" /> : g.type === "harvest" ? <Sparkles className="size-4 text-violet-500" /> : <Sprout className="size-4 text-emerald-600" />}
                    </span>
                    <div className="min-w-0 flex-1">
                      <p className="text-sm font-medium">{goalLabel(g)}</p>
                      <div className="mt-1.5 flex items-center gap-2">
                        <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
                          <motion.div className="h-full rounded-full bg-gradient-to-r from-lime-400 to-emerald-500" animate={{ width: `${(g.progress / g.target) * 100}%` }} transition={{ duration: reduce ? 0 : 0.4 }} />
                        </div>
                        <span className="font-mono text-[11px] text-muted-foreground tabular-nums">
                          {g.progress}/{g.target}
                        </span>
                      </div>
                    </div>
                  </div>
                </li>
              );
            })}
          </ul>
          <p className="text-xs text-muted-foreground">{s.dailyDone ? "Today’s bloom is complete. Come back tomorrow for new goals." : "Finish all three for +1000 points and 10 fresh seeds."}</p>
          <div className="mt-auto grid grid-cols-3 gap-2 text-center">
            <MiniStat label="Merges" value={s.merges} />
            <MiniStat label="Harvests" value={s.harvested} />
            <MiniStat label="Trees" value={s.made[4] + s.made[5]} />
          </div>
        </aside>

        {/* board */}
        <main className="relative z-10 flex min-w-0 flex-1 flex-col items-center gap-3 px-3 pt-3 pb-3 sm:px-6 sm:pt-5">
          {/* compact goals */}
          <div className="flex w-full max-w-md gap-1.5 lg:hidden" aria-label="Daily goals">
            {s.goals.map((g) => (
              <div key={g.id} className="min-w-0 flex-1 rounded-xl border bg-card/90 px-2 py-1.5 backdrop-blur">
                <p className="truncate text-[10px] font-medium">{goalLabel(g)}</p>
                <div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
                  <div className="h-full rounded-full bg-emerald-500 transition-[width]" style={{ width: `${(g.progress / g.target) * 100}%` }} />
                </div>
              </div>
            ))}
          </div>

          <div ref={boardWrapRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
            <div
              ref={boardRef}
              role="grid"
              aria-label="Garden bed"
              className="relative grid rounded-[28px] bg-emerald-900/10 p-1.5 shadow-[inset_0_2px_10px_rgba(0,0,0,0.12)] ring-1 ring-emerald-900/10 dark:bg-emerald-400/5 dark:ring-emerald-300/10"
              style={{ width: boardSize, height: boardSize, gridTemplateColumns: `repeat(${COLS}, 1fr)`, gridTemplateRows: `repeat(${ROWS}, 1fr)` }}
            >
              {s.cells.map((it, i) => {
                const r = Math.floor(i / COLS);
                const c = i % COLS;
                const dragging = drag?.from === i;
                const over = drag && drag.over === i && drag.from !== i ? dropResult(s.cells, drag.from, i) : null;
                const hinted = hint && (hint[0] === i || hint[1] === i);
                return (
                  <div
                    key={i}
                    role="gridcell"
                    aria-label={`Row ${r + 1}, column ${c + 1}: ${it ? (it.kind === "can" ? "Watering can" : PLANT_NAMES[it.level]) : "empty"}`}
                    aria-selected={picked === i}
                    className={cn("relative p-[3px]", dragging && "z-30")}
                  >
                    <div
                      className={cn(
                        "absolute inset-[3px] rounded-2xl transition-all duration-150",
                        (r + c) % 2 ? "bg-lime-100 dark:bg-emerald-950/80" : "bg-lime-200/70 dark:bg-emerald-900/50",
                        "shadow-[inset_0_-3px_0_rgba(0,0,0,0.06)]",
                        over === "merge" || over === "water" ? "scale-105 bg-amber-200 ring-2 ring-amber-400 dark:bg-amber-500/30" : over ? "ring-2 ring-emerald-500/50" : "",
                        kbd && cursor === i && "ring-2 ring-foreground/70",
                        picked === i && "ring-2 ring-amber-500",
                      )}
                    />
                    {!it && (i * 7) % 5 < 2 && (
                      <svg viewBox="0 0 20 12" className={cn("pointer-events-none absolute bottom-[18%] w-[26%] text-lime-600/35 dark:text-emerald-300/15", i % 2 ? "left-[18%]" : "right-[18%]")} aria-hidden>
                        <path d="M5 12 Q5 8 2 6 M8 12 Q8 6 6 2 M11 12 Q12 7 15 4 M14 12 Q15 10 18 9" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
                      </svg>
                    )}
                    {it && (
                      <ItemView
                        key={it.id}
                        item={it}
                        size={cell}
                        reduce={reduce}
                        fx={s.fx}
                        cellIndex={i}
                        hinted={!!hinted}
                        drag={dragging ? drag : null}
                        onPointerDown={(e) => onItemDown(e, i)}
                        onPointerMove={onItemMove}
                        onPointerUp={onItemUp}
                        onPointerCancel={() => setDrag(null)}
                      />
                    )}
                  </div>
                );
              })}
              {/* effects */}
              <div className="pointer-events-none absolute inset-1.5">
                {s.fx.map((f) => (
                  <FxView key={f.id} fx={f} cell={(boardSize - 12) / COLS} reduce={reduce} onDone={() => dispatch({ type: "fxDone", id: f.id })} />
                ))}
              </div>
            </div>
          </div>

          {/* basket row */}
          <div className="flex w-full max-w-md items-center justify-between gap-3">
            <div className="flex items-center gap-2" aria-label={`${s.energy} of ${MAX_ENERGY} seeds left`}>
              <span className="relative grid size-10 place-items-center">
                <svg viewBox="0 0 40 40" className="absolute inset-0 -rotate-90" aria-hidden>
                  <circle cx="20" cy="20" r="17" className="fill-none stroke-muted" strokeWidth="3" />
                  <circle cx="20" cy="20" r="17" className="fill-none stroke-amber-500 transition-[stroke-dashoffset] duration-700" strokeWidth="3" strokeLinecap="round" strokeDasharray={107} strokeDashoffset={107 * (1 - s.energy / MAX_ENERGY)} />
                </svg>
                <Bean className="size-4 text-amber-600 dark:text-amber-400" />
              </span>
              <div className="leading-tight">
                <p className="font-mono text-sm font-bold tabular-nums">
                  {s.energy}
                  <span className="text-muted-foreground">/{MAX_ENERGY}</span>
                </p>
                <p className="text-[10px] text-muted-foreground">seeds left</p>
              </div>
            </div>
            <motion.button
              type="button"
              onClick={spawn}
              aria-label="Plant from basket (B)"
              key={s.spawnTick}
              animate={reduce || s.spawnTick === 0 ? undefined : { scale: [1, 0.9, 1.06, 1], rotate: [0, -4, 3, 0] }}
              transition={{ duration: 0.35 }}
              className="group relative -mt-2 grid place-items-center rounded-3xl px-2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-50"
              disabled={s.status === "over"}
            >
              <BasketArt className="h-16 w-20 drop-shadow-md transition group-hover:-translate-y-0.5 group-active:scale-95" />
              <span className="absolute -top-1 -right-1 rounded-full bg-foreground px-1.5 py-0.5 text-[10px] font-bold text-background">−1</span>
            </motion.button>
            <div className="w-[88px] text-right text-[11px] leading-tight text-muted-foreground">
              <span className="hidden sm:inline">
                Tap basket or <Kbd>B</Kbd>
              </span>
              <span className="sm:hidden">Drag twins together</span>
            </div>
          </div>

          {/* toast */}
          <AnimatePresence>
            {s.notice && (
              <motion.p
                key={s.notice.id}
                role="status"
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: [0, 1, 1, 0], y: [8, 0, 0, -4] }}
                transition={{ duration: 2.4, times: [0, 0.1, 0.85, 1] }}
                className="pointer-events-none absolute bottom-24 left-1/2 z-30 -translate-x-1/2 rounded-full bg-foreground px-4 py-2 text-xs font-semibold whitespace-nowrap text-background shadow-lg"
              >
                {s.notice.text}
              </motion.p>
            )}
          </AnimatePresence>

          {/* overlays */}
          <AnimatePresence>
            {s.status !== "playing" && (
              <motion.div
                key={s.status}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reduce ? 0 : 0.25 }}
                className="absolute inset-0 z-40 grid place-items-center bg-background/60 p-6 backdrop-blur-sm"
              >
                <motion.div initial={reduce ? false : { y: 18, scale: 0.97 }} animate={{ y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 260, damping: 22 }} className="w-full max-w-sm rounded-3xl border bg-card p-6 text-center shadow-xl">
                  {s.status === "ready" && (
                    <>
                      <div className="mx-auto flex w-fit items-end gap-1">
                        {[1, 2, 3, 4, 5].map((l) => (
                          <ItemArt key={l} kind="plant" level={l} reduce={reduce} className={cn(l === 5 ? "size-14" : "size-10")} />
                        ))}
                      </div>
                      <h2 className="mt-3 font-serif text-3xl font-semibold italic">{title}</h2>
                      <p className="mt-2 text-sm text-muted-foreground">Plant seeds from the basket, then drag two of a kind together to help them grow, all the way to a magic tree.</p>
                      <PrimaryButton onClick={() => dispatch({ type: "start" })}>
                        <Sprout className="size-4" /> Start gardening
                      </PrimaryButton>
                      <p className="mt-3 hidden text-[11px] text-muted-foreground sm:block">Arrows + Enter to pick & drop · B basket · P pause</p>
                    </>
                  )}
                  {s.status === "paused" && (
                    <>
                      <h2 className="font-serif text-3xl font-semibold italic">Resting…</h2>
                      <p className="mt-2 text-sm text-muted-foreground">Your garden will wait right here.</p>
                      <PrimaryButton onClick={() => dispatch({ type: "resume" })}>
                        <Play className="size-4" /> Back to the garden
                      </PrimaryButton>
                    </>
                  )}
                  {s.status === "over" && (
                    <>
                      <p className="text-xs font-bold tracking-[0.2em] text-emerald-600 uppercase dark:text-emerald-400">Season complete</p>
                      <h2 className="mt-2 font-mono text-5xl font-black tabular-nums">{s.score}</h2>
                      {newBest ? (
                        <p className="mx-auto mt-3 inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-lime-400 to-emerald-500 px-3 py-1 text-xs font-bold text-white">
                          <Trophy className="size-3.5" /> New best!
                        </p>
                      ) : (
                        <p className="mt-2 text-xs text-muted-foreground">Best {best}</p>
                      )}
                      <p className="mt-3 text-sm text-muted-foreground">Out of seeds and no twins left to merge. {s.merges} merges, {s.harvested} harvests.</p>
                      <PrimaryButton onClick={restart}>
                        <RotateCcw className="size-4" /> Plant a new garden
                      </PrimaryButton>
                    </>
                  )}
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>
        </main>

        {/* almanac */}
        <aside className="relative z-10 hidden w-64 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-background/70 p-5 backdrop-blur xl:flex">
          <h3 className="text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">Almanac</h3>
          <ol className="space-y-1.5">
            {[1, 2, 3, 4, 5].map((l) => (
              <li key={l} className={cn("flex items-center gap-3 rounded-2xl border bg-card px-2.5 py-1.5", l > topLevel + 1 && "opacity-60")}>
                <ItemArt kind="plant" level={l} reduce className="size-10 shrink-0" />
                <div className="min-w-0 flex-1">
                  <p className="text-sm font-medium">{PLANT_NAMES[l]}</p>
                  <p className="text-[11px] text-muted-foreground">{l === 1 ? "From the basket" : l === MAX_LEVEL ? `Tap to harvest · +${HARVEST_POINTS}, +${HARVEST_SEEDS} seeds` : `2 × ${PLANT_NAMES[l - 1].toLowerCase()} · +${MERGE_POINTS[l]}${SEED_REWARD[l] ? `, +${SEED_REWARD[l]} seed${SEED_REWARD[l] > 1 ? "s" : ""}` : ""}`}</p>
                </div>
              </li>
            ))}
            <li className="flex items-center gap-3 rounded-2xl border border-dashed bg-card px-2.5 py-1.5">
              <ItemArt kind="can" level={0} reduce className="size-10 shrink-0" />
              <div className="min-w-0 flex-1">
                <p className="text-sm font-medium">Watering can</p>
                <p className="text-[11px] text-muted-foreground">Rare. Drop on any plant to grow it</p>
              </div>
            </li>
          </ol>
          <p className="mt-auto text-xs text-muted-foreground">Trees return seeds and harvests return more. The season ends when you run out of seeds with no twins left to merge.</p>
        </aside>
      </div>
      <p className="sr-only" aria-live="polite">
        {s.status === "over" ? `Season complete. Score ${s.score}.` : s.status === "paused" ? "Paused" : picked !== null ? "Item picked up. Move and press Enter to drop." : ""}
      </p>
    </div>
  );
}

interface ItemViewProps {
  item: Item;
  size: number;
  reduce: boolean;
  fx: Fx[];
  cellIndex: number;
  hinted: boolean;
  drag: { dx: number; dy: number } | null;
  onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
  onPointerMove: (e: React.PointerEvent) => void;
  onPointerUp: (e: React.PointerEvent) => void;
  onPointerCancel: () => void;
}

function ItemView({ item, size, reduce, fx, cellIndex, hinted, drag, ...handlers }: ItemViewProps) {
  // choose the entrance based on why this item appeared
  const [entrance] = React.useState(() => {
    const f = fx.find((x) => x.cell === cellIndex);
    return f?.kind === "spawn" ? "spawn" : f?.kind === "merge" || f?.kind === "water" || f?.kind === "harvest" ? "merge" : "settle";
  });
  const initial = reduce ? false : entrance === "spawn" ? { y: -size * 0.8, scale: 0.3, opacity: 0 } : entrance === "merge" ? { scale: 0.3 } : { scale: 0.85, opacity: 0.7 };
  const animate = entrance === "merge" && !reduce ? { y: 0, scale: [0.3, 1.28, 0.94, 1], opacity: 1 } : { y: 0, scale: 1, opacity: 1 };
  return (
    <div
      {...handlers}
      className={cn("absolute inset-0 cursor-grab touch-none select-none active:cursor-grabbing", item.kind === "plant" && item.level === MAX_LEVEL && "cursor-pointer")}
      style={drag ? { transform: `translate(${drag.dx}px, ${drag.dy}px) scale(1.15)`, zIndex: 40, filter: "drop-shadow(0 10px 10px rgba(0,0,0,0.25))" } : undefined}
    >
      <motion.div
        initial={initial}
        animate={animate}
        transition={entrance === "merge" ? { duration: 0.45, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 22 }}
        className="grid size-full place-items-center"
      >
        <motion.div
          className="size-[88%]"
          animate={hinted && !reduce ? { rotate: [0, -8, 8, -5, 0] } : { rotate: 0 }}
          transition={hinted ? { duration: 0.7, repeat: Infinity, repeatDelay: 0.8 } : { duration: 0.2 }}
        >
          <ItemArt kind={item.kind} level={item.level} reduce={reduce} className="size-full" />
        </motion.div>
      </motion.div>
    </div>
  );
}

function FxView({ fx, cell, reduce, onDone }: { fx: Fx; cell: number; reduce: boolean; onDone: () => void }) {
  const cx = (fx.cell % COLS) * cell + cell / 2;
  const cy = Math.floor(fx.cell / COLS) * cell + cell / 2;
  const doneRef = React.useRef(onDone);
  React.useEffect(() => {
    doneRef.current = onDone;
  }, [onDone]);
  React.useEffect(() => {
    const t = window.setTimeout(() => doneRef.current(), reduce ? 400 : fx.kind === "daily" ? 2600 : 1100);
    return () => window.clearTimeout(t);
  }, [fx.kind, reduce]);

  if (fx.kind === "daily") {
    return (
      <motion.div
        initial={{ opacity: 0, scale: 0.7 }}
        animate={{ opacity: [0, 1, 1, 0], scale: [0.7, 1.05, 1, 1] }}
        transition={{ duration: 2.4, times: [0, 0.15, 0.8, 1] }}
        className="absolute inset-0 grid place-items-center"
      >
        <div className="rounded-3xl bg-gradient-to-br from-lime-300 via-emerald-400 to-teal-500 px-6 py-4 text-center text-white shadow-2xl">
          <Sparkles className="mx-auto size-6" />
          <p className="mt-1 font-serif text-2xl font-semibold italic">{fx.text}</p>
        </div>
      </motion.div>
    );
  }
  const n = fx.kind === "spawn" ? 5 : fx.kind === "harvest" ? 16 : 10;
  const spread = fx.kind === "spawn" ? cell * 0.45 : fx.kind === "harvest" ? cell * 1.6 : cell * 0.95;
  return (
    <div className="absolute" style={{ left: cx, top: cy }}>
      {!reduce && (
        <motion.span
          className={cn("absolute rounded-full border-2", fx.kind === "harvest" ? "border-amber-400" : fx.kind === "spawn" ? "border-amber-700/30" : "border-emerald-400")}
          style={{ width: cell, height: cell, left: -cell / 2, top: -cell / 2 }}
          initial={{ scale: 0.3, opacity: 0.9 }}
          animate={{ scale: fx.kind === "harvest" ? 2.4 : 1.5, opacity: 0 }}
          transition={{ duration: 0.6, ease: "easeOut" }}
        />
      )}
      {!reduce &&
        Array.from({ length: n }, (_, i) => {
          const a = (i / n) * Math.PI * 2 + fx.id;
          const color = fx.kind === "spawn" ? "#a16207" : fx.kind === "harvest" ? ["#fde047", "#f0abfc", "#a78bfa"][i % 3] : LEAF_COLORS[(i + fx.level) % LEAF_COLORS.length];
          const sz = fx.kind === "spawn" ? 4 : 7;
          return (
            <motion.span
              key={i}
              className="absolute"
              style={{ width: sz, height: sz * (fx.kind === "spawn" ? 1 : 1.6), left: -sz / 2, top: -sz / 2, background: color, borderRadius: fx.kind === "spawn" ? 999 : "999px 0 999px 0" }}
              initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 1 }}
              animate={{ x: Math.cos(a) * spread, y: Math.sin(a) * spread + (fx.kind === "spawn" ? 0 : cell * 0.25), opacity: 0, rotate: 200, scale: 0.6 }}
              transition={{ duration: 0.7 + (i % 3) * 0.1, ease: "easeOut" }}
            />
          );
        })}
      {fx.text && (
        <motion.span
          className={cn("absolute -translate-x-1/2 font-mono text-sm font-black whitespace-nowrap drop-shadow", fx.kind === "harvest" ? "text-amber-500" : "text-emerald-600 dark:text-emerald-300")}
          style={{ top: -cell * 0.55 }}
          initial={{ y: 0, opacity: 0 }}
          animate={{ y: -cell * 0.5, opacity: [0, 1, 1, 0] }}
          transition={{ duration: 1, times: [0, 0.15, 0.7, 1] }}
        >
          {fx.text}
        </motion.span>
      )}
    </div>
  );
}

function GardenBackdrop() {
  return (
    <div className="pointer-events-none absolute inset-0 overflow-hidden bg-gradient-to-b from-sky-50 via-lime-50 to-emerald-50 dark:from-slate-950 dark:via-slate-950 dark:to-emerald-950/60" aria-hidden>
      <svg viewBox="0 0 1200 300" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 h-1/3 w-full">
        <path d="M0 160 C200 90 380 140 560 110 C760 80 940 150 1200 100 V300 H0 Z" className="fill-lime-200/70 dark:fill-emerald-900/40" />
        <path d="M0 220 C240 170 420 230 640 190 C860 150 1000 220 1200 180 V300 H0 Z" className="fill-emerald-200/70 dark:fill-emerald-900/60" />
      </svg>
      <div className="absolute top-8 right-[12%] size-16 rounded-full bg-amber-200/70 blur-[2px] dark:bg-slate-200/80 dark:shadow-[0_0_60px_10px_rgba(226,232,240,0.25)]" />
      <div className="absolute top-10 right-[calc(12%-10px)] hidden size-14 rounded-full bg-slate-950 dark:block" />
      {[
        [8, 30],
        [22, 60],
        [70, 40],
        [85, 70],
        [45, 20],
      ].map(([x, y], i) => (
        <span key={i} className="absolute hidden size-1.5 animate-pulse rounded-full bg-yellow-200 shadow-[0_0_8px_2px_rgba(253,224,71,0.6)] dark:block" style={{ left: `${x}%`, top: `${y}%`, animationDelay: `${i * 0.4}s` }} />
      ))}
    </div>
  );
}

function PrimaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="mt-5 inline-flex h-11 items-center gap-2 rounded-full bg-gradient-to-r from-lime-500 to-emerald-600 px-6 text-sm font-bold text-white shadow-lg shadow-emerald-500/25 transition hover:brightness-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none active:scale-95"
    >
      {children}
    </button>
  );
}

function IconButton({ label, onClick, children, disabled, pressed }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean; pressed?: boolean }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      disabled={disabled}
      onClick={onClick}
      className="grid size-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-40"
    >
      {children}
    </button>
  );
}

function Stat({ label, value, className }: { label: string; value: number; className?: string }) {
  return (
    <div className={cn("flex flex-col items-end leading-none", className)}>
      <span className="text-[9px] font-bold tracking-[0.16em] text-muted-foreground uppercase">{label}</span>
      <motion.span key={value} initial={{ scale: 1.15 }} animate={{ scale: 1 }} className="mt-1 font-mono text-lg font-black tabular-nums">
        {value}
      </motion.span>
    </div>
  );
}

function MiniStat({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-xl border bg-card px-2 py-2">
      <p className="text-[9px] font-bold tracking-wider text-muted-foreground uppercase">{label}</p>
      <p className="font-mono text-base font-bold tabular-nums">{value}</p>
    </div>
  );
}

function Kbd({ children }: { children: React.ReactNode }) {
  return <kbd className="rounded border bg-card px-1 py-0.5 font-mono text-[10px] text-foreground">{children}</kbd>;
}

More in Games

View all →