Add Dogs and Bunny puzzle
Recreate the Dogs and Bunny puzzle with functionality for random puzzle generation at varying difficulty levels (easy, medium, hard). Allow users to screenshot puzzles or solutions. Implement puzzle sharing via URL parameters.
This commit is contained in:
parent
81f6873c4d
commit
61feaaf9e9
6 changed files with 553 additions and 1 deletions
24
package-lock.json
generated
24
package-lock.json
generated
|
|
@ -43,8 +43,11 @@
|
||||||
"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",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
@ -4662,6 +4665,12 @@
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -4890,6 +4899,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-image": {
|
||||||
|
"version": "1.11.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||||
|
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|
@ -5661,6 +5676,15 @@
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lz-string": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"lz-string": "bin/bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.12",
|
"version": "0.30.12",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,11 @@
|
||||||
"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",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -48,7 +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();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
|
@ -103,6 +103,7 @@ const App = () => (
|
||||||
<Route path="/puzzles/sikinia-parliament" element={<SikiniaParliamentPuzzlePage />} />
|
<Route path="/puzzles/sikinia-parliament" element={<SikiniaParliamentPuzzlePage />} />
|
||||||
<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/dogs-bunny" element={<DogsBunnyPuzzlePage />} />
|
||||||
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
|
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
|
||||||
<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 />} />
|
||||||
|
|
|
||||||
497
src/components/DogsBunnyPuzzle.tsx
Normal file
497
src/components/DogsBunnyPuzzle.tsx
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Share2, Download, Shuffle, Copy, Link as LinkIcon } from "lucide-react";
|
||||||
|
import * as htmlToImage from "html-to-image";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||||
|
import SocialShare from "@/components/SocialShare";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type NodeType = "bone" | "flower" | "carrot" | "well" | "blank";
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
id: string;
|
||||||
|
type: NodeType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeCond = { kind: "somebodyAt" | "nobodyAt"; type: NodeType };
|
||||||
|
|
||||||
|
interface Edge {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
conditions: EdgeCond[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PuzzleSpec {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
start: { dog: string; bunnies: string[] };
|
||||||
|
goalType: NodeType; // typically "bone"
|
||||||
|
difficulty: Difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Difficulty = "easy" | "medium" | "hard";
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const NODE_TYPES: NodeType[] = ["bone", "flower", "carrot", "well", "blank"];
|
||||||
|
|
||||||
|
function randInt(n: number) {
|
||||||
|
return Math.floor(Math.random() * n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOne<T>(arr: T[]): T {
|
||||||
|
return arr[randInt(arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid(prefix = "id"): string {
|
||||||
|
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeState(dog: string, bunnies: string[]) {
|
||||||
|
const sorted = [...bunnies].sort();
|
||||||
|
return `${dog}|${sorted.join(',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionsSatisfied(conds: EdgeCond[], nodesById: Record<string, Node>, entitiesAtType: (t: NodeType) => number) {
|
||||||
|
return conds.every((c) => {
|
||||||
|
const count = entitiesAtType(c.type);
|
||||||
|
if (c.kind === "somebodyAt") return count > 0;
|
||||||
|
if (c.kind === "nobodyAt") return count === 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutCircle(n: number, radius = 220) {
|
||||||
|
const angleStep = (2 * Math.PI) / n;
|
||||||
|
const centerX = 360;
|
||||||
|
const centerY = 300;
|
||||||
|
return Array.from({ length: n }).map((_, i) => ({
|
||||||
|
x: centerX + radius * Math.cos(i * angleStep - Math.PI / 2),
|
||||||
|
y: centerY + radius * Math.sin(i * angleStep - Math.PI / 2),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function genPuzzle(difficulty: Difficulty): PuzzleSpec {
|
||||||
|
const params = {
|
||||||
|
easy: { nNodes: 7, nBunnies: 2, outDeg: [1, 2], condsPerEdge: [1, 1], maxSteps: 12 },
|
||||||
|
medium: { nNodes: 9, nBunnies: 3, outDeg: [1, 3], condsPerEdge: [1, 2], maxSteps: 20 },
|
||||||
|
hard: { nNodes: 11, nBunnies: 4, outDeg: [2, 3], condsPerEdge: [2, 3], maxSteps: 30 },
|
||||||
|
}[difficulty];
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const positions = layoutCircle(params.nNodes);
|
||||||
|
const nodes: Node[] = positions.map((p, i) => ({
|
||||||
|
id: `n${i}`,
|
||||||
|
type: pickOne(NODE_TYPES),
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
}));
|
||||||
|
// ensure at least one bone node
|
||||||
|
if (!nodes.some((n) => n.type === "bone")) nodes[randInt(nodes.length)].type = "bone";
|
||||||
|
|
||||||
|
// Edges
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const outMin = params.outDeg[0];
|
||||||
|
const outMax = params.outDeg[1];
|
||||||
|
const outCount = outMin + randInt(outMax - outMin + 1);
|
||||||
|
const targets = new Set<number>();
|
||||||
|
while (targets.size < outCount) {
|
||||||
|
const t = randInt(nodes.length);
|
||||||
|
if (t !== i) targets.add(t);
|
||||||
|
}
|
||||||
|
[...targets].forEach((t) => {
|
||||||
|
const condCount = params.condsPerEdge[0] + randInt(params.condsPerEdge[1] - params.condsPerEdge[0] + 1);
|
||||||
|
const conditions: EdgeCond[] = [];
|
||||||
|
for (let k = 0; k < condCount; k++) {
|
||||||
|
const kind = Math.random() < 0.6 ? "somebodyAt" : "nobodyAt";
|
||||||
|
const type = pickOne(NODE_TYPES);
|
||||||
|
conditions.push({ kind, type });
|
||||||
|
}
|
||||||
|
edges.push({ id: uid("e"), from: nodes[i].id, to: nodes[t].id, conditions });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start positions
|
||||||
|
const boneNodes = nodes.filter((n) => n.type === "bone");
|
||||||
|
const startDog = pickOne(nodes).id;
|
||||||
|
const bunnies: string[] = [];
|
||||||
|
for (let i = 0; i < params.nBunnies; i++) bunnies.push(pickOne(nodes).id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
start: { dog: startDog, bunnies },
|
||||||
|
goalType: "bone",
|
||||||
|
difficulty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve(spec: PuzzleSpec, maxSteps: number): string[] | null {
|
||||||
|
// BFS state space search to get dog to any node of goalType
|
||||||
|
const nodesById = Object.fromEntries(spec.nodes.map((n) => [n.id, n] as const));
|
||||||
|
const outEdges = new Map<string, Edge[]>();
|
||||||
|
spec.edges.forEach((e) => {
|
||||||
|
if (!outEdges.has(e.from)) outEdges.set(e.from, []);
|
||||||
|
outEdges.get(e.from)!.push(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = { dog: spec.start.dog, bunnies: [...spec.start.bunnies] };
|
||||||
|
const startKey = serializeState(start.dog, start.bunnies);
|
||||||
|
const q: { dog: string; bunnies: string[]; path: string[] }[] = [{ ...start, path: [] }];
|
||||||
|
const seen = new Set([startKey]);
|
||||||
|
|
||||||
|
const entitiesAtType = (dog: string, bunnies: string[]) => (t: NodeType) => {
|
||||||
|
let count = 0;
|
||||||
|
if (nodesById[dog].type === t) count++;
|
||||||
|
bunnies.forEach((b) => {
|
||||||
|
if (nodesById[b].type === t) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let depth = 0; depth <= maxSteps && q.length; depth++) {
|
||||||
|
const cur = q.shift();
|
||||||
|
if (!cur) break;
|
||||||
|
const dogAtGoal = nodesById[cur.dog].type === spec.goalType;
|
||||||
|
if (dogAtGoal) return cur.path;
|
||||||
|
|
||||||
|
const entAt = entitiesAtType(cur.dog, cur.bunnies);
|
||||||
|
|
||||||
|
// Moves: try moving dog and each bunny along available edges
|
||||||
|
const candidates: Array<{ who: "dog" | number; from: string }> = [
|
||||||
|
{ who: "dog", from: cur.dog },
|
||||||
|
...cur.bunnies.map((id, idx) => ({ who: idx, from: id })),
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const outs = outEdges.get(c.from) || [];
|
||||||
|
for (const e of outs) {
|
||||||
|
if (!conditionsSatisfied(e.conditions, nodesById, entAt)) continue;
|
||||||
|
const nextDog = c.who === "dog" ? e.to : cur.dog;
|
||||||
|
const nextBunnies = cur.bunnies.map((b, i) => (c.who === i ? e.to : b));
|
||||||
|
const key = serializeState(nextDog, nextBunnies);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
q.push({ dog: nextDog, bunnies: nextBunnies, path: [...cur.path, `${c.who === "dog" ? "Dog" : `Bunny ${c.who + 1}`}: ${c.from}→${e.to}`] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSolvable(difficulty: Difficulty): PuzzleSpec {
|
||||||
|
const limits = { easy: 12, medium: 20, hard: 30 } as const;
|
||||||
|
for (let attempts = 0; attempts < 40; attempts++) {
|
||||||
|
const candidate = genPuzzle(difficulty);
|
||||||
|
const sol = solve(candidate, limits[difficulty]);
|
||||||
|
if (sol) return candidate;
|
||||||
|
}
|
||||||
|
// Fallback — even if unsolved after attempts, return a simple hand-crafted tiny solvable graph
|
||||||
|
const nodes: Node[] = layoutCircle(6).map((p, i) => ({ id: `n${i}`, type: i === 3 ? "bone" : pickOne(NODE_TYPES), x: p.x, y: p.y }));
|
||||||
|
nodes[3].type = "bone";
|
||||||
|
const edges: Edge[] = [
|
||||||
|
{ id: uid("e"), from: "n0", to: "n1", conditions: [{ kind: "somebodyAt", type: "flower" }] },
|
||||||
|
{ id: uid("e"), from: "n1", to: "n2", conditions: [{ kind: "somebodyAt", type: "carrot" }] },
|
||||||
|
{ id: uid("e"), from: "n2", to: "n3", conditions: [{ kind: "nobodyAt", type: "well" }] },
|
||||||
|
{ id: uid("e"), from: "n4", to: "n3", conditions: [{ kind: "somebodyAt", type: "carrot" }] },
|
||||||
|
];
|
||||||
|
return { nodes, edges, start: { dog: "n0", bunnies: ["n4"] }, goalType: "bone", difficulty };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSpec(spec: PuzzleSpec) {
|
||||||
|
return compressToEncodedURIComponent(JSON.stringify(spec));
|
||||||
|
}
|
||||||
|
function decodeSpec(s: string): PuzzleSpec | null {
|
||||||
|
try {
|
||||||
|
const txt = decompressFromEncodedURIComponent(s);
|
||||||
|
if (!txt) return null;
|
||||||
|
return JSON.parse(txt) as PuzzleSpec;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons for node types (simple emoji for now to keep it lightweight)
|
||||||
|
const ICON: Record<NodeType, string> = {
|
||||||
|
bone: "🦴",
|
||||||
|
carrot: "🥕",
|
||||||
|
flower: "🌸",
|
||||||
|
well: "🪣",
|
||||||
|
blank: "⬤",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component
|
||||||
|
const DogsBunnyPuzzle: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const urlParam = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "");
|
||||||
|
const paramPuzzle = urlParam.get("p");
|
||||||
|
const urlDifficulty = (urlParam.get("d") as Difficulty) || undefined;
|
||||||
|
|
||||||
|
const [difficulty, setDifficulty] = useState<Difficulty>(urlDifficulty || "easy");
|
||||||
|
const [spec, setSpec] = useState<PuzzleSpec>(() => {
|
||||||
|
const decoded = paramPuzzle ? decodeSpec(paramPuzzle) : null;
|
||||||
|
return decoded || ensureSolvable(urlDifficulty || "easy");
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodesById = useMemo(() => Object.fromEntries(spec.nodes.map((n) => [n.id, n] as const)), [spec.nodes]);
|
||||||
|
const outEdges = useMemo(() => {
|
||||||
|
const m = new Map<string, Edge[]>();
|
||||||
|
spec.edges.forEach((e) => {
|
||||||
|
if (!m.has(e.from)) m.set(e.from, []);
|
||||||
|
m.get(e.from)!.push(e);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}, [spec.edges]);
|
||||||
|
|
||||||
|
const [dog, setDog] = useState(spec.start.dog);
|
||||||
|
const [bunnies, setBunnies] = useState<string[]>(spec.start.bunnies);
|
||||||
|
const [selected, setSelected] = useState<{ who: "dog" | number; nodeId: string } | null>(null);
|
||||||
|
const [solution, setSolution] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Dogs & Bunny Puzzle – Interactive";
|
||||||
|
const metaDesc = document.querySelector('meta[name="description"]');
|
||||||
|
if (metaDesc) metaDesc.setAttribute("content", "Play the Dogs & Bunny puzzle. Generate random solvable puzzles, share via URL, and export screenshots.");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const entAtType = useCallback(
|
||||||
|
(t: NodeType) => {
|
||||||
|
let count = nodesById[dog].type === t ? 1 : 0;
|
||||||
|
bunnies.forEach((b) => {
|
||||||
|
if (nodesById[b].type === t) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
[dog, bunnies, nodesById]
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableMoves = useMemo(() => {
|
||||||
|
if (!selected) return [] as { to: string; e: Edge }[];
|
||||||
|
const outs = outEdges.get(selected.nodeId) || [];
|
||||||
|
return outs
|
||||||
|
.filter((e) => conditionsSatisfied(e.conditions, nodesById, entAtType))
|
||||||
|
.map((e) => ({ to: e.to, e }));
|
||||||
|
}, [selected, outEdges, entAtType, nodesById]);
|
||||||
|
|
||||||
|
const doMove = (to: string) => {
|
||||||
|
if (!selected) return;
|
||||||
|
if (selected.who === "dog") setDog(to);
|
||||||
|
else setBunnies((bb) => bb.map((b, i) => (i === selected.who ? to : b)));
|
||||||
|
setSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setDog(spec.start.dog);
|
||||||
|
setBunnies(spec.start.bunnies);
|
||||||
|
setSelected(null);
|
||||||
|
setSolution(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const regenerate = (d: Difficulty) => {
|
||||||
|
const next = ensureSolvable(d);
|
||||||
|
setSpec(next);
|
||||||
|
setDog(next.start.dog);
|
||||||
|
setBunnies(next.start.bunnies);
|
||||||
|
setSelected(null);
|
||||||
|
setSolution(null);
|
||||||
|
setDifficulty(d);
|
||||||
|
// update URL with encoded puzzle for shareability
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set("p", encodeSpec(next));
|
||||||
|
params.set("d", d);
|
||||||
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||||
|
window.history.replaceState({}, "", newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
try {
|
||||||
|
const encoded = encodeSpec({ ...spec, start: { dog, bunnies } });
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("p", encoded);
|
||||||
|
url.searchParams.set("d", difficulty);
|
||||||
|
await navigator.clipboard.writeText(url.toString());
|
||||||
|
toast({ title: "Link copied", description: "Share this exact puzzle and current state." });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Copy failed", description: "Could not copy link.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPng = async () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
try {
|
||||||
|
const dataUrl = await htmlToImage.toPng(containerRef.current, { pixelRatio: 2, cacheBust: true, backgroundColor: "transparent" });
|
||||||
|
saveAs(dataUrl, "dogs-bunny-puzzle.png");
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Export failed", description: "Please try again.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeSolution = () => {
|
||||||
|
const sol = solve({ ...spec, start: { dog, bunnies } }, { easy: 12, medium: 20, hard: 30 }[spec.difficulty]);
|
||||||
|
setSolution(sol);
|
||||||
|
if (!sol) toast({ title: "No solution found", description: "This state might be a dead end." });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render helpers
|
||||||
|
const size = { w: 720, h: 520 };
|
||||||
|
|
||||||
|
const nodeEl = (n: Node) => {
|
||||||
|
const isSelected = selected?.nodeId === n.id;
|
||||||
|
const isDog = dog === n.id;
|
||||||
|
const bunnyCount = bunnies.filter((b) => b === n.id).length;
|
||||||
|
const onSelect = () => {
|
||||||
|
if (isDog) setSelected({ who: "dog", nodeId: n.id });
|
||||||
|
else if (bunnyCount > 0) {
|
||||||
|
// pick the first bunny at this node (simple UX)
|
||||||
|
const idx = bunnies.findIndex((b) => b === n.id);
|
||||||
|
setSelected({ who: idx as number, nodeId: n.id });
|
||||||
|
} else setSelected(null);
|
||||||
|
};
|
||||||
|
const atGoal = nodesById[dog].type === spec.goalType && n.id === dog;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={n.id} onClick={onSelect} className="cursor-pointer">
|
||||||
|
<circle cx={n.x} cy={n.y} r={32} className={cn("fill-background stroke-border", isSelected && "ring-2 ring-primary fill-accent")} />
|
||||||
|
<text x={n.x} y={n.y - 20} textAnchor="middle" className="fill-muted-foreground text-[10px] select-none">
|
||||||
|
{n.type}
|
||||||
|
</text>
|
||||||
|
<text x={n.x} y={n.y + 4} textAnchor="middle" className="text-xl select-none">
|
||||||
|
{ICON[n.type]}
|
||||||
|
</text>
|
||||||
|
{isDog && (
|
||||||
|
<text x={n.x - 18} y={n.y + 26} textAnchor="middle" className="select-none">🐶</text>
|
||||||
|
)}
|
||||||
|
{bunnyCount > 0 && (
|
||||||
|
<text x={n.x + 10} y={n.y + 26} textAnchor="middle" className="select-none">{"🐰".repeat(Math.min(3, bunnyCount))}</text>
|
||||||
|
)}
|
||||||
|
{atGoal && (
|
||||||
|
<text x={n.x} y={n.y - 38} textAnchor="middle" className="fill-primary text-xs">Goal!</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeEl = (e: Edge) => {
|
||||||
|
const a = nodesById[e.from];
|
||||||
|
const b = nodesById[e.to];
|
||||||
|
const midx = (a.x + b.x) / 2;
|
||||||
|
const midy = (a.y + b.y) / 2;
|
||||||
|
|
||||||
|
// arrow line
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const endx = b.x - ux * 34;
|
||||||
|
const endy = b.y - uy * 34;
|
||||||
|
|
||||||
|
const active = conditionsSatisfied(e.conditions, nodesById, entAtType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={e.id}>
|
||||||
|
<line x1={a.x} y1={a.y} x2={endx} y2={endy} className={cn("stroke-border", active ? "stroke-primary" : "opacity-60")} markerEnd="url(#arrow)" />
|
||||||
|
<foreignObject x={midx - 70} y={midy - 14} width={140} height={28}>
|
||||||
|
<div className={cn("px-1 py-0.5 text-[10px] rounded-md border", active ? "border-primary text-foreground" : "border-muted text-muted-foreground opacity-70")}
|
||||||
|
title={e.conditions.map((c) => `${c.kind === "somebodyAt" ? "somebody" : "nobody"} at ${c.type}`).join(" AND ")}>
|
||||||
|
{e.conditions.map((c, i) => (
|
||||||
|
<span key={i} className="mr-1">
|
||||||
|
{c.kind === "somebodyAt" ? "∃" : "∄"} {ICON[c.type]}
|
||||||
|
{i < e.conditions.length - 1 ? " ∧" : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const puzzleEncoded = useMemo(() => encodeSpec(spec), [spec]);
|
||||||
|
const shareUrl = useMemo(() => {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
u.searchParams.set("p", puzzleEncoded);
|
||||||
|
u.searchParams.set("d", spec.difficulty);
|
||||||
|
return u.toString();
|
||||||
|
}, [puzzleEncoded, spec.difficulty]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<header className="flex items-center justify-between gap-2">
|
||||||
|
<h1 className="text-2xl font-bold">Dogs & Bunny Puzzle</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Difficulty"
|
||||||
|
className="h-10 rounded-md border bg-background px-3"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => regenerate(e.target.value as Difficulty)}
|
||||||
|
>
|
||||||
|
<option value="easy">Easy</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="hard">Hard</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="secondary" onClick={() => regenerate(difficulty)} title="New puzzle"><Shuffle className="mr-1" />New</Button>
|
||||||
|
<Button variant="outline" onClick={reset} title="Reset">Reset</Button>
|
||||||
|
<Button variant="outline" onClick={computeSolution} title="Try solve">Solve</Button>
|
||||||
|
<Button variant="outline" onClick={copyLink} title="Copy shareable link"><Copy className="mr-1" />Link</Button>
|
||||||
|
<Button variant="default" onClick={exportPng} title="Export PNG"><Download className="mr-1" />PNG</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="p-2" ref={containerRef}>
|
||||||
|
<svg width={size.w} height={size.h} viewBox={`0 0 ${size.w} ${size.h}`}>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" className="fill-muted-foreground" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{/* edges first */}
|
||||||
|
{spec.edges.map(edgeEl)}
|
||||||
|
{/* nodes on top */}
|
||||||
|
{spec.nodes.map(nodeEl)}
|
||||||
|
</svg>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="font-semibold mb-2">How to Play</h2>
|
||||||
|
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>Click the dog or a bunny to see available moves; click a destination node to move along active edges.</li>
|
||||||
|
<li>Edges are active only if all shown conditions are currently true.</li>
|
||||||
|
<li>Goal: move the dog to any bone node.</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="font-semibold mb-2">Share & Export</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Copy a link to share the exact puzzle. Export a PNG snapshot of the puzzle or your current solution.</p>
|
||||||
|
<SocialShare title="Dogs & Bunny Puzzle" description="A cute logic puzzle with dependencies" url={shareUrl} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{solution && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="font-semibold mb-2">Solver Output</h2>
|
||||||
|
{solution ? (
|
||||||
|
<ol className="list-decimal pl-5 text-sm text-muted-foreground space-y-1">
|
||||||
|
{solution.map((s, i) => (
|
||||||
|
<li key={i}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No solution found.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DogsBunnyPuzzle;
|
||||||
17
src/pages/DogsBunnyPuzzlePage.tsx
Normal file
17
src/pages/DogsBunnyPuzzlePage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import Layout from "@/components/Layout";
|
||||||
|
import DogsBunnyPuzzle from "@/components/DogsBunnyPuzzle";
|
||||||
|
|
||||||
|
const DogsBunnyPuzzlePage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<main className="py-10 px-4">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-4">
|
||||||
|
<DogsBunnyPuzzle />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</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: "Move the dog to a bone by navigating edges whose conditions depend on where animals are.",
|
||||||
|
path: "/puzzles/dogs-bunny",
|
||||||
|
tags: ["Logic", "Graph", "Dependencies"],
|
||||||
|
difficulty: "Intermediate" as const,
|
||||||
|
duration: "5-20 min",
|
||||||
|
participants: "1 player"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue