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 */ function MemoryGame(parameters, id) { /** @alias H5P.MemoryGame# */ var self = this; // Initialize event inheritance EventDispatcher.call(self); var flipped, timer, counter, popup, $bottom, $taskComplete, $feedback, $wrapper, maxWidth, numCols; var cards = []; var flipBacks = []; // Que of cards to be flipped back var numFlipped = 0; var removed = 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.', done: 'All of the cards have been found.', cardPrefix: 'Card %num: ', cardUnturned: 'Unturned.', cardMatched: 'Match found.' } }, parameters); /** * 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) { // Incorrect, must be scheduled for flipping back flipBacks.push(card); flipBacks.push(mate); // Wait for next click to flip them back… if (numFlipped > 2) { // or do it straight away processFlipBacks(); } return; } // Update counters numFlipped -= 2; removed += 2; var isFinished = (removed === cards.length); // Remove them from the game. card.remove(!isFinished); mate.remove(); 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()); } 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! * @private */ var finished = function () { timer.stop(); $taskComplete.show(); $feedback.addClass('h5p-show'); // Announce $bottom.focus(); // Create and trigger xAPI event 'completed' var completedEvent = self.createXAPIEventTemplate('completed'); completedEvent.setScoredResult(1, 1, self, true, true); completedEvent.data.statement.result.duration = 'PT' + (Math.round(timer.getTime() / 10) / 100) + 'S'; self.trigger(completedEvent); if (parameters.behaviour && parameters.behaviour.allowRetry) { // Create retry button var retryButton = createButton('reset', parameters.l10n.tryAgain || 'Reset', function () { // Trigger handler (action) retryButton.classList.add('h5p-memory-transout'); setTimeout(function () { // Remove button on nextTick to get transition effect $wrapper[0].removeChild(retryButton); }, 300); resetGame(); }); retryButton.classList.add('h5p-memory-transin'); setTimeout(function () { // Remove class on nextTick to get transition effectupd retryButton.classList.remove('h5p-memory-transin'); }, 0); // Same size as cards retryButton.style.fontSize = (parseFloat($wrapper.children('ul')[0].style.fontSize) * 0.75) + 'px'; $wrapper[0].appendChild(retryButton); // Add to DOM } }; /** * Shuffle the cards and restart the game! * @private */ var resetGame = function () { // Reset cards removed = 0; // Remove feedback $feedback[0].classList.remove('h5p-show'); $taskComplete.hide(); // Reset timer and counter timer.reset(); counter.reset(); // 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'); 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; }; /** * 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', function () { // Always return focus to the card last flipped for (var i = 0; i < cards.length; i++) { cards[i].makeUntabbable(); } card.makeTabbable(); popup.close(); self.triggerXAPI('interacted'); // Keep track of time spent timer.play(); // Keep track of the number of flipped cards numFlipped++; // Announce the card unless it's the last one and it's correct var isMatched = (flipped === mate); var isLast = ((removed + 2) === cards.length); card.updateLabel(isMatched, !(isMatched && isLast)); if (flipped !== undefined) { var matie = flipped; // Reset the flipped card. flipped = undefined; setTimeout(function () { check(card, matie, mate); }, 800); } else { if (flipBacks.length > 1) { // Turn back any flipped cards processFlipBacks(); } // Keep track of the flipped card. flipped = card; } // Count number of cards turned counter.increment(); }); /** * Create event handler for moving focus to the next or the previous * card on the table. * * @private * @param {number} direction +1/-1 * @return {function} */ var createCardChangeFocusHandler = function (direction) { return function () { // Locate next card for (var i = 0; i < cards.length; i++) { if (cards[i] === card) { // Found current card var nextCard, fails = 0; do { fails++; nextCard = cards[i + (direction * fails)]; if (!nextCard) { return; // No more cards } } while (nextCard.isRemoved()); card.makeUntabbable(); nextCard.setFocus(); return; } } }; }; // Register handlers for moving focus to next and previous card card.on('next', createCardChangeFocusHandler(1)); card.on('prev', createCardChangeFocusHandler(-1)); /** * 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); }; /** * Will flip back two and two cards */ var processFlipBacks = function () { flipBacks[0].flipBack(); flipBacks[1].flipBack(); flipBacks.splice(0, 2); numFlipped -= 2; }; /** * @private */ var getCardsToUse = function () { var numCardsToUse = (parameters.behaviour && parameters.behaviour.numCardsToUse ? parseInt(parameters.behaviour.numCardsToUse) : 0); if (numCardsToUse <= 2 || numCardsToUse >= parameters.cards.length) { // Use all cards return parameters.cards; } // Pick random cards from pool var cardsToUse = []; var pickedCardsMap = {}; var numPicket = 0; while (numPicket < numCardsToUse) { var pickIndex = Math.floor(Math.random() * parameters.cards.length); if (pickedCardsMap[pickIndex]) { continue; // Already picked, try again! } cardsToUse.push(parameters.cards[pickIndex]); pickedCardsMap[pickIndex] = true; numPicket++; } return cardsToUse; }; 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); } // Initialize cards. var cardsToUse = getCardsToUse(); for (var i = 0; i < cardsToUse.length; i++) { var cardParams = cardsToUse[i]; if (MemoryGame.Card.isValid(cardParams)) { // Create first card var cardTwo, cardOne = new MemoryGame.Card(cardParams.image, id, cardParams.imageAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.audio); if (MemoryGame.Card.hasTwoImages(cardParams)) { // Use matching image for card two cardTwo = new MemoryGame.Card(cardParams.match, id, cardParams.matchAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.matchAudio); cardOne.hasTwoImages = cardTwo.hasTwoImages = true; } else { // Add two cards with the same image cardTwo = new MemoryGame.Card(cardParams.image, id, cardParams.imageAlt, parameters.l10n, cardParams.description, cardStyles, cardParams.audio); } // Add cards to card list for shuffeling addCard(cardOne, cardTwo); addCard(cardTwo, cardOne); } } H5P.shuffleArray(cards); /** * 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'); } // Add cards to list var $list = $('