4
\$\begingroup\$

I have written a game in JS and html5, using the <canvas> element. It's an endless scroller where the player drives their car and tries to avoid crashing into enemy cars. The further they get without crashing, the higher their score. Here's the raw source code, and some commentary and explanations are given below.

src/game.js:

'use strict';

require(['src/graphics', 'src/logic', 'src/utils'], 
        (graphics, logic, utils) => {
    const main = () => {
        const canvas = new graphics.Canvas(document);

        canvas.width = logic.constants.mapWidth;
        canvas.height = logic.constants.mapHeight;

        const drawCar = (car) => {
            for (const part of car.physicalParts())
                canvas.drawRect(part.rect, part.color);
        };

        const playerCar = logic.PlayerCar.atDefaultPosition();
        let enemyCars = [];

        const drawPlayerCar = () => {
            drawCar(playerCar);
        };

        const drawEnemyCars = () => {
            for (const enemyCar of enemyCars)
                drawCar(enemyCar);
        };

        const crashHasHappened = () => {
            for (const enemyCar of enemyCars)
                if (logic.carsCrashed(enemyCar, playerCar))
                    return true;
            return false;
        };

        let decorations = [];

        const drawDecorations = () => {
            for (const decoration of decorations)
                canvas.drawRect(decoration.rect, decoration.color);
        };

        const showScore = () => {
            alert(`Final score: ${Math.floor(distanceTraveled/1000)}`);
        };

        const clearCanvas = () => {
            const backgroundColor = '#E6E6F5';
            canvas.clear(backgroundColor);
        }

        let distanceTraveled = 0;

        const moveAllObjects = () => {
            playerCar.updateSpeedBasedOnDistanceTraveled(distanceTraveled);

            const relativitySystem = logic.VerticalRelativitySystem.relativeTo(playerCar);

            relativitySystem.addElements(enemyCars);
            relativitySystem.addElements(decorations);

            // This could be filtered out into a separate function
            if (keyHandler.keyIsDown('ArrowLeft'))
                playerCar.moveLeft(0);
            if (keyHandler.keyIsDown('ArrowRight'))
                playerCar.moveRight(logic.constants.mapWidth-logic.carParts.constants.carWidth);

            relativitySystem.moveElements();

            distanceTraveled += playerCar.verticalSpeed;
        };

        const destroyOffscreenObjects = () => {
            enemyCars = logic.activeObjects(enemyCars);
            decorations = logic.activeObjects(decorations);
        };

        const drawEverything = () => {
            drawDecorations();
            drawPlayerCar();
            drawEnemyCars();
        };

        const keyHandler = new logic.KeyHandler(['ArrowLeft', 'ArrowRight'], document);

        const game = new logic.Game({
            gameOverChecker: crashHasHappened,
            onGameOver: showScore
        });

        game.addEachFrameCallback(clearCanvas)
            .addEachFrameCallback(moveAllObjects)
            .addEachFrameCallback(destroyOffscreenObjects)
            .addEachFrameCallback(drawEverything);

        game.runEveryCalculated(
            () => enemyCars.push(logic.EnemyCar.atRandomPosition()), 
            () => 2800/playerCar.verticalSpeed);

        game.runEveryCalculated(
            () => decorations.push(logic.decorations.RoadDrawing.atDefaultPosition()),
            () => 2000/playerCar.verticalSpeed);

        game.run();
    };

    main();
});

src/logic.js:

'use strict';

