uccser/cs-field-guide

View on GitHub
csfieldguide/static/interactives/clicking-with-shaking/js/clicking-with-shaking.js

Summary

Maintainability
D
1 day
Test Coverage
/**
 * The datatable to show the results.
 * @type {null}
 */
let table = null;

/**
 * How many times the user has missed the target for the current set.
 * @type {number}
 */
let misses = 0;

/**
 * How many times the user has hit the target for the current set.
 * @type {number}
 */
let hits = 0;

/**
 * The user's current score.
 *
 * Hits increment the score while misses decrement the score. The score should not go below 0. The user has won when
 * the score is equal to GOAL_SCORE.
 * @type {number}
 */
let score = 0;

/**
 * The score the user needs to reach to pass the current set.
 * @type {number}
 */
let GOAL_SCORE = 5;

/**
 * The button element that the user must click.
 * @type {null}
 */
let target = null;

/**
 * The button element that starts/restarts/resumes the game when clicked.
 * @type {null}
 */
let playButton = null;

/**
 * The button element that toggles whether sfx will be played.
 * @type {null}
 */
let muteButton = null;

/**
 * The button element that skips the remaining sets and shows the results.
 * @type {null}
 */
let skipButton = null;

/**
 * The div element that allows the user to download the table.
 * @type {null}
 */
let downloadCSVDiv = null;

/**
 * The datetime when the current set started to calculate how long it took the user to complete the set.
 * @type {null}
 */
let startTime = null;

/**
 * A list of the button CSS classes defined in clicking-with-shaking.scss.
 *
 * Used to update the target style (size) and used to check whether special behaviour should activate, such as whether
 * the button should also shift in addition to vibrating.
 * @type {string[]}
 */
let buttonSizeClasses = ["btn-large", "btn-medium", "btn-small", "btn-small-moving"];

/**
 * The set number the user is up to. Goes up to the size of buttonSizeClasses exclusive.
 * @type {number}
 */
let set = 0;

/**
 * The span element to update to show the time elapsed for the current set.
 * @type {null}
 */
let timeElement = null;

/**
 * The span element to update to show the current set number.
 * @type {null}
 */
let setElement = null;

/**
 * A list of objects.
 *
 * Each object has the following keys: buttonSize, hits, misses, accuracy, time, averageTime. The set number can be
 * inferred from the index in the list.
 *
 * buttonSize: A string with the format w x h, where w is the target width and h is the height in pixels for the set.
 * hits: The number of hits for the set.
 * misses: The number of misses for the set.
 * accuracy: A floating number representing the user's accuracy for the set (hits divided by the sum of hits and
 *           misses).
 * time: The time in milliseconds the user took to complete the set.
 * averageTime: The average time between each successful click (hits divided by time).
 *
 * @type {null}
 */
let results = [];

/**
 * The div element set as the boundary for the game.
 *
 * Clicking within this div will result in either a hit or miss, which clickHandler checks.
 * @type {null}
 */
let clickArea = null;

/**
 * The timeout object the progress bar uses to time when the bar color should return to normal.
 *
 * Required to store the reference so the timeout can be cancelled when needed (e.g. another click has occurred that
 * should overwrite the color change).
 * @type {null}
 */
let progressBarTimeout = null;

/**
 * The context for playing sfx.
 * @type {null}
 */
let audioContext = null;

/**
 * The audio buffer for the hit sound effect.
 * @type {null}
 */
let hitSFXBuffer = null;

/**
 * The audio buffer for the miss sound effect.
 * @type {null}
 */
let missSFXBuffer = null;

/**
 * A boolean representing whether the game is currently muted.
 * @type {boolean}
 */
let mute = false;

/**
 * The div for the progress bar.
 *
 * Used to update the progress bar width and style.
 * @type {null}
 */
let progressBar = null;

/**
 * Whether the button is currently offset.
 *
 * Required to ensure the button is only offset in one direction at a time i.e. shift() can only be called when
 * buttonIsOffset is false.
 * @type {boolean}
 */
