Explicación de una simulación interactiva de un tiro parabólico usando HTML y JavaScript con cálculos físicos para visualizar la trayectoria de un proyectil.
Vamos a implementar una simulación interactiva de un tiro balístico parabólico usando el elemento <canvas>
con JavaScript. Representaremos visualmente la trayectoria de un proyectil influido por la gravedad. La idea es mostrar cómo se pueden combinar conceptos físicos básicos y ver como se puede montar esta visualización con tecnología web.
Puedes ver todo el código al final del artículo, está comentado y es entendible pero algo extenso, por eso trataremos de explicarlo a grandes rasgos. El código se organiza en tres grandes grupos de funcionalidades:
Definimos un elemento <canvas>
con dimensiones específicas que actúa como área de dibujo y un bloque de información (<div>
) para mostrar parámetros como ángulo, velocidad inicial, tiempo transcurrido, altura, distancia y la altura máxima alcanzada. Estos elementos permiten al usuario observar cómo se modifican los valores conforme avanza la simulación.
<canvas id="canvas" width="800" height="600"></canvas><div id="info"> <p>Angle: <span id="angle">0</span> degrees</p> <p>Initial Velocity: <span id="v0">0</span> m/s</p> <p>Time: <span id="time">0</span> s</p> <p>Height: <span id="height">0</span> m</p> <p>Distance: <span id="distance">0</span> m</p> <p>Max Height: <span id="max_height">0</span> m</p></div>
Se establece un sistema de coordenadas personalizado, ubicando el origen en la parte inferior izquierda del lienzo para emular un entorno similar a un plano cartesiano. Con funciones dedicadas dibujamos:
El comportamiento del proyectil se basa en fórmulas clásicas del tiro parabólico. Se calculan las componentes horizontal y vertical de la velocidad y, en función del tiempo, se actualizan la posición y la altura. Para animar la simulación se usa la función requestAnimationFrame
, que refresca la escena en cada fotograma y acumula los puntos del recorrido en un arreglo para luego dibujar toda la trayectoria.
La interactividad se gestiona mediante eventos de ratón:
Puedes ver todo el código y su funcionamiento a continuación:
<!DOCTYPE html><html lang="en">
<head> <meta charset="UTF-8"> <title>Simulación de un tiro balístico parabólico</title> <style> body { font-family: Arial, sans-serif; display: flex; flex-direction: column; align-items: center; background: white; margin: 0; padding: 20px; }
#canvas { border: 1px solid black; background: white; }
#info { margin-top: 10px; padding: 10px; background: rgba(255, 255, 255, 0.8); border-radius: 5px; } </style></head>
<body> <p>Pulsa sobre el elemento canvas para definir un angulo y la parábola a disparar.</p> <canvas id="canvas" width="800" height="600"></canvas> <div id="info"> <p>Ángulo: <span id="angle">0</span> grados</p> <p>Velocidad inicial: <span id="v0">0</span> m/s</p> <p>Tiempo: <span id="time">0</span> s</p> <p>Altura: <span id="height">0</span> m</p> <p>Distancia: <span id="distance">0</span> m</p> <p>Altura máxima: <span id="max_height">0</span> m</p> </div>
<script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const scale = 10; // 10 pixels per meter const g = 9.8; // gravity in m/s² const originX = 50; const originY = canvas.height - 50;
let state = { angle: 0, // degrees v0: 0, // m/s t: 0, // seconds maxHeight: 0, isDragging: false, isShooting: false, arrowEndX: 0, arrowEndY: 0, trail: [], lastTime: null, animationFrameId: null };
// Draw axes with grid lines function drawAxes() { ctx.strokeStyle = 'black'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(originX, originY); ctx.lineTo(canvas.width - 10, originY); ctx.moveTo(originX, originY); ctx.lineTo(originX, 10); ctx.stroke(); ctx.fillText('Distancia (m)', canvas.width / 2, canvas.height - 10); ctx.save(); ctx.translate(10, canvas.height / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('Altura (m)', 0, 0); ctx.restore();
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; for (let x = originX + scale * 10; x < canvas.width; x += scale * 10) { ctx.beginPath(); ctx.moveTo(x, originY); ctx.lineTo(x, 10); ctx.stroke(); ctx.fillText(((x - originX) / scale).toFixed(0), x - 5, originY + 15); } for (let y = originY - scale * 10; y > 0; y -= scale * 10) { ctx.beginPath(); ctx.moveTo(originX, y); ctx.lineTo(canvas.width - 10, y); ctx.stroke(); ctx.fillText(((originY - y) / scale).toFixed(0), originX - 25, y + 5); } }
// Draw the aiming arrow and angle arc with label function drawArrowAndAngle() { if (!state.isDragging) return; ctx.strokeStyle = 'red'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(originX, originY); ctx.lineTo(originX + state.arrowEndX, originY - state.arrowEndY); ctx.stroke();
const headLength = 10; const dx = state.arrowEndX; const dy = state.arrowEndY; const angleRad = Math.atan2(dy, dx); ctx.beginPath(); ctx.moveTo(originX + state.arrowEndX, originY - state.arrowEndY); ctx.lineTo( originX + state.arrowEndX - headLength * Math.cos(angleRad - Math.PI / 6), originY - state.arrowEndY + headLength * Math.sin(angleRad - Math.PI / 6) ); ctx.moveTo(originX + state.arrowEndX, originY - state.arrowEndY); ctx.lineTo( originX + state.arrowEndX - headLength * Math.cos(angleRad + Math.PI / 6), originY - state.arrowEndY + headLength * Math.sin(angleRad + Math.PI / 6) ); ctx.stroke();
// Draw angle arc ctx.strokeStyle = 'purple'; ctx.lineWidth = 1; ctx.beginPath(); const radius = 30; const adjustedAngleRad = -Math.max(0, Math.min(angleRad, Math.PI / 2)); ctx.arc(originX, originY, radius, 0, adjustedAngleRad, true); ctx.stroke();
// Draw angle label near arrowhead ctx.fillStyle = 'black'; ctx.font = '12px Arial'; const labelX = originX + state.arrowEndX + 10; // Offset to the right const labelY = originY - state.arrowEndY - 10; // Offset above ctx.fillText(`${state.angle.toFixed(1)}°`, labelX, labelY); }
// Draw the projectile function drawProjectile(x, y) { const drawX = originX + x * scale; const drawY = originY - y * scale; ctx.fillStyle = 'red'; ctx.beginPath(); ctx.arc(drawX, drawY, 5, 0, 2 * Math.PI); ctx.fill(); }
// Draw the trail or preview path function drawPath(points, isPreview = false) { ctx.strokeStyle = 'blue'; ctx.lineWidth = isPreview ? 1 : 2; if (isPreview) ctx.setLineDash([5, 5]); ctx.beginPath(); points.forEach((point, index) => { const drawX = originX + point.x * scale; const drawY = originY - point.y * scale; if (index === 0) ctx.moveTo(drawX, drawY); else ctx.lineTo(drawX, drawY); }); ctx.stroke(); ctx.setLineDash([]); }
// Calculate trajectory for preview function calculateTrajectory() { const vx = state.v0 * Math.cos(state.angle * Math.PI / 180); const vy = state.v0 * Math.sin(state.angle * Math.PI / 180); const tFlight = (2 * vy) / g; const path = []; for (let t = 0; t <= tFlight; t += 0.1) { const x = Math.max(0, vx * t); const y = Math.max(0, vy * t - 0.5 * g * t * t); path.push({ x, y }); } return path; }
// Update parameters from arrow, limit to 0-90 degrees function updateParameters(mouseX, mouseY) { let dx = Math.max(0, (mouseX - originX) / scale); // No negative x let dy = Math.max(0, (originY - mouseY) / scale); // No negative y state.angle = Math.atan2(dy, dx) * 180 / Math.PI; state.v0 = Math.sqrt(dx * dx + dy * dy) * 2; state.arrowEndX = dx * scale; state.arrowEndY = dy * scale; }
// Update info display function updateInfo() { const currentHeight = state.trail.length ? state.trail[state.trail.length - 1].y : 0; const currentDistance = state.trail.length ? state.trail[state.trail.length - 1].x : 0; document.getElementById('angle').textContent = state.angle.toFixed(2); document.getElementById('v0').textContent = state.v0.toFixed(2); document.getElementById('time').textContent = state.t.toFixed(2); document.getElementById('height').textContent = currentHeight.toFixed(2); document.getElementById('distance').textContent = currentDistance.toFixed(2); document.getElementById('max_height').textContent = state.maxHeight.toFixed(2); }
// Render the scene function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawAxes();
if (state.isDragging) { drawArrowAndAngle(); const previewPath = calculateTrajectory(); drawPath(previewPath, true); drawProjectile(0, 0); } else if (state.isShooting || state.trail.length > 0) { drawPath(state.trail); const x = state.trail[state.trail.length - 1].x; const y = state.trail[state.trail.length - 1].y; drawProjectile(x, y); if (y === 0 && state.t > 0) { ctx.fillStyle = 'orange'; ctx.beginPath(); ctx.arc(originX + x * scale, originY, 10, 0, 2 * Math.PI); ctx.fill(); } } else { drawProjectile(0, 0); } }
// Animation step with delta time function animate(timestamp) { if (!state.lastTime) state.lastTime = timestamp; const deltaTime = (timestamp - state.lastTime) / 1000; // seconds state.t += deltaTime; state.lastTime = timestamp;
const vx = state.v0 * Math.cos(state.angle * Math.PI / 180); const vy = state.v0 * Math.sin(state.angle * Math.PI / 180); const x = Math.max(0, vx * state.t); const y = Math.max(0, vy * state.t - 0.5 * g * state.t * state.t);
state.trail.push({ x, y }); if (y > state.maxHeight) state.maxHeight = y;
if (y === 0 && state.t > 0) { state.isShooting = false; }
updateInfo(); render(); if (state.isShooting) { state.animationFrameId = requestAnimationFrame(animate); } }
// Event listeners canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top;
// Reset state for new shot if (state.animationFrameId) cancelAnimationFrame(state.animationFrameId); state = { angle: 0, v0: 0, t: 0, maxHeight: 0, isDragging: true, isShooting: false, arrowEndX: 0, arrowEndY: 0, trail: [], lastTime: null, animationFrameId: null }; updateParameters(mouseX, mouseY); render(); });
canvas.addEventListener('mousemove', (e) => { if (state.isDragging) { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; updateParameters(mouseX, mouseY); render(); } });
canvas.addEventListener('mouseup', () => { if (state.isDragging) { state.isDragging = false; state.isShooting = true; state.trail = [{ x: 0, y: 0 }]; state.t = 0; state.maxHeight = 0; state.animationFrameId = requestAnimationFrame(animate); } });
// Handle tab visibility change document.addEventListener('visibilitychange', () => { if (document.hidden) { if (state.animationFrameId) { cancelAnimationFrame(state.animationFrameId); state.animationFrameId = null; } } else { if (state.isShooting && !state.animationFrameId) { state.lastTime = performance.now(); state.animationFrameId = requestAnimationFrame(animate); } } });
// Initial render updateInfo(); render(); </script></body>
</html>
El ejemplo expuesto demuestra cómo se puede representar el tiro parabólico en la web. Un concepto físico básico fácil de implementar en programación usando JavaScript y HTML.