/* About: Tetris for HTML, you can insert it in anny Div tagged with an id * This is a simple elegant sample for how to work with a Canvas */ class Tetris_CTX2D { static get DEBUG_PARENT_OBJECT() { return true; }; static get AUTO_CONTINUE_ON_FOCUS() { return false; }; static get SHOW_FPS_INTERVAL() { return 1000 / 4; }; // four times per second // static get TICK_TIME() { return 1 / 4; }; static get STATE_RUNNING() { return 0; }; static get STATE_ENDED() { return 1; }; static get GRID_COLUMS() { return 10; }; static get GRID_ROWS() { return 20; }; static get SHAPES() { return [ [0, 0, 0, 0, 1, 1, 1, 1], [0, 0, 0, 0, 1, 1, 1, 0, 1], [0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1], [0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1], [0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1], [0, 0, 0, 0, 0, 1, 1, 0, 1, 1], [0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1] ]; }; static get COLORS() { return [ '#74A5FD', 'orange', 'blue', '#FFD700', 'red', '#22AB22', '#BA55D3' ]; }; static get KEY_ARROW_UP() { return 'ArrowUp'; }; static get KEY_ARROW_DOWN() { return 'ArrowDown'; }; static get KEY_ARROW_LEFT() { return 'ArrowLeft'; }; static get KEY_ARROW_RIGHT() { return 'ArrowRight'; }; static get KEY_W() { return 'w'; }; static get KEY_S() { return 's'; }; static get KEY_A() { return 'a'; }; static get KEY_D() { return 'd'; }; static get KEY_ENTER() { return 'Enter'; }; static get KEY_SPACEBAR() { return ' '; }; static get KEY_ESCAPE() { return 'Escape'; }; running = false; animationRequestId = 0; constructor(divId, width, height, difficulty = 1, showFps = false) { this.createCanvas(divId, width, height); this.canvasEl.title = "Playing: Tetris"; this.ctx = this.canvasEl.getContext("2d"); this.ctx.textAlign = "center"; this.difficulty = difficulty; this.BLOCK_WIDTH = this.width / Tetris_CTX2D.GRID_COLUMS; this.BLOCK_HEIGHT = this.height / Tetris_CTX2D.GRID_ROWS; this.audioCtx = new AudioContext(); this.sounds = []; this.sounds[0] = new Audio('./snd/pop.mp3'); this.audioCtx.createMediaElementSource(this.sounds[0]).connect(this.audioCtx.destination); this.sounds[1] = new Audio('./snd/click-button.mp3'); this.audioCtx.createMediaElementSource(this.sounds[1]).connect(this.audioCtx.destination); this.sounds[2] = new Audio('./snd/short-success.mp3'); this.audioCtx.createMediaElementSource(this.sounds[2]).connect(this.audioCtx.destination); this.showFps = showFps; if (showFps) { // add framecounter and fps variables and html this.frameCounter = 0; this.initTime = performance.now(); const el = document.createElement('p'); this.frameLabel = document.createElement('span'); const floatRight = document.createElement('span'); floatRight.style = "float: right;"; this.fpsCounter = 0; this.fpsLabel = document.createElement('span'); el.appendChild(document.createTextNode('fps: ')); el.appendChild(this.fpsLabel); floatRight.appendChild(document.createTextNode('frame: ')); floatRight.appendChild(this.frameLabel); el.appendChild(floatRight); this.divEl.appendChild(el); } this.newGame(); this.canvasEl.addEventListener("keydown", (e) => this.onKeyDown(e)); // this.canvasEl.addEventListener("keyup", (e) => this.onKeyUp(e)); this.canvasEl.addEventListener("blur", (e) => this.onBlur(e)); this.canvasEl.addEventListener("focus", (e) => this.onFocus(e)); if (Tetris_CTX2D.DEBUG_PARENT_OBJECT) window.game = this; if (typeof terminalAddCommand === "function") terminalAddCommand("restartgame", (t) => this.terminalRestartGame(t)); if (typeof terminalPrintLn === "function") terminalPrintLn("new Tetris_CTX2D: @", divId, ' ', width, 'x', height, ' difficulty=', difficulty, ' showFps=', showFps); this.drawCanvas(); } terminalRestartGame(term) { term.terminalClose(); this.restartGame(); // this.canvasEl.focus(); setTimeout(() => { this.canvasEl.focus(); this.pausePlayGame(); }, 200); } createCanvas(divId, width = 0, height = 0, zoom = 1) { this.divEl = document.getElementById(divId); if (this.divEl === null) throw new Error("elementId not found: " + divId); while (this.divEl.firstChild) { this.divEl.removeChild(this.divEl.lastChild); } this.canvasEl = this.divEl.appendChild(document.createElement("canvas")); const c = this.canvasEl; this.width = width; this.height = height; if (width > 0 && height > 0) { c.style.width = width * zoom + 'px'; c.style.height = height * zoom + 'px'; c.width = width; c.height = height; } c.tabIndex = 0; // improtant for keyboard focus! // c.style.imageRendering = 'pixelated'; } // creates a new 4x4 shape in global variable 'current' // 4x4 so as to cover the size when the shape is rotated newCurrentShape() { this.current = this.nextShape; this.nextShape = this.newShape(); this.currentX = Tetris_CTX2D.GRID_COLUMS / 2 - 4 / 2; this.currentY = 0; } newShape() { let shapeId = Math.floor(Math.random() * Tetris_CTX2D.SHAPES.length); for (let i = 0; shapeId == this.nextShapeId && i < 3; i++) { shapeId = Math.floor(Math.random() * Tetris_CTX2D.SHAPES.length); } this.nextShapeId = shapeId; const reference = Tetris_CTX2D.SHAPES[shapeId]; let newShape = []; for (let y = 0; y < 4; ++y) { newShape[y] = []; for (let x = 0; x < 4; ++x) { let i = 4 * y + x; if (typeof reference[i] != 'undefined' && reference[i]) { newShape[y][x] = shapeId + 1; } else { newShape[y][x] = 0; } } } return newShape; } // clears the board initBoard() { this.board = []; for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) { this.board[y] = []; for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) { this.board[y][x] = 0; } } } // keep the element moving down, creating new shapes and clearing lines tick() { if (this.isValidMove(0, 1)) { ++this.currentY; } else if (this.currentY == 0) { this.endGame(); } else { this.freezeCurrentShapeToBoard(); this.clearLines(); this.newCurrentShape(); } } // stop shape at its position and fix it to board freezeCurrentShapeToBoard() { if (this.currentY == 0) { this.lose = true; return; } this.playSound(0); for (let y = 0; y < 4; ++y) { for (let x = 0; x < 4; ++x) { if (this.current[y][x]) { this.board[y + this.currentY][x + this.currentX] = this.current[y][x]; } } } } // returns the rotated shape 'current' perpendicularly anticlockwise rotateShape(current) { let newCurrent = []; for (let y = 0; y < 4; ++y) { newCurrent[y] = []; for (let x = 0; x < 4; ++x) { newCurrent[y][x] = current[3 - x][y]; } } return newCurrent; } // check if any lines are filled and clear them clearLines() { let rowsFilled = 0; for (let y = Tetris_CTX2D.GRID_ROWS - 1; y >= 0; --y) { let rowFilled = true; for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) { if (this.board[y][x] == 0) { rowFilled = false; break; } } if (rowFilled) { rowsFilled++; this.lose = false; for (let yy = y; yy > 0; --yy) { for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) { this.board[yy][x] = this.board[yy - 1][x]; } } ++y; } } if (rowsFilled > 0) { let points = 10 * rowsFilled * rowsFilled; this.score += points; this.playSound(2); if (typeof terminalPrintLn === "function") { if (rowsFilled > 1) terminalPrintLn("Cleared ", rowsFilled, " lines! ", points, " points earned. Score=", this.score); else terminalPrintLn("Cleared a line! ", points, " points earned. Score=", this.score); } } } playSound(index) { try { const snd = this.sounds[index]; snd.currentTime = 0; snd.play(); } catch (e) { console.log(`Failed to play sound '${index}': ${e}}`) } } // checks if the resulting position of current shape will be feasible isValidMove(offsetX = 0, offsetY = 0, newCurrent = this.current) { offsetX = this.currentX + offsetX; offsetY = this.currentY + offsetY; for (let y = 0; y < 4; ++y) { for (let x = 0; x < 4; ++x) { if (newCurrent[y][x]) { if (typeof this.board[y + offsetY] == 'undefined' || typeof this.board[y + offsetY][x + offsetX] == 'undefined' || this.board[y + offsetY][x + offsetX] || x + offsetX < 0 || y + offsetY >= Tetris_CTX2D.GRID_ROWS || x + offsetX >= Tetris_CTX2D.GRID_COLUMS) { return false; } } } } return true; } newGame() { this.gameState = Tetris_CTX2D.STATE_RUNNING; this.tickTime = 0; this.stepTime = 1/4; if(this.difficulty == 0){ this.stepTime = 1/2; }else{ this.stepTime = this.stepTime/ this.difficulty; } this.initBoard(); this.score = 0; this.nextShape = this.newShape(); this.newCurrentShape(); this.lose = false; } restartGame() { this.newGame(); } endGame() { if (typeof terminalPrintLn === "function") terminalPrintLn("Ending tetris with " + this.score + " points."); // console.log("Ending with Score" + this.score + " points"); this.running = false; this.gameState = Tetris_CTX2D.STATE_ENDED; } startRunning(fn) { if (this.animationRequestId != 0) cancelAnimationFrame(this.animationRequestId); this.running = true; this.prevNow = performance.now(); if (this.showFps) { this.initTime = performance.now(); } this.animationRequestId = requestAnimationFrame(fn); } close() { if (this.animationRequestId != 0) { cancelAnimationFrame(this.animationRequestId); this.animationRequestId = 0; } this.running = false; } pausePlayGame() { if (this.gameState == Tetris_CTX2D.STATE_ENDED) return; this.running = !this.running; if (this.running) { this.startRunning(() => this.updateCanvas()); } } updateCanvas() { const now = performance.now(); const timeDelta = (now - this.prevNow) / 1000; //timeDelta = (milli - milli) / toSeconds this.prevNow = now; //#state switch if (this.gameState == Tetris_CTX2D.STATE_RUNNING) { this.tickTime += timeDelta; if (this.tickTime >= this.stepTime) { this.tickTime -= this.stepTime; this.tick(); } } this.drawCanvas(); if (this.running) { // loop this.animationRequestId = requestAnimationFrame(() => this.updateCanvas()); } else { // not looping this.animationRequestId = 0; } } // draws the board and the moving shape drawCanvas() { if (this.showFps) { this.frameCounter++; this.fpsCounter++; const now = performance.now(); const diff = now - this.initTime; if (Tetris_CTX2D.SHOW_FPS_INTERVAL < diff || !this.running) { this.initTime = now; const seconds = diff / 1000; const fps = this.fpsCounter / seconds; this.fpsCounter = 0; if (this.frameLabel) this.frameLabel.innerHTML = this.frameCounter; if (this.fpsLabel) { this.fpsLabel.innerHTML = Math.round((fps + Number.EPSILON) * 100) / 100; if (!this.running) this.fpsLabel.innerHTML += " (not running)"; } } } // draw game const ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); //# grid ctx.strokeStyle = 'lightgrey'; for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) { ctx.beginPath(); ctx.moveTo(x * this.BLOCK_WIDTH, 0); ctx.lineTo(x * this.BLOCK_WIDTH, Tetris_CTX2D.GRID_ROWS * this.BLOCK_HEIGHT); ctx.stroke(); } for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) { ctx.beginPath(); ctx.moveTo(0, y * this.BLOCK_HEIGHT); ctx.lineTo(Tetris_CTX2D.GRID_COLUMS * this.BLOCK_WIDTH, y * this.BLOCK_HEIGHT); ctx.stroke(); } // board ctx.strokeStyle = '#404040'; for (let x = 0; x < Tetris_CTX2D.GRID_COLUMS; ++x) { for (let y = 0; y < Tetris_CTX2D.GRID_ROWS; ++y) { if (this.board[y][x]) { ctx.fillStyle = Tetris_CTX2D.COLORS[this.board[y][x] - 1]; ctx.fillRect(1 + this.BLOCK_WIDTH * (x), 1 + this.BLOCK_HEIGHT * (y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1); ctx.strokeRect(1 + this.BLOCK_WIDTH * (x), 1 + this.BLOCK_HEIGHT * (y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1); } } } // next shape const offX = 8, offY = 0; for (let y = 0; y < 4; ++y) { for (let x = 0; x < 4; ++x) { if (this.nextShape[y][x]) { ctx.fillStyle = Tetris_CTX2D.COLORS[this.nextShape[y][x] - 1]; ctx.fillRect(1 + this.BLOCK_WIDTH * offX + x * this.BLOCK_WIDTH / 2, 1 + this.BLOCK_HEIGHT * offY + y * this.BLOCK_HEIGHT / 2, this.BLOCK_WIDTH / 2 - 1, this.BLOCK_HEIGHT / 2 - 1); ctx.strokeRect(1 + this.BLOCK_WIDTH * offX + x * this.BLOCK_WIDTH / 2, 1 + this.BLOCK_HEIGHT * offY + y * this.BLOCK_HEIGHT / 2, this.BLOCK_WIDTH / 2 - 1, this.BLOCK_HEIGHT / 2 - 1); } } } // tickTime ctx.strokeStyle = '#8080B0'; const rotation = this.tickTime / this.stepTime * Math.PI / 2; for (let i = 0; i < 4; i++) { const r = rotation + i * Math.PI / 2; ctx.beginPath(); ctx.moveTo(this.BLOCK_WIDTH, this.BLOCK_HEIGHT); ctx.lineTo(this.BLOCK_WIDTH + this.BLOCK_WIDTH * Math.cos(r) / 2, this.BLOCK_HEIGHT + this.BLOCK_HEIGHT * Math.sin(r) / 2); ctx.stroke(); } // current shape //# shadow ctx.save(); ctx.shadowColor = '#000000bf'; ctx.shadowBlur = 3; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; for (let y = 0; y < 4; ++y) { for (let x = 0; x < 4; ++x) { if (this.current[y][x]) { ctx.fillStyle = Tetris_CTX2D.COLORS[this.current[y][x] - 1]; ctx.fillRect(1 + this.BLOCK_WIDTH * (this.currentX + x), 1 + this.BLOCK_HEIGHT * (this.currentY + y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1); } } } ctx.restore(); //end shadow ctx.strokeStyle = '#00000060'; for (let y = 0; y < 4; ++y) { for (let x = 0; x < 4; ++x) { if (this.current[y][x]) { ctx.strokeRect(1 + this.BLOCK_WIDTH * (this.currentX + x), 1 + this.BLOCK_HEIGHT * (this.currentY + y), this.BLOCK_WIDTH - 1, this.BLOCK_HEIGHT - 1); } } } const FONT_SIZE = this.width > 440 ? 24 : 16; ctx.font = (1.5 * FONT_SIZE) + "px serif"; ctx.fillStyle = '#408040'; ctx.fillText("score: " + this.score, this.width / 2, FONT_SIZE * 1.5); ctx.font = FONT_SIZE + "px serif"; // unfocused & paused banner if (!this.running) { const x = this.width / 2; const y = this.height / 2; const y2 = y - FONT_SIZE / 2; ctx.strokeStyle = '#404040'; const g = ctx.createLinearGradient(0, 0, this.width, 0); g.addColorStop(0, '#404040a0'); g.addColorStop(0.2, '#404040df'); g.addColorStop(0.8, '#404040df'); g.addColorStop(1, '#404040a0'); ctx.fillStyle = g; ctx.fillRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2); ctx.strokeRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2); ctx.fillStyle = (this.gameState === Tetris_CTX2D.STATE_ENDED) ? 'red' : 'gray'; ctx.font = 3 * FONT_SIZE + "px serif"; let text = (this.gameState === Tetris_CTX2D.STATE_ENDED) ? "Game over!" : "Paused"; ctx.fillText(text, x, y - 2 * FONT_SIZE); ctx.strokeText(text, x, y - 2 * FONT_SIZE); ctx.fillStyle = this.isFocused ? 'goldenrod' : 'lightgray'; ctx.font = FONT_SIZE + "px serif"; text = this.isFocused ? `Press space or enter to ${(this.gameState === Tetris_CTX2D.STATE_ENDED) ? "start next round" : "continue"}.` : "Click here to continue. (unfocused)"; ctx.strokeText(text, x, y2 + FONT_SIZE); ctx.fillText(text, x, y2 + FONT_SIZE); } } onKeyDown(e) { if (e.key == Tetris_CTX2D.KEY_ESCAPE) { if (this.running) this.pausePlayGame() e.preventDefault(); return false; } if (this.running && this.gameState == Tetris_CTX2D.STATE_RUNNING) { if (e.key == Tetris_CTX2D.KEY_ARROW_UP || e.key == Tetris_CTX2D.KEY_W) { let rotatedShape = this.rotateShape(this.current); if (this.isValidMove(0, 0, rotatedShape)) { this.current = rotatedShape; this.playSound(1); } e.preventDefault(); return false; } else if (e.key == Tetris_CTX2D.KEY_ARROW_DOWN || e.key == Tetris_CTX2D.KEY_S) { this.tick(); this.tickTime = 0; e.preventDefault(); return false; } else if (e.key == Tetris_CTX2D.KEY_ARROW_LEFT || e.key == Tetris_CTX2D.KEY_A) { if (this.isValidMove(-1)) { this.currentX--; } e.preventDefault(); return false; } else if (e.key == Tetris_CTX2D.KEY_ARROW_RIGHT || e.key == Tetris_CTX2D.KEY_D) { if (this.isValidMove(1)) { this.currentX++; } e.preventDefault(); return false; } } else if (this.gameState == Tetris_CTX2D.STATE_ENDED) { if (e.key == Tetris_CTX2D.KEY_SPACEBAR || e.key == Tetris_CTX2D.KEY_ENTER) { this.restartGame(); this.startRunning(() => this.updateCanvas()); e.preventDefault(); return false; } } if (e.key == Tetris_CTX2D.KEY_SPACEBAR || e.key == Tetris_CTX2D.KEY_ENTER) { this.pausePlayGame(); e.preventDefault(); return false; } return true; } onBlur() { this.audioCtx.suspend(); this.isFocused = false; this.canvasEl.style.borderColor = null; if (this.running) { this.pausePlayGame(); } else { this.drawCanvas(); } } onFocus() { this.audioCtx.resume(); this.isFocused = true; this.canvasEl.style.borderColor = "red"; if (!this.running && Tetris_CTX2D.AUTO_CONTINUE_ON_FOCUS) { this.pausePlayGame(); } else { this.drawCanvas(); } } } function startTetris(divId, width = 300, height = 600, difficulty = 1, showFps = true) { return new Tetris_CTX2D(divId, width, height, difficulty, showFps); }