【JavaScriptを好き❤️になろう】パズルゲーム



【JavaScriptを好き❤️になろう】パズルゲーム

なかなか理解が進みにくい JavaScript もゲーム制作を通してなら楽しく学べそうだと思いませんか?

今回は鉄板のパズルゲームを JavaScript で再現。しかもプレーンの JavaScript ではなく、 Vue.js を使って端的に画像をパズル化し、ゲーミング。

少しでも JavaScript のモチベーションアップにお役立ちいただければ幸いです。

目次
  1. 【JavaScriptを好きになろう】パズルゲーム
  2. パズルゲームの概要
  3. パズルゲーム作成のフロー
  4. CSSで画像を分割
  5. 分割した画像の配置
  6. タイマー
  7. パズルの答え合わせ
  8. JavaScript製(Vue.js)パズルゲームを動かす様子
  9. まとめ

【JavaScriptを好きになろう】パズルゲーム

パズルゲームの概要

  • 9つに分割された画像を元の形に揃える
  • 制限時間も設定

昔からある”絵”を揃えるゲームをWebで再現。「JavaScript puzzle game」 で検索するといくつかの同じ内容のゲーム制作が紹介されますが、プレーンの JavaScript だとコード量が多く、9つの切り分けた画像も別途用意する必要があったりしてチョット大変。

今回はフロントエンドで流行りの Vue.js を使って、手っ取り早くパズルゲームを再現してみました。 Vue.js って何?という方もHTMLやCSS、JavaScriptを知っていればある程度わかると思いますので、まずはパズルゲームをやってみて、内容をチェックしてください。

サンプルページ

コード/GitHub

今コードを見る(HTML、46行)

      <!DOCTYPE html>
      <html lang="ja" >
      <head>
        <meta charset="UTF-8">
        <meta data-rh="true" name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
        <title>JavaScript Puzzle Game</title>
        <link rel="stylesheet" href="style.css">

      </head>
      <body>
      <main>
        <article>
          <div id="page-container">
            <div id="app">
                <div id="game-container">
                    <div id="tile-game">
                        <countdown-timer :seconds='120' :run="gameRunning" @out-of-time="playerLoss=$event">
                        </countdown-timer>
                      <div id="tile-container-outer">
                        <div id="tile-container-inner">
                            <div v-if="playerWon" class="layer win">
                                <p id="win-text">You Won!</p>
                            </div>
                            <div v-if="playerLoss" class="layer lose">
                                <p>時間切れ</p>
                                <p>ゲームオーバー</p>
                            </div>
                            <tile-component v-for="tile in tiles" :tile="tile" :key="tile.id" @is-adjacent="slideTile(tile);">
                            </tile-component>
                        </div>
                      </div>
                      <div>
                        <p> 動かした数: {{ playerMoves }}</p>
                      </div>
                  </div>
              </div>
            </div>
          </div>
          <hr>
          <p><a href="https://pythonchannel.com">町のWeb屋 大島</a></p>
        </article>
      </main>
      <script src='https://cdn.jsdelivr.net/npm/vue/dist/vue.js'></script><script  src="./script.js"></script>

      </body>
      </html>

    
