枠にあてはめるブロックパズルゲーム 脳トレ

ブロックを動かして黒枠内にピッタリはめるパズルゲーム

view source

JavaScript

document.title = '枠にあてはめるブロックパズルゲーム 脳トレ';
//ブロックを動かして黒枠内にピッタリはめるパズルゲーム

/*
https://ja.wikipedia.org/wiki/%E3%82%A6%E3%83%9C%E3%83%B3%E3%82%B4

ウボンゴ
ルール
36枚のボードが入っており、各ボードの右側にはその形を作るための問題、左側にはその形を構成しているピースが4つ記されている。なお、裏表で異なる問題が記されており、裏面の問題は構成するピースが3つになった、簡単な問題が記されている。

各プレイヤーはボードを受け取り、ダイスを1つ振る。その出目に応じて、左側のピースを手元に用意し、右側のシルエットに合わせてピースを配置する。ピースを置く際に、はみ出たり、他のピースの上に重ねたりしてはならない。これを、砂時計がひっくり返され、落ちきるまでに完成させなければならない。
*/

const container = document.getElementById('demo');

const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
canvas.classList.add('landscape');
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';

// グリッドサイズやパーツ設定
const gridSize = 50;
const boardWidth = Math.floor(canvas.width / gridSize)+1;//32
const boardHeight = Math.floor(canvas.height / gridSize)+1;//18

let stage = 0;
let isMessage = true;
let lastUsedPiece = null;

function getCorrectedCoords(e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  let clientX, clientY;
  if (e.touches) {
    clientX = e.touches[0].clientX;
    clientY = e.touches[0].clientY;
  } else {
    clientX = e.clientX;
    clientY = e.clientY;
  }
  return {
    x: (clientX - rect.left) * scaleX,
    y: (clientY - rect.top) * scaleY
  };
}

// 盤面の描画
function drawGrid() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.strokeStyle = '#999';
  for (let x = 0; x <= boardWidth; x++) {
    ctx.beginPath();
    ctx.moveTo(x * gridSize, 0);
    ctx.lineTo(x * gridSize, boardHeight * gridSize);
    ctx.stroke();
  }
  for (let y = 0; y <= boardHeight; y++) {
    ctx.beginPath();
    ctx.moveTo(0, y * gridSize);
    ctx.lineTo(boardWidth * gridSize, y * gridSize);
    ctx.stroke();
  }
}

// ブロックの例
function drawPiece(x, y, shape, color) {
  ctx.fillStyle = color;
  shape.forEach(([dx, dy]) => {
    ctx.fillRect((x + dx) * gridSize, (y + dy) * gridSize, gridSize, gridSize);
    ctx.strokeRect((x + dx) * gridSize, (y + dy) * gridSize, gridSize, gridSize);
  });
}

// --- ピースクラス ---
class Piece {
  constructor(shape, color, x, y) {
    this.shape = shape;
    this.color = color;
    this.x = x;
    this.y = y;
    this.dragging = false;
    this.offsetX = 0;
    this.offsetY = 0;
    this.selected = false;
  }

  // ピース描画
  draw(ctx, fill = true, alpha = 1) {
    ctx.globalAlpha = alpha;
    ctx[fill ? 'fillStyle' : 'strokeStyle'] = this.color;
    for (const [dx, dy] of this.shape) {
      const px = (this.x + dx) * gridSize;
      const py = (this.y + dy) * gridSize;
      if (fill) {
        ctx.fillRect(px, py, gridSize, gridSize);
        ctx.lineWidth = this.selected ? 4 : 1;
        ctx.strokeStyle = '#333';
        ctx.strokeRect(px, py, gridSize, gridSize);
      } else {
        ctx.strokeRect(px, py, gridSize, gridSize);
      }
    }
    ctx.globalAlpha = 1;
    ctx.lineWidth = 1;
  }

  // マウスヒット判定
  isHit(mx, my) {
    for (const [dx, dy] of this.shape) {
      const px = (this.x + dx) * gridSize;
      const py = (this.y + dy) * gridSize;
      if (mx >= px && mx <= px + gridSize && my >= py && my <= py + gridSize) {
        return true;
      }
    }
    return false;
  }

  startDrag(mx, my) {
    this.dragging = true;
    this.offsetX = mx / gridSize - this.x;
    this.offsetY = my / gridSize - this.y;
  }

  dragTo(mx, my) {
    this.x = Math.floor(mx / gridSize - this.offsetX + 0.5);
    this.y = Math.floor(my / gridSize - this.offsetY + 0.5);
  }

