Color Slide Challenge ボードゲームのソースコードの紹介 HTML CSS JavaScript

7×7の49マスの中に赤・青・黄色・緑の色または画像が各12個×4で48個の色がシャッフルで配置されている
いずれかの色または画像をマスの上下・左右に動かせて、全体マスの中央が空白になるように同色の4ブロックを成立させるゲームです。

各コードの修正・追加・変更・デザインの変更等は制限なく使用できます。しかし、管理者がデモ用として公開していますので、丸ごとコピペしてWEB公開する事は許可致しませんが、起動時のUI(ユーザーインターフェイス)を全面変更すればこの限りではありません。

音声はお借りもので、WEBで使用する場合はAmitaro's 様のクレジット表記が必要です

仕様
・いづれかのマスにマウスまたはドラッグ&ドロップするとタイマーがカウントダウン

・タイルの切り替え 色表示または画像

・制限時間は7分 7分以内に完成したら完成演出 紙吹雪

・マスの移動時は移動箇所または種類に応じで各ハイライトの表示

・タイマーカウント0で完成しない場合は、全てのマス内の色または画像が吹っ飛ぶ演出

・完成時は終了タイムのローカル保存(ゲームオーバー 中止の場合 00:00)

・中止ボタン(強制終了 各初期化 再シャッフル)

・再挑戦ボタン(初期化 再シャッフル) 初期起動時またはゲーム中はボタン無効

・再挑戦ボタン ゲームオーバーまたは完成時ボタン有効

・完成した場合の次回のスタート時間は完成時間に応じて、開始タイマーがスタート ※上書き制

・時間内にゲームオーバーした場合は初期値7分からスタート

・音声ボタンON/OFFの切り替え ※残りタイムの音声

・2分前 1分前 10秒前 3・2・1の各音声

・10秒からカウントに合わせて赤で点滅

・記録削除ボタンで、ローカルストレージの値を削除

・表示関係は画面内のメッセージ表示(アラートは不採用)

・色表示は3D風の立体表示

・全体画面構成は目に優しい落ち着いた色を採用

・背景はXPD_22.PNGのリピート仕様 ※任意

・スクロールは動作可能で非表示

・ブラウザサイズに応じた画面調整とメディア調整

フォルダ構成(階層)

-------Colors-Puzzle/
|
|---index.html
|---style.css
|---script.js
|
|------sounds/
|  | --- timer_2.mp3  2分
|  | --- timer_1.mp3 1分
|  | --- 10-seconds-ago.mp3 10秒
|  | --- 3,2,1.mp3 3・2・1カウント
|------img/
  |---0.png 切り替え用画像 0.png~3.png
  |---1.png
  |---2.png
  |---3.png
  |---XPD_22.PNG Microsoft Expression Web 4背景画像

HTML
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>4色ブロック完成ゲーム</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <header class="header">
    <h1>Color Slide Challenge</h1>
  </header>

  <main class="game-container">

    <section class="status-panel">
      <div class="status-box">
        <span class="status-label">残り時間</span>
        <span id="timer" class="timer">07:00</span>
      </div>

      <div class="status-box">
        <span class="status-label">ゲーム状態</span>
        <span id="gameStatus" class="game-status">準備完了</span>
      </div>

      <div class="status-box">
        <span class="status-label">今回の結果</span>
        <span id="resultTime" class="result-time">00:00</span>
      </div>
    </section>

    <section class="board-section">
      <div id="board"
           class="board"
           role="group"
           aria-label="7行7列のゲーム盤面">
      </div>
    </section>

    <section class="controls">
      <button id="cancelButton" class="button cancel-button">
        中止
      </button>

      <button id="retryButton"
              class="button retry-button"
              disabled>
        再挑戦
      </button>

      <button id="displayModeButton" type="button">
          画像表示に切替
      </button>

      <button id="clearRecordsButton" type="button">
          記録削除
      </button>

      <button id="soundButton"
              class="button sound-button"
              aria-pressed="true">
        音声:ON
      </button>
    </section>

    <section class="message-panel">
      <p id="message" role="status" aria-live="polite">
        ブロックを操作するとタイマーが開始します。
      </p>
    </section>

    <section class="record-panel">
      <h2>記録</h2>
      <p>
        最短完成タイム:
        <strong id="bestTime">00:00</strong>
      </p>
      <p>
        完成回数:
        <strong id="winCount">0</strong>
      </p>
    </section>

    <section class="rules">
      <h2>遊び方</h2>
      <ul>
        <li>盤面は7×7の49マスです。</li>
        <li>赤・青・黄色・緑が各12個、合計48個配置されます。</li>
        <li>空白マスは初期配置ではランダムな位置にあります。</li>
        <li>空白に隣接するブロックをクリックすると移動します。</li>
        <li>ブロックを空白マスへドラッグして移動することもできます。</li>
        <li>各色12個を、ひとつの連続したブロックにまとめて完成させます。</li>
        <li>完成時には中央のマスが空白になっている必要があります。</li>
        <li>制限時間は7分です。</li>
      </ul>
    </section>

  </main>

  <audio id="sound2min"
         src="sounds/timer_2.mp3"
         preload="auto"></audio>

  <audio id="sound1min"
         src="sounds/timer_1.mp3"
         preload="auto"></audio>

  <audio id="sound10sec"
         src="sounds/10-seconds-ago.mp3"
         preload="auto"></audio>

  <audio id="sound321"
         src="sounds/3,2,1.mp3"
         preload="auto"></audio>

  <a href="https://flower-processing.net/wp/"
        style="text-decoration: none; color: #0a56a1;" target="_blank"rel="noopener">MOMOPLAN  Sample Game</a>
        
  <script src="script.js"></script>
