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