define(['src/utils'], (utils) => {
    const logic = {
        constants: {},
        decorations: {},
        internal: {}
    };

    logic.pointsAdded = (p1, p2) => {
        return {
            x: p1.x+p2.x,
            y: p1.y+p2.y
        };
    };

    logic.pointsSubtracted = (p1, p2) => {
        return {
            x: p1.x-p2.x,
            y: p1.y-p2.y
        };
    };

    logic.pointsAreEqual = (p1, p2) => p1.x === p2.x && p1.y === p2.y;

    logic.internal.KeyTracker = class {
        constructor() {
            this.isDownMap = new Map();
        }

        setKeyDown(key) {
            this.isDownMap.set(key, true);
        }

        setKeyUp(key) {
            this.isDownMap.set(key, false);
        }

        keyIsDown(key) {
            return this.isDownMap.has(key) ? this.isDownMap.get(key) : false;
        }
    };

    logic.KeyHandler = class {
        constructor(keysArray, document) {
            this.keyTracker = new logic.internal.KeyTracker();

            document.addEventListener('keydown', logic.KeyHandler.eventCallbackForKeyInKeys(keysArray, (key) => {
                this.keyTracker.setKeyDown(key);
            }));

            document.addEventListener('keyup', logic.KeyHandler.eventCallbackForKeyInKeys(keysArray, (key) => {
                this.keyTracker.setKeyUp(key);
            }));
        }

        static eventCallbackForKeyInKeys(keysArray, callback) {
            return (event) => {
                if (utils.contains(keysArray, event.key))
                    callback(event.key);
            };
        }

        keyIsDown(key) {
            return this.keyTracker.keyIsDown(key);
        }
    };

    logic.carParts = {
        constants: {}
    };

    logic.carParts.facingUp = (position, color) => {
        const tire = ({x, y}) => {
            return {
                rect: {
                    x, y, 
                    width: logic.carParts.constants.tireWidth,
                    height: logic.carParts.constants.tireHeight
                },
                color: 'black'
            };
        };

        const bonnet = {
            rect: {
                x: position.x+logic.carParts.constants.tireWidth*2, 
                y: position.y, 
                width: logic.carParts.constants.bonnetWidth, 
                height: logic.carParts.constants.bonnetHeight,
            },
            color
        };
        const roof = {
            rect: {
                x: position.x+logic.carParts.constants.tireWidth, 
                y: bonnet.rect.y+logic.carParts.constants.bonnetHeight, 
                width: logic.carParts.constants.roofWidth,
                height: logic.carParts.constants.roofHeight
            },
            color
        };
        const frontLeftTire = tire({
            x: position.x,
            y: roof.rect.y+5
        });
        const bottomLeftTire = tire({
            x: position.x, 
            y: roof.rect.y+logic.carParts.constants.roofHeight-20
        });
        const frontRightTire = tire({
            x: roof.rect.x+logic.carParts.constants.roofWidth, 
            y: roof.rect.y+5
        });
        const bottomRightTire = tire({
            x: roof.rect.x+logic.carParts.constants.roofWidth, 
            y: roof.rect.y+logic.carParts.constants.roofHeight-20
        });

        return [
            bonnet, roof, frontLeftTire, bottomLeftTire, frontRightTire, bottomRightTire
        ];
    };

    logic.carParts.constants.tireWidth = 7;
    logic.carParts.constants.tireHeight = 15;
    logic.carParts.constants.bonnetWidth = 30;    
    logic.carParts.constants.bonnetHeight = 20;    
    logic.carParts.constants.roofWidth = logic.carParts.constants.bonnetWidth+logic.carParts.constants.tireWidth*2;
    logic.carParts.constants.roofHeight = logic.carParts.constants.bonnetHeight+50;
    logic.carParts.constants.carWidth = logic.carParts.constants.roofWidth+logic.carParts.constants.tireWidth*2
    logic.carParts.constants.carHeight = logic.carParts.constants.roofHeight+logic.carParts.constants.bonnetHeight;

    logic.constants.laneCount = 6;
    logic.constants.mapWidth = logic.constants.laneCount*logic.carParts.constants.carWidth;
    logic.constants.mapHeight = logic.carParts.constants.carHeight*6;

    logic.RandomlyColored = class {
        constructor() {
            const randomColor = () => utils.randomElement(
                ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'magenta', 'aqua', 'maroon']);

            this.color = randomColor();
        }
    };

    logic.PlayerCar = class extends logic.RandomlyColored {
        static atDefaultPosition(){
            return new logic.PlayerCar({
                x: logic.constants.mapWidth/2-logic.carParts.constants.carWidth/2,
                y: logic.constants.mapHeight-logic.carParts.constants.carHeight-5
            });
        }

        static baseSpeed() {
            return 5;
        }

        static maxSpeed() {
            return 7;
        }

        constructor(position) {
            super();
            this.position = position;
            this.horizontalSpeed = this.verticalSpeed = 0;
            this.updateSpeedBasedOnDistanceTraveled(0);
        }

        updateSpeedBasedOnDistanceTraveled(distanceTraveled) {
            const newVerticalSpeed = Math.abs(distanceTraveled/5000)+logic.PlayerCar.baseSpeed();
            this.horizontalSpeed = this.verticalSpeed = 
                (newVerticalSpeed > logic.PlayerCar.maxSpeed()) ? logic.PlayerCar.maxSpeed() : newVerticalSpeed;
        }

        physicalParts() {
            return logic.carParts.facingUp(this.position, this.color);
        }

        moveLeft(leftBoundary) {
            this.position.x = logic.xFilteredThroughLeftBoundary(
                this.position.x-this.horizontalSpeed, leftBoundary
            );
        }

        moveRight(rightBoundary) {
            this.position.x = logic.xFilteredThroughRightBoundary(
                this.position.x+this.horizontalSpeed, rightBoundary
            );
        }
    };

    logic.xFilteredThroughRightBoundary = (x, rightBoundary) => (x > rightBoundary) ? rightBoundary : x;

    logic.xFilteredThroughLeftBoundary = (x, leftBoundary) => (x < leftBoundary) ? leftBoundary : x;

    logic.EnemyCar = class extends logic.RandomlyColored {
        static atRandomPosition() {
            return new logic.EnemyCar({
                x: canvas.width/logic.constants.laneCount*utils.randomRange(0, logic.constants.laneCount), 
                y: -logic.carParts.constants.carHeight
            });
        }

        constructor(position) {
            super();
            this.position = position;
            this.verticalSpeed = 2;            
        }

        physicalParts() {
            return logic.carParts.facingUp(this.position, this.color);
        }
    };

    logic.onScreenCars = (cars) => {
        const carIsOnScreen = (car) => car.position.y < -logic.constants.mapHeight;
        return cars.filter(carIsOnScreen);
    };

    logic.rectanglesAreOverlapped = (rect1, rect2) => {
        return logic.internal.rectanglePoints(rect1).some(point => logic.internal.pointIsInsideRectangle(point, rect2)) ||
            logic.internal.rectanglePoints(rect2).some(point => logic.internal.pointIsInsideRectangle(point, rect1));
    };

    logic.internal.pointIsInsideRectangle = (point, rect) => {
        const upperLeftRectPoint = logic.internal.upperLeftPoint(rect);
        const lowerRightRectPoint = logic.internal.lowerRightPoint(rect);
        return (point.x > upperLeftRectPoint.x && point.x < lowerRightRectPoint.x) &&
            (point.y > upperLeftRectPoint.y && point.y < lowerRightRectPoint.y);
    };

    logic.internal.upperLeftPoint = (rect) => {
        return {x: rect.x, y: rect.y};
    };

    logic.internal.upperRightPoint = (rect) => {
        return {x: rect.x+rect.width, y: rect.y};
    };

    logic.internal.lowerLeftPoint = (rect) => {
        return {x: rect.x, y: rect.y+rect.height};
    };

    logic.internal.lowerRightPoint = (rect) => {
        return {x: rect.x+rect.width, y: rect.y+rect.height};
    };

    logic.internal.rectanglePoints = (rectangle) => [
        logic.internal.upperLeftPoint(rectangle),
        logic.internal.upperRightPoint(rectangle),
        logic.internal.lowerLeftPoint(rectangle),
        logic.internal.lowerRightPoint(rectangle)
    ];

    logic.carsCrashed = (c1, c2) => {
        for (const part1 of c1.physicalParts())
            for (const part2 of c2.physicalParts())
                if (logic.rectanglesAreOverlapped(part1.rect, part2.rect))
                    return true;
        return false;
    };

    logic.activeObjects = (objects) => {
        const isActive = (object) => object.position.y < logic.constants.mapHeight;
        return objects.filter(isActive);
    };

    logic.VerticalRelativitySystem = class {
        static relativeTo({object, verticalSpeed}) {
            return new logic.VerticalRelativitySystem({object, verticalSpeed});
        }

        constructor(centerObject) {
            this.centerObject = centerObject;
            this.elements = [];
        }

        addElements(elementArray) {
            this.elements.push(...elementArray);
        }

        moveElements() {
            for (const element of this.elements)
                element.position.y += this.centerObject.verticalSpeed-element.verticalSpeed;
        }
    };

    logic.Game = class {
        constructor({gameOverChecker, onGameOver}) {
            this.eachFrameCallbacks = [];
            this.gameOverChecker = gameOverChecker;
            this.onGameOver = onGameOver;
            this.gameOver = false;
        }

        run() {
            utils.runInBackground(() => {
                if (!this.gameOver) {
                    this.callEachFrameCallbacks();
                    this.checkGameOver();
                }
            });
        }

        checkGameOver() {
            if (this.gameOverChecker()) {
                this.gameOver = true;
                this.onGameOver();
            }
        }

        callEachFrameCallbacks() {
            for (const callback of this.eachFrameCallbacks)
                callback();
        }

        addEachFrameCallback(callback) {
            this.eachFrameCallbacks.push(callback);
            return this;
        }

        runEveryCalculated(task, milliseconds) {
            utils.runEveryCalculated(() => {
                if (!this.gameOver)
                    task();
            }, milliseconds)
        }
    };

    logic.decorations.constants = {};

    logic.decorations.constants.roadDrawingWidth = 30;
    logic.decorations.constants.roadDrawingHeight = 120;

    logic.decorations.RoadDrawing = class {
        static atDefaultPosition() {
            return new logic.decorations.RoadDrawing({
                x: logic.constants.mapWidth/2-logic.decorations.constants.roadDrawingWidth/2,
                y: -logic.decorations.constants.roadDrawingHeight
            });
        }

        constructor(position) {
            this.position = position;
            this.color = 'grey';
            this.verticalSpeed = 0;
        }

        get rect() {
            return {
                x: this.position.x,
                y: this.position.y,
                width: logic.decorations.constants.roadDrawingWidth,
                height: logic.decorations.constants.roadDrawingHeight
            };
        }
    };

    return logic;
});

