2
\$\begingroup\$

I wrote a Simon clone in JavaScript, HTML, and CSS. It's extremely simple, but turned out fairly well.

I'm looking for general feedback, as well as these things specifically:

  1. I opted to try to do any required animation manually, instead of relying on jQuery. I wanted to see what went into animations. The only animation I ended up needing was the ability to flash an element. Conceptually, it's fairly simple. Take the color of an element, and over a period of time, lighten it, then darken it back to the original color. The main algorithm was simple, but I ran into the issue where if a flash is initiated on an element currently being flashed, it will become permanently lightened (because the background color is saved when the flash is started). I ended up trying a few different ways of managing flashes, and ended up making an object that can be queue flash jobs, and execute them sequentially for the guarantee that the same element won't be flashed twice at the same time. It works, but it feels like an abuse of OOP. Is there a simpler way of achieving this?

  2. I found control flow to be difficult since the game is entirely driven entirely in response to user input. It basically became a string of callbacks, and while it isn't so complicated that I'd call it "callback hell", I imagine there's probably a better way.

  3. How easy is it to use? The only indication of what's going on is the flashes in the background. I think it's fairly easy to understand; but I wrote it.

What I don't need suggestions on:

  1. As noted above, I could have used jQuery to make my life a lot easier in a lot of places. My intent was to go for the hard way though.
  2. I realized after posting this that I modified how I track the cells, and am no longer using board, despite it being initialized in the constructor. It can be removed.

Here's a Stack Snippet (doesn't show properly on the Android app):

 var DELAY_BETWEEN_GAMES = 1000;

 var container = document.getElementById('container');
 var statusBar = document.getElementById('statusBar');

 var board = new SimonBoard(2, 'simonTable');
 var table = board.getTable();

 var indicator = table;

 var flasher = new ElementFlasher();

 container.appendChild(table);
 resizeTableToFitScreen(table);

 board.onLoseHandler = function() {
   flasher.flashElementThen(indicator, DELAY_BETWEEN_GAMES, function() {
     setTimeout(function() {
       board.reset();
       board.start();
     }, DELAY_BETWEEN_GAMES)
   });

 };

 board.onTurnEndHandler = function(correct) {
   flasher.flashElementThen(indicator, DELAY_BETWEEN_GAMES / 2)
   statusBar.innerHTML = correct;
 };

 setTimeout(function() {
   flasher.flashElementThen(indicator, 333);
   board.start();
 }, 1000);

 function resizeTableToFitScreen(table) {
   var squares = table.children.length;

   var cellWidth = 85 / squares;
   var cellHeight = 85 / squares;

   var fontSize = (1500 / squares) + "%";

   Array.prototype.forEach.call(table.children, function(row) {
     Array.prototype.forEach.call(row.children, function(cell) {
       cell.style.width = cellWidth + 'vw';
       cell.style.height = cellHeight + 'vh';

       cell.style.fontSize = fontSize;
     });
   });
 }

 function Board(squaresWide, squaresHigh) {
   var self = this;

   var EMPTY_PLACEHOLDER = null;

   var dimensions = {
     width: squaresWide,
     height: squaresHigh
   };

   var squares = createBoardArray(dimensions.width, dimensions.height);

   this.setSquare = function(x, y, object) {
     assertIsInBounds(x, y);

     squares[getIndexOfSquare(x, y)] = object;
   };

   this.getSquare = function(x, y) {
     assertIsInBounds(x, y);

     return squares[getIndexOfSquare(x, y)];
   };

   this.squareIsOccupied = function(x, y) {
     return this.isInBounds(x, y) && this.getSquare(x, y) !== EMPTY_PLACEHOLDER;
   }

   this.swapSquares = function(x1, y1, x2, y2) {
     assertIsInBounds(x1, y1);
     assertIsInBounds(x2, y2);

     var old1 = this.getSquare(x1, y1);
     var old2 = this.getSquare(x2, y2);

     this.setSquare(x1, y1, old2);
     this.setSquare(x2, y2, old1);
   }

   this.clearBoard = function() {
     squares = createBoardArray(dimensions.width, dimensions.height);
   };

   this.getEmptyPlaceholder = function() {
     return EMPTY_PLACEHOLDER;
   }

   /**
    *
    * @param {Function} f function(x, y, cellContents)
    * @returns {undefined}
    */
   this.forEach = function(f) {
     for (var y = 0; y < dimensions.height; y++) {
       for (var x = 0; x < dimensions.width; x++) {
         f(x, y, this.getSquare(x, y));
       }
     }
   }


   this.toString = function() {
     var stringArr = [];

     for (var i = 0; i < squares.length; i++) {
       stringArr.push(
         squares[i] === EMPTY_PLACEHOLDER ?
         '-' : squares[i]
       );

       if ((i + 1) % dimensions.width === 0) {
         stringArr.push('\n');

       } else {
         stringArr.push(' | ');
       }
     }

     return stringArr.join('');
   };

   this.isInBounds = function(x, y) {
     return x >= 0 && x < dimensions.width &&
       y >= 0 && y < dimensions.height;
   }

   function assertIsInBounds(x, y) {
     if (!self.isInBounds(x, y)) {
       throw new Error("Coord out of bounds: (" + x + ", " + y + ")");
     }
   }

   function getIndexOfSquare(x, y) {
     return y * dimensions.width + x;
   }

   function createBoardArray(width, height) {
     var arr = [];

     var total = width * height;
     for (var i = 0; i < total; i++) {
       arr.push(EMPTY_PLACEHOLDER);
     }

     return arr;
   }

 }

 function testBoard() {
   var width = 10;
   var height = 10;

   var board = new Board(width, height);

   //Ensure it can get/set properly
   var x = width / 2;
   var y = height / 2;

   var originalObj = {
     name: "test"
   };

   board.setSquare(x, y, originalObj);
   var retrieved = board.getSquare(x, y);

   testAssert(retrieved === originalObj, "Middle Get/Set");

   board.clearBoard();

   //Ensure the edges of the board can be set properly
   x = width - 1;
   y = height - 1;

   board.setSquare(x, y, originalObj);
   retrieved = board.getSquare(x, y);

   testAssert(retrieved === originalObj, "Edge Get/Set");

   console.log("Board Test Completed");
 }

 function printGridTest() {
   var width = 15;
   var height = 20;

   var board = new Board(width, height);

   for (var y = 0; y < height; y++) {
     for (var x = 0; x < width; x++) {
       board.setSquare(x, y, "(" + x + ", " + y + ")");
     }
   }

   console.log(board.toString());
 }

 function testAssert(expression, testName) {
   var quotedName = "\"" + testName + "\"";
   if (expression) {
     console.log(quotedName + " completed successfully.");

   } else {
     console.error(quotedName + " failed.");
   }
 }

 function SimonBoard(sideLength, className) {
   sideLength = sideLength || 2;

   var self = this;

   var MAX_DEC_COLOR = Math.pow(256, 3);
   var DELAY_BETWEEN_ROUNDS = 1000;
   var DELAY_BETWEEN_SEQUENCE_FLASHES = 100;
   var SEQUENCE_FLASH_LENGTH = 200;

   var board = new Board(sideLength, sideLength);

   var table = createTable(sideLength, className);

   var isListening = false;

   var actualSequence = [];

   var correctIndex = 0;

   var flasher = new ElementFlasher();

   init();

   /**
    * Called when the player suceeds, and the turn ends. Is passed the round number.
    */
   this.onTurnEndHandler = function() {};

   /**
    * Called when the player loses. Is passed the round number when the game ended.
    */
   this.onLoseHandler = function() {};

   /**
    * Returns the table so the board can be drawn.
    *
    * @returns {Element}
    */
   this.getTable = function() {
     return table;
   };

   /**
    * Resets the game. Scores are reset, and the game starts from the beginning
    */
   this.reset = function() {
     init();
   };

   /**
    * Starts the game.
    */
   this.start = function() {
     startRound();
   }

   /**
    * Causes the given sequence of cells to flash sequentially. <p>
    * The sequence is given as cell numbers
    *
    * @param {[Number]} sequence The sequence of cell numbers to flash
    * @param {Number} flashLength How long (in ms) the flash should last for.
    * @param {Number} delayBetween How long (in ms) the delay between flashes should be
    * @param {Callback} The callback to call when the flashes finish
    */
   function flashSequenceThen(sequence, flashLength, delayBetween, f) {
     f = f || function() {};
     flashLength = flashLength || 0.33;

     for (var i = 0; i < sequence.length; i++) {
       var cellNumber = sequence[i];
       var cell = getCell(table, cellNumber);

       flasher.addToQueue(cell, flashLength);
     }

     flasher.runFlashesThen(delayBetween, f);
   }

   /**
    * Starts the round by adding a random cell to the expected sequence, then allows listening for button clicks.
    */
   function startRound() {
     console.log("Starting round...");
     var randomCell = getRandomCell(table);
     actualSequence.push(randomCell.cellNumber);

     flashSequenceThen(actualSequence, SEQUENCE_FLASH_LENGTH, DELAY_BETWEEN_SEQUENCE_FLASHES, function() {
       isListening = true;
     });
   }

   /**
    * Ends the round by disabling button click listening. <p>
    * If the player suceeded, the turn end handler is called, and the next round is initiated after a delay. <p>
    * If the player lost, the lose handler is called, and the game is reset.
    * @param {type} playerSucceeded Whether or not the player correctly entered the sequence.
    */
   function endRound(playerSucceeded) {
     console.log("Ending round... (" + playerSucceeded + ")");
     isListening = false;

     var nRight = correctIndex;
     correctIndex = 0;

     if (playerSucceeded) {
       self.onTurnEndHandler(nRight);
       setTimeout(startRound, DELAY_BETWEEN_ROUNDS);

     } else {
       self.onLoseHandler(nRight);
       self.reset();
     }
   }

   /**
    * Initializes the game to starting values
    */
   function init() {
     isListening = false;

     actualSequence = [];

     correctIndex = 0;
   }

   /**
    * Creates the main table for the game.
    * @param {Number} sideLength The lenth of each side, in squares.
    * @param {String} className The class to assign to the table.
    * @returns {Element}
    */
   function createTable(sideLength, className) {
     var table = document.createElement('table');
     table.className = className;

     var colors = genSquareColors(sideLength * sideLength, (MAX_DEC_COLOR / (sideLength * sideLength) / 10));
     console.log(colors.toString());

     var cellNumber = 0;
     for (var rowN = 0; rowN < sideLength; rowN++) {
       var row = document.createElement('tr');

       for (var colN = 0; colN < sideLength; colN++) {
         var cell = document.createElement('td');

         cell.cellNumber = cellNumber;
         cell.innerHTML = cell.cellNumber;
         cell.style.backgroundColor = colors[cellNumber];
         cell.onclick = clickHandler;

         row.appendChild(cell);

         cellNumber++;
       }

       table.appendChild(row);
     }

     return table;
   }

   /**
    * Returns a random cell from the given table. <p>
    * Assumes every row is the same length, and that the table is as high as it is wide.
    * @param {Element} table The square table holding the cells.
    * @returns {Element}
    */
   function getRandomCell(table) {
     var sideLength = table.children.length;

     var cellIndex = randomInt(0, sideLength * sideLength - 1);
     var cell = getCell(table, cellIndex);

     return cell;
   }

   /**
    * Generates a list of colors between 0 and MAX_DEC_COLORS. <p>
    * The generated colors will be at least "minDifference" different.
    *
    * @param {Number} nSquares The number of colors to generate
    * @param {Number} minDifference The minimum amount each color must be different for every other.
    * @returns {Array}
    */
   function genSquareColors(nSquares, minDifference) {
     if (minDifference >= MAX_DEC_COLOR / nSquares) {
       throw new Error("minDifference is too high! Must be < " + (MAX_DEC_COLOR / nSquares));
     }

     var colors = [];

     while (colors.length < nSquares) {
       var randDecColor = randomColorDec();

       var passed = true;
       for (var i = 0; i < colors.length && passed; i++) {
         var difference = Math.abs(colors[i] - randDecColor);

         passed = difference > minDifference;
       }

       if (passed) {
         colors.push(randDecColor);

       } else {
         //console.log("Color gen failed: " + randDecColor);
       }
     }

     return colors.map(function(decColor) {
       return decimalToHexString(decColor);
     });
   }

   /**
    * Shortcut function to generate a random decimal color in the range of 0 to MAX_DEC_COLOR
    *
    * @param {type} decColorMin The minimum color value to allow.
    * @param {type} decColorMax The maximum color value to allow
    * @returns {Number}
    */
   function randomColorDec(decColorMin, decColorMax) {
     decColorMin = decColorMin || 0;
     decColorMax = decColorMax || MAX_DEC_COLOR;

     return randomInt(decColorMin, decColorMax);
   }

   function randomFloat(min, max) {
     return Math.random() * (max + min) - min;
   }

   function randomInt(min, max) {
     return Math.floor(randomFloat(min, max));
   }

   /**
    * Converts a numeric decimal color value into a hex color string
    * @param {type} decimalColor
    * @returns {String}
    */
   function decimalToHexString(decimalColor) {
     var hexColor = decimalColor.toString(16);
     var decimalIndex = hexColor.indexOf('.');

     var truncHexColor = decimalIndex === -1 ?
       hexColor : hexColor.substr(0, decimalIndex);

     //Pad the left side with 0s
     if (truncHexColor.length < 6) {
       truncHexColor = '0'.repeat(6 - truncHexColor.length) + truncHexColor;
     }

     return '#' + truncHexColor;
   }

   /**
    * Gets the cell at the given cell number of the given table.
    *
    * @param {Element} table The table to fetch the cell from.
    * @param {Number} cellNumber The cell number of the cell to fetch
    * @returns {Element}
    */
   function getCell(table, cellNumber) {
     var sideLength = table.children.length;

     if (cellNumber > sideLength * sideLength - 1) throw new Error(
       "cellNumber out of bounds!: " + cellNumber);

     var rowN = Math.floor(cellNumber / sideLength);
     var colN = cellNumber % sideLength;

     var row = table.childNodes[rowN];

     return row.childNodes[colN];
   }

   function clickHandler(event) {
     if (isListening) {
       var cell = event.target;

       flasher.flashElementThen(cell, 100);

       var chosenCellNumber = cell.cellNumber;
       var actualCellNumber = actualSequence[correctIndex];

       var playerIsCorrect = chosenCellNumber === actualCellNumber;
       if (playerIsCorrect) {
         correctIndex++;
       }

       console.log("Correct Index: " + correctIndex + ", Actual Sequence Length: " + actualSequence.length);
       console.log("\t" + actualSequence);

       if (!playerIsCorrect || correctIndex === actualSequence.length) {
         endRound(playerIsCorrect);
       }

     } else {
       console.log("Not listening...");
     }
   }

   /*function flashRandom(length) {
       length = length || -1;
       
       var cell = getRandomCell(table);
       
       flasher.flashElementThen(cell, SEQUENCE_FLASH_LENGTH, function() {
           setTimeout(function() {
               if (length === 1) {
                   return;
               }
               
               flashRandom(--length);
           }, DELAY_BETWEEN_SEQUENCE_FLASHES);
       })
   }*/
 }

 /**
  * @constructor
  * An object to assist in flashing elements, and sequences of elements. <p>
  * Helps ensure that the same element isn't being flashes more than once at a time.
  */
 function ElementFlasher() {
   var self = this;

   var flashQueue = [];

   /**
    * Adds the given element to the queue to be flashed later.
    *
    * @param {Element} element The element to be flashed
    * @param {Number} duration How long the flash should last for (in ms).
    */
   this.addToQueue = function(element, duration) {
     flashQueue.push(
       new FlashEvent(element, duration)
     );
   };

   /**
    * Executes all the flashes in the flash queue, clears the flash queue, then calls the callback.
    *
    * @param {Number} delayBetween The delay (in ms) between flashes
    * @param {type} f
    * @returns {undefined}
    */
   this.runFlashesThen = function(delayBetween, f) {
     f = f || function() {};

     flashEvents(flashQueue, delayBetween, function() {
       flashQueue = [];
       f();
     });
   };

   /**
    * Flashes an element by increasing, then decreasing its bacground color. <p>
    * If the same element is flashed a second time while the first flash is still executing, it will permanently lighten the color of the element.
    *
    * @param {Element} element The element to flash
    * @param {Number} durationMS How long the flash should last for
    * @param {Callback} f The callback to call when the flash has completed
    */
   this.flashElementThen = function(element, durationMS, f) {
     f = f || function() {};

     var originalBackground = getComputedStyle(element).backgroundColor;

     var frameDelayMS = 33;
     var percPerMS = 1 / durationMS;
     var changePerFrame = percPerMS * frameDelayMS;

     var currentPerc = 0;
     var darkening = false;
     var flashInterval = setInterval(function() {
       //console.log("Flash perc: " + currentPerc);
       if (currentPerc >= 1) {
         darkening = true;
       }

       if (darkening) {
         currentPerc -= changePerFrame;

       } else {
         currentPerc += changePerFrame;
       }

       var changedColor = shadeRGBColor(originalBackground, currentPerc);
       element.style.backgroundColor = changedColor;

       if (currentPerc <= 0) {
         clearInterval(flashInterval);
         element.style.backgroundColor = originalBackground;
         f();
       }
     }, frameDelayMS);
   }

   /**
    * Flashes the given sequence of FlashEvents.
    *
    * @param {[FlashEvent]} events The sequence of FlashEvents to flash.
    * @param {Number} delayBetween The delay (in ms) between each flash.
    * @param {Callback} thenF The callback to call after all the flashes have completed.
    */
   function flashEvents(events, delayBetween, thenF) {
     if (events.length < 1) {
       thenF();
       return;
     }

     thenF = thenF || function() {};

     var event = events[0];
     var element = event.element;
     var duration = event.duration;

     self.flashElementThen(element, duration, function() {
       setTimeout(function() {
         flashEvents(events.slice(1), delayBetween, thenF);
       }, delayBetween);
     });


   }

   /**
    * Lightens the given rgb color by the given percent. <p>
    * Stolen from http://stackoverflow.com/a/13542669/3000206
    *
    * @param {String} color The CSS string in the form "rgb(#,#,#)"
    * @param {Number} percent A decimal percentage of the amount to lighten the color by
    * @returns {String}
    */
   function shadeRGBColor(color, percent) {
     var f = color.split(","),
       t = percent < 0 ? 0 : 255,
       p = percent < 0 ? percent * -1 : percent,
       R = parseInt(f[0].slice(4)),
       G = parseInt(f[1]),
       B = parseInt(f[2]);
     return "rgb(" + (Math.round((t - R) * p) + R) + "," + (Math.round((t - G) * p) + G) + "," + (Math.round((t - B) * p) + B) + ")";
   }

   /**
    * @constructor
    * An object to represent a single flash "event".
    *
    * @param {Element} element The element to be flashed in the future.
    * @param {Number} duration How long the flash should last.
    */
   function FlashEvent(element, duration) {
     this.element = element;
     this.duration = duration;
   }
 }
