Add Dogs & Bunny puzzle
Recreate the Dogs & Bunny puzzle with random puzzle generation, difficulty levels, screenshot functionality, and shareable URL parameters.
This commit is contained in:
parent
c257a5f531
commit
f33b2bfa4f
6 changed files with 669 additions and 0 deletions
50
package-lock.json
generated
50
package-lock.json
generated
|
|
@ -43,6 +43,7 @@
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
|
@ -3360,6 +3361,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|
@ -4048,6 +4058,15 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -4890,6 +4909,19 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -6901,6 +6933,15 @@
|
||||||
"tailwindcss": ">=3.0.0 || insiders"
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-table": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
|
|
@ -7124,6 +7165,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage';
|
||||||
import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage';
|
import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage';
|
||||||
import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage';
|
import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage';
|
||||||
import NQueensPuzzlePage from './pages/NQueensPuzzlePage';
|
import NQueensPuzzlePage from './pages/NQueensPuzzlePage';
|
||||||
|
import DogsBunnyPuzzlePage from './pages/DogsBunnyPuzzlePage';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
|
@ -104,6 +105,7 @@ const App = () => (
|
||||||
<Route path="/puzzles/plate-swap" element={<PlateSwapPuzzlePage />} />
|
<Route path="/puzzles/plate-swap" element={<PlateSwapPuzzlePage />} />
|
||||||
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
|
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
|
||||||
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
|
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
|
||||||
|
<Route path="/puzzles/dogs-bunny" element={<DogsBunnyPuzzlePage />} />
|
||||||
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
|
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
|
||||||
<Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} />
|
<Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} />
|
||||||
|
|
||||||
|
|
|
||||||
468
src/components/DogsBunnyPuzzle.tsx
Normal file
468
src/components/DogsBunnyPuzzle.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Share2, Download, RotateCcw, Shuffle, Trophy } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
|
type Location = {
|
||||||
|
id: string;
|
||||||
|
type: 'carrot' | 'bone' | 'flower' | 'well' | 'empty';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Character = {
|
||||||
|
id: string;
|
||||||
|
type: 'bunny' | 'dog';
|
||||||
|
locationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
conditions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PuzzleState = {
|
||||||
|
locations: Location[];
|
||||||
|
characters: Character[];
|
||||||
|
edges: Edge[];
|
||||||
|
moveCount: number;
|
||||||
|
isComplete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DogsBunnyPuzzleProps {
|
||||||
|
difficulty?: 'easy' | 'medium' | 'hard';
|
||||||
|
seed?: string;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DogsBunnyPuzzle: React.FC<DogsBunnyPuzzleProps> = ({
|
||||||
|
difficulty = 'medium',
|
||||||
|
seed,
|
||||||
|
onComplete
|
||||||
|
}) => {
|
||||||
|
const [puzzleState, setPuzzleState] = useState<PuzzleState | null>(null);
|
||||||
|
const [selectedCharacter, setSelectedCharacter] = useState<string | null>(null);
|
||||||
|
const [validMoves, setValidMoves] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Generate puzzle based on difficulty and seed
|
||||||
|
const generatePuzzle = useCallback((diff: string, puzzleSeed?: string): PuzzleState => {
|
||||||
|
const rng = puzzleSeed ? seededRandom(puzzleSeed) : Math.random;
|
||||||
|
|
||||||
|
// Create locations in a circular/connected pattern
|
||||||
|
const locations: Location[] = [
|
||||||
|
{ id: 'loc1', type: 'carrot', x: 150, y: 50 },
|
||||||
|
{ id: 'loc2', type: 'bone', x: 350, y: 100 },
|
||||||
|
{ id: 'loc3', type: 'flower', x: 400, y: 250 },
|
||||||
|
{ id: 'loc4', type: 'well', x: 300, y: 350 },
|
||||||
|
{ id: 'loc5', type: 'empty', x: 150, y: 350 },
|
||||||
|
{ id: 'loc6', type: 'empty', x: 50, y: 250 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create characters - bunnies start away from carrot, dog away from bone
|
||||||
|
const characters: Character[] = [
|
||||||
|
{ id: 'bunny1', type: 'bunny', locationId: 'loc4' },
|
||||||
|
{ id: 'bunny2', type: 'bunny', locationId: 'loc5' },
|
||||||
|
{ id: 'dog1', type: 'dog', locationId: 'loc6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate edges with conditions based on difficulty
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
const locationTypes = ['carrot', 'bone', 'flower', 'well', 'bunny', 'dog'];
|
||||||
|
|
||||||
|
// Connect all locations with conditional edges
|
||||||
|
for (let i = 0; i < locations.length; i++) {
|
||||||
|
for (let j = i + 1; j < locations.length; j++) {
|
||||||
|
const numConditions = diff === 'easy' ? 1 : diff === 'medium' ? 2 : 3;
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
for (let k = 0; k < numConditions; k++) {
|
||||||
|
const conditionType = locationTypes[Math.floor(rng() * locationTypes.length)];
|
||||||
|
conditions.push(`SOMEBODY AT ${conditionType.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
from: locations[i].id,
|
||||||
|
to: locations[j].id,
|
||||||
|
conditions: conditions
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
from: locations[j].id,
|
||||||
|
to: locations[i].id,
|
||||||
|
conditions: conditions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
characters,
|
||||||
|
edges,
|
||||||
|
moveCount: 0,
|
||||||
|
isComplete: false
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Seeded random number generator
|
||||||
|
const seededRandom = (seed: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
const char = seed.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hash = Math.sin(hash) * 10000;
|
||||||
|
return hash - Math.floor(hash);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize puzzle
|
||||||
|
useEffect(() => {
|
||||||
|
const newPuzzle = generatePuzzle(difficulty, seed);
|
||||||
|
setPuzzleState(newPuzzle);
|
||||||
|
}, [difficulty, seed, generatePuzzle]);
|
||||||
|
|
||||||
|
// Check if conditions are met for an edge
|
||||||
|
const areConditionsMet = (edge: Edge): boolean => {
|
||||||
|
if (!puzzleState) return false;
|
||||||
|
|
||||||
|
return edge.conditions.every(condition => {
|
||||||
|
const parts = condition.split(' AT ');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
|
||||||
|
const targetType = parts[1].toLowerCase();
|
||||||
|
|
||||||
|
// Check if somebody is at the specified location type
|
||||||
|
return puzzleState.characters.some(char => {
|
||||||
|
const location = puzzleState.locations.find(loc => loc.id === char.locationId);
|
||||||
|
return location && (
|
||||||
|
location.type === targetType ||
|
||||||
|
char.type === targetType
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get valid moves for selected character
|
||||||
|
const getValidMoves = (characterId: string): string[] => {
|
||||||
|
if (!puzzleState) return [];
|
||||||
|
|
||||||
|
const character = puzzleState.characters.find(c => c.id === characterId);
|
||||||
|
if (!character) return [];
|
||||||
|
|
||||||
|
const validLocationIds: string[] = [];
|
||||||
|
const currentLocation = character.locationId;
|
||||||
|
|
||||||
|
puzzleState.edges.forEach(edge => {
|
||||||
|
if (edge.from === currentLocation && areConditionsMet(edge)) {
|
||||||
|
validLocationIds.push(edge.to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return validLocationIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle character selection
|
||||||
|
const handleCharacterSelect = (characterId: string) => {
|
||||||
|
setSelectedCharacter(characterId);
|
||||||
|
setValidMoves(getValidMoves(characterId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle character move
|
||||||
|
const handleMove = (targetLocationId: string) => {
|
||||||
|
if (!puzzleState || !selectedCharacter) return;
|
||||||
|
|
||||||
|
const updatedCharacters = puzzleState.characters.map(char =>
|
||||||
|
char.id === selectedCharacter
|
||||||
|
? { ...char, locationId: targetLocationId }
|
||||||
|
: char
|
||||||
|
);
|
||||||
|
|
||||||
|
const newMoveCount = puzzleState.moveCount + 1;
|
||||||
|
|
||||||
|
// Check if puzzle is complete
|
||||||
|
const isComplete = checkCompletion(updatedCharacters);
|
||||||
|
|
||||||
|
setPuzzleState({
|
||||||
|
...puzzleState,
|
||||||
|
characters: updatedCharacters,
|
||||||
|
moveCount: newMoveCount,
|
||||||
|
isComplete
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedCharacter(null);
|
||||||
|
setValidMoves([]);
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
toast.success(`Puzzle completed in ${newMoveCount} moves!`);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if puzzle is complete
|
||||||
|
const checkCompletion = (characters: Character[]): boolean => {
|
||||||
|
const bunniesAtCarrot = characters.filter(char =>
|
||||||
|
char.type === 'bunny' &&
|
||||||
|
puzzleState?.locations.find(loc => loc.id === char.locationId)?.type === 'carrot'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const dogsAtBone = characters.filter(char =>
|
||||||
|
char.type === 'dog' &&
|
||||||
|
puzzleState?.locations.find(loc => loc.id === char.locationId)?.type === 'bone'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return bunniesAtCarrot === 2 && dogsAtBone === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset puzzle
|
||||||
|
const resetPuzzle = () => {
|
||||||
|
const newPuzzle = generatePuzzle(difficulty, seed);
|
||||||
|
setPuzzleState(newPuzzle);
|
||||||
|
setSelectedCharacter(null);
|
||||||
|
setValidMoves([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate new puzzle
|
||||||
|
const generateNewPuzzle = () => {
|
||||||
|
const newSeed = Math.random().toString(36).substring(7);
|
||||||
|
const newPuzzle = generatePuzzle(difficulty, newSeed);
|
||||||
|
setPuzzleState(newPuzzle);
|
||||||
|
setSelectedCharacter(null);
|
||||||
|
setValidMoves([]);
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('seed', newSeed);
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share puzzle
|
||||||
|
const sharePuzzle = async () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (seed) url.searchParams.set('seed', seed);
|
||||||
|
url.searchParams.set('difficulty', difficulty);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url.toString());
|
||||||
|
toast.success('Puzzle URL copied to clipboard!');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to copy URL');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Screenshot puzzle
|
||||||
|
const screenshotPuzzle = async () => {
|
||||||
|
const element = document.getElementById('puzzle-area');
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = await html2canvas(element);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `dogs-bunny-puzzle-${difficulty}-${Date.now()}.png`;
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
toast.success('Screenshot saved!');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to take screenshot');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get character icon
|
||||||
|
const getCharacterIcon = (type: 'bunny' | 'dog') => {
|
||||||
|
return type === 'bunny' ? '🐰' : '🐕';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get location icon
|
||||||
|
const getLocationIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'carrot': return '🥕';
|
||||||
|
case 'bone': return '🦴';
|
||||||
|
case 'flower': return '🌸';
|
||||||
|
case 'well': return '🪣';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!puzzleState) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto p-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Dogs & Bunny Puzzle
|
||||||
|
{puzzleState.isComplete && <Trophy className="h-5 w-5 text-yellow-500" />}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Get all bunnies to the carrot 🥕 and the dog to the bone 🦴
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{difficulty}</Badge>
|
||||||
|
<Badge variant="secondary">Moves: {puzzleState.moveCount}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Puzzle Area */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
id="puzzle-area"
|
||||||
|
className="relative bg-muted/10 rounded-lg p-6 min-h-[500px]"
|
||||||
|
style={{ width: '500px', height: '400px' }}
|
||||||
|
>
|
||||||
|
{/* Edges */}
|
||||||
|
<svg className="absolute inset-0 w-full h-full pointer-events-none">
|
||||||
|
{puzzleState.edges.map((edge, index) => {
|
||||||
|
const fromLoc = puzzleState.locations.find(l => l.id === edge.from);
|
||||||
|
const toLoc = puzzleState.locations.find(l => l.id === edge.to);
|
||||||
|
if (!fromLoc || !toLoc) return null;
|
||||||
|
|
||||||
|
const isValid = selectedCharacter &&
|
||||||
|
puzzleState.characters.find(c => c.id === selectedCharacter)?.locationId === edge.from &&
|
||||||
|
areConditionsMet(edge);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={index}>
|
||||||
|
<line
|
||||||
|
x1={fromLoc.x + 20}
|
||||||
|
y1={fromLoc.y + 20}
|
||||||
|
x2={toLoc.x + 20}
|
||||||
|
y2={toLoc.y + 20}
|
||||||
|
stroke={isValid ? "hsl(var(--primary))" : "hsl(var(--border))"}
|
||||||
|
strokeWidth={isValid ? "3" : "1"}
|
||||||
|
strokeDasharray={isValid ? "none" : "5,5"}
|
||||||
|
/>
|
||||||
|
{/* Condition labels */}
|
||||||
|
<text
|
||||||
|
x={(fromLoc.x + toLoc.x) / 2 + 20}
|
||||||
|
y={(fromLoc.y + toLoc.y) / 2 + 15}
|
||||||
|
fontSize="10"
|
||||||
|
fill="hsl(var(--foreground))"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="pointer-events-none"
|
||||||
|
>
|
||||||
|
{edge.conditions.length > 0 && edge.conditions[0].replace('SOMEBODY AT ', '')}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Locations */}
|
||||||
|
{puzzleState.locations.map((location) => {
|
||||||
|
const charactersHere = puzzleState.characters.filter(c => c.locationId === location.id);
|
||||||
|
const isValidMove = validMoves.includes(location.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={location.id}
|
||||||
|
className={`absolute w-10 h-10 rounded-full border-2 flex items-center justify-center cursor-pointer transition-all ${
|
||||||
|
isValidMove
|
||||||
|
? 'border-primary bg-primary/20 scale-110'
|
||||||
|
: 'border-border bg-background hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: location.x,
|
||||||
|
top: location.y,
|
||||||
|
}}
|
||||||
|
onClick={() => isValidMove && handleMove(location.id)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">
|
||||||
|
{getLocationIcon(location.type)}
|
||||||
|
</span>
|
||||||
|
{/* Characters at this location */}
|
||||||
|
{charactersHere.map((char, index) => (
|
||||||
|
<div
|
||||||
|
key={char.id}
|
||||||
|
className={`absolute w-6 h-6 rounded-full border flex items-center justify-center cursor-pointer transition-all ${
|
||||||
|
selectedCharacter === char.id
|
||||||
|
? 'border-primary bg-primary text-primary-foreground scale-110'
|
||||||
|
: 'border-border bg-background hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
right: -8 - (index * 12),
|
||||||
|
top: -8 - (index * 12),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCharacterSelect(char.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs">
|
||||||
|
{getCharacterIcon(char.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions & Controls */}
|
||||||
|
<div className="w-full lg:w-80 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold">How to Play:</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Click a character to select it</li>
|
||||||
|
<li>• Click a highlighted location to move</li>
|
||||||
|
<li>• Edges show movement conditions</li>
|
||||||
|
<li>• Multiple animals can share locations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold">Legend:</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>🐰 Bunny</div>
|
||||||
|
<div>🐕 Dog</div>
|
||||||
|
<div>🥕 Carrot</div>
|
||||||
|
<div>🦴 Bone</div>
|
||||||
|
<div>🌸 Flower</div>
|
||||||
|
<div>🪣 Well</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button onClick={resetPuzzle} variant="outline" className="w-full">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button onClick={generateNewPuzzle} variant="outline" className="w-full">
|
||||||
|
<Shuffle className="h-4 w-4 mr-2" />
|
||||||
|
New Puzzle
|
||||||
|
</Button>
|
||||||
|
<Button onClick={sharePuzzle} variant="outline" className="w-full">
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button onClick={screenshotPuzzle} variant="outline" className="w-full">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Screenshot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DogsBunnyPuzzle;
|
||||||
138
src/pages/DogsBunnyPuzzlePage.tsx
Normal file
138
src/pages/DogsBunnyPuzzlePage.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '@/components/Layout';
|
||||||
|
import DogsBunnyPuzzle from '@/components/DogsBunnyPuzzle';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
const DogsBunnyPuzzlePage = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium');
|
||||||
|
const [seed, setSeed] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const diffParam = searchParams.get('difficulty') as 'easy' | 'medium' | 'hard';
|
||||||
|
const seedParam = searchParams.get('seed');
|
||||||
|
|
||||||
|
if (diffParam && ['easy', 'medium', 'hard'].includes(diffParam)) {
|
||||||
|
setDifficulty(diffParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seedParam) {
|
||||||
|
setSeed(seedParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleDifficultyChange = (newDifficulty: 'easy' | 'medium' | 'hard') => {
|
||||||
|
setDifficulty(newDifficulty);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set('difficulty', newDifficulty);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackNavigation = () => {
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
if (from === 'puzzles') {
|
||||||
|
navigate('/themes/puzzles');
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBackNavigation}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">Dogs & Bunny Puzzle</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
A logic puzzle about getting animals to their favorite spots
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">Difficulty:</span>
|
||||||
|
<Select value={difficulty} onValueChange={handleDifficultyChange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="hard">Hard</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-muted/10 rounded-lg p-6 mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Puzzle Rules</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">Objective:</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Get all bunnies 🐰 to the carrot 🥕</li>
|
||||||
|
<li>• Get the dog 🐕 to the bone 🦴</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">Movement Rules:</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Click a character to select it</li>
|
||||||
|
<li>• You can only move along edges where all conditions are met</li>
|
||||||
|
<li>• Multiple animals can occupy the same location</li>
|
||||||
|
<li>• Edge labels show the required conditions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Puzzle Component */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<DogsBunnyPuzzle
|
||||||
|
difficulty={difficulty}
|
||||||
|
seed={seed}
|
||||||
|
onComplete={() => {
|
||||||
|
// Could add achievements or stats here
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Original puzzle concept by <a
|
||||||
|
href="https://twitter.com/lisperati"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Conrad Barski (@lisperati)
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DogsBunnyPuzzlePage;
|
||||||
|
|
@ -55,6 +55,16 @@ const Puzzles = () => {
|
||||||
difficulty: "Intermediate" as const,
|
difficulty: "Intermediate" as const,
|
||||||
duration: "5-15 min",
|
duration: "5-15 min",
|
||||||
participants: "1 player"
|
participants: "1 player"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dogs-bunny",
|
||||||
|
title: "Dogs & Bunny Puzzle",
|
||||||
|
description: "Guide bunnies to the carrot and the dog to the bone using conditional movement rules. A graph traversal puzzle!",
|
||||||
|
path: "/puzzles/dogs-bunny",
|
||||||
|
tags: ["Graph Theory", "Logic", "Pathfinding", "Animals"],
|
||||||
|
difficulty: "Intermediate" as const,
|
||||||
|
duration: "10-25 min",
|
||||||
|
participants: "1 player"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue