年末年始は普段離れて暮らす家族に会う機会が多いものです。そこで撮影した家族写真を利用して遊べるパズルゲームを作ってみませんか。身近なスマートフォンのブラウザでも動くようにHTML/JavaScriptを使ってみましょう。また、パズルをクリアしたら、「最初にクリアした人にケーキがあるよ」などの、メッセージが表示される仕組みにして、年末年始に遊んで楽しめるものにしてみましょう。

  • 家族の写真やイラストを利用してパズルゲームを作ろう

    家族の写真やイラストを利用してパズルゲームを作ろう

スライドパズルとは?

スライドパズルとは、画面上に配置されたタイルを動かして、目的の配置に並び替えるゲームのことを言います。スライドパズルには、いろいろな種類があります。

スライドパズルで最も有名なのが「15パズル」です。15パズルは、4行4列のマスの中に、1から15までの数字のタイルがランダムに配置されます。1マスは空白で、その空白を利用してタイルを動かします。数字を動かして1から15まで小さい順に並べることができればクリアとなります。

  • スライドパズルの王道「15パズル」のルール

    スライドパズルの王道「15パズル」のルール

このような15パズルは有名で、姉妹連載のこちらで作り方を紹介しています。本稿でもブラウザ上で遊べるように、JavaScriptでゲームを作りますが、ちょっとルールを変えて、別のスライドパズルを作ってみましょう。

それで、15パズルのように空白でタイルを入れ替えるのではなく、空白のタイルなしにして、タイルの行全体を上下、あるいは、列全体を左右にスライドさせるものにしてみましょう。タイルの配置を並び替える「回転タイルパズル」を作ってみましょう。

  • 今回作る「回転タイルパズル」のルール

    今回作る「回転タイルパズル」のルール

最初に数字が表示されるものを作って、その後で、数字の代わりに家族写真を表示する仕組みのものにしてみましょう。15パズルと違って穴がないので、画像が映えるものになるでしょう。

シンプルな回転タイルパズルを作成しよう

最初に、プログラムの仕組みを確認するために、最小限の機能だけを実装した「回転タイルパズル」を作ってみましょう。次の画面のように、最初はランダムに数字が並んでいますが、1から9まで順番に並び替えたらゲームクリアです。

  • 数字タイルを回転させるパズル

    数字タイルを回転させるパズル

この手のパズルでは、どのようにゲームデータを管理するかがポイントとなります。今回は画面を二次元配列で表現することにしました。この点に注目してプログラムを確認してみましょう。

プログラム全体を、こちらにアップロードしています。プログラムを実行するには、プログラムをコピーして、テキストエディタに貼り付けて、「game.html」という名前で保存します。そして、HTMLファイルをブラウザにドラッグ&ドロップすれば実行できます。

なお、リンク先は、「プログラム貯蔵庫」というプログラミング初心者を支援するサービスで、プログラムの下にある「プログラムを実行」のボタンを押せば、プログラムをブラウザ上で実行できます。

  • プログラム貯蔵庫に書き込めば、プログラムをすぐに実行できる

    プログラム貯蔵庫に書き込めば、プログラムをすぐに実行できる

シンプルな回転タイルパズルのプログラムを確認してみよう

今回作成したプログラムはHTMLを含めて、93行しかないので、全体を確認するのにも、それほど労力はかかりません。とは言え、プログラムを少しずつ確認していきましょう。

最初に、ゲーム画面を構成するHTMLを確認してみましょう。

<!DOCTYPE html>
<!-- 画面HTML --- (※1) -->>
<html><head><meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>回転タイルパズル - 数字版</title>
</head><body style="text-align: center;">
    <h1 id="title">回転タイルパズル - 数字版</h1>
    <canvas id="gameCanvas" width="400" height="400"></canvas>
</body>
<script> // これ以降がJavaScript

(*1)以降にあるのが、ゲームの画面となるHTMLです。ゲームの中心となる要素が、<canvas>です。ここにゲーム画面が描画されます。JavaScriptを使って、画面を描画します。

プログラムの続き - プログラム全体の基本設定を確認しよう

続く、JavaScriptを確認していきましょう。プログラム冒頭、以下の(*2)の部分で、ゲーム全体で使う基本設定を定数として定義します。