#statusBar {
  text-align: center;
  font-size: 200%;
}
.simonTable {
  margin: auto;
  padding: 0.5%;
  background-color: darkblue;
}
.simonTable tr td {
  text-align: center;
  border: 1px solid black;
}
<div id="statusBar">0</div>
<div id="indicator"></div>
<div id="container">

</div>

Board.js:

function Board(squaresWide, squaresHigh) {
    var self = this;

    var EMPTY_PLACEHOLDER = null;

    var dimensions = {
        width:  squaresWide,
        height: squaresHigh
    };

    var squares = createBoardArray(dimensions.width, dimensions.height);

    this.setSquare = function(x, y, object) {
        assertIsInBounds(x, y);

        squares[ getIndexOfSquare(x, y) ] = object;
    };

    this.getSquare = function(x, y) {
        assertIsInBounds(x, y);

        return squares[ getIndexOfSquare(x, y) ];
    };

    this.squareIsOccupied = function(x, y) {
        return this.isInBounds(x, y)
                && this.getSquare(x,y) !== EMPTY_PLACEHOLDER;
    }

    this.swapSquares = function(x1, y1, x2, y2) {
        assertIsInBounds(x1, y1);
        assertIsInBounds(x2, y2);

        var old1 = this.getSquare(x1, y1);
        var old2 = this.getSquare(x2, y2);

        this.setSquare(x1, y1, old2);
        this.setSquare(x2, y2, old1);
    }

    this.clearBoard = function() {
        squares = createBoardArray(dimensions.width, dimensions.height);
    };

    this.getEmptyPlaceholder = function() {
        return EMPTY_PLACEHOLDER;
    }

    /**
     * 
     * @param {Function} f function(x, y, cellContents)
     * @returns {undefined}
     */
    this.forEach = function(f) {
        for (var y = 0; y < dimensions.height; y++) {
            for (var x = 0; x < dimensions.width; x++) {
                f(x, y, this.getSquare(x, y));
            }
        }
    }


    this.toString = function() {
        var stringArr = [];

        for (var i = 0; i < squares.length; i++) {
            stringArr.push(
                squares[i] === EMPTY_PLACEHOLDER ?
                    '-' : squares[i]
            );

            if ((i + 1) % dimensions.width === 0) {
                stringArr.push('\n');

            } else {
                stringArr.push(' | ');
            }
        }

        return stringArr.join('');
    };

    this.isInBounds = function(x, y) {
        return  x >= 0 && x < dimensions.width &&
                y >= 0 && y < dimensions.height;
    }

    function assertIsInBounds(x, y) {
        if (!self.isInBounds(x, y)) {
            throw new Error("Coord out of bounds: (" + x + ", " + y + ")");
        }
    }

    function getIndexOfSquare(x, y) {
        return y * dimensions.width + x;
    }

    function createBoardArray(width, height) {
        var arr = [];

        var total = width * height;
        for (var i = 0; i < total; i++) {
            arr.push(EMPTY_PLACEHOLDER);
        }

        return arr;
    }

}