let buttonIsOffset = false;


/**
 * Sets up the page for the game.
 *
 * Assigns values to some of the global variables. Sets the initial target class. Sets up the click handlers for the
 * clickArea and buttons. Calls setUpAudio. Sets up the results datatable.
 */
$(document).ready(function() {
  target = document.getElementById("target");
  muteButton = document.getElementById("mute");
  skipButton = document.getElementById("give-up");
  playButton = document.getElementById("play");
  timeElement = document.getElementById("time");
  setElement = document.getElementById("set");
  clickArea = document.getElementById("game-view");
  progressBar = document.getElementById("game-progress");
  downloadCSVDiv = document.getElementById("download-table-csv");

  target.classList.add(buttonSizeClasses[set]);
  clickArea.addEventListener('mousedown', clickHandler, false);
  $(playButton).click(toggleState);
  $(muteButton).click(toggleMute);
  $(skipButton).click(gameOver);
  $(downloadCSVDiv).click(downloadCSV);
  setUpAudio();

  table = $('#results-table').DataTable( {
    "paging":   false,
    "info":     false,
    "searching": false,
    "oLanguage": {
      "sEmptyTable": "Complete the experiment to show statistics"
    },
    "dom": 'frti',
    "buttons": [
      'csv'
    ]
  });
});


/**
 * Updates the progress bar.
 *
 * Updates the width and text to match the current score. Also changes the color (depending on if the score has
 * increased), and changes the color back to normal some time later. Clears progressBarTimeout to prevent multiple
 * color changes at once.
 *
 * @param increase Whether the score has increased i.e. the target was hit.
 */
function updateProgress(increase) {
  progressBar.style.width = (score / GOAL_SCORE * 100).toString() + "%";
  progressBar.innerText = score + "/" + GOAL_SCORE;
  clearTimeout(progressBarTimeout);

  if (increase) {
    progressReplace("bg-success");
  } else {
    progressReplace("bg-danger");
  }

  progressBarTimeout = setTimeout(function() {
    progressReplace("bg-primary");
  }, 500);
}


/**
 * Changes the progress bar color to the specified one by replacing any existing Bootstrap bg classes.
 * @param newVal The new bg class.
 */
function progressReplace(newVal) {
  progressBar.classList.replace("bg-primary", newVal);
  progressBar.classList.replace("bg-success", newVal);
  progressBar.classList.replace("bg-danger", newVal);
}


/**
 * Toggles whether the game is muted, including changing the mute button text.
 */
function toggleMute() {
  mute = !mute;
  if (mute) {
    $("#mute").html("Unmute");
  } else {
    $("#mute").html("Mute");
  }
}


/**
 * Initialises the audioContext and hitSFXBuffer and missSFXBuffer.
 *
 * hitSoundURL and missSoundURL are defined in clicking-with-shaking.html.
 */
function setUpAudio() {
  audioContext = new AudioContext();
  const playButton = document.querySelector('#play');

  window.fetch(hitSoundURL)
    .then(response => response.arrayBuffer())
    .then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
    .then(audioBuffer => {
      playButton.disabled = false;
      hitSFXBuffer = audioBuffer;
    });

  window.fetch(missSoundURL)
    .then(response => response.arrayBuffer())
    .then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
    .then(audioBuffer => {
      playButton.disabled = false;
      missSFXBuffer = audioBuffer;
    });
}


/**
 * Plays a sound effect from the buffer.
 * @param audioBuffer The buffer to play.
 */
function playSFX(audioBuffer) {
  if (!mute) {
    const source = audioContext.createBufferSource();
    source.buffer = audioBuffer;
    source.connect(audioContext.destination);
    source.start();
  }
}