</body>
</html>
script.js
"use strict";

/* ==================================================
   4色ブロック完成ゲーム
   HTML・CSS・JavaScript 3ファイル構成
================================================== */

/* =========================
   1. 基本設定
========================= */

const CONFIG = {
  SIZE: 7, /*横7列 縦7行*/
  TOTAL: 49, /*合計*/
  CENTER: 24, /*中央空白*/
  LIMIT: 42*10000, /*タイム*/

  // 将来、画像を使用する場合は true
  USE_IMAGES: false,

  IMAGE_PATHS: [
    "0.png", /*画像使用*/
    "1.png",
    "2.png",
    "3.png"
  ],

  COLORS: [
    { name: "赤", className: "color-red" }, /*カラー色*/
    { name: "青", className: "color-blue" },
    { name: "黄色", className: "color-yellow" },
    { name: "緑", className: "color-green" }
  ],

  STORAGE_KEY: "fourColorGameRecords",

  // 完成形からのシャッフル移動回数
  SHUFFLE_MOVES: 700
};

/* =========================
   2. DOM取得
========================= */

const $ = id => document.getElementById(id);

const boardElement = $("board");
const timerElement = $("timer");
const statusElement = $("gameStatus");
const resultElement = $("resultTime");
const messageElement = $("message");

const cancelButton = $("cancelButton");
const retryButton = $("retryButton");
const soundButton = $("soundButton");

const bestTimeElement = $("bestTime");
const winCountElement = $("winCount");

const audio = {
  twoMinutes: $("sound2min"),
  oneMinute: $("sound1min"),
  tenSeconds: $("sound10sec"),
  countdown: $("sound321")
};

/* =========================
   3. ゲーム状態
========================= */

let board = [];

let started = false;
let finished = false;
let soundEnabled = true;

let startTime = 0;
let endTime = 0;

let remainingTime = CONFIG.LIMIT;

// 次の挑戦で使用する制限時間
let nextStartTime = CONFIG.LIMIT;

// 前回の終了理由
let previousResult = null;

// 前回の完成所要時間
let previousCompletionTime = null;

let timerInterval = null;
let draggedIndex = null;
let effectLayer = null;

let lastPlayedThreshold = null;

/* =========================
   4. 記録の読み込み
========================= */

function loadRecords() {
  try {
    const data = JSON.parse(
      localStorage.getItem(CONFIG.STORAGE_KEY)
    );

    if (
      data &&
      Number.isInteger(data.winCount) &&
      Array.isArray(data.times)
    ) {
      return {
        winCount: data.winCount,
        times: data.times.filter(
          time => Number.isFinite(time) && time >= 0
        )
      };
    }
  } catch (error) {
    console.warn("記録を読み込めませんでした。", error);
  }

  return {
    winCount: 0,
    times: []
  };
}

let records = loadRecords();

/* =========================
   5. 時間表示
========================= */

function formatTime(milliseconds) {
  const seconds = Math.ceil(
    Math.max(0, milliseconds) / 1000
  );

  const minutes = Math.floor(seconds / 60);
  const rest = seconds % 60;

  return (
    String(minutes).padStart(2, "0") +
    ":" +
    String(rest).padStart(2, "0")
  );
}

function elapsedTime() {
  if (!started) return 0;

  return Math.max(
    0,
    (finished ? endTime : Date.now()) - startTime
  );
}

function updateTimerDisplay() {
    const seconds = Math.ceil(remainingTime / 1000);

    const minutesText = String(Math.floor(seconds / 60))
        .padStart(2, "0");

    const secondsText = String(seconds % 60)
        .padStart(2, "0");

    timerElement.textContent =
        `${minutesText}:${secondsText}`;

    // 残り10秒から赤点滅
    timerElement.classList.toggle(
        "danger",
        seconds >= 1 && seconds <= 10
    );

    // 残り時間がある間は実際の秒数に合わせて点滅
    if (seconds >= 1 && seconds <= 10) {
        const elapsedInSecond =
            (1000 - (remainingTime % 1000)) % 1000;

        timerElement.style.opacity =
            elapsedInSecond < 500 ? "1" : "0.2";
    } else {
        timerElement.style.opacity = "1";
    }
}

/* =========================
   6. 記録の保存
========================= */

function saveRecords() {
  try {
    localStorage.setItem(
      CONFIG.STORAGE_KEY,
      JSON.stringify(records)
    );

    return true;
  } catch (error) {
    console.warn("記録を保存できませんでした。", error);

    setMessage(
      "記録を保存できませんでした。ブラウザーの保存設定をご確認ください。"
    );

    return false;
  }
}

function updateRecords() {
  const best = records.times.length
    ? Math.min(...records.times)
    : null;

  bestTimeElement.textContent =
    best === null ? "00:00" : formatTime(best);

  winCountElement.textContent = records.winCount;
}

/* =========================
   7. メッセージ表示
========================= */

