// Hearts — a Puyo-style block puzzle game. // 6 columns × 12 rows. Pairs of coloured hearts drop, rotate, land. // 4+ connected same-colour pop in a fiery explosion; gravity collapses; chains multiply score. const COLS = 6; const ROWS = 12; // Five colours — sprite is naturally blue, others use CSS hue-rotate filters. const COLORS = ["B", "R", "Y", "G", "P"]; const COLOR_LABEL = { B: "blue", R: "red", Y: "yellow", G: "green", P: "pink", }; function emptyBoard() { return Array.from({ length: ROWS }, () => Array(COLS).fill(null)); } function randColor() { return COLORS[Math.floor(Math.random() * COLORS.length)]; } function newPair() { return { a: randColor(), // pivot b: randColor(), // partner x: 2, // pivot column y: 0, // pivot row rot: 0, // 0 = partner up, 1 = right, 2 = down, 3 = left }; } function partnerCell(p) { switch (p.rot) { case 0: return [p.x, p.y - 1]; case 1: return [p.x + 1, p.y]; case 2: return [p.x, p.y + 1]; case 3: return [p.x - 1, p.y]; } } function valid(board, p) { const [bx, by] = partnerCell(p); for (const [x, y] of [[p.x, p.y], [bx, by]]) { if (x < 0 || x >= COLS || y >= ROWS) return false; if (y >= 0 && board[y][x]) return false; } return true; } function landPair(board, p) { const next = board.map(r => r.slice()); const [bx, by] = partnerCell(p); if (p.y >= 0 && p.y < ROWS) next[p.y][p.x] = p.a; if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) next[by][bx] = p.b; return next; } function applyGravity(board) { const next = board.map(r => r.slice()); for (let x = 0; x < COLS; x++) { let write = ROWS - 1; for (let y = ROWS - 1; y >= 0; y--) { if (next[y][x]) { const v = next[y][x]; next[y][x] = null; next[write][x] = v; write--; } } } return next; } function findGroups(board) { const seen = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); const groups = []; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if (!board[y][x] || seen[y][x]) continue; const color = board[y][x]; const stack = [[x, y]]; const group = []; while (stack.length) { const [cx, cy] = stack.pop(); if (cx < 0 || cx >= COLS || cy < 0 || cy >= ROWS) continue; if (seen[cy][cx]) continue; if (board[cy][cx] !== color) continue; seen[cy][cx] = true; group.push([cx, cy]); stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); } if (group.length >= 4) groups.push({ color, cells: group }); } } return groups; } function Cell({ color, popping, falling }) { if (popping) { return (
); } if (!color) return
; const cls = "cell filled heart-" + COLOR_LABEL[color] + (falling ? " falling" : ""); return (
); } function NextCell({ color }) { if (!color) return
; return (
); } function Game() { const [board, setBoard] = React.useState(emptyBoard); const [pair, setPair] = React.useState(newPair); const [next1, setNext1] = React.useState(() => ({ a: randColor(), b: randColor() })); const [score, setScore] = React.useState(0); const [chain, setChain] = React.useState(0); const [best, setBest] = React.useState(() => +(localStorage.getItem("rv_hearts_best") || 0)); const [started, setStarted] = React.useState(false); const [gameOver, setGameOver] = React.useState(false); const [paused, setPaused] = React.useState(false); const [popping, setPopping] = React.useState(new Set()); const [resolving, setResolving] = React.useState(false); const stateRef = React.useRef({ board, pair, resolving, paused, gameOver, started }); React.useEffect(() => { stateRef.current = { board, pair, resolving, paused, gameOver, started }; }, [board, pair, resolving, paused, gameOver, started]); // Resolve chains const resolve = React.useCallback(async (b) => { setResolving(true); let cur = applyGravity(b); setBoard(cur); let localChain = 0; while (true) { const groups = findGroups(cur); if (groups.length === 0) break; localChain++; const popSet = new Set(); let popCount = 0; for (const g of groups) { for (const [x, y] of g.cells) popSet.add(`${x},${y}`); popCount += g.cells.length; } // Show explosion frames over the popping cells (cells temporarily blank // so the explosion shows on top; the explosion lasts ~520ms). setPopping(popSet); setChain(localChain); setScore(s => s + popCount * 10 * localChain); // Blank the cells underneath the explosion while it animates const blanked = cur.map(r => r.slice()); for (const g of groups) for (const [x, y] of g.cells) blanked[y][x] = null; setBoard(blanked); await new Promise(r => setTimeout(r, 1100)); setPopping(new Set()); cur = applyGravity(blanked); setBoard(cur); await new Promise(r => setTimeout(r, 200)); } setChain(0); setResolving(false); return cur; }, []); const spawnNext = React.useCallback((b) => { const fresh = { a: next1.a, b: next1.b, x: 2, y: 0, rot: 0 }; if (!valid(b, fresh)) { setGameOver(true); setBest(prev => { const newBest = Math.max(prev, score); localStorage.setItem("rv_hearts_best", newBest); return newBest; }); return; } setPair(fresh); setNext1({ a: randColor(), b: randColor() }); }, [next1, score]); // Drop tick React.useEffect(() => { if (!started || gameOver || paused || resolving) return; const id = setInterval(() => { setPair(p => { const moved = { ...p, y: p.y + 1 }; if (valid(stateRef.current.board, moved)) return moved; const newBoard = landPair(stateRef.current.board, p); resolve(newBoard).then(final => spawnNext(final)); return p; }); }, 620); return () => clearInterval(id); }, [started, gameOver, paused, resolving, resolve, spawnNext]); // Input React.useEffect(() => { const onKey = (e) => { if (!started) { if (e.key === "Enter" || e.key === " ") start(); return; } if (gameOver) { if (e.key === "Enter" || e.key === " ") restart(); return; } if (e.key === "p" || e.key === "P") { setPaused(x => !x); return; } if (paused || resolving) return; const b = stateRef.current.board; setPair(p => { let np = p; if (e.key === "ArrowLeft") np = { ...p, x: p.x - 1 }; else if (e.key === "ArrowRight") np = { ...p, x: p.x + 1 }; else if (e.key === "ArrowDown") np = { ...p, y: p.y + 1 }; else if (e.key === "z" || e.key === "Z" || e.key === "ArrowUp") { np = { ...p, rot: (p.rot + 3) % 4 }; if (!valid(b, np)) np = { ...np, x: np.x + (np.rot === 3 ? 1 : np.rot === 1 ? -1 : 0) }; } else if (e.key === "x" || e.key === "X") { np = { ...p, rot: (p.rot + 1) % 4 }; if (!valid(b, np)) np = { ...np, x: np.x + (np.rot === 3 ? 1 : np.rot === 1 ? -1 : 0) }; } else if (e.key === " ") { let cur = p; while (valid(b, { ...cur, y: cur.y + 1 })) cur = { ...cur, y: cur.y + 1 }; const newBoard = landPair(b, cur); resolve(newBoard).then(final => spawnNext(final)); e.preventDefault(); return cur; } else { return p; } return valid(b, np) ? np : p; }); if (["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", " "].includes(e.key)) { e.preventDefault(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [started, gameOver, paused, resolving, resolve, spawnNext]); function start() { setBoard(emptyBoard()); setPair(newPair()); setNext1({ a: randColor(), b: randColor() }); setScore(0); setChain(0); setGameOver(false); setPaused(false); setStarted(true); } function restart() { start(); } // Render board with active pair overlay. Cells from the active pair are // marked `falling` so CSS can spin them; settled cells freeze on frame 0. const display = board.map(r => r.slice()); const falling = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); if (started && !gameOver && !resolving) { const [bx, by] = partnerCell(pair); if (pair.y >= 0 && pair.y < ROWS && pair.x >= 0 && pair.x < COLS) { display[pair.y][pair.x] = pair.a; falling[pair.y][pair.x] = true; } if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) { display[by][bx] = pair.b; falling[by][bx] = true; } } return (
SCORE
{String(score).padStart(6, "0")}
HI-SCORE
{String(Math.max(best, score)).padStart(6, "0")}
{display.map((row, y) => (
{row.map((c, x) => ( ))}
))}
{chain > 0 && (
{chain}× CHAIN!
)} {(!started || gameOver || paused) && (
{!started ? ( <>
HEARTS
PRESS START O INVIO
) : gameOver ? ( <>
GAME OVER
PRESS ENTER OR TAP
) : ( <>
PAUSED
PRESS P TO RESUME
)}
)}
NEXT
{/* on-screen pad for touch */}
); } function fakeKey(key) { window.dispatchEvent(new KeyboardEvent("keydown", { key })); } window.__mountGame = function (el) { ReactDOM.createRoot(el).render(); };