From 667724dae6e216ea08675d3742f4014560c78e09 Mon Sep 17 00:00:00 2001 From: Oliver Tacke Date: Thu, 4 Jan 2024 16:47:52 +0100 Subject: [PATCH] Add global timer option --- language/.en.json | 18 ++++- language/de.json | 18 ++++- language/es-mx.json | 16 ++++ language/es.json | 18 ++++- language/eu.json | 18 ++++- language/gl.json | 18 ++++- language/lt.json | 16 ++++ language/zh-cn.json | 16 ++++ language/zh-hans.json | 16 ++++ language/zh.json | 16 ++++ semantics.json | 30 +++++++- .../exercise-screen/exercise-screen.js | 35 ++------- .../exercise-screen/timer-display.js | 13 +--- src/scripts/components/main.js | 32 +++++++- src/scripts/components/map/stage/stage.js | 22 +----- .../components/mixins/main-initialization.js | 14 +++- .../mixins/main-question-type-contract.js | 3 +- src/scripts/components/mixins/main-timer.js | 73 +++++++++++++++++++ .../mixins/main-user-confirmation.js | 5 +- .../status-containers/status-container.js | 9 +++ .../status-containers/status-container.scss | 20 +++++ .../status-containers/status-containers.js | 13 ++++ .../status-containers/status-containers.scss | 2 + src/scripts/components/toolbar/toolbar.js | 45 +++++++++++- src/scripts/components/toolbar/toolbar.scss | 7 ++ src/scripts/h5p-game-map.js | 19 ----- src/scripts/mixins/question-type-contract.js | 20 +++++ src/scripts/services/animate.js | 37 ++++++++-- src/scripts/services/timer.js | 33 ++++++++- 29 files changed, 505 insertions(+), 97 deletions(-) create mode 100644 src/scripts/components/mixins/main-timer.js diff --git a/language/.en.json b/language/.en.json index b2b35e5..0e582a7 100644 --- a/language/.en.json +++ b/language/.en.json @@ -338,6 +338,14 @@ "label": "Finish score", "description": "Optional score that can be lower than the summed maximum score of all exercises, so users can receive full score without completing all exercises." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Timeout warning time", + "description": "Optionally set when a timeout warning audio should be played (number of remaining seconds). An audio needs to be set in the audio settings." + }, { "label": "Map", "fields": [ @@ -406,6 +414,10 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Dialog header finish map", "default": "Finish map?" @@ -451,6 +463,10 @@ "label": "Dialog text game over", "default": "You have lost all your lives. Please try again!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Dialog header time out", "default": "Time out!" @@ -593,4 +609,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/language/de.json b/language/de.json index 691d968..573a8c9 100644 --- a/language/de.json +++ b/language/de.json @@ -338,12 +338,20 @@ "label": "Punktzahl zum Beenden", "description": "Optionale Punktzahl, die kleiner sein kann als die eigentlich durch alle Aufgaben erreichbaren Punkte, so dass Nutzerinnen und Nutzer auch volle Punktzahl erreichen können, ohne alle Etappen zu absolvieren." }, + { + "label": "Globales Zeitlimit", + "description": "Du kannst optional ein Zeitlimit in Sekunden setzen. Wenn die Nutzerinnen und Nutzer dieses Limit überschreiten, ist das Spiel sofort vorbei." + }, + { + "label": "Zeitlimitwarnung", + "description": "Du kannst optional einstellen, wann ein Tonsignal als Warnung ertönen soll (Anzahl der verbleibenden Sekunden). Eine Tondatei muss in den Audioeinstellungen dafür hinterlegt sein." + }, { "label": "Karte", "fields": [ { "label": "Zeige Etappenbeschriftungen", - "description": "Wähle, ob die Beschriftung einer Etappe während des Schwebens mit der Maus angezeit wird. Die Beschriftung wir auf Geräten mit \"Touch\"-Bedienung nicht angezeit." + "description": "Wähle, ob die Beschriftung einer Etappe während des Schwebens mit der Maus angezeigt wird. Die Beschriftung wir auf Geräten mit \"Touch\"-Bedienung nicht angezeit." }, { "label": "Umherwandern", @@ -406,6 +414,10 @@ "label": "Volle Punktzahl, aber keine Leben mehr", "default": "Du hast die volle Punktzahl erreicht, aber alle deine Leben verloren!" }, + { + "label": "Volle Punktzahl, aber Zeitüberschreitung", + "default": "Du hast die volle Punktzahl erreicht, aber die verfügbare Zeit überschritten!" + }, { "label": "Dialogüberschrift Karte abgeschlossen", "default": "Karte beenden?" @@ -451,6 +463,10 @@ "label": "Dialogtext Game Over", "default": "Du hast alle deine Leben verloren. Versuche es noch einmal!" }, + { + "label": "Dialogtext Game Over durch Zeitüberschreitung", + "default": "Dir ist die Zeit ausgegangen. Versuche es noch einmal!" + }, { "label": "Dialogüberschrift Zeitüberschreitung", "default": "Zeit abgelaufen!" diff --git a/language/es-mx.json b/language/es-mx.json index 5446a38..92ce5d2 100644 --- a/language/es-mx.json +++ b/language/es-mx.json @@ -338,6 +338,14 @@ "label": "Puntaje final", "description": "Puntaje opcional que puede ser menor que el puntaje máximo sumado de todos los ejercicios, de forma tal que los usuarios puedan recibir puntaje total sin completar todos los ejercicios." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Hora de advertencia de tiempo agotado", + "description": "Opcionalmente configurar cuando debería de oírse una advertencia de audio (número de segundos restantes) de que se agota el tiempo. Un audio necesita configurarse en las configuraciones de audio." + }, { "label": "Mapa", "fields": [ @@ -406,6 +414,10 @@ "label": "Puntaje completo, pero sin vidas restantes", "default": "¡Usted ha obtenido puntaje completo, pero perdió todas sus vidas!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Encabezado de diálogo de mapa terminado", "default": "¿Terminar mapa?" @@ -451,6 +463,10 @@ "label": "Texto del diálogo fin del juego", "default": "Usted ha perdido todas sus vidas. ¡Por favor inténtelo de nuevo!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Encabezado de diálogo de tiempo agotado", "default": "¡Tiempo agotado!" diff --git a/language/es.json b/language/es.json index fb216d7..17822ea 100644 --- a/language/es.json +++ b/language/es.json @@ -338,6 +338,14 @@ "label": "Puntuación final", "description": "Puntuación opcional que puede ser más baja que la puntuación máxima sumada de todos los ejercicios, de manera que los usuarios puedan recibir la puntuación completa sin completar todos los ejercicios." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Tiempo de advertencia de límite de tiempo", + "description": "Configure opcionalmente cuándo debe reproducirse un audio de advertencia de límite de tiempo (número de segundos restantes). Es necesario configurar un audio en la configuración de audio." + }, { "label": "Mapa", "fields": [ @@ -406,6 +414,10 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Encabezado del cuadro de diálogo de finalización de mapa", "default": "¿Terminar mapa?" @@ -451,6 +463,10 @@ "label": "Texto del diálogo de juego terminado", "default": "Has perdido todas tus vidas. ¡Inténtalo de nuevo!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Encabezado de diálogo de límite de tiempo", "default": "¡Se acabó el tiempo!" @@ -593,4 +609,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/language/eu.json b/language/eu.json index 54c3dc5..f2ada53 100644 --- a/language/eu.json +++ b/language/eu.json @@ -338,6 +338,14 @@ "label": "Finish score", "description": "Optional score that can be lower than the summed maximum score of all exercises, so users can receive full score without completing all exercises." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Timeout warning time", + "description": "Optionally set when a timeout warning audio should be played (number of remaining seconds). An audio needs to be set in the audio settings." + }, { "label": "Map", "fields": [ @@ -406,6 +414,10 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Dialog header finish map", "default": "Finish map?" @@ -451,6 +463,10 @@ "label": "Dialog text game over", "default": "You have lost all your lives. Please try again!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Dialog header time out", "default": "Time out!" @@ -593,4 +609,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/language/gl.json b/language/gl.json index 28a46ed..e50c730 100644 --- a/language/gl.json +++ b/language/gl.json @@ -338,6 +338,14 @@ "label": "Puntuación de finalización", "description": "Puntuación opcional que pode ser inferior á puntuación máxima sumada de todos os exercicios, polo que os usuarios poden recibir a puntuación completa sen completar todos os exercicios." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Tempo de aviso de límite de tempo", + "description": "Opcionalmente, define cando se debe reproducir un son de límite de tempo (número de segundos restantes). É necesario configurar un son na configuración de son." + }, { "label": "Mapa", "fields": [ @@ -406,6 +414,10 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Cabeceira do diálogo do mapa de finalización", "default": "Rematar o mapa?" @@ -451,6 +463,10 @@ "label": "Texto do diálogo de xogo rematado", "default": "Perdiches todas as túas vidas. Por favor inténtao de novo!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Cabeceira do diálogo de tempo rematado", "default": "Tempo rematado!" @@ -593,4 +609,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/language/lt.json b/language/lt.json index 158f2d8..e3e28a8 100644 --- a/language/lt.json +++ b/language/lt.json @@ -338,6 +338,14 @@ "label": "Finish score", "description": "Optional score that can be lower than the summed maximum score of all exercises, so users can receive full score without completing all exercises." }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "Timeout warning time", + "description": "Optionally set when a timeout warning audio should be played (number of remaining seconds). An audio needs to be set in the audio settings." + }, { "label": "Map", "fields": [ @@ -406,6 +414,10 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "Dialog header finish map", "default": "Finish map?" @@ -451,6 +463,10 @@ "label": "Dialog text game over", "default": "You have lost all your lives. Please try again!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "Dialog header time out", "default": "Time out!" diff --git a/language/zh-cn.json b/language/zh-cn.json index 119b07f..42f4748 100644 --- a/language/zh-cn.json +++ b/language/zh-cn.json @@ -338,6 +338,14 @@ "label": "完赛得分", "description": "可选分数。可以低于所有练习的总分数,因此用户无需完成所有练习即可获得满分。" }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "超时警告时间设置", + "description": "(可选)设置何时播放超时警告音频(剩余秒数)。需要在音频设置中设置超时警告音频。" + }, { "label": "地图", "fields": [ @@ -406,6 +414,10 @@ "label": "满分,但没有生命了", "default": "你已经取得了满分,但失去了所有的生命!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "完成地图对话框标题", "default": "完成地图?" @@ -451,6 +463,10 @@ "label": "游戏结束对话文本", "default": "你已经失去了所有的生命。请重试!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "超时对话框标题", "default": "超时啦!" diff --git a/language/zh-hans.json b/language/zh-hans.json index 119b07f..42f4748 100644 --- a/language/zh-hans.json +++ b/language/zh-hans.json @@ -338,6 +338,14 @@ "label": "完赛得分", "description": "可选分数。可以低于所有练习的总分数,因此用户无需完成所有练习即可获得满分。" }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "超时警告时间设置", + "description": "(可选)设置何时播放超时警告音频(剩余秒数)。需要在音频设置中设置超时警告音频。" + }, { "label": "地图", "fields": [ @@ -406,6 +414,10 @@ "label": "满分,但没有生命了", "default": "你已经取得了满分,但失去了所有的生命!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "完成地图对话框标题", "default": "完成地图?" @@ -451,6 +463,10 @@ "label": "游戏结束对话文本", "default": "你已经失去了所有的生命。请重试!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "超时对话框标题", "default": "超时啦!" diff --git a/language/zh.json b/language/zh.json index 119b07f..42f4748 100644 --- a/language/zh.json +++ b/language/zh.json @@ -338,6 +338,14 @@ "label": "完赛得分", "description": "可选分数。可以低于所有练习的总分数,因此用户无需完成所有练习即可获得满分。" }, + { + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately." + }, + { + "label": "超时警告时间设置", + "description": "(可选)设置何时播放超时警告音频(剩余秒数)。需要在音频设置中设置超时警告音频。" + }, { "label": "地图", "fields": [ @@ -406,6 +414,10 @@ "label": "满分,但没有生命了", "default": "你已经取得了满分,但失去了所有的生命!" }, + { + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "label": "完成地图对话框标题", "default": "完成地图?" @@ -451,6 +463,10 @@ "label": "游戏结束对话文本", "default": "你已经失去了所有的生命。请重试!" }, + { + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "label": "超时对话框标题", "default": "超时啦!" diff --git a/semantics.json b/semantics.json index 5b82adf..7f90bb0 100644 --- a/semantics.json +++ b/semantics.json @@ -805,6 +805,22 @@ "min": 0, "optional": true }, + { + "name": "timeLimitGlobal", + "type": "number", + "label": "Global time limit", + "description": "Optional time limit in seconds for the whole game. If a user exceeds this time, the game will be over immediately.", + "min": 1, + "optional": true + }, + { + "name": "timeoutWarningGlobal", + "type": "number", + "label": "Timeout warning time", + "description": "Optionally set when a timeout warning audio should be played (number of remaining seconds). An audio needs to be set in the audio settings.", + "min": 1, + "optional": true + }, { "name": "map", "type": "group", @@ -919,6 +935,12 @@ "label": "Full score, but no lives left", "default": "You have achieved full score, but lost all your lifes!" }, + { + "name": "fullScoreButTimeout", + "type": "text", + "label": "Full score, but timed out", + "default": "You have achieved full score, but ran out of time!" + }, { "name": "confirmFinishHeader", "type": "text", @@ -986,6 +1008,12 @@ "label": "Dialog text game over", "default": "You have lost all your lives. Please try again!" }, + { + "name": "confirmGameOverDialogTimeout", + "type": "text", + "label": "Dialog text game over by timeout", + "default": "You have run out of time. Please try again!" + }, { "name": "confirmTimeoutHeader", "type": "text", @@ -1211,4 +1239,4 @@ } ] } -] +] \ No newline at end of file diff --git a/src/scripts/components/exercise-screen/exercise-screen.js b/src/scripts/components/exercise-screen/exercise-screen.js index 0ed1a72..c932476 100644 --- a/src/scripts/components/exercise-screen/exercise-screen.js +++ b/src/scripts/components/exercise-screen/exercise-screen.js @@ -1,3 +1,4 @@ +import { animate } from '@services/animate.js'; import FocusTrap from '@services/focus-trap.js'; import Util from '@services/util.js'; import TimerDisplay from './timer-display.js'; @@ -22,7 +23,6 @@ export default class ExerciseScreen { onCloseAnimationEnded: () => {} }, callbacks); - this.handleAnimationEnded = this.handleAnimationEnded.bind(this); this.handleOpenAnimationEnded = this.handleOpenAnimationEnded.bind(this); this.handleCloseAnimationEnded = this.handleCloseAnimationEnded.bind(this); @@ -258,35 +258,10 @@ export default class ExerciseScreen { this.isAnimating = true; - // Cannot make this work with this.handleAnimationEnded.bind(this, callback) - this.animationEndedCallback = callback; - - this.contentContainer.addEventListener( - 'animationend', this.handleAnimationEnded - ); - - this.contentContainer.classList.add('animate'); - this.contentContainer.classList.add(`animate-${animationName}`); - } - - /** - * Handle animation ended. - */ - handleAnimationEnded() { - this.contentContainer.classList.remove('animate'); - this.contentContainer.className = this.contentContainer.className - .replace(/animate-w*/g, ''); - - this.contentContainer.removeEventListener( - 'animationend', this.handleAnimationEnded - ); - - this.isAnimating = false; - - if (typeof this.animationEndedCallback === 'function') { - this.animationEndedCallback(); - this.animationEndedCallback = null; - } + animate(this.contentContainer, animationName, () => { + this.isAnimating = false; + callback(); + }); } /** diff --git a/src/scripts/components/exercise-screen/timer-display.js b/src/scripts/components/exercise-screen/timer-display.js index 9c2ca87..49a51bd 100644 --- a/src/scripts/components/exercise-screen/timer-display.js +++ b/src/scripts/components/exercise-screen/timer-display.js @@ -1,3 +1,4 @@ +import Timer from '@services/timer.js'; import './timer-display.scss'; /** Class representing a timer on screen */ @@ -53,18 +54,12 @@ export default class TimerDisplay { return; } - if (typeof timeMs !== 'number') { + const timecode = Timer.toTimecode(timeMs); + if (!timecode) { return; } - const date = new Date(0); - date.setSeconds(Math.round(Math.max(0, timeMs / 1000))); - - this.dom.innerText = date - .toISOString() - .split('T')[1] - .split('.')[0] - .replace(/^[0:]+/, '') || '0'; + this.dom.innerText = timecode; this.show(); } diff --git a/src/scripts/components/main.js b/src/scripts/components/main.js index c9eddd7..c976d9b 100644 --- a/src/scripts/components/main.js +++ b/src/scripts/components/main.js @@ -1,4 +1,5 @@ import CallbackQueue from '@services/callback-queue.js'; +import Timer from '@services/timer.js'; import Util from '@services/util.js'; import MainAudio from './mixins/main-audio.js'; import MainInitialization from './mixins/main-initialization.js'; @@ -6,6 +7,7 @@ import MainHandlersStage from './mixins/main-handlers-stage.js'; import MainHandlersExercise from './mixins/main-handlers-exercise.js'; import MainHandlersExerciseScreen from './mixins/main-handlers-exercise-screen.js'; import MainQuestionTypeContract from './mixins/main-question-type-contract.js'; +import MainTimer from './mixins/main-timer.js'; import MainUserConfirmation from './mixins/main-user-confirmation.js'; import './main.scss'; @@ -41,6 +43,7 @@ export default class Main { MainHandlersExercise, MainHandlersExerciseScreen, MainQuestionTypeContract, + MainTimer, MainUserConfirmation ] ); @@ -59,8 +62,15 @@ export default class Main { this.buildDOM(); this.startVisibilityObserver(); + + this.initializeTimer(); + this.reset({ isInitial: true }); + if (this.params.globals.get('params').behaviour.timeLimitGlobal) { + this.toolbar.showStatusContainer('timer'); + } + if (typeof this.params.globals.get('params').behaviour.lives === 'number') { this.toolbar.showStatusContainer('lives'); } @@ -103,6 +113,16 @@ export default class Main { this.map.show(); this.contentDOM.classList.remove('display-none'); + if (this.timer) { + const timerState = this.timer.getState(); + if (timerState === Timer.STATE_PAUSED) { + this.timer.resume(); + } + else if (timerState === Timer.STATE_ENDED) { + this.timer.start(); + } + } + if (params.readOpened) { this.params.globals.get('read')( this.params.dictionary.get('a11y.mapWasOpened') @@ -134,6 +154,8 @@ export default class Main { */ hide() { this.map.hide(); + this.timer.pause(); + this.contentDOM.classList.add('display-none'); } @@ -226,6 +248,7 @@ export default class Main { showEndscreen(params = {}) { const endscreenParams = this.params.globals.get('params').endScreen; this.toolbar.toggleHintFinishButton(false); + this.toolbar.toggleHintTimer(false); // Prepare end screen const score = this.getScore(); @@ -249,7 +272,11 @@ export default class Main { const defaultTitle = `

${this.params.dictionary.get('l10n.completedMap')}

`; - if (score >= maxScore && this.livesLeft > 0) { + if ( + score >= maxScore && + this.livesLeft > 0 && + (typeof this.remainingTime !== 'number' || this.remainingTime > 0) + ) { const success = endscreenParams.success; this.endScreen.setMedium(success.endScreenMediumSuccess); @@ -274,6 +301,9 @@ export default class Main { if (this.livesLeft === 0 && score >= maxScore) { html = `${html}

${this.params.dictionary.get('l10n.fullScoreButnoLivesLeft')}

`; } + else if (this.timer?.getTime() === 0 && score >= maxScore) { + html = `${html}

${this.params.dictionary.get('l10n.fullScoreButTimeout')}

`; + } this.endScreen.setIntroduction(html); if (!this.isShowingSolutions) { diff --git a/src/scripts/components/map/stage/stage.js b/src/scripts/components/map/stage/stage.js index 65326e3..3f07b36 100644 --- a/src/scripts/components/map/stage/stage.js +++ b/src/scripts/components/map/stage/stage.js @@ -1,4 +1,5 @@ import Color from 'color'; +import { animate } from '@services/animate.js'; import Util from '@services/util.js'; import Label from './label.js'; import './stage.scss'; @@ -38,8 +39,6 @@ export default class Stage { this.shouldBePlayful = true; this.isReachableState = true; - this.handleAnimationEnded = this.handleAnimationEnded.bind(this); - this.dom = document.createElement('button'); this.dom.classList.add('h5p-game-map-stage'); this.dom.setAttribute('id', `stage-button-${this.params.id}`); @@ -416,22 +415,9 @@ export default class Stage { this.isAnimating = true; - this.dom.addEventListener('animationend', this.handleAnimationEnded); - - this.dom.classList.add('animate'); - this.dom.classList.add(`animate-${animationName}`); - } - - /** - * Handle animation ended. - */ - handleAnimationEnded() { - this.dom.classList.remove('animate'); - this.dom.className = this.dom.className.replace(/animate-\w*/g, ''); - - this.dom.removeEventListener('animationend', this.handleAnimationEnded); - - this.isAnimating = false; + animate(this.dom, animationName, () => { + this.isAnimating = false; + }); } /** diff --git a/src/scripts/components/mixins/main-initialization.js b/src/scripts/components/mixins/main-initialization.js index 45750c2..e0dbba5 100644 --- a/src/scripts/components/mixins/main-initialization.js +++ b/src/scripts/components/mixins/main-initialization.js @@ -155,6 +155,7 @@ export default class MainInitialization { // Set up toolbar's status containers const toolbarStatusContainers = [ + { id: 'timer' }, { id: 'lives' }, { id: 'stages', hasMaxValue: true }, { id: 'score', hasMaxValue: true } @@ -213,7 +214,8 @@ export default class MainInitialization { dictionary: this.params.dictionary, ...(globalParams.headline && { headline: globalParams.headline }), buttons: toolbarButtons, - statusContainers: toolbarStatusContainers + statusContainers: toolbarStatusContainers, + useAnimation: globalParams.visual.misc.useAnimation }); this.contentDOM.append(this.toolbar.getDOM()); @@ -377,6 +379,7 @@ export default class MainInitialization { */ reset(params = {}) { this.toolbar.toggleHintFinishButton(false); + this.toolbar.toggleHintTimer(false); this.params.jukebox.muteAll(); this.stageAttentionSeekerTimeout = null; @@ -393,6 +396,15 @@ export default class MainInitialization { this.livesLeft = globalParams.behaviour.lives ?? Infinity; } + if (params.isInitial && typeof previousState.timeLeft === 'number') { + this.resetTimer(previousState.timeLeft); + } + else { + this.resetTimer( + this.params.globals.get('params').behaviour.timeLimitGlobal * 1000 + ); + } + if (this.livesLeft === 0) { this.stages.forEach((stage) => { stage.setState('sealed'); diff --git a/src/scripts/components/mixins/main-question-type-contract.js b/src/scripts/components/mixins/main-question-type-contract.js index 88f4949..d93b4c9 100644 --- a/src/scripts/components/mixins/main-question-type-contract.js +++ b/src/scripts/components/mixins/main-question-type-contract.js @@ -81,7 +81,8 @@ export default class MainQuestionTypeContract { exercises: this.exercises.getCurrentState(), stages: this.stages.getCurrentState(), paths: this.paths.getCurrentState(), - livesLeft: this.livesLeft + ...(this.livesLeft && { livesLeft: this.livesLeft }), + ...(this.remainingTime && { timeLeft: this.remainingTime }) }; } } diff --git a/src/scripts/components/mixins/main-timer.js b/src/scripts/components/mixins/main-timer.js new file mode 100644 index 0000000..563c240 --- /dev/null +++ b/src/scripts/components/mixins/main-timer.js @@ -0,0 +1,73 @@ +import Timer from '@services/timer.js'; +/** + * Mixin containing methods related to the timer. + */ +export default class MainTimer { + + /** + * Initialize timer. + */ + initializeTimer() { + if (this.params.globals.get('params').behaviour.timeLimitGlobal) { + this.timer = new Timer( + { interval: 500 }, + { + onTick: () => { + this.remainingTime = this.timer.getTime(); + const isTimeoutWarning = this.isTimeoutWarning(); + + if (isTimeoutWarning) { + this.hasPlayedTimeoutWarningGlobal = true; + this.params.jukebox.play('timeoutWarning'); + this.toolbar.toggleHintTimer(true); + } + + this.toolbar.setStatusContainerStatus( + 'timer', + { value: Timer.toTimecode(this.remainingTime) } + ); + }, + onExpired: () => { + this.showGameOverConfirmation('confirmGameOverDialogTimeout'); + } + } + ); + } + } + + /** + * Determine whether exercise is in timeout warning state. + * @returns {boolean} True, if exercise is in timeout warning state. + */ + isTimeoutWarning() { + if (this.hasPlayedTimeoutWarningGlobal) { + return false; + } + + const timeoutWarning = + this.params.globals.get('params').behaviour.timeoutWarningGlobal; + + return ( + typeof timeoutWarning === 'number' && + this.remainingTime <= timeoutWarning * 1000 + ); + } + + /** + * Reset timer. + * @param {number} timeMs Time in milliseconds. + */ + resetTimer(timeMs) { + if (typeof timeMs !== 'number' || timeMs < 1) { + return; + } + + this.hasPlayedTimeoutWarningGlobal = false; + this.timer?.reset(timeMs); + + this.toolbar.setStatusContainerStatus( + 'timer', + { value: Timer.toTimecode(timeMs) } + ); + } +} diff --git a/src/scripts/components/mixins/main-user-confirmation.js b/src/scripts/components/mixins/main-user-confirmation.js index e9f9459..6165b54 100644 --- a/src/scripts/components/mixins/main-user-confirmation.js +++ b/src/scripts/components/mixins/main-user-confirmation.js @@ -65,15 +65,16 @@ export default class MainUserConfirmation { /** * Handle game over. + * @param {string} [dialogKey] Dialog key for message. */ - showGameOverConfirmation() { + showGameOverConfirmation(dialogKey = 'confirmGameOverDialog') { this.gameDone = true; this.stages.togglePlayfulness(false); this.confirmationDialog.update( { headerText: this.params.dictionary.get('l10n.confirmGameOverHeader'), - dialogText: this.params.dictionary.get('l10n.confirmGameOverDialog'), + dialogText: this.params.dictionary.get(`l10n.${dialogKey}`), confirmText: this.params.dictionary.get('l10n.ok'), hideCancel: true }, { diff --git a/src/scripts/components/toolbar/status-containers/status-container.js b/src/scripts/components/toolbar/status-containers/status-container.js index c68eded..bc273c1 100644 --- a/src/scripts/components/toolbar/status-containers/status-container.js +++ b/src/scripts/components/toolbar/status-containers/status-container.js @@ -1,3 +1,4 @@ +import { animate } from '@services/animate.js'; import Util from '@services/util.js'; import './status-container.scss'; @@ -77,4 +78,12 @@ export default class StatusContainer { hide() { this.dom.classList.add('display-none'); } + + /** + * Animate. + * @param {string|null} animationName Animation name, null to stop animation. + */ + animate(animationName) { + animate(this.dom, animationName); + } } diff --git a/src/scripts/components/toolbar/status-containers/status-container.scss b/src/scripts/components/toolbar/status-containers/status-container.scss index cc9ce4a..3dd3e20 100644 --- a/src/scripts/components/toolbar/status-containers/status-container.scss +++ b/src/scripts/components/toolbar/status-containers/status-container.scss @@ -21,6 +21,12 @@ display: none; } + &.animate-pulse { + animation: pulse; + animation-duration: 0.5s; + animation-timing-function: ease-in-out; + } + .status-container-values { box-sizing: border-box; display: flex; @@ -38,3 +44,17 @@ } } } + +@keyframes pulse { + 0% { + transform: scale3d(1, 1, 1); + } + + 50% { + transform: scale3d(1.1, 1.1, 1.1); + } + + 100% { + transform: scale3d(1, 1, 1); + } +} diff --git a/src/scripts/components/toolbar/status-containers/status-containers.js b/src/scripts/components/toolbar/status-containers/status-containers.js index 846e8eb..f12f3a0 100644 --- a/src/scripts/components/toolbar/status-containers/status-containers.js +++ b/src/scripts/components/toolbar/status-containers/status-containers.js @@ -92,4 +92,17 @@ export default class StatusContainers { this.containers[id].setStatus(params); } + + /** + * Animate container. + * @param {string} id Container id. + * @param {string|null} animationName Animation name, null to stop animation. + */ + animate(id, animationName) { + if (!this.containers[id]) { + return; + } + + this.containers[id].animate(animationName); + } } diff --git a/src/scripts/components/toolbar/status-containers/status-containers.scss b/src/scripts/components/toolbar/status-containers/status-containers.scss index 0992065..68ed875 100644 --- a/src/scripts/components/toolbar/status-containers/status-containers.scss +++ b/src/scripts/components/toolbar/status-containers/status-containers.scss @@ -2,7 +2,9 @@ box-sizing: border-box; display: flex; flex-direction: row; + flex-wrap: wrap; gap: 1.25rem; + justify-content: center; // Workaround for iOS < 14.5 which doesn't support gap for flexbox @supports (-webkit-touch-callout: none) and (not (translate: none)) { diff --git a/src/scripts/components/toolbar/toolbar.js b/src/scripts/components/toolbar/toolbar.js index ca68c9c..e8d4792 100644 --- a/src/scripts/components/toolbar/toolbar.js +++ b/src/scripts/components/toolbar/toolbar.js @@ -294,19 +294,46 @@ export default class Toolbar { } else { window.clearTimeout(this.hintFinishButtonTimeout); + this.animateButton('finish', null); + } + } + + /** + * Toggle hint for timer. + * @param {boolean} state True to hint, false to "un"hint. + */ + toggleHintTimer(state) { + state = (typeof state === 'boolean') ? + state : + typeof this.hintTimerTimeout !== 'number'; + + if (state) { + this.animateStatusContainer('timer', 'pulse'); + + this.hintTimerTimeout = window.setTimeout(() => { + this.toggleHintTimer(true); + }, HINT_INTERVAL_TIMER_MS); + } + else { + window.clearTimeout(this.hintTimerTimeout); + this.animateStatusContainer('timer', null); } } /** * Animate button. * @param {string} id Button id. - * @param {string} className Class name to animate with. + * @param {string|null} className Class name to animate with, null to stop animation. */ - animateButton(id = '', className = '') { + animateButton(id = '', className) { if (!this.buttons[id]) { return; // Button not available } + if (!this.params.useAnimation) { + return; + } + animate(this.buttons[id].getDOM(), className); } @@ -404,6 +431,19 @@ export default class Toolbar { this.statusContainers.hideContainer(id); } + /** + * Animate status container. + * @param {string} id Container id. + * @param {string|null} animationName Animation name, null to stop animation. + */ + animateStatusContainer(id, animationName) { + if (!this.params.useAnimation) { + return; + } + + this.statusContainers.animate(id, animationName); + } + /** * Toggle solution mode on and off. * @param {boolean} state If true, solution mode is on, else off. @@ -415,3 +455,4 @@ export default class Toolbar { /** @constant {number} HINT_INTERVAL_MS Default hint interval. */ export const HINT_INTERVAL_MS = 5000; +export const HINT_INTERVAL_TIMER_MS = 1000; diff --git a/src/scripts/components/toolbar/toolbar.scss b/src/scripts/components/toolbar/toolbar.scss index bd26013..96700e7 100644 --- a/src/scripts/components/toolbar/toolbar.scss +++ b/src/scripts/components/toolbar/toolbar.scss @@ -35,6 +35,13 @@ row-gap: 0.25rem; .status-containers { + .status-container-timer { + &::before { + content: "\f017"; + font-family: "H5PFontAwesome4", sans-serif; + } + } + .status-container-lives { &::before { content: "\f004"; diff --git a/src/scripts/h5p-game-map.js b/src/scripts/h5p-game-map.js index f645b7a..a2b96a2 100644 --- a/src/scripts/h5p-game-map.js +++ b/src/scripts/h5p-game-map.js @@ -237,25 +237,6 @@ export default class GameMap extends H5P.Question { this.jukebox.fill(audios); } - /** - * Get current state. - * @returns {object} Current state to be retrieved later. - */ - getCurrentState() { - if (!this.main) { - return {}; - } - - if (!this.getAnswerGiven()) { - // Nothing relevant to store, but previous state in DB must be cleared after reset - return this.contentWasReset ? {} : undefined; - } - - return { - content: this.main.getCurrentState() - }; - } - /** * Handle progress changed. * @param {number} index Index of stage + 1. diff --git a/src/scripts/mixins/question-type-contract.js b/src/scripts/mixins/question-type-contract.js index ea6708d..01fcae0 100644 --- a/src/scripts/mixins/question-type-contract.js +++ b/src/scripts/mixins/question-type-contract.js @@ -64,6 +64,26 @@ export default class QuestionTypeContract { }; } + /** + * Get current state. + * @returns {object} Current state to be retrieved later. + * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-7} + */ + getCurrentState() { + if (!this.main) { + return {}; + } + + if (!this.getAnswerGiven() && !this.params.behaviour.timeLimitGlobal) { + // Nothing relevant to store, but previous state in DB must be cleared after reset + return this.contentWasReset ? {} : undefined; + } + + return { + content: this.main.getCurrentState() + }; + } + /** * Get context data. * Contract used for confusion report. diff --git a/src/scripts/services/animate.js b/src/scripts/services/animate.js index 8bf484e..5e6953f 100644 --- a/src/scripts/services/animate.js +++ b/src/scripts/services/animate.js @@ -1,24 +1,49 @@ /** * Animate DOM element. * @param {HTMLElement} element Element to animate. - * @param {string} animationName Animation name. + * @param {string|null} animationName Animation name, null to stop animation. + * @param {function} [callback] Callback when done. */ -export const animate = (element, animationName = '') => { - if (!element || !animationName || typeof animationName !== 'string') { +export const animate = (element, animationName = '', callback = () => {}) => { + if (!element) { + return; + } + + if (animationName === null) { + element.dispatchEvent(new Event('animationend')); + return; + } + else if (typeof animationName !== 'string') { + return; + } + + // Determine mediaQuery result for prefers-reduced-motion preference + const reduceMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + )?.matches; + + if (reduceMotion) { return; } const className = `animate-${animationName}`; const listener = (event) => { - if (event.animationName !== animationName) { + if ( + event.animationName !== animationName && + event.animationName !== undefined // Clearing animation. + ) { return; } + element.classList.remove('animate'); element.classList.remove(className); - element.removeEventListener('animationend', listener); + + callback(); }; - element.addEventListener('animationend', listener); + element.addEventListener('animationend', listener, { once: true }); + + element.classList.add('animate'); element.classList.add(className); }; diff --git a/src/scripts/services/timer.js b/src/scripts/services/timer.js index e72da51..e926f4c 100644 --- a/src/scripts/services/timer.js +++ b/src/scripts/services/timer.js @@ -43,6 +43,14 @@ export default class Timer { this.callbacks.onStateChanged(state, this.getTime()); } + /** + * Get current state. + * @returns {number} Current state. + */ + getState() { + return this.state; + } + /** * Start. * @param {number} [defaultTime] Time to start with. @@ -102,10 +110,11 @@ export default class Timer { /** * Reset. + * @param {number} [timeMs] Time in ms. */ - reset() { + reset(timeMs = 0) { this.stop(); - this.setTime(0); + this.setTime(timeMs); } /** @@ -147,6 +156,26 @@ export default class Timer { this.update(); }, this.params.interval); } + + /** + * Convert time in ms to timecode. + * @param {number} timeMs Time in ms. + * @returns {string|undefined} Timecode. + */ + static toTimecode(timeMs) { + if (typeof timeMs !== 'number') { + return; + } + + const date = new Date(0); + date.setSeconds(Math.round(Math.max(0, timeMs / 1000))); + + return date + .toISOString() + .split('T')[1] + .split('.')[0] + .replace(/^[0:]+/, '') || '0'; + } } /** @constant {number} STATE_ENDED State ended (or not started) */