function setMessage(text, type = "") {
  messageElement.textContent = text;

  messageElement.parentElement.classList.remove(
    "success",
    "failure"
  );

  if (type) {
    messageElement.parentElement.classList.add(type);
  }
}

/* ==================================================
   8. 完成形を作成

   赤・青・黄色・緑をそれぞれ12マスの
   連続したブロックにする。

   中央の24番目は空白。

   これを基準に合法的な移動を繰り返して
   シャッフルするため、完成可能な盤面になる。
================================================== */

function createSolvedBoard() {
  const result = Array(CONFIG.TOTAL).fill(null);

  for (let row = 0; row < CONFIG.SIZE; row++) {
    for (let col = 0; col < CONFIG.SIZE; col++) {
      const index = row * CONFIG.SIZE + col;

      if (row <= 2 && col <= 3) {
        // 赤:上側左の12マス
        result[index] = 0;
      } else if (row <= 3 && col >= 4) {
        // 青:上側右9マス+右端の3マス
        result[index] = 1;
      } else if (row >= 4 && col >= 3) {
        // 黄色:下側右の12マス
        result[index] = 2;
      } else if (
        (row >= 4 && col <= 2) ||
        (row === 3 && col <= 2)
      ) {
        // 緑:下側左9マス+左端の3マス
        result[index] = 3;
      }
    }
  }

  result[CONFIG.CENTER] = null;

  return result;
}

/* =========================
   9. 隣接マスの取得
========================= */

function getNeighbors(index) {
  const row = Math.floor(index / CONFIG.SIZE);
  const col = index % CONFIG.SIZE;

  const neighbors = [];

  if (row > 0) {
    neighbors.push(index - CONFIG.SIZE);
  }

  if (row < CONFIG.SIZE - 1) {
    neighbors.push(index + CONFIG.SIZE);
  }

  if (col > 0) {
    neighbors.push(index - 1);
  }

  if (col < CONFIG.SIZE - 1) {
    neighbors.push(index + 1);
  }

  return neighbors;
}

/* =========================
   10. 空白マスの検索
========================= */

function findEmpty() {
  return board.indexOf(null);
}

function isAdjacent(a, b) {
  return getNeighbors(a).includes(b);
}

/* =========================
   11. 完成可能な盤面を生成
========================= */

function createBoardData() {
  board = createSolvedBoard();

  let empty = findEmpty();
  let previousEmpty = -1;

  // 完成形から合法的な移動を繰り返す
  for (let i = 0; i < CONFIG.SHUFFLE_MOVES; i++) {
    let choices = getNeighbors(empty).filter(
      index => index !== previousEmpty
    );

    // 念のため候補がない場合は全隣接マスを使う
    if (choices.length === 0) {
      choices = getNeighbors(empty);
    }

    const from = choices[
      Math.floor(Math.random() * choices.length)
    ];

    [board[from], board[empty]] = [
      board[empty],
      board[from]
    ];

    previousEmpty = empty;
    empty = from;
  }

  // 空白マスはシャッフル後の位置に残る
  return board.slice();
}

/* =========================
   12. 盤面描画
========================= */

function renderBoard() {
  boardElement.innerHTML = "";

  board.forEach((color, index) => {
    const cell = document.createElement("button");

    cell.type = "button";
    cell.className = "cell";

    cell.dataset.index = index;

    const row = Math.floor(index / CONFIG.SIZE);
    const col = index % CONFIG.SIZE;

    cell.setAttribute(
      "aria-label",
      color === null
        ? `空白、${row + 1}行${col + 1}列`
        : `${CONFIG.COLORS[color].name}、${row + 1}行${col + 1}列`
    );

    if (color === null) {
      cell.classList.add("empty");
    } else {
      const tile = document.createElement("span");

      tile.classList.add("tile");

      if (CONFIG.USE_IMAGES) {
        tile.style.backgroundImage =
          `url("${CONFIG.IMAGE_PATHS[color]}")`;

        tile.style.backgroundPosition = "center";
        tile.style.backgroundSize = "contain";
        tile.style.backgroundRepeat = "no-repeat";
      } else {
        tile.classList.add(
          CONFIG.COLORS[color].className
        );
      }

      cell.appendChild(tile);
      cell.draggable = !finished;
    }

    // クリックしたマスでゲーム開始
    cell.addEventListener("click", () => {
      if (finished) return;

      if (!started) {
        startGame();
      }

      if (color !== null) {
        moveTile(index);
      }
    });

    // ドラッグ開始時にもゲーム開始
    cell.addEventListener("dragstart", event => {
      if (finished || color === null) {
        event.preventDefault();
        return;
      }

      if (!started) {
        startGame();
      }

      draggedIndex = index;

      event.dataTransfer.effectAllowed = "move";
      event.dataTransfer.setData(
        "text/plain",
        String(index)
      );
    });

    cell.addEventListener("dragend", () => {
      draggedIndex = null;
    });

    cell.addEventListener("dragover", event => {
      if (finished) return;

      event.preventDefault();
      event.dataTransfer.dropEffect = "move";
    });

    cell.addEventListener("drop", event => {
      event.preventDefault();

      if (finished) return;

      if (!started) {
        startGame();
      }

      const fromValue =
        event.dataTransfer.getData("text/plain");

      const from = fromValue !== ""
        ? Number(fromValue)
        : draggedIndex;

      const target = Number(cell.dataset.index);

      if (
        Number.isInteger(from) &&
        from >= 0 &&
        from < CONFIG.TOTAL
      ) {
        moveTile(from, target);
      }

      draggedIndex = null;
    });

    if (
      !finished &&
      color !== null &&
      isAdjacent(index, findEmpty())
    ) {
      cell.classList.add("movable");
    }

    boardElement.appendChild(cell);
  });
}