src/graphics.js:

'use strict';

define(['src/utils'], (utils) => {
    const graphics = {
        constants: {},
        internal: {}
    };

    graphics.Canvas = class {
        constructor(document) {
            this.canvas = document.getElementById('canvas');
            this.context = this.canvas.getContext('2d');
        }

        drawRect({x, y, width, height}, color) {
            this.context.fillStyle = color;
            this.context.fillRect(x, y, width, height);
        }

        clear(backgroundColor) {
            this.drawRect({
                x: 0, 
                y: 0, 
                width: this.width, 
                height: this.height
            }, backgroundColor);
        }

        get width() {
            return this.canvas.width;
        }

        get height() {
            return this.canvas.height;
        }

        set width(value) {
            this.canvas.width = value;
        }

        set height(value) {
            this.canvas.height = value;
        }
    };

    graphics.ImageNotCached = class extends Error {};

    graphics.ImageCache = class {
        constructor(...fileNames) {
            this.images = new Map();
            this.loadedCount = 0;
        }

        loadFiles(fileName, fileNames) {
            this.images[fileName] = graphics.internal.imageWithSrc(fileName);
            this.images[fileName].addEventListener('load', () => {
                ++this.loadedCount;
            });
        }

        whenLoaded(callback) {
            utils.runInBackground(() => {
                if (this.images.size === this.loadedCount)
                    callback();
            });
        }

        image(fileName) {
            return this.images[fileName];
        }
    };

    graphics.internal.imageWithSrc = (src) => {
        const result = new Image();
        result.src = src;
        return result;
    };

    return graphics;
});