function testBoard() {
    var width = 10;
    var height = 10;

    var board = new Board(width, height);

    //Ensure it can get/set properly
    var x = width / 2;
    var y = height / 2;

    var originalObj = { name: "test" };

    board.setSquare(x, y, originalObj);
    var retrieved = board.getSquare(x, y);

    testAssert(retrieved === originalObj, "Middle Get/Set");

    board.clearBoard();

    //Ensure the edges of the board can be set properly
    x = width - 1;
    y = height - 1;

    board.setSquare(x, y, originalObj);
    retrieved = board.getSquare(x, y);

    testAssert(retrieved === originalObj, "Edge Get/Set");

    console.log("Board Test Completed");
}

function printGridTest() {
    var width = 15;
    var height = 20;

    var board = new Board(width, height);

    for (var y = 0; y < height; y++) {
        for (var x = 0; x < width; x++) {
            board.setSquare(x, y, "(" + x + ", " + y + ")");
        }
    }

    console.log(board.toString());
}

function testAssert(expression, testName) {
    var quotedName = "\"" + testName + "\"";
    if (expression) {
        console.log(quotedName + " completed successfully.");

    } else {
        console.error(quotedName + " failed.");
    }
}

SimonBoard.js:

function SimonBoard(sideLength, className) {
    sideLength = sideLength || 2;

    var self = this;

    var MAX_DEC_COLOR = Math.pow(256, 3);
    var DELAY_BETWEEN_ROUNDS = 1000;
    var DELAY_BETWEEN_SEQUENCE_FLASHES = 100;
    var SEQUENCE_FLASH_LENGTH = 200;

    var board = new Board(sideLength, sideLength);

    var table = createTable(sideLength, className);

    var isListening = false;

    var actualSequence = [];

    var correctIndex = 0;

    var flasher = new ElementFlasher();

    init();

    /**
     * Called when the player suceeds, and the turn ends. Is passed the round number.
     */
    this.onTurnEndHandler = function(){};

    /**
     * Called when the player loses. Is passed the round number when the game ended.
     */
    this.onLoseHandler = function(){};

    /**
     * Returns the table so the board can be drawn.
     * 
     * @returns {Element}
     */
    this.getTable = function() {
        return table;
    };

    /**
     * Resets the game. Scores are reset, and the game starts from the beginning
     */
    this.reset = function() {
        init();
    };

    /**
     * Starts the game.
     */   
    this.start = function() {
        startRound();
    }

    /**
     * Causes the given sequence of cells to flash sequentially. <p>
     * The sequence is given as cell numbers
     * 
     * @param {[Number]} sequence The sequence of cell numbers to flash
     * @param {Number} flashLength How long (in ms) the flash should last for.
     * @param {Number} delayBetween How long (in ms) the delay between flashes should be
     * @param {Callback} The callback to call when the flashes finish
     */
    function flashSequenceThen(sequence, flashLength, delayBetween, f) {
        f = f || function(){};
        flashLength = flashLength || 0.33;

        for (var i = 0; i < sequence.length; i++) {
            var cellNumber = sequence[i];
            var cell = getCell(table, cellNumber);

            flasher.addToQueue(cell, flashLength);
        }

        flasher.runFlashesThen(delayBetween, f);
    }

    /**
     * Starts the round by adding a random cell to the expected sequence, then allows listening for button clicks.
     */
    function startRound(){
        console.log("Starting round...");
        var randomCell = getRandomCell(table);
        actualSequence.push(randomCell.cellNumber);

        flashSequenceThen(actualSequence, SEQUENCE_FLASH_LENGTH, DELAY_BETWEEN_SEQUENCE_FLASHES, function() {
            isListening = true;
        });
    }

    /**
     * Ends the round by disabling button click listening. <p>
     * If the player suceeded, the turn end handler is called, and the next round is initiated after a delay. <p>
     * If the player lost, the lose handler is called, and the game is reset.
     * @param {type} playerSucceeded Whether or not the player correctly entered the sequence.
     */
    function endRound(playerSucceeded) {
        console.log("Ending round... (" + playerSucceeded + ")");
        isListening = false;

        var nRight = correctIndex;
        correctIndex = 0;

        if (playerSucceeded) {
            self.onTurnEndHandler(nRight);
            setTimeout(startRound, DELAY_BETWEEN_ROUNDS);

        } else {
            self.onLoseHandler(nRight);
            self.reset();
        }
    }

    /**
     * Initializes the game to starting values
     */
    function init() {
        isListening = false;

        actualSequence = [];

        correctIndex = 0;
    }

    /**
     * Creates the main table for the game. 
     * @param {Number} sideLength The lenth of each side, in squares.
     * @param {String} className The class to assign to the table. 
     * @returns {Element}
     */
    function createTable(sideLength, className) {
        var table = document.createElement('table');
        table.className = className;

        var colors = genSquareColors(sideLength * sideLength, (MAX_DEC_COLOR / (sideLength * sideLength) / 10));
        console.log(colors.toString());

        var cellNumber = 0;
        for (var rowN = 0; rowN < sideLength; rowN++) {
            var row = document.createElement('tr');

            for (var colN = 0; colN < sideLength; colN++) {
                var cell = document.createElement('td');

                cell.cellNumber = cellNumber;
                cell.innerHTML = cell.cellNumber;
                cell.style.backgroundColor = colors[cellNumber];
                cell.onclick = clickHandler;

                row.appendChild(cell);

                cellNumber++;
            }

            table.appendChild(row);
        }

        return table;
    }

    /**
     * Returns a random cell from the given table. <p>
     * Assumes every row is the same length, and that the table is as high as it is wide.
     * @param {Element} table The square table holding the cells. 
     * @returns {Element}
     */
    function getRandomCell(table) {
        var sideLength = table.children.length;

        var cellIndex = randomInt(0, sideLength * sideLength - 1);
        var cell = getCell(table, cellIndex);

        return cell;
    }

    /**
     * Generates a list of colors between 0 and MAX_DEC_COLORS. <p>
     * The generated colors will be at least "minDifference" different.
     * 
     * @param {Number} nSquares The number of colors to generate
     * @param {Number} minDifference The minimum amount each color must be different for every other.
     * @returns {Array}
     */
    function genSquareColors(nSquares, minDifference) {
        if (minDifference >= MAX_DEC_COLOR / nSquares) {
            throw new Error("minDifference is too high! Must be < " + (MAX_DEC_COLOR / nSquares));
        }

        var colors = [];

        while (colors.length < nSquares) {
            var randDecColor = randomColorDec();

            var passed = true;
            for (var i = 0; i < colors.length && passed; i++) {
                var difference = Math.abs(colors[i] - randDecColor);

                passed = difference > minDifference;     
            }

            if (passed) {
                colors.push(randDecColor);

            } else {
                //console.log("Color gen failed: " + randDecColor);
            }
        }

        return colors.map(function(decColor) {
            return decimalToHexString(decColor);
        });
    }

    /**
     * Shortcut function to generate a random decimal color in the range of 0 to MAX_DEC_COLOR
     * 
     * @param {type} decColorMin The minimum color value to allow.
     * @param {type} decColorMax The maximum color value to allow
     * @returns {Number}
     */
    function randomColorDec(decColorMin, decColorMax) {
        decColorMin = decColorMin || 0;
        decColorMax = decColorMax || MAX_DEC_COLOR;

        return randomInt(decColorMin, decColorMax);
    }

    function randomFloat(min, max) {        
        return Math.random() * (max + min) - min;
    }

    function randomInt(min, max) {
        return Math.floor( randomFloat(min, max) );
    }

    /**
     * Converts a numeric decimal color value into a hex color string 
     * @param {type} decimalColor
     * @returns {String}
     */
    function decimalToHexString(decimalColor) {
        var hexColor = decimalColor.toString(16);
        var decimalIndex = hexColor.indexOf('.');

        var truncHexColor = decimalIndex === -1 ?
                                hexColor : hexColor.substr(0, decimalIndex);

        //Pad the left side with 0s
        if (truncHexColor.length < 6) {
            truncHexColor = '0'.repeat(6 - truncHexColor.length) + truncHexColor;
        }

        return '#' + truncHexColor;
    }

    /**
     * Gets the cell at the given cell number of the given table.
     * 
     * @param {Element} table The table to fetch the cell from.
     * @param {Number} cellNumber The cell number of the cell to fetch
     * @returns {Element}
     */
    function getCell(table, cellNumber) {
        var sideLength = table.children.length;

        if (cellNumber > sideLength * sideLength - 1) throw new Error(
                "cellNumber out of bounds!: " + cellNumber);

        var rowN = Math.floor(cellNumber / sideLength);
        var colN = cellNumber % sideLength;

        var row = table.childNodes[rowN];

        return row.childNodes[colN];
    }

    function clickHandler(event) {
        if (isListening) {
            var cell = event.target;

            flasher.flashElementThen(cell, 100);

            var chosenCellNumber = cell.cellNumber;
            var actualCellNumber = actualSequence[correctIndex];

            var playerIsCorrect = chosenCellNumber === actualCellNumber;
            if (playerIsCorrect) {
                correctIndex++;  
            }

            console.log("Correct Index: " + correctIndex + ", Actual Sequence Length: " + actualSequence.length);
            console.log("\t" + actualSequence);

            if (!playerIsCorrect || correctIndex === actualSequence.length) {
                endRound(playerIsCorrect);
            }

        } else {
            console.log("Not listening...");
        }
    }

    /*function flashRandom(length) {
        length = length || -1;

        var cell = getRandomCell(table);

        flasher.flashElementThen(cell, SEQUENCE_FLASH_LENGTH, function() {
            setTimeout(function() {
                if (length === 1) {
                    return;
                }


                flashRandom(--length);
            }, DELAY_BETWEEN_SEQUENCE_FLASHES);
        })
    }*/
}