/**
 * Switches the state of the game (either in a set or between sets) to show different UI elements.
 *
 * Either the target or play button is visible. If the target is visible, the skip button is also visible. setElement
 * is updated, the table is cleared in case it is visible, startTime is reset, and shake is called to make the target
 * start vibrating.
 *
 * If the play button is visible, the skip button is hidden, the play button text is updated, and the play button
 * is temporarily disabled to avoid accidental clicks when the user is spam clicking the target.
 */
function toggleState() {
  target.hidden = !target.hidden;
  playButton.hidden = !playButton.hidden;
  downloadCSVDiv.hidden = true;
  if (!target.hidden) {
    skipButton.style.visibility = "visible";

    setElement.innerText = set + 1;
    table.clear().draw();
    startTime = new Date().getTime();
    shake();
  } else {
    skipButton.style.visibility = "hidden";
    playButton.innerText = "Next Set";
    playButton.disabled = true;
    setTimeout(function () { playButton.disabled = false; }, 1000);
  }
}

/**
 * Handles when clickArea is clicked.
 *
 * If the target was clicked, hits and score are incremented, and the hit sound effect is played. If the user has hit
 * the GOAL_SCORE, a row is added to the result table, and either gameOver or nextSet is called depending if the user
 * has completed all the sets. Otherwise, the progress bar is updated.
 *
 * Otherwise, misses is incremented, and score is decremented, bounded by 0. The miss sound effect is played and the
 * progress bar is updated.
 *
 * @param event The event object to determine what was clicked.
 */
function clickHandler(event) {
  let elementID = event.target.id;
  if (elementID === target.id) {
    hits++;
    score++;
    playSFX(hitSFXBuffer);

    if (score === GOAL_SCORE) {
      addResult();
      if (set === buttonSizeClasses.length - 1) {
        gameOver();
      } else{
        nextSet();
      }
    } else {
      updateProgress(true);
    }
  } else if (![playButton.id, muteButton.id, skipButton.id].includes(elementID) && !target.hidden) {
      misses++;
      score = Math.max(score - 1, 0);
      playSFX(missSFXBuffer);
      updateProgress(false);
  }
}


/**
 * Sets up the game over state.
 *
 * Resets set to zero, and calls reset game. Shows the game results, clears the game results, and updates the play
 * button text.
 */
function gameOver() {
  let oldSetNum = set;
  set = 0;
  resetGame(oldSetNum, set);
  showResults();
  playButton.innerText = "Play Again";
  results = [];
}


/**
 * Prepares the game for the next set.
 *
 * Increments set and calls reset game.
 */
function nextSet() {
  let oldSetNum = set;
  set++;
  resetGame(oldSetNum, set);
}


/**
 * Resets the game in preparation for the next set or a new game.
 *
 * Toggles the state (which should change to the between-sets state), and resets misses, hits, and score. Calls
 * updateProgress, and updates the target class.
 * @param oldSetNum The previous set number.
 * @param newSetNum The new set number.
 */
function resetGame(oldSetNum, newSetNum) {
  toggleState();
  misses = 0;
  hits = 0;
  score = 0;
  updateProgress(true);
  target.classList.replace(buttonSizeClasses[oldSetNum], buttonSizeClasses[newSetNum]);
}


/**
 * Calculates the metrics for the current set and adds a new object to results.
 */
function addResult() {
  const end = new Date().getTime();
  const time = end - startTime;
  const buttonSize = target.offsetWidth.toString() + " x " + target.offsetHeight.toString();
  const accuracy = (Math.round((hits / (hits + misses)) * 10000) / 100)
  const averageTime = Math.round(time / hits * 100) / 100;

  let result = { buttonSize: buttonSize, hits: hits, misses: misses, accuracy: accuracy, time: time,
    averageTime: averageTime }
  results.push(result)
}


/**
 * Iterates through the results and adds a row to the results datatable.
 *
 * Also adds an empty row for each skipped set.
 */
