view source

JavaScript

const demo = document.getElementById("demo");
const canvas = document.createElement('canvas');
const ctx = canvas.getContext("2d");
demo.appendChild(canvas);

const gridSize = 60;
const gridCount = 6;
canvas.width = gridSize * gridCount;
canvas.height = gridSize * gridCount;

//const dropColors = ["red", "blue", "green", "yellow", "purple"];
const dropColors = ["#e74c3c", "#3498db", "#2ecc71", "#f1c40f", "#9b59b6"];


let grid = [];
let dragPath = [];
let dragging = false;
let dragStart = null;


/*
################################################################
################################################################
*/

class Drop {
  constructor(x, y, color = null) {
    this.x = x;
    this.y = y;
    this.color = color || dropColors[Math.floor(Math.random() * dropColors.length)];

    this.drawX = x * gridSize;
    this.drawY = y * gridSize;

    // アニメ用
    this.isExploding = false;
    this.explodeProgress = 0; // 0.0〜1.0
  }
  setPosition(x, y) {
    this.x = x;
    this.y = y;
  }
  startExplode() {
    this.isExploding = true;
    this.explodeProgress = 0;
  }

  update() {
    const targetX = this.x * gridSize;
    const targetY = this.y * gridSize;
    const speed = 0.2;

    this.drawX += (targetX - this.drawX) * speed;
    this.drawY += (targetY - this.drawY) * speed;

    if (this.isExploding) {
      this.explodeProgress += 0.1; // アニメ速度
    }
  }

 drawShadow(ctx) {
    const shadowX = this.x * gridSize;
    const shadowY = this.y * gridSize;
    this._drawCircle(ctx, shadowX, shadowY, gridSize / 2 - 10, this.color, 0.3);
  }

  drawDragged(ctx, mouseX, mouseY) {
    this._drawCircle(ctx, mouseX - gridSize / 2, mouseY - gridSize / 2, gridSize / 2, this.color, 1);
  }

  draw(ctx) {
    if (this.isExploding) {
      const radius = (gridSize / 2 - 8) * (1 + 0.5 * Math.sin(this.explodeProgress * Math.PI));
      const alpha = 1 - this.explodeProgress;
      if (alpha <= 0) return; // 描画しない(完全消滅)

      this._drawBall(ctx, this.drawX, this.drawY, radius, this.color, alpha);
    } else {
      const radius = gridSize / 2 - 8;
      this._drawBall(ctx, this.drawX, this.drawY, radius, this.color, 1);
    }
  }


  _drawBase(ctx, x, y, radius, color) {
    const centerX = x + gridSize / 2;
    const centerY = y + gridSize / 2;

    const gradient = ctx.createRadialGradient(
      centerX, centerY, radius * 0.2,
      centerX, centerY, radius
    );

    gradient.addColorStop(0, "white"); // 中心が明るめ
    gradient.addColorStop(0.3, color); // 中間:元の色
    gradient.addColorStop(1, "black"); // 外周で若干暗め

    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
    ctx.fill();
  }


  _drawBall(ctx, x, y, radius, color, alpha = 1) {
    const centerX = x + gridSize / 2;
    const centerY = y + gridSize / 2;

    ctx.save();
    ctx.globalAlpha = alpha;

    const gradient = ctx.createRadialGradient(
      centerX - radius * 0.3, centerY - radius * 0.3, radius * 0.2,
      centerX, centerY, radius
    );

    gradient.addColorStop(0, "white");                   // ハイライト中心
    gradient.addColorStop(0.4, color);                   // 中間色(メインカラー)
    gradient.addColorStop(1, this._shadeColor(color, 0.6)); // 外側:元の色をちょっと暗くしただけ

    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();
  }

  _shadeColor(hex, factor = 0.7) {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);

    const toHex = (c) => {
      const v = Math.floor(c * factor);
      return v.toString(16).padStart(2, "0");
    };

    return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
  }

}


/*
################################################################
################################################################
*/