// 定数の定義 --- (※2)
const RANDOM_COUNT = 4; // シャッフル回数
const GRID_SIZE = 3; // グリッドのサイズ(3x3)
const canvas = document.getElementById('gameCanvas'); // ゲーム画面
const ctx = canvas.getContext('2d'); // 描画用コンテキストを取得
const TILE_SIZE = canvas.width / GRID_SIZE; // タイルのサイズを計算
const dragInfo = {xy: [0, 0], f: false, tile: [0, 0]}; // マウス操作の情報

(*2)では、ゲーム全体で使う基本設定を定数としてまとめています。定数RANDOM_COUNTは、シャッフルの回数です。この値を増やすとゲームが難しくなります。ここでは、4を指定しているので簡単にゲームをクリアできる状態となっています。

定数GRID_SIZEは、3×3 のタイルサイズを指定し、定数TILE_SIZEは、1タイルの大きさをピクセルサイズで指定します。

定数dragInfoは、マウス操作に関連する情報を記憶するための値です。dragInfo.xyはマウスを押した位置、dragInfo.fはドラッグ中かどうか、dragInfo.tileにはどのタイルを操作するのかを指定します。

プログラムの続き - タイル状態を初期化しよう

続く(*3)で、タイル管理に使う二次元配列の定数tilesを初期化します。

// タイルを初期化 --- (※3)
const tiles = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

なお、上記の定数tilesの定義では、一行で書いていますが、次のように書き直すと、パズルの状態がより分かりやすいでしょう。

// タイルの初期化 --- (※3) 見やすく書き換えたもの
const tiles = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
];

このように、二次元配列変数を使うなら、パズルの状態を行列で表現できるので、ゲームの管理がやりやすくなります。

プログラムの続き - タイル状態を回転させる関数を確認しよう

続く(*4a)と(*4b)では、タイル状態を、行方向、列方向に回転する関数を定義します。

// 行全体をスライド(循環シフト) --- (※4a)
function shiftRow(row, toRight = true) {
    if (row < 0 || row >= GRID_SIZE) return;
    const r = tiles[row];
    if (toRight) { r.unshift(r.pop()); } else { r.push(r.shift()); }
}
// 列全体をスライド(循環シフト)--- (※4b)
function shiftColumn(col, toDown = true) {
    if (col < 0 || col >= GRID_SIZE) return;
    const c = tiles.map(row => row[col]);
    if (toDown) { c.unshift(c.pop()); } else { c.push(c.shift()); }
    for (let y = 0; y < GRID_SIZE; y++) { tiles[y][col] = c[y]; }
}

(*4a)の関数shiftRowでは行全体をスライドする処理を記述し、(*4b)の関数shiftColumnでは列全体をスライドする処理を記述します。

関数shiftRowでは行全体をスライドします。処理としては、配列の末尾を取り出して先頭に挿入します。これに便利なのが、配列Arrayにあるメソッドです。Arrayには下記のようなメソッドが用意されています。

<<
Arrayのメソッド 説明
a.pop() 配列aの末尾の値を取り出して返す
a.shift() 配列aの先頭の値を取り出して返す
a.push(v) 配列aの末尾に値vを追加
a.unshift(v) 配列aの先頭に値vを追加

そのため、こうしたメソッドを使うと、特定の行をスライドさせるのに便利です。関数shiftRowでは右方向へのシフトと左方向へのシフトの両方を実現していますが、右シフトの処理だけを取り出すと下記のようになります。

// (※4a)から抽出したプログラムを分かりやすくしたもの 
// row行目にある配列を取り出す
const r = tiles[row];
// 末尾の値を取り出して先頭に挿入
r.unshift(r.pop());

プログラムの続き - スワイプ操作でタイルを回転させる処理を確認しよう

続いて、以下(*5)はマウス操作によってタイルを回転させる処理を実装したものです。実際のマウス処理は、(*8)以降で記述しており、ここでは現在の座標を得て、タイル回転を行う処理だけを記述します。