/* =========================
   13. ブロック移動
========================= */

function moveTile(from, dropTarget = null) {
  if (finished) return;

  if (
    !Number.isInteger(from) ||
    from < 0 ||
    from >= CONFIG.TOTAL
  ) {
    return;
  }

  if (board[from] === null) return;

  const empty = findEmpty();

  // ドラッグ時は空白マスへのドロップだけ許可
  if (
    dropTarget !== null &&
    dropTarget !== empty
  ) {
    return;
  }

  // 空白に上下左右で隣接している必要がある
  if (!isAdjacent(from, empty)) {
    return;
  }

  if (!started) {
    startGame();
  }

  // 移動先(元の空白マス)の位置を保存
  const movedTo = empty;

  // タイルを移動
  [board[from], board[empty]] = [
    board[empty],
    board[from]
  ];

  // 盤面を再描画
  renderBoard();

  // 移動先のマス枠を光らせる
  // 移動したタイルの色を取得
const movedColor = board[movedTo];

// 移動先のマスを取得
// 移動先のマスを取得
const cells = document.querySelectorAll("#board .cell");
const movedCell = cells[movedTo];

// 移動先にあるタイルを取得
const movedTile = movedCell?.querySelector(".tile");

if (movedCell && movedTile) {
  // タイルのCSSクラスから色を判定
  const tileClass = movedTile.className;

  let glowClass = "";

  if (tileClass.includes("red")) {
    glowClass = "glow-red";
  } else if (tileClass.includes("blue")) {
    glowClass = "glow-blue";
  } else if (
    tileClass.includes("yellow")
  ) {
    glowClass = "glow-yellow";
  } else if (tileClass.includes("green")) {
    glowClass = "glow-green";
  }

  // 前回の発光を解除
  movedCell.classList.remove(
    "glow-red",
    "glow-blue",
    "glow-yellow",
    "glow-green"
  );

  if (glowClass) {
    // アニメーションを再実行
    void movedCell.offsetWidth;

    movedCell.classList.add(glowClass);

    setTimeout(() => {
      movedCell.classList.remove(glowClass);
    }, 500);
  }
}

  // 完成判定
  if (checkCompletion()) {
    finishGame(true);
  }
}

/* =========================
   14. ゲーム開始
========================= */

function startGame() {
  if (started || finished) return;

  started = true;
  startTime = Date.now();

  // 再挑戦時も、その時点の残り時間を基準にする
  const initialTime = remainingTime;

  statusElement.textContent = "プレイ中";

  setMessage(
    "ゲーム開始!4色のブロックを完成させましょう。"
  );

  timerInterval = setInterval(() => {
    if (!started || finished) return;

    remainingTime = Math.max(
      0,
      initialTime - (Date.now() - startTime)
    );

    updateTimerDisplay();
    checkTimeSignals();

    if (remainingTime <= 0) {
      finishGame(false);
    }
  }, 50);

  updateTimerDisplay();
}

/* =========================
   15. 残り時間の音声
========================= */

function checkTimeSignals() {
  if (!soundEnabled || !started || finished) {
    return;
  }

  const seconds = Math.ceil(remainingTime / 1000);

  if (
    seconds <= 120 &&
    seconds > 60 &&
    lastPlayedThreshold !== 120
  ) {
    lastPlayedThreshold = 120;
    playSound(audio.twoMinutes);
  }

  if (
    seconds <= 60 &&
    seconds > 10 &&
    lastPlayedThreshold !== 60
  ) {
    lastPlayedThreshold = 60;
    playSound(audio.oneMinute);
  }

  if (
    seconds <= 10 &&
    seconds > 3 &&
    lastPlayedThreshold !== 10
  ) {
    lastPlayedThreshold = 10;
    playSound(audio.tenSeconds);
  }

  if (
    seconds <= 3 &&
    seconds > 0 &&
    lastPlayedThreshold !== 3
  ) {
    lastPlayedThreshold = 3;
    playSound(audio.countdown);
  }
}

function playSound(sound) {
  if (!soundEnabled || !sound) return;

  sound.currentTime = 0;

  const result = sound.play();

  if (result && typeof result.catch === "function") {
    result.catch(error => {
      console.warn(
        "音声を再生できませんでした。",
        error
      );
    });
  }
}

/* =========================
   16. 音声 ON / OFF
========================= */

soundButton.addEventListener("click", () => {
  soundEnabled = !soundEnabled;

  soundButton.textContent = soundEnabled
    ? "音声:ON"
    : "音声:OFF";

  soundButton.setAttribute(
    "aria-pressed",
    String(soundEnabled)
  );

  if (!soundEnabled) {
    Object.values(audio).forEach(sound => {
      sound.pause();
      sound.currentTime = 0;
    });

    setMessage("音声をOFFにしました。");
  } else {
    setMessage("音声をONにしました。");
  }
});

/* ==================================================
   17. 完成判定

   ・中央は空白
   ・各色12マス
   ・各色の12マスがひとつにつながっている
================================================== */