src/utils.js:

'use strict';

define(() => {
    const utils = {};

    utils.contains = (array, element) => array.indexOf(element) !== -1;

    utils.runEveryCalculated = (task, millisecondsGetter) => {
        setTimeout(() => {
            utils.runEveryCalculated(task, millisecondsGetter);
            task();
        }, millisecondsGetter());
    }

    utils.runEvery = (task, milliseconds) => utils.runEveryCalculated(task, () => milliseconds);

    utils.runInBackground = (task) => utils.runEvery(task, 0);

    utils.randomElement = (array) => array[utils.randomRange(0, array.length)];

    utils.randomRange = (from, to) => from + Math.floor(Math.random() * (to-from));

    return utils;
});

As for the testing, I've written an extremely small module to use for running the tests. I did this because I was unable to find a JS library for testing non-NodeJS applications. If one such library does exist, please bring it to my attention as part of an actual code review.

src/unitTesting.js:

'use strict';

define(() => {
    const unitTesting = {};

    unitTesting.AssertionFailed = class extends Error {};

    unitTesting.assertTrue = (e) => {
        if (!e)
            throw AssertionFailed;
    };

    unitTesting.assertFalse = (e) => unitTesting.assertTrue(!e);

    unitTesting.assertEqual = (e1, e2) => unitTesting.assertTrue(e1 === e2);

    unitTesting.assertThrows = (callback, Exception) => {
        try {
            callback();
        } catch (e) {
            if (!e instanceof Exception)
                throw unitTesting.AssertionFailed;
        }
    };

    unitTesting.runTestSuite = (suite) => {
        for (let testName in suite) {
            try {
                suite[testName]();
                console.log(`  Passed ${testName}`);
            } catch (e) {
                console.log(`  FAILED ${testName}`);
                if (!e instanceof unitTesting.AssertionFailed)
                    console.log(`Due to exception ${e}`);
            }
        }
    };

    unitTesting.runEachTestSuite = (suites) => {
        for (const suiteName in suites) {
            console.log(`Test suite: ${suiteName}`);
            unitTesting.runTestSuite(suites[suiteName]);
            console.log('');
        }
    }

    return unitTesting;
});

