TransWikia.com

JS + CANVAS Projectile Motion

Code Review Asked by J.Albert on November 5, 2021

Basically you’ve got a canvas, and you can click it to launch a ball in a certain angle and speed, everything is working fine, except for the arrow tip of the vector thing. I can’t get it to draw correctly. Sometimes it can’t detect collisions. It seems to happen when the ball has a really high speed.

I managed to solve the collisions problem, but sometimes the colors of the balls change, that shouldn’t be a thing.

How should I approach the arrow tip thing? I mean, how can I draw it at the correct position and angle (so basically the TRANSLATE and ROTATE functions).

class Ball{
    
    
    constructor(positionP, velocityP, accelerationP, radiusP, contextP, colorP){
        
        this.radius = radiusP;
        this.context = contextP;
        this.color = colorP; 
        

        this.colliding = false;
        this.collidingColor = "yellow";
        
        
        this.friction = 0.97;
        this.mass = 1;
        
        this.position = {
            x: positionP.x,
            y: positionP.y
        }
        
        this.velocity = {
            x: velocityP.x,
            y: velocityP.y
        }
        
        this.acceleration = {
            x : 0,
            y : accelerationP/60
        }
    }
    
    draw(){
        
        if(this.colliding) this.context.fillStyle = this.collidingColor;
        else this.context.fillStyle = this.color; 
            
        this.context.beginPath();
        this.context.arc(this.position.x, this.position.y, this.radius, 0, 2*Math.PI);
        this.context.fill();
    }
    
    move(){
               
        this.velocity.x += this.acceleration.x;
        this.velocity.y = this.velocity.y + this.acceleration.y;


        this.position.x += this.velocity.x;
        this.position.y -= this.velocity.y;
        
    }
}



//screen labels
let fpsInd = document.getElementById("fpsIndicator");
let mouseInd = document.getElementById("mouse");
let hypIndicator = document.getElementById("hypotenuse");
let oppIndicator = document.getElementById("opp");
let adjIndicator = document.getElementById("adj");
let thetaIndicator = document.getElementById("theta");


let manualLauncherBtn = document.getElementById("launchManual");
let manualVelocity = document.getElementById("mVelocity");
let manualAngle = document.getElementById("mAngle");

var strThetaChar = thetaIndicator.innerHTML.split(" ")[0];
var currentThetaRadians;

var CANVAS_CLICKED = false;
let canvas = document.getElementById("area");
let ctx = canvas.getContext("2d");

var frames = 0;
var fps = 0;
var lastCallTime;

let Balls = [];

/////////////////////////motion variables//////////
let velocity_initial;
let angle;
let time;


//VERTICAL 
let y_velocity_original = 0;
let y_velocity_final = 0;
let y_original = 0;
let y_final = 0;
let y_acceleration = 0;
let max_height = 0;


//HORIZONTAL 
let x_velocity_original = 0;
let x_velocity_final = 0;
let x_original = 0;
let x_final = 0;
let x_acceleration = 0;




canvas.addEventListener('mousemove', (event)=>{
    updateMouse(event);
    solveTriangle();
});

canvas.addEventListener('click', () => {
   CANVAS_CLICKED = true;    
   MotionMain(ctx); 
});

manualLauncherBtn.addEventListener('click', () => {
   performManuaLaunch(); 
});

var origin = {
    x: 0,
    y: canvas.height
}

var originBalls = {
    x: 0,
    y: canvas.height-1
}

var mousePosition = {
    x: 0,
    y: 0
}




window.requestAnimationFrame(loop);
function loop() {
      
  frames ++;
  getFPS();
    
  console.log(CANVAS_CLICKED);    
    
  if(frames % 3 == 0) 
    fpsInd.innerHTML = "FPS: "+fps;      

  clearScreen();    
  workNextFrame();    
    

  CANVAS_CLICKED = false;    
    
  window.requestAnimationFrame(loop);  
}

//motion functions
function MotionMain(ctx){      
        
    y_acceleration = parseFloat(y_acceleration);  
    solveProblem();
    showLaunchData();
    
    newBall();
}