// スワイプ検出してタイルの移動処理 --- (※5)
function handleSwipe(cx, cy) {
    const p = [cx - dragInfo.xy[0], cy - dragInfo.xy[1]];
    const distance = Math.sqrt(p[0] * p[0] + p[1] * p[1]);
    if (distance > 40) {
        if (Math.abs(p[0]) > Math.abs(p[1])) {
            shiftRow(dragInfo.tile[1], p[0] > 0);
        } else {
            shiftColumn(dragInfo.tile[0], p[1] > 0);
        }
        dragInfo.f = false;
        draw();
    }
}

上記(*5)で定義した関数handleSwipeでは、マウスのドラッグ方向を判定して、実際に行か列の回転処理を実行します。

ここでは、マウスボタンを押した位置と、カーソルの現在位置の差から移動距離を計算して、移動距離が40以上であれば、回転を実行します。

プログラムの続き - タイルをシャッフルする処理を確認しよう

次に(*6)のタイルをシャッフルする処理を確認しましょう。

// タイルをシャッフルする - ランダムに行または列をシフト --- (※6)
function shuffleTiles() {
    for (let i = 0; i < RANDOM_COUNT; i++) {
        const idx = Math.floor(Math.random() * GRID_SIZE);
        const dir = (Math.random() > 0.5);
        if (Math.random() > 0.5) { shiftRow(idx, dir) } else { shiftColumn(idx, dir) }
    }
}

上記(*6)の関数shuffleTilesでは、シャッフル処理を実装します。これは、ゲーム開始時に実行される関数です。

プレイヤーの操作と同じ仕組みで、関数shiftRowとshiftColumnをランダムに呼び出すことで、シャッフルします。このように、プレイヤーの疑似操作を、ランダムに行うことで、必ず解けるパズルの配置になります。

プログラムの続き - ゲーム画面を描画しよう

続く、(*7)はゲーム画面の描画処理です。キャンバス要素に描画を行います。

// ゲーム画面の描画 --- (※7)
function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let y = 0; y < GRID_SIZE; y++) { // タイルを一つずつ描画
        for (let x = 0; x < GRID_SIZE; x++) {
            // 背景を描画
            ctx.fillStyle = 'purple';
            ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE - 2, TILE_SIZE - 2);
            // タイル番号を描画
            ctx.fillStyle = 'white'; ctx.font = '50px Arial';
            ctx.fillText(tiles[y][x], x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2);
        }
    }
}

上記(*7)の関数drawでは、ゲーム画面の描画を行います。紫色の四角がタイル背景で、白文字で数字を表示します。

なお、このdrawは、ユーザーが操作するたびに、毎回呼び出され、その度に、盤面を描き直します。

プログラムの続き - マウスイベントと初期化処理を確認しよう

続く(*8)と(*9)では、マウスに関連するイベントを記述し、(*10)でゲームの初期化処理を行います。

// マウスのイベントを設定
canvas.addEventListener('mousedown', (e) => { // マウスボタンを押した時 --- (※8)
    dragInfo.f = true;
    dragInfo.xy = [e.clientX, e.clientY]; // 押した位置を保存
    // マウス座標からタイル位置を取得
    const r = canvas.getBoundingClientRect();
    dragInfo.tile = [ // タイルの位置を保存
        Math.floor((e.clientX - r.left) / TILE_SIZE),
        Math.floor((e.clientY - r.top) / TILE_SIZE)
    ];
});
canvas.addEventListener('mousemove', (e) => { // カーソルを移動したとき --- (※9)
    if (dragInfo.f) {
        handleSwipe(e.clientX, e.clientY);
        const isClear = tiles.flat().every((v, i) => v === i + 1); // クリア判定
        if (isClear) {
            document.getElementById('title').innerText = 'クリアしました!';
        }
    }
});
canvas.addEventListener('mouseup', () => {dragInfo.f = false;});
// 初回の処理 --- (※10)
shuffleTiles(); draw();
</script>
</html>

上記(*8)は、マウスのボタンを押したときに実行されるイベントを定義します。ドラッグ開始を表すフラグである、dragInfo.fをtrueに設定し、現在の座標と、どのタイルをクリックしたかを記憶します。

上記(*9)では、マウス移動時の処理を記述します。dragInfo.fを確認してドラッグ中なら、スワイプ判定を行います。移動後にタイルが全部揃ったかどうかのクリア判定を行います。

(*10)では、パズルの初期化処理として、タイルをシャッフルして描画を行います。

改良しよう - 数字を画像に置き換えよう

基本的なパズルゲームが完成したので、これを画像を使うようにしてみましょう。家族写真などを、ペイントや画像編集ソフトを使って、リサイズして正方形の画像を「photo.png」という名前で保存しましょう。

なお、家族写真をそのまま使うのも良いのですが、最近では、ChatGPTやGoogle Geminiなどの生成AIを使うことで、写真を見栄えの良いイラストに描画し直すことができます。

画像をチャット画面にアップロードして「優しい水彩画風のイラストに描きなおして」などと指定することで、家族写真をイラストにできます。その際、「肌をつやつやにして」とか「もうちょっと若く見えるようにして」などと注文できるので便利です。ただし、やり過ぎると誰か分からなくなってしまうので注意しましょう。

また、ゲーム画面は正方形に描き直してもらうと、使いやすいものになります。

  • ChatGPTを使って家族写真をゲーム用に加工しているところ

    ChatGPTを使って家族写真をゲーム用に加工しているところ

改造しよう - プログラムを書き換えよう

正方形の画像「photo.png」を用意したら、プログラムの(*7)の部分を次のように書き換えましょう。数字を描画する代わりに、画像を描画するように変更します。

// --- 差し替え : ここから ---
// 数字の代わりに画像を描画しよう --- (※7)
// 画像の読み込み
const img = new Image();
img.src = 'photo.png';
img.onload = () => { img.ok = true; };
// ゲーム画面の描画
function draw() {
    if (!img.ok) {
        setTimeout(draw, 100);
        return; // 画像が読み込まれていない場合は遅延させて描画
    }
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // タイルを一つずつ描画
    for (let y = 0; y < GRID_SIZE; y++) {
        for (let x = 0; x < GRID_SIZE; x++) {
            const no = tiles[y][x] - 1; // タイル番号(0~8)
            const ix = (no % GRID_SIZE);
            const iy = Math.floor(no / GRID_SIZE);
            const imgW = img.width / GRID_SIZE;
            const imgH = img.height / GRID_SIZE;
            ctx.drawImage(
                img,
                ix * imgW, iy * imgH, imgW, imgH,
                x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE
            );
        }
    }
}
// --- 差し替え : ここまで ---

プログラムを書き換えて実行すると次のように表示されます。

  • 数字を描画する部分を画像を描画するように変更したもの

    数字を描画する部分を画像を描画するように変更したもの

改造しよう - スマートフォン対応とゲームクリアのメッセージ

続いて、ゲームをクリアした時にメッセージを表示するようにしてみましょう。ここでは、「完成⭐最初にクリアした人にケーキがあるよ🍰'」と表示するものにしてみます。

プログラムの(*9)を以下のように書き換えることで、ゲームクリア時のメッセージを変更できます。

const CLEAR_MESSAGE = '完成⭐最初にクリアした人にケーキがあるよ🍰';

// カーソルを移動したとき --- (※9)
canvas.addEventListener('mousemove', (e) => {
    if (dragInfo.f) {
        handleSwipe(e.clientX, e.clientY);
        const isClear = tiles.flat().every((v, i) => v === i + 1); // クリア判定
        if (isClear) {
            document.getElementById('title').innerText = CLEAR_MESSAGE;
        }
    }
});

上記の変更したところ、次のように表示されるようになりました。

  • クリアした時のメッセージを変更したところ

    クリアした時のメッセージを変更したところ

最後に、スマートフォン対応を行いましょう。プログラムの末尾に以下を追加します。

// --- 差し替え : ここから ---
// スマートフォンのタッチイベントの設定
canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const touch = e.touches[0];
    dragInfo.f = true;
    dragInfo.xy = [touch.clientX, touch.clientY]; // 押した位置を保存
    // マウス座標からタイル位置を取得
    const r = canvas.getBoundingClientRect();
    dragInfo.tile = [ // タイルの位置を保存
        Math.floor((touch.clientX - r.left) / TILE_SIZE),
        Math.floor((touch.clientY - r.top) / TILE_SIZE)
    ];
});
canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    if (dragInfo.f) {
        const touch = e.touches[0];
        handleSwipe(touch.clientX, touch.clientY);
        const isClear = tiles.flat().every((v, i) => v === i + 1); // クリア判定
        if (isClear) {
            document.getElementById('title').innerText = CLEAR_MESSAGE;
        }
    }
});
canvas.addEventListener('touchend', (e) => {
    e.preventDefault();
    dragInfo.f = false;
});
// --- 差し替え : ここまで ---

