4
\$\begingroup\$

Here's my attempt at an Arkanoid/Breakout clone using JavaScript with ThreeJS. The feedback I'm looking for is more on the code side; the game itself is a work in progress. You can take it for a spin by running the code snippet below.

(function() {
    "use strict";

    const paddleStates = {
        MOVING_LEFT : 0,
        MOVING_RIGHT : 1,
        STATIONARY : 2
    };

    function createMeshAtPosition(meshProperties, position) {
        let mesh = new THREE.Mesh(meshProperties.geometry, meshProperties.material);
        mesh.position.copy(position);
        return mesh;
    }

    function createFullScreenRenderer(elementId, settings) {
        let renderer = new THREE.WebGLRenderer({
            canvas: document.getElementById(elementId)
        });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setClearColor(settings.backgroundColor);
        return renderer;
    }

    function createCamera() {
        let camera = new THREE.PerspectiveCamera(
            90,
            window.innerWidth / window.innerHeight,
            0.1,
            3000);
        camera.position.set(0.0, 10.0, 0.0);
        camera.lookAt(0.0, 0.0, -10.0);
        return camera;
    }

    function makeResizeCallback(camera, renderer) {
        return function() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        };
    }

    function makeKeyDownCallback(paddle, speed) {
        return function(event) {
            if (paddle.state === paddleStates.STATIONARY) {
                if (event.key === "ArrowLeft") {
                    paddle.velocity.x = -speed;
                    paddle.state = paddleStates.MOVING_LEFT;
                } else if (event.key === "ArrowRight") {
                    paddle.velocity.x = speed;
                    paddle.state = paddleStates.MOVING_RIGHT;
                }
            }
        };
    }

    function makeKeyUpCallback(paddle) {
        return function(event) {
            if (paddle.state === paddleStates.MOVING_LEFT && event.key === "ArrowLeft") {
                paddle.velocity.x = 0.0;
                paddle.state = paddleStates.STATIONARY;
            } else if (paddle.state === paddleStates.MOVING_RIGHT && event.key === "ArrowRight") {
                paddle.velocity.x = 0.0;
                paddle.state = paddleStates.STATIONARY;
            }
        };
    }

    function updatePosition(gameObject) {
        gameObject.mesh.position.add(gameObject.velocity);
    }

    function resolveBallBlockCollision(ball, blockMesh, blockProperties, callback) {
        if ((ball.mesh.position.z + ball.radius > blockMesh.position.z - blockProperties.height / 2 &&
            (ball.mesh.position.z < blockMesh.position.z)) &&
            (ball.mesh.position.x > blockMesh.position.x - blockProperties.width / 2) &&
            (ball.mesh.position.x < blockMesh.position.x + blockProperties.width / 2) &&
            (ball.velocity.z > 0.0)) 
        {
            ball.velocity.z *= -1.0;
            callback();
            return true;
        }

        if ((ball.mesh.position.z - ball.radius < blockMesh.position.z + blockProperties.height / 2 &&
            (ball.mesh.position.z > blockMesh.position.z)) &&
            (ball.mesh.position.x > blockMesh.position.x - blockProperties.width / 2) &&
            (ball.mesh.position.x < blockMesh.position.x + blockProperties.width / 2) &&
            (ball.velocity.z < 0.0)) 
        {
            ball.velocity.z *= -1.0;
            callback();
            return true;
        }

        if ((ball.mesh.position.x + ball.radius > blockMesh.position.x - blockProperties.width / 2 &&
            (ball.mesh.position.x < blockMesh.position.x)) &&
            (ball.mesh.position.z > blockMesh.position.z - blockProperties.height / 2) &&
            (ball.mesh.position.z < blockMesh.position.z + blockProperties.height / 2) &&
            (ball.velocity.x > 0.0)) 
        {
            ball.velocity.x *= -1.0;
            callback();
            return true;
        }

        if ((ball.mesh.position.x - ball.radius < blockMesh.position.x + blockProperties.width / 2 &&
            (ball.mesh.position.x > blockMesh.position.x)) &&
            (ball.mesh.position.z > blockMesh.position.z - blockProperties.height / 2) &&
            (ball.mesh.position.z < blockMesh.position.z + blockProperties.height / 2) &&
            (ball.velocity.x < 0.0)) 
        {
            ball.velocity.x *= -1.0;
            callback();
            return true;
        }

        return false;
    }

    function main() {
        // Hard-coded "settings"
        let settings = {
            backgroundColor : 0x008888,
            paddleSpeed : 0.3,
            ballSpeed: 0.2
        };

        let paddle = {
            width : 4,
            height : 1,
            depth : 1,
            color : 0xffffff,
            velocity : new THREE.Vector3(0.0, 0.0, 0.0),
            state : paddleStates.STATIONARY,
            startPosition : new THREE.Vector3(0.0, 0.0, -4.0)
        };

        let ball = {
            radius : 0.5,
            color : 0xffff00,
            velocity : new THREE.Vector3(settings.ballSpeed, 0.0, -settings.ballSpeed),
            startPosition : new THREE.Vector3(0.0, 0.0, -9.0),
            segments : {
                width : 16,
                height : 16
            }
        };

        const levelBounds = {
            top : -35.0,
            right : 17.0,
            left : -17.0,
            bottom : 0.0
        };

        const bricks = {
            rows : 11,
            columns : 11,
            distanceFromEdges : 1.0,
            distanceFromTop : 13.0,
            spacing : 0.2,
            color : 0xff00ff,
            depth : 1.0
        };

        const lights = [
            new THREE.AmbientLight(0xffffff, 0.5), 
            new THREE.PointLight(0xffffff, 0.5)
        ];

        // Game
        let renderer = createFullScreenRenderer("game-window", settings);

        let scene = new THREE.Scene();
        let camera = createCamera();
        scene.add(camera);

        paddle.mesh = createMeshAtPosition({
            geometry : new THREE.BoxGeometry(paddle.width, paddle.depth, paddle.height),
            material : new THREE.MeshLambertMaterial({ color : paddle.color })
        }, paddle.startPosition);
        scene.add(paddle.mesh);

        ball.mesh = createMeshAtPosition({
            geometry : new THREE.SphereGeometry(ball.radius, ball.segments.width, ball.segments.height),
            material : new THREE.MeshLambertMaterial({ color: ball.color })
        }, ball.startPosition);
        scene.add(ball.mesh);

        lights.forEach(light => scene.add(light));

        const levelWidth = levelBounds.right - levelBounds.left;
        const brick = {
            width : (levelWidth - 2 * bricks.distanceFromEdges + bricks.spacing * (1 - bricks.columns)) / bricks.columns,
            height : (bricks.distanceFromTop - bricks.distanceFromEdges) / bricks.rows,
            depth : bricks.depth
        };

        let visibleBricks = [];
        for (let row = 0; row < bricks.rows; row += 1) {
            for (let column = 0; column < bricks.columns; column += 1) {
                let position = new THREE.Vector3(
                    levelBounds.left + bricks.distanceFromEdges + column * (brick.width + bricks.spacing) + 0.5 * brick.width,
                    0.0,
                    levelBounds.top + bricks.distanceFromEdges + row * (brick.height + bricks.spacing) + 0.5 * brick.height);
                let mesh = createMeshAtPosition({
                    geometry : new THREE.BoxGeometry(brick.width, brick.depth, brick.height),
                    material : new THREE.MeshLambertMaterial({ color : bricks.color })
                }, position);
                let name = `${row},${column}`;
                mesh.name = name;
                scene.add(mesh);
                visibleBricks.push({
                    position : position,
                    name : name
                });
            }
        }  

        requestAnimationFrame(render);
        function render() {
            // update paddle position
            // ball-level collision
            if ((ball.mesh.position.z - ball.radius < levelBounds.top && ball.velocity.z < 0.0) ||
                (ball.mesh.position.z + ball.radius > levelBounds.bottom && ball.velocity.z > 0.0)) 
            {
                ball.velocity.z *= -1.0;
            }

            if ((ball.mesh.position.x + ball.radius > levelBounds.right && ball.velocity.x > 0.0) ||
                (ball.mesh.position.x - ball.radius < levelBounds.left && ball.velocity.x < 0.0))
            {
                ball.velocity.x *= -1.0;
            }

            resolveBallBlockCollision(ball, paddle.mesh, paddle, function() {});

            // ball-brick collision
            for (let i = 0; i < visibleBricks.length; i += 1) {
                let visibleBrick = visibleBricks[i];
                let isCollided = resolveBallBlockCollision(ball, visibleBrick, brick, function() {
                    let selectedObject = scene.getObjectByName(visibleBrick.name);
                    scene.remove(selectedObject);
                    visibleBricks.splice(i, 1);
                });
                if (isCollided) {
                    break;
                }
            }

            updatePosition(paddle);
            updatePosition(ball);
            renderer.render(scene, camera);
            requestAnimationFrame(render);
        }

        window.addEventListener("resize", makeResizeCallback(camera, renderer), false);
        window.addEventListener("keydown", makeKeyDownCallback(paddle, settings.paddleSpeed), false);
        window.addEventListener("keyup", makeKeyUpCallback(paddle), false);
    }

    window.addEventListener("load", main, false);
})();
<!DOCTYPE html>
<html>
    <head>
        <title>Arkanoid</title>
        <style>
            body {
                padding: 0px;
                margin: 0px;
                overflow: hidden;
            }
        </style>
    </head>
    <body>
        <canvas id="game-window"></canvas>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/95/three.js"></script>
    </body>