function performManuaLaunch(){    
    let launchData = createManualBall();
    let {ball, velocities, maxHeight, s, t} = launchData;
           
    showManualData(velocities, t, maxHeight, s);  
    Balls.push(ball);
}

function showManualData(velocities, t, maxHeight, s){
    let a = document.getElementById("acceleration").value;
    
    document.getElementById('velocity').value = manualVelocity.value;
    document.getElementById('launchAngle').value = manualAngle.value;
    document.getElementById('Yvel').value = velocities.y.toFixed(2);
    document.getElementById('Xvel').value = velocities.x.toFixed(2);
    document.getElementById('time').value = parseFloat(t).toFixed(2);
    document.getElementById('mHeight').value = parseFloat(maxHeight).toFixed(2);
    document.getElementById('Xdisplacement').value = parseFloat(s).toFixed(2);
    document.getElementById('yAcceleration').value = a;
}

function createManualBall(){
    let mTime, mDisplacementX, mMaxHeight, mBall;
    
    let m_angle = parseFloat(manualAngle.value);
    let m_vel = parseFloat(manualVelocity.value);
    
    let velY = Math.sin( (m_angle * Math.PI) / 180) * m_vel;
    let velX = Math.cos( (m_angle * Math.PI) / 180) * m_vel;
    
        
    let accelerationY = parseFloat(document.getElementById("acceleration").value);
    
    // 1- get the time -> Vf=Vo+at --> t=Vo-Vf/a
    mTime = Math.abs( velY / accelerationY )*2;
    mTime = mTime.toFixed(3);
    
    // 2- get the range -> Xf=Vo*t
    mDisplacementX = (velX * mTime).toFixed(0);
    
    // 3- get the maximum height -> Vo*Vo/2*a
    mMaxHeight = Math.abs( (velY*velY) / ( 2 * accelerationY ) ).toFixed(2);
    
    mBall = new Ball(originBalls, {
        x: velX,
        y: velY
    }, accelerationY,10, ctx, "yellow");
    
    return {
        t: mTime,
        s: mDisplacementX,
        maxHeight: mMaxHeight,
        velocities: {x: velX, y: velY},
        ball: mBall
    }
}


function solveProblem(){
    
    time = 0;
    x_final = 0;
    max_height = 0;
        
    // 1- get the time -> Vf=Vo+at --> t=Vo-Vf/a
    time = Math.abs( (y_velocity_original - y_velocity_final) / y_acceleration )*2;
    time = time.toFixed(3);
    
    // 2- get the range -> Xf=Vo*t
    x_final = (x_velocity_original * time).toFixed(0);
    
    // 3- get the maximum height -> Vo*Vo/2*a
    max_height = Math.abs( (y_velocity_original*y_velocity_original) / (2*y_acceleration) ).toFixed(2);  
    
    //alert("time: "+time+"s range: "+x_final+"m Max.Height: "+max_height+"m");
}

function showLaunchData(){
    document.getElementById('velocity').value = velocity_initial;
    document.getElementById('launchAngle').value = angle;
    document.getElementById('Yvel').value = y_velocity_original;
    document.getElementById('Xvel').value = x_velocity_original;
    document.getElementById('time').value = time;
    document.getElementById('mHeight').value = max_height;
    document.getElementById('Xdisplacement').value = x_final;
    document.getElementById('yAcceleration').value = y_acceleration;
}

function newBall(){
    
    if(!CANVAS_CLICKED) return;
    
    let c;
    
    
    
    if(angle <= 30) c = "red";
    else if(angle <= 45 && angle > 30) c = "green";
    else if(angle <= 60 && angle > 45) c = "blue";
    else if(angle <= 90 && angle > 60) c = "black";
    
    
    //c = randomColor();
      
    var ball = new Ball( originBalls, {
        x: x_velocity_original,
        y: y_velocity_original
    }, y_acceleration, 8, ctx, c);
    
    
    Balls.unshift(ball);
}