function checkCompletion() {
  if (board.length !== CONFIG.TOTAL) {
    return false;
  }

  // 中央の空白を確認
  if (board[CONFIG.CENTER] !== null) {
    return false;
  }

  // 各色12マスを確認
  const counts = [0, 0, 0, 0];

  board.forEach(color => {
    if (color !== null) {
      counts[color]++;
    }
  });

  if (!counts.every(count => count === 12)) {
    return false;
  }

  // 各色の連結成分を調べる
  for (let color = 0; color < 4; color++) {
    const cells = [];

    board.forEach((value, index) => {
      if (value === color) {
        cells.push(index);
      }
    });

    if (cells.length !== 12) {
      return false;
    }

    const visited = new Set();
    const queue = [cells[0]];

    visited.add(cells[0]);

    while (queue.length > 0) {
      const current = queue.shift();

      for (const next of getNeighbors(current)) {
        if (
          board[next] === color &&
          !visited.has(next)
        ) {
          visited.add(next);
          queue.push(next);
        }
      }
    }

    // 12マスすべてが連続していること
    if (visited.size !== 12) {
      return false;
    }
  }

  return true;
}

/* =========================
   18. ゲーム終了
========================= */

function finishGame(success) {
  if (finished) return;

  endTime = Date.now();

  const elapsed = started
    ? Math.max(0, endTime - startTime)
    : 0;

  finished = true;
  started = false;

  clearInterval(timerInterval);
  timerInterval = null;

  retryButton.disabled = false;
  cancelButton.disabled = true;

  timerElement.classList.remove("danger");

  if (success) {
    // 完成時の所要時間を保存
    previousCompletionTime = Math.min(
      nextStartTime,
      elapsed
    );

    previousResult = "success";

    // 次回の制限時間は今回の完成タイム
    nextStartTime = previousCompletionTime;

    remainingTime = Math.max(
      0,
      nextStartTime - elapsed
    );

    resultElement.textContent =
      formatTime(previousCompletionTime);

    statusElement.textContent = "完成!";

    setMessage(
      `完成おめでとうございます!タイム:${formatTime(previousCompletionTime)}`,
      "success"
    );

    records.winCount++;
    records.times.push(previousCompletionTime);

    saveRecords();
    updateRecords();

    renderBoard();
    showConfetti();
  } else {
    previousResult = "gameover";
    previousCompletionTime = null;

    // ゲームオーバー後の次回は10分
    nextStartTime = CONFIG.LIMIT;

    remainingTime = 0;

    resultElement.textContent = "00:00";

    statusElement.textContent = "ゲームオーバー";

    setMessage(
      "時間切れです。再挑戦ボタンでもう一度挑戦できます。",
      "failure"
    );

    renderBoard();
    showExplosion();
  }

  updateTimerDisplay();
}

/* =========================
   19. 中止ボタン
========================= */

cancelButton.addEventListener("click", () => {
  if (finished) return;

  clearInterval(timerInterval);
  timerInterval = null;

  started = false;
  finished = true;

  previousResult = "cancel";
  previousCompletionTime = null;

  // 中止後は次回10分
  nextStartTime = CONFIG.LIMIT;
  remainingTime = 0;

  resultElement.textContent = "00:00";
  statusElement.textContent = "中止";

  timerElement.classList.remove("danger");

  // 中止時は盤面を再シャッフル
  board = createBoardData();

  cancelButton.disabled = true;
  retryButton.disabled = false;

  renderBoard();
  updateTimerDisplay();

  setMessage(
    "ゲームを中止しました。再挑戦ボタンで新しいゲームを始められます。"
  );
});

/* =========================
   20. 再挑戦ボタン
========================= */

retryButton.addEventListener("click", () => {
  if (!finished) return;

  // 完成後:前回の完成時間から
  // ゲームオーバー・中止後:10分から
  if (previousResult === "success") {
    nextStartTime = previousCompletionTime;
  } else {
    nextStartTime = CONFIG.LIMIT;
  }

  initializeGame();

  setMessage(
    `新しい盤面を用意しました。残り時間 ${formatTime(remainingTime)}。ブロックを操作すると計測が始まります。`
  );
});

/* =========================
   21. 初期化
========================= */

function initializeGame() {
  clearInterval(timerInterval);
  timerInterval = null;

  Object.values(audio).forEach(sound => {
    sound.pause();
    sound.currentTime = 0;
  });

  started = false;
  finished = false;

  startTime = 0;
  endTime = 0;

  remainingTime = nextStartTime;

  draggedIndex = null;
  lastPlayedThreshold = null;

  board = createBoardData();

  timerElement.classList.remove("danger");

  timerElement.textContent = formatTime(remainingTime);
  resultElement.textContent = "00:00";

  statusElement.textContent = "準備完了";

  cancelButton.disabled = false;
  retryButton.disabled = true;

  renderBoard();
  updateRecords();
}

/* =========================
   22.画像・カラー切り替え
========================= */

const displayModeButton =
    document.getElementById("displayModeButton");



let imageMode = false;

displayModeButton.addEventListener("click", () => {
    imageMode = !imageMode;

    boardElement.classList.toggle(
        "image-mode",
        imageMode
    );

    displayModeButton.textContent = imageMode
        ? "カラー表示に切替"
        : "画像表示に切替";
});

/* =========================
   23. 演出レイヤー
========================= */