function showResults() {
  for (let i = 0; i < results.length; i++) {
    let result = results[i];
    table.row.add([
      i + 1,
      result.buttonSize,
      result.hits,
      result.misses,
      result.accuracy,
      result.time,
      result.averageTime
    ]).draw(false);
  }

  for (let i = results.length; i < buttonSizeClasses.length; i++) {
    table.row.add([
      i + 1, "-", "-", "-", "-", "-", "-"
    ]).draw(false);
  }

  downloadCSVDiv.hidden = false;
}


/**
 * Shakes the target using randomised parameters.
 *
 * The distance of the shake is relative to the size of the click area to prevent the button from going out of bounds
 * on different screen sizes. Given it is the final set and the button is not already offset, shift may be called.
 * Provided the target is still visible, shake is called again.
 *
 * Also updates timeElement with the current elapsed time.
 * @return {Promise<void>}
 */
async function shake() {
  let direction =  Math.random() < 0.5 ? "left" : "up";
  let distancePercent = getRandomBetween(0.1, 0.2);

  let distance = clickArea.offsetHeight * distancePercent;
  if (direction === "left") {
    distance = clickArea.offsetWidth * distancePercent;
  }

  const end = new Date().getTime();
  timeElement.innerText = Math.floor((end - startTime) / 1000);

  if (buttonSizeClasses[set] === "btn-small-moving" && !buttonIsOffset && Math.random() > 0.9) {
    shift();
  }

  $('#target').effect("shake", {times: 1, distance: distance, direction: direction}, 300, () => {
    if (!target.hidden) {
     shake();
    }
  });
}


/**
 * Shifts the target to a different part of the clickArea using randomised parameters.
 *
 * The shift amount is relative to the size of the click area to prevent the button from going out of bounds on
 * different screen sizes. The button shift is achieved by modifying the margins. After a randomised duration, the
 * button is returned to the center. buttonIsOffset is set to true.
 * @return {Promise<void>}
 */
async function shift() {
  buttonIsOffset = true;
  let directionProb =  Math.random();
  let direction;
  if (directionProb < 0.25) {
    direction = "left";
  } else if (directionProb < 0.5) {
    direction = "right";
  } else if (directionProb < 0.75) {
    direction = "up";
  } else {
    direction = "down";
  }

  let distancePercent = getRandomBetween(0.1, 0.3);
  let distance = clickArea.offsetHeight * distancePercent;
  if (direction === "left" || direction === "right") {
    distance = clickArea.offsetWidth * distancePercent;
  }

  let duration = Math.round(getRandomBetween(0.1, 1) * 5000);

  if (direction === "left") {
    $(target).animate({ "margin-left": "-=" + distance.toString() }, 200, null,
      () => { returnToCentre("margin-left", duration) });
  } else if (direction === "right") {
    $(target).animate({ "margin-left": "+=" + distance.toString() }, 200, null,
      () => { returnToCentre("margin-left", duration) });
  } else if (direction === "up") {
    $(target).animate({ "margin-top": "-=" + distance.toString() }, 200, null,
      () => { returnToCentre("margin-top", duration) });
  } else {
    $(target).animate({ "margin-top": "+=" + distance.toString() }, 200, null,
      () => { returnToCentre("margin-top", duration) });
  }
}


/**
 * Returns the shifted target back to the center after the specified duration.
 *
 * Sets buttonIsOffset back to false after the handler is executed.
 * @param margin The margin (e.g. margin-left) that needs to be reset.
 * @param duration The duration before the target is reset in milliseconds.
 */
function returnToCentre(margin, duration) {
  setTimeout(function() {
    let obj = {}
    obj[margin] = "0";
    $(target).animate(obj, 200);
    buttonIsOffset = false;
  }, duration)
}


/**
 * Obtains a random number between two numbers.
 * @param min The lower bound number.
 * @param max The upper bound number.
 * @return {*} A number.
 */
function getRandomBetween(min, max) {
  return Math.random() * (max - min) + min; //The maximum is exclusive and the minimum is inclusive
}


/**
 * Downloads the table data as a CSV file.
 */
function downloadCSV() {
    table.button(0).trigger();
}