diff --git a/projects/maze/index.html b/projects/maze/index.html index 75b35c2..1ef9e18 100644 --- a/projects/maze/index.html +++ b/projects/maze/index.html @@ -1,19 +1,44 @@ - + - - - Maze + + Maze Solver -
-

Maze

-

Contribute: generator, solver, keyboard navigation.

+

Maze Solver

+
+
+ + +
+
+ + + 20x20 +
+
+ + +
+
+ + + +
+
+ +
+ Nodes Visited: 0 + Path Length: 0 + Time: 0ms +
- \ No newline at end of file diff --git a/projects/maze/main.js b/projects/maze/main.js index 8a1f5ad..db0c568 100644 --- a/projects/maze/main.js +++ b/projects/maze/main.js @@ -1,4 +1,269 @@ -const c = document.getElementById('maze'); const ctx = c.getContext('2d'); -// TODO: implement maze generation and basic player movement -ctx.fillStyle = '#17171c'; ctx.fillRect(0, 0, c.width, c.height); -ctx.fillStyle = '#6ee7b7'; ctx.fillRect(8, 8, 24, 24); + const canvas = document.getElementById('maze'); +const ctx = canvas.getContext('2d'); + +// UI Elements +const algorithmSelect = document.getElementById('algorithm'); +const mazeSizeSlider = document.getElementById('mazeSize'); +const mazeSizeValue = document.getElementById('mazeSizeValue'); +const speedSlider = document.getElementById('speed'); +const generateBtn = document.getElementById('generateBtn'); +const solveBtn = document.getElementById('solveBtn'); +const clearBtn = document.getElementById('clearBtn'); + +// Metrics +const nodesVisitedEl = document.getElementById('nodes-visited'); +const pathLengthEl = document.getElementById('path-length'); +const timeTakenEl = document.getElementById('time-taken'); + +let size = 20; +let cellSize = canvas.width / size; +let grid = []; +let animationFrameId; + +// --- Maze Generation (Recursive Backtracker) --- +function createGrid() { + grid = []; + for (let y = 0; y < size; y++) { + let row = []; + for (let x = 0; x < size; x++) { + row.push({ x, y, walls: { top: true, right: true, bottom: true, left: true }, visited: false }); + } + grid.push(row); + } +} + +function generateMaze() { + createGrid(); + let stack = []; + let current = grid[0][0]; + current.visited = true; + stack.push(current); + + while (stack.length > 0) { + current = stack.pop(); + let neighbors = getUnvisitedNeighbors(current.x, current.y); + + if (neighbors.length > 0) { + stack.push(current); + let neighbor = neighbors[Math.floor(Math.random() * neighbors.length)]; + removeWall(current, neighbor); + neighbor.visited = true; + stack.push(neighbor); + } + } + // Reset visited for solver + grid.forEach(row => row.forEach(cell => cell.visited = false)); + drawMaze(); +} + +function getUnvisitedNeighbors(x, y) { + const neighbors = []; + if (y > 0 && !grid[y - 1][x].visited) neighbors.push(grid[y - 1][x]); // Top + if (x < size - 1 && !grid[y][x + 1].visited) neighbors.push(grid[y][x + 1]); // Right + if (y < size - 1 && !grid[y + 1][x].visited) neighbors.push(grid[y + 1][x]); // Bottom + if (x > 0 && !grid[y][x - 1].visited) neighbors.push(grid[y][x - 1]); // Left + return neighbors; +} + +function removeWall(a, b) { + let x = a.x - b.x; + if (x === 1) { a.walls.left = false; b.walls.right = false; } + else if (x === -1) { a.walls.right = false; b.walls.left = false; } + let y = a.y - b.y; + if (y === 1) { a.walls.top = false; b.walls.bottom = false; } + else if (y === -1) { a.walls.bottom = false; b.walls.top = false; } +} + +// --- Pathfinding Algorithms --- +function solve() { + cancelAnimationFrame(animationFrameId); + clearPath(); + const startTime = performance.now(); + const algorithm = algorithmSelect.value === 'bfs' ? bfs : astar; + const { visitedOrder, path } = algorithm(); + const endTime = performance.now(); + + timeTakenEl.textContent = `${Math.round(endTime - startTime)}ms`; + animateSolution(visitedOrder, path); +} + +function bfs() { + const start = grid[0][0]; + const end = grid[size - 1][size - 1]; + let queue = [start]; + start.visited = true; + let visitedOrder = [start]; + let parentMap = new Map(); + + while (queue.length > 0) { + const current = queue.shift(); + if (current === end) break; + + getValidNeighbors(current).forEach(neighbor => { + if (!neighbor.visited) { + neighbor.visited = true; + parentMap.set(neighbor, current); + queue.push(neighbor); + visitedOrder.push(neighbor); + } + }); + } + return { visitedOrder, path: reconstructPath(parentMap, end) }; +} + +function astar() { + const start = grid[0][0]; + const end = grid[size - 1][size - 1]; + let openSet = [start]; + start.g = 0; + start.h = heuristic(start, end); + start.f = start.h; + + let visitedOrder = []; + let parentMap = new Map(); + + while (openSet.length > 0) { + openSet.sort((a, b) => a.f - b.f); + const current = openSet.shift(); + + visitedOrder.push(current); + current.visited = true; + + if (current === end) break; + + getValidNeighbors(current).forEach(neighbor => { + if (neighbor.visited) return; + + const tentativeG = current.g + 1; + if (tentativeG < (neighbor.g || Infinity)) { + parentMap.set(neighbor, current); + neighbor.g = tentativeG; + neighbor.h = heuristic(neighbor, end); + neighbor.f = neighbor.g + neighbor.h; + if (!openSet.includes(neighbor)) { + openSet.push(neighbor); + } + } + }); + } + return { visitedOrder, path: reconstructPath(parentMap, end) }; +} + +function getValidNeighbors(cell) { + const neighbors = []; + const { x, y } = cell; + if (!cell.walls.top && y > 0) neighbors.push(grid[y - 1][x]); + if (!cell.walls.right && x < size - 1) neighbors.push(grid[y][x + 1]); + if (!cell.walls.bottom && y < size - 1) neighbors.push(grid[y + 1][x]); + if (!cell.walls.left && x > 0) neighbors.push(grid[y][x - 1]); + return neighbors; +} + +function heuristic(a, b) { // Manhattan distance + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +function reconstructPath(parentMap, end) { + let path = [end]; + let current = end; + while (parentMap.has(current)) { + current = parentMap.get(current); + path.unshift(current); + } + return path; +} + + +// --- Drawing & Animation --- +function drawCell(cell, color) { + ctx.fillStyle = color; + ctx.fillRect(cell.x * cellSize + 1, cell.y * cellSize + 1, cellSize - 2, cellSize - 2); +} + +function drawMaze() { + ctx.fillStyle = '#17171c'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = '#3a3a4a'; + ctx.lineWidth = 2; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let cell = grid[y][x]; + if (cell.walls.top) { ctx.beginPath(); ctx.moveTo(x * cellSize, y * cellSize); ctx.lineTo((x + 1) * cellSize, y * cellSize); ctx.stroke(); } + if (cell.walls.right) { ctx.beginPath(); ctx.moveTo((x + 1) * cellSize, y * cellSize); ctx.lineTo((x + 1) * cellSize, (y + 1) * cellSize); ctx.stroke(); } + if (cell.walls.bottom) { ctx.beginPath(); ctx.moveTo((x + 1) * cellSize, (y + 1) * cellSize); ctx.lineTo(x * cellSize, (y + 1) * cellSize); ctx.stroke(); } + if (cell.walls.left) { ctx.beginPath(); ctx.moveTo(x * cellSize, (y + 1) * cellSize); ctx.lineTo(x * cellSize, y * cellSize); ctx.stroke(); } + } + } + // Draw start and end points + drawCell(grid[0][0], '#6ee7b7'); // Start + drawCell(grid[size - 1][size - 1], '#f472b6'); // End +} + +function animateSolution(visitedOrder, path) { + let i = 0; + const speed = 101 - speedSlider.value; + + function animate() { + if (i < visitedOrder.length) { + drawCell(visitedOrder[i], '#3b82f6'); // Visited color + nodesVisitedEl.textContent = i + 1; + i++; + animationFrameId = setTimeout(animate, speed / 5); + } else { + drawPath(path); + } + } + animate(); +} + +function drawPath(path) { + let i = 0; + function animate() { + if (i < path.length) { + drawCell(path[i], '#eab308'); // Path color + pathLengthEl.textContent = i + 1; + i++; + animationFrameId = setTimeout(animate, 20); + } else { + // Redraw start and end over the path + drawCell(grid[0][0], '#6ee7b7'); + drawCell(grid[size-1][size-1], '#f472b6'); + } + } + animate(); +} + +function clearPath() { + cancelAnimationFrame(animationFrameId); + grid.forEach(row => row.forEach(cell => { + cell.visited = false; + delete cell.g; delete cell.h; delete cell.f; + })); + nodesVisitedEl.textContent = 0; + pathLengthEl.textContent = 0; + timeTakenEl.textContent = '0ms'; + drawMaze(); +} + + +// --- Event Listeners --- +generateBtn.addEventListener('click', () => { + cancelAnimationFrame(animationFrameId); + generateMaze(); + clearPath(); +}); +solveBtn.addEventListener('click', solve); +clearBtn.addEventListener('click', clearPath); + +mazeSizeSlider.addEventListener('input', (e) => { + size = parseInt(e.target.value); + mazeSizeValue.textContent = `${size}x${size}`; + cellSize = canvas.width / size; + cancelAnimationFrame(animationFrameId); + generateMaze(); + clearPath(); +}); + +// --- Initial Load --- +generateMaze(); \ No newline at end of file diff --git a/projects/maze/styles.css b/projects/maze/styles.css index a3ef54d..80810e7 100644 --- a/projects/maze/styles.css +++ b/projects/maze/styles.css @@ -1,25 +1,95 @@ -body { - font-family: system-ui; + body { + font-family: system-ui, -apple-system, sans-serif; background: #0f0f12; color: #eef1f8; margin: 0; - padding: 2rem; + padding: 1rem; display: grid; - place-items: center + place-items: center; + min-height: 100vh; } main { max-width: 560px; - width: 100% + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +h1 { + text-align: center; + margin: 0; + color: #6ee7b7; } canvas { background: #17171c; border: 1px solid #262631; - border-radius: .5rem + border-radius: .5rem; + width: 100%; + height: auto; +} + +.controls { + background: #17171c; + border: 1px solid #262631; + border-radius: .5rem; + padding: 1rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + align-items: center; } -.notes { +.control-group, .button-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +label { + font-size: 0.9rem; color: #a6adbb; - font-size: .9rem +} + +select, button { + background: #262631; + color: #eef1f8; + border: 1px solid #3a3a4a; + border-radius: 0.25rem; + padding: 0.4rem 0.6rem; + font-family: inherit; + cursor: pointer; +} + +button { + background: #6ee7b7; + color: #0f0f12; + font-weight: bold; + border: none; + transition: background 0.2s; +} + +button:hover { + background: #86efc8; +} + +input[type="range"] { + cursor: pointer; +} + +.metrics { + color: #a6adbb; + font-size: .9rem; + display: flex; + justify-content: space-around; + background: #17171c; + padding: 0.75rem; + border-radius: 0.5rem; +} + +.metrics strong { + color: #eef1f8; } \ No newline at end of file