"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>;
}