diff --git a/app.js b/app.js index 538d6d8..7cd2280 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,6 @@ import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import * as FPS from 'three/addons/libs/stats.module.js'; let P = (x, y, z = 0) => new THREE.Vector3(x, y, z) @@ -15,7 +16,7 @@ HTMLElement.prototype.addNewChild = function (tag, properties) { } -/* Contants */ +/* Constants */ const ROWS = 24 const SKYLINE = 20 @@ -251,7 +252,7 @@ class MinoMaterial extends THREE.MeshBasicMaterial { color: color, reflectivity: 0.95, envMap: minoRenderTarget.texture, - roughness: 0, + roughness: 0.1, metalness: 0.25 }) } @@ -324,7 +325,7 @@ class Tetromino extends THREE.Group { move(translation, testFacing) { if (this.canMove(translation, testFacing)) { - scheduler.clearTimeout(lockDown) + scheduler.clearTimeout(game.lockDown) this.position.add(translation) if (!testFacing) { this.rotatedLast = false @@ -336,13 +337,13 @@ class Tetromino extends THREE.Group { } else { this.locked = true scene.remove(ghost) - scheduler.setTimeout(lockDown, stats.lockDelay) + scheduler.setTimeout(game.lockDown, stats.lockDelay) } return true } else if (translation == TRANSLATION.DOWN) { this.locked = true - if (!scheduler.timeoutTasks.has(lockDown)) - scheduler.setTimeout(lockDown, stats.lockDelay) + if (!scheduler.timeoutTasks.has(game.lockDown)) + scheduler.setTimeout(game.lockDown, stats.lockDelay) } } @@ -488,95 +489,124 @@ Ghost.prototype.minoesPosition = [ [P(0, 0, 0), P(0, 0, 0), P(0, 0, 0), P(0, 0, 0)], ] - -class Settings { - constructor() { - this.form = settingsForm - this.load() - this.modal = new bootstrap.Modal('#settingsModal') - settingsModal.addEventListener('shown.bs.modal', () => { - resumeButton.focus() - }) - } - - load() { - for (let input of settingsForm.elements) { - if (input.name) { - if (localStorage[input.name]) input.value = localStorage[input.name] - } - } - } - - save() { - for (let element of settingsForm.elements) { - if (element.name) { - localStorage[element.name] = element.value - } - } - } - - init() { - this.form.onsubmit = newGame - levelInput.name = "startLevel" - levelInput.disabled = false - //titleHeader.innerHTML = "teTra" - resumeButton.innerHTML = "Jouer" - } - - show() { - resumeButton.disabled = false - settings.form.classList.remove('was-validated') - settings.modal.show() - settings.form.reportValidity() - } - - getInputs() { - for (let input of this.form.querySelectorAll("input[type='text']")) { - this[input.name] = KEY_NAMES[input.value] || input.value - } - for (let input of this.form.querySelectorAll("input[type='number'], input[type='range']")) { - this[input.name] = input.valueAsNumber - } - for (let input of this.form.querySelectorAll("input[type='checkbox']")) { - this[input.name] = input.checked == true - } - - this.keyBind = {} - for (let actionName in playerActions) { - this.keyBind[settings[actionName]] = playerActions[actionName] - } - } -} - -window.changeKey = function (input) { - let prevValue = input.value - input.value = "" +function changeKey() { + let controller = this + let input = controller.domElement.getElementsByTagName("input")[0] + input.select() input.onkeydown = function (event) { - event.preventDefault() - input.value = KEY_NAMES[event.key] || event.key + controller.setValue(KEY_NAMES[event.key] || event.key) input.blur() } input.onblur = function (event) { - if (input.value == "") input.value = prevValue input.onkeydown = null input.onblur = null + settings.bindKeys() + } +} + + +class Settings { + constructor(gui) { + this.startLevel = 1 + + this.moveLeft = "←" + this.moveRight = "→" + this.rotateCCW = "w" + this.rotateCW = "↑" + this.softDrop = "↓" + this.hardDrop = "Espace" + this.hold = "c" + this.pause = "Échap." + + this.arrDelay = 50 + this.dasDelay = 300 + + this.musicVolume = 50 + this.sfxVolume = 50 + + this.gui = gui.addFolder("Options").close() + + this.gui.add(this, "startLevel").name("Niveau initial").min(1).max(15).step(1) + + this.gui.keyFolder = this.gui.addFolder("Commandes").open() + let moveLeftController = this.gui.keyFolder.add(this,"moveLeft").name('Gauche') + moveLeftController.domElement.onclick = changeKey.bind(moveLeftController) + let moveRightController = this.gui.keyFolder.add(this,"moveRight").name('Droite') + moveRightController.domElement.onclick = changeKey.bind(moveRightController) + let rotateCWController = this.gui.keyFolder.add(this,"rotateCW").name('Rotation horaire') + rotateCWController.domElement.onclick = changeKey.bind(rotateCWController) + let rotateCCWController = this.gui.keyFolder.add(this,"rotateCCW").name('anti-horaire') + rotateCCWController.domElement.onclick = changeKey.bind(rotateCCWController) + let softDropController = this.gui.keyFolder.add(this,"softDrop").name('Chute lente') + softDropController.domElement.onclick = changeKey.bind(softDropController) + let hardDropController = this.gui.keyFolder.add(this,"hardDrop").name('Chute rapide') + hardDropController.domElement.onclick = changeKey.bind(hardDropController) + let holdController = this.gui.keyFolder.add(this,"hold").name('Garder') + holdController.domElement.onclick = changeKey.bind(holdController) + let pauseController = this.gui.keyFolder.add(this,"pause").name('Pause') + pauseController.domElement.onclick = changeKey.bind(pauseController) + + this.gui.delayFolder = this.gui.addFolder("Répétition automatique").open() + this.gui.delayFolder.add(this,"arrDelay").name("ARR (ms)").min(2).max(200).step(1); + this.gui.delayFolder.add(this,"dasDelay").name("DAS (ms)").min(100).max(500).step(5); + + this.gui.volumeFolder = this.gui.addFolder("Volume").open() + this.gui.volumeFolder.add(this,"musicVolume").name("Musique").min(0).max(100).step(1).onChange((volume) => { + music.setVolume(volume/100) + }) + this.gui.volumeFolder.add(this,"sfxVolume").name("SFX").min(0).max(100).step(1).onChange((volume) => { + lineClearSound.setVolume(volume/100) + tetrisSound.setVolume(volume/100) + hardDropSound.setVolume(volume/100) + }) + + this.load() + this.bindKeys() + } + + bindKeys() { + this.keyBind = {} + for (let actionName in playerActions) { + this.keyBind[KEY_NAMES[this[actionName]] || this[actionName]] = playerActions[actionName] + } + } + + load() { + if (localStorage["teTraSettings"]) this.gui.load(JSON.parse(localStorage["teTraSettings"])) + } + + save() { + localStorage["teTraSettings"] = JSON.stringify(this.gui.save()) } } class Stats { - constructor() { - this.modal = new bootstrap.Modal('#statsModal') - this.load() - } + constructor(parentGui) { + this.clock = new THREE.Clock(false) + this.clock.timeFormat = new Intl.DateTimeFormat("fr-FR", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC" + }) + this.elapsedTime = 0 - load() { - this.highScore = Number(localStorage["highScore"]) || 0 + this.init() + + this.gui = parentGui.addFolder("Stats") + this.gui.add(this, "level").name("Niveau").disable().listen() + this.gui.add(this, "goal").name("Objectif").disable().listen() + this.gui.add(this, "score").name("Score").disable().listen() + this.gui.add(this, "highScore").name("Meilleur score").disable().listen() + this.gui.timeController = this.gui.add(this, "time").name("Temps").disable().listen() } init() { - this.score = 0 + this._level = 0 + this._score = 0 this.goal = 0 + this.highScore = Number(localStorage["teTraHighScore"]) || 0 this.combo = 0 this.b2b = 0 this.startTime = new Date() @@ -593,7 +623,6 @@ class Stats { if (score > this.highScore) { this.highScore = score } - scoreDiv.innerText = score.toLocaleString() } get score() { @@ -603,29 +632,15 @@ class Stats { set level(level) { this._level = level this.goal += level * 5 - if (level <= 20) { - this.fallPeriod = 1000 * Math.pow(0.8 - ((level - 1) * 0.007), level - 1) - } - if (level > 15) - this.lockDelay = 500 * Math.pow(0.9, level - 15) - levelInput.value = level + if (level <= 20) this.fallPeriod = 1000 * Math.pow(0.8 - ((level - 1) * 0.007), level - 1) + if (level > 15) this.lockDelay = 500 * Math.pow(0.9, level - 15) messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>NIVEAU<br/>${this.level}</h1>` }) - levelDiv.innerText = level } get level() { return this._level } - set goal(goal) { - this._goal = goal - goalDiv.innerText = goal - } - - get goal() { - return this._goal - } - set combo(combo) { this._combo = combo if (combo > this.maxCombo) this.maxCombo = combo @@ -644,12 +659,8 @@ class Stats { return this._b2b } - set time(time) { - this.startTime = new Date() - time - } - get time() { - return new Date() - this.startTime + return this.clock.timeFormat.format(this.clock.elapsedTime * 1000) } lockDown(nbClearedLines, tSpin) { @@ -735,53 +746,23 @@ class Stats { this.goal -= awardedLineClears if (this.goal <= 0) this.level++ } - - show() { - let time = stats.time - statsModalScoreCell.innerText = this.score.toLocaleString() - statsModalHighScoreCell.innerText = this.highScore.toLocaleString() - statsModalLevelCell.innerText = this.level - statsModalTimeCell.innerText = this.timeFormat.format(time) - statsModaltotalClearedLines.innerText = this.totalClearedLines - statsModaltotalClearedLinesPM.innerText = (stats.totalClearedLines * 60000 / time).toFixed(2) - statsModalNbQuatris.innerText = this.nbQuatris - statsModalNbTSpin.innerText = this.nbTSpin - statsModalMaxCombo.innerText = this.maxCombo - statsModalMaxB2B.innerText = this.maxB2B - this.modal.show() - } - - save() { - localStorage["highScore"] = this.highScore - } -} -Stats.prototype.timeFormat = new Intl.DateTimeFormat("fr-FR", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - timeZone: "UTC" -}) - -function tick() { - timeDiv.innerText = stats.timeFormat.format(stats.time) } /* Scene */ -const manager = new THREE.LoadingManager() -manager.onStart = function (url, itemsLoaded, itemsTotal) { - loadingBar.style.setProperty("width", '0%') +const loadManager = new THREE.LoadingManager() +loadManager.onStart = function (url, itemsLoaded, itemsTotal) { + loadingPercent.innerText = "0%" } -manager.onProgress = function (url, itemsLoaded, itemsTotal) { - loadingBar.style.setProperty("width", 100 * itemsLoaded / itemsTotal + '%') +loadManager.onProgress = function (url, itemsLoaded, itemsTotal) { + loadingPercent.innerText = 100 * itemsLoaded / itemsTotal + '%' } -manager.onLoad = function () { - restart() - messagesSpan.innerHTML = "" +loadManager.onLoad = function () { + loaddingCircle.remove() renderer.setAnimationLoop(animate) } -manager.onError = function (url) { +loadManager.onError = function (url) { messagesSpan.innerHTML = 'Erreur de chargement' } @@ -835,7 +816,7 @@ const colorFullOpacity = 0.2 const commonCylinderGeometry = new THREE.CylinderGeometry(25, 25, 500, 12, 1, true) // dark space full of stars - background cylinder -const darkCylinderTexture = new THREE.TextureLoader(manager).load("images/dark.jpg") +const darkCylinderTexture = new THREE.TextureLoader(loadManager).load("images/dark.jpg") darkCylinderTexture.wrapS = THREE.RepeatWrapping darkCylinderTexture.wrapT = THREE.MirroredRepeatWrapping darkCylinderTexture.repeat.set(1, 1) @@ -853,7 +834,7 @@ darkCylinder.position.set(5, 10, -10) scene.add(darkCylinder) // colourfull space full of nebulas - main universe cylinder -const colorFullCylinderTexture = new THREE.TextureLoader(manager).load("images/colorfull.jpg") +const colorFullCylinderTexture = new THREE.TextureLoader(loadManager).load("images/colorfull.jpg") colorFullCylinderTexture.wrapS = THREE.RepeatWrapping colorFullCylinderTexture.wrapT = THREE.MirroredRepeatWrapping colorFullCylinderTexture.repeat.set(1, 1) @@ -925,19 +906,6 @@ const hardDroppedMatrix = mixer.clipAction(clip) hardDroppedMatrix.loop = THREE.LoopOnce hardDroppedMatrix.setDuration(0.2) - -const lineClearSound = new Audio("audio/line-clear.wav") -const tetrisSound = new Audio("audio/tetris.wav") -const hardDropSound = new Audio("audio/hard-drop.wav") -const music = new Audio("https://iterations.org/files/music/remixes/Tetris_CheDDer_OC_ReMix.mp3") - music.loop = true - -window.addEventListener("resize", () => { - renderer.setSize(window.innerWidth, window.innerHeight) - camera.aspect = window.innerWidth / window.innerHeight - camera.updateProjectionMatrix() -}) - let clock = new THREE.Clock() function animate() { @@ -961,9 +929,14 @@ function animate() { minoCamera.update(renderer, scene) if (showFPS) fps.update(); - } +window.addEventListener("resize", () => { + renderer.setSize(window.innerWidth, window.innerHeight) + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() +}) + /* Game logic */ @@ -971,113 +944,127 @@ messagesSpan.onanimationend = function (event) { event.target.remove() } -let scheduler = new Scheduler() -let settings = new Settings() -let stats = new Stats() -let playing = false -//let favicon = document.querySelector("link[rel~='icon']") +let piece = null -window.restart = function () { - stats.modal.hide() - stats.init() - settings.init() - holdQueue.remove(holdQueue.piece) - holdQueue.piece = null - if (nextQueue.pieces) nextQueue.pieces.forEach(piece => nextQueue.remove(piece)) - Array.from(matrix.children).forEach(mino => matrix.remove(mino)) - matrix.init() - scene.remove(piece) - piece = null - scene.remove(ghost) - music.currentTime = 0 - pauseSettings() -} +let game = { + init: function() { + this.playing = false -function pauseSettings() { - stats.pauseTime = stats.time + stats.init() + + holdQueue.remove(holdQueue.piece) + holdQueue.piece = null + if (nextQueue.pieces) nextQueue.pieces.forEach(piece => nextQueue.remove(piece)) + Array.from(matrix.children).forEach(mino => matrix.remove(mino)) + matrix.init() + scene.remove(piece) + piece = null + scene.remove(ghost) + music.currentTime = 0 + }, - scheduler.clearInterval(fall) - scheduler.clearTimeout(lockDown) - scheduler.clearTimeout(repeat) - scheduler.clearInterval(autorepeat) - scheduler.clearInterval(tick) + start: function() { + startButton.hide() - music.pause() - document.onkeydown = null + this.playing = true + stats.clock.start() - settings.show() -} + onblur = this.pause -function newGame(event) { - if (!settings.form.checkValidity()) { - event.preventDefault() - event.stopPropagation() - settings.form.reportValidity() - settings.form.classList.add('was-validated') - } else { - levelInput.name = "level" - levelInput.disabled = true - //titleHeader.innerHTML = "PAUSE" - resumeButton.innerHTML = "Reprendre" - event.target.onsubmit = resume nextQueue.init() - stats.level = levelInput.valueAsNumber - localStorage["startLevel"] = levelInput.value - playing = true - onblur = pauseSettings - resume(event) - } -} -function resume(event) { - event.preventDefault() - event.stopPropagation() - - settings.form.reportValidity() - settings.form.classList.add('was-validated') - - if (settings.form.checkValidity()) { - settings.modal.hide() - settings.getInputs() - renderer.domElement.focus() + stats.level = settings.startLevel + this.resume() + }, + resume: function(event) { document.onkeydown = onkeydown document.onkeyup = onkeyup - stats.time = stats.pauseTime + stats.clock.start() + stats.clock.elapsedTime = stats.elapsedTime + music.play() - lineClearSound.volume = settings.sfxVolume - tetrisSound.volume = settings.sfxVolume - hardDropSound.volume = settings.sfxVolume - if (settings.musicVolume > 0) { - music.volume = settings.musicVolume - music.play() + if (piece) scheduler.setInterval(game.fall, stats.fallPeriod) + else this.generate() + }, + + generate: function(heldPiece) { + if (heldPiece) { + piece = heldPiece + } else { + piece = nextQueue.shift() } + piece.position.set(4, SKYLINE) + scene.add(piece) + ghost.copy(piece) + scene.add(ghost) + + if (piece.canMove(TRANSLATION.NONE)) { + scheduler.setInterval(game.fall, stats.fallPeriod) + } else { + game.over() // block out + } + }, - scheduler.setInterval(tick) + fall: function() { + piece.move(TRANSLATION.DOWN) + }, + + lockDown: function() { + scheduler.clearTimeout(game.lockDown) + scheduler.clearInterval(game.fall) + + if (matrix.lock(piece)) { + scene.remove(piece) + let tSpin = piece.tSpin + let nbClearedLines = matrix.clearLines() + if (settings.sfxVolume) { + if (nbClearedLines == 4 || (tSpin && nbClearedLines)) { + tetrisSound.currentTime = 0 + tetrisSound.play() + } else if (nbClearedLines || tSpin) { + lineClearSound.currentTime = 0 + lineClearSound.play() + } + } + stats.lockDown(nbClearedLines, tSpin) + + game.generate() + } else { + game.over() // lock out + } + }, - if (piece) scheduler.setInterval(fall, stats.fallPeriod) - else generate() - } -} + pause: function() { + stats.elapsedTime = stats.clock.elapsedTime + stats.clock.stop() + + scheduler.clearInterval(game.fall) + scheduler.clearTimeout(game.lockDown) + scheduler.clearTimeout(repeat) + scheduler.clearInterval(autorepeat) + + music.pause() + document.onkeydown = null + renderer.domElement.tabIndex = 1 + renderer.domElement.onfocus = game.resume -var piece = null -function generate(heldPiece) { - if (heldPiece) { - piece = heldPiece - } else { - piece = nextQueue.shift() - } - piece.position.set(4, SKYLINE) - scene.add(piece) - ghost.copy(piece) - scene.add(ghost) + messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>PAUSE</h1>` }) + }, - if (piece.canMove(TRANSLATION.NONE)) { - scheduler.setInterval(fall, stats.fallPeriod) - } else { - gameOver() // block out - } + over: function() { + piece.locked = false + + document.onkeydown = null + renderer.domElement.onblur = null + renderer.domElement.onfocus = null + game.playing = false + music.pause() + stats.clock.stop() + localStorage["teTraHighScore"] = stats.highScore + messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>GAME<br/>OVER</h1>` }) + }, } let playerActions = { @@ -1085,31 +1072,31 @@ let playerActions = { moveRight: () => piece.move(TRANSLATION.RIGHT), - rotateClockwise: () => piece.rotate(ROTATION.CW), + rotateCW: () => piece.rotate(ROTATION.CW), - rotateCounterclockwise: () => piece.rotate(ROTATION.CCW), + rotateCCW: () => piece.rotate(ROTATION.CCW), softDrop: function () { if (piece.move(TRANSLATION.DOWN)) stats.score++ }, hardDrop: function () { - scheduler.clearTimeout(lockDown) + scheduler.clearTimeout(game.lockDown) hardDropSound.play() if (settings.sfxVolume) { hardDropSound.currentTime = 0 hardDropSound.play() } while (piece.move(TRANSLATION.DOWN)) stats.score += 2 - lockDown() + game.lockDown() hardDroppedMatrix.reset() hardDroppedMatrix.play() }, hold: function () { if (piece.holdEnabled) { - scheduler.clearInterval(fall) - scheduler.clearTimeout(lockDown) + scheduler.clearInterval(game.fall) + scheduler.clearTimeout(game.lockDown) let heldpiece = holdQueue.piece holdQueue.piece = piece @@ -1118,13 +1105,48 @@ let playerActions = { holdQueue.piece.position.set(0, 0) holdQueue.piece.facing = FACING.NORTH holdQueue.add(holdQueue.piece) - generate(heldpiece) + game.generate(heldpiece) } }, - pause: pauseSettings, + pause: game.pause, } +// Sounds +const listener = new THREE.AudioListener() +camera.add( listener ) +const audioLoader = new THREE.AudioLoader() +const music = new THREE.Audio(listener) +audioLoader.load('audio/Tetris_CheDDer_OC_ReMix.mp3', function( buffer ) { + music.setBuffer(buffer) + music.setLoop(true) + music.setVolume(settings.musicVolume/100) + music.play() +}) +const lineClearSound = new THREE.Audio(listener) +audioLoader.load('audio/line-clear.wav', function( buffer ) { + lineClearSound.setBuffer(buffer) + lineClearSound.setVolume(settings.sfxVolume/100) +}) +const tetrisSound = new THREE.Audio(listener) +audioLoader.load('audio/tetris.wav', function( buffer ) { + tetrisSound.setBuffer(buffer) + tetrisSound.setVolume(settings.sfxVolume/100) +}) +const hardDropSound = new THREE.Audio(listener) +audioLoader.load('audio/hard-drop.wav', function( buffer ) { + hardDropSound.setBuffer(buffer) + hardDropSound.setVolume(settings.sfxVolume/100) +}) + +let scheduler = new Scheduler() +var gui = new GUI().title("teTra") +let startButton = gui.add(game, "start").name("Démarrer") +let settings = new Settings(gui) +let stats = new Stats(gui) + +game.init() + // Handle player inputs const REPEATABLE_ACTIONS = [ playerActions.moveLeft, @@ -1135,18 +1157,19 @@ let pressedKeys = new Set() let actionsQueue = [] function onkeydown(event) { - if (event.key in settings.keyBind) { + let key = event.key + if (key in settings.keyBind) { event.preventDefault() - if (!pressedKeys.has(event.key)) { - pressedKeys.add(event.key) - let action = settings.keyBind[event.key] + if (!pressedKeys.has(key)) { + pressedKeys.add(key) + let action = settings.keyBind[key] action() if (REPEATABLE_ACTIONS.includes(action)) { actionsQueue.unshift(action) scheduler.clearTimeout(repeat) scheduler.clearInterval(autorepeat) if (action == playerActions.softDrop) scheduler.setInterval(autorepeat, settings.fallPeriod / 20) - else scheduler.setTimeout(repeat, settings.das) + else scheduler.setTimeout(repeat, settings.dasDelay) } } } @@ -1155,7 +1178,7 @@ function onkeydown(event) { function repeat() { if (actionsQueue.length) { actionsQueue[0]() - scheduler.setInterval(autorepeat, settings.arr) + scheduler.setInterval(autorepeat, settings.arrDelay) } } @@ -1168,10 +1191,11 @@ function autorepeat() { } function onkeyup(event) { - if (event.key in settings.keyBind) { + let key = event.key + if (key in settings.keyBind) { event.preventDefault() - pressedKeys.delete(event.key) - let action = settings.keyBind[event.key] + pressedKeys.delete(key) + let action = settings.keyBind[key] if (actionsQueue.includes(action)) { actionsQueue.splice(actionsQueue.indexOf(action), 1) if (!actionsQueue.length) { @@ -1182,52 +1206,9 @@ function onkeyup(event) { } } -function fall() { - piece.move(TRANSLATION.DOWN) -} - -function lockDown() { - scheduler.clearTimeout(lockDown) - scheduler.clearInterval(fall) - - if (matrix.lock(piece)) { - scene.remove(piece) - let tSpin = piece.tSpin - let nbClearedLines = matrix.clearLines() - if (settings.sfxVolume) { - if (nbClearedLines == 4 || (tSpin && nbClearedLines)) { - tetrisSound.currentTime = 0 - tetrisSound.play() - } else if (nbClearedLines || tSpin) { - lineClearSound.currentTime = 0 - lineClearSound.play() - } - } - stats.lockDown(nbClearedLines, tSpin) - - generate() - } else { - gameOver() // lock out - } -} - -function gameOver() { - piece.locked = false - - document.onkeydown = null - onblur = null - playing = false - music.pause() - - scheduler.clearInterval(tick) - - stats.show() -} - window.onbeforeunload = function (event) { - stats.save() settings.save() - if (playing) return false + if (game.playing) return false } diff --git a/audio/Tetris_CheDDer_OC_ReMix.mp3 b/audio/Tetris_CheDDer_OC_ReMix.mp3 new file mode 100644 index 0000000..bcc226e Binary files /dev/null and b/audio/Tetris_CheDDer_OC_ReMix.mp3 differ diff --git a/gui.html b/gui.html new file mode 100644 index 0000000..77eb617 --- /dev/null +++ b/gui.html @@ -0,0 +1,159 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.4/font/bootstrap-icons.css"> + <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> + <script type="importmap"> + { + "imports": { + "three": "https://unpkg.com/three@0.152.2/build/three.module.js?module", + "three/addons/": "https://unpkg.com/three@0.152.2/examples/jsm/" + } + } + </script> + <style> + body { + background: url(https://adrien.malingrey.fr/jeux/.assets/themes/clouds/background.jpg); + } + .lil-gui { + --background-color: rgba(33, 37, 41, 30%); + backdrop-filter: blur(15px); + } + .lil-gui.autoPlace { + left: 15px; + } + .lil-gui .controller.disabled { + opacity: .8; + } + i { + display: inline-block; + width: 100%; + text-align: center; + } + </style> +</head> +<body> + <script type="module"> + import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; + + var game = { + startLevel: 1, + start: () => { + gui.gameFolder.hide() + gui.statsFolder.show() + gui.statsFolder.open() + gui.settingsFolder.close() + }, + } + + var stats = { + level: 1, + goal: 0, + score: 0, + highScore:0, + time: "00:00:00", + } + + var settings = { + moveLeftKey : "ArrowLeft", + moveRightKey: "ArrowRight", + rotateCCWKey: "w", + rotateCWKey : "ArrowUp", + softDropKey : "ArrowDown", + hardDropKey : " ", + holdKey : "c", + pauseKey : "Escape", + + arrDelay: 50, + dasDelay: 300, + + musicVolume: 50, + sfxVolume : 50, + }; + + const KEY_NAMES = { + ["ArrowLeft"] : "←", + ["ArrowRight"] : "→", + ["ArrowUp"] : "↑", + ["ArrowDown"] : "↓", + [" "] : "Espace", + ["Escape"] : "Échap.", + ["Backspace"] : "Ret. arrière", + ["Enter"] : "Entrée", + ["←"] : "ArrowLeft", + ["→"] : "ArrowRight", + ["↑"] : "ArrowUp", + ["↓"] : "ArrowDown", + ["Espace"] : " ", + ["Échap."] : "Escape", + ["Ret. arrière"]: "Backspace", + ["Entrée"] : "Enter", + } + + function changeKey(event) { + const input = event.target + let prevValue = input.value + input.value = "" + input.onkeydown = function (event) { + event.preventDefault() + input.value = KEY_NAMES[event.key] || event.key + input.blur() + } + input.onblur = function (event) { + if (input.value == "") input.value = prevValue + input.onkeydown = null + input.onblur = null + } + } + + class Gui extends GUI { + constructor() { + super({title: "teTra"}); + + this.gameFolder = this.addFolder("Partie") + this.gameFolder.add(game, "startLevel").name("Niveau").min(1).max(15).step(1) + this.gameFolder.add(game, "start").name("Commencer") + this.gameFolder.open() + + this.statsFolder = this.addFolder("Stats") + this.statsFolder.add(stats,"level").name("Niveau").disable() + this.statsFolder.add(stats,"goal").name("Objectif").disable() + this.statsFolder.add(stats,"score").name("Score").disable() + this.statsFolder.add(stats,"highScore").name("Meilleur score").disable() + this.statsFolder.add(stats,"time").name("Temps").disable() + this.statsFolder.hide() + + this.settingsFolder = this.addFolder("Options"); + this.settingsFolder.close() + + this.settingsFolder.keyMapping = this.settingsFolder.addFolder("Commandes") + this.settingsFolder.keyMapping.add(settings,"moveLeftKey").name('<i class="bi bi-arrow-left"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"moveRightKey").name('<i class="bi bi-arrow-right"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"rotateCCWKey").name('<i class="bi bi-arrow-counterclockwise"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"rotateCWKey").name('<i class="bi bi-arrow-clockwise"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"softDropKey").name('<i class="bi bi-arrow-down-short"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"hardDropKey").name('<i class="bi bi-download"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"holdKey").name('<i class="bi bi-arrow-left-right"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.add(settings,"pauseKey").name('<i class="bi bi-pause"></i>').domElement.onclick = changeKey + this.settingsFolder.keyMapping.open() + + this.settingsFolder.delayFolder = this.settingsFolder.addFolder("Répétition automatique") + this.settingsFolder.delayFolder.add(settings,"arrDelay").name("ARR (ms)").min(2).max(200); + this.settingsFolder.delayFolder.add(settings,"dasDelay").name("DAS (ms)").min(100).max(500).step(5); + this.settingsFolder.delayFolder.open() + + this.settingsFolder.volumeFolder = this.settingsFolder.addFolder("Volume") + this.settingsFolder.volumeFolder.add(settings,"musicVolume").name("Musique").min(0).max(100); + this.settingsFolder.volumeFolder.add(settings,"sfxVolume").name("SFX").min(0).max(100) + this.settingsFolder.volumeFolder.open() + + } + } + + var gui = new Gui(); + + </script> +</body> +</html> diff --git a/index.html b/index.html index 613d3c1..c67d605 100644 --- a/index.html +++ b/index.html @@ -4,162 +4,32 @@ <head> <meta charset="utf-8" /> - <title>teTra</title> - <link rel="icon" href="favicon.ico"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.4/font/bootstrap-icons.css"> - <link rel="stylesheet" href="style.css"> - <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> - <script type="importmap"> - { - "imports": { - "three": "https://unpkg.com/three@0.152.2/build/three.module.js?module", - "three/addons/": "https://unpkg.com/three@0.152.2/examples/jsm/" - } + <title>teTra</title> + <link rel="icon" href="favicon.ico"> + <link rel="stylesheet" href="style.css"> + <link rel="stylesheet" href="loading.css"> + <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> + <script type="importmap"> + { + "imports": { + "three": "https://unpkg.com/three@0.152.2/build/three.module.js?module", + "three/addons/": "https://unpkg.com/three@0.152.2/examples/jsm/" } - </script> + } + </script> </head> - <body data-bs-theme="dark"> - - <div class="modal fade" id="settingsModal" data-bs-backdrop="static" data-bs-keyboard="false"> - <div class="modal-dialog modal-dialog-centered"> - <div class="modal-content"> - <div class="modal-header"> - <h1 id="titleHeader" class="modal-title w-100 text-center">teTra</h1> - </div> - <div class="modal-body"> - <form name="settingsForm" class="needs-validation" novalidate> - <fieldset id="keyBindFielset" class="row g-2 mb-3 align-items-center text-center"> - <legend class="text-start">Commandes</legend> - <label for="moveLeftInput" title="Gauche" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-left"></i> - </label> - <div class="col-sm-4"> - <input name="moveLeft" id="moveLeftInput" type="text" class="form-control text-center" value="←" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <div class="col-sm-4"> - <input name="moveRight" id="moveRightInput" type="text" class="form-control text-center" value="→" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <label for="moveRightInput" title="Droite" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-right"></i> - </label> - <label for="rotateCounterclockwiseInput" title="Rotation anti-horaire" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-counterclockwise"></i> - </label> - <div class="col-sm-4"> - <input name="rotateCounterclockwise" id="rotateCounterclockwiseInput" type="text" class="form-control text-center" value="w" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <div class="col-sm-4"> - <input name="rotateClockwise" id="rotateClockwiseInput" type="text" class="form-control text-center" value="↑" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <label for="rotateClockwiseInput" title="Rotation horaire" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-clockwise"></i> - </label> - <label for="softDropInput" title="Chute lente" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-down-short"></i> - </label> - <div class="col-sm-4"> - <input name="softDrop" id="softDropInput" type="text" class="form-control text-center" value="↓" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <div class="col-sm-4"> - <input name="hardDrop" id="hardDropInput" type="text" class="form-control text-center" value="Espace" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <label for="hardDropInput" title="Chute rapide" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-download"></i> - </label> - <label for="holdInput" title="Échanger la pièce" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-arrow-left-right"></i> - </label> - <div class="col-sm-4"> - <input name="hold" id="holdInput" type="text" class="form-control text-center" value="c" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <div class="col-sm-4"> - <input name="pause" id="pauseInput" type="text" class="form-control text-center" value="Échap" onclick="changeKey(this)" placeholder="Touche ?" required> - </div> - <label for="pauseInput" title="Pause" class="col-sm-2 col-form-label d-flex align-items-center justify-content-center"> - <i class="bi bi-pause"></i> - </label> - </fieldset> - <fieldset id="autorepearFieldset" class="row g-2 mb-3 align-items-center text-center"> - <legend class="text-start">Répétition automatique</legend> - <label for="arrInput" class="col-sm-2 col-form-label" title="Automatic Repeat Rate : période de répétition de l'action">ARR</label> - <div class="col-sm-4"> - <div class="input-group"> - <input name="arr" id="arrInput" type="number" class="form-control text-center" value="50" min="2" max="200" step="1"> - <div class="input-group-text">ms</div> - </div> - </div> - <div class="col-sm-4"> - <div class="input-group"> - <input name="das" id="dasInput" type="number" class="form-control text-center" value="300" min="100" max="500" step="5"> - <div class="input-group-text">ms</div> - </div> - </div> - <label for="dasInput" class="col-sm-2 col-form-label" title="Delayed AutoShift : délai initial avant répétition">DAS</label> - </fieldset> - <fieldset id="audioFieldset" class="row g-2 mb-3 align-items-center text-center"> - <legend class="text-start">Volume</legend> - <label for="musicVolumeInput" class="col-sm-2 col-form-label">Musique</label> - <div class="col-sm-4"> - <input name="musicVolume" id="musicVolumeInput" type="range" class="form-range" value=".5" min="0" max="1" step="0.01"> - </div> - <div class="col-sm-4"> - <input name="sfxVolume" id="sfxVolumeInput" type="range" class="form-range" value=".5" min="0" max="1" step="0.01"> - </div> - <label for="sfxVolumeInput" class="col-sm-2 col-form-label">SFX</label> - </fieldset> - <fieldset class="row g-2 mb-3 align-items-center text-center"> - <legend class="text-start">Partie</legend> - <label for="levelInput" class="col-sm-2 col-form-label text-center">Niveau</label> - <div class="col-sm-4"> - <input name="startLevel" id="levelInput" type="number" class="form-control text-center" value="1" min="1" max="15"> - </div> - <div class="col-sm-4"> - <button id="resumeButton" type="submit" class="btn btn-primary w-100" autofocus>Jouer</button> - </div> - </fieldset> - </form> - </div> - </div> - </div> - </div> - - <span id="messagesSpan"> - <div class="progress" role="progressbar"> - <div id="loadingBar" class="progress-bar overflow-visible progress-bar-striped progress-bar-animated" style="width: 0%">Chargement...</div> - </div> - </span> - - <span id="scoreSpan">Score<br/><div id="scoreDiv">0</div></span> - <span id="timeSpan">Temps<br/><div id="timeDiv">00:00:00</div></span> - <span id="levelSpan">Niveau<br/><div id="levelDiv">0</div></span> - <span id="goalSpan">Objectif<br/><div id="goalDiv">0</div></span> - - <div class="modal fade" id="statsModal" tabindex="-1"> - <div class="modal-dialog modal-dialog-centered"> - <div class="modal-dialog"> - <div class="modal-content"> - <div class="modal-header"> - <h2 class="modal-title w-100 text-center">Fin</h2> - <button type="button" class="btn-close" data-bs-dismiss="modal"></button> - </div> - <div class="modal-body p-0"> - <table class="table mb-0"> - <tr><th>Score </th><td id="statsModalScoreCell"> </td><th>Niveau </th><td id="statsModalLevelCell"> </td></tr> - <tr><th>Meilleur score</th><td id="statsModalHighScoreCell"> </td><th>Temps </th><td id="statsModalTimeCell"> </td></tr> - <tr><th>Lignes </th><td id="statsModaltotalClearedLines"></td><th>Lignes par minute </th><td id="statsModaltotalClearedLinesPM"></td></tr> - <tr><th>Quatris </th><td id="statsModalNbQuatris"> </td><th>Plus long combo </th><td id="statsModalMaxCombo"> </td></tr> - <tr><th>Pirouettes </th><td id="statsModalNbTSpin"> </td><th>Plus long bout à bout</th><td id="statsModalMaxB2B"> </td></tr> - </table> - </div> - <div class="modal-footer"> - <button id="restartButton" type="button" class="btn btn-primary" onclick="restart()"">Rejouer ?</button> - </div> - </div> + <body> + <div id="loaddingCircle"> + <div class="e-loadholder"> + <div class="m-loader"> + <span class="e-text"> + <div>Chargement</div> + <div id="loadingPercent">0%</div></span> </div> </div> </div> + <span id="messagesSpan"></span> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous"></script> <script type="module" src="app.js"></script> </body> diff --git a/loading.css b/loading.css new file mode 100644 index 0000000..81dc7e0 --- /dev/null +++ b/loading.css @@ -0,0 +1,274 @@ +@-webkit-keyframes outerRotate1 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@-moz-keyframes outerRotate1 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@-o-keyframes outerRotate1 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@keyframes outerRotate1 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@-webkit-keyframes outerRotate2 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +@-moz-keyframes outerRotate2 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +@-o-keyframes outerRotate2 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +@keyframes outerRotate2 { + 0% { + transform: translate(-50%, -50%) rotate(0); + } + + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +@-webkit-keyframes textColour { + 0% { + color: #fff; + } + + 100% { + color: #3BB2D0; + } +} + +@-moz-keyframes textColour { + 0% { + color: #fff; + } + + 100% { + color: #3BB2D0; + } +} + +@-o-keyframes textColour { + 0% { + color: #fff; + } + + 100% { + color: #3BB2D0; + } +} + +@keyframes textColour { + 0% { + color: #fff; + } + + 100% { + color: #3BB2D0; + } +} + +#loaddingCircle { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; +} + +.e-loadholder { + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + width: 240px; + height: 240px; + border: 5px solid #1B5F70; + border-radius: 120px; + box-sizing: border-box; +} + +.e-loadholder:after { + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + content: " "; + display: block; + background: #222; + transform-origin: center; + z-index: 0; +} + +.e-loadholder:after { + width: 100px; + height: 200%; + -webkit-animation: outerRotate2 30s infinite linear; + -moz-animation: outerRotate2 30s infinite linear; + -o-animation: outerRotate2 30s infinite linear; + animation: outerRotate2 30s infinite linear; +} + +.e-loadholder .m-loader { + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + width: 200px; + height: 200px; + color: #888; + text-align: center; + border: 5px solid #2a93ae; + border-radius: 100px; + box-sizing: border-box; + z-index: 20; + text-transform: uppercase; +} + +.e-loadholder .m-loader:after { + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + content: " "; + display: block; + background: #222; + transform-origin: center; + z-index: -1; +} + +.e-loadholder .m-loader:after { + width: 100px; + height: 106%; + -webkit-animation: outerRotate1 15s infinite linear; + -moz-animation: outerRotate1 15s infinite linear; + -o-animation: outerRotate1 15s infinite linear; + animation: outerRotate1 15s infinite linear; +} + +.e-loadholder .m-loader .e-text { + font-family: "Open Sans", sans-serif; + font-size: 10px; + font-size: 1rem; + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + -webkit-animation: textColour 1s alternate linear infinite; + -moz-animation: textColour 1s alternate linear infinite; + -o-animation: textColour 1s alternate linear infinite; + animation: textColour 1s alternate linear infinite; + display: flex; + flex-direction: column; + justify-content: center; + width: 140px; + height: 140px; + text-align: center; + border: 5px solid #3bb2d0; + border-radius: 70px; + box-sizing: border-box; + z-index: 20; +} + +.e-loadholder .m-loader .e-text:before, .e-loadholder .m-loader .e-text:after { + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-51%, -50%); + -moz-transform: translate(-51%, -50%); + -ms-transform: translate(-51%, -50%); + -o-transform: translate(-51%, -50%); + transform: translate(-51%, -50%); + content: " "; + display: block; + background: #222; + transform-origin: center; + z-index: -1; +} + +.e-loadholder .m-loader .e-text:before { + width: 110%; + height: 40px; + -webkit-animation: outerRotate2 3.5s infinite linear; + -moz-animation: outerRotate2 3.5s infinite linear; + -o-animation: outerRotate2 3.5s infinite linear; + animation: outerRotate2 3.5s infinite linear; +} + +.e-loadholder .m-loader .e-text:after { + width: 40px; + height: 110%; + -webkit-animation: outerRotate1 8s infinite linear; + -moz-animation: outerRotate1 8s infinite linear; + -o-animation: outerRotate1 8s infinite linear; + animation: outerRotate1 8s infinite linear; +} \ No newline at end of file diff --git a/style.css b/style.css index 9d0b2df..30605d4 100644 --- a/style.css +++ b/style.css @@ -1,58 +1,34 @@ body { - margin: 0 + margin: 0; + background-color: #222; } span { position: absolute; } -#messagesSpan .progress { - margin-top: 70vh; - opacity: 1; +.lil-gui { + --background-color: rgba(33, 37, 41, 30%); + --width: 200px; } - -#messagesSpan .progress-bar { - opacity: inherit; -} - -#scoreSpan { - top: 1rem; - left: 1rem; - text-align: left; -} - -#timeSpan { - top: 1rem; - right: 1rem; - text-align: right; -} - -#levelSpan { - bottom: 1rem; - left: 1rem; - text-align: left; -} - -#goalSpan { - bottom: 1rem; - right: 1rem; - text-align: right; -} - @supports (backdrop-filter: blur()) { - .card, - .modal-content { - background-color: rgba(33, 37, 41, 30%); + .lil-gui { backdrop-filter: blur(15px); } } -canvas { - cursor: grab; +.lil-gui.autoPlace { + top: inherit; + bottom: 15px; + left: 15px; } -#titleHeader { - letter-spacing: 1rem; +.lil-gui .controller.disabled { + opacity: .8; +} + +canvas { + cursor: grab; } #messagesSpan { @@ -63,7 +39,12 @@ canvas { transform: translate(-50%, 0); color: rgba(255, 255, 255, 0.8); text-shadow: 1px 1px rgba(0, 0, 0, 0.8); - font-size: 4vmin; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-weight: 400; + line-height: 1.5; + font-size: 3vmin; text-align: center; } @@ -72,6 +53,10 @@ canvas { overflow: hidden; } +h1 { + font-size: calc(1.375rem + 1.5vw); +} + @keyframes show-level-animation { from { opacity: 0;