// 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();
};