  endDrag() {
    this.dragging = false;
  }

  // ★ 回転(時計回り90度)
/*
  rotate() {
    this.shape = this.shape.map(([dx, dy]) => [dy, -dx]);
  }
*/
  rotate() {
    // 1. バウンディングボックスの中心を求める
    const xs = this.shape.map(([x]) => x);
    const ys = this.shape.map(([, y]) => y);
    const minX = Math.min(...xs);
    const maxX = Math.max(...xs);
    const minY = Math.min(...ys);
    const maxY = Math.max(...ys);
    const cx = (minX + maxX) / 2;
    const cy = (minY + maxY) / 2;

    // 2. 中心基準で回転
    let rotated = this.shape.map(([x, y]) => {
      const dx = x - cx;
      const dy = y - cy;

      const rx = dy;
      const ry = -dx;

      return [Math.round(rx + cx), Math.round(ry + cy)];
    });

    // 3. 左上に移動(バウンディングボックスのmin値を0にする)
    const newXs = rotated.map(([x]) => x);
    const newYs = rotated.map(([, y]) => y);
    const shiftX = Math.min(...newXs);
    const shiftY = Math.min(...newYs);

    this.shape = rotated.map(([x, y]) => [x - shiftX, y - shiftY]);
  }

}

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


function setLastUsedPiece(p) {
  if (lastUsedPiece && lastUsedPiece !== p) {
    lastUsedPiece.selected = false;
  }
  lastUsedPiece = p;
  if (lastUsedPiece) {
    lastUsedPiece.selected = true;
  }
}



const baseShapes = [
  { name: '四角1', shape: [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0]] },
  { name: '四角2', shape: [[0, 0], [1, 0], [0, 1], [1, 1], [2, 1]] },
  { name: 'I字1', shape: [[0, 0], [1, 0], [2, 0], [3, 0], [1, 1]] },
  { name: 'I字2', shape: [[0, 1], [1, 1], [2, 1], [3, 1], [1, 0]] },
  { name: 'L字', shape: [[0, 0], [0, 1], [0, 2], [1, 2]] },
  { name: '逆L', shape: [[0, 2], [1, 2], [1, 1], [1, 0]] },
  { name: 'L字2', shape: [[0, 0], [0, 1], [0, 2], [1, 2], [0, -1]] },
  { name: '逆L2', shape: [[0, 2], [1, 2], [1, 1], [1, 0], [1, -1]] },
  { name: 'S字', shape: [[0, 0], [1, 0], [1, 1], [2, 1]] },
  { name: 'Z字', shape: [[1, 0], [2, 0], [0, 1], [1, 1]] },
  { name: 'コの字', shape: [[0, 0], [2, 0], [0, 1], [1, 1], [2, 1]] }
];
//{ name: '小L', shape: [[0, 0], [1, 0], [0, 1]] },
//  { name: '四角', shape: [[0, 0], [1, 0], [0, 1], [1, 1]] },
//{ name: 'I字', shape: [[0, 0], [1, 0], [2, 0], [3, 0]] },
//{ name: '十字', shape: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]] },
//{ name: 'T字', shape: [[0, 1], [1, 1], [2, 1], [1, 0]] },

const pieceColors = [
  '#f44336', '#2196f3', '#ffeb3b', '#4caf50',
  '#9c27b0', '#ff9800', '#795548', '#00bcd4',
  '#e91e63', '#8bc34a', '#3f51b5', '#ffc107'
];


function setPieces() {
  const pieces = baseShapes.map((entry, i) => {
    const shape = entry.shape;
    const color = pieceColors[i % pieceColors.length];
    const x = 1 + (i % 4) * 3;         // 横に並べる(2マスずつ間隔)
    const y = 2 + Math.floor(i / 4) * 3; // 縦も段ごとに間隔
    return new Piece(shape, color, x, y);
  });
  return pieces;
}

let pieces = setPieces();

let draggingPiece = null;

function draw() {
  drawGrid();

  for (const p of pieces) {
    p.draw(ctx);
  }
  drawSilhouette();

  if(isMessage){
    drawStartMessage();
  }
}