</html>

\$\endgroup\$
  • \$\begingroup\$ Code snippet fails to run for me: "SecurityError: The operation is insecure", line 44. \$\endgroup\$ – einpoklum - reinstate Monica Aug 25 '18 at 17:40
  • \$\begingroup\$ @einpoklum Make sure that your browser supports WebGL. Try Chrome or a more recent version of Firefox perhaps. \$\endgroup\$ – nasser-sh Aug 25 '18 at 20:56
  • \$\begingroup\$ I'm using FF 52.9.0 ESR, that should be plenty recent IMHO. \$\endgroup\$ – einpoklum - reinstate Monica Aug 25 '18 at 20:58
  • \$\begingroup\$ @einpoklum I'm not doubting you my friend, just a suggestion. I just tried to run it on FF 61.0.1, and I also get a warning, albeit different than yours, but the game still runs. Mine has to do with some GL shader extension not being supported. I can't think of another reason why it wouldn't work other than the browser issue :( Let me know if you try with a different one and you get it to work! \$\endgroup\$ – nasser-sh Aug 25 '18 at 21:12
2
+100
\$\begingroup\$

Feedback

Overall it looks like a nice start. The UI looks nice and the game functions just like other paddle games I have played before.

For the most part the code is well-organized. It is nice that everything is contained in an IIFE, strict equality comparisons are used and strict mode is enabled. There are just a couple simplifications that I found (see the Suggestions section below).

