From ae1fa3e4ad45e5ac139ba1e0cd5564072d0acb90 Mon Sep 17 00:00:00 2001
From: adrien <adrien@malingrey.fr>
Date: Tue, 4 Jul 2023 23:48:57 +0200
Subject: [PATCH] refactoring

---
 app.js                                     | 312 +--------------------
 gui.html                                   | 159 -----------
 jsm/common.js                              |  28 ++
 service-worker.js => jsm/service-worker.js |   0
 jsm/settings.js                            |  82 ++++++
 jsm/stats.js                               | 166 +++++++++++
 jsm/utils.js                               |  29 ++
 7 files changed, 310 insertions(+), 466 deletions(-)
 delete mode 100644 gui.html
 create mode 100644 jsm/common.js
 rename service-worker.js => jsm/service-worker.js (100%)
 create mode 100644 jsm/settings.js
 create mode 100644 jsm/stats.js
 create mode 100644 jsm/utils.js

diff --git a/app.js b/app.js
index 9fe9c76..6f1fb1e 100644
--- a/app.js
+++ b/app.js
@@ -2,6 +2,10 @@ 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';
+import { T_SPIN } from './jsm/common.js'
+import { Settings } from './jsm/settings.js'
+import { Stats } from './jsm/stats.js'
+import { Scheduler } from './jsm/utils.js'
 
 let P = (x, y, z = 0) => new THREE.Vector3(x, y, z)
 
@@ -22,11 +26,6 @@ const ROWS = 24
 const SKYLINE = 20
 const COLUMNS = 10
 