// --- シルエット描画(アウトライン) ---
function generateSilhouette(baseShapes, targetX = 13, targetY = 1, count=3) {
  const placedCells = [];
  const usedIndices = [];
  const occupied = new Set();
  const usedShapeIndices = new Set();
  const maxTries = 2000;

  function coordKey(x, y) {
    return `${x},${y}`;
  }

  function isValidPlacement(shape, px, py) {
    return shape.every(([dx, dy]) => {
      const x = px + dx, y = py + dy;
      return x >= 0 && x < 24 && y >= 0 && y < 16 && !occupied.has(coordKey(x, y));
    });
  }
/*
  function isTouching(shape, px, py) {
    return shape.some(([dx, dy]) => {
      const x = px + dx, y = py + dy;
      const neighbors = [
        coordKey(x - 1, y), coordKey(x + 1, y),
        coordKey(x, y - 1), coordKey(x, y + 1)
      ];
      return neighbors.some(n => occupied.has(n));
    });
  }
*/
  //2マス以上接しているか
  function isTouching(shape, px, py) {
    let touchingCount = 0;

    for (const [dx, dy] of shape) {
      const x = px + dx, y = py + dy;
      const neighbors = [
        `${x - 1},${y}`, `${x + 1},${y}`,
        `${x},${y - 1}`, `${x},${y + 1}`
      ];
      for (const neighbor of neighbors) {
        if (occupied.has(neighbor)) {
          touchingCount++;
          break; // この1マスが接していればそれでOKなので break
        }
      }
    }

    return touchingCount >= 2;
  }


  let added = 0;
  let tries = 0;

  while (added < count && tries < maxTries) {
    const availableIndices = baseShapes
      .map((_, i) => i)
      .filter(i => !usedShapeIndices.has(i));
    if (availableIndices.length === 0) break;

    const shapeIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
    const originalShape = baseShapes[shapeIndex].shape;
    const rotation = Math.floor(Math.random() * 4);
    const shape = rotateShape(originalShape, rotation);

    const shapeW = Math.max(...shape.map(([dx]) => dx));
    const shapeH = Math.max(...shape.map(([_, dy]) => dy));
    const px = Math.floor(Math.random() * (24 - shapeW));
    const py = Math.floor(Math.random() * (16 - shapeH));

    if (!isValidPlacement(shape, px, py)) {
      tries++;
      continue;
    }
    if (added > 0 && !isTouching(shape, px, py)) {
      tries++;
      continue;
    }

    for (const [dx, dy] of shape) {
      const x = px + dx;
      const y = py + dy;
      placedCells.push([x, y]);
      occupied.add(coordKey(x, y));
    }

    usedShapeIndices.add(shapeIndex);
    usedIndices.push({ index: shapeIndex, shape, x: px, y: py });
    added++;
  }

  // ★ 相対座標に変換
  const minX = Math.min(...placedCells.map(([x]) => x));
  const minY = Math.min(...placedCells.map(([_, y]) => y));
  const normalizedShape = placedCells.map(([x, y]) => [x - minX, y - minY]);

  return {
    shape: normalizedShape,     // 相対形状
    x: targetX,                 // 右上へ配置
    y: targetY,
    color: 'black',
    pieces: usedIndices
  };
}





function rotateShape(shape, times = 1) {
  let result = shape;
  for (let t = 0; t < times; t++) {
    result = result.map(([x, y]) => [y, -x]);
  }
  return normalizeShape(result);
}

function normalizeShape(shape) {
  const minX = Math.min(...shape.map(([x]) => x));
  const minY = Math.min(...shape.map(([_, y]) => y));
  return shape.map(([x, y]) => [x - minX, y - minY]);
}


function checkCompletion(pieces, silhouette) {
  const silhouetteSet = new Set(
    silhouette.shape.map(([dx, dy]) => `${silhouette.x + dx},${silhouette.y + dy}`)
  );

  const covered = new Set();

  for (const piece of pieces) {
    let touchesSilhouette = false;
    for (const [dx, dy] of piece.shape) {
      const absX = piece.x + dx;
      const absY = piece.y + dy;
      const key = `${absX},${absY}`;

      if (silhouetteSet.has(key)) {
        covered.add(key);
        touchesSilhouette = true;
      }
    }

    if (!touchesSilhouette) {
      // 完全に外にある → 無視
      continue;
    }

    // 枠と接しているのに、枠外にはみ出してるブロックがあればNG
    for (const [dx, dy] of piece.shape) {
      const absX = piece.x + dx;
      const absY = piece.y + dy;
      const key = `${absX},${absY}`;
      if (!silhouetteSet.has(key)) {
        return false; // はみ出し
      }
    }
  }

  // 全部のマスがカバーされたか?
  for (const key of silhouetteSet) {
    if (!covered.has(key)) return false;
  }

  return true;
}