Suggestions

  1. use const for block-scope variables not re-assigned - this includes arrays where elements are merely pushed and popped
  2. Move settings and perhaps other objects outside main()
  3. updatePosition (and some others) could be an arrow function, just like the one used in render() to add each element in lights to the scene.
  4. The for loop in render that iterates over the visible bricks can be simplified using Array.prototype.some() and not storing the object from selectedObject in a variable (since it is only used once for the following line).

    visibleBricks.some(function(visibleBrick, i) {
      return resolveBallBlockCollision(ball, visibleBrick, brick, function() {        
        scene.remove(scene.getObjectByName(visibleBrick.name));
        visibleBricks.splice(i, 1);
      });
    });
    

    Bearing in mind that each iteration would require an extra function call (as is the case with functional programming approaches), this approach would be slower so one must decide if code length/clarity or optimization is a priority.

  5. The conditionals in function returned by makeKeyUpCallback() can be combined into single if statement with OR (i.e. ||), since the two expression blocks are identical

  6. The 1st and 2nd, as well as the 3rd and 4th conditional blocks in resolveBallBlockCollision() could possibly be abstracted to a separate function, or else just combined to avoid duplicated code in the expression block.
  7. Some function parameters are excessive - for example: resolveBallBlockCollision() seems to accept 4 parameters but the 1st is always ball, so I would declare that at the start of the code with let, assign it in main and then have that function reference it from the variable outside.
  8. For future code changes: will createFullScreenRenderer() (or main()) be called multiple times? For example, once all bricks are removed, will the game reset? If so, I would cache the DOM lookup by id for the canvas element (as well as any other elements) in a (const) variable.