function createEffectLayer() {
  if (effectLayer) {
    effectLayer.remove();
  }

  effectLayer = document.createElement("div");
  effectLayer.className = "effect-layer";

  document.body.appendChild(effectLayer);

  return effectLayer;
}

/* =========================
   24. 完成時の紙吹雪
========================= */

function showConfetti() {
  const layer = createEffectLayer();

  const rect = boardElement.getBoundingClientRect();

  const colors = [
    "#ff5252",
    "#448aff",
    "#ffdf4d",
    "#4caf50",
    "#ffffff",
    "#ff8c42"
  ];

  for (let i = 0; i < 180; i++) {
    const piece = document.createElement("span");

    piece.className = "confetti";

    // 盤面内を中心に紙吹雪を発生
    piece.style.left =
      rect.left + Math.random() * rect.width + "px";

    piece.style.top =
      rect.top + Math.random() * rect.height * 0.25 + "px";

    piece.style.backgroundColor =
      colors[Math.floor(Math.random() * colors.length)];

    piece.style.setProperty(
      "--drift",
      (Math.random() * 400 - 200) + "px"
    );

    piece.style.setProperty(
      "--duration",
      (2 + Math.random() * 3) + "s"
    );

    layer.appendChild(piece);
  }

  setTimeout(() => {
    if (layer.isConnected) {
      layer.remove();
    }
  }, 6000);
}

/* =========================
   25. ゲームオーバー演出

   49マスの盤面にある色ブロックを
   それぞれの現在位置から飛ばす。
========================= */

function showExplosion() {
    const tiles = Array.from(
        document.querySelectorAll("#board .tile")
    );

    // 49マスの色付きタイルが存在しない場合は終了
    if (tiles.length === 0) {
        return;
    }

    tiles.forEach((tile, index) => {
        // 各タイルの現在の画面上の位置と大きさを取得
        const rect = tile.getBoundingClientRect();

        // 表示されている位置から複製を作成
        const flyingTile = tile.cloneNode(true);

        // 元のマスと同じ位置・大きさに固定
        flyingTile.style.position = "fixed";
        flyingTile.style.left = `${rect.left}px`;
        flyingTile.style.top = `${rect.top}px`;
        flyingTile.style.width = `${rect.width}px`;
        flyingTile.style.height = `${rect.height}px`;
        flyingTile.style.margin = "0";
        flyingTile.style.boxSizing = "border-box";
        flyingTile.style.zIndex = "99999";
        flyingTile.style.pointerEvents = "none";
        flyingTile.style.opacity = "1";
        flyingTile.style.visibility = "visible";
        flyingTile.style.transform = "none";

        // body直下に配置し、親要素の座標の影響を受けないようにする
        document.body.appendChild(flyingTile);

        // 元のマスのタイルだけを隠す
        tile.style.visibility = "hidden";

        // タイルごとに違う方向へ吹き飛ばす
        const angle = Math.random() * Math.PI * 2;
        const distance = 250 + Math.random() * 500;
        const moveX = Math.cos(angle) * distance;
        const moveY = Math.sin(angle) * distance;
        const rotate = (Math.random() - 0.5) * 1800;

        // 現在位置から外側へ飛び、縮小して消える
        const animation = flyingTile.animate(
            [
                {
                    transform: "translate(0, 0) rotate(0deg) scale(1)",
                    opacity: 1
                },
                {
                    transform:
                        `translate(${moveX}px, ${moveY}px) ` +
                        `rotate(${rotate}deg) scale(0.1)`,
                    opacity: 0
                }
            ],
            {
                duration: 900 + Math.random() * 400,
                easing: "cubic-bezier(0.2, 0.7, 0.4, 1)",
                fill: "forwards",
                delay: Math.random() * 100
            }
        );

        // アニメーション終了後に複製を削除
        animation.onfinish = () => {
            flyingTile.remove();
        };
    });
}

/* =========================
   26.ローカル記録削除
========================= */

const clearRecordsButton =
    document.getElementById("clearRecordsButton");

clearRecordsButton.addEventListener("click", () => {
    // 1. ローカル記録を削除
    localStorage.removeItem("fourColorGameRecords");

    // 2. 記録表示を初期状態に戻す
    if (typeof updateRecordsDisplay === "function") {
        updateRecordsDisplay();
    }

    // 3. ベストタイムを初期状態に戻す
    const bestTimeElement =
        document.getElementById("bestTime");

    if (bestTimeElement) {
        bestTimeElement.textContent = "00:00";
    }

    // 4. 完成回数を初期状態に戻す
    const winCountElement =
        document.getElementById("winCount");

    if (winCountElement) {
        winCountElement.textContent = "0";
    }

    // 5. メッセージを表示
    const messageElement =
        document.getElementById("message");

    if (messageElement) {
        messageElement.textContent =
            "ローカル記録を削除しました。";
    }
});

/* =========================
   27. 起動
========================= */

initializeGame();
style.css
/* =========================
   基本設定
========================= */

:root {
  --background: #27312f;
  --panel: #35413d;
  --text: #f0f2e9;
  --muted: #c4cec5;
  --accent: #b5c9b1;
  --danger: #ff5151;
  --cell-gap: 5px;
}

* {
  box-sizing: border-box;
}

html {
  min-height: 100%;
  scrollbar-width: none;
}

html::-webkit-scrollbar,
body::-webkit-scrollbar {
  display: none;
}