src/logicTests.js:

'use strict';

define(['tests/unitTesting.js', 'src/logic'], (unitTesting, logic) => {
    const logicTests = {};
    const unitTestingExtensions = {};

    unitTestingExtensions.assertPointsAreEqual = (p1, p2) => unitTesting.assertTrue(logic.pointsAreEqual(p1, p2));

    unitTestingExtensions.assertPointsAreNotEqual = (p1, p2) => unitTesting.assertFalse(logic.pointsAreEqual(p1, p2));

    logicTests.pointsAreEqualTest = () => {
        unitTestingExtensions.assertPointsAreEqual(
            {x: 33, y: -2}, 
            {x: 33, y: -2}
        );

        unitTestingExtensions.assertPointsAreNotEqual(
            {x: 1, y: 42},
            {x: 2, y: 42}
        );

        unitTestingExtensions.assertPointsAreNotEqual(
            {x: 1, y: 42},
            {x: 1, y: 43}
        );

        unitTestingExtensions.assertPointsAreNotEqual(
            {x: 1, y: 42},
            {x: 2, y: 43}
        );
    };

    logicTests.pointsAddedTest = () => {
        const testAdditionFor = ({point1, point2, result}) => unitTestingExtensions.assertPointsAreEqual(
            logic.pointsAdded(point1, point2), result
        );

        testAdditionFor({
            point1: {x: 1, y: 1}, 
            point2: {x: 1, y: 1},
            result: {x: 2, y: 2}
        });

        testAdditionFor({
            point1: {x: -1, y: 1}, 
            point2: {x: 1, y: 1},
            result: {x: 0, y: 2}
        });

        testAdditionFor({
            point1: {x: -1, y: -1}, 
            point2: {x: -1, y: -1},
            result: {x: -2, y: -2}
        });
    };

    logicTests.pointsSubtractedTest = () => {
        const testSubtractionFor = ({point1, point2, result}) => unitTestingExtensions.assertPointsAreEqual(
            logic.pointsSubtracted(point1, point2), result
        );

        testSubtractionFor({
            point1: {x: 1, y: 1}, 
            point2: {x: 1, y: 1},
            result: {x: 0, y: 0}
        });

        testSubtractionFor({
            point1: {x: -1, y: 1}, 
            point2: {x: 1,  y: 1},
            result: {x: -2, y: 0}
        });

        testSubtractionFor({
            point1: {x: -1, y: -1}, 
            point2: {x: -1, y: -1},
            result: {x: 0,  y: 0}
        });
    };

    logicTests.internalKeyTrackerTest = () => {
        const keyTracker = new logic.internal.KeyTracker();

        unitTesting.assertFalse(keyTracker.keyIsDown('Enter'));
        unitTesting.assertFalse(keyTracker.keyIsDown('ArrowUp'));

        keyTracker.setKeyDown('Enter');

        unitTesting.assertTrue(keyTracker.keyIsDown('Enter'));
        unitTesting.assertFalse(keyTracker.keyIsDown('ArrowUp'));

        keyTracker.setKeyUp('Enter');
        keyTracker.setKeyDown('ArrowUp');

        unitTesting.assertFalse(keyTracker.keyIsDown('Enter'));
        unitTesting.assertTrue(keyTracker.keyIsDown('ArrowUp'));

        keyTracker.setKeyDown('Enter');

        unitTesting.assertTrue(keyTracker.keyIsDown('Enter'));
        unitTesting.assertTrue(keyTracker.keyIsDown('ArrowUp'));
    };

    logicTests.rectanglePointsTest = () => {
        const rect = {
            x: 5, 
            y: 5,
            width: 10,
            height: 15
        };

        unitTestingExtensions.assertPointsAreEqual(logic.internal.upperLeftPoint(rect), {x: 5, y: 5});
        unitTestingExtensions.assertPointsAreEqual(logic.internal.upperRightPoint(rect), {x: 15, y: 5});
        unitTestingExtensions.assertPointsAreEqual(logic.internal.lowerLeftPoint(rect), {x: 5, y: 20});
        unitTestingExtensions.assertPointsAreEqual(logic.internal.lowerRightPoint(rect), {x: 15, y: 20});
    };

    logicTests.internalPointIsInsideRectangleTest = () => {
        const rect = {
            x: 5, 
            y: 5,
            width: 10,
            height: 15
        };

        const testAgainstRect = (point, result) => unitTesting.assertEqual(
            logic.internal.pointIsInsideRectangle(point, rect), result
        );

        testAgainstRect({x: 5, y: 5}, false);
        testAgainstRect({x: 4, y: 5}, false);
        testAgainstRect({x: 5, y: 4}, false);
        testAgainstRect({x: 4, y: 4}, false);
        testAgainstRect({x: 5, y: 6}, false);
        testAgainstRect({x: 6, y: 5}, false);
        testAgainstRect({x: 6, y: 6}, true);

        testAgainstRect({x: 12, y: 6}, true);
        testAgainstRect({x: 6, y: 12}, true);

        testAgainstRect({x: 15, y: 20}, false);
        testAgainstRect({x: 16, y: 20}, false);
        testAgainstRect({x: 15, y: 21}, false);
        testAgainstRect({x: 16, y: 21}, false);
        testAgainstRect({x: 14, y: 20}, false);
        testAgainstRect({x: 15, y: 19}, false);
        testAgainstRect({x: 14, y: 19}, true);
    };

    logicTests.rectanglesAreOverlappedTest = () => {
        const rect = {
            x: 5, 
            y: 5,
            width: 10,
            height: 15
        };

        const testAgainstRect = (rect2, result) => unitTesting.assertEqual(
            logic.rectanglesAreOverlapped(rect, rect2), result
        );

        const testFromUpperLeft = () => {
            testAgainstRect({
                x: 0,
                y: 0,
                width: 5,
                height: 5
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 4,
                height: 4
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 5,
                height: 4
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 4,
                height: 5
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 6,
                height: 5
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 5,
                height: 6
            }, false);

            testAgainstRect({
                x: 0,
                y: 0,
                width: 6,
                height: 6
            }, true);
        };

        const testFromUpperRight = () => {
            testAgainstRect({
                x: 15,
                y: 0,
                width: 5,
                height: 5
            }, false);

            testAgainstRect({
                x: 15,
                y: 0,
                width: 5,
                height: 4
            }, false);

            testAgainstRect({
                x: 15,
                y: 0,
                width: 5,
                height: 6
            }, false);

            testAgainstRect({
                x: 15,
                y: 0,
                width: 5,
                height: 4
            }, false);
        };

        const testFromLowerLeft = () => {
            testAgainstRect({
                x: 5,
                y: 20,
                width: 1,
                height: 1
            }, false);

            testAgainstRect({
                x: 5,
                y: 20,
                width: 2,
                height: 2
            }, false);

            testAgainstRect({
                x: 5,
                y: 19,
                width: 2,
                height: 2
            }, true);

            testAgainstRect({
                x: 4,
                y: 20,
                width: 1,
                height: 1
            }, false);

            testAgainstRect({
                x: 3,
                y: 20,
                width: 1,
                height: 1
            }, false);

            testAgainstRect({
                x: 5,
                y: 21,
                width: 3,
                height: 3
            }, false);

            testAgainstRect({
                x: 5,
                y: 19,
                width: 1,
                height: 1
            }, true);
        };

        const testFromLowerRight = () => {
            testAgainstRect({
                x: 15,
                y: 20,
                width: 1,
                height: 1
            }, false);

            testAgainstRect({
                x: 16,
                y: 20,
                width: 1,
                height: 1
            }, false);

            testAgainstRect({
                x: 5,
                y: 21,
                width: 1,
                height: 1
            }, false);
        };

        const testArbitrary = () => {
            testAgainstRect({
                x: 10,
                y: 0,
                width: 5,
                height: 5
            }, false);

            testAgainstRect({
                x: 10,
                y: 0,
                width: 4,
                height: 5
            }, false);
        };

        testFromUpperLeft();
        testFromLowerLeft();
        testFromUpperRight();
        testFromLowerRight();
        testArbitrary();
    };

    return logicTests;
});