function randomColor(){
    let colors = ['red','cyan','blue','Dark Blue','Light Blue','Purple','Lime','Magenta','silver','gray','green','orange','brown','maroon'];
    return colors[randomInteger(0,colors.length)];
}
function randomInteger(min, max){
    return Math.floor(Math.random() * (max-min+1) + min);
}
/////////////////////////////////////////

function workNextFrame(){
    drawVectorTip(); 
    drawVectorLine(); 
    
    drawAngleIcon();
    drawAngleArc();
    
    
    updateBalls();
    cleanBalls();
    
    if(Balls.length > 1) checkCollisions();
}

function updateBalls(){
    for(let i = Balls.length-1 ; i >= 0; i--){       
        Balls[i].draw();
        Balls[i].move();
    }
}

function cleanBalls(){
    
    let BallsCopy = Balls;
        
    for(let i = Balls.length-1; i >= 0; i--){       
        let ball = Balls[i];
        if(ball.position.y > canvas.height){
            BallsCopy = Balls.filter( (ball) => {
                return ball.position.y <= canvas.height     
            });
        }        
    }
    
    Balls = BallsCopy;
    
    document.getElementById("ballCount").innerHTML = "NºBalls: "+Balls.length;
}

function drawVectorTip(){
    
    var img = document.getElementById("tip");
    
    ctx.save();
    
    ctx.translate(canvas.width/2, canvas.height/2);
    ctx.rotate(-currentThetaRadians);
    
    ctx.drawImage(img, mousePosition.x, mousePosition.y, 40, 40);
    
    ctx.restore();
}



function drawAngleArc(){
    
    ctx.beginPath();
    ctx.arc(origin.x, origin.y, 30, 0, -currentThetaRadians, true);
    ctx.stroke(); 
    
    //document.getElementById("currentRads").innerHTML = currentThetaRadians.toFixed(3);
}

function solveTriangle(){
    var opp = Math.floor(canvas.height - mousePosition.y);
    var adj = Math.floor(mousePosition.x);
    var hyp = Math.floor(Math.sqrt( opp*opp + adj*adj ));
    
    var thetaRadians = Math.atan(opp/adj);
    var thetaDegrees = (thetaRadians*180)/Math.PI;
    
    currentThetaRadians = thetaRadians;
    
    oppIndicator.innerHTML = `Opposite: ${opp}`;
    adjIndicator.innerHTML = `Adjacent: ${adj}`;
    hypIndicator.innerHTML = `Hypotenuse: ${hyp}`;
    
    thetaIndicator.innerHTML = strThetaChar+" "+thetaDegrees.toFixed(2)+"º";
    
    
    passMotionData(opp, adj, hyp, thetaDegrees);  
}

function passMotionData(opp, adj, hyp, thetaDegrees){
    
    velocity_initial = hyp;
    angle = thetaDegrees.toFixed(2);
    time = null; 
    
    y_velocity_original = opp;
    y_velocity_final = 0;
    y_original = canvas.height;
    y_final = canvas.height;
    y_acceleration = document.getElementById("acceleration").value;
    max_height = null;
    
    x_velocity_original = adj;
    x_velocity_final = 0;
    x_original = 0;
    x_final = null;
    x_acceleration = 0;     
}

function drawVectorLine(){
    
    ctx.beginPath();
    ctx.moveTo(origin.x, origin.y);
    ctx.lineTo(mousePosition.x, mousePosition.y);
    ctx.stroke();
    
}

function drawAngleIcon(){
  var img = document.getElementById("angle");
  ctx.drawImage(img, origin.x+5, origin.y-14, 10, 10);
}

function clearScreen(){
    ctx.clearRect(0, 0, canvas.width, canvas.height);
}

function updateMouse(event){
  var rect = event.target.getBoundingClientRect();
     
  mousePosition.x = event.clientX - rect.left; //x position within the element.
  mousePosition.y = event.clientY - rect.top;  //y position within the element.
    
  mouseInd.innerHTML = `X: ${mousePosition.x.toFixed(0)}  Y: ${mousePosition.y.toFixed(0)}`;
}