function findMatches() {
  const matched = [];

  // 横方向のチェック
  for (let y = 0; y < gridCount; y++) {
    let chain = [];
    for (let x = 0; x < gridCount; x++) {
      const current = grid[y][x];

      if (
        current &&
        chain.length > 0 &&
        current.color === chain[chain.length - 1].color
      ) {
        chain.push(current);
      } else {
        if (chain.length >= 3) matched.push(...chain);
        chain = current ? [current] : [];
      }
    }
    if (chain.length >= 3) matched.push(...chain);
  }


  // 縦方向のチェック
  for (let x = 0; x < gridCount; x++) {
    let chain = [];
    for (let y = 0; y < gridCount; y++) {
      const current = grid[y][x];

      if (
        current &&
        chain.length > 0 &&
        current.color === chain[chain.length - 1].color
      ) {
        chain.push(current);
      } else {
        if (chain.length >= 3) matched.push(...chain);
        chain = current ? [current] : [];
      }
    }
    if (chain.length >= 3) matched.push(...chain);
  }

  return matched;
}

function applyGravity() {
  for (let x = 0; x < gridCount; x++) {
    for (let y = gridCount - 1; y >= 0; y--) {
      if (grid[y][x] === null) {
        // 上から探して落とす
        for (let k = y - 1; k >= 0; k--) {
          if (grid[k][x]) {
            grid[y][x] = grid[k][x];
            grid[k][x] = null;
            grid[y][x].setPosition(x, y);
            break;
          }
        }
      }
    }
  }

  // 上が全部nullだった場合、新しいドロップを入れる
  for (let x = 0; x < gridCount; x++) {
    for (let y = 0; y < gridCount; y++) {
      if (grid[y][x] === null) {
        grid[y][x] = new Drop(x, y);
      }
    }
  }
}


function removeMatches(matched) {
  for (let drop of matched) {
    drop.startExplode(); // ← 即削除ではなくアニメ開始!

    // サウンド再生(ピッチをランダム化して連鎖感UP)
    const pitch = 300 + Math.random() * 400;
    playRetroPopSound(pitch);
  }

}

function updateGrid() {
  for (let y = 0; y < gridCount; y++) {
    for (let x = 0; x < gridCount; x++) {
      const drop = grid[y][x];
      if (drop) {
        drop.update();
        if (drop.isExploding && drop.explodeProgress >= 1) {
          grid[y][x] = null; // 爆発終了 → 消す
        }
      }
    }
  }
}


function handleAfterMove() {
  let chain = 0;

  function step() {
    const matches = findMatches();
    if (matches.length > 0) {
      chain++;
      removeMatches(matches);

      // 💡 爆発アニメが完了するまで待ってから applyGravity()
      setTimeout(() => {
        applyGravity();
        setTimeout(step, 200); // 次の連鎖までちょっと待つ
      }, 300); // ← 爆発アニメにかかる時間(爆発速度 0.1 × 10フレーム程度)
    } else {
      if (chain > 0) {
        console.log(`連鎖数: ${chain}`);
      }
    }
  }

  step();
}


function swapDrops(posA, posB) {
  if (!isValidPos(posA) || !isValidPos(posB)) return;

  const dropA = grid[posA.y][posA.x];
  const dropB = grid[posB.y][posB.x];
  if (!dropA || !dropB) return;

  grid[posA.y][posA.x] = dropB;
  grid[posB.y][posB.x] = dropA;

  dropA.setPosition(posB.x, posB.y);
  dropB.setPosition(posA.x, posA.y);
}

/*
################################################################
################################################################
*/
let audioCtx = null;

function initAudio() {
  if (!audioCtx) {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    console.log("AudioContext initialized!");
  }
}
function playPopSound(pitch = 440, duration = 0.1, volume = 0.01) {
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();

  osc.type = "sine"; // 他に "square", "triangle", "sawtooth" もある
  osc.frequency.value = pitch;

  gain.gain.value = volume;

  osc.connect(gain);
  gain.connect(ctx.destination);

  osc.start();
  osc.stop(ctx.currentTime + duration);
}
function playRetroPopSound(pitch = 600, duration = 0.15, volume = 0.02) {
  if (!audioCtx) return; // 初期化前なら何もしない
  //const ctx = new (window.AudioContext || window.webkitAudioContext)();
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();

  osc.type = "square"; // ← レトロっぽい波形
  osc.frequency.value = pitch;

  gain.gain.setValueAtTime(volume, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);

  osc.connect(gain);
  gain.connect(audioCtx.destination);

  osc.start();
  osc.stop(audioCtx.currentTime + duration);
}
document.body.addEventListener("click", initAudio); // ユーザー操作で有効化
/*
################################################################
################################################################
*/







function initGrid() {
  grid = [];
  for (let y = 0; y < gridCount; y++) {
    const row = [];
    for (let x = 0; x < gridCount; x++) {
      row.push(new Drop(x, y));
    }
    grid.push(row);
  }
}


function drawBackGround() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#cccccc";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const colorA = "#d2b48c"; // 薄い茶色(tan)
  const colorB = "#a0522d"; // 濃い茶色(sienna)
  for (let y = 0; y < gridCount; y++) {
    for (let x = 0; x < gridCount; x++) {
      const isEven = (x + y) % 2 === 0;
      ctx.fillStyle = isEven ? colorA : colorB;
      ctx.fillRect(x * gridSize, y * gridSize, gridSize, gridSize);
    }
  }
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
}

function drawGrid() {
  for (let row of grid) {
    for (let drop of row) {
      if (drop) {
        drop.update(); //
        drop.draw(ctx);
      }
    }
  }
}

function getGridPos(x, y) {
  return {
    x: Math.floor(x / gridSize),
    y: Math.floor(y / gridSize)
  };
}

function isValidPos(pos) {
  return (
    pos.x >= 0 &&
    pos.y >= 0 &&
    pos.x < gridCount &&
    pos.y < gridCount
  );
}


/*
################################################################
################################################################
*/

function getMousePos(e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;

  return {
    x: (e.clientX - rect.left) * scaleX,
    y: (e.clientY - rect.top) * scaleY
  };
}

let draggingDrop = null;
canvas.addEventListener("mousedown", (e) => {
  const mouse = getMousePos(e);
  const pos = getGridPos(mouse.x, mouse.y);
  if (!isValidPos(pos)) return;

  dragging = true;
  dragPath = [pos];
  dragStart = pos;
  draggingDrop = grid[pos.y][pos.x];
  lastMousePos = mouse;
  swappedPairs = []; // ← スワップ記録を初期化
});

let lastMousePos = null;
let swappedPairs = [];



canvas.addEventListener("mousemove", (e) => {
  if (!dragging || !draggingDrop) return;

  const mouse = getMousePos(e);
  lastMousePos = mouse;

  draggingDrop.drawX = mouse.x - gridSize / 2;
  draggingDrop.drawY = mouse.y - gridSize / 2;

  const pos = getGridPos(mouse.x, mouse.y);
  const last = dragPath[dragPath.length - 1];

  if (!isValidPos(pos)) return;

  const isAdjacent =
    (Math.abs(pos.x - last.x) === 1 && pos.y === last.y) ||
    (Math.abs(pos.y - last.y) === 1 && pos.x === last.x);

  if (isAdjacent) {
    swapDrops(last, pos);      // 👈 外に出したスワップ処理を呼び出し!
    dragPath.push(pos);        // 重複も許可(何周でもOK)
  }
});


canvas.addEventListener("mouseup", () => {
  dragging = false;
  draggingDrop = null;
  lastMousePos = null;
  dragPath = [];
  swappedPairs = [];

  handleAfterMove();
});


/*
################################################################
################################################################
*/

function loop() {
  drawBackGround();
  drawGrid();
  updateGrid();

  if (dragging && draggingDrop && lastMousePos) {
    draggingDrop.drawX = lastMousePos.x - gridSize / 2;
    draggingDrop.drawY = lastMousePos.y - gridSize / 2;
  }
  requestAnimationFrame(loop);
}


initGrid();
handleAfterMove();
loop();

CSS

body{
  overflow:hidden;
  background-color:#000000;
  font-family:monospace;
}
canvas{
  display: block;
  margin: auto;
  width: 100vmin;
  height: 100vmin;
  aspect-ratio: 1 / 1;
}

HTML

ページのソースを表示 : Ctrl+U , DevTools : F12

view-source:https://hi0a.com/demo/-js/js-game-puzzle-3match/

ABOUT

hi0a.com 「ひまアプリ」は無料で遊べるミニゲームや便利ツールを公開しています。

プログラミング言語の動作デモやWEBデザイン、ソースコード、フロントエンド等の開発者のための技術を公開しています。

必要な機能の関数をコピペ利用したり勉強に活用できます。

プログラムの動作サンプル結果は画面上部に出力表示されています。

環境:最新のブラウザ GoogleChrome / Windows / Android / iPhone 等の端末で動作確認しています。

画像素材や音素材は半分自作でフリー素材配布サイトも利用しています。LINK参照。

動く便利なものが好きなだけで技術自体に興味はないのでコードは汚いです。

途中放置や実験状態、仕様変更、API廃止等で動かないページもあります。