今コードを見る(CSS、214行)

      * {
        box-sizing: border-box;
      }

      :root {
        --image-url: url("elephant.jpg");
      }

      html,
      body,
      #page-container {
        width: 100vw;
        height: 100vh;
        margin: 0;
        background-color: rgb(248, 168, 159);
        overflow: hidden;
      }

      p {
        text-align: center;
        font-weight: bold;
        font-size: 1.5em;
        margin: 1%;
      }

      #win-text {
        font-size: 6em;
        position: relative;
        top: 1em;
      }

      #game-container {
        width: 65vw;
        max-width: 650px;
        height: 100vh;
        position: relative;
        top: 25px;
        margin: auto;
      }

      #tile-container-inner {
        width: 55vw;
        max-width: 584px;
        height: 55vw;
        max-height: 584px;
        background-color: rgb(198, 200, 202);
        border: 2px solid black;
        position: relative;
        top: 1.35%;
        margin: auto;
        display: grid;
        grid-template-columns: auto auto auto;
      }

      /****Classes****/

      .layer {
        width: 100%;
        height: 100%;
        position: absolute;
        top: 0;
        left: 0;
        opacity: 0.8;
        animation: fadeIn 2s linear;
      }

      .win {
        background-color: white;
      }

      .lose {
        background-color: gray;
        color: white;
      }

      .tile {
        width: 100%;
        height: 100%;
        background-color: white;
        border: 1px solid black;

        cursor: pointer;
      }

      .tile-1 {
        background: var(--image-url) no-repeat top right;
        background-size: 300%;
      }

      .tile-2 {
        background: var(--image-url) no-repeat center right;
        background-size: 300%;
      }

      .tile-3 {
        background: var(--image-url) no-repeat bottom center;
        background-size: 300%;
      }

      .tile-4 {
        background: var(--image-url) no-repeat bottom left;
        background-size: 300%;
      }

      .tile-5 {
        background: var(--image-url) no-repeat top left;
        background-size: 300%;
      }

      .tile-6 {
        background: var(--image-url) no-repeat center center;
        background-size: 300%;
      }

      .tile-7 {
        background: var(--image-url) no-repeat center left;
        background-size: 300%;
      }

      .tile-8 {
        background: var(--image-url) no-repeat top center;
        background-size: 300%;
      }

      .row-1-col-1 {
        grid-row-start: 1;
        grid-row-end: 2;
        grid-column-start: 1;
        grid-column-end: 2;
      }

      .row-1-col-2 {
        grid-row-start: 1;
        grid-row-end: 2;
        grid-column-start: 2;
        grid-column-end: 3;
      }

      .row-1-col-3 {
        grid-row-start: 1;
        grid-row-end: 2;
        grid-column-start: 3;
        grid-column-end: 4;
      }

      .row-2-col-1 {
        grid-row-start: 2;
        grid-row-end: 3;
        grid-column-start: 1;
        grid-column-end: 2;
      }

      .row-2-col-2 {
        grid-row-start: 2;
        grid-row-end: 3;
        grid-column-start: 2;
        grid-column-end: 3;
      }

      .row-2-col-3 {
        grid-row-start: 2;
        grid-row-end: 3;
        grid-column-start: 3;
        grid-column-end: 4;
      }

      .row-3-col-1 {
        grid-row-start: 3;
        grid-row-end: 4;
        grid-column-start: 1;
        grid-column-end: 2;
      }

      .row-3-col-2 {
        grid-row-start: 3;
        grid-row-end: 4;
        grid-column-start: 2;
        grid-column-end: 3;
      }

      .row-3-col-3 {
        grid-row-start: 3;
        grid-row-end: 4;
        grid-column-start: 3;
        grid-column-end: 4;
      }

      .timer-danger {
        color: red;
      }

      .timer-warning {
        color: yellow;
      }

      .timer-normal {
        color:black;
      }

      .timer {
        font-size: 2em;
        font-weight: bold;
        text-align: center;
        cursor: pointer;
        margin-top: 5vh;
      }
      @keyframes fadeIn {
        0% {
            opacity: 0;
        }
        100% {
            opacity: 0.8;
        }
      }
    
