Created
August 16, 2026 11:19
-
-
Save mildlyautistic/33a6f98e00f3031dd3df02da217ba68e to your computer and use it in GitHub Desktop.
A vibe coded simple grid painter for planning game levels.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Infinite Grid Painter - Advanced</title> | |
| <style> | |
| body, html { | |
| margin: 0; padding: 0; width: 100%; height: 100%; | |
| overflow: hidden; font-family: sans-serif; background-color: #111; | |
| } | |
| #toolbar { | |
| position: absolute; top: 0; left: 0; width: 100%; | |
| background: #222; padding: 10px; display: flex; gap: 10px; | |
| align-items: center; box-sizing: border-box; | |
| border-bottom: 1px solid #444; color: white; z-index: 10; | |
| flex-wrap: wrap; | |
| } | |
| .color-btn { | |
| width: 26px; height: 26px; border: 2px solid transparent; | |
| border-radius: 4px; cursor: pointer; | |
| } | |
| .color-btn.active { border-color: white; outline: 2px solid #aaa; } | |
| .divider { width: 2px; height: 26px; background: #444; margin: 0 5px; } | |
| button { | |
| padding: 6px 12px; background: #444; color: white; | |
| border: none; border-radius: 4px; cursor: pointer; font-size: 13px; | |
| } | |
| button:hover { background: #555; } | |
| button.tool.active { background: #666; outline: 2px solid white; } | |
| .action { background: #007bff; font-weight: bold; } | |
| .action:hover { background: #0056b3; } | |
| .danger { background: #dc3545; } | |
| .danger:hover { background: #a71d2a; } | |
| #selection-actions { display: none; gap: 10px; } | |
| canvas { display: block; } | |
| /* Dialog Styles */ | |
| dialog { | |
| background: #2a2a2a; color: white; border: 1px solid #555; | |
| border-radius: 8px; padding: 20px; width: 300px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5); | |
| } | |
| dialog::backdrop { background: rgba(0, 0, 0, 0.6); } | |
| .dialog-group { margin-bottom: 15px; } | |
| .dialog-group label { display: block; margin-bottom: 5px; color: #ccc; font-size: 13px; } | |
| .dialog-group input, .dialog-group select { | |
| width: 100%; box-sizing: border-box; background: #111; color: white; | |
| border: 1px solid #444; padding: 8px; border-radius: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="toolbar"> | |
| <span>Colors:</span> | |
| <div class="color-btn active" style="background: #ff3b30;" data-color="#ff3b30"></div> | |
| <div class="color-btn" style="background: #34c759;" data-color="#34c759"></div> | |
| <div class="color-btn" style="background: #007aff;" data-color="#007aff"></div> | |
| <div class="color-btn" style="background: #ffcc00;" data-color="#ffcc00"></div> | |
| <div class="color-btn" style="background: #ffffff;" data-color="#ffffff"></div> | |
| <div class="color-btn" style="background: #000000;" data-color="#000000"></div> | |
| <div class="divider"></div> | |
| <button class="tool active" data-tool="paint" title="Shortcut: 1">Paint (1)</button> | |
| <button class="tool" data-tool="select" title="Shortcut: 2">Select (2)</button> | |
| <button class="tool" data-tool="text" title="Shortcut: 3">Add Note (3)</button> | |
| <button class="tool" data-tool="marker" title="Shortcut: 4">Add Marker (4)</button> | |
| <div class="divider"></div> | |
| <span>Zoom:</span> | |
| <button id="btn-zoom-out">-</button> | |
| <button id="btn-zoom-reset">100%</button> | |
| <button id="btn-zoom-in">+</button> | |
| <div class="divider"></div> | |
| <span>Rotate:</span> | |
| <button id="btn-rot-ccw" title="Rotate Left 15°">↺</button> | |
| <button id="btn-rot-cw" title="Rotate Right 15°">↻</button> | |
| <button id="btn-rot-reset" class="action" style="display:none;">Reset Rotation</button> | |
| <div class="divider"></div> | |
| <div id="selection-actions"> | |
| <button class="action" id="btn-fill">Fill Selection</button> | |
| <button class="danger" id="btn-clear">Clear Selection</button> | |
| </div> | |
| <button class="action" style="margin-left: auto;" id="btn-export">Export PNG</button> | |
| </div> | |
| <canvas id="gridCanvas"></canvas> | |
| <dialog id="marker-dialog"> | |
| <h3 id="marker-dialog-title" style="margin-top: 0;">Add Marker</h3> | |
| <div class="dialog-group"> | |
| <label>Icon:</label> | |
| <select id="marker-icon"> | |
| <option value="🚩">🚩 Flag</option> | |
| <option value="👾">👾 Enemy / Monster</option> | |
| <option value="💰">💰 Loot / Gold</option> | |
| <option value="🚪">🚪 Door / Exit</option> | |
| <option value="🔑">🔑 Key</option> | |
| <option value="⭐️">⭐️ Star / Goal</option> | |
| <option value="💀">💀 Danger / Skull</option> | |
| <option value="⚔️">⚔️ Combat / Weapon</option> | |
| <option value="🛡️">🛡️ Shield / Armor</option> | |
| <option value="❤️">❤️ Health / Restore</option> | |
| </select> | |
| </div> | |
| <div class="dialog-group"> | |
| <label>Label (Optional):</label> | |
| <input type="text" id="marker-label" placeholder="e.g. Boss Room"> | |
| </div> | |
| <div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;"> | |
| <button class="danger" id="btn-marker-delete" style="display: none; margin-right: auto;">Delete</button> | |
| <button id="btn-marker-cancel">Cancel</button> | |
| <button class="action" id="btn-marker-save">Save</button> | |
| </div> | |
| </dialog> | |
| <script> | |
| const canvas = document.getElementById('gridCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Configuration & State | |
| const BASE_CELL_SIZE = 20; | |
| let zoom = 1; | |
| let rotation = 0; // NEW: Track rotation in radians | |
| let camera = { x: 0, y: 0 }; | |
| const cells = new Map(); | |
| const notes = []; | |
| let noteIdCounter = 0; | |
| const markers = []; | |
| let markerIdCounter = 0; | |
| const history = []; | |
| const MAX_HISTORY = 50; | |
| let currentColor = '#ff3b30'; | |
| let currentTool = 'paint'; | |
| let isDragging = false; | |
| let isPanning = false; | |
| let lastCell = null; | |
| let lastMouse = { x: 0, y: 0 }; | |
| let selection = null; | |
| let draggingNote = null; | |
| let noteDragOffset = { x: 0, y: 0 }; | |
| let pendingMarkerGridPos = null; | |
| let editingMarker = null; | |
| function resize() { | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| draw(); | |
| } | |
| window.addEventListener('resize', resize); | |
| // --- MATH / TRANSFORM HELPERS --- | |
| function getCellSize() { return BASE_CELL_SIZE * zoom; } | |
| // NEW: Inverse transforms screen clicks to logical grid space when rotated | |
| function getTransformedMouse(screenX, screenY) { | |
| if (rotation === 0) return { x: screenX, y: screenY }; | |
| const cx = canvas.width / 2; | |
| const cy = canvas.height / 2; | |
| const dx = screenX - cx; | |
| const dy = screenY - cy; | |
| const invRot = -rotation; // Inverse rotation | |
| const nx = dx * Math.cos(invRot) - dy * Math.sin(invRot) + cx; | |
| const ny = dx * Math.sin(invRot) + dy * Math.cos(invRot) + cy; | |
| return { x: nx, y: ny }; | |
| } | |
| function getGridCoords(screenX, screenY) { | |
| const tm = getTransformedMouse(screenX, screenY); | |
| const size = getCellSize(); | |
| return { | |
| x: Math.floor((tm.x - camera.x) / size), | |
| y: Math.floor((tm.y - camera.y) / size) | |
| }; | |
| } | |
| function getScreenCoords(gridX, gridY) { | |
| const size = getCellSize(); | |
| return { | |
| x: gridX * size + camera.x, | |
| y: gridY * size + camera.y | |
| }; | |
| } | |
| // --- STATE MANAGEMENT --- | |
| function saveState() { | |
| const state = { | |
| cells: Array.from(cells.entries()), | |
| notes: JSON.parse(JSON.stringify(notes)), | |
| markers: JSON.parse(JSON.stringify(markers)) | |
| }; | |
| history.push(state); | |
| if (history.length > MAX_HISTORY) history.shift(); | |
| } | |
| function undo() { | |
| if (history.length === 0) return; | |
| const state = history.pop(); | |
| cells.clear(); | |
| state.cells.forEach(([k, v]) => cells.set(k, v)); | |
| notes.length = 0; | |
| notes.push(...state.notes); | |
| markers.length = 0; | |
| markers.push(...state.markers); | |
| draw(); | |
| } | |
| // --- ROTATION UI --- | |
| const ROTATION_STEP = 15 * Math.PI / 180; | |
| const btnRotReset = document.getElementById('btn-rot-reset'); | |
| function updateRotationUI() { | |
| if (Math.abs(rotation % (Math.PI * 2)) > 0.001) { | |
| btnRotReset.style.display = 'inline-block'; | |
| } else { | |
| btnRotReset.style.display = 'none'; | |
| rotation = 0; // snap to exact 0 | |
| } | |
| } | |
| document.getElementById('btn-rot-ccw').addEventListener('click', () => { | |
| rotation -= ROTATION_STEP; | |
| updateRotationUI(); | |
| draw(); | |
| }); | |
| document.getElementById('btn-rot-cw').addEventListener('click', () => { | |
| rotation += ROTATION_STEP; | |
| updateRotationUI(); | |
| draw(); | |
| }); | |
| btnRotReset.addEventListener('click', () => { | |
| rotation = 0; | |
| updateRotationUI(); | |
| draw(); | |
| }); | |
| // --- DRAWING --- | |
| function draw(exportCtx = null, exportCamera = null, exportZoom = null) { | |
| const context = exportCtx || ctx; | |
| const cam = exportCamera || camera; | |
| const z = exportZoom || zoom; | |
| const size = BASE_CELL_SIZE * z; | |
| const width = exportCtx ? exportCtx.canvas.width : canvas.width; | |
| const height = exportCtx ? exportCtx.canvas.height : canvas.height; | |
| context.fillStyle = '#000000'; | |
| context.fillRect(0, 0, width, height); | |
| const cx = width / 2; | |
| const cy = height / 2; | |
| // Apply rotation (Exports force 0 rotation to keep PNGs square) | |
| const isRotated = rotation !== 0 && !exportCtx; | |
| if (isRotated) { | |
| context.save(); | |
| context.translate(cx, cy); | |
| context.rotate(rotation); | |
| context.translate(-cx, -cy); | |
| } | |
| // Expand drawing bounds if rotated to prevent clipping corners | |
| const diag = Math.sqrt(width * width + height * height); | |
| const diffX = isRotated ? (diag - width) / 2 : 0; | |
| const diffY = isRotated ? (diag - height) / 2 : 0; | |
| context.strokeStyle = '#222222'; | |
| context.lineWidth = 1; | |
| const startX = Math.floor((-cam.x - diffX) / size) * size; | |
| const startY = Math.floor((-cam.y - diffY) / size) * size; | |
| const endX = width + diffX + size; | |
| const endY = height + diffY + size; | |
| context.beginPath(); | |
| for (let x = startX; x < endX; x += size) { | |
| context.moveTo(x + (cam.x % size), -diffY); | |
| context.lineTo(x + (cam.x % size), height + diffY); | |
| } | |
| for (let y = startY; y < endY; y += size) { | |
| context.moveTo(-diffX, y + (cam.y % size)); | |
| context.lineTo(width + diffX, y + (cam.y % size)); | |
| } | |
| context.stroke(); | |
| cells.forEach((color, key) => { | |
| const [gx, gy] = key.split(',').map(Number); | |
| const px = gx * size + cam.x; | |
| const py = gy * size + cam.y; | |
| if (px + size > -diffX && px < endX && py + size > -diffY && py < endY) { | |
| context.fillStyle = color; | |
| context.fillRect(Math.floor(px), Math.floor(py), Math.ceil(size), Math.ceil(size)); | |
| } | |
| }); | |
| if (selection && !exportCtx) { | |
| const minX = Math.min(selection.startX, selection.endX); | |
| const maxX = Math.max(selection.startX, selection.endX); | |
| const minY = Math.min(selection.startY, selection.endY); | |
| const maxY = Math.max(selection.startY, selection.endY); | |
| const p1 = getScreenCoords(minX, minY); | |
| const p2 = getScreenCoords(maxX + 1, maxY + 1); | |
| context.fillStyle = 'rgba(0, 123, 255, 0.3)'; | |
| context.fillRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y); | |
| context.strokeStyle = '#007bff'; | |
| context.lineWidth = 2; | |
| context.setLineDash([5, 5]); | |
| context.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y); | |
| context.setLineDash([]); | |
| } | |
| context.textBaseline = 'top'; | |
| markers.forEach(marker => { | |
| const px = marker.x * size + cam.x; | |
| const py = marker.y * size + cam.y; | |
| context.font = `${Math.max(14, 18 * z)}px sans-serif`; | |
| context.fillText(marker.icon, px + (size * 0.1), py + (size * 0.1)); | |
| if (marker.label) { | |
| context.font = `${Math.max(10, 12 * z)}px sans-serif`; | |
| const labelWidth = context.measureText(marker.label).width; | |
| context.fillStyle = 'rgba(0,0,0,0.7)'; | |
| context.fillRect(px, py + size + 2, labelWidth + 4, (12 * z) + 4); | |
| context.fillStyle = marker.color; | |
| context.fillText(marker.label, px + 2, py + size + 4); | |
| } | |
| }); | |
| context.font = `${Math.max(12, 16 * z)}px sans-serif`; | |
| notes.forEach(note => { | |
| const px = note.x * size + cam.x; | |
| const py = note.y * size + cam.y; | |
| const textWidth = context.measureText(note.text).width; | |
| context.fillStyle = 'rgba(0,0,0,0.6)'; | |
| context.fillRect(px, py, textWidth + 8, (16 * z) + 8); | |
| context.fillStyle = note.color; | |
| context.fillText(note.text, px + 4, py + 4); | |
| }); | |
| if (isRotated) { | |
| context.restore(); | |
| } | |
| } | |
| // --- INTERACTIONS --- | |
| function handleZoom(amount, mouseX = canvas.width / 2, mouseY = canvas.height / 2) { | |
| const newZoom = Math.min(Math.max(0.1, zoom * amount), 10); | |
| const tm = getTransformedMouse(mouseX, mouseY); // Account for rotation offset | |
| camera.x = tm.x - (tm.x - camera.x) * (newZoom / zoom); | |
| camera.y = tm.y - (tm.y - camera.y) * (newZoom / zoom); | |
| zoom = newZoom; | |
| draw(); | |
| } | |
| function getNoteAtScreenCoords(screenX, screenY) { | |
| const tm = getTransformedMouse(screenX, screenY); | |
| ctx.font = `${Math.max(12, 16 * zoom)}px sans-serif`; | |
| const size = getCellSize(); | |
| for (let i = notes.length - 1; i >= 0; i--) { | |
| const note = notes[i]; | |
| const px = note.x * size + camera.x; | |
| const py = note.y * size + camera.y; | |
| const textWidth = ctx.measureText(note.text).width; | |
| const textHeight = 16 * zoom; | |
| if (tm.x >= px && tm.x <= px + textWidth + 8 && tm.y >= py && tm.y <= py + textHeight + 8) { | |
| return note; | |
| } | |
| } | |
| return null; | |
| } | |
| function getMarkerAtScreenCoords(screenX, screenY) { | |
| const tm = getTransformedMouse(screenX, screenY); | |
| const size = getCellSize(); | |
| for (let i = markers.length - 1; i >= 0; i--) { | |
| const marker = markers[i]; | |
| const px = marker.x * size + camera.x; | |
| const py = marker.y * size + camera.y; | |
| if (tm.x >= px && tm.x <= px + size && tm.y >= py && tm.y <= py + size) { | |
| return marker; | |
| } | |
| } | |
| return null; | |
| } | |
| canvas.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| const zoomAmount = e.deltaY < 0 ? 1.1 : (1 / 1.1); | |
| handleZoom(zoomAmount, e.clientX, e.clientY); | |
| }); | |
| canvas.addEventListener('mousedown', (e) => { | |
| if (e.button === 2 || e.button === 1) { | |
| isPanning = true; | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| canvas.style.cursor = 'grabbing'; | |
| return; | |
| } | |
| if (e.button === 0) { | |
| const clickedNote = getNoteAtScreenCoords(e.clientX, e.clientY); | |
| if (clickedNote) { | |
| saveState(); | |
| draggingNote = clickedNote; | |
| const tm = getTransformedMouse(e.clientX, e.clientY); | |
| const size = getCellSize(); | |
| noteDragOffset = { | |
| x: (tm.x - camera.x) / size - clickedNote.x, | |
| y: (tm.y - camera.y) / size - clickedNote.y | |
| }; | |
| return; | |
| } | |
| const clickedMarker = getMarkerAtScreenCoords(e.clientX, e.clientY); | |
| if (clickedMarker) { | |
| editingMarker = clickedMarker; | |
| document.getElementById('marker-dialog-title').innerText = 'Edit Marker'; | |
| document.getElementById('marker-icon').value = clickedMarker.icon; | |
| document.getElementById('marker-label').value = clickedMarker.label; | |
| document.getElementById('btn-marker-delete').style.display = 'block'; | |
| document.getElementById('marker-dialog').showModal(); | |
| return; | |
| } | |
| const gridPos = getGridCoords(e.clientX, e.clientY); | |
| if (currentTool === 'paint') { | |
| saveState(); | |
| isDragging = true; | |
| lastCell = gridPos; | |
| cells.set(`${gridPos.x},${gridPos.y}`, currentColor); | |
| selection = null; | |
| document.getElementById('selection-actions').style.display = 'none'; | |
| draw(); | |
| } else if (currentTool === 'select') { | |
| isDragging = true; | |
| selection = { startX: gridPos.x, startY: gridPos.y, endX: gridPos.x, endY: gridPos.y }; | |
| document.getElementById('selection-actions').style.display = 'flex'; | |
| draw(); | |
| } else if (currentTool === 'text') { | |
| const text = prompt("Enter your note:"); | |
| if (text) { | |
| saveState(); | |
| notes.push({ id: noteIdCounter++, x: gridPos.x, y: gridPos.y, text: text, color: currentColor }); | |
| draw(); | |
| } | |
| } else if (currentTool === 'marker') { | |
| pendingMarkerGridPos = gridPos; | |
| editingMarker = null; | |
| document.getElementById('marker-dialog-title').innerText = 'Add Marker'; | |
| document.getElementById('marker-icon').value = '🚩'; | |
| document.getElementById('marker-label').value = ''; | |
| document.getElementById('btn-marker-delete').style.display = 'none'; | |
| document.getElementById('marker-dialog').showModal(); | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| if (isPanning) { | |
| const dx = e.clientX - lastMouse.x; | |
| const dy = e.clientY - lastMouse.y; | |
| // Adjust pan movement according to inverse rotation | |
| if (rotation !== 0) { | |
| const invRot = -rotation; | |
| camera.x += dx * Math.cos(invRot) - dy * Math.sin(invRot); | |
| camera.y += dx * Math.sin(invRot) + dy * Math.cos(invRot); | |
| } else { | |
| camera.x += dx; | |
| camera.y += dy; | |
| } | |
| lastMouse = { x: e.clientX, y: e.clientY }; | |
| draw(); | |
| } else if (draggingNote) { | |
| const tm = getTransformedMouse(e.clientX, e.clientY); | |
| const size = getCellSize(); | |
| draggingNote.x = ((tm.x - camera.x) / size) - noteDragOffset.x; | |
| draggingNote.y = ((tm.y - camera.y) / size) - noteDragOffset.y; | |
| draw(); | |
| } else if (isDragging) { | |
| const currentCell = getGridCoords(e.clientX, e.clientY); | |
| if (currentTool === 'paint') { | |
| if (currentCell.x !== lastCell.x || currentCell.y !== lastCell.y) { | |
| let cx = lastCell.x, cy = lastCell.y; | |
| while (cx !== currentCell.x || cy !== currentCell.y) { | |
| if (Math.abs(currentCell.x - cx) > Math.abs(currentCell.y - cy)) cx += Math.sign(currentCell.x - cx); | |
| else cy += Math.sign(currentCell.y - cy); | |
| cells.set(`${cx},${cy}`, currentColor); | |
| } | |
| lastCell = currentCell; | |
| draw(); | |
| } | |
| } else if (currentTool === 'select') { | |
| selection.endX = currentCell.x; | |
| selection.endY = currentCell.y; | |
| draw(); | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', () => { | |
| isDragging = false; | |
| isPanning = false; | |
| draggingNote = null; | |
| canvas.style.cursor = 'crosshair'; | |
| }); | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| // --- KEYBOARD & UI LISTENERS --- | |
| window.addEventListener('keydown', (e) => { | |
| const activeTag = document.activeElement.tagName; | |
| if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || activeTag === 'SELECT') return; | |
| if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { | |
| e.preventDefault(); | |
| undo(); | |
| return; | |
| } | |
| switch(e.key) { | |
| case '1': document.querySelector('[data-tool="paint"]').click(); break; | |
| case '2': document.querySelector('[data-tool="select"]').click(); break; | |
| case '3': document.querySelector('[data-tool="text"]').click(); break; | |
| case '4': document.querySelector('[data-tool="marker"]').click(); break; | |
| } | |
| }); | |
| const markerDialog = document.getElementById('marker-dialog'); | |
| document.getElementById('btn-marker-cancel').addEventListener('click', () => markerDialog.close()); | |
| document.getElementById('btn-marker-save').addEventListener('click', () => { | |
| saveState(); | |
| const icon = document.getElementById('marker-icon').value; | |
| const label = document.getElementById('marker-label').value; | |
| if (editingMarker) { | |
| editingMarker.icon = icon; | |
| editingMarker.label = label; | |
| } else if (pendingMarkerGridPos) { | |
| markers.push({ | |
| id: markerIdCounter++, | |
| x: pendingMarkerGridPos.x, y: pendingMarkerGridPos.y, | |
| icon: icon, label: label, color: currentColor | |
| }); | |
| } | |
| markerDialog.close(); | |
| draw(); | |
| }); | |
| document.getElementById('btn-marker-delete').addEventListener('click', () => { | |
| if (editingMarker) { | |
| saveState(); | |
| const index = markers.indexOf(editingMarker); | |
| if (index > -1) markers.splice(index, 1); | |
| markerDialog.close(); | |
| draw(); | |
| } | |
| }); | |
| document.querySelectorAll('.color-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| document.querySelectorAll('.color-btn').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| currentColor = e.target.dataset.color; | |
| }); | |
| }); | |
| document.querySelectorAll('button.tool').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| currentTool = e.target.dataset.tool; | |
| document.querySelectorAll('button.tool').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| if (currentTool !== 'select') { | |
| selection = null; | |
| document.getElementById('selection-actions').style.display = 'none'; | |
| draw(); | |
| } | |
| }); | |
| }); | |
| document.getElementById('btn-zoom-in').addEventListener('click', () => handleZoom(1.5)); | |
| document.getElementById('btn-zoom-out').addEventListener('click', () => handleZoom(1 / 1.5)); | |
| document.getElementById('btn-zoom-reset').addEventListener('click', () => { | |
| const oldZoom = zoom; | |
| zoom = 1; | |
| const centerX = canvas.width / 2; | |
| const centerY = canvas.height / 2; | |
| camera.x = centerX - (centerX - camera.x) * (1 / oldZoom); | |
| camera.y = centerY - (centerY - camera.y) * (1 / oldZoom); | |
| draw(); | |
| }); | |
| document.getElementById('btn-fill').addEventListener('click', () => { | |
| if (!selection) return; | |
| saveState(); | |
| const minX = Math.min(selection.startX, selection.endX); | |
| const maxX = Math.max(selection.startX, selection.endX); | |
| const minY = Math.min(selection.startY, selection.endY); | |
| const maxY = Math.max(selection.startY, selection.endY); | |
| for (let x = minX; x <= maxX; x++) { | |
| for (let y = minY; y <= maxY; y++) cells.set(`${x},${y}`, currentColor); | |
| } | |
| draw(); | |
| }); | |
| document.getElementById('btn-clear').addEventListener('click', () => { | |
| if (!selection) return; | |
| saveState(); | |
| const minX = Math.min(selection.startX, selection.endX); | |
| const maxX = Math.max(selection.startX, selection.endX); | |
| const minY = Math.min(selection.startY, selection.endY); | |
| const maxY = Math.max(selection.startY, selection.endY); | |
| for (let x = minX; x <= maxX; x++) { | |
| for (let y = minY; y <= maxY; y++) cells.delete(`${x},${y}`); | |
| } | |
| draw(); | |
| }); | |
| document.getElementById('btn-export').addEventListener('click', () => { | |
| const offCanvas = document.createElement('canvas'); | |
| const offCtx = offCanvas.getContext('2d'); | |
| const exportZoom = 1; | |
| const exportCellSize = BASE_CELL_SIZE; | |
| if (selection) { | |
| const minX = Math.min(selection.startX, selection.endX); | |
| const maxX = Math.max(selection.startX, selection.endX); | |
| const minY = Math.min(selection.startY, selection.endY); | |
| const maxY = Math.max(selection.startY, selection.endY); | |
| offCanvas.width = (maxX - minX + 1) * exportCellSize; | |
| offCanvas.height = (maxY - minY + 1) * exportCellSize; | |
| const exportCamera = { x: -(minX * exportCellSize), y: -(minY * exportCellSize) }; | |
| const tempSelection = selection; | |
| selection = null; | |
| draw(offCtx, exportCamera, exportZoom); | |
| selection = tempSelection; | |
| } else { | |
| offCanvas.width = 2560; | |
| offCanvas.height = 1440; | |
| const screenCenterX = canvas.width / 2; | |
| const screenCenterY = canvas.height / 2; | |
| const centerGridX = (screenCenterX - camera.x) / getCellSize(); | |
| const centerGridY = (screenCenterY - camera.y) / getCellSize(); | |
| const exportCamera = { | |
| x: (offCanvas.width / 2) - (centerGridX * exportCellSize), | |
| y: (offCanvas.height / 2) - (centerGridY * exportCellSize) | |
| }; | |
| draw(offCtx, exportCamera, exportZoom); | |
| } | |
| const link = document.createElement('a'); | |
| link.download = selection ? 'grid-selection.png' : 'grid-2k.png'; | |
| link.href = offCanvas.toDataURL('image/png'); | |
| link.click(); | |
| }); | |
| resize(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment