6
\$\begingroup\$

In a Pong-style 2D game, a ball (a circle of radius BALLRADIUS) can bounce (with perfect elasticity) on the top or bottom of the screen. When it hits the left or right side of the screen, one of the players scores a point. This function calculates the y-coordinate at which a ball initially at coordinates (x, y) and travelling at velocity (vx, vy) will next hit the left or right side of the screen.

const WIDTH = 1000;
const HEIGHT = 1000;
const BALLRADIUS = 10;

function predictBallDestination(x, y, vx, vy) {
    /* Predict y coordinate at which a ball at (x, y) and travelling at velocity
    (vx, vy) will next hit the left or right side of the screen. The ball can bounce
    on the top or bottom of the screen with perfect elasticity. */
    if (vy === 0) {  // Traveling horizontally
        return y;   
    }
    if (vx === 0) {  // Traveling vertically
        throw new Error("Ball.predict: ball can never reach the side because vx is 0");
    }
    // Calculate horizontal distance to the side towards which it is traveling
    const dx = vx > 0 ? WIDTH - BALLRADIUS - x : x - BALLRADIUS;   
    
    // Calculate total vertical distance it will travel 
    const dy = Math.abs(vy * dx / vx); 
    
    // Calculate remaining vertical distance to travel after the first bounce
    let remainder;  
    if (vy > 0) {  // Initially going down
        if (dy <= HEIGHT - y - BALLRADIUS) {
            return y + dy; // no bounce
        }
        remainder = dy - (HEIGHT - y - BALLRADIUS);
    } else {    // Initially going up
        if (dy <= y - BALLRADIUS) {
            return y - dy; // no bounce
        }
        remainder = dy - (y - BALLRADIUS);
    }

    // Calculate distance it will still have to travel after the last bounce
    const lastPart = remainder % (HEIGHT - BALLRADIUS * 2);  

    const bounces = 1 + Math.floor(remainder / (HEIGHT - BALLRADIUS * 2));
    const evenBounces = bounces % 2 === 0;
    console.log("Bounces", bounces);
    if ((vy > 0 && evenBounces) || (vy < 0 && !evenBounces)) {    
        // If the ball is going down after the last bounce
        return lastPart + BALLRADIUS;
    } else {    // going up after the last bounce
        return HEIGHT - BALLRADIUS - lastPart;
    }
}
console.log(predictBallDestination(500, 500, 1, 0));     // 500 - travels right and hits the right side at (WIDTH - 10, 500)
console.log(predictBallDestination(500, 500, 1, .5));   // 745 - travels down-right without bounce 
console.log(predictBallDestination(500, 500, 1, 1));    // 990 - hits at the corner
console.log(predictBallDestination(500, 500, 1, 2));     // 500 - bounces once at the middle bottom 
console.log(predictBallDestination(500, 500, 1, 4));     // 500 - bounces twice and still hits in the middle

I would appreciate any suggestions to simplify the logic or otherwise improve this function. Is there some way to take care of all the different cases (going up or down, no bounce, odd number of bounces, even number of bounces) with a single formula?

\$\endgroup\$

3 Answers 3

1
+100
\$\begingroup\$

Single responsibility.

Never ignore or cover up bugs.

I will point out that the function predictBallDestination has gone way outside its role by throwing an error due to a game state that should never happen.

Even if it is possible somehow for the ball to have no horizontal movement, detecting this state should be done at a much higher level.

Yes this function is where vx = 0 will matter, but then a divide by zero is the least of your problems.

Always attempt to recover the correct state, and reserve throwing errors only when there is absolutely no way to recover the valid state.

Ask yourself "What is setting vx == 0?" and fix the problem there! Throwing an error is just ignoring the underlying bug.

More constants

You code is doing a lot of repeated calculations. Most notable you are subtracting BALLRADIUS many times. Extend your list of constants to avoid the needless math.

For example to define a play field with inset