今コードを見る(JavaScript、212行)

      /* eslint-disable no-console */
      window.onload = function () {
        Vue.component("countdown-timer", {
            data() {
                return {
                    counterInSeconds: this.seconds,
                    warningInSeconds: this.warningTime,
                    dangerInSeconds: this.dangerTime,
                    counterRunning: false,
                    timerID: -1,
                    noWarning: true,
                    warning: false,
                    dangerZone: false
                };
            },
            props: {
                seconds: {
                    type: Number,
                    default: 60
                },
                warningTime: {
                    type: Number,
                    default: 30
                },
                dangerTime: {
                    type: Number,
                    default: 10
                },
                run: {
                    type: Boolean,
                    default: false
                }
            },
            template: `
制限時間: {{ this.getTime() }}
`, methods: { getTime: function () { // html から、この場合 120 let minutes = Math.floor(this.counterInSeconds / 60); let seconds = this.counterInSeconds % 60; if (seconds < 10) { seconds = "0" + seconds; } return minutes + ":" + seconds; }, startTimer: function () { if (!this.counterRunning) { this.timerID = setInterval(this.reduceTime, 1000); setTimeout(() => { clearInterval(this.timerID); this.$emit("out-of-time", true); }, this.seconds * 1000); this.counterRunning = true; } }, stopTimer: function () { clearInterval(this.timerID); }, reduceTime: function () { this.counterInSeconds--; console.log(this.counterInSeconds); } }, watch: { run: function (newVal, oldVal) { newVal ? this.startTimer() : this.stopTimer(); }, counterInSeconds: function () { switch (this.counterInSeconds) { case this.warningInSeconds: this.warning = true; this.noWarning = false; this.dangerZone = false; break; case this.dangerInSeconds: this.dangerZone = true; this.warning = false; this.noWarning = false; break; } } } }); Vue.component("tile-component", { props: ["tile"], template: `
` }); new Vue({ el: "#app", data: { tiles: [ { id: 1, row: 0, col: 0 }, { id: 2, row: 0, col: 1 }, { id: 3, row: 0, col: 2 }, { id: 4, row: 1, col: 0 }, { id: 5, row: 1, col: 1 }, { id: 6, row: 1, col: 2 }, { id: 7, row: 2, col: 0 }, { id: 8, row: 2, col: 1 } ], emptySlot: { id: 9, row: 2, col: 2 }, answer: [ [5, 8, 1], [7, 6, 2], [4, 3, 9] ], userAnswer: [ [0, 0, 0], [0, 0, 0], [0, 0, 0] ], playerWon: false, playerLoss: false, playerMoves: 0, gameRunning: false, }, methods: { isAdjacent: function (row, col) { return Math.abs(row - this.emptySlot.row) + Math.abs(col - this.emptySlot.col) === 1  ? true : false; }, slideTile: function (tile) { if (this.isAdjacent(tile.row, tile.col)) { if (!this.gameRunning) { this.gameRunning = true; } [tile.row, tile.col, this.emptySlot.row, this.emptySlot.col] = [this.emptySlot.row, this.emptySlot.col, tile.row, tile.col ]; this.userAnswer[tile.row][tile.col] = tile.id; this.userAnswer[this.emptySlot.row][this.emptySlot.col] = this.emptySlot.id; // emptyslot.id 9 this.playerMoves++; // 何回動かした? this.checkAnswer(); console.log('tile id : '+ tile.id); } }, checkAnswer: function () { for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (this.userAnswer[i][j] !== this.answer[i][j]) { // userAnswer と answer の配列チェック(id) // [[0][0], [0][1] , [0][2]] // [[1][0], [1][1] , [1][2]] // [[2][0], [2][1] , [2][2]] return; } } } this.gameRunning = false; this.playerWon = true; } } }); };

パズルゲーム作成のフロー

image

  • 元画像の用意
  • CSSで画像を分割表示
  • タップで画像を移動、タイマースタート
  • 答え合わせ
  • 合っていなかったら続けてプレイ
  • 制限時間がきたら終わり

今回は 600px × 600px の象の画像を用意。こちらをベースに 3×3 に画像を分割しました。

2×2 の方が簡単と思って制作しましたが、パネルが同じ方向にしか動かせないためゲームになりませんでした。 3×3 が適当と判断します。

CSSで画像を分割

CSSで画像を分割、といいましても実際に画像を分割するのではなく、分割しているように見せる処理になります。

image

上図のように CSS の

background: url("〇〇.jpg") no-repeat center center;

を使って画像を呼び出し、center centertop right などで表示位置を指定。この center centertop right でゲーム初期のバラバラの画像をレイアウトしています。ランダムではなく、CSSによってバラバラの画像を表現しているんですね。そして画像サイズを 300% にすることで 3×3 に分割した画像にフィット。

各画像の表示制御は、 CSS の display: grid; で制御。 ベースの画像が 600px × 600px で、 3×3 に分割なので扱いやすいサイズ設定となっています。

分割した画像の配置

image

先ほど元画像からバラバラに切り分けた画像は、上図のような CSS で配置。通常であればこうした HTML の記述、普通にコードを書くと思いますが、今回は Vue.js を使っていますので、いちいち HTML に class 名などを記述しなくてもOK。

下図のように Vue.js のテンプレート機能を使うことで JavaScript が HTML コードを生成してくれます。 Ruby on Rails や Django などのフレームワークを体験したことのある方なら、このテンプレート感、好きですよね。

image

JavaScript ファイルより

この一連の処理の結果、ゲーム開始時のバラバラの画面が作られます。そして各画像には id が振られていて、id がどの場所にあるか、ということをチェックしてパズルの答え合わせを実行。

タイマー

タイマーの扱い方はいくつかありますが、今回は Vue.js 的なタイマー制御。 HTML でセットした制限時間を元に 1秒ずつカウントダウン。そして 30秒を切るとタイマーの文字が黄色に変わり、10秒を切ると赤色に。

こうした条件分岐の処理、いく通りかやり方があると思いますが、 Vue.js のテンプレート機能を使って JavaScript と HTML を連動して処理。JavaScript側で 30秒を切ったら CSS を変更し、文字の色を変更する、という処理を行っています。

Vue.js が初めての方も、なんとなく便利そうなフレームワークだな、と感じていただければ幸いです。

パズルの答え合わせ

ゲームスタート時点で表示されるバラバラの画像、この各画像には下図のように id が振り分けられています。

これは各画像を JavaScript の配列で管理することで id 管理ができ、例えば一番左上の画像は配列的には

[0][0]

で id は HTML の CSS を JavaScript で自動生成した時に振り付けられた id = 1。こんな風に初期のバラバラの画像を id で確認すると以下のように。

この id 的には整った画像をシャッフル(ゲーム)して、象の絵を作ることで id が以下のようになります。

image

各画像の id の配列が上図のようになればOKなので、その状態を JavaScript の配列にセット。そして今動かしているパネルの配列を用意し、 今の配列 ID = 答えの配列ID になれば正解、ということですね。

この配列の答え合わせはパネルを動かす度に実行され、 JavaScript のループ分で配列の状況をチェック。 もしパズルの図柄が元の絵と合っていれば、正解シグナルの this.playerWon = true; が実行されて、 HTML側の方も v-if="playerWon" が実行されてゲームが終了。

この JavaScript 側での if文と HTML側での if文のハーモニー、表現の幅を広げてくれることを示唆してくれていますね。

JavaScript製(Vue.js)パズルゲームを動かす様子

本稿と合わせてご参考いただければ幸いです。

Webサイト担当者としてのスキルが身に付く

CodeCampの無料体験はこちら

まとめ

今回はパズルゲームを通じて JavaScript に触れ、 Vue.js に触れ、そして実は CSS の変数も使っていて、HTML/CSS、JavaScript初学者にとってはいい刺激になったのではないでしょうか。

「 HTML/CSS、JavaScriptを使えると楽しそうだけど、コードがさっぱり分からない」「JavaScript の本を買ったけれど、なかなか頭に入らない」という方は、一度学習環境を見直してみませんか? まずはCodeCampの無料体験を通じて、プログラミングの楽しさに触れていただきたいです。

ホームページをみて、無料体験の空き状況をチェックしてみませんか? ちょっとした勇気が大きな成長をもたらしてくれるかもしれません。ぜひトライしてみてください。

オシママサラ
この記事を書いた人
オシママサラ
\ 無料体験開催中!/自分のペースで確実に習得!
オンライン・プログラミングレッスンNo.1のCodeCamp