Updated code

(function() {
  "use strict";

  const paddleStates = {
    MOVING_LEFT: 0,
    MOVING_RIGHT: 1,
    STATIONARY: 2
  };
  // Hard-coded "settings"
  const settings = {
    backgroundColor: 0x008888,
    paddleSpeed: 0.3,
    ballSpeed: 0.2
  };

  const levelBounds = {
    top: -35.0,
    right: 17.0,
    left: -17.0,
    bottom: 0.0
  };

  const bricks = {
    rows: 11,
    columns: 11,
    distanceFromEdges: 1.0,
    distanceFromTop: 13.0,
    spacing: 0.2,
    color: 0xff00ff,
    depth: 1.0
  };
  let paddle, ball;

  function createMeshAtPosition(meshProperties, position) {
    const mesh = new THREE.Mesh(meshProperties.geometry, meshProperties.material);
    mesh.position.copy(position);
    return mesh;
  }

  function createFullScreenRenderer(elementId, settings) {
    const renderer = new THREE.WebGLRenderer({
      canvas: document.getElementById(elementId)
    });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setClearColor(settings.backgroundColor);
    return renderer;
  }

  function createCamera() {
    const camera = new THREE.PerspectiveCamera(
      90,
      window.innerWidth / window.innerHeight,
      0.1,
      3000);
    camera.position.set(0.0, 10.0, 0.0);
    camera.lookAt(0.0, 0.0, -10.0);
    return camera;
  }

  function makeResizeCallback(camera, renderer) {
    return function() {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    };
  }

  function makeKeyDownCallback(paddle, speed) {
    return function(event) {
      if (paddle.state === paddleStates.STATIONARY) {
        if (event.key === "ArrowLeft") {
          paddle.velocity.x = -speed;
          paddle.state = paddleStates.MOVING_LEFT;
        } else if (event.key === "ArrowRight") {
          paddle.velocity.x = speed;
          paddle.state = paddleStates.MOVING_RIGHT;
        }
      }
    };
  }

  function makeKeyUpCallback(paddle) {
    return function(event) {
      if (paddle.state === paddleStates.MOVING_LEFT && event.key === "ArrowLeft" ||
        paddle.state === paddleStates.MOVING_RIGHT && event.key === "ArrowRight") {
        paddle.velocity.x = 0.0;
        paddle.state = paddleStates.STATIONARY;
      }
    };
  }
  const updatePosition = gameObject => gameObject.mesh.position.add(gameObject.velocity);

  function resolveBallBlockCollision(blockMesh, blockProperties, callback) {
    if (ShouldFlipBallZDirection(blockMesh, blockProperties)) {
      ball.velocity.z *= -1.0;
      callback();
      return true;
    }

    if (ShouldFlipBallXDirection(blockMesh, blockProperties)) {
      ball.velocity.x *= -1.0;
      callback();
      return true;
    }

    return false;
  }
  //better name likely exists for this - ballAtTopOrBottom ?
  function ShouldFlipBallZDirection(blockMesh, blockProperties) {
    return ((ball.mesh.position.z + ball.radius > blockMesh.position.z - blockProperties.height / 2 &&
          (ball.mesh.position.z < blockMesh.position.z)) &&
        (ball.mesh.position.x > blockMesh.position.x - blockProperties.width / 2) &&
        (ball.mesh.position.x < blockMesh.position.x + blockProperties.width / 2) &&
        (ball.velocity.z > 0.0)) ||
      ((ball.mesh.position.z - ball.radius < blockMesh.position.z + blockProperties.height / 2 &&
          (ball.mesh.position.z > blockMesh.position.z)) &&
        (ball.mesh.position.x > blockMesh.position.x - blockProperties.width / 2) &&
        (ball.mesh.position.x < blockMesh.position.x + blockProperties.width / 2) &&
        (ball.velocity.z < 0.0));
  }
  //better name likely exists for this - ballAtSide ?
  function ShouldFlipBallXDirection(blockMesh, blockProperties) {
    return ((ball.mesh.position.x + ball.radius > blockMesh.position.x - blockProperties.width / 2 &&
          (ball.mesh.position.x < blockMesh.position.x)) &&
        (ball.mesh.position.z > blockMesh.position.z - blockProperties.height / 2) &&
        (ball.mesh.position.z < blockMesh.position.z + blockProperties.height / 2) &&
        (ball.velocity.x > 0.0)) ||
      ((ball.mesh.position.x - ball.radius < blockMesh.position.x + blockProperties.width / 2 &&
          (ball.mesh.position.x > blockMesh.position.x)) &&
        (ball.mesh.position.z > blockMesh.position.z - blockProperties.height / 2) &&
        (ball.mesh.position.z < blockMesh.position.z + blockProperties.height / 2) &&
        (ball.velocity.x < 0.0));
  }

  function main() {
    paddle = {
      width: 4,
      height: 1,
      depth: 1,
      color: 0xffffff,
      velocity: new THREE.Vector3(0.0, 0.0, 0.0),
      state: paddleStates.STATIONARY,
      startPosition: new THREE.Vector3(0.0, 0.0, -4.0)
    };

    ball = {
      radius: 0.5,
      color: 0xffff00,
      velocity: new THREE.Vector3(settings.ballSpeed, 0.0, -settings.ballSpeed),
      startPosition: new THREE.Vector3(0.0, 0.0, -9.0),
      segments: {
        width: 16,
        height: 16
      }
    };

    const lights = [
      new THREE.AmbientLight(0xffffff, 0.5),
      new THREE.PointLight(0xffffff, 0.5)
    ];

    // Game
    const renderer = createFullScreenRenderer("game-window", settings);

    const scene = new THREE.Scene();
    const camera = createCamera();
    scene.add(camera);

    paddle.mesh = createMeshAtPosition({
      geometry: new THREE.BoxGeometry(paddle.width, paddle.depth, paddle.height),
      material: new THREE.MeshLambertMaterial({
        color: paddle.color
      })
    }, paddle.startPosition);
    scene.add(paddle.mesh);

    ball.mesh = createMeshAtPosition({
      geometry: new THREE.SphereGeometry(ball.radius, ball.segments.width, ball.segments.height),
      material: new THREE.MeshLambertMaterial({
        color: ball.color
      })
    }, ball.startPosition);
    scene.add(ball.mesh);

    lights.forEach(light => scene.add(light));

    const levelWidth = levelBounds.right - levelBounds.left;
    const brick = {
      width: (levelWidth - 2 * bricks.distanceFromEdges + bricks.spacing * (1 - bricks.columns)) / bricks.columns,
      height: (bricks.distanceFromTop - bricks.distanceFromEdges) / bricks.rows,
      depth: bricks.depth
    };

    const visibleBricks = [];
    for (let row = 0; row < bricks.rows; row += 1) {
      for (let column = 0; column < bricks.columns; column += 1) {
        let position = new THREE.Vector3(
          levelBounds.left + bricks.distanceFromEdges + column * (brick.width + bricks.spacing) + 0.5 * brick.width,
          0.0,
          levelBounds.top + bricks.distanceFromEdges + row * (brick.height + bricks.spacing) + 0.5 * brick.height);
        const mesh = createMeshAtPosition({
          geometry: new THREE.BoxGeometry(brick.width, brick.depth, brick.height),
          material: new THREE.MeshLambertMaterial({
            color: bricks.color
          })
        }, position);
        const name = `${row},${column}`;
        mesh.name = name;
        scene.add(mesh);
        visibleBricks.push({
          position: position,
          name: name
        });
      }
    }

    requestAnimationFrame(render);

    function render() {
      // update paddle position
      // ball-level collision
      if ((ball.mesh.position.z - ball.radius < levelBounds.top && ball.velocity.z < 0.0) ||
        (ball.mesh.position.z + ball.radius > levelBounds.bottom && ball.velocity.z > 0.0)) {
        ball.velocity.z *= -1.0;
      }

      if ((ball.mesh.position.x + ball.radius > levelBounds.right && ball.velocity.x > 0.0) ||
        (ball.mesh.position.x - ball.radius < levelBounds.left && ball.velocity.x < 0.0)) {
        ball.velocity.x *= -1.0;
      }

      resolveBallBlockCollision(paddle.mesh, paddle, function() {});

      // ball-brick collision
      visibleBricks.some(function(visibleBrick, i) {
        return resolveBallBlockCollision(visibleBrick, brick, function() {
          scene.remove(scene.getObjectByName(visibleBrick.name));
          visibleBricks.splice(i, 1);
        });
      });

      updatePosition(paddle);
      updatePosition(ball);
      renderer.render(scene, camera);
      requestAnimationFrame(render);
    }

    window.addEventListener("resize", makeResizeCallback(camera, renderer), false);
    window.addEventListener("keydown", makeKeyDownCallback(paddle, settings.paddleSpeed), false);
    window.addEventListener("keyup", makeKeyUpCallback(paddle), false);
  }

  window.addEventListener("load", main, false);
})();
body {
  padding: 0px;
  margin: 0px;
  overflow: hidden;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/95/three.js"></script>
<canvas id="game-window"></canvas>

\$\endgroup\$
  • \$\begingroup\$ Thanks very much for your feedback! That's an interesting take: I normally only like to use closures when I need to, which is why, for example, I pass ball as an argument to resolveBallBlockCollision rather than what you describes in #7, or keep the settings objects inside main. paddleStates was an exception because I'm using it as a "type". Is my rationale uncommon in JS? What do you think are the disadvantages? \$\endgroup\$ – nasser-sh Aug 25 '18 at 21:46
4
\$\begingroup\$

I agree with the other answer; I like your attempt so far. The code works and it's quite readable - but that's only because its a small project. I wanted to point out a major flaw in your approach so far:

Your render() function includes game logic such as detecting collisions. There are many reasons why this is bad idea, but the most succinct way of explaining it is that it violates the Single Responsibility Principle quite severely. All game engines separate their rendering and update logic. The easiest way to correct this is to move all non-rendering logic into a new update() function, which runs just before the render() function in a new mainLoop() function.

// Create a new function to be called every frame
requestAnimationFrame(mainLoop);

function mainLoop() {
   // move game logic into here
   update();

   // leave remaining rendering code here
   render();
}

You can refactor further than this (quite significantly so), but this a good first initial refactoring, which will make your code easier to maintain. I would Google "fix your timestep" and look into articles relating to timing and how your game behaves when framerates drop, and how it behaves on devices with lower processing power, such as mobile.

And as an aside, you should seriously consider converting this to TypeScript if you plan for the game to grow much beyond what it is now, in fact I would argue it's a must for any project larger than a simple forms app, but that's just an opinion.

\$\endgroup\$
  • \$\begingroup\$ Thanks for your feedback! How differently would you design your objects to make it easier to achieve this separation of concerns? For example, it was easy for me to mix collision code with the graphics code because the position of the game object was stored in ball.mesh.position, and ball.mesh is a graphics object. How would you do it differently? \$\endgroup\$ – nasser-sh Aug 25 '18 at 21:17
  • \$\begingroup\$ A common approach is to store all objects' positions in addition to the mesh positions, because that is a rendering concern. Each game object could be stored as an "entity class", with {x, y} positions, which are then copied to the mesh during the rendering step. This may seem like overkill to begin with but it will begin to make sense when you are running updates at different intervals to rendering. You'll soon want to split every single object into a different file. You'll want to begin thinking about positions as Vector objects, and investigate Entity Component Systems. \$\endgroup\$ – Lunster Aug 26 '18 at 8:22
  • \$\begingroup\$ I would also recommend reading Game Programming Patterns by Robert Nystrom, the examples are in C++ but they're very brief. gameprogrammingpatterns.com \$\endgroup\$ – Lunster Aug 26 '18 at 8:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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