Array.prototype.add = function(other) { return this.map((x, i) => x + other[i]) } Array.prototype.mul = function(k) { return this.map(x => k * x) } Array.prototype.translate = function(vector) { return this.map(pos => pos.add(vector)) } Array.prototype.rotate = function(spin) { return [-spin*this[1], spin*this[0]] } Array.prototype.pick = function() { return this.splice(Math.floor(Math.random()*this.length), 1)[0] } const MINO_SIZE = 20 const NEXT_PIECES = 6 const HOLD_ROWS = 6 const HOLD_COLUMNS = 6 const MATRIX_ROWS = 24 const MATRIX_COLUMNS = 10 const NEXT_ROWS = 24 const NEXT_COLUMNS = 6 const HOLD_BG_COLOR = "" const HOLD_BORDER_COLOR = "" const MATRIX_BG_COLOR = "" const MATRIX_BORDER_COLOR = "#333" const NEXT_BG_COLOR = "" const NEXT_BORDER_COLOR = "" const MINO_BORDER_COLOR = "white" const HELD_PIECE_POSITION = [2, 3] const FALLING_PIECE_POSITION = [4, 3] const NEXT_PIECES_POSITIONS = Array.from({length: NEXT_PIECES}, (v, k) => [2, k*4+3]) const LOCK_DELAY = 500 const FALL_PERIOD = 1000 const AUTOREPEAT_DELAY = 300 const AUTOREPEAT_PERIOD = 10 const ANIMATION_DELAY = 100 const TEMP_TEXTS_DELAY = 700 const MOVEMENT = { LEFT: [-1, 0], RIGHT: [ 1, 0], DOWN: [ 0, 1] } const SPIN = { CW: 1, // ClockWise CCW: -1 // CounterClockWise } const T_SPIN = { NONE: "", MINI: "MINI\nT-SPIN", T_SPIN: "T-SPIN" } const T_SLOT = { A: 0, B: 1, C: 3, D: 2 } const T_SLOT_POS = [[-1, -1], [1, -1], [1, 1], [-1, 1]] const SCORES = [ {linesClearedName: "", "": 0, "MINI\nT-SPIN": 1, "T-SPIN": 4}, {linesClearedName: "SINGLE", "": 1, "MINI\nT-SPIN": 2, "T-SPIN": 8}, {linesClearedName: "DOUBLE", "": 3, "T-SPIN": 12}, {linesClearedName: "TRIPLE", "": 5, "T-SPIN": 16}, {linesClearedName: "TETRIS", "": 8}, ] const REPEATABLE_ACTIONS = [moveLeft, moveRight, softDrop] const STATE = { PLAYING: "PLAYING", PAUSED: "PAUSE", GAME_OVER: "GAME OVER" } const actionsDefaultKeys = { moveLeft: "ArrowLeft", moveRight: "ArrowRight", softDrop: "ArrowDown", hardDrop: " ", rotateCW: "ArrowUp", rotateCCW: "z", hold: "c", pause: "Escape", } var actions = {} 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) } } } randomBag = [] class Tetromino { constructor(position=null, shape=null) { this.pos = position this.orientation = 0 this.rotatedLast = false this.rotationPoint5Used = false this.holdEnabled = true this.locked = false this.srs = {} // Super Rotation System this.srs[SPIN.CW] = [ [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]], [[0, 0], [ 1, 0], [ 1, 1], [0, -2], [ 1, -2]], [[0, 0], [ 1, 0], [ 1, -1], [0, 2], [ 1, 2]], [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]], ] this.srs[SPIN.CCW] = [ [[0, 0], [ 1, 0], [ 1, -1], [0, 2], [ 1, 2]], [[0, 0], [ 1, 0], [ 1, 1], [0, -2], [ 1, -2]], [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]], [[0, 0], [-1, 0], [-1, 1], [0, 2], [-1, -2]], ] if (shape) this.shape = shape else { if (!randomBag.length) randomBag = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'] this.shape = randomBag.pick() } switch(this.shape) { case 'I': this.color = "rgb(153, 179, 255)" this.lightColor = "rgb(234, 250, 250)" this.transparentColor = "rgba(234, 250, 250, 0.5)" this.minoesPos = [[-1, 0], [0, 0], [1, 0], [2, 0]] this.srs[SPIN.CW] = [ [[ 1, 0], [-1, 0], [ 2, 0], [-1, 1], [ 2, -2]], [[ 0, 1], [-1, 1], [ 2, 1], [-1, -1], [ 2, 2]], [[-1, 0], [ 1, 0], [-2, 0], [ 1, -1], [-2, 2]], [[ 0, 1], [ 1, -1], [-2, -1], [ 1, 1], [-2, -2]], ] this.srs[SPIN.CCW] = [ [[ 0, 1], [-1, 1], [ 2, 1], [-1, -1], [ 2, 2]], [[-1, 0], [ 1, 0], [-2, 0], [ 1, -1], [-2, 2]], [[ 0, -1], [ 1, -1], [-2, -1], [ 1, 1], [-2, -2]], [[ 1, 0], [-1, 0], [ 2, 0], [-1, 1], [ 2, -2]], ] break case 'J': this.color = "rgb(153, 255, 255)" this.lightColor = "rgb(230, 240, 255)" this.transparentColor = "rgba(230, 240, 255, 0.5)" this.minoesPos = [[-1, -1], [-1, 0], [0, 0], [1, 0]] break case 'L': this.color = "rgb(255, 204, 153)" this.lightColor = "rgb(255, 224, 204)" this.transparentColor = "rgba(255, 224, 204, 0.5)" this.minoesPos = [[-1, 0], [0, 0], [1, 0], [1, -1]] break case 'O': this.color = " rgb(255, 255, 153)" this.lightColor = "rgb(255, 255, 230)" this.transparentColor = "rgba(255, 255, 230, 0.5)" this.minoesPos = [[0, 0], [1, 0], [0, -1], [1, -1]] this.srs[SPIN.CW] = [[]] this.srs[SPIN.CCW] = [[]] break case 'S': this.color = "rgb(153, 255, 153)" this.lightColor = "rgb(236, 255, 230)" this.transparentColor = "rgba(236, 255, 230, 0.5)" this.minoesPos = [[-1, 0], [0, 0], [0, -1], [1, -1]] break case 'T': this.color = "rgb(204, 153, 255)" this.lightColor= "rgb(242, 230, 255)" this.transparentColor = "rgba(242, 230, 255, 0.5)" this.minoesPos = [[-1, 0], [0, 0], [1, 0], [0, -1]] break case 'Z': this.color = "rgb(255, 153, 153)" this.lightColor = "rgb(255, 230, 230)" this.transparentColor = "rgba(255, 230, 230, 0.5)" this.minoesPos = [[-1, -1], [0, -1], [0, 0], [1, 0]] break } } get minoesAbsPos() { return this.minoesPos.translate(this.pos) } drawIn(table) { var bgColor = this.locked ? this.lightColor : this.color this.minoesAbsPos.forEach( pos => { drawMino(table.rows[pos[1]].cells[pos[0]], bgColor, MINO_BORDER_COLOR)}) } } function drawMino(cell, backgroundColor, borderColor) { cell.style.backgroundColor = backgroundColor cell.style.borderColor = borderColor } class MinoesTable { constructor(id, rows, columns, defaultBgColor, defaultBorderColor) { this.table = document.getElementById(id) this.rows = rows this.columns = columns this.defaultBgColor = defaultBgColor this.defaultBorderColor = defaultBorderColor this.width = columns * MINO_SIZE this.height = rows * MINO_SIZE this.piece = null for (var y=0; y < rows; y++) { var row = this.table.insertRow() for (var x=0; x < columns; x++) { row.insertCell() } } } clearTable() { for(var y=0; y < this.rows; y++) { for (var x=0; x < this.columns; x++) { var cell = this.table.rows[y].cells[x] drawMino(cell, this.defaultBgColor, this.defaultBorderColor) } } } } class HoldQueue extends MinoesTable { constructor() { super("hold", HOLD_ROWS, HOLD_COLUMNS, HOLD_BG_COLOR, HOLD_BORDER_COLOR) } draw() { this.clearTable() if (this.piece && state != STATE.PAUSED) this.piece.drawIn(this.table) } } class Matrix extends MinoesTable { constructor() { super("matrix", MATRIX_ROWS, MATRIX_COLUMNS, MATRIX_BG_COLOR, MATRIX_BORDER_COLOR) this.lockedMinoes = Array.from(Array(MATRIX_ROWS+3), row => Array(MATRIX_COLUMNS)) this.piece = null /*this.context.textAlign = "center" this.context.textBaseline = "center" this.context.font = "27px 'Share Tech', sans-serif" this.centerX = this.width / 2 this.centerY = this.height / 2 this.linesCleared = [] this.trail = { minoesPos: [], height: 0, gradient: null }*/ } cellIsOccupied(x, y) { return 0 <= x && x < MATRIX_COLUMNS && y < MATRIX_ROWS ? this.lockedMinoes[y][x] : true } spaceToMove(minoesAbsPos) { return !minoesAbsPos.some(pos => this.cellIsOccupied(...pos)) } draw() { if (state == STATE.PAUSED) { this.clearTable() } else { // locked minoes for(var y=0; y < this.rows; y++) { for (var x=0; x < this.columns; x++) { var cell = this.table.rows[y].cells[x] var bgColor = this.lockedMinoes[y][x] if (bgColor) drawMino(cell, bgColor, MINO_BORDER_COLOR) else { if (y < 4) drawMino(cell, this.defaultBgColor, "transparent") else drawMino(cell, this.defaultBgColor, MATRIX_BORDER_COLOR) } } } // trail /*if (this.trail.height) { this.context.fillStyle = this.trail.gradient this.trail.minoesPos.forEach(topLeft => { this.context.fillRect(...topLeft, MINO_SIZE, this.trail.height) }) }*/ // falling piece /*for (var ghostYOffset = 1; this.spaceToMove(this.piece.minoesAbsPos.translate([0, ghostYOffset])); ghostYOffset++) {} ghostYOffset--*/ this.piece.drawIn(this.table) // Lines cleared /*this.context.fillStyle = "rgba(255, 255, 255, 0.5)" this.linesCleared.forEach(y => this.context.fillRect(0, y, this.width, MINO_SIZE))*/ } // text /*var texts = [] switch(state) { case STATE.PLAYING: if (tempTexts.length) texts = tempTexts[0] break case STATE.PAUSED: texts = ["PAUSED"] break case STATE.GAME_OVER: texts = ["GAME", "OVER"] } if (texts.length) { this.context.save() this.context.shadowColor = "black" this.context.shadowOffsetX = 1 this.context.shadowOffsetY = 1 this.context.shadowBlur = 2 this.context.fillStyle = "white" if (texts.length == 1) this.context.fillText(texts[0], this.centerX, this.centerY) else { this.context.fillText(texts[0], this.centerX, this.centerY - 20) this.context.fillText(texts[1], this.centerX, this.centerY + 20) } this.context.restore() }*/ } } class NextQueue extends MinoesTable { constructor() { super("next", NEXT_ROWS, NEXT_COLUMNS, NEXT_BG_COLOR, NEXT_BORDER_COLOR) this.pieces = Array.from({length: NEXT_PIECES}, (v, k) => new Tetromino(NEXT_PIECES_POSITIONS[k])) } draw() { this.clearTable() if (state != STATE.PAUSED) { this.pieces.forEach(piece => piece.drawIn(this.table)) } } } timeFormat = new Intl.DateTimeFormat("fr-FR", { minute: "2-digit", second: "2-digit", hourCycle: "h24", timeZone: "UTC" }).format class Stats { constructor () { this.div = document.getElementById("stats-values") this._score = 0 this.highScore = localStorage.getItem('highScore') || 0 this.goal = 0 this.linesCleared = 0 this.startTime = Date.now() this.pauseTime = 0 this.combo = -1 this.lockDelay = LOCK_DELAY this.fallPeriod = FALL_PERIOD } get score() { return this._score } set score(score) { this._score = score if (score > this.highScore) this.highScore = score } newLevel(level=null) { if (level) this.level = level else this.level++ printTempTexts(["LEVEL", this.level]) this.goal += 5 * this.level if (this.level <= 20) this.fallPeriod = 1000 * Math.pow(0.8 - ((this.level - 1) * 0.007), this.level - 1) if (this.level > 15) this.lockDelay = 500 * Math.pow(0.9, this.level - 15) } lockDown(tSpin, linesCleared) { var patternName = [] var patternScore = 0 var combo_score = 0 if (tSpin) patternName.push(tSpin) if (linesCleared) { patternName.push(SCORES[linesCleared].linesClearedName) this.combo++ } else this.combo = -1 if (linesCleared || tSpin) { this.linesCleared += linesCleared patternScore = SCORES[linesCleared][tSpin] this.goal -= patternScore patternScore *= 100 * this.level patternName = patternName.join("\n") } if (this.combo >= 1) combo_score = (linesCleared == 1 ? 20 : 50) * this.combo * this.level this.score += patternScore + combo_score if (patternScore) printTempTexts([patternName, patternScore]) if (combo_score) printTempTexts([`COMBO x${this.combo}`, combo_score]) } print() { this.div.innerHTML = `${this.score}
${this.highScore}
${timeFormat(Date.now() - this.startTime)}

${this.level}
${this.goal}
${this.linesCleared}` } } function newLevel(startLevel) { stats.newLevel(startLevel) generationPhase() } function generationPhase(held_piece=null) { if (!held_piece) { matrix.piece = nextQueue.pieces.shift() nextQueue.pieces.push(new Tetromino()) nextQueue.pieces.forEach((piece, i) => piece.pos = NEXT_PIECES_POSITIONS[i]) } matrix.piece.pos = FALLING_PIECE_POSITION if (matrix.spaceToMove(matrix.piece.minoesPos.translate(matrix.piece.pos))){ scheduler.clearInterval(lockPhase) scheduler.setInterval(lockPhase, stats.fallPeriod) fallingPhase() } else gameOver() } function fallingPhase() { scheduler.clearTimeout(lockDown) matrix.piece.locked = false } function lockPhase() { if (!move(MOVEMENT.DOWN)) { matrix.piece.locked = true if (!scheduler.timeoutTasks.has(lockDown)) scheduler.setTimeout(lockDown, stats.lockDelay) } requestAnimationFrame(draw) } function move(movement, testMinoesPos=matrix.piece.minoesPos) { const testPos = matrix.piece.pos.add(movement) if (matrix.spaceToMove(testMinoesPos.translate(testPos))) { matrix.piece.pos = testPos matrix.piece.minoesPos = testMinoesPos if (movement != MOVEMENT.DOWN) matrix.piece.rotatedLast = false if (matrix.spaceToMove(matrix.piece.minoesPos.translate(matrix.piece.pos.add(MOVEMENT.DOWN)))) fallingPhase() else { matrix.piece.locked = true scheduler.clearTimeout(lockDown) scheduler.setTimeout(lockDown, stats.lockDelay) } return true } else { return false } } function rotate(spin) { const test_minoes_pos = matrix.piece.minoesPos.map(pos => pos.rotate(spin)) rotationPoint = 1 for (const movement of matrix.piece.srs[spin][matrix.piece.orientation]) { if (move(movement, test_minoes_pos)) { matrix.piece.orientation = (matrix.piece.orientation + spin + 4) % 4 matrix.piece.rotatedLast = true if (rotationPoint == 5) matrix.piece.rotationPoint5Used = true return true } rotationPoint++ } return false } function lockDown(){ scheduler.clearInterval(lockPhase) if (matrix.piece.minoesAbsPos.every(pos => pos.y < 4)) game_over() else { matrix.piece.minoesAbsPos.forEach(pos => matrix.lockedMinoes[pos[1]][pos[0]] = matrix.piece.color) // T-Spin detection var tSpin = T_SPIN.NONE if (matrix.piece.rotatedLast && matrix.piece.shape == "T") { const tSlots = T_SLOT_POS.translate(matrix.piece.pos).map(pos => matrix.cellIsOccupied(...pos)), a = tSlots[(matrix.piece.orientation+T_SLOT.A)%4], b = tSlots[(matrix.piece.orientation+T_SLOT.B)%4], c = tSlots[(matrix.piece.orientation+T_SLOT.C)%4], d = tSlots[(matrix.piece.orientation+T_SLOT.D)%4] if (a && b && (c || d)) tSpin = T_SPIN.T_SPIN else if (c && d && (a || b)) tSpin = matrix.piece.rotationPoint5Used ? T_SPIN.T_SPIN : T_SPIN.MINI } // Complete lines matrix.linesCleared = [] matrix.lockedMinoes.forEach((row, y) => { if (row.filter(mino => mino.length).length == MATRIX_COLUMNS) { matrix.lockedMinoes.splice(y, 1) matrix.lockedMinoes.unshift(Array(MATRIX_COLUMNS)) matrix.linesCleared.push((y-3) * MINO_SIZE) } }) stats.lockDown(tSpin, matrix.linesCleared.length) requestAnimationFrame(draw) scheduler.setTimeout(clearLinesCleared, ANIMATION_DELAY) if (stats.goal <= 0) newLevel() else generationPhase() } } function clearLinesCleared() { matrix.linesCleared = [] requestAnimationFrame(draw) } function gameOver() { state = STATE.GAME_OVER scheduler.clearInterval(lockPhase) scheduler.clearTimeout(lockDown) scheduler.clearInterval(clock) requestAnimationFrame(draw) if (stats.score == stats.highScore) { alert("Bravo !\nVous avez battu votre précédent record.") localStorage.setItem('highScore', stats.highScore) } } function autorepeat() { if (actionsToRepeat.length) { actionsToRepeat[0]() requestAnimationFrame(draw) if (scheduler.timeoutTasks.has(autorepeat)) { scheduler.clearTimeout(autorepeat) scheduler.setInterval(autorepeat, AUTOREPEAT_PERIOD) } } else { scheduler.clearTimeout(autorepeat) scheduler.clearInterval(autorepeat) } } function keyDownHandler(e) { if (e.key in actions[state]) e.preventDefault() if (!pressedKeys.has(e.key)) { pressedKeys.add(e.key) if (e.key in actions[state]) { action = actions[state][e.key] action() requestAnimationFrame(draw) if (REPEATABLE_ACTIONS.includes(action)) { actionsToRepeat.unshift(action) scheduler.clearTimeout(autorepeat) scheduler.clearInterval(autorepeat) if (action == softDrop) scheduler.setInterval(autorepeat, stats.fallPeriod / 20) else scheduler.setTimeout(autorepeat, AUTOREPEAT_DELAY) } } } } function keyUpHandler(e) { pressedKeys.delete(e.key) if (e.key in actions[state]) { action = actions[state][e.key] if (actionsToRepeat.includes(action)) { actionsToRepeat.splice(actionsToRepeat.indexOf(action), 1) if (!actionsToRepeat.length) { scheduler.clearTimeout(autorepeat) scheduler.clearInterval(autorepeat) } } } } function moveLeft() { move(MOVEMENT.LEFT); } function moveRight() { move(MOVEMENT.RIGHT) } function softDrop() { if (move(MOVEMENT.DOWN)) stats.score++ } function hardDrop() { scheduler.clearInterval(lockPhase) scheduler.clearTimeout(lockDown) /*matrix.trail.minoesPos = Array.from(matrix.piece.minoesAbsPos).map(pos => pos.mul(MINO_SIZE)) for (matrix.trail.height=0; move(MOVEMENT.DOWN); matrix.trail.height += MINO_SIZE) { stats.score += 2 }*/ while (move(MOVEMENT.DOWN)) {} lockDown() /*matrix.trail.gradient = matrix.context.createLinearGradient(0, 0, 0, matrix.trail.height) matrix.trail.gradient.addColorStop(0,"rgba(255, 255, 255, 0)") matrix.trail.gradient.addColorStop(1, matrix.piece.transparentColor) scheduler.setTimeout(clearTrail, ANIMATION_DELAY)*/ } function clearTrail() { matrix.trail.height = 0 requestAnimationFrame(draw) } function rotateCW() { rotate(SPIN.CW) } function rotateCCW() { rotate(SPIN.CCW) } function hold() { if (this.matrix.piece.holdEnabled) { scheduler.clearInterval(move) scheduler.clearInterval(lockDown) var shape = this.matrix.piece.shape this.matrix.piece = this.holdQueue.piece this.holdQueue.piece = new Tetromino(HELD_PIECE_POSITION, shape) this.holdQueue.piece.holdEnabled = false this.generationPhase(this.matrix.piece) } } function pause() { state = STATE.PAUSED stats.pauseTime = Date.now() - stats.startTime scheduler.clearInterval(lockPhase) scheduler.clearTimeout(lockDown) scheduler.clearTimeout(autorepeat) scheduler.clearInterval(clock) } function resume() { state = STATE.PLAYING stats.startTime = Date.now() - stats.pauseTime scheduler.setTimeout(lockPhase, stats.fallPeriod) if (matrix.piece.locked) scheduler.setTimeout(lockDown, stats.lockDelay) requestAnimationFrame(draw) scheduler.setInterval(clock, 1000) } function printTempTexts(texts) { tempTexts.push(texts) if (!scheduler.intervalTasks.has(delTempTexts)) scheduler.setInterval(delTempTexts, TEMP_TEXTS_DELAY) } function delTempTexts(self) { if (tempTexts.length) tempTexts.shift() else scheduler.clearInterval(delTempTexts) } function clock() { //stats.print() } function draw() { holdQueue.draw() matrix.draw() nextQueue.draw() //stats.print() } function getKey(action) { return localStorage.getItem(action) || actionsDefaultKeys[action] } window.onload = function() { tempTexts = [] holdQueue = new HoldQueue() stats = new Stats() matrix = new Matrix() nextQueue = new NextQueue() actions[STATE.PLAYING] = {} actions[STATE.PLAYING][getKey("moveLeft")] = moveLeft actions[STATE.PLAYING][getKey("moveRight")] = moveRight actions[STATE.PLAYING][getKey("softDrop")] = softDrop actions[STATE.PLAYING][getKey("hardDrop")] = hardDrop actions[STATE.PLAYING][getKey("rotateCW")] = rotateCW actions[STATE.PLAYING][getKey("rotateCCW")] = rotateCCW actions[STATE.PLAYING][getKey("hold")] = hold actions[STATE.PLAYING][getKey("pause")] = pause actions[STATE.PAUSED] = {} actions[STATE.PAUSED][getKey("pause")] = resume actions[STATE.GAME_OVER] = {} pressedKeys = new Set() actionsToRepeat = [] addEventListener("keydown", keyDownHandler, false) addEventListener("keyup", keyUpHandler, false) state = STATE.PLAYING scheduler = new Scheduler() scheduler.setInterval(clock, 1000) this.newLevel(1) }