Developer

JavaScriptで作ったテトリスのサンプル
2021.08.01
Lv1

JavaScriptで作ったテトリスのサンプル

■はじめに

JavaScriptの学習も兼ねて、テトリスを作成してみたのでサンプルコードを公開します。
解説は載せませんが、要望があればそちらも投稿するかもしれません。

★ゲーム作成に興味がある方はこちらもチェック

■動作イメージ

■ソースコード

先に作成の際に意識したポイントを以下に挙げます。

● できるだけClassを使って実装する
● キーボード入力イベントの確認
● キャンバスへの描画方法

特にJavascriptでのClassの使い方を確認する事を意識して作成したサンプルでもあるので、
ただテトリスを作るだけであればClassを使う必要は無いと思います。

改造したり機能追加などしてご活用頂ければと思います。

ダウンロード

HTML
<!DOCTYPE html>
<html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>テトリス</title>
      <style>
        #container{
          position: relative;
          margin: auto;
          width: 700px;
        }

        #next-canvas{
          position: absolute; 
          top: 0;
          left:400px;
        }

        #img-howto{
          position: absolute;
          bottom: 200px;
          left: 360px;
        }

        #start-btn {
          position: absolute;
          left: 400px;
          bottom: 0;
          width: 180px;
          text-align: center;
          font-size: 16px;
          color: #FFF;
          text-decoration: none;
          font-weight: bold;
          padding: 12px 24px;
          border:none;
          border-radius: 4px;
          background-image: linear-gradient(to top, #c471f5 0%, #fa71cd 100%);
          transition: .5s;
          background-size: 200%;
        }

        #start-btn:hover {
          background-position: right center;
        }
      </style>
  </head>
  <body>
    <div id="container">
      <canvas id="main-canvas"></canvas>
        <canvas id="next-canvas"></canvas>
        <img id="img-howto" src="images/howto.png">
        <button id="start-btn">開始</button>
    </div>
    <script src="tetris.js"></script>
  </body>
</html>
JavaScript
const SATRT_BTN_ID = "start-btn"
const MAIN_CANVAS_ID = "main-canvas"
const NEXT_CANVAS_ID = "next-canvas"
const GAME_SPEED = 500;
const BLOCK_SIZE = 32;
const COLS_COUNT = 10;
const ROWS_COUNT = 20;
const SCREEN_WIDTH = COLS_COUNT * BLOCK_SIZE;
const SCREEN_HEIGHT = ROWS_COUNT * BLOCK_SIZE;
const NEXT_AREA_SIZE = 160;
const BLOCK_SOURCES = [
        "images/block-0.png",
        "images/block-1.png",
        "images/block-2.png",
        "images/block-3.png",
        "images/block-4.png",
        "images/block-5.png",
        "images/block-6.png"
    ]

window.onload = function(){
  Asset.init()
  let game = new Game()
  document.getElementById(SATRT_BTN_ID).onclick = function(){
      game.start()
      this.blur() // ボタンのフォーカスを外す
  }
}

// 素材を管理するクラス
// ゲーム開始前に初期化する
class Asset{
    // ブロック用Imageの配列
    static blockImages = []

    // 初期化処理
    // callback には、init完了後に行う処理を渡す
    static init(callback){
        let loadCnt = 0
        for(let i = 0; i <= 6; i++){
            let img = new Image();
            img.src = BLOCK_SOURCES[i];
            img.onload = function(){
                loadCnt++
                Asset.blockImages.push(img)

                // 全ての画像読み込みが終われば、callback実行
                if(loadCnt >= BLOCK_SOURCES.length && callback){
                    callback()
                }
            }
        }
    }
}

class Game{
    constructor(){
        this.initMainCanvas()
        this.initNextCanvas()
    }

    // メインキャンバスの初期化
    initMainCanvas(){
        this.mainCanvas = document.getElementById(MAIN_CANVAS_ID);
        this.mainCtx = this.mainCanvas.getContext("2d");
        this.mainCanvas.width = SCREEN_WIDTH;
        this.mainCanvas.height = SCREEN_HEIGHT;
        this.mainCanvas.style.border = "4px solid #555";
    }