function checkCollisions(){
    
    let first, second;
    let p = 0;
    let stopInnerLoop = false;;
    
    if(Balls.length < 2) return;
    
    for(let i = 0; i < Balls.length; i++){   
        
        first = Balls[i];
        stopInnerLoop = false;
        p = 0;
        
        while(p < Balls.length && !stopInnerLoop){        
            second = Balls[p];
            
            if(i != p){
               if(distance(first, second) < first.radius + second.radius){
                 first.colliding = true;  
                 second.colliding = true;   
                 resolveCollision(first, second);
                 stopInnerLoop = true;  
               }
               else{
                 first.colliding = false;
                 second.colliding = false;   
               }  
                   
            } 
            
            p++;
        }
    }
    
    updateCollisionsCounter();
}

function distance(first, second){
    let diffX = Math.abs(first.position.x - second.position.x);
    let diffY = Math.abs(first.position.y - second.position.y);
    let distance = Math.sqrt(diffX*diffX + diffY*diffY);
      
    return distance;
}


function updateCollisionsCounter(){
    
    let collisionsElement = document.getElementById("collision");
    let counter = 0;
    
    collisionsElement.innerHTML = "No Collisions";
    
    for(let i = 0; i < Balls.length; i++){        
        if(Balls[i].colliding) counter++;
    }
    
    if(counter > 0){
        collisionsElement.innerHTML = "Balls Colliding x"+(counter);
    }
}

///funcions xungues de colisions
function rotate(velocity, angle){
    const rotatedVelocities = {
        x: velocity.x * Math.cos(angle) - velocity.y * Math.sin(angle),   
        y: velocity.x * Math.sin(angle) + velocity.y * Math.cos(angle)
    };
    
    return rotatedVelocities;
}


function resolveCollision(bubble, otherBubble){
    const xVelocityDiff = Math.abs(bubble.velocity.x - otherBubble.velocity.x);
    const yVelocityDiff = Math.abs(bubble.velocity.y - otherBubble.velocity.y);
    
    const xDist = otherBubble.velocity.x - bubble.velocity.x;
    const yDist = otherBubble.velocity.y - bubble.velocity.y;
    
    //prevent accidental overlap of bubbles
    if(xVelocityDiff * xDist + yVelocityDiff * yDist >= 0){
        
        
        //grab angle between the two colliding bubbles
        const angle = -Math.atan2(otherBubble.velocity.y - bubble.velocity.y, otherBubble.velocity.x - bubble.velocity.x);
        
        //store mass in var for better readability in collision equation
        const m1 = bubble.mass;
        const m2 = otherBubble.mass;
        
        //velocity before equation
        const u1 = rotate(bubble.velocity, angle);
        const u2 = rotate(otherBubble.velocity, angle);
        
        //velocity after 1 dimension collision equation
        const v1 = {x: u1.x * (m1 - m2) / (m1 + m2) + u2.x * 2 * m2 / (m1 + m2), y: u1.y };
        const v2 = {x: u2.x * (m1 - m2) / (m1 + m2) + u1.x * 2 * m2 / (m1 + m2), y: u2.y };
        
        //final velocity after rotating axis back to original location
        const vFinal1 = rotate(v1, -angle);
        const vFinal2 = rotate(v2, -angle);
        
        //swap bubbles velocities for realistic bounce effect
        bubble.velocity.x = vFinal1.x;
        bubble.velocity.y = vFinal1.y;
        
        otherBubble.velocity.x = vFinal2.x;
        otherBubble.velocity.y = vFinal2.y;
    }
}
////

function getFPS(){
    
    let delta;
    
    if(!lastCallTime){
        lastCallTime = Date.now();
        fps = 0;
        return;
    }
    
    delta = (Date.now() - lastCallTime) / 1000;
    lastCallTime = Date.now();
    fps = Math.floor(1/delta);
}
* {
    -webkit-user-select: none; /* Safari */        
    -moz-user-select: none; /* Firefox */
    -ms-user-select: none; /* IE10+/Edge */
    user-select: none; /* Standard */
}