const WIDTH = canvas.width;   // Whatever sets the size
const HEIGHT = canvas.height;
const INSET = 50;
const BALLRADIUS = 10;
const LEFT = INSET + BALLRADIUS;
const RIGHT = WIDTH - INSET - BALLRADIUS;
const TOP = INSET + BALLRADIUS;
const BOTTOM = HEIGHT - INSET - BALLRADIUS;

To many conditions

You are checking the ball velocity over and over, is ball going left or right, up or down. That can all be done in the math. The only check needed is which edge you want the y position at.

If you set the max slope of the ball's travel such that it does not hit the top and bottom edge more than say 2000 times you can simplify the final odd even check.

// using constants from above
const MAX_BOUNCES = 2000; // number of times ball hits top and bottom
function getYPos(x, y, vx, vy) {
    const h = BOTTOM - TOP;
    const edge = vx < 0 ? LEFT : RIGHT;
    const hy = ((y + (edge - x) * vy / vx) - TOP + h * MAX_BOUNCES) % (h * 2);
    return (hy < h ? hy : h - hy + h) + TOP;
}
\$\endgroup\$
3
  • \$\begingroup\$ Thanks for this suggestion. I put the error check there mainly for transparency / readability - making it obvious to the reader, who might not even know that the ball should always have horizontal movement in Pong, that vx === 0 is not an expected case, as the rest of the function does not make sense in this case. (As it happens with the error catching removed, the function returns NaN when vx is 0, but this is far from obvious from reading it.) Perhaps returning NaN or just documenting the expected range of inputs better would be more appropriate. \$\endgroup\$
    – Stuart
    Apr 6, 2022 at 15:11
  • \$\begingroup\$ @Stuart The reader (I presume is a person modifying the code) should know it can never be zero. Anyone modifying the code that does not know this should not have their fingers in the pie to start with. \$\endgroup\$
    – Blindman67
    Apr 6, 2022 at 15:34
  • \$\begingroup\$ Your final suggestion is what I was looking for - a way to reduce the conditions to (almost) a single equation. However I have ended up defining a modulo function const mod = (a, n) => ((a % n ) + n ) % n and then const hy = mod((y + (edge - x) * vy / vx) - TOP + h * MAX_BOUNCES), h * 2) instead of adding MAX_BOUNCES \$\endgroup\$
    – Stuart
    Apr 7, 2022 at 14:35
2
\$\begingroup\$

As I looked over the code, I considered a few possibilities based on your request regarding the cases of the ball going up and the ball going down (you already handle the ball going vertical only and horizontal only). I thought to write out two new functions, each one handling the up and down cases. However, I decided against it since both functions had some code which were repeated between the two and didn't add significant value (unless further extractions were taken to avoid repeating code).

The primary changes I made were mostly extracting values to add readability and avoided repeating the code and values which these introduced variables held. For example, vy > 0 is repeated a few times in your code, so I extracted it to a constant variable named goingDown:

const goingDown = vy > 0;
//...
const dx = goingDown ? WIDTH - BALLRADIUS - x : x - BALLRADIUS;

There were a few other calculations which were repeated in your code (at least 2 repetitions was my threshold), so I created those as constants within the function.

I also simplified the assignment to remainder by extracting out the check for no bounces so that I could use the ternary operator.

Since the end of the function has an if..else at the end, I remove the else block since there is nothing else at the end after it:

    } // else {
        // Going up after the last bounce
        return HEIGHT - BALLRADIUS - lastPart;
    // }

I would have removed the bounces variable as well, but decided to leave it since we use it in the console.log and it actually helps with readability.

const WIDTH = 1000;
const HEIGHT = 1000;
const BALLRADIUS = 10;