-const DELAY = {
-    LOCK: 500,
-    FALL: 1000,
-}
-
 const COLORS = {
     I: 0xafeff9,
     J: 0xb8b4ff,
@@ -56,57 +55,6 @@ const ROTATION = {
     CCW: -1,  // CounterClockWise
 }
 
-const T_SPIN = {
-    NONE: "",
-    MINI: "PETITE<br/>PIROUETTE",
-    T_SPIN: "PIROUETTE"
-}
-
-// score = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
-const AWARDED_LINE_CLEARS = {
-    [T_SPIN.NONE]: [0, 1, 3, 5, 8],
-    [T_SPIN.MINI]: [1, 2],
-    [T_SPIN.T_SPIN]: [4, 8, 12, 16]
-}
-
-const CLEARED_LINES_NAMES = [
-    "",
-    "SOLO",
-    "DUO",
-    "TRIO",
-    "TETRA",
-]
-
-
-/* Classes */
-
-class Scheduler {
-    constructor() {
-        this.intervalTasks = new Map()
-        this.timeoutTasks = new Map()
-    }
-
-    setInterval(func, delay, ...args) {
-        this.intervalTasks.set(func, window.setInterval(func, delay, ...args))
-    }
-
-    setTimeout(func, delay, ...args) {
-        this.timeoutTasks.set(func, window.setTimeout(func, delay, ...args))
-    }
-
-    clearInterval(func) {
-        if (this.intervalTasks.has(func))
-            window.clearInterval(this.intervalTasks.get(func))
-        this.intervalTasks.delete(func)
-    }
-
-    clearTimeout(func) {
-        if (this.timeoutTasks.has(func))
-            window.clearTimeout(this.timeoutTasks.get(func))
-        this.timeoutTasks.delete(func)
-    }
-}
-
 
 class Matrix extends THREE.Group {
     constructor() {
@@ -231,23 +179,17 @@ Mino.prototype.geometry = new THREE.ExtrudeGeometry(minoFaceShape, minoExtrudeSe
 
 
 class MinoMaterial extends THREE.MeshBasicMaterial {
-
     constructor(color) {
         super({
             side: THREE.DoubleSide,
             color: color,
             envMap: minoRenderTarget.texture,
             reflectivity: 0.9,
-            //roughness: 0,
-            //metalness: 0.85,
-
         })
     }
-
 }
 
 class GhostMaterial extends THREE.MeshBasicMaterial {
-
     constructor(color) {
         super({
             side: THREE.DoubleSide,
@@ -258,7 +200,6 @@ class GhostMaterial extends THREE.MeshBasicMaterial {
             opacity: 0.25
         })
     }
-
 }
 
 
@@ -477,249 +418,6 @@ Ghost.prototype.minoesPosition = [
 ]
 
 
-class Stats {
-    constructor() {
-        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
-
-        this.init()
-    }
-
-    init() {
-        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()
-        this.lockDelay = DELAY.LOCK
-        this.totalClearedLines = 0
-        this.nbTetra = 0
-        this.nbTSpin = 0
-        this.maxCombo = 0
-        this.maxB2B = 0
-    }
-
-    set score(score) {
-        this._score = score
-        if (score > this.highScore) {
-            this.highScore = score
-        }
-    }
-
-    get score() {
-        return this._score
-    }
-
-    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)
-        messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>NIVEAU<br/>${this.level}</h1>` })
-    }
-
-    get level() {
-        return this._level
-    }
-
-    set combo(combo) {
-        this._combo = combo
-        if (combo > this.maxCombo) this.maxCombo = combo
-    }
-
-    get combo() {
-        return this._combo
-    }
-
-    set b2b(b2b) {
-        this._b2b = b2b
-        if (b2b > this.maxB2B) this.maxB2B = b2b
-    }
-
-    get b2b() {
-        return this._b2b
-    }
-
-    get time() {
-        return this.clock.timeFormat.format(this.clock.elapsedTime * 1000)
-    }
-
-    lockDown(nbClearedLines, tSpin) {
-        this.totalClearedLines += nbClearedLines
-        if (nbClearedLines == 4) this.nbTetra++
-        if (tSpin == T_SPIN.T_SPIN) this.nbTSpin++
-
-        // Cleared lines & T-Spin
-        let awardedLineClears = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
-        let patternScore = 100 * this.level * awardedLineClears
-        if (tSpin) messagesSpan.addNewChild("div", {
-            className: "rotate-in-animation",
-            innerHTML: tSpin
-        })
-        if (nbClearedLines) messagesSpan.addNewChild("div", {
-            className: "zoom-in-animation",
-            innerHTML: CLEARED_LINES_NAMES[nbClearedLines]
-        })
-        if (patternScore) {
-            messagesSpan.addNewChild("div", {
-                className: "zoom-in-animation",
-                style: "animation-delay: .2s",
-                innerHTML: patternScore
-            })
-            this.score += patternScore
-        }
-
-        // Combo
-        if (nbClearedLines) {
-            this.combo++
-            if (this.combo >= 1) {
-                let comboScore = (nbClearedLines == 1 ? 20 : 50) * this.combo * this.level
-                if (this.combo == 1) {
-                    messagesSpan.addNewChild("div", {
-                        className: "zoom-in-animation",
-                        style: "animation-delay: .4s",
-                        innerHTML: `COMBO<br/>${comboScore}`
-                    })
-                } else {
-                    messagesSpan.addNewChild("div", {
-                        className: "zoom-in-animation",
-                        style: "animation-delay: .4s",
-                        innerHTML: `COMBO x${this.combo}<br/>${comboScore}`
-                    })
-                }
-                this.score += comboScore
-            }
-        } else {
-            this.combo = -1
-        }
-
-        // Back to back sequence
-        if ((nbClearedLines == 4) || (tSpin && nbClearedLines)) {
-            this.b2b++
-            if (this.b2b >= 1) {
-                let b2bScore = patternScore / 2
-                if (this.b2b == 1) {
-                    messagesSpan.addNewChild("div", {
-                        className: "zoom-in-animation",
-                        style: "animation-delay: .4s",
-                        innerHTML: `BOUT À BOUT<br/>${b2bScore}`
-                    })
-                } else {
-                    messagesSpan.addNewChild("div", {
-                        className: "zoom-in-animation",
-                        style: "animation-delay: .4s",
-                        innerHTML: `BOUT À BOUT x${this.b2b}<br/>${b2bScore}`
-                    })
-                }
-                this.score += b2bScore
-            }
-        } else if (nbClearedLines && !tSpin) {
-            if (this.b2b >= 1) {
-                messagesSpan.addNewChild("div", {
-                    className: "zoom-in-animation",
-                    style: "animation-delay: .4s",
-                    innerHTML: `FIN DU BOUT À BOUT`
-                })
-            }
-            this.b2b = -1
-        }
-
-        this.goal -= awardedLineClears
-        if (this.goal <= 0) this.level++
-    }
-}
-
-
-
-let jsKeyRenamer = new Proxy({
-    ["←"]           : "ArrowLeft",
-    ["→"]           : "ArrowRight",
-    ["↑"]           : "ArrowUp",
-    ["↓"]           : "ArrowDown",
-    ["Espace"]      : " ",
-    ["Échap."]      : "Escape",
-    ["Ret. arrière"]: "Backspace",
-    ["Entrée"]      : "Enter",
-}, {
-    get(obj, keyName) {
-        return keyName in obj? obj[keyName] : keyName
-    }
-})
-let friendyKeyRenamer = new Proxy({
-    ["ArrowLeft"]   : "←",
-    ["ArrowRight"]  : "→",
-    ["ArrowUp"]     : "↑",
-    ["ArrowDown"]   : "↓",
-    [" "]           : "Espace",
-    ["Escape"]      : "Échap.",
-    ["Backspace"]   : "Ret. arrière",
-    ["Enter"]       : "Entrée",
-}, {
-    get(obj, keyName) {
-        return keyName in obj? obj[keyName] : keyName
-    }
-})
-
-class Settings {
-    constructor() {
-        this.startLevel = 1
-
-        let keyMaps = {
-			key: {},
-			action: {}
-		}
-
-        this.key = new Proxy(keyMaps, {
-			set(km, action, key) {
-				km.action[key] = action
-                return km.key[action] = jsKeyRenamer[key]
-			},
-            has(km, action) {
-                return action in km.key
-            },
-			get(km, action) {
-				return friendyKeyRenamer[km.key[action]]
-			}
-		})
-		this.action = new Proxy(keyMaps, {
-			set(km, key, action) {
-				km.key[action] = key
-                return km.action[key] = action
-			},
-            has(km, key) {
-                return key in km.action
-            },
-			get(km, key) {
-				return km.action[key]
-			}
-		})
-
-        this.key.moveLeft  = "ArrowLeft"
-        this.key.moveRight = "ArrowRight"
-        this.key.rotateCCW = "w"
-        this.key.rotateCW  = "ArrowUp"
-        this.key.softDrop  = "ArrowDown"
-        this.key.hardDrop  = " "
-        this.key.hold      = "c"
-        this.key.pause     = "Escape"
-
-        this.arrDelay = 50
-        this.dasDelay = 300
-        
-        this.musicVolume = 50
-        this.sfxVolume   = 50
-    }
-}
-
-
 class TetraGUI extends GUI {
     constructor(game, settings, stats, debug=false) {
         super({title: "teTra"})
@@ -1288,5 +986,5 @@ window.onbeforeunload = function (event) {
 
 
 if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.register('service-worker.js');
+    navigator.serviceWorker.register('jsm/service-worker.js');
 }
\ No newline at end of file
diff --git a/gui.html b/gui.html
deleted file mode 100644
index 77eb617..0000000
--- a/gui.html
+++ /dev/null
@@ -1,159 +0,0 @@
-<!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/jsm/common.js b/jsm/common.js
new file mode 100644
index 0000000..121918d
--- /dev/null
+++ b/jsm/common.js
@@ -0,0 +1,28 @@
+const DELAY = {
+  LOCK: 500,
+  FALL: 1000,
+}
+
+const T_SPIN = {
+  NONE: "",
+  MINI: "PETITE<br/>PIROUETTE",
+  T_SPIN: "PIROUETTE"
+}
+
+// score = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
+const AWARDED_LINE_CLEARS = {
+  [T_SPIN.NONE]: [0, 1, 3, 5, 8],
+  [T_SPIN.MINI]: [1, 2],
+  [T_SPIN.T_SPIN]: [4, 8, 12, 16]
+}
+
+const CLEARED_LINES_NAMES = [
+  "",
+  "SOLO",
+  "DUO",
+  "TRIO",
+  "TETRA",
+]
+
+
+export { DELAY, T_SPIN, AWARDED_LINE_CLEARS, CLEARED_LINES_NAMES }
\ No newline at end of file
diff --git a/service-worker.js b/jsm/service-worker.js
similarity index 100%
rename from service-worker.js
rename to jsm/service-worker.js
diff --git a/jsm/settings.js b/jsm/settings.js
new file mode 100644
index 0000000..45ca26b
--- /dev/null
+++ b/jsm/settings.js
@@ -0,0 +1,82 @@
+let jsKeyRenamer = new Proxy({
+  ["←"]           : "ArrowLeft",
+  ["→"]           : "ArrowRight",
+  ["↑"]           : "ArrowUp",
+  ["↓"]           : "ArrowDown",
+  ["Espace"]      : " ",
+  ["Échap."]      : "Escape",
+  ["Ret. arrière"]: "Backspace",
+  ["Entrée"]      : "Enter",
+}, {
+  get(obj, keyName) {
+      return keyName in obj? obj[keyName] : keyName
+  }
+})
+let friendyKeyRenamer = new Proxy({
+  ["ArrowLeft"]   : "←",
+  ["ArrowRight"]  : "→",
+  ["ArrowUp"]     : "↑",
+  ["ArrowDown"]   : "↓",
+  [" "]           : "Espace",
+  ["Escape"]      : "Échap.",
+  ["Backspace"]   : "Ret. arrière",
+  ["Enter"]       : "Entrée",
+}, {
+  get(obj, keyName) {
+      return keyName in obj? obj[keyName] : keyName
+  }
+})
+
+class Settings {
+  constructor() {
+      this.startLevel = 1
+
+      let keyMaps = {
+    key: {},
+    action: {}
+  }
+
+      this.key = new Proxy(keyMaps, {
+    set(km, action, key) {
+      km.action[key] = action
+              return km.key[action] = jsKeyRenamer[key]
+    },
+          has(km, action) {
+              return action in km.key
+          },
+    get(km, action) {
+      return friendyKeyRenamer[km.key[action]]
+    }
+  })
+  this.action = new Proxy(keyMaps, {
+    set(km, key, action) {
+      km.key[action] = key
+              return km.action[key] = action
+    },
+          has(km, key) {
+              return key in km.action
+          },
+    get(km, key) {
+      return km.action[key]
+    }
+  })
+
+      this.key.moveLeft  = "ArrowLeft"
+      this.key.moveRight = "ArrowRight"
+      this.key.rotateCCW = "w"
+      this.key.rotateCW  = "ArrowUp"
+      this.key.softDrop  = "ArrowDown"
+      this.key.hardDrop  = " "
+      this.key.hold      = "c"
+      this.key.pause     = "Escape"
+
+      this.arrDelay = 50
+      this.dasDelay = 300
+      
+      this.musicVolume = 50
+      this.sfxVolume   = 50
+  }
+}
+
+
+export { Settings }
\ No newline at end of file
diff --git a/jsm/stats.js b/jsm/stats.js
new file mode 100644
index 0000000..d9dc60b
--- /dev/null
+++ b/jsm/stats.js
@@ -0,0 +1,166 @@
+import { Clock } from 'three'
+import { DELAY, T_SPIN, AWARDED_LINE_CLEARS, CLEARED_LINES_NAMES } from './common.js'
+
+
+class Stats {
+  constructor() {
+      this.clock = new Clock(false)
+      this.clock.timeFormat = new Intl.DateTimeFormat("fr-FR", {
+          hour: "numeric",
+          minute: "2-digit",
+          second: "2-digit",
+          timeZone: "UTC"
+      })
+      this.elapsedTime = 0
+
+      this.init()
+  }
+
+  init() {
+      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()
+      this.lockDelay = DELAY.LOCK
+      this.totalClearedLines = 0
+      this.nbTetra = 0
+      this.nbTSpin = 0
+      this.maxCombo = 0
+      this.maxB2B = 0
+  }
+
+  set score(score) {
+      this._score = score
+      if (score > this.highScore) {
+          this.highScore = score
+      }
+  }
+
+  get score() {
+      return this._score
+  }
+
+  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)
+      messagesSpan.addNewChild("div", { className: "show-level-animation", innerHTML: `<h1>NIVEAU<br/>${this.level}</h1>` })
+  }
+
+  get level() {
+      return this._level
+  }
+
+  set combo(combo) {
+      this._combo = combo
+      if (combo > this.maxCombo) this.maxCombo = combo
+  }
+
+  get combo() {
+      return this._combo
+  }
+
+  set b2b(b2b) {
+      this._b2b = b2b
+      if (b2b > this.maxB2B) this.maxB2B = b2b
+  }
+
+  get b2b() {
+      return this._b2b
+  }
+
+  get time() {
+      return this.clock.timeFormat.format(this.clock.elapsedTime * 1000)
+  }
+
+  lockDown(nbClearedLines, tSpin) {
+      this.totalClearedLines += nbClearedLines
+      if (nbClearedLines == 4) this.nbTetra++
+      if (tSpin == T_SPIN.T_SPIN) this.nbTSpin++
+
+      // Cleared lines & T-Spin
+      let awardedLineClears = AWARDED_LINE_CLEARS[tSpin][nbClearedLines]
+      let patternScore = 100 * this.level * awardedLineClears
+      if (tSpin) messagesSpan.addNewChild("div", {
+          className: "rotate-in-animation",
+          innerHTML: tSpin
+      })
+      if (nbClearedLines) messagesSpan.addNewChild("div", {
+          className: "zoom-in-animation",
+          innerHTML: CLEARED_LINES_NAMES[nbClearedLines]
+      })
+      if (patternScore) {
+          messagesSpan.addNewChild("div", {
+              className: "zoom-in-animation",
+              style: "animation-delay: .2s",
+              innerHTML: patternScore
+          })
+          this.score += patternScore
+      }
+
+      // Combo
+      if (nbClearedLines) {
+          this.combo++
+          if (this.combo >= 1) {
+              let comboScore = (nbClearedLines == 1 ? 20 : 50) * this.combo * this.level
+              if (this.combo == 1) {
+                  messagesSpan.addNewChild("div", {
+                      className: "zoom-in-animation",
+                      style: "animation-delay: .4s",
+                      innerHTML: `COMBO<br/>${comboScore}`
+                  })
+              } else {
+                  messagesSpan.addNewChild("div", {
+                      className: "zoom-in-animation",
+                      style: "animation-delay: .4s",
+                      innerHTML: `COMBO x${this.combo}<br/>${comboScore}`
+                  })
+              }
+              this.score += comboScore
+          }
+      } else {
+          this.combo = -1
+      }
+
+      // Back to back sequence
+      if ((nbClearedLines == 4) || (tSpin && nbClearedLines)) {
+          this.b2b++
+          if (this.b2b >= 1) {
+              let b2bScore = patternScore / 2
+              if (this.b2b == 1) {
+                  messagesSpan.addNewChild("div", {
+                      className: "zoom-in-animation",
+                      style: "animation-delay: .4s",
+                      innerHTML: `BOUT À BOUT<br/>${b2bScore}`
+                  })
+              } else {
+                  messagesSpan.addNewChild("div", {
+                      className: "zoom-in-animation",
+                      style: "animation-delay: .4s",
+                      innerHTML: `BOUT À BOUT x${this.b2b}<br/>${b2bScore}`
+                  })
+              }
+              this.score += b2bScore
+          }
+      } else if (nbClearedLines && !tSpin) {
+          if (this.b2b >= 1) {
+              messagesSpan.addNewChild("div", {
+                  className: "zoom-in-animation",
+                  style: "animation-delay: .4s",
+                  innerHTML: `FIN DU BOUT À BOUT`
+              })
+          }
+          this.b2b = -1
+      }
+
+      this.goal -= awardedLineClears
+      if (this.goal <= 0) this.level++
+  }
+}
+
+
+export { Stats }
\ No newline at end of file
diff --git a/jsm/utils.js b/jsm/utils.js
new file mode 100644
index 0000000..8486faa
--- /dev/null
+++ b/jsm/utils.js
@@ -0,0 +1,29 @@
+class Scheduler {
+  constructor() {
+      this.intervalTasks = new Map()
+      this.timeoutTasks = new Map()
+  }
+
+  setInterval(func, delay, ...args) {
+      this.intervalTasks.set(func, window.setInterval(func, delay, ...args))
+  }
+
+  setTimeout(func, delay, ...args) {
+      this.timeoutTasks.set(func, window.setTimeout(func, delay, ...args))
+  }
+
+  clearInterval(func) {
+      if (this.intervalTasks.has(func))
+          window.clearInterval(this.intervalTasks.get(func))
+      this.intervalTasks.delete(func)
+  }
+
+  clearTimeout(func) {
+      if (this.timeoutTasks.has(func))
+          window.clearTimeout(this.timeoutTasks.get(func))
+      this.timeoutTasks.delete(func)
+  }
+}
+
+
+export { Scheduler }
\ No newline at end of file