Reverted to commit 81f6873c4d
This commit is contained in:
parent
61feaaf9e9
commit
c257a5f531
6 changed files with 1 additions and 553 deletions
24
package-lock.json
generated
24
package-lock.json
generated
|
|
@ -43,11 +43,8 @@
|
|||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -4665,12 +4662,6 @@
|
|||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -4899,12 +4890,6 @@
|
|||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -5676,15 +5661,6 @@
|
|||
"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": {
|
||||
"version": "0.30.12",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
|
||||
|
|
|
|||
|
|
@ -46,11 +46,8 @@
|
|||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -48,7 +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();
|
||||
|
||||
const App = () => (
|
||||
|
|
@ -103,7 +103,6 @@ const App = () => (
|
|||
<Route path="/puzzles/sikinia-parliament" element={<SikiniaParliamentPuzzlePage />} />
|
||||
<Route path="/puzzles/plate-swap" element={<PlateSwapPuzzlePage />} />
|
||||
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
|
||||
<Route path="/puzzles/dogs-bunny" element={<DogsBunnyPuzzlePage />} />
|
||||
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
|
||||
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
|
||||
<Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} />
|
||||
|
|
|
|||
|
|
@ -1,497 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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,16 +55,6 @@ const Puzzles = () => {
|
|||
difficulty: "Intermediate" as const,
|
||||
duration: "5-15 min",
|
||||
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