interactives/src/components/DominoRetilingPuzzle.tsx
gpt-engineer-app[bot] 56c73bf714 Changes
2025-12-08 09:41:19 +00:00

480 lines
No EOL
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, ChevronDown, ChevronUp, Shuffle, Lightbulb } from 'lucide-react';
import SocialShare from '@/components/SocialShare';
interface Domino {
id: number;
row: number;
col: number;
isHorizontal: boolean;
}
interface DominoRetilingPuzzleProps {
showSocialShare?: boolean;
}
const DominoRetilingPuzzle: React.FC<DominoRetilingPuzzleProps> = ({ showSocialShare = true }) => {
const [boardSize, setBoardSize] = useState(8);
const [dominoes, setDominoes] = useState<Domino[]>([]);
const [selectedDomino, setSelectedDomino] = useState<number | null>(null);
const [showInstructions, setShowInstructions] = useState(false);
const [moveCount, setMoveCount] = useState(0);
const [showHint, setShowHint] = useState(false);
const [hoverPlacement, setHoverPlacement] = useState<{ row: number; col: number; isHorizontal: boolean } | null>(null);
const BOARD_ROWS = boardSize;
const BOARD_COLS = boardSize + 1; // n + 1 extra square on top row
// Generate a random valid tiling of the n×n board using backtracking
const generateRandomTiling = useCallback(() => {
const newDominoes: Domino[] = [];
const occupied = new Set<string>();
let dominoId = 0;
// Helper to find first uncovered cell
const findFirstUncovered = (): { row: number; col: number } | null => {
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
if (!occupied.has(`${row},${col}`)) {
return { row, col };
}
}
}
return null;
};
// Greedy fill with backtracking if needed
while (true) {
const cell = findFirstUncovered();
if (!cell) break; // All covered
const { row, col } = cell;
// Try to place a domino here
const canHorizontal = col + 1 < boardSize && !occupied.has(`${row},${col + 1}`);
const canVertical = row + 1 < boardSize && !occupied.has(`${row + 1},${col}`);
if (!canHorizontal && !canVertical) {
// Stuck - this shouldn't happen with even board sizes, but reset if it does
newDominoes.length = 0;
occupied.clear();
dominoId = 0;
continue;
}
let isHorizontal: boolean;
if (canHorizontal && canVertical) {
isHorizontal = Math.random() > 0.5;
} else {
isHorizontal = canHorizontal;
}
newDominoes.push({
id: dominoId++,
row,
col,
isHorizontal
});
occupied.add(`${row},${col}`);
if (isHorizontal) {
occupied.add(`${row},${col + 1}`);
} else {
occupied.add(`${row + 1},${col}`);
}
}
setDominoes(newDominoes);
setSelectedDomino(null);
setMoveCount(0);
setHoverPlacement(null);
}, [boardSize]);
useEffect(() => {
generateRandomTiling();
}, [generateRandomTiling]);
// Get all cells occupied by dominoes
const getOccupiedCells = useCallback((excludeDominoId?: number): Set<string> => {
const occupied = new Set<string>();
dominoes.forEach(d => {
if (d.id === excludeDominoId) return;
occupied.add(`${d.row},${d.col}`);
if (d.isHorizontal) {
occupied.add(`${d.row},${d.col + 1}`);
} else {
occupied.add(`${d.row + 1},${d.col}`);
}
});
return occupied;
}, [dominoes]);
// Check if a position is valid for the augmented board
const isValidCell = useCallback((row: number, col: number): boolean => {
if (row < 0 || row >= BOARD_ROWS) return false;
if (row === 0) return col >= 0 && col < BOARD_COLS;
return col >= 0 && col < boardSize;
}, [BOARD_ROWS, BOARD_COLS, boardSize]);
// Check if we can place a domino at a position
const canPlaceDomino = useCallback((row: number, col: number, isHorizontal: boolean, excludeDominoId: number): boolean => {
const cell1Row = row;
const cell1Col = col;
const cell2Row = isHorizontal ? row : row + 1;
const cell2Col = isHorizontal ? col + 1 : col;
if (!isValidCell(cell1Row, cell1Col) || !isValidCell(cell2Row, cell2Col)) return false;
const occupied = getOccupiedCells(excludeDominoId);
return !occupied.has(`${cell1Row},${cell1Col}`) && !occupied.has(`${cell2Row},${cell2Col}`);
}, [getOccupiedCells]);
// Handle clicking on a domino
const handleDominoClick = (dominoId: number) => {
if (selectedDomino === dominoId) {
setSelectedDomino(null);
setHoverPlacement(null);
} else {
setSelectedDomino(dominoId);
setHoverPlacement(null);
}
};
// Handle clicking on a valid placement
const handlePlacementClick = (row: number, col: number, isHorizontal: boolean) => {
if (selectedDomino === null) return;
setDominoes(prev => prev.map(d =>
d.id === selectedDomino
? { ...d, row, col, isHorizontal }
: d
));
setSelectedDomino(null);
setHoverPlacement(null);
setMoveCount(prev => prev + 1);
};
// Handle cell hover
const handleCellHover = (row: number, col: number) => {
if (selectedDomino === null) return;
// Check which placements are valid for this cell
const canH = canPlaceDomino(row, col, true, selectedDomino);
const canV = canPlaceDomino(row, col, false, selectedDomino);
if (canH && canV) {
// Prefer horizontal
setHoverPlacement({ row, col, isHorizontal: true });
} else if (canH) {
setHoverPlacement({ row, col, isHorizontal: true });
} else if (canV) {
setHoverPlacement({ row, col, isHorizontal: false });
} else {
setHoverPlacement(null);
}
};
// Check if puzzle is solved (all dominoes horizontal)
const isSolved = dominoes.length > 0 && dominoes.every(d => d.isHorizontal);
// Get domino color based on orientation
const getDominoColor = (isHorizontal: boolean): string => {
if (isHorizontal) return 'bg-emerald-500 border-emerald-700';
return 'bg-rose-400 border-rose-600';
};
const cellSize = Math.max(32, Math.min(44, 480 / boardSize));
// Render the board
const renderBoard = () => {
const cells: JSX.Element[] = [];
// Render the inaccessible shaded region (last column except top cell)
for (let row = 1; row < BOARD_ROWS; row++) {
cells.push(
<div
key={`inaccessible-${row}`}
className="border border-border/30"
style={{
position: 'absolute',
left: boardSize * cellSize,
top: row * cellSize,
width: cellSize,
height: cellSize,
backgroundColor: 'rgba(100, 100, 100, 0.3)',
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(80, 80, 80, 0.4) 4px, rgba(80, 80, 80, 0.4) 8px)',
}}
/>
);
}
// Render cells
for (let row = 0; row < BOARD_ROWS; row++) {
const maxCol = row === 0 ? BOARD_COLS : boardSize;
for (let col = 0; col < maxCol; col++) {
const isExtraSquare = row === 0 && col === boardSize;
const cellStyle: React.CSSProperties = {
position: 'absolute',
left: col * cellSize,
top: row * cellSize,
width: cellSize,
height: cellSize,
};
cells.push(
<div
key={`cell-${row}-${col}`}
className={`border border-border/50 ${
isExtraSquare
? 'bg-blue-400/50'
: (row + col) % 2 === 0 ? 'bg-muted/30' : 'bg-muted/60'
} ${selectedDomino !== null ? 'cursor-pointer' : ''}`}
style={cellStyle}
onMouseEnter={() => handleCellHover(row, col)}
onMouseLeave={() => setHoverPlacement(null)}
onClick={() => {
if (hoverPlacement && hoverPlacement.row === row && hoverPlacement.col === col) {
handlePlacementClick(hoverPlacement.row, hoverPlacement.col, hoverPlacement.isHorizontal);
}
}}
/>
);
}
}
// Render dominoes (except selected one)
dominoes.forEach(d => {
if (d.id === selectedDomino) return; // Hide selected domino
const width = d.isHorizontal ? cellSize * 2 - 4 : cellSize - 4;
const height = d.isHorizontal ? cellSize - 4 : cellSize * 2 - 4;
cells.push(
<div
key={`domino-${d.id}`}
className={`absolute rounded border-2 cursor-pointer transition-all z-20 ${getDominoColor(d.isHorizontal)} hover:brightness-110`}
style={{
left: d.col * cellSize + 2,
top: d.row * cellSize + 2,
width,
height,
}}
onClick={() => handleDominoClick(d.id)}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className={`w-2 h-2 rounded-full ${d.isHorizontal ? 'bg-emerald-700' : 'bg-rose-700'}`} />
</div>
</div>
);
});
// Render hover preview domino
if (hoverPlacement && selectedDomino !== null) {
const width = hoverPlacement.isHorizontal ? cellSize * 2 - 4 : cellSize - 4;
const height = hoverPlacement.isHorizontal ? cellSize - 4 : cellSize * 2 - 4;
cells.push(
<div
key="hover-preview"
className="absolute rounded border-2 cursor-pointer z-30 bg-amber-400 border-amber-600 opacity-80 hover:opacity-100 transition-opacity"
style={{
left: hoverPlacement.col * cellSize + 2,
top: hoverPlacement.row * cellSize + 2,
width,
height,
pointerEvents: 'none',
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-amber-700" />
</div>
</div>
);
}
return cells;
};
const horizontalCount = dominoes.filter(d => d.isHorizontal).length;
const verticalCount = dominoes.filter(d => !d.isHorizontal).length;
const numDominoes = (boardSize * boardSize) / 2;
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<Card className="bg-gradient-to-r from-primary/10 to-secondary/10 border-primary/20">
<CardHeader className="text-center">
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Domino Retiling Puzzle
</CardTitle>
<p className="text-muted-foreground mt-2">
Can you retile the augmented board so all dominoes are horizontal?
</p>
</CardHeader>
<CardContent>
<div className="text-center space-y-4">
<p className="text-lg text-muted-foreground">
A {boardSize}×{boardSize} board is tiled with {numDominoes} dominoes. An extra square is added to the top-right.
Move dominoes one at a time to make all dominoes horizontal.
</p>
<div className="flex flex-wrap justify-center gap-4 items-center">
<div className="flex items-center gap-3">
<label className="text-sm font-medium">Board Size: {boardSize}×{boardSize}</label>
<input
type="range"
min="2"
max="12"
step="2"
value={boardSize}
onChange={(e) => setBoardSize(parseInt(e.target.value))}
className="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer"
/>
</div>
<Button onClick={generateRandomTiling} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
New Puzzle
</Button>
<Button onClick={() => setShowHint(!showHint)} variant="outline" size="sm">
<Lightbulb className="w-4 h-4 mr-2" />
{showHint ? 'Hide Hint' : 'Show Hint'}
</Button>
</div>
<div className="flex justify-center items-center gap-4 flex-wrap">
<Badge variant="outline" className="text-sm">
Moves: {moveCount}
</Badge>
<Badge variant="outline" className="text-sm bg-emerald-100 text-emerald-800 border-emerald-300">
Horizontal: {horizontalCount}
</Badge>
<Badge variant="outline" className="text-sm bg-rose-100 text-rose-800 border-rose-300">
Vertical: {verticalCount}
</Badge>
</div>
</div>
</CardContent>
</Card>
{showHint && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium mb-1">Hint:</p>
<p>Consider the coloring argument: in a standard chessboard coloring, each horizontal domino covers one black and one white square.
But on the augmented board (8×8 + 1 extra square), the parity changes. Think about what this means for the possibility of an all-horizontal tiling!</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Instructions */}
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowInstructions(!showInstructions)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Instructions</CardTitle>
{showInstructions ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</CardHeader>
{showInstructions && (
<CardContent className="pt-0">
<div className="space-y-4">
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Click on a domino to pick it up (it disappears from the board)</li>
<li>Hover over empty cells to see where you can place it</li>
<li>Click to place the domino in the highlighted position</li>
<li>You can only move a domino if there are two adjacent empty squares to receive it</li>
<li>Goal: Make all {numDominoes} dominoes horizontal</li>
</ul>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-emerald-500" />
<span>Horizontal</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-rose-400" />
<span>Vertical</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-amber-400" />
<span>Selected</span>
</div>
</div>
</div>
</CardContent>
)}
</Card>
{/* Board */}
<Card>
<CardContent className="p-8">
<div className="flex justify-center">
<div
className="relative bg-background rounded-lg border border-border shadow-sm p-3"
>
<div
className="relative"
style={{
width: BOARD_COLS * cellSize,
height: BOARD_ROWS * cellSize,
}}
>
{renderBoard()}
</div>
</div>
</div>
{selectedDomino !== null && (
<p className="text-center text-sm text-muted-foreground mt-4">
Domino picked up. Hover over empty cells to preview placement, then click to place.
</p>
)}
</CardContent>
</Card>
{/* Result */}
{isSolved && (
<Card className="border-green-200 bg-green-50">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-600" />
<div>
<p className="font-medium text-green-800">
Wait... You solved it? That shouldn't be possible!
</p>
<p className="text-sm text-green-700 mt-1">
Actually, this puzzle is impossible to solve! The extra square creates a parity imbalance that prevents an all-horizontal tiling.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Social Share */}
{showSocialShare && (
<SocialShare
title="Domino Retiling Puzzle"
description="Can you retile the augmented 8×8+1 board so that all 32 dominoes are horizontal? A classic impossibility puzzle!"
url="/puzzles/domino-retiling"
/>
)}
</div>
);
};
export default DominoRetilingPuzzle;