body {
  margin: 0;
  min-height: 100vh;
  padding: 16px;
  background-color: var(--background);
  background-image: url("img/XPD_22.PNG");
  background-repeat: repeat;
  color: var(--text);
  font-family: "Yu Gothic", "Meiryo", sans-serif;
  text-align: center;
  overflow-y: auto;
  scrollbar-width: none;
}

button {
  font: inherit;
}

/* =========================
   ヘッダー
========================= */

.header {
  margin: 0 auto 18px;
  max-width: 900px;
}

.header h1 {
  margin: 0;
  font-size: clamp(23px, 4vw, 36px);
  color: #0d0435;
  text-shadow: 0 2px 4px rgba(252, 244, 244, 0.945);
}

.subtitle {
  margin: 8px 0 0;
  color: var(--muted);
  font-size: 14px;
}

/* =========================
   全体レイアウト
========================= */

.game-container {
  width: min(100%, 760px);
  margin: 0 auto;
}

.status-panel {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  margin-bottom: 16px;
}

.status-box {
  min-width: 0;
  padding: 10px 5px;
  border: 1px solid #82918a;
  border-radius: 10px;
  background: #283632eF;
  box-shadow:
    inset 0 1px 3px #ffffff12,
    0 3px 6px #0003;
}

.status-label {
  display: block;
  margin-bottom: 5px;
  color: var(--muted);
  font-size: 12px;
}

.timer,
.game-status,
.result-time {
  display: block;
  font-weight: bold;
  overflow-wrap: anywhere;
}

.timer {
  font-size: clamp(23px, 5vw, 34px);
  font-variant-numeric: tabular-nums;
  letter-spacing: 1px;
}

.game-status {
  font-size: clamp(14px, 3vw, 19px);
}

.result-time {
  font-size: clamp(18px, 4vw, 25px);
  font-variant-numeric: tabular-nums;
}

/* =========================
   残り10秒の警告
========================= */

.timer.danger {
    color: #ff2222;
}

/* =========================
   ゲーム盤面
========================= */

.board-section {
  width: 100%;
  margin: 0 auto;
}

.board {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: var(--cell-gap);
  width: min(100%, 600px);
  aspect-ratio: 1;
  margin: 0 auto;
  padding: 8px;
  border: 2px solid #71817a;
  border-radius: 12px;
  background: #17231feF;
  box-shadow:
    0 8px 18px #0005,
    inset 0 2px 5px #0005;
}

/* 各マス */

.cell {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 0;
  padding: 0;
  overflow: hidden;
  border: 1px solid #ffffff32;
  border-radius: 7px;
  background: #26322d;
  box-shadow:
    inset 0 2px 4px #0005,
    0 1px 2px #ffffff0a;
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}

.cell:focus-visible {
  outline: 3px solid #fff;
  outline-offset: -3px;
}

.cell.movable {
  border-color: #e3ead2;
}

.cell.movable:hover {
  filter: brightness(1.14);
}

.cell:active {
  filter: brightness(0.92);
}

/* 空白マス */

.cell.empty {
  background: #17201d;
  border: 2px dashed #697970;
  box-shadow: inset 0 0 12px #0008;
  cursor: default;
}

/* =========================
   立体的な色ブロック
========================= */

.tile {
  position: absolute;
  inset: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid #ffffff75;
  border-radius: 6px;
  box-shadow:
    inset 2px 2px 3px #ffffff58,
    inset -3px -4px 5px #0004,
    0 3px 3px #0005;
  transition:
    transform 0.12s ease,
    filter 0.12s ease;
}

.tile::after {
  content: "";
  position: absolute;
  top: 4px;
  left: 5px;
  right: 5px;
  height: 22%;
  border-radius: 50%;
  background: #ffffff32;
  filter: blur(2px);
  pointer-events: none;
}