canvas {

    height: 500px;
    width: 1700px;
    display: block;
    margin-left: auto;
    margin-right: auto;
    border: 1px solid red;
}

p {
    margin: 0;
    margin-top: 8px;
}

#infoarea {
    display: flex;
    justify-content: flex-start;
}

#container {
    display: flex;
    justify-content: space-around:
}

.subcontainer{
    margin: 4px;
    padding: 4px;
    box-shadow: 2px 2px 2px gray;
}
<html lang="es">
    <head>
        <meta charset="utf-8">
        <title>Projectile Motion</title>
        <link rel="stylesheet" href="index.css">
    </head>
    
    <body>
        
        <h3 id="fpsIndicator">FPS: 0</h3>
        
        <h3 id="mouse">X:0  Y:0</h3>
              
        <canvas id="area" height=500 width=1700></canvas>
        
        <p id="collision">collision indicator</p>
        <p id="ballCount">NºBalls: 0</p>
        
        <br>
        <br>
               
        <div id="infoarea">
           
            <!-- triangle-->
            <div style="border: 1px solid black; width: 220px; padding: 3px;">
                <h2 style="text-align: center;">TRIANGLE</h2>

                <h3 id="hypotenuse">hypotenuse: 0</h3>

                <h3 id="adj">adjacent: 0</h3>
                <h3 id="opp">opposite: 0</h3>
                <h3 id="theta">&theta;: 0.00º</h3>
                <h3 id="currentRads"></h3>
            </div>
            
            <!-- motion input -->
            <div style="border: 1px solid black; width: 220px; padding: 3px;">
                <h2 style="text-align: center;">MOTION INPUT</h2>

                
                <div>
                    <p>Launch Velocity (m/s)</p>
                    <input type="number" id="velocity">
                </div>
                
                <div>
                    <p>Launch Angle (degrees)</p>
                    <input type="number" id="launchAngle">
                </div>
                
                <div>
                    <p>Vertical Acceleration (gravity)</p>
                    <select name="" id="acceleration">
                        <option value="-3.7">Mercury (3.7 m/s)</option>
                        <option value="-8.87">Venus (8.87 m/s)</option>
                        <option selected value="-9.807">Earth (9.807 m/s)</option>
                        <option value="-3.711">Mars (3.711 m/s)</option>
                        <option value="-24.79">Jupiter (24.79 m/s)</option>
                        <option value="-10.44">Saturn (10.44 m/s)</option>
                        <option value="-8.87">Uranus (8.87 m/s)</option>
                        <option value="-11.15">Neptune (11.15 m/s)</option>
                        <option value="-0.62">Pluto (0.62 m/s)</option>
                        <option value="-5000">STRONK GRAVITY (5000m/s)</option>
                    </select>
                </div>
                
            </div>
            
            <!-- motion data -->
            <div style="border: 1px solid black; padding: 3px;">
                <h2 style="text-align: center;">
                    PREDICTED MOTION DATA
                    <h4 style="text-align: center;">(with 0 collisions assumed)</h4>    
                </h2>
                

                <div id="container">
                  
                   <!-- fields -->
                   <div class="subcontainer">
                        <div>
                            <p>Vertical Velocity (m/s)</p>
                            <input type="number" id="Yvel">
                        </div>

                        <div>
                            <p>Horizontal Velocity (m/s)</p>
                            <input type="number" id="Xvel">
                        </div>

                        <div>
                            <p>Flight Time (seconds)</p>
                            <input type="number" id="time">
                        </div>
                    </div>
                    
                    <!-- fields 2 -->
                    <div class="subcontainer">
                        <div>
                            <p>Max Height achieved (m)</p>
                            <input type="number" id="mHeight">
                        </div>

                        <div>
                            <p>X displacement (aprox.range)</p>
                            <input type="number" id="Xdisplacement">
                        </div>

                        <div>
                            <p>Y Acceleration (gravity)</p>
                            <input type="number" id="yAcceleration">
                        </div>
                    </div>
                    
                    
                    
                    
                </div>    
                
            </div>
            
            <!-- manual input-->
            <div style="border: 1px solid black; padding: 3px;">
               
                <h2 style="text-align: center;">MANUAL INPUT</h2>
                
                <div id="container">
                  
                   <!-- fields -->
                   <div class="subcontainer">
                       
                        <div>
                            <p>Launch Angle (degrees)</p>
                            <input type="number" id="mAngle">
                        </div>

                        <div>
                            <p>Launch Velocity (m/s)</p>
                            <input type="number" id="mVelocity">
                        </div>

                       <div style="display: flex; justify-content: center; margin-top: 3px;">
                            <button id="launchManual">
                                Launch!
                            </button>
                        </div>
                        
                    </div>
                </div>
                
                
            </div>
            
        </div>    
        
        
        <!-- THESE TWO IMAGES ARE SUPPOSED TO BE THE THETA SYMBOL FOR THE ANGLE, 
             AND THE ARROW TIP, FOR THE VECTOR, NOW THEY'RE EMPTY FOR OBVIOUS REASONS -->
        
        <img src="" id="angle" alt="" style="display: none;">
        <img src="" id="tip" alt="" style="display: none;">
    </body>
    
    <footer>
        <script src="ball.js" type="text/javascript"></script>
        <script src="motion.js" type="text/javascript"></script>
        <script src="index.js" type="text/javascript"></script>
    </footer>
    
