Magic Sketch

<!DOCTYPE html> <html lang="pt-BR"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Etch A Sketch Interativo</title> <script src="https://cdn.tailwindcss.com"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet"> <style> body { font-family: 'Outfit', sans-serif; touch-action: manipulation; } .retro-title { font-family: 'Press Start 2P', cursive; } @keyframes shake { 0% { transform: translate(1px, 1px) rotate(0deg); } 10% { transform: translate(-2px, -2px) rotate(-1deg); } 20% { transform: translate(-3px, 0px) rotate(1deg); } 30% { transform: translate(3px, 2px) rotate(0deg); } 40% { transform: translate(1px, -1px) rotate(1deg); } 50% { transform: translate(-1px, 2px) rotate(-1deg); } 60% { transform: translate(-3px, 1px) rotate(0deg); } 70% { transform: translate(3px, 1px) rotate(-1deg); } 80% { transform: translate(-1px, -1px) rotate(1deg); } 90% { transform: translate(1px, 2px) rotate(0deg); } 100% { transform: translate(1px, -2px) rotate(-1deg); } } .shaking { animation: shake 0.6s ease-in-out infinite; } .screen-container { background-color: #8d99ae; box-shadow: inset 0 0 15px rgba(0,0,0,0.5); } </style> </head> <body class="bg-slate-900 text-slate-100 min-h-screen flex flex-col items-center justify-center p-4"> <div id="toy-body" class="bg-red-600 border-4 border-red-700 rounded-3xl p-6 sm:p-8 shadow-2xl max-w-3xl w-full flex flex-col items-center transition-all duration-300"> <div class="w-full flex flex-col sm:flex-row items-center justify-between mb-4 gap-4"> <h1 class="retro-title text-yellow-300 text-xl sm:text-2xl tracking-widest text-center shadow-sm"> MAGIC SKETCH </h1> <div class="flex flex-wrap items-center gap-2 justify-center"> <button id="tool-pencil" class="bg-slate-800 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm"> <i class="fa-solid fa-pen"></i> <span>Lápis</span> </button> <button id="tool-eraser" class="bg-yellow-400 hover:bg-yellow-300 text-red-900 font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm"> <i class="fa-solid fa-eraser"></i> <span>Borracha</span> </button> <label for="image-upload" class="bg-yellow-400 hover:bg-yellow-300 text-red-900 font-bold py-2 px-4 rounded-xl cursor-pointer shadow-md transition flex items-center gap-2 text-sm"> <i class="fa-solid fa-upload"></i> <span>Desenhar Foto</span> </label> <input type="file" id="image-upload" accept="image/*" class="hidden"> <button id="btn-shake" class="bg-red-800 hover:bg-red-900 text-white font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm"> <i class="fa-solid fa-hand-sparkles"></i> <span>Sacudir</span> </button> <button id="btn-save" class="bg-emerald-500 hover:bg-emerald-400 text-white font-bold py-2 px-3 rounded-xl shadow-md transition text-sm" title="Baixar Imagem"> <i class="fa-solid fa-download"></i> </button> </div> </div> <div class="screen-container relative w-full rounded-2xl border-8 border-red-700 overflow-hidden flex items-center justify-center bg-gray-300"> <canvas id="etch-canvas" class="w-full h-[320px] sm:h-[420px] cursor-crosshair block"></canvas> <div id="status-overlay" class="hidden absolute inset-0 bg-black/70 backdrop-blur-sm flex flex-col items-center justify-center text-white p-6 text-center"> <i id="status-icon" class="fa-solid fa-circle-notch fa-spin text-4xl mb-3 text-yellow-400"></i> <p id="status-text" class="text-lg font-semibold mb-2"> A processar imagem... </p> <div id="progress-container" class="w-full max-w-md bg-gray-800 rounded-full h-5 border border-gray-600 relative overflow-hidden shadow-inner hidden my-2"> <div id="progress-bar" class="bg-gradient-to-r from-yellow-500 to-amber-400 h-full w-0 transition-all duration-75 rounded-full"></div> <span id="progress-text" class="absolute inset-0 flex items-center justify-center text-xs font-bold text-white drop-shadow">0%</span> </div> <button id="btn-cancel" class="mt-3 bg-red-600 hover:bg-red-500 text-white px-4 py-1.5 rounded-lg text-sm hidden shadow"> Cancelar </button> </div> </div> <div class="w-full mt-6 flex flex-col md:flex-row items-center justify-between gap-6"> <div class="flex flex-col items-center gap-2"> <div id="knob-x" class="w-20 h-20 sm:w-24 sm:h-24 bg-gradient-to-br from-white to-gray-300 rounded-full border-4 border-gray-400 shadow-lg cursor-grab flex items-center justify-center relative active:cursor-grabbing select-none"> <div class="w-2 h-2 bg-red-600 rounded-full absolute top-3"></div> <span class="text-xs text-gray-500 font-bold">X</span> </div> <span class="text-xs text-yellow-200 font-medium">Esquerda / Direita</span> </div> <div class="flex flex-col items-center"> <span class="text-xs text-red-200 mb-1">Controlo Direcional / Teclado</span> <div class="grid grid-cols-3 gap-1"> <div></div> <button id="btn-up" class="bg-red-700 active:bg-red-800 text-white w-10 h-10 rounded-lg flex items-center justify-center shadow"> <i class="fa-solid fa-chevron-up"></i> </button> <div></div> <button id="btn-left" class="bg-red-700 active:bg-red-800 text-white w-10 h-10 rounded-lg flex items-center justify-center shadow"> <i class="fa-solid fa-chevron-left"></i> </button> <button id="btn-down" class="bg-red-700 active:bg-red-800 text-white w-10 h-10 rounded-lg flex items-center justify-center shadow"> <i class="fa-solid fa-chevron-down"></i> </button> <button id="btn-right" class="bg-red-700 active:bg-red-800 text-white w-10 h-10 rounded-lg flex items-center justify-center shadow"> <i class="fa-solid fa-chevron-right"></i> </button> </div> </div> <div class="flex flex-col items-center gap-2"> <div id="knob-y" class="w-20 h-20 sm:w-24 sm:h-24 bg-gradient-to-br from-white to-gray-300 rounded-full border-4 border-gray-400 shadow-lg cursor-grab flex items-center justify-center relative active:cursor-grabbing select-none"> <div class="w-2 h-2 bg-red-600 rounded-full absolute top-3"></div> <span class="text-xs text-gray-500 font-bold">Y</span> </div> <span class="text-xs text-yellow-200 font-medium">Cima / Baixo</span> </div> </div> </div> <div class="max-w-xl text-center mt-6 text-slate-400 text-xs sm:text-sm space-y-1"> <p><i class="fa-solid fa-keyboard mr-1"></i> Use as <strong>Teclas de Seta</strong> do teclado para desenhar livremente.</p> <p><i class="fa-solid fa-image mr-1"></i> Carregue uma foto para ver o desenho automático com percentagem em tempo real!</p> <p><i class="fa-solid fa-eraser mr-1"></i> Use a borracha para apagar partes do desenho.</p> </div> <canvas id="hidden-canvas" class="hidden"></canvas> <script> const canvas = document.getElementById('etch-canvas'); const ctx = canvas.getContext('2d'); const hiddenCanvas = document.getElementById('hidden-canvas'); const hiddenCtx = hiddenCanvas.getContext('2d'); const toyBody = document.getElementById('toy-body'); const statusOverlay = document.getElementById('status-overlay'); const statusText = document.getElementById('status-text'); const statusIcon = document.getElementById('status-icon'); const btnCancel = document.getElementById('btn-cancel'); const progressContainer = document.getElementById('progress-container'); const progressBar = document.getElementById('progress-bar'); const progressText = document.getElementById('progress-text'); const toolPencil = document.getElementById('tool-pencil'); const toolEraser = document.getElementById('tool-eraser'); let posX = 0; let posY = 0; const speed = 3; let angleX = 0; let angleY = 0; let currentTool = 'pencil'; let isAutoDrawing = false; let autoDrawCancelRequested = false; const keysPressed = {}; function setTool(tool) { currentTool = tool; toolPencil.className = tool === 'pencil' ? 'bg-slate-800 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm ring-2 ring-yellow-300' : 'bg-slate-800 hover:bg-slate-700 text-white font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm'; toolEraser.className = tool === 'eraser' ? 'bg-yellow-400 hover:bg-yellow-300 text-red-900 font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm ring-2 ring-white' : 'bg-yellow-400 hover:bg-yellow-300 text-red-900 font-bold py-2 px-4 rounded-xl shadow-md transition flex items-center gap-2 text-sm'; } function initCanvas() { const rect = canvas.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; clearScreen(); } function clearScreen() { ctx.fillStyle = '#b0b8c1'; ctx.fillRect(0, 0, canvas.width, canvas.height); posX = canvas.width / 2; posY = canvas.height / 2; ctx.beginPath(); ctx.moveTo(posX, posY); } function setupLineStyle() { ctx.strokeStyle = currentTool === 'eraser' ? '#b0b8c1' : '#2b2d42'; ctx.lineWidth = currentTool === 'eraser' ? 16 : 2.2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; } function drawTo(newX, newY) { newX = Math.max(0, Math.min(canvas.width, newX)); newY = Math.max(0, Math.min(canvas.height, newY)); if (newX === posX && newY === posY) return; setupLineStyle(); ctx.beginPath(); ctx.moveTo(posX, posY); ctx.lineTo(newX, newY); ctx.stroke(); const deltaX = newX - posX; const deltaY = newY - posY; angleX += deltaX * 3; angleY += deltaY * 3; const knobX = document.getElementById('knob-x'); const knobY = document.getElementById('knob-y'); if (knobX) knobX.style.transform = `rotate(${angleX}deg)`; if (knobY) knobY.style.transform = `rotate(${angleY}deg)`; posX = newX; posY = newY; } function gameLoop() { if (!isAutoDrawing) { let dx = 0; let dy = 0; if (keysPressed['ArrowLeft'] || keysPressed['a'] || keysPressed['A']) dx -= speed; if (keysPressed['ArrowRight'] || keysPressed['d'] || keysPressed['D']) dx += speed; if (keysPressed['ArrowUp'] || keysPressed['w'] || keysPressed['W']) dy -= speed; if (keysPressed['ArrowDown'] || keysPressed['s'] || keysPressed['S']) dy += speed; if (dx !== 0 || dy !== 0) { drawTo(posX + dx, posY + dy); } } requestAnimationFrame(gameLoop); } window.addEventListener('keydown', (e) => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) e.preventDefault(); keysPressed[e.key] = true; if (e.key.toLowerCase() === 'e') setTool('eraser'); if (e.key.toLowerCase() === 'p') setTool('pencil'); }); window.addEventListener('keyup', (e) => { keysPressed[e.key] = false; }); toolPencil.addEventListener('click', () => setTool('pencil')); toolEraser.addEventListener('click', () => setTool('eraser')); const setupDpad = (btnId, dx, dy) => { const btn = document.getElementById(btnId); let interval = null; const startMove = (e) => { e.preventDefault(); if (isAutoDrawing) return; drawTo(posX + dx * speed, posY + dy * speed); interval = setInterval(() => { drawTo(posX + dx * speed, posY + dy * speed); }, 20); }; const stopMove = () => { if (interval) clearInterval(interval); }; btn.addEventListener('mousedown', startMove); btn.addEventListener('mouseup', stopMove); btn.addEventListener('mouseleave', stopMove); btn.addEventListener('touchstart', startMove); btn.addEventListener('touchend', stopMove); }; setupDpad('btn-left', -1, 0); setupDpad('btn-right', 1, 0); setupDpad('btn-up', 0, -1); setupDpad('btn-down', 0, 1); const setupKnobDrag = (knobId, isXAxis) => { const knob = document.getElementById(knobId); if (!knob) return; let startPos = 0; let isDragging = false; const onStart = (e) => { if (isAutoDrawing) return; isDragging = true; startPos = e.touches ? (isXAxis ? e.touches[0].clientX : e.touches[0].clientY) : (isXAxis ? e.clientX : e.clientY); }; const onMove = (e) => { if (!isDragging || isAutoDrawing) return; const currentPos = e.touches ? (isXAxis ? e.touches[0].clientX : e.touches[0].clientY) : (isXAxis ? e.clientX : e.clientY); const delta = currentPos - startPos; startPos = currentPos; if (isXAxis) { drawTo(posX + delta * 0.8, posY); } else { drawTo(posX, posY + delta * 0.8); } }; const onEnd = () => { isDragging = false; }; knob.addEventListener('mousedown', onStart); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onEnd); knob.addEventListener('touchstart', onStart); window.addEventListener('touchmove', onMove); window.addEventListener('touchend', onEnd); }; setupKnobDrag('knob-x', true); setupKnobDrag('knob-y', false); document.getElementById('btn-shake').addEventListener('click', () => { if (isAutoDrawing) { autoDrawCancelRequested = true; } toyBody.classList.add('shaking'); setTimeout(() => clearScreen(), 300); setTimeout(() => toyBody.classList.remove('shaking'), 600); }); document.getElementById('btn-save').addEventListener('click', () => { const link = document.createElement('a'); link.download = 'meu-etch-a-sketch.png'; link.href = canvas.toDataURL(); link.click(); }); const imageUpload = document.getElementById('image-upload'); imageUpload.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { processAndDrawImage(img); }; img.src = event.target.result; }; reader.readAsDataURL(file); imageUpload.value = ''; }); function processAndDrawImage(img) { showStatus("A analisar linhas da foto...", true); progressContainer.classList.add('hidden'); setTimeout(() => { const maxWidth = canvas.width; const maxHeight = canvas.height; let width = img.width; let height = img.height; const ratio = Math.min(maxWidth / width, maxHeight / height) * 0.9; width = Math.floor(width * ratio); height = Math.floor(height * ratio); hiddenCanvas.width = width; hiddenCanvas.height = height; hiddenCtx.drawImage(img, 0, 0, width, height); const imageData = hiddenCtx.getImageData(0, 0, width, height); const data = imageData.data; const grayGrid = []; for (let y = 0; y < height; y++) { const row = []; for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const gray = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]; row.push(gray); } grayGrid.push(row); } const edgePoints = []; const threshold = 28; for (let y = 0; y < height - 1; y += 2) { for (let x = 0; x < width - 1; x += 2) { const diffX = Math.abs(grayGrid[y][x] - grayGrid[y][x + 1]); const diffY = Math.abs(grayGrid[y][x] - grayGrid[y + 1][x]); if (diffX > threshold || diffY > threshold) { edgePoints.push({ x, y }); } } } if (edgePoints.length === 0) { hideStatus(); alert("Não foi possível detetar contornos suficientes nesta foto. Tente uma com mais contraste!"); return; } const offsetX = Math.floor((canvas.width - width) / 2); const offsetY = Math.floor((canvas.height - height) / 2); const path = generateEtchPath(edgePoints, offsetX, offsetY); animatePathDrawing(path); }, 100); } function generateEtchPath(points, offsetX, offsetY) { const path = []; if (points.length === 0) return path; const unvisited = [...points]; let current = unvisited.shift(); path.push({ x: current.x + offsetX, y: current.y + offsetY }); while (unvisited.length > 0) { let nearestIdx = -1; let minDist = Infinity; const maxSearch = Math.min(unvisited.length, 300); for (let i = 0; i < maxSearch; i++) { const pt = unvisited[i]; const dist = Math.hypot(pt.x - current.x, pt.y - current.y); if (dist < minDist) { minDist = dist; nearestIdx = i; } } if (nearestIdx !== -1) { current = unvisited.splice(nearestIdx, 1)[0]; path.push({ x: current.x + offsetX, y: current.y + offsetY }); } else { current = unvisited.shift(); path.push({ x: current.x + offsetX, y: current.y + offsetY }); } } return path; } function animatePathDrawing(path) { clearScreen(); isAutoDrawing = true; autoDrawCancelRequested = false; btnCancel.classList.remove('hidden'); progressContainer.classList.remove('hidden'); updateProgress(0); showStatus("A desenhar foto...", false); let index = 0; const totalPoints = path.length; const pointsPerFrame = Math.max(2, Math.floor(totalPoints / 600)); function step() { if (autoDrawCancelRequested || index >= totalPoints) { isAutoDrawing = false; autoDrawCancelRequested = false; hideStatus(); btnCancel.classList.add('hidden'); progressContainer.classList.add('hidden'); return; } for (let i = 0; i < pointsPerFrame && index < totalPoints; i++) { const pt = path[index]; drawTo(pt.x, pt.y); index++; } const percent = Math.min(100, Math.floor((index / totalPoints) * 100)); updateProgress(percent); requestAnimationFrame(step); } requestAnimationFrame(step); } function updateProgress(percent) { progressBar.style.width = `${percent}%`; progressText.innerText = `${percent}%`; } btnCancel.addEventListener('click', () => { autoDrawCancelRequested = true; }); function showStatus(text, spinner = true) { statusText.innerText = text; statusIcon.className = spinner ? "fa-solid fa-circle-notch fa-spin text-4xl mb-3 text-yellow-400" : "fa-solid fa-pen-nib text-4xl mb-3 text-yellow-400 animate-bounce"; statusOverlay.classList.remove('hidden'); } function hideStatus() { statusOverlay.classList.add('hidden'); } window.addEventListener('load', () => { initCanvas(); setTool('pencil'); gameLoop(); }); window.addEventListener('resize', () => { const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvas.width; tempCanvas.height = canvas.height; const tempCtx = tempCanvas.getContext('2d'); tempCtx.drawImage(canvas, 0, 0); initCanvas(); ctx.drawImage(tempCanvas, 0, 0); }); </script> </body> </html>

Public Last updated: 2026-08-06 04:59:20 PM