JavaScriptとCanvasを使って、画像をX×Yに分割し、Gridレイアウトで順に合体させるアニメーションを実装する方法を解説します。シンプルな構成で再利用も可能。初心者にもわかりやすいサンプル付き。

はじめに

ゲーム開発をしていく中で、必要になった画像を分割表示する方法と、分割した物を結合する方法の備忘録メモです。

JavaScriptとCanvasを使って、画像をX×Yに分割し、Gridレイアウトで順に合体させるアニメーションを実装する方法を解説します。シンプルな構成で再利用も可能。初心者にもわかりやすいサンプル付きです。

サンプル構成

サンプル構成

project/
├─ index.html(←これから作成)
├─ img/
│  └─ sample.png(画像はユーザー準備)
└─ js/
   ├─ ImageModule.js(画像分割+合体)
   └─ main.js(処理統括)

各ファイル

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>画像分割&合体サンプル</title>
  <style>
    body {
      background: #222;
      color: #fff;
      text-align: center;
      font-family: sans-serif;
    }
    #result {
      width: 80vw;
      margin: 20px auto;
    }
    img {
      width: 100%;
    }
  </style>
</head>
<body>
  <h1>画像分割 → 合体アニメーション</h1>
  <button id="runBtn">実行する</button>
  <div id="result"></div>

  <script type="module">
    import { splitImage, showMergeAnimation } from "./js/ImageModule.js";

    document.getElementById("runBtn").addEventListener("click", async () => {
      const imgSrc = "./img/sample.png";
      const stage = { stage: 1, x: 4, y: 2 }; // 任意のステージ構成
      const container = document.getElementById("result");

      const img = new Image();
      img.crossOrigin = "anonymous";
      img.src = imgSrc;
      await new Promise((resolve) => (img.onload = resolve));

      const pieces = splitImage(img, stage.x, stage.y);
      showMergeAnimation(pieces, container, stage.x, stage.y);
    });
  </script>
</body>
</html>

main.js

import { splitImage, showMergeAnimation } from "./ImageModule.js";

export async function handleImageProcess(imgSrc, stage, container) {
  const img = new Image();
  img.crossOrigin = "anonymous";
  img.src = imgSrc;

  await new Promise((resolve) => (img.onload = resolve));

  const { x, y } = stageMap.find((s) => s.stage === stage);
  const pieces = splitImage(img, x, y);
  showMergeAnimation(pieces, container, x, y);

  return pieces;
}

ImageModule.js

// Canvasで画像を分割
export function splitImage(img, xCount, yCount) {
  const pieces = [];
  const pieceWidth = img.width / xCount;
  const pieceHeight = img.height / yCount;
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");

  canvas.width = pieceWidth;
  canvas.height = pieceHeight;

  for (let y = 0; y < yCount; y++) {
    for (let x = 0; x < xCount; x++) {
      ctx.clearRect(0, 0, pieceWidth, pieceHeight);
      ctx.drawImage(
        img,
        x * pieceWidth,
        y * pieceHeight,
        pieceWidth,
        pieceHeight,w
        0,
        0,
        pieceWidth,
        pieceHeight
      );
      const pieceUrl = canvas.toDataURL();
      pieces.push({ x, y, src: pieceUrl });
    }
  }

  return pieces;
}

// 分割パーツを grid で配置し、アニメーションで一体化させる。
export function showMergeAnimation(pieces, container, xCount, yCount) {
  container.innerHTML = "";
  container.style.display = "grid";
  container.style.gridTemplateColumns = `repeat(${xCount}, 1fr)`;

  pieces.forEach((piece, i) => {
    const img = document.createElement("img");
    img.src = piece.src;
    img.style.width = "100%";
    img.style.opacity = "0";
    img.style.transition = "opacity 0.3s ease";
    container.appendChild(img);

    setTimeout(() => {
      img.style.opacity = "1";
    }, i * 100); // 遅延で順にフェード表示
  });
}

実行結果

スクリプト実行結果。 画像を分割表示させています。

実行結果

アニメ―ション

実行結果

コード解説

なぜ await が必要だったか?

await new Promise((resolve) => (img.onload = resolve));

これは、

画像が読み込まれてない状態で .width や .height にアクセスすると値が 0 になる

それを防ぐために img.onload を待ってから処理を始める

という最低限の安全確保のために使ってるだけです。

✅ 実行結果:一瞬で分割される理由

  • Canvasの描画&データURL化はブラウザ内で超高速
  • 画像の元データがローカルにあり、ネット通信が不要

これにより、「非同期だけど体感的にはほぼ同期」となっています。

await new Promise((resolve) => (img.onload = resolve)); の意味と制限

内容 説明
目的 画像の読み込みが完了するまで待つ
背景 .width, .height を正しく取得するためには img.onload が必要
await使用 非同期的に読み込み完了を待機するスマートな手段
高画質画像の注意点 img.src = "highres.jpg" の場合、読み込みに時間がかかることもあるため、awaitはより重要になる

🧠 ちょっと豆知識:他の書き方との比較 ✅ よくある非推奨例(読み込み完了を待たずに使う)

const img = new Image();
img.src = "sample.jpg";
console.log(img.width); // → 0(読み込み前なので未定義)

await を使うことで確実に読み込み待ち