function nextStage() {
  stage++;
  const pieceCount = Math.min(stage + 1, 8); //
  silhouette = generateSilhouette(baseShapes, 14, 1, pieceCount);

  pieces.length = 0; // クリア
  pieces = setPieces()
/*
  for (const entry of silhouette.pieces) {
    const color = pieceColors[entry.index % pieceColors.length];
    pieces.push(new Piece(entry.shape, color, entry.x, entry.y));
  }
*/
  if(stage > 4){
    updateURLWithSilhouette(silhouette);
  }
  draw();
}





function drawSilhouette() {
  const set = new Set(silhouette.shape.map(([dx, dy]) => `${dx},${dy}`));
  ctx.lineWidth = 6;
  ctx.lineCap = 'round'; 
  ctx.strokeStyle = silhouette.color;

  for (const [dx, dy] of silhouette.shape) {
    const px = (silhouette.x + dx) * gridSize;
    const py = (silhouette.y + dy) * gridSize;

    ctx.beginPath();

    // 上辺
    if (!set.has(`${dx},${dy - 1}`)) {
      ctx.moveTo(px, py);
      ctx.lineTo(px + gridSize, py);
    }

    // 下辺
    if (!set.has(`${dx},${dy + 1}`)) {
      ctx.moveTo(px, py + gridSize);
      ctx.lineTo(px + gridSize, py + gridSize);
    }

    // 左辺
    if (!set.has(`${dx - 1},${dy}`)) {
      ctx.moveTo(px, py);
      ctx.lineTo(px, py + gridSize);
    }

    // 右辺
    if (!set.has(`${dx + 1},${dy}`)) {
      ctx.moveTo(px + gridSize, py);
      ctx.lineTo(px + gridSize, py + gridSize);
    }

    ctx.stroke();
  }

  ctx.lineWidth = 1; // 元に戻す
  ctx.lineCap = 'butt'; 
}