.color-red {
  background: linear-gradient(145deg, #ff8585, #d72f39 60%, #98202b);
}

.color-blue {
  background: linear-gradient(145deg, #88caff, #287bc7 60%, #194a87);
}

.color-yellow {
  background: linear-gradient(145deg, #fff2a0, #e8bf38 60%, #ad8420);
}

.color-green {
  background: linear-gradient(145deg, #a9e7a0, #42a65c 60%, #22663b);
}

/* =========================
   操作ボタン
========================= */

.controls {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 12px;
  margin: 18px 0;
}

.button {
  min-width: 120px;
  padding: 11px 20px;
  border: 1px solid #ffffff35;
  border-radius: 8px;
  color: #fff;
  font-weight: bold;
  cursor: pointer;
  box-shadow: 0 3px 5px #0003;
  transition:
    transform 0.12s ease,
    filter 0.12s ease;
}

.button:hover:not(:disabled) {
  filter: brightness(1.12);
  transform: translateY(-1px);
}

.button:active:not(:disabled) {
  transform: translateY(1px);
}

.button:disabled {
  opacity: 0.38;
  cursor: not-allowed;
}

.cancel-button {
  background: linear-gradient(145deg, #b75d58, #793b39);
}

.retry-button {
  background: linear-gradient(145deg, #629c73, #356847);
}

.sound-button {
  background: linear-gradient(145deg, #677e89, #3a505a);
}

/* =========================
   メッセージ
========================= */

.message-panel {
  min-height: 58px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 10px;
  border: 1px solid #64756a;
  border-radius: 9px;
  background: #26352fef;
}

.message-panel p {
  margin: 0;
  line-height: 1.7;
  overflow-wrap: anywhere;
}

.message-panel.success {
  color: #c8f5b9;
  border-color: #78b57b;
}

.message-panel.failure {
  color: #ffb1a7;
  border-color: #c96b62;
}

/* =========================
   記録・説明
========================= */

.record-panel,
.rules {
  margin-top: 16px;
  padding: 14px;
  border: 1px solid #64756a;
  border-radius: 10px;
  background: #293832ed;
  text-align: left;
}

.record-panel h2,
.rules h2 {
  margin: 0 0 10px;
  font-size: 18px;
}

.record-panel p {
  margin: 6px 0;
}

.rules ul {
  margin: 0;
  padding-left: 22px;
  line-height: 1.9;
  color: var(--muted);
}

/* =========================
   演出用レイヤー
========================= */

.effect-layer {
  position: fixed;
  inset: 0;
  z-index: 100;
  overflow: hidden;
  pointer-events: none;
}

.confetti {
  position: absolute;
  top: -20px;
  width: 10px;
  height: 16px;
  animation: confettiFall var(--duration, 3s) linear forwards;
}

/* =========================
   画像・カラー切り替え
========================= */

#displayModeButton {
    padding: 10px 16px;
    border: 1px solid #777;
    border-radius: 8px;
    background: #333;
    color: #fff;
    font-size: 14px;
    cursor: pointer;
    transition: background 0.2s;
}

#displayModeButton:hover {
    background: #505050;
}

/* 画像表示:赤 */
#board.image-mode .tile.color-red {
    background-color: transparent !important;
    background-image: url("img/0.png");
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
}

/* 画像表示:青 */
#board.image-mode .tile.color-blue {
    background-color: transparent !important;
    background-image: url("img/1.png");
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
}

/* 画像表示:黄色 */
#board.image-mode .tile.color-yellow {
    background-color: transparent !important;
    background-image: url("img/2.png");
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
}

/* 画像表示:緑 */
#board.image-mode .tile.color-green {
    background-color: transparent !important;
    background-image: url("img/3.png");
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
}

#clearRecordsButton {
    padding: 10px 16px;
    border: 1px solid #a44;
    border-radius: 8px;
    background: #632f2f;
    color: #fff;
    font-size: 14px;
    cursor: pointer;
}

#clearRecordsButton:hover {
    background: #853d3d;
}

a {
    font-weight: bold;
}

/* 共通:移動直後の発光 */
#board .cell.glow-red,
#board .cell.glow-blue,
#board .cell.glow-yellow,
#board .cell.glow-green {
    position: relative;
    z-index: 2;
    animation: colorGlow 0.45s ease-out;
}

/* 赤 */
#board .cell.glow-red {
    --glow-color: #ff3030;
}

/* 青 */
#board .cell.glow-blue {
    --glow-color: #35aaff;
}

/* 黄色 */
#board .cell.glow-yellow {
    --glow-color: #ffe34d;
}

/* 緑 */
#board .cell.glow-green {
    --glow-color: #40ff85;
}

/* 発光アニメーション */
@keyframes colorGlow {
    0% {
        outline: 3px solid var(--glow-color);
        outline-offset: -3px;
        box-shadow:
            0 0 8px var(--glow-color),
            0 0 18px var(--glow-color),
            inset 0 0 10px var(--glow-color);
    }

    50% {
        outline: 3px solid var(--glow-color);
        outline-offset: -3px;
        box-shadow:
            0 0 14px var(--glow-color),
            0 0 28px var(--glow-color),
            0 0 40px var(--glow-color),
            inset 0 0 14px var(--glow-color);
    }

    100% {
        outline: 0 solid transparent;
        box-shadow: none;
    }
}

@keyframes confettiFall {
  to {
    transform:
      translate3d(var(--drift, 0px), 110vh, 0)
      rotate(900deg);
    opacity: 0;
  }
}

.flying-tile {
  animation: tileFly 0.8s ease-in forwards;
}

@keyframes tileFly {
  to {
    transform:
      translate(var(--fly-x), var(--fly-y))
      rotate(var(--fly-rotate))
      scale(0.2);
    opacity: 0;
  }
}

/* =========================
   スマートフォン対応
========================= */

@media (max-width: 480px) {
  body {
    padding: 8px;
  }

  .status-panel {
    gap: 5px;
  }

  .status-box {
    padding: 8px 3px;
  }

  .board {
    gap: 3px;
    padding: 5px;
  }

  .cell {
    border-radius: 4px;
  }

  .tile {
    inset: 2px;
    border-radius: 3px;
  }

  .controls {
    gap: 8px;
  }

  .button {
    min-width: 95px;
    padding: 10px 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .timer.danger {
    animation-duration: 1.5s;
  }

  .tile,
  .button {
    transition: none;
  }
}

画像に関して
Microsoft Expression Web(マイクロソフト エクスプレッションウェブ)は、かつてマイクロソフトが開発し提供していたWebオーサリングツールです。
2012年12月20日、開発終了を発表。最終版となるExpression Web 4 SP2を無償公開しています。

デモ用はゲームオーバー用のアクション表示GAME OVERが表示され、さらに1ブロック12マスが揃うとブロック枠がハイライトされます。

デモ用Color Slide Challenge