const img = new Image();
img.src = "sample.jpg";
await new Promise((resolve) => (img.onload = resolve));
console.log(img.width); // → 正常な数値

結合・拡大アニメーション

画像間に少し間隔をあけて配置して、 カードがアニメーションで結合して1枚の絵になり拡大表示

ステップ 動きの内容
① 分割状態 カード(画像)が少し間隔をあけて Grid に配置されている
② 結合開始 カード同士の間隔が縮まり、1枚の画像にくっついていく
③ 拡大表示 最後に全体がふわっと拡大して画面中央に表示される

Step 1: Gridに間隔をあけて表示

#result {
  display: grid;
  grid-template-columns: repeat(4, 1fr); /* 例:4列 */
  gap: 8px; /* カード間の余白 */
  width: fit-content;
  margin: auto;
  transition: gap 0.5s ease;
}

Step 2: .merge クラスで gap を0にして“合体”させる

#result.merge {
  gap: 0px;
}

💡 JavaScriptで合体を発動:

setTimeout(() => {
  container.classList.add("merge");
}, 1500); // すべてのピースが出揃ったあとで

Step 3: 拡大アニメーションを追加

#result.zoom {
  transform: scale(1.2);
  transition: transform 0.5s ease;
}
setTimeout(() => {
  container.classList.add("zoom");
}, 2500); // 合体後に拡大演出
// 1. 最初に全ピースを並べる
showMergeAnimation(...);

// 2. 少し後にカード間のギャップをなくす(合体演出)
setTimeout(() => {
  container.classList.add("merge");
}, 1500);

// 3. 合体完了後にふわっと拡大
setTimeout(() => {
  container.classList.add("zoom");
}, 2500);

補足:セリフやキャラを表示

全体アニメ終了後にセリフやキャラを表示も可

setTimeout(() => {
  document.getElementById("character-box").classList.add("show");
}, 3200);

完成後に元の絵と差し替え

Step 1: .merge → .zoom のあとに、元画像と差し替え

📄 css:#final-image を配置しておく(非表示)

<img id="final-image" src="./img/sample.png" />
#final-image {
  opacity: 0;//非表示
  transform: scale(0.9);
  transition: opacity 0.5s ease, transform 0.5s ease;
  visibility: hidden; /* ←オプション */
  pointer-events: none; /* クリック無効(任意) */
}
#final-image.show {
  opacity: 1;
  transform: scale(1);
  visibility: visible;
}

🧠 JS側の処理(結合後に差し替え)

// 合体完了 → 拡大演出 → さらに1秒後に本物に差し替え
setTimeout(() => {
  const container = document.getElementById("result");
  container.innerHTML = ""; // 分割ピースを消す

  const finalImage = document.getElementById("final-image");
  finalImage.classList.add("show"); // 本物画像をふわっと表示
}, 3500);

まとめ:CSSで非表示にするベストプラクティス

状態 方法 遷移効果あり?
完全非表示 display: none ❌(transition効かない)
アニメ対応 opacity: 0visibility: hidden ✅(ふわっと表示可能)

動作サンプル

おまけ

4x5=20分割をやってみました。

何処まで分割できるか?

実際には以下の2つが制限要因になります:

🧠 1. ブラウザのメモリ負荷(Canvas + Base64)

  • canvas.toDataURL() を大量に使うため、ピースが多いとメモリを圧迫
  • 例えば 100枚(10×10)以上になると、古いブラウザやスマホで動作がカクつく可能性あり
  • 特に 高解像度画像 × 多数分割 の組み合わせは要注意

🧠 2. DOMの操作負荷(大量の 追加)

  • 1枚ずつ setTimeout() で追加されていくので、枚数が多いほど合体に時間がかかる
  • DOMノード数が100〜200を超えると、描画やアニメーションが鈍くなることがある

✅ 安定動作の現実的な上限目安(参考)

分割数 状況 備考
~5×5(25枚) 💯 快適動作 スマホでもOK
~6×6(36枚) ✅ 実用範囲 少し重くなる環境も
~8×8(64枚) ⚠️ 注意 PCなら可、スマホは厳しいかも
10×10以上 🔥 高負荷 特殊用途・非推奨

高難易度で遊ぶコツ・工夫

対策 内容
canvas.toBlob() に切り替える toDataURL()より軽量だが、非同期処理になる
✅ 縮小バージョンの画像を用意 img.width を小さめに抑える(例:横400px)
✅ アニメの表示数を絞る 最初の数ピースだけフェード → 残りは一括表示などで最適化

シャッフル結合

// 今の pieces をシャッフル
function shuffleArray(array) {
  return array.sort(() => Math.random() - 0.5);
}

const shuffledPieces = shuffleArray([...pieces]);

shuffledPieces.forEach((piece, i) => {
  // そのまま setTimeout で表示
});

→ コード差し替えだけで完了。 → 「何が出てくるかわからない」ワクワク感!

備考

🛠️ 今後やるとよいこと(記事化・実験メモ用)

  • ✅ 画像が読み込まれたことを await で確実に待つ
  • ✅ grid + opacity の遅延表示で シンプルかつ魅力的なアニメ
  • ✅ stageMap 定義を main.js or ImageModule.js に整理しておくと、複数ステージの検証に便利