    // ネクストキャンバスの初期化
    initNextCanvas(){
        this.nextCanvas = document.getElementById(NEXT_CANVAS_ID);
        this.nextCtx = this.nextCanvas.getContext("2d");
        this.nextCanvas.width = NEXT_AREA_SIZE
        this.nextCanvas.height = NEXT_AREA_SIZE;
        this.nextCanvas.style.border = "4px solid #555";
    }

    // ゲームの開始処理(STARTボタンクリック時)
    start(){
        // フィールドとミノの初期化
        this.field = new Field()

        // 最初のミノを読み込み
        this.popMino()

        // 初回描画
        this.drawAll()

        // 落下処理
        clearInterval(this.timer)
        this.timer = setInterval(() => this.dropMino(), 1000);

        // キーボードイベントの登録
        this.setKeyEvent()
    }

    // 新しいミノを読み込む
    popMino(){
        this.mino = this.nextMino ?? new Mino()
        this.mino.spawn()
        this.nextMino = new Mino()

        // ゲームオーバー判定
        if(!this.valid(0, 1)){
            this.drawAll()
            clearInterval(this.timer)
            alert("ゲームオーバー")
        }
    }

    // 画面の描画
    drawAll(){
        // 表示クリア
        this.mainCtx.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
        this.nextCtx.clearRect(0, 0, NEXT_AREA_SIZE, NEXT_AREA_SIZE)

        // 落下済みのミノを描画
        this.field.drawFixedBlocks(this.mainCtx)

        // 再描画
        this.nextMino.drawNext(this.nextCtx)
        this.mino.draw(this.mainCtx)
    }

    // ミノの落下処理
    dropMino(){
        if(this.valid(0, 1)) {
            this.mino.y++;
        }else{
            // Minoを固定する(座標変換してFieldに渡す)
            this.mino.blocks.forEach( e => {
                e.x += this.mino.x
                e.y += this.mino.y
            })
            this.field.blocks = this.field.blocks.concat(this.mino.blocks)
            this.field.checkLine()
            this.popMino()
        }
        this.drawAll();
    }
    
    // 次の移動が可能かチェック
    valid(moveX, moveY, rot=0){
        let newBlocks = this.mino.getNewBlocks(moveX, moveY, rot)
        return newBlocks.every(block => {
            return (
                block.x >= 0 &&
                block.y >= -1 &&
                block.x < COLS_COUNT &&
                block.y < ROWS_COUNT &&
                !this.field.has(block.x, block.y)
            )
        })
    }

    // キーボードイベント
    setKeyEvent(){
        document.onkeydown = function(e){
            switch(e.keyCode){
                case 37: // 左
                    if( this.valid(-1, 0)) this.mino.x--;
                    break;
                case 39: // 右
                    if( this.valid(1, 0)) this.mino.x++;
                    break;
                case 40: // 下
                    if( this.valid(0, 1) ) this.mino.y++;
                    break;
                case 32: // スペース
                    if( this.valid(0, 0, 1)) this.mino.rotate();
                    break;
            }
            this.drawAll()
        }.bind(this)
    }
}

class Block{
    // 基準地点からの座標
    // 移動中 ⇒ Minoの左上
    // 配置後 ⇒ Fieldの左上
    constructor(x, y, type){
        this.x = x
        this.y = y
        
        // 描画しないときはタイプを指定しない
        if(type >= 0) this.setType(type)
    }

    setType(type){
        this.type = type
        this.image = Asset.blockImages[type]
    }

    // Minoに属するときは、Minoの位置をオフセットに指定
    // Fieldに属するときは、(0,0)を起点とするので不要
    draw(offsetX = 0, offsetY = 0, ctx){
        let drawX = this.x + offsetX
        let drawY = this.y + offsetY

        // 画面外は描画しない
        if(drawX >= 0 && drawX < COLS_COUNT &&
           drawY >= 0 && drawY < ROWS_COUNT){
            ctx.drawImage(
                this.image, 
                drawX * BLOCK_SIZE, 
                drawY * BLOCK_SIZE,
                BLOCK_SIZE, 
                BLOCK_SIZE
            )
        }
    }

