diff --git a/package-lock.json b/package-lock.json index 7e686ba..316d834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", + "html2canvas": "^1.4.1", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -3360,6 +3361,15 @@ "dev": true, "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4048,6 +4058,15 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4890,6 +4909,19 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6901,6 +6933,15 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -7124,6 +7165,15 @@ "dev": true, "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": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", diff --git a/package.json b/package.json index 50578b1..5e0f581 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", + "html2canvas": "^1.4.1", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", diff --git a/src/App.tsx b/src/App.tsx index fadaae4..4361b25 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage'; import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage'; import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage'; import NQueensPuzzlePage from './pages/NQueensPuzzlePage'; +import DogsBunnyPuzzlePage from './pages/DogsBunnyPuzzlePage'; const queryClient = new QueryClient(); @@ -104,6 +105,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> diff --git a/src/components/DogsBunnyPuzzle.tsx b/src/components/DogsBunnyPuzzle.tsx new file mode 100644 index 0000000..05d02ed --- /dev/null +++ b/src/components/DogsBunnyPuzzle.tsx @@ -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 = ({ + difficulty = 'medium', + seed, + onComplete +}) => { + const [puzzleState, setPuzzleState] = useState(null); + const [selectedCharacter, setSelectedCharacter] = useState(null); + const [validMoves, setValidMoves] = useState([]); + + // 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
Loading...
; + + return ( +
+ + +
+
+ + Dogs & Bunny Puzzle + {puzzleState.isComplete && } + +

+ Get all bunnies to the carrot 🥕 and the dog to the bone 🦴 +

+
+
+ {difficulty} + Moves: {puzzleState.moveCount} +
+
+
+ + +
+ {/* Puzzle Area */} +
+
+ {/* Edges */} + + {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 ( + + + {/* Condition labels */} + + {edge.conditions.length > 0 && edge.conditions[0].replace('SOMEBODY AT ', '')} + + + ); + })} + + + {/* Locations */} + {puzzleState.locations.map((location) => { + const charactersHere = puzzleState.characters.filter(c => c.locationId === location.id); + const isValidMove = validMoves.includes(location.id); + + return ( +
isValidMove && handleMove(location.id)} + > + + {getLocationIcon(location.type)} + + {/* Characters at this location */} + {charactersHere.map((char, index) => ( +
{ + e.stopPropagation(); + handleCharacterSelect(char.id); + }} + > + + {getCharacterIcon(char.type)} + +
+ ))} +
+ ); + })} +
+
+ + {/* Instructions & Controls */} +
+
+

How to Play:

+
    +
  • • Click a character to select it
  • +
  • • Click a highlighted location to move
  • +
  • • Edges show movement conditions
  • +
  • • Multiple animals can share locations
  • +
+
+ + + +
+

Legend:

+
+
🐰 Bunny
+
🐕 Dog
+
🥕 Carrot
+
🦴 Bone
+
🌸 Flower
+
🪣 Well
+
+
+ + + +
+ + + + +
+
+
+
+
+
+ ); +}; + +export default DogsBunnyPuzzle; \ No newline at end of file diff --git a/src/pages/DogsBunnyPuzzlePage.tsx b/src/pages/DogsBunnyPuzzlePage.tsx new file mode 100644 index 0000000..5ddbd27 --- /dev/null +++ b/src/pages/DogsBunnyPuzzlePage.tsx @@ -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(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 ( + +
+
+ {/* Header */} +
+
+ +
+

Dogs & Bunny Puzzle

+

+ A logic puzzle about getting animals to their favorite spots +

+
+
+ +
+
+ Difficulty: + +
+
+
+ + {/* Instructions */} +
+

Puzzle Rules

+
+
+

Objective:

+
    +
  • • Get all bunnies 🐰 to the carrot 🥕
  • +
  • • Get the dog 🐕 to the bone 🦴
  • +
+
+
+

Movement Rules:

+
    +
  • • Click a character to select it
  • +
  • • You can only move along edges where all conditions are met
  • +
  • • Multiple animals can occupy the same location
  • +
  • • Edge labels show the required conditions
  • +
+
+
+
+ + {/* Puzzle Component */} +
+ { + // Could add achievements or stats here + }} + /> +
+ + {/* Additional Info */} +
+

+ Original puzzle concept by + Conrad Barski (@lisperati) + +

+
+
+
+
+ ); +}; + +export default DogsBunnyPuzzlePage; \ No newline at end of file diff --git a/src/pages/theme-pages/Puzzles.tsx b/src/pages/theme-pages/Puzzles.tsx index 3cf3896..ba587b3 100644 --- a/src/pages/theme-pages/Puzzles.tsx +++ b/src/pages/theme-pages/Puzzles.tsx @@ -55,6 +55,16 @@ const Puzzles = () => { difficulty: "Intermediate" as const, duration: "5-15 min", 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" } ];