src/utilsTests.js:

'use strict';

define(['tests/unitTesting', 'src/utils'], (unitTesting, utils) => {
    const utilsTests = {};

    utilsTests.containsTest = () => {
        unitTesting.assertTrue(utils.contains([1, 2, 3, 4], 3));
        unitTesting.assertFalse(utils.contains([1, 2, 3], 10));
    }

    return utilsTests;
});

src/allTests.js:

'use strict';

require(['tests/unitTesting', 'tests/logicTests', 'tests/utilsTests'], 
        (unitTesting, logicTests, utilsTests) => {
    const testSuites = {logicTests, utilsTests};
    unitTesting.runEachTestSuite(testSuites);
});

index.html:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf8">
        <title>Cars</title>
    </head>
    <body>
        <canvas id="canvas"></canvas>
        <script src="./lib/require.js"></script>
        <script src="./tests/allTests.js"></script>
        <script src="./src/game.js"></script>
    </body>
</html>

A bit of commentary and reasoning:

In the modules you will see a pattern like this one:

const moduleName = {
    internal: {}
};

E.g.

const graphics = {
    constants: {},
    internal: {}
};

The reason why the internals are exposed like this rather than being encapsulated by for example using closures is so that they'd be accessible for testing.

You may notice there's a bit of dead code in the graphics module, like the ImageCache class for example. This is dead code for now, but I intend on perhaps using it in future features of the game.