ElementFlasher.js:

/**
 * @constructor
 * An object to assist in flashing elements, and sequences of elements. <p>
 * Helps ensure that the same element isn't being flashes more than once at a time.
 */
function ElementFlasher() {
    var self = this;

    var flashQueue = [];

    /**
     * Adds the given element to the queue to be flashed later.
     * 
     * @param {Element} element The element to be flashed
     * @param {Number} duration How long the flash should last for (in ms).
     */
    this.addToQueue = function(element, duration) {
        flashQueue.push(
            new FlashEvent(element, duration)
        );
    };

    /**
     * Executes all the flashes in the flash queue, clears the flash queue, then calls the callback.
     * 
     * @param {Number} delayBetween The delay (in ms) between flashes
     * @param {type} f
     * @returns {undefined}
     */
    this.runFlashesThen = function(delayBetween, f) {
        f = f || function(){};

        flashEvents(flashQueue, delayBetween, function() {
            flashQueue = [];
            f();
        });
    };

    /**
     * Flashes an element by increasing, then decreasing its bacground color. <p>
     * If the same element is flashed a second time while the first flash is still executing, it will permanently lighten the color of the element.
     * 
     * @param {Element} element The element to flash
     * @param {Number} durationMS How long the flash should last for
     * @param {Callback} f The callback to call when the flash has completed
     */
    this.flashElementThen = function(element, durationMS, f) {
        f = f || function(){};

        var originalBackground = getComputedStyle(element).backgroundColor;

        var frameDelayMS = 33;
        var percPerMS = 1 / durationMS;
        var changePerFrame = percPerMS * frameDelayMS;

        var currentPerc = 0;
        var darkening = false;
        var flashInterval = setInterval(function() {
            //console.log("Flash perc: " + currentPerc);
            if (currentPerc >= 1) {
                darkening = true;
            }

            if (darkening) {
                currentPerc -= changePerFrame;

            } else {
                currentPerc += changePerFrame;
            }

            var changedColor = shadeRGBColor(originalBackground, currentPerc);
            element.style.backgroundColor = changedColor;

            if (currentPerc <= 0) {
                clearInterval(flashInterval);
                element.style.backgroundColor = originalBackground;
                f();
            }
        }, frameDelayMS);
    }

    /**
     * Flashes the given sequence of FlashEvents.
     * 
     * @param {[FlashEvent]} events The sequence of FlashEvents to flash.
     * @param {Number} delayBetween The delay (in ms) between each flash.
     * @param {Callback} thenF The callback to call after all the flashes have completed.
     */
    function flashEvents(events, delayBetween, thenF) {
        if (events.length < 1) {
            thenF();
            return;
        }

        thenF = thenF || function(){};

        var event = events[0];
        var element = event.element;
        var duration = event.duration;

        self.flashElementThen(element, duration, function() {
            setTimeout(function() {
                flashEvents(events.slice(1), delayBetween, thenF);
            }, delayBetween);
        });


    }

    /**
     * Lightens the given rgb color by the given percent. <p>
     * Stolen from http://stackoverflow.com/a/13542669/3000206
     * 
     * @param {String} color The CSS string in the form "rgb(#,#,#)"
     * @param {Number} percent A decimal percentage of the amount to lighten the color by
     * @returns {String}
     */
    function shadeRGBColor(color, percent) {
        var f=color.split(","),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=parseInt(f[0].slice(4)),G=parseInt(f[1]),B=parseInt(f[2]);
        return "rgb("+(Math.round((t-R)*p)+R)+","+(Math.round((t-G)*p)+G)+","+(Math.round((t-B)*p)+B)+")";
    }

    /**
     * @constructor
     * An object to represent a single flash "event".
     * 
     * @param {Element} element The element to be flashed in the future.
     * @param {Number} duration How long the flash should last.
     */
    function FlashEvent(element, duration) {
        this.element = element;
        this.duration = duration;
    }
}