そして、最後に(*2)の定数RANDOM_COUNTを7や9などに変更して、ゲーム開始時のシャッフル回数を増やしましょう。ゲームが難しくなります。

また、スマートフォン対応のために、(*1)の<canvas>のwidth属性を370のように小さくして画面をはみ出さないように修正します。

<body style="text-align: center; margin:0; padding:0;">
    <h1 id="title">回転タイルパズル - 画像版</h1>
    <canvas id="gameCanvas" width="370" height="370"></canvas>
</body>

ゲームを専用ページにアップロードしよう

最後に、ゲームを専用ページにアップロードして、家族のスマートフォンで確認できるようにしましょう。先ほど紹介した「プログラム貯蔵庫」を使うと、手軽にプログラムをアップロードできます。

貯蔵庫のアカウントを作成すれば、HTML/JavaScriptのプログラムを手軽にアップロードできます。こちらから新規ユーザー登録できます。

登録後にログインしたら「新規」ボタンをクリックして、プログラムを作成しましょう。プログラムを書き込んだら、「新規保存」ボタンをクリックします。続いて、下記のような保存画面が出るので、タイトルやプログラムの説明を記述して、利用規約の「同意する」にチェックを入れて「保存」ボタンを押しましょう。

  • プログラムを書いて「新規保存」ボタンを押して情報を記入しよう

    プログラムを書いて「新規保存」ボタンを押して情報を記入しよう

注意点ですが、設定項目の「公開設定」を「限定的に公開」に変更すると、ほかの人から見えないようになります。プログラム貯蔵庫は、基本的にみんなで作ったプログラムを見せ合うサービスとなっています。そのため、デフォルトの「作品を公開」を選ぶと、トップページにリンクが表示されてしまいます。家族だけに見せたい場合は、この設定を指定するように気をつけましょう。

なお、こちらから画像ファイルをアップロードできます。パズルに使いたい画像をアップロードしましょう。アップロードした素材のURLが表示されます。

  • 画像をアップロードしたところ

    画像をアップロードしたところ

画像のURLが表示されたら、先ほど画像の読み込みに指定した部分を書き換えましょう。

// 画像の読み込み
const img = new Image();
img.src = '(ここにアップロードした画像のURLを指定)';

以上で、作業は完了です。貯蔵庫の作品ページの下の方に、共有用のURLが表示されますので、このURLをコピーして、LINEやメールなどで共有しましょう。このように、プログラム貯蔵庫を使うことで、手軽にプログラムや画像をアップロードできるので、活用してみてください。

  • 作品のURLをコピーして共有しよう

    作品のURLをコピーして共有しよう

まとめ

以上、本稿では、家族写真など好きな画像を使ったスライドパズルを作って、家族や友人に共有する方法を紹介しました。HTML/JavaScriptを使ってプログラムを作れば、ブラウザで動かせるゲームが作成できます。自分の好きな画像をゲームに取り込んで使えるのは、とても楽しいものです。ぜひ、画像を変えたり、プログラムを改造したりして、自分なりのパズルゲームに改造してみてください。

自由型プログラマー。くじらはんどにて、プログラミングの楽しさを伝える活動をしている。代表作に、日本語プログラミング言語「なでしこ」 、テキスト音楽「サクラ」など。2001年オンラインソフト大賞入賞、2004年度未踏ユース スーパークリエータ認定、2010年 OSS貢献者章受賞。これまで50冊以上の技術書を執筆した。直近では、「大規模言語モデルを使いこなすためのプロンプトエンジニアリングの教科書(マイナビ出版)」「Pythonでつくるデスクトップアプリ(ソシム)」「実践力を身につける Pythonの教科書 第2版」「シゴトがはかどる Python自動処理の教科書(マイナビ出版)」など。