While playing, the player should have a feeling like his car is moving upward. In reality, the player's car is still, and all other object are moving toward the bottom of the screen. The VerticalRelativitySystem class abstracts that fact away:

(from src/logic.js:)

logic.VerticalRelativitySystem = class {
    static relativeTo({object, verticalSpeed}) {
        return new logic.VerticalRelativitySystem({object, verticalSpeed});
    }

    constructor(centerObject) {
        this.centerObject = centerObject;
        this.elements = [];
    }

    addElements(elementArray) {
        this.elements.push(...elementArray);
    }

    moveElements() {
        for (const element of this.elements)
            element.position.y += this.centerObject.verticalSpeed-element.verticalSpeed;
    }
};

Every element of the system gets moved relative to the object being followed - in our case, the followed object is the player's car:

(from src/game.js:)

const relativitySystem = logic.VerticalRelativitySystem.relativeTo(playerCar);

relativitySystem.addElements(enemyCars);
relativitySystem.addElements(decorations);

And the objects moving relative to the player's car are the enemy cars and the decorations.

As for the decorations, currently there's only "road drawings" for lack of a better word - these are supposed to be the yellow lines on this picture:

If there's a better name to use here, please point that out :-).

In the utils module, there are a few somewhat similar functions:

utils.runEveryCalculated = (task, millisecondsGetter) => {
    setTimeout(() => {
        utils.runEveryCalculated(task, millisecondsGetter);
        task();
    }, millisecondsGetter());
};    

utils.runEvery = (task, milliseconds) => utils.runEveryCalculated(task, () => milliseconds);

I have doubts about these names. The name runEvery is supposed to mean "run task every x milliseconds". The name runEveryCalculated is supposed to mean "run task every x milliseconds where x is computed using a callback".

GitHub link

Play the game

\$\endgroup\$
2
\$\begingroup\$

For a small game like this, your implementation of runEveryCalculated() is okay since you don't expect to have lag spikes. setTimeout() has no guarantee for its timing and it's behavior can vary from browser to browser. You should use requestAnimationFrame() to do this instead.

Additionally, it is a bad idea to have your physics engine tied to your FPS/tickrate and it can cause unexpected behavior with more complex games. You can get around that by calculating the delta time between each update and using that to do your physics calculations.

\$\endgroup\$
1
  • \$\begingroup\$ I'm also just going to leave this link here. It's an article I wrote about building multiplayer browser games, but part of it is relevant to you since it talks a little bit about time-based physics calculations. \$\endgroup\$
    – omgimanerd
    Jul 15 '17 at 0:28

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.