var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var H5P = H5P || {}; /** * H5P-Timer * * General purpose timer that can be used by other H5P libraries. * * @param {H5P.jQuery} $ */ H5P.Timer = function ($, EventDispatcher) { /** * Create a timer. * * @constructor * @param {number} [interval=Timer.DEFAULT_INTERVAL] - The update interval. */ function Timer() { var interval = arguments.length <= 0 || arguments[0] === undefined ? Timer.DEFAULT_INTERVAL : arguments[0]; var self = this; // time on clock and the time the clock has run var clockTimeMilliSeconds = 0; var playingTimeMilliSeconds = 0; // used to update recurring notifications var clockUpdateMilliSeconds = 0; // indicators for total running time of the timer var firstDate = null; var startDate = null; var lastDate = null; // update loop var loop = null; // timer status var status = Timer.STOPPED; // indicate counting direction var mode = Timer.FORWARD; // notifications var notifications = []; // counter for notifications; var notificationsIdCounter = 0; // Inheritance H5P.EventDispatcher.call(self); // sanitize interval if (Timer.isInteger(interval)) { interval = Math.max(interval, 1); } else { interval = Timer.DEFAULT_INTERVAL; } /** * Get the timer status. * * @public * @return {number} The timer status. */ self.getStatus = function () { return status; }; /** * Get the timer mode. * * @public * @return {number} The timer mode. */ self.getMode = function () { return mode; }; /** * Get the time that's on the clock. * * @private * @return {number} The time on the clock. */ var getClockTime = function getClockTime() { return clockTimeMilliSeconds; }; /** * Get the time the timer was playing so far. * * @private * @return {number} The time played. */ var getPlayingTime = function getPlayingTime() { return playingTimeMilliSeconds; }; /** * Get the total running time from play() until stop(). * * @private * @return {number} The total running time. */ var getRunningTime = function getRunningTime() { if (!firstDate) { return 0; } if (status !== Timer.STOPPED) { return new Date().getTime() - firstDate.getTime(); } else { return !lastDate ? 0 : lastDate.getTime() - firstDate; } }; /** * Get one of the times. * * @public * @param {number} [type=Timer.TYPE_CLOCK] - Type of the time to get. * @return {number} Clock Time, Playing Time or Running Time. */ self.getTime = function () { var type = arguments.length <= 0 || arguments[0] === undefined ? Timer.TYPE_CLOCK : arguments[0]; if (!Timer.isInteger(type)) { return; } // break will never be reached, but for consistency... switch (type) { case Timer.TYPE_CLOCK: return getClockTime(); break; case Timer.TYPE_PLAYING: return getPlayingTime(); break; case Timer.TYPE_RUNNING: return getRunningTime(); break; default: return getClockTime(); } }; /** * Set the clock time. * * @public * @param {number} time - The time in milliseconds. */ self.setClockTime = function (time) { if ($.type(time) === 'string') { time = Timer.toMilliseconds(time); } if (!Timer.isInteger(time)) { return; } // notifications only need an update if changing clock against direction clockUpdateMilliSeconds = (time - clockTimeMilliSeconds) * mode < 0 ? time - clockTimeMilliSeconds : 0; clockTimeMilliSeconds = time; }; /** * Reset the timer. * * @public */ self.reset = function () { if (status !== Timer.STOPPED) { return; } clockTimeMilliSeconds = 0; playingTimeMilliSeconds = 0; firstDate = null; lastDate = null; loop = null; notifications = []; notificationsIdCounter = 0; self.trigger('reset', {}, {bubbles: true, external: true}); }; /** * Set timer mode. * * @public * @param {number} mode - The timer mode. */ self.setMode = function (direction) { if (direction !== Timer.FORWARD && direction !== Timer.BACKWARD) { return; } mode = direction; }; /** * Start the timer. * * @public */ self.play = function () { if (status === Timer.PLAYING) { return; } if (!firstDate) { firstDate = new Date(); } startDate = new Date(); status = Timer.PLAYING; self.trigger('play', {}, {bubbles: true, external: true}); update(); }; /** * Pause the timer. * * @public */ self.pause = function () { if (status !== Timer.PLAYING) { return; } status = Timer.PAUSED; self.trigger('pause', {}, {bubbles: true, external: true}); }; /** * Stop the timer. * * @public */ self.stop = function () { if (status === Timer.STOPPED) { return; } lastDate = new Date(); status = Timer.STOPPED; self.trigger('stop', {}, {bubbles: true, external: true}); }; /** * Update the timer until Timer.STOPPED. * * @private */ var update = function update() { var currentMilliSeconds = 0; // stop because requested if (status === Timer.STOPPED) { clearTimeout(loop); return; } //stop because countdown reaches 0 if (mode === Timer.BACKWARD && clockTimeMilliSeconds <= 0) { self.stop(); return; } // update times if (status === Timer.PLAYING) { currentMilliSeconds = new Date().getTime() - startDate; clockTimeMilliSeconds += currentMilliSeconds * mode; playingTimeMilliSeconds += currentMilliSeconds; } startDate = new Date(); checkNotifications(); loop = setTimeout(function () { update(); }, interval); }; /** * Get next notification id. * * @private * @return {number} id - The next id. */ var getNextNotificationId = function getNextNotificationId() { return notificationsIdCounter++; }; /** * Set a notification * * @public * @param {Object|String} params - Parameters for the notification. * @callback callback - Callback function. * @return {number} ID of the notification. */ self.notify = function (params, callback) { var id = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : getNextNotificationId(); // common default values for the clock timer // TODO: find a better place for this, maybe a JSON file? var defaults = {}; defaults['every_tenth_second'] = { "repeat": 100 }; defaults['every_second'] = { "repeat": 1000 }; defaults['every_minute'] = { "repeat": 60000 }; defaults['every_hour'] = { "repeat": 3600000 }; // Sanity check for callback function if (!callback instanceof Function) { return; } // Get default values if ($.type(params) === 'string') { params = defaults[params]; } if (params !== null && (typeof params === 'undefined' ? 'undefined' : _typeof(params)) === 'object') { // Sanitize type if (!params.type) { params.type = Timer.TYPE_CLOCK; } else { if (!Timer.isInteger(params.type)) { return; } if (params.type < Timer.TYPE_CLOCK || params.type > Timer.TYPE_RUNNING) { return; } } // Sanitize mode if (!params.mode) { params.mode = Timer.NOTIFY_ABSOLUTE; } else { if (!Timer.isInteger(params.mode)) { return; } if (params.mode < Timer.NOTIFY_ABSOLUTE || params.type > Timer.NOTIFY_RELATIVE) { return; } } // Sanitize calltime if (!params.calltime) { params.calltime = params.mode === Timer.NOTIFY_ABSOLUTE ? self.getTime(params.type) : 0; } else { if ($.type(params.calltime) === 'string') { params.calltime = Timer.toMilliseconds(params.calltime); } if (!Timer.isInteger(params.calltime)) { return; } if (params.calltime < 0) { return; } if (params.mode === Timer.NOTIFY_RELATIVE) { params.calltime = Math.max(params.calltime, interval); if (params.type === Timer.TYPE_CLOCK) { // clock could be running backwards params.calltime *= mode; } params.calltime += self.getTime(params.type); } } // Sanitize repeat if ($.type(params.repeat) === 'string') { params.repeat = Timer.toMilliseconds(params.repeat); } // repeat must be >= interval (ideally multiple of interval) if (params.repeat !== undefined) { if (!Timer.isInteger(params.repeat)) { return; } params.repeat = Math.max(params.repeat, interval); } } else { // neither object nor string return; } // add notification notifications.push({ 'id': id, 'type': params.type, 'calltime': params.calltime, 'repeat': params.repeat, 'callback': callback }); return id; }; /** * Remove a notification. * * @public * @param {number} id - The id of the notification. */ self.clearNotification = function (id) { notifications = $.grep(notifications, function (item) { return item.id === id; }, true); }; /** * Set a new starting time for notifications. * * @private * @param elements {Object] elements - The notifications to be updated. * @param deltaMilliSeconds {Number} - The time difference to be set. */ var updateNotificationTime = function updateNotificationTime(elements, deltaMilliSeconds) { if (!Timer.isInteger(deltaMilliSeconds)) { return; } elements.forEach(function (element) { // remove notification self.clearNotification(element.id); //rebuild notification with new data self.notify({ 'type': element.type, 'calltime': self.getTime(element.type) + deltaMilliSeconds, 'repeat': element.repeat }, element.callback, element.id); }); }; /** * Check notifications for necessary callbacks. * * @private */ var checkNotifications = function checkNotifications() { var backwards = 1; var elements = []; // update recurring clock notifications if clock was changed if (clockUpdateMilliSeconds !== 0) { elements = $.grep(notifications, function (item) { return item.type === Timer.TYPE_CLOCK && item.repeat != undefined; }); updateNotificationTime(elements, clockUpdateMilliSeconds); clockUpdateMilliSeconds = 0; } // check all notifications for triggering notifications.forEach(function (element) { /* * trigger if notification time is in the past * which means calltime >= Clock Time if mode is BACKWARD (= -1) */ backwards = element.type === Timer.TYPE_CLOCK ? mode : 1; if (element.calltime * backwards <= self.getTime(element.type) * backwards) { // notify callback function element.callback.apply(this); // remove notification self.clearNotification(element.id); // You could use updateNotificationTime() here, but waste some time // rebuild notification if it should be repeated if (element.repeat) { self.notify({ 'type': element.type, 'calltime': self.getTime(element.type) + element.repeat * backwards, 'repeat': element.repeat }, element.callback, element.id); } } }); }; } // Inheritance Timer.prototype = Object.create(H5P.EventDispatcher.prototype); Timer.prototype.constructor = Timer; /** * Generate timecode elements from milliseconds. * * @private * @param {number} milliSeconds - The milliseconds. * @return {Object} The timecode elements. */ var toTimecodeElements = function toTimecodeElements(milliSeconds) { var years = 0; var month = 0; var weeks = 0; var days = 0; var hours = 0; var minutes = 0; var seconds = 0; var tenthSeconds = 0; if (!Timer.isInteger(milliSeconds)) { return; } milliSeconds = Math.round(milliSeconds / 100); tenthSeconds = milliSeconds - Math.floor(milliSeconds / 10) * 10; seconds = Math.floor(milliSeconds / 10); minutes = Math.floor(seconds / 60); hours = Math.floor(minutes / 60); days = Math.floor(hours / 24); weeks = Math.floor(days / 7); month = Math.floor(days / 30.4375); // roughly (30.4375 = mean of 4 years) years = Math.floor(days / 365); // roughly (no leap years considered) return { years: years, month: month, weeks: weeks, days: days, hours: hours, minutes: minutes, seconds: seconds, tenthSeconds: tenthSeconds }; }; /** * Extract humanized time element from time for concatenating. * * @public * @param {number} milliSeconds - The milliSeconds. * @param {string} element - Time element: hours, minutes, seconds or tenthSeconds. * @param {boolean} [rounded=false] - If true, element value will be rounded. * @return {number} The time element. */ Timer.extractTimeElement = function (time, element) { var rounded = arguments.length <= 2 || arguments[2] === undefined ? false : arguments[2]; var timeElements = null; if ($.type(time) === 'string') { time = Timer.toMilliseconds(time); } if (!Timer.isInteger(time)) { return; } if ($.type(element) !== 'string') { return; } if ($.type(rounded) !== 'boolean') { return; } if (rounded) { timeElements = { years: Math.round(time / 31536000000), month: Math.round(time / 2629800000), weeks: Math.round(time / 604800000), days: Math.round(time / 86400000), hours: Math.round(time / 3600000), minutes: Math.round(time / 60000), seconds: Math.round(time / 1000), tenthSeconds: Math.round(time / 100) }; } else { timeElements = toTimecodeElements(time); } return timeElements[element]; }; /** * Convert time in milliseconds to timecode. * * @public * @param {number} milliSeconds - The time in milliSeconds. * @return {string} The humanized timecode. */ Timer.toTimecode = function (milliSeconds) { var timecodeElements = null; var timecode = ''; var minutes = 0; var seconds = 0; if (!Timer.isInteger(milliSeconds)) { return; } if (milliSeconds < 0) { return; } timecodeElements = toTimecodeElements(milliSeconds); minutes = Math.floor(timecodeElements['minutes'] % 60); seconds = Math.floor(timecodeElements['seconds'] % 60); // create timecode if (timecodeElements['hours'] > 0) { timecode += timecodeElements['hours'] + ':'; } if (minutes < 10) { timecode += '0'; } timecode += minutes + ':'; if (seconds < 10) { timecode += '0'; } timecode += seconds + '.'; timecode += timecodeElements['tenthSeconds']; return timecode; }; /** * Convert timecode to milliseconds. * * @public * @param {string} timecode - The timecode. * @return {number} Milliseconds derived from timecode */ Timer.toMilliseconds = function (timecode) { var head = []; var tail = ''; var hours = 0; var minutes = 0; var seconds = 0; var tenthSeconds = 0; if (!Timer.isTimecode(timecode)) { return; } // thx to the regexp we know everything can be converted to a legit integer in range head = timecode.split('.')[0].split(':'); while (head.length < 3) { head = ['0'].concat(head); } hours = parseInt(head[0]); minutes = parseInt(head[1]); seconds = parseInt(head[2]); tail = timecode.split('.')[1]; if (tail) { tenthSeconds = Math.round(parseInt(tail) / Math.pow(10, tail.length - 1)); } return (hours * 36000 + minutes * 600 + seconds * 10 + tenthSeconds) * 100; }; /** * Check if a string is a timecode. * * @public * @param {string} value - String to check * @return {boolean} true, if string is a timecode */ Timer.isTimecode = function (value) { var reg_timecode = /((((((\d+:)?([0-5]))?\d:)?([0-5]))?\d)(\.\d+)?)/; if ($.type(value) !== 'string') { return false; } return value === value.match(reg_timecode)[0] ? true : false; }; // Workaround for IE and potentially other browsers within Timer object Timer.isInteger = Timer.isInteger || function(value) { return typeof value === "number" && isFinite(value) && Math.floor(value) === value; }; // Timer states /** @constant {number} */ Timer.STOPPED = 0; /** @constant {number} */ Timer.PLAYING = 1; /** @constant {number} */ Timer.PAUSED = 2; // Timer directions /** @constant {number} */ Timer.FORWARD = 1; /** @constant {number} */ Timer.BACKWARD = -1; /** @constant {number} */ Timer.DEFAULT_INTERVAL = 10; // Counter types /** @constant {number} */ Timer.TYPE_CLOCK = 0; /** @constant {number} */ Timer.TYPE_PLAYING = 1; /** @constant {number} */ Timer.TYPE_RUNNING = 2; // Notification types /** @constant {number} */ Timer.NOTIFY_ABSOLUTE = 0; /** @constant {number} */ Timer.NOTIFY_RELATIVE = 1; return Timer; }(H5P.jQuery, H5P.EventDispatcher); ; H5P.MemoryGame = (function (EventDispatcher, $) { // We don't want to go smaller than 100px per card(including the required margin) var CARD_MIN_SIZE = 100; // PX var CARD_STD_SIZE = 116; // PX var STD_FONT_SIZE = 16; // PX var LIST_PADDING = 1; // EMs var numInstances = 0; /** * Memory Game Constructor * * @class H5P.MemoryGame * @extends H5P.EventDispatcher * @param {Object} parameters * @param {Number} id * @param {Object} [extras] Saved state, metadata, etc. * @param {object} [extras.previousState] The previous state of the game */ function MemoryGame(parameters, id, extras) { /** @alias H5P.MemoryGame# */ var self = this; this.previousState = extras.previousState ?? {}; // Initialize event inheritance EventDispatcher.call(self); var flipped, timer, counter, popup, $bottom, $taskComplete, $feedback, $wrapper, maxWidth, numCols, audioCard; var cards = []; var score = 0; numInstances++; // Add defaults parameters = $.extend(true, { l10n: { cardTurns: 'Card turns', timeSpent: 'Time spent', feedback: 'Good work!', tryAgain: 'Reset', closeLabel: 'Close', label: 'Memory Game. Find the matching cards.', labelInstructions: 'Use arrow keys left and right to navigate cards. Use space or enter key to turn card.', done: 'All of the cards have been found.', cardPrefix: 'Card %num of %total:', cardUnturned: 'Unturned. Click to turn.', cardTurned: 'Turned.', cardMatched: 'Match found.', cardMatchedA11y: 'Your cards match!', cardNotMatchedA11y: 'Your chosen cards do not match. Turn other cards to try again.' } }, parameters); // Filter out invalid cards parameters.cards = (parameters.cards ?? []).filter((cardParams) => { return MemoryGame.Card.isValid(cardParams); }); /** * Get number of cards that are currently flipped and in game. * @returns {number} Number of cards that are currently flipped. */ var getNumFlipped = () => { return cards .filter((card) => card.isFlipped() && !card.isRemoved()) .length; }; /** * Check if these two cards belongs together. * * @private * @param {H5P.MemoryGame.Card} card * @param {H5P.MemoryGame.Card} mate * @param {H5P.MemoryGame.Card} correct */ var check = function (card, mate, correct) { if (mate !== correct) { ariaLiveRegion.read(parameters.l10n.cardNotMatchedA11y); return; } // Remove them from the game. card.remove(); mate.remove(); var isFinished = cards.every((card) => card.isRemoved()); var desc = card.getDescription(); if (desc !== undefined) { // Pause timer and show desciption. timer.pause(); var imgs = [card.getImage()]; if (card.hasTwoImages) { imgs.push(mate.getImage()); } // Keep message for dialog modal shorter without instructions $applicationLabel.html(parameters.l10n.label); popup.show(desc, imgs, cardStyles ? cardStyles.back : undefined, function (refocus) { if (isFinished) { // Game done card.makeUntabbable(); finished(); } else { // Popup is closed, continue. timer.play(); if (refocus) { card.setFocus(); } } }); } else if (isFinished) { // Game done card.makeUntabbable(); finished(); } }; /** * Game has finished! * @param {object} [params] Parameters. * @param {boolean} [params.restoring] True if restoring state. * @private */ var finished = function (params = {}) { if (!params.restoring) { timer.stop(); } $taskComplete.show(); $feedback.addClass('h5p-show'); // Announce setTimeout(function () { $bottom.focus(); }, 0); // Give closing dialog modal time to free screen reader score = 1; if (!params.restoring) { self.trigger(self.createXAPICompletedEvent()); } if (parameters.behaviour && parameters.behaviour.allowRetry) { // Create retry button self.retryButton = createButton('reset', parameters.l10n.tryAgain || 'Reset', function () { self.resetTask(true); }); self.retryButton.classList.add('h5p-memory-transin'); setTimeout(function () { // Remove class on nextTick to get transition effectupd self.retryButton.classList.remove('h5p-memory-transin'); }, 0); $wrapper[0].appendChild(self.retryButton); // Add to DOM self.trigger('resize'); } }; /** * Remove retry button. * @private */ const removeRetryButton = function () { if (!self.retryButton || self.retryButton.parentNode !== $wrapper[0]) { return; // Button not defined or attached to wrapper } self.retryButton.classList.add('h5p-memory-transout'); setTimeout(function () { // Remove button on nextTick to get transition effect $wrapper[0].removeChild(self.retryButton); }, 300); }; /** * Shuffle the cards and restart the game! * @private */ var resetGame = function (moveFocus = false) { // Reset cards score = 0; flipped = undefined; // Remove feedback $feedback[0].classList.remove('h5p-show'); $taskComplete.hide(); popup.close(); // Reset timer and counter timer.stop(); timer.reset(); counter.reset(); flipBackCards(); // Randomize cards H5P.shuffleArray(cards); setTimeout(function () { // Re-append to DOM after flipping back for (var i = 0; i < cards.length; i++) { cards[i].reAppend(); } for (var j = 0; j < cards.length; j++) { cards[j].reset(); } // Scale new layout $wrapper.children('ul').children('.h5p-row-break').removeClass('h5p-row-break'); maxWidth = -1; self.trigger('resize'); moveFocus && cards[0].setFocus(); }, 600); }; /** * Game has finished! * @private */ var createButton = function (name, label, action) { var buttonElement = document.createElement('div'); buttonElement.classList.add('h5p-memory-' + name); buttonElement.innerHTML = label; buttonElement.setAttribute('role', 'button'); buttonElement.tabIndex = 0; buttonElement.addEventListener('click', function () { action.apply(buttonElement); }, false); buttonElement.addEventListener('keypress', function (event) { if (event.which === 13 || event.which === 32) { // Enter or Space key event.preventDefault(); action.apply(buttonElement); } }, false); return buttonElement; }; /** * Flip back all cards unless pair found or excluded. * @param {object} [params] Parameters. * @param {H5P.MemoryGame.Card[]} [params.excluded] Cards to exclude from flip back. * @param {boolean} [params.keepPairs] True to keep pairs that were found. */ var flipBackCards = (params = {}) => { cards.forEach((card) => { params.excluded = params.excluded ?? []; params.keepPairs = params.keepPairs ?? false; if (params.excluded.includes(card)) { return; // Skip the card that was flipped } if (params.keepPairs) { const mate = getCardMate(card); if ( mate.isFlipped() && card.isFlipped() && !params.excluded.includes(mate) ) { return; } } card.flipBack(); }); }; /** * Get mate of a card. * @param {H5P.MemoryGame.Card} card Card. * @returns {H5P.MemoryGame.Card} Mate of the card. * @private */ var getCardMate = (card) => { const idSegments = card.getId().split('-'); return cards.find((mate) => { const mateIdSegments = mate.getId().split('-'); return ( idSegments[0] === mateIdSegments[0] && idSegments[1] !== mateIdSegments[1] ); }); } /** * Adds card to card list and set up a flip listener. * * @private * @param {H5P.MemoryGame.Card} card * @param {H5P.MemoryGame.Card} mate */ var addCard = function (card, mate) { card.on('flip', (event) => { self.answerGiven = true; if (getNumFlipped() === 3 && !event.data?.restoring) { // Flip back all cards except the one that was just flipped flipBackCards({ excluded: [card], keepPairs: true }); } if (audioCard) { audioCard.stopAudio(); } if (!event.data?.restoring) { popup.close(); self.triggerXAPI('interacted'); // Keep track of time spent timer.play(); } // Announce the card unless it's the last one and it's correct var isMatched = (flipped === mate); var isLast = cards.every((card) => card.isRemoved()); card.updateLabel(isMatched, !(isMatched && isLast)); if (flipped !== undefined) { var matie = flipped; // Reset the flipped card. flipped = undefined; if (!event.data?.restoring) { setTimeout(() => { check(card, matie, mate); }, 800); } } else { flipped = card; } if (!event.data?.restoring) { // Always return focus to the card last flipped for (var i = 0; i < cards.length; i++) { cards[i].makeUntabbable(); } (flipped || card).makeTabbable(); // Count number of cards turned counter.increment(); } }); card.on('audioplay', function () { if (audioCard) { audioCard.stopAudio(); } audioCard = card; }); card.on('audiostop', function () { audioCard = undefined; }); /** * Create event handler for moving focus to next available card i * given direction. * * @private * @param {number} direction Direction code, see MemoryGame.DIRECTION_x. * @return {function} Focus handler. */ var createCardChangeFocusHandler = function (direction) { return function () { // Get current card index const currentIndex = cards.map(function (card) { return card.isTabbable; }).indexOf(true); if (currentIndex === -1) { return; // No tabbable card found } // Skip cards that have already been removed from the game let adjacentIndex = currentIndex; do { adjacentIndex = getAdjacentCardIndex(adjacentIndex, direction); } while (adjacentIndex !== null && cards[adjacentIndex].isRemoved()); if (adjacentIndex === null) { return; // No card available in that direction } // Move focus cards[currentIndex].makeUntabbable(); cards[adjacentIndex].setFocus(); }; }; // Register handlers for moving focus in given direction card.on('up', createCardChangeFocusHandler(MemoryGame.DIRECTION_UP)); card.on('next', createCardChangeFocusHandler(MemoryGame.DIRECTION_RIGHT)); card.on('down', createCardChangeFocusHandler(MemoryGame.DIRECTION_DOWN)); card.on('prev', createCardChangeFocusHandler(MemoryGame.DIRECTION_LEFT)); /** * Create event handler for moving focus to the first or the last card * on the table. * * @private * @param {number} direction +1/-1 * @return {function} */ var createEndCardFocusHandler = function (direction) { return function () { var focusSet = false; for (var i = 0; i < cards.length; i++) { var j = (direction === -1 ? cards.length - (i + 1) : i); if (!focusSet && !cards[j].isRemoved()) { cards[j].setFocus(); focusSet = true; } else if (cards[j] === card) { card.makeUntabbable(); } } }; }; // Register handlers for moving focus to first and last card card.on('first', createEndCardFocusHandler(1)); card.on('last', createEndCardFocusHandler(-1)); cards.push(card); }; var cardStyles, invertShades; if (parameters.lookNFeel) { // If the contrast between the chosen color and white is too low we invert the shades to create good contrast invertShades = (parameters.lookNFeel.themeColor && getContrast(parameters.lookNFeel.themeColor) < 1.7 ? -1 : 1); var backImage = (parameters.lookNFeel.cardBack ? H5P.getPath(parameters.lookNFeel.cardBack.path, id) : null); cardStyles = MemoryGame.Card.determineStyles(parameters.lookNFeel.themeColor, invertShades, backImage); } // Determine number of cards to be used const numCardsToUse = Math.max( 2, parseInt(parameters.behaviour?.numCardsToUse ?? parameters.cards.length) ); // Create cards pool let cardsPool = parameters.cards .reduce((result, cardParams, index) => { // Create first card const cardOne = new MemoryGame.Card(cardParams.image, id, 2 * numCardsToUse, cardParams.imageAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.audio, `${index}-1`); let cardTwo; if (MemoryGame.Card.hasTwoImages(cardParams)) { // Use matching image for card two cardTwo = new MemoryGame.Card(cardParams.match, id, 2 * numCardsToUse, cardParams.matchAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.matchAudio, `${index}-2`); cardOne.hasTwoImages = cardTwo.hasTwoImages = true; } else { // Add two cards with the same image cardTwo = new MemoryGame.Card(cardParams.image, id, 2 * numCardsToUse, cardParams.imageAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.audio, `${index}-2`); } return [...result, cardOne, cardTwo]; }, []); let cardOrder; if (this.previousState.cards) { cardOrder = this.previousState.cards.map((cardState) => cardState.id); } else { while (cardsPool.length > 2 * numCardsToUse) { // Extract unique indexex from the current cardsPool const uniqueCardIndexes = Array.from(new Set(cardsPool.map(card => card.getId().split('-')[0]))); // Remove cards with randomly selected index const indexToRemove = uniqueCardIndexes[Math.floor(Math.random() * uniqueCardIndexes.length)]; cardsPool = cardsPool.filter(card => card.getId().split('-')[0] !== indexToRemove); } cardOrder = cardsPool.map((card) => card.getId()); H5P.shuffleArray(cardOrder); } // Create cards to be used in the game cardOrder.forEach((cardId) => { const card = cardsPool.find((card) => card.getId() === cardId); const matchId = (cardId.split('-')[1] === '1') ? cardId.replace('-1', '-2') : cardId.replace('-2', '-1') const match = cardsPool.find((card) => card.getId() === matchId); addCard(card, match); }); // Restore state of cards this.previousState.cards?.forEach((cardState) => { const card = cards.find((card) => card.getId() === cardState.id); if (!card) { return; } if (cardState.flipped) { card.flip({ restoring: true }); } if (cardState.removed) { card.remove(); } /* * Keep track of the flipped card. When restoring 1/3 flipped cards, * we need to ensure that the non-matching card is set as flipped */ if (getNumFlipped() % 2 === 1) { flipped = cards .filter((card) => { return card.isFlipped() && !getCardMate(card).isFlipped(); }) .shift(); } }); // Ensure all cards are removed if state was stored during flip time period if (cards.every((card) => card.isFlipped())) { cards.forEach((card) => card.remove()); } // Set score before DOM is attached to page if (cards.every((card) => card.isRemoved())) { score = 1; } // Build DOM elements to be attached later var $list = $('<ul/>', { role: 'application', 'aria-labelledby': 'h5p-intro-' + numInstances }); for (var i = 0; i < cards.length; i++) { cards[i].appendTo($list); } if (cards.length) { // Make first available card tabbable cards.filter((card) => !card.isRemoved())[0]?.makeTabbable(); $applicationLabel = $('<div/>', { id: 'h5p-intro-' + numInstances, 'class': 'h5p-memory-hidden-read', html: parameters.l10n.label + ' ' + parameters.l10n.labelInstructions, }); $bottom = $('<div/>', { 'class': 'h5p-programatically-focusable', tabindex: '-1', }); $taskComplete = $('<div/>', { 'class': 'h5p-memory-complete h5p-memory-hidden-read', html: parameters.l10n.done, appendTo: $bottom }); $feedback = $('<div class="h5p-feedback">' + parameters.l10n.feedback + '</div>').appendTo($bottom); // Add status bar var $status = $('<dl class="h5p-status">' + '<dt>' + parameters.l10n.timeSpent + ':</dt>' + '<dd class="h5p-time-spent"><time role="timer" datetime="PT0M0S">0:00</time><span class="h5p-memory-hidden-read">.</span></dd>' + '<dt>' + parameters.l10n.cardTurns + ':</dt>' + '<dd class="h5p-card-turns">0<span class="h5p-memory-hidden-read">.</span></dd>' + '</dl>').appendTo($bottom); timer = new MemoryGame.Timer( $status.find('time')[0], this.previousState.timer ?? 0 ); counter = new MemoryGame.Counter( $status.find('.h5p-card-turns'), this.previousState.counter ?? 0 ); popup = new MemoryGame.Popup(parameters.l10n); popup.on('closed', function () { // Add instructions back $applicationLabel.html(parameters.l10n.label + ' ' + parameters.l10n.labelInstructions); }); // Aria live region to politely read to screen reader ariaLiveRegion = new MemoryGame.AriaLiveRegion(); } else { const $foo = $('<div/>') .text('No card was added to the memory game!') .appendTo($list); $list.appendTo($wrapper); } /** * Attach this game's html to the given container. * * @param {H5P.jQuery} $container */ self.attach = function ($container) { this.triggerXAPI('attempted'); // TODO: Only create on first attach! $wrapper = $container.addClass('h5p-memory-game').html(''); if (invertShades === -1) { $container.addClass('h5p-invert-shades'); } if (cards.length) { $applicationLabel.appendTo($wrapper); $list.appendTo($wrapper); $bottom.appendTo($wrapper); popup.appendTo($wrapper); $wrapper.append(ariaLiveRegion.getDOM()); $wrapper.click(function () { popup.close(); }); } else { $list.appendTo($wrapper); } // resize to scale game size and check for finished game afterwards this.trigger('resize'); window.requestAnimationFrame(() => { if (cards.length && cards.every((card) => card.isRemoved())) { finished({ restoring: true }); } }); self.attached = true; /* * DOM is only created here in `attach`, so it cannot necessarily be reset * by `resetTask` if using MemoryGame as subcontent after resuming. */ if (this.shouldResetDOMOnAttach) { removeRetryButton(); resetGame(); this.shouldResetDOMOnAttach = false; } }; /** * Will try to scale the game so that it fits within its container. * Puts the cards into a grid layout to make it as square as possible – * which improves the playability on multiple devices. * * @private */ var scaleGameSize = function () { // Check how much space we have available var $list = $wrapper.children('ul'); var newMaxWidth = parseFloat(window.getComputedStyle($list[0]).width); if (maxWidth === newMaxWidth) { return; // Same size, no need to recalculate } else { maxWidth = newMaxWidth; } // Get the card holders var $elements = $list.children(); if ($elements.length < 4) { return; // No need to proceed } // Determine the optimal number of columns var newNumCols = Math.ceil(Math.sqrt($elements.length)); // Do not exceed the max number of columns var maxCols = Math.floor(maxWidth / CARD_MIN_SIZE); if (newNumCols > maxCols) { newNumCols = maxCols; } if (numCols !== newNumCols) { // We need to change layout numCols = newNumCols; // Calculate new column size in percentage and round it down (we don't // want things sticking out…) var colSize = Math.floor((100 / numCols) * 10000) / 10000; $elements.css('width', colSize + '%').each(function (i, e) { $(e).toggleClass('h5p-row-break', i === numCols); }); } // Calculate how much one percentage of the standard/default size is var onePercentage = ((CARD_STD_SIZE * numCols) + STD_FONT_SIZE) / 100; var paddingSize = (STD_FONT_SIZE * LIST_PADDING) / onePercentage; var cardSize = (100 - paddingSize) / numCols; var fontSize = (((maxWidth * (cardSize / 100)) * STD_FONT_SIZE) / CARD_STD_SIZE); // We use font size to evenly scale all parts of the cards. $list.css('font-size', fontSize + 'px'); popup.setSize(fontSize); // due to rounding errors in browsers the margins may vary a bit… }; /** * Get index of adjacent card. * * @private * @param {number} currentIndex Index of card to check adjacent card for. * @param {number} direction Direction code, cmp. MemoryGame.DIRECTION_x. * @returns {number|null} Index of adjacent card or null if not retrievable. */ const getAdjacentCardIndex = function (currentIndex, direction) { if ( typeof currentIndex !== 'number' || currentIndex < 0 || currentIndex > cards.length - 1 || ( direction !== MemoryGame.DIRECTION_UP && direction !== MemoryGame.DIRECTION_RIGHT && direction !== MemoryGame.DIRECTION_DOWN && direction !== MemoryGame.DIRECTION_LEFT ) ) { return null; // Parameters not valid } let adjacentIndex = null; if (direction === MemoryGame.DIRECTION_LEFT) { adjacentIndex = currentIndex - 1; } else if (direction === MemoryGame.DIRECTION_RIGHT) { adjacentIndex = currentIndex + 1; } else if (direction === MemoryGame.DIRECTION_UP) { adjacentIndex = currentIndex - numCols; } else if (direction === MemoryGame.DIRECTION_DOWN) { adjacentIndex = currentIndex + numCols; } return (adjacentIndex >= 0 && adjacentIndex < cards.length) ? adjacentIndex : null; // Out of bounds } if (parameters.behaviour && parameters.behaviour.useGrid && numCardsToUse) { self.on('resize', () => { scaleGameSize(); if (self.retryButton) { self.retryButton.style.fontSize = (parseFloat($wrapper.children('ul')[0].style.fontSize) * 0.75) + 'px'; } }); } /** * Determine whether the task was answered already. * @returns {boolean} True if answer was given by user, else false. * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-1} */ self.getAnswerGiven = () => { return self.answerGiven; } /** * Get the user's score for this task. * * @returns {Number} The current score. */ self.getScore = function () { return score; }; /** * Get the maximum score for this task. * * @returns {Number} The maximum score. */ self.getMaxScore = function () { return 1; }; /** * Create a 'completed' xAPI event object. * * @returns {Object} xAPI completed event */ self.createXAPICompletedEvent = function () { var completedEvent = self.createXAPIEventTemplate('completed'); completedEvent.setScoredResult(self.getScore(), self.getMaxScore(), self, true, true); completedEvent.data.statement.result.duration = 'PT' + (Math.round(timer.getTime() / 10) / 100) + 'S'; return completedEvent; } /** * Contract used by report rendering engine. * * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-6} * * @returns {Object} xAPI data */ self.getXAPIData = function () { var completedEvent = self.createXAPICompletedEvent(); return { statement: completedEvent.data.statement }; }; /** * Reset task. * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-5} */ self.resetTask = function (moveFocus = false) { if (self.attached) { removeRetryButton(); resetGame(moveFocus); } else { /* * DOM is only created in `attach`, so it cannot necessarily be reset * here if using MemoryGame as subcontent after resuming. Schedule for * when DOM is attached. */ this.shouldResetDOMOnAttach = true; } this.wasReset = true; this.answerGiven = false; this.previousState = {}; delete this.cardOrder; }; /** * Get current state. * @returns {object} Current state to be retrieved later. * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-7} */ self.getCurrentState = () => { if (!this.getAnswerGiven()) { return this.wasReset ? {} : undefined; } cardsState = cards.map((card) => { const flipped = card.isFlipped(); const removed = card.isRemoved(); return { id: card.getId(), // Just saving some bytes in user state database table ...(flipped && { flipped: flipped }), ...(removed && { removed: removed }) } }); return { timer: timer.getTime(), counter: counter.getCount(), cards: cardsState } } } // Extends the event dispatcher MemoryGame.prototype = Object.create(EventDispatcher.prototype); MemoryGame.prototype.constructor = MemoryGame; /** @constant {number} DIRECTION_UP Code for up. */ MemoryGame.DIRECTION_UP = 0; /** @constant {number} DIRECTION_LEFT Code for left. Legacy value. */ MemoryGame.DIRECTION_LEFT = -1; /** @constant {number} DIRECTION_DOWN Code for down. */ MemoryGame.DIRECTION_DOWN = 2; /** @constant {number} DIRECTION_DOWN Code for right. Legacy value. */ MemoryGame.DIRECTION_RIGHT = 1 /** * Determine color contrast level compared to white(#fff) * * @private * @param {string} color hex code * @return {number} From 1 to Infinity. */ var getContrast = function (color) { return 255 / ((parseInt(color.substring(1, 3), 16) * 299 + parseInt(color.substring(3, 5), 16) * 587 + parseInt(color.substring(5, 7), 16) * 144) / 1000); }; return MemoryGame; })(H5P.EventDispatcher, H5P.jQuery); ; (function (MemoryGame, EventDispatcher, $) { /** * @private * @constant {number} WCAG_MIN_CONTRAST_AA_LARGE Minimum contrast ratio. * @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html */ const WCAG_MIN_CONTRAST_AA_LARGE = 3; /** * Controls all the operations for each card. * * @class H5P.MemoryGame.Card * @extends H5P.EventDispatcher * @param {Object} image * @param {number} contentId * @param {number} cardsTotal Number of cards in total. * @param {string} alt * @param {Object} l10n Localization * @param {string} [description] * @param {Object} [styles] * @param {string} id Unique identifier for card including original+match info. */ MemoryGame.Card = function (image, contentId, cardsTotal, alt, l10n, description, styles, audio, id) { /** @alias H5P.MemoryGame.Card# */ var self = this; this.id = id; // Keep track of tabbable state self.isTabbable = false; // Initialize event inheritance EventDispatcher.call(self); let path, width, height, $card, $wrapper, $image, removedState, flippedState, audioPlayer; /** * Process HTML escaped string for use as attribute value, * e.g. for alt text or title attributes. * * @param {string} value * @return {string} WARNING! Do NOT use for innerHTML. */ const massageAttributeOutput = (value = 'Missing description') => { const dparser = new DOMParser().parseFromString(value, 'text/html'); const div = document.createElement('div'); div.innerHTML = dparser.documentElement.textContent;; return div.textContent || div.innerText; }; self.buildDOM = () => { $wrapper = $('<li class="h5p-memory-wrap" tabindex="-1" role="button"><div class="h5p-memory-card">' + '<div class="h5p-front"' + (styles && styles.front ? styles.front : '') + '>' + (styles && styles.backImage ? '' : '<span></span>') + '</div>' + '<div class="h5p-back"' + (styles && styles.back ? styles.back : '') + '>' + (path ? '<img src="' + path + '" alt="" style="width:' + width + ';height:' + height + '"/>' + (audioPlayer ? '<div class="h5p-memory-audio-button"></div>' : '') : '<i class="h5p-memory-audio-instead-of-image">') + '</div>' + '</div></li>'); $wrapper.on('keydown', (event) => { switch (event.code) { case 'Enter': case 'Space': self.flip(); event.preventDefault(); return; case 'ArrowRight': // Move focus forward self.trigger('next'); event.preventDefault(); return; case 'ArrowDown': // Move focus down self.trigger('down'); event.preventDefault(); return; case 'ArrowLeft': // Move focus back self.trigger('prev'); event.preventDefault(); return; case 'ArrowUp': // Up // Move focus up self.trigger('up'); event.preventDefault(); return; case 'End': // Move to last card self.trigger('last'); event.preventDefault(); return; case 'Home': // Move to first card self.trigger('first'); event.preventDefault(); return; } }); $image = $wrapper.find('img'); $card = $wrapper.children('.h5p-memory-card') .children('.h5p-front') .click(function () { self.flip(); }) .end(); if (audioPlayer) { $card.children('.h5p-back') .click(function () { if ($card.hasClass('h5p-memory-audio-playing')) { self.stopAudio(); } else { audioPlayer.play(); } }) } } // alt = alt || 'Missing description'; // Default for old games alt = massageAttributeOutput(alt); if (image && image.path) { path = H5P.getPath(image.path, contentId); if (image.width !== undefined && image.height !== undefined) { if (image.width > image.height) { width = '100%'; height = 'auto'; } else { height = '100%'; width = 'auto'; } } else { width = height = '100%'; } } if (audio) { // Check if browser supports audio. audioPlayer = document.createElement('audio'); if (audioPlayer.canPlayType !== undefined) { // Add supported source files. for (var i = 0; i < audio.length; i++) { if (audioPlayer.canPlayType(audio[i].mime)) { var source = document.createElement('source'); source.src = H5P.getPath(audio[i].path, contentId); source.type = audio[i].mime; audioPlayer.appendChild(source); } } } if (!audioPlayer.children.length) { audioPlayer = null; // Not supported } else { audioPlayer.controls = false; audioPlayer.preload = 'auto'; var handlePlaying = function () { if ($card) { $card.addClass('h5p-memory-audio-playing'); self.trigger('audioplay'); } }; var handleStopping = function () { if ($card) { $card.removeClass('h5p-memory-audio-playing'); self.trigger('audiostop'); } }; audioPlayer.addEventListener('play', handlePlaying); audioPlayer.addEventListener('ended', handleStopping); audioPlayer.addEventListener('pause', handleStopping); } } this.buildDOM(); /** * Get id of the card. * @returns {string} The id of the card. (originalIndex-sideNumber) */ this.getId = () => { return self.id; }; /** * Update the cards label to make it accessible to users with a readspeaker * * @param {boolean} isMatched The card has been matched * @param {boolean} announce Announce the current state of the card * @param {boolean} reset Go back to the default label */ self.updateLabel = function (isMatched, announce, reset) { // Determine new label from input params const imageAlt = alt ? ` ${alt}`: ''; let label = reset ? l10n.cardUnturned : `${l10n.cardTurned}${imageAlt}`; if (isMatched) { label = l10n.cardMatched + ' ' + label; } // Update the card's label $wrapper.attr('aria-label', l10n.cardPrefix .replace('%num', $wrapper.index() + 1) .replace('%total', cardsTotal) + ' ' + label); // Update disabled property $wrapper.attr('aria-disabled', reset ? null : 'true'); // Announce the label change if (announce) { $wrapper.blur().focus(); // Announce card label } }; /** * Flip card. * * Win 11 screen reader announces image's alt tag even though it never gets * focus and button provides aria-label. Therefore alt tag is only set when * card is turned. * @param {object} [params] Parameters. * @param {boolean} [params.restoring] True if card is being restored from a saved state. */ self.flip = function (params = {}) { if (flippedState) { $wrapper.blur().focus(); // Announce card label again return; } $card.addClass('h5p-flipped'); $image.attr('alt', alt); flippedState = true; if (audioPlayer && !params.restoring) { audioPlayer.play(); } this.trigger('flip', { restoring: params.restoring }); }; /** * Flip card back. */ self.flipBack = function () { self.stopAudio(); self.updateLabel(null, null, true); // Reset card label $card.removeClass('h5p-flipped'); $image.attr('alt', ''); flippedState = false; }; /** * Remove. */ self.remove = function () { $card.addClass('h5p-matched'); removedState = true; }; /** * Reset card to natural state */ self.reset = function () { self.stopAudio(); self.updateLabel(null, null, true); // Reset card label flippedState = false; removedState = false; $card[0].classList.remove('h5p-flipped', 'h5p-matched'); }; /** * Get card description. * * @returns {string} */ self.getDescription = function () { return description; }; /** * Get image clone. * * @returns {H5P.jQuery} */ self.getImage = function () { return $card.find('img').clone(); }; /** * Append card to the given container. * * @param {H5P.jQuery} $container */ self.appendTo = function ($container) { $wrapper.appendTo($container); $wrapper.attr( 'aria-label', l10n.cardPrefix .replace('%num', $wrapper.index() + 1) .replace('%total', cardsTotal) + ' ' + l10n.cardUnturned ); }; /** * Re-append to parent container. */ self.reAppend = function () { var parent = $wrapper[0].parentElement; parent.appendChild($wrapper[0]); }; /** * Make the card accessible when tabbing */ self.makeTabbable = function () { if ($wrapper) { $wrapper.attr('tabindex', '0'); this.isTabbable = true; } }; /** * Prevent tabbing to the card */ self.makeUntabbable = function () { if ($wrapper) { $wrapper.attr('tabindex', '-1'); this.isTabbable = false; } }; /** * Make card tabbable and move focus to it */ self.setFocus = function () { self.makeTabbable(); if ($wrapper) { $wrapper.focus(); } }; /** * Check if the card has been removed from the game, i.e. if has * been matched. */ this.isRemoved = () => { return removedState ?? false; }; /** * Determine whether card is flipped or not. * @returns {boolean} True if card is flipped, else false. */ this.isFlipped = () => { return flippedState ?? false; } /** * Stop any audio track that might be playing. */ self.stopAudio = function () { if (audioPlayer) { audioPlayer.pause(); audioPlayer.currentTime = 0; } }; }; // Extends the event dispatcher MemoryGame.Card.prototype = Object.create(EventDispatcher.prototype); MemoryGame.Card.prototype.constructor = MemoryGame.Card; /** * Check to see if the given object corresponds with the semantics for * a memory game card. * * @param {object} params * @returns {boolean} */ MemoryGame.Card.isValid = function (params) { return (params !== undefined && (params.image !== undefined && params.image.path !== undefined) || params.audio); }; /** * Checks to see if the card parameters should create cards with different * images. * * @param {object} params * @returns {boolean} */ MemoryGame.Card.hasTwoImages = function (params) { return (params !== undefined && (params.match !== undefined && params.match.path !== undefined) || params.matchAudio); }; /** * Determines the theme for how the cards should look * * @param {string} color The base color selected * @param {number} invertShades Factor used to invert shades in case of bad contrast */ MemoryGame.Card.determineStyles = function (color, invertShades, backImage) { var styles = { front: '', back: '', backImage: !!backImage }; // Create color theme if (color) { const frontColor = shadeEnforceContrast(color, 43.75 * invertShades); const backColor = shade(frontColor, 12.75 * invertShades); styles.front += 'color:' + color + ';' + 'background-color:' + frontColor + ';' + 'border-color:' + frontColor +';'; styles.back += 'color:' + color + ';' + 'background-color:' + backColor + ';' + 'border-color:' + frontColor +';'; } // Add back image for card if (backImage) { var backgroundImage = "background-image:url('" + backImage + "')"; styles.front += backgroundImage; styles.back += backgroundImage; } // Prep style attribute if (styles.front) { styles.front = ' style="' + styles.front + '"'; } if (styles.back) { styles.back = ' style="' + styles.back + '"'; } return styles; }; /** * Get RGB color components from color hex value. * * @private * @param {string} color Color as hex value, e.g. '#123456`. * @returns {number[]} Red, green, blue color component as integer from 0-255. */ const getRGB = function (color) { return [ parseInt(color.substring(1, 3), 16), parseInt(color.substring(3, 5), 16), parseInt(color.substring(5, 7), 16) ]; } /** * Compute luminance for color. * * @private * @see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef * @param {string} color Color as hex value, e.g. '#123456`. * @returns {number} Luminance, [0-1], 0 = lightest, 1 = darkest. */ const computeLuminance = function (color) { const rgba = getRGB(color) .map(function (v) { v = v / 255; return v < 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return rgba[0] * 0.2126 + rgba[1] * 0.7152 + rgba[2] * 0.0722; } /** * Compute relative contrast between two colors. * * @private * @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html * @param {string} color1 Color as hex value, e.g. '#123456`. * @param {string} color2 Color as hex value, e.g. '#123456`. * @returns {number} Contrast, [1-21], 1 = no contrast, 21 = max contrast. */ const computeContrast = function (color1, color2) { const luminance1 = computeLuminance(color1); const luminance2 = computeLuminance(color2); return ( (Math.max(luminance1, luminance2) + 0.05) / (Math.min(luminance1, luminance2) + 0.05) ) } /** * Use shade function, but enforce minimum contrast * * @param {string} color Color as hex value, e.g. '#123456`. * @param {number} percent Shading percentage. * @returns {string} Color as hex value, e.g. '#123456`. */ const shadeEnforceContrast = function (color, percent) { let shadedColor; do { shadedColor = shade(color, percent); if (shadedColor === '#ffffff' || shadedColor === '#000000') { // Cannot brighten/darken, make original color 5% points darker/brighter color = shade(color, -5 * Math.sign(percent)); } else { // Increase shading by 5 percent percent = percent * 1.05; } } while (computeContrast(color, shadedColor) < WCAG_MIN_CONTRAST_AA_LARGE); return shadedColor; } /** * Convert hex color into shade depending on given percent * * @private * @param {string} color * @param {number} percent * @return {string} new color */ var shade = function (color, percent) { var newColor = '#'; // Determine if we should lighten or darken var max = (percent < 0 ? 0 : 255); // Always stay positive if (percent < 0) { percent *= -1; } percent /= 100; for (var i = 1; i < 6; i += 2) { // Grab channel and convert from hex to dec var channel = parseInt(color.substring(i, i + 2), 16); // Calculate new shade and convert back to hex channel = (Math.round((max - channel) * percent) + channel).toString(16); // Make sure to always use two digits newColor += (channel.length < 2 ? '0' + channel : channel); } return newColor; }; })(H5P.MemoryGame, H5P.EventDispatcher, H5P.jQuery); ; (function (MemoryGame) { /** * Keeps track of the number of cards that has been turned * * @class H5P.MemoryGame.Counter * @param {H5P.jQuery} $container */ MemoryGame.Counter = function ($container, startValue = 0) { /** @alias H5P.MemoryGame.Counter# */ var self = this; var current = startValue; /** * @private */ self.update = function () { $container[0].innerText = current; }; /** * Get current count. * @returns {number} Current count. */ self.getCount = () => { return current; } /** * Increment the counter. */ self.increment = function () { current++; self.update(); }; /** * Revert counter back to its natural state */ self.reset = function () { current = 0; self.update(); }; self.update(); }; })(H5P.MemoryGame); ; (function (MemoryGame, EventDispatcher, $) { /** * A dialog for reading the description of a card. * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/ * * @class H5P.MemoryGame.Popup * @extends H5P.EventDispatcher * @param {Object.<string, string>} l10n */ MemoryGame.Popup = function (l10n) { // Initialize event inheritance EventDispatcher.call(this); /** @alias H5P.MemoryGame.Popup# */ var self = this; var closed; const $popup = $( '<div class="h5p-memory-obscure-content"><div class="h5p-memory-pop" role="dialog" aria-modal="true"><div class="h5p-memory-top"></div><div class="h5p-memory-desc h5p-programatically-focusable" tabindex="-1"></div><div class="h5p-memory-close" role="button" tabindex="0" title="' + (l10n.closeLabel || 'Close') + '" aria-label="' + (l10n.closeLabel || 'Close') + '"></div></div></div>' ) .on('keydown', function (event) { if (event.code === 'Escape') { self.close(true); event.preventDefault(); } }) .hide(); const $top = $popup.find('.h5p-memory-top'); // Hook up the close button const $closeButton = $popup .find('.h5p-memory-close') .on('click', function () { self.close(true); }) .on('keydown', function (event) { if (event.code === 'Enter' || event.code === 'Space') { self.close(true); event.preventDefault(); } else if (event.code === 'Tab') { event.preventDefault(); // Lock focus } }); const $desc = $popup .find('.h5p-memory-desc') .on('keydown', function (event) { if (event.code === 'Tab') { // Keep focus inside dialog $closeButton.focus(); event.preventDefault(); } }); /** * Append the popup to a container. * @param {H5P.jQuery} $container Container to append to. */ this.appendTo = ($container) => { $container.append($popup); }; /** * Show the popup. * * @param {string} desc * @param {H5P.jQuery[]} imgs * @param {function} done */ self.show = function (desc, imgs, styles, done) { const announcement = '<span class="h5p-memory-screen-reader">' + l10n.cardMatchedA11y + '</span>' + desc; $desc.html(announcement); $top.html('').toggleClass('h5p-memory-two-images', imgs.length > 1); for (var i = 0; i < imgs.length; i++) { $('<div class="h5p-memory-image"' + (styles ? styles : '') + '></div>').append(imgs[i]).appendTo($top); } $popup.show(); $desc.focus(); closed = done; }; /** * Close the popup. * * @param {boolean} refocus Sets focus after closing the dialog */ self.close = function (refocus) { if (closed !== undefined) { $popup.hide(); closed(refocus); closed = undefined; self.trigger('closed'); } }; /** * Sets popup size relative to the card size * * @param {number} fontSize */ self.setSize = function (fontSize) { // Set image size $top[0].style.fontSize = fontSize + 'px'; // Determine card size var cardSize = fontSize * 6.25; // From CSS // Set popup size $popup[0].style.minWidth = (cardSize * 2.5) + 'px'; $popup[0].style.minHeight = cardSize + 'px'; }; }; })(H5P.MemoryGame, H5P.EventDispatcher, H5P.jQuery); ; (function (MemoryGame, Timer) { /** * Adapter between memory game and H5P.Timer * * @class H5P.MemoryGame.Timer * @extends H5P.Timer * @param {Element} element */ MemoryGame.Timer = function (element, startValue = 0) { /** @alias H5P.MemoryGame.Timer# */ var self = this; // Initialize event inheritance Timer.call(self, 100); this.setClockTime(startValue); /** @private {string} */ var naturalState = element.innerText; /** * Set up callback for time updates. * Formats time stamp for humans. * * @private */ var update = function () { var time = self.getTime(); var minutes = Timer.extractTimeElement(time, 'minutes'); var seconds = Timer.extractTimeElement(time, 'seconds') % 60; // Update duration attribute element.setAttribute('datetime', 'PT' + minutes + 'M' + seconds + 'S'); // Add leading zero if (seconds < 10) { seconds = '0' + seconds; } element.innerText = minutes + ':' + seconds; }; // Setup default behavior self.notify('every_tenth_second', update); self.on('reset', function () { element.innerText = naturalState; self.notify('every_tenth_second', update); }); update(); }; // Inheritance MemoryGame.Timer.prototype = Object.create(Timer.prototype); MemoryGame.Timer.prototype.constructor = MemoryGame.Timer; })(H5P.MemoryGame, H5P.Timer); ; (function (MemoryGame) { /** * Aria live region for reading to screen reader. * * @class H5P.MemoryGame.Popup */ MemoryGame.AriaLiveRegion = function () { let readText, timeout = null; // Build dom with defaults const dom = document.createElement('div'); dom.classList.add('h5p-memory-aria-live-region'); dom.setAttribute('aria-live', 'polite'); dom.style.height = '1px'; dom.style.overflow = 'hidden'; dom.style.position = 'absolute'; dom.style.textIndent = '1px'; dom.style.top = '-1px'; dom.style.width = '1px'; /** * Get DOM of aria live region. * * @returns {HTMLElement} DOM of aria live region. */ this.getDOM = function () { return dom; } /** * Set class if default CSS values do not suffice. * * @param {string} className Class name to set. Add CSS elsewhere. */ this.setClass = function(className) { if (typeof className !== 'string') { return; } // Remove default values dom.style.height = ''; dom.style.overflow = ''; dom.style.position = ''; dom.style.textIndent = ''; dom.style.top = ''; dom.style.width = ''; dom.classList = className; } /** * Read text via aria live region. * * @param {string} text Text to read. */ this.read = function (text) { if (readText) { const lastChar = readText .substring(readText.length - 1); readText = [`${readText}${lastChar === '.' ? '' : '.'}`, text] .join(' '); } else { readText = text; } dom.innerText = readText; window.clearTimeout(timeout); timeout = window.setTimeout(function () { readText = null; dom.innerText = ''; }, 100); } } })(H5P.MemoryGame); ;