function predictBallDestination(x, y, vx, vy) {
    /* Predict y coordinate at which a ball at (x, y) and travelling at velocity
    (vx, vy) will next hit the left or right side of the screen. The ball can bounce
    on the top or bottom of the screen with perfect elasticity. */
    if (vy === 0) {  // Traveling horizontally
        return y;
    }
    if (vx === 0) {  // Traveling vertically
        throw new Error("Ball.predict: ball can never reach the side because vx is 0");
    }
    const goingDown = vy > 0;
    const goingUp = vy < 0;

    // Calculate horizontal distance to the side toward which it is traveling
    const dx = goingDown ? WIDTH - BALLRADIUS - x : x - BALLRADIUS;

    // Calculate total vertical distance it will travel
    const dy = Math.abs((vy * dx) / vx);

    const TOP_Y = y - BALLRADIUS;
    const DOWN_VERT_DIST = HEIGHT - TOP_Y;
    if (goingDown && dy <= DOWN_VERT_DIST) {
        // Going down and no bounces
        return y + dy;
    }
    if (goingUp && dy <= TOP_Y) {
        // Going up and no bounces
        return y - dy;
    }

    // Calculate remaining vertical distance to travel after the first bounce
    const remainder = goingDown ? dy - DOWN_VERT_DIST : dy - TOP_Y;

    const VERTICAL_TRAVEL = HEIGHT - BALLRADIUS * 2;

    // Calculate distance it will still have to travel after the last bounce
    const lastPart = remainder % VERTICAL_TRAVEL;

    const bounces = 1 + Math.floor(remainder / VERTICAL_TRAVEL);
    const evenBounces = bounces % 2 === 0;
    console.log("Bounces", bounces, "times");
    if ((goingDown && evenBounces) || (goingUp && !evenBounces)) {
        // Going down after the last bounce
        return lastPart + BALLRADIUS;
    }
    // Going up after the last bounce
    return HEIGHT - BALLRADIUS - lastPart;
}
console.log(predictBallDestination(500, 500, 1, 0)); // 500 - travels right and hits the right side at (WIDTH - 10, 500)
console.log(predictBallDestination(500, 500, 1, 0.5)); // 745 - travels down-right without bounce
console.log(predictBallDestination(500, 500, 1, 1)); // 990 - hits at the corner
console.log(predictBallDestination(500, 500, 1, 2)); // 500 - bounces once at the middle bottom
console.log(predictBallDestination(500, 500, 1, 4)); // 500 - bounces twice and still hits in the middle

\$\endgroup\$
0
\$\begingroup\$

I have simplified this slightly and made the logic more transparent by calculating endX and endY (the destination coordinates without any bounce) instead of dx and dy (the horizontal and vertical distances to be traveled).

function predictBallDestination(x, y, vx, vy) {
    /* Predict y coordinate at which a ball at (x, y) and travelling at velocity (vx, vy) will next hit the 
        left or right side of the screen. The ball can bounce on the top or bottom of the screen with perfect
        elasticity. */
    if (vy === 0) {
        return y;
    }
    if (vx === 0) {
        throw new Error("Ball.predict: ball can never reach the side because vx is 0");
    }
    const top = BALLRADIUS;
    const bottom = HEIGHT - BALLRADIUS; 
    const endX = vx > 0 ? WIDTH - BALLRADIUS : BALLRADIUS;   // x-coordinate of the side the ball is bouncing towards
    const endY = y + vy * (endX - x) / vx; // final y coordinate without any bounce 
    if (top <= endY && endY <= bottom) {    // no bounce
        return endY;
    } 
    const remainder = vy > 0 ? endY - bottom : top - endY;
    const innerHeight = bottom - top;
    const bounces = 1 + Math.floor(remainder / innerHeight);
    const evenBounces = bounces % 2 === 0;
    console.log("Bounces", bounces);
    const lastPart = remainder % innerHeight;
    if ((vy > 0 && evenBounces) || (vy < 0 && !evenBounces)) {     // bouncing down  at the end
        return top + lastPart;
    } else {                        // bouncing up at the end
        return bottom - lastPart;
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.