function drawStartMessage() {
  ctx.save();
  ctx.font = `${gridSize * 0.6}px sans-serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fontWeight = 'bold';
  ctx.fillStyle = 'black';

  const message1 = '黒枠内にぴったりブロックを移動させよう!';
  const message2 = '◆移動:ドラッグ・選択Tab&↑↓←→ ';
  const message3 = '◆回転:ダブルクリック・スペース・R';

  const centerX = canvas.width / 2;
  const startY = canvas.height - gridSize * 3;
  const lineHeight = gridSize * 1;

  ctx.fillText(message1, centerX, startY);
  ctx.fillText(message2, centerX, startY+lineHeight*1);
  ctx.fillText(message3, centerX, startY+lineHeight*2);
  ctx.restore();
}



let lastTapTime = 0;
let lastTapX = 0;
let lastTapY = 0;
const DOUBLE_TAP_DELAY = 300; // 300ms以内
const DOUBLE_TAP_DISTANCE = 20; // px



function startInteraction(e) {
  e.preventDefault();
  const { x, y } = getCorrectedCoords(e);

  let hitPiece = null;
  for (const p of pieces.slice().reverse()) {
    if (p.isHit(x, y)) {
      hitPiece = p;
      break;
    }
  }
  // ダブルタップ処理(タッチ時のみ)
  if (e.type === 'touchstart') {
    
    enterFullscreen(canvas);
    const now = Date.now();
    const timeDiff = now - lastTapTime;
    const dx = x - lastTapX;
    const dy = y - lastTapY;
    const distance = Math.sqrt(dx * dx + dy * dy);

    if (timeDiff < DOUBLE_TAP_DELAY && distance < DOUBLE_TAP_DISTANCE) {
      if (hitPiece) {
        hitPiece.rotate();
        draw();
      }
      lastTapTime = 0; // リセット
      //return; // 回転だけして終了
    }

    lastTapTime = now;
    lastTapX = x;
    lastTapY = y;
  }


  // ドラッグ処理(hitしていれば)
  if (hitPiece) {
    draggingPiece = hitPiece;
    hitPiece.startDrag(x, y);
    setLastUsedPiece(hitPiece);
  }
}

function moveInteraction(e) {
  e.preventDefault();
  if (!draggingPiece) return;
  const { x, y } = getCorrectedCoords(e);
  draggingPiece.dragTo(x, y);
  draw();
}

function endInteraction(e) {
  e.preventDefault();
  if (draggingPiece) {
    draggingPiece.endDrag();
    draggingPiece = null;
    draw();

    if (checkCompletion(pieces, silhouette)) {
      silhouette.color = 'red';
      draw();
      setTimeout(() => nextStage(), 300);
    }
  }
}


function handleDoubleClick(e) {
  e.preventDefault();
  isMessage = false;
  const { x, y } = getCorrectedCoords(e);
  for (const p of pieces.slice().reverse()) {
    if (p.isHit(x, y)) {
      p.rotate();
      setLastUsedPiece(p);
      draw();
      break;
    }
  }
}


// マウス
canvas.addEventListener('mousedown', startInteraction);
canvas.addEventListener('mousemove', moveInteraction);
canvas.addEventListener('mouseup', endInteraction);
canvas.addEventListener('dblclick', handleDoubleClick);

// タッチ
canvas.addEventListener('touchstart', startInteraction, { passive: false });
canvas.addEventListener('touchmove', moveInteraction, { passive: false });
canvas.addEventListener('touchend', endInteraction, { passive: false });
canvas.addEventListener('touchcancel', endInteraction, { passive: false });

document.addEventListener('keydown', (e) => {

  // Tabでピース選択
  if (e.key === 'Tab') {
    e.preventDefault(); // フォーカス移動を防ぐ
    if (pieces.length === 0) return;
    let currentIndex = pieces.indexOf(lastUsedPiece);
    
    if (e.shiftKey) {
      // Shift+Tab で逆順
      currentIndex = (currentIndex <= 0) ? pieces.length - 1 : currentIndex - 1;
    } else {
      // 通常のTabで順送り
      currentIndex = (currentIndex + 1) % pieces.length;
    }

    setLastUsedPiece(pieces[currentIndex]); 
    draw();
    return;
  }

  if (!lastUsedPiece) return;

  switch (e.key) {
    case 'r':
    case 'R':
    case ' ':
      lastUsedPiece.rotate();
      break;
    case 'ArrowLeft':
      lastUsedPiece.x -= 1;
      break;
    case 'ArrowRight':
      lastUsedPiece.x += 1;
      break;
    case 'ArrowUp':
      lastUsedPiece.y -= 1;
      break;
    case 'ArrowDown':
      lastUsedPiece.y += 1;
      break;
    default:
      return; //
  }

  e.preventDefault();
  draw();
});


function enterFullscreen(element) {
  if (element.requestFullscreen) {
    element.requestFullscreen();
  } else if (element.webkitRequestFullscreen) { // Safari
    element.webkitRequestFullscreen();
  } else if (element.msRequestFullscreen) { // IE11
    element.msRequestFullscreen();
  }
}




function exportSilhouette(silhouette) {
  return {
    shape: silhouette.shape,
    x: silhouette.x,
    y: silhouette.y,
    pieces: silhouette.pieces.map(p => ({
      index: p.index,
      shape: p.shape,
      x: p.x,
      y: p.y
    }))
  };
}
function generateURLFromSilhouette(silhouette) {
  const data = exportSilhouette(silhouette);
  const json = JSON.stringify(data);
  const base64 = btoa(encodeURIComponent(json));
  return `${location.origin}${location.pathname}?data=${base64}`;
}
function importSilhouetteFromURL() {
  const params = new URLSearchParams(window.location.search);
  const data = params.get('data');
  if (!data) return null;

  try {
    const json = decodeURIComponent(atob(data));
    const obj = JSON.parse(json);
    return obj;
  } catch (e) {
    console.error("データの読み込み失敗", e);
    return null;
  }
}

function updateURLWithSilhouette(silhouette) {
  const data = {
    shape: silhouette.shape,
    x: silhouette.x,
    y: silhouette.y,
    pieces: silhouette.pieces.map(p => ({
      index: p.index,
      shape: p.shape,
      x: p.x,
      y: p.y
    }))
  };
  const base64 = btoa(encodeURIComponent(JSON.stringify(data)));
  const newURL = `${location.origin}${location.pathname}?data=${base64}`;
  history.replaceState(null, '', newURL);
}


const imported = importSilhouetteFromURL();
if (imported) {
  silhouette = {
    shape: imported.shape,
    x: imported.x,
    y: imported.y,
    color: 'black',
    pieces: imported.pieces
  };
} else {
  nextStage(); // 通常スタート
}






draw();

CSS

HTML

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

view-source:https://hi0a.com/game/puzzle-block/

ABOUT

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

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

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

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

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

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

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