    // 次のミノを描画する
    // タイプごとに余白を調整して、中央に表示
    drawNext(ctx){
        let offsetX = 0
        let offsetY = 0
        switch(this.type){
            case 0:
                offsetX = 0.5
                offsetY = 0
                break;
            case 1:
                offsetX = 0.5
                offsetY = 0.5
                break;
            default:
                offsetX = 1
                offsetY = 0.5
                break;
        }

        ctx.drawImage(
            this.image, 
            (this.x + offsetX) * BLOCK_SIZE, 
            (this.y + offsetY) * BLOCK_SIZE,
            BLOCK_SIZE, 
            BLOCK_SIZE
        )
    }
}

class Mino{
    constructor(){
        this.type = Math.floor(Math.random() * 7);
        this.initBlocks()
    }

    initBlocks(){
        let t = this.type
        switch(t){
            case 0: // I型
                this.blocks = [new Block(0,2,t),new Block(1,2,t),new Block(2,2,t),new Block(3,2,t)]
                break;
            case 1: // O型
                this.blocks = [new Block(1,1,t),new Block(2,1,t),new Block(1,2,t),new Block(2,2,t)]
                break;
            case 2: // T型
                this.blocks = [new Block(1,1,t),new Block(0,2,t),new Block(1,2,t),new Block(2,2,t)]
                break;
            case 3: // J型
                this.blocks = [new Block(1,1,t),new Block(0,2,t),new Block(1,2,t),new Block(2,2,t)]
                break;
            case 4: // L型
                this.blocks = [new Block(2,1,t),new Block(0,2,t),new Block(1,2,t),new Block(2,2,t)]
                break;
            case 5: // S型
                this.blocks = [new Block(1,1,t),new Block(2,1,t),new Block(0,2,t),new Block(1,2,t)]
                break;
            case 6: // Z型
                this.blocks = [new Block(0,1,t),new Block(1,1,t),new Block(1,2,t),new Block(2,2,t)]
                break;
            }
    }

    // フィールドに生成する
    spawn(){
        this.x = COLS_COUNT/2 - 2
        this.y = -3
    }

    // フィールドに描画する
    draw(ctx){
        this.blocks.forEach(block => {
            block.draw(this.x, this.y, ctx)
        })
    }

    // 次のミノを描画する
    drawNext(ctx){
        this.blocks.forEach(block => {
            block.drawNext(ctx)
        })
    }
    
    // 回転させる
    rotate(){
        this.blocks.forEach(block=>{
            let oldX = block.x
            block.x = block.y
            block.y = 3-oldX
        })
    }

    // 次に移動しようとしている位置の情報を持ったミノを生成
    // 描画はせず、移動が可能かどうかの判定に使用する
    getNewBlocks(moveX, moveY, rot){
        let newBlocks = this.blocks.map(block=>{
            return new Block(block.x, block.y)
        })
        newBlocks.forEach(block => {
            // 移動させる場合
            if(moveX || moveY){
                block.x += moveX
                block.y += moveY
            }

            // 回転させる場合
            if(rot){
                let oldX = block.x
                block.x = block.y
                block.y = 3-oldX
            }

            // グローバル座標に変換
            block.x += this.x
            block.y += this.y
        })
        
        return newBlocks
    }
}

class Field{
    constructor(){
        this.blocks = []
    }

    drawFixedBlocks(ctx){
        this.blocks.forEach(block => block.draw(0, 0, ctx))
    }

    checkLine(){
      for(var r = 0; r < ROWS_COUNT; r++){
        var c = this.blocks.filter(block => block.y === r).length
        if(c === COLS_COUNT){
          this.blocks = this.blocks.filter(block => block.y !== r)
          this.blocks.filter(block => block.y < r).forEach(upper => upper.y++)
        }
      }
    }

    has(x, y){
        return this.blocks.some(block => block.x == x && block.y == y)
    }
}

実践Unityゲームプログラミング 連載目次

「初心者のための」Unityゲーム制作 目次