index.html:

<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
    <head>
        <meta charset="UTF-8">
        <title>Simon</title>
        <style>
            #statusBar {
                text-align: center;
                font-size: 200%;
            }

            .simonTable {
                margin: auto;

                padding: 0.5%;

                background-color: darkblue;
            }

            .simonTable tr td {
                text-align: center;
                border: 1px solid black;

            }
        </style>
    </head>
    <body>
        <div id="statusBar">0</div>
        <div id="indicator"> </div>
        <div id="container">

        </div>

        <script src="Board.js"></script>
        <script src="ElementFlasher.js"></script>
        <script src="SimonBoard.js"></script>
        <script>
            var DELAY_BETWEEN_GAMES = 1000;

            var container = document.getElementById('container');
            var statusBar = document.getElementById('statusBar');

            var board = new SimonBoard(2, 'simonTable');
            var table = board.getTable();

            var indicator = table;

            var flasher = new ElementFlasher();

            container.appendChild(table);
            resizeTableToFitScreen(table);

            board.onLoseHandler = function() {
                flasher.flashElementThen(indicator, DELAY_BETWEEN_GAMES, function() {
                    setTimeout(function() {
                        board.reset();
                        board.start();
                    }, DELAY_BETWEEN_GAMES)
                });

            };

            board.onTurnEndHandler = function(correct) {
                flasher.flashElementThen(indicator, DELAY_BETWEEN_GAMES / 2)
                statusBar.innerHTML = correct;
            };

            setTimeout(function() {
                flasher.flashElementThen(indicator, 333);
                board.start();
            }, 1000);

            function resizeTableToFitScreen(table) {
                var squares = table.children.length;

                var cellWidth = 85 / squares;
                var cellHeight = 85 / squares;

                var fontSize = (1500 / squares) + "%";

                Array.prototype.forEach.call(table.children, function(row) {
                    Array.prototype.forEach.call(row.children, function(cell) {
                        cell.style.width = cellWidth + 'vw';
                        cell.style.height = cellHeight + 'vh';

                        cell.style.fontSize = fontSize;
                    });
                });
            }

        </script>

    </body>
</html>
\$\endgroup\$

Your Answer

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

Browse other questions tagged or ask your own question.