</html>

One Answer

Welcome to Code Review! This is an interesting project and hopefully the information below is helpful. By the way, some of the statements and questions in the post (e.g. "I can't get it to draw correctly. Sometimes it can't detect collisions." and "How should I approach the arrow tip thing?") could be perceived as making the post not on-topic on this site but the suggestions below pertain to the existing code.

There are fourteen variables declared with const though that does not mean that they are immutable - it just means they cannot be re-assigned. Properties of objects declared with const can be updated unless Object.freeze() is called on the object when it is assigned. It is recommended that const be the default keyword used and then when re-assignment is deemed necessary switch to using let. There are very few reasons to use var.

Typically code in C-based languages follows a convention where constants are named in ALL_CAPS. This code has CANVAS_CLICKED but that is not a constant. For the sake of readability use a consistent convention like camelCase or snake_case for that variable like the other variables. Additionally, Uppercase names are often reserved for classes, so while Ball is a class, the array Balls should be balls.


There are multiple functions that call document.getElementById() to fetch DOM elements. While it may not be a large issue with todays browsers, it is wise to cache those references to the DOM elements in variables because each lookup can be expensive.

bridge toll

”...DOM access is actually pretty costly - I think of it like if I have a bridge - like two pieces of land with a toll bridge, and the JavaScript engine is on one side, and the DOM is on the other, and every time I want to access the DOM from the JavaScript engine, I have to pay that toll”
    - John Hrvatin, Microsoft, MIX09, in this talk Building High Performance Web Applications and Sites at 29:38, also cited in the O'Reilly Javascript book by Nicholas C Zakas Pg 36, as well as mentioned in this post


The click handler on the launcher button, i.e.

manualLauncherBtn.addEventListener('click', () => {
  performManuaLaunch(); 
});

could be simplified to:

manualLauncherBtn.addEventListener('click', performManuaLaunch)

The loops could be update to use for...of loops in some places - e.g. instead of this loop in updateBalls():

for(let i = Balls.length-1 ; i >= 0; i--){       
    Balls[i].draw();
    Balls[i].move();
}

It could be simplified to:

for(const ball of Balls){       
    ball.draw();
    ball.move();
}

Though bear in mind that performance would suffer. If you did want to keep the for loop, the post-loop operation could be combined with the while expression:

for(let i = Balls.length ; i--; /* intentionally blank */){       

And this loop in updateCollisionsCounter() could be simplified from:

for(let i = 0; i < Balls.length; i++){        
    if(Balls[i].colliding) counter++;
}

to:

const counter = Balls.filter(b => b.colliding).length;

Answered by Sᴀᴍ Onᴇᴌᴀ on November 5, 2021

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP