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つ振る。その出目に応じて、左側のピースを手元に用意し、右側のシルエットに合わせてピースを配置する。ピースを置く際に、はみ出たり、他のピースの上に重ねたりしてはならない。これを、砂時計がひっくり返され、落ちきるまでに完成させなければならない。
*/

// Ubongo風の盤面をキャンバスに描く(簡易バージョン)

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';

// グリッドサイズやパーツ設定
const gridSize = 40;
const boardWidth = 32;
const boardHeight = 18;

let stage = 0; // 初期ステージ(1 → 3ピース)


function getCorrectedCoords(e) {
  // CSSで適用されているtransform(scaleなど)を考慮
  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;
  }

  // ピース描画
  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.strokeStyle = '#333';
        ctx.strokeRect(px, py, gridSize, gridSize);
      } else {
        ctx.strokeRect(px, py, gridSize, gridSize);
      }
    }
    ctx.globalAlpha = 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]);
  }


}



const baseShapes = [
  { name: '四角', shape: [[0, 0], [1, 0], [0, 1], [1, 1]] },
  { name: 'I字', shape: [[0, 0], [1, 0], [2, 0], [3, 0]] },
  { name: 'T字', shape: [[0, 1], [1, 1], [2, 1], [1, 0]] },
  { name: '十字', shape: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]] },
  { name: 'L字', shape: [[0, 0], [0, 1], [0, 2], [1, 2]] },
  { name: '逆L', shape: [[0, 2], [1, 2], [1, 1], [1, 0]] },
  { 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]] },

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();

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

// --- シルエット描画(アウトライン) ---
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));
    });
  }

  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) {
    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);
      }
    }
  }

  // ✅ すべてのシルエットマスが covered に含まれているか
  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));
  }
*/
  draw();
}
nextStage();




function drawSilhouette() {
  const set = new Set(silhouette.shape.map(([dx, dy]) => `${dx},${dy}`));
  ctx.lineWidth = 4;
  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; // 元に戻す
}


canvas.addEventListener('mousedown', e => {

  const {x,y} = getCorrectedCoords(e);

  for (const p of pieces.slice().reverse()) {
    if (p.isHit(x, y)) {
      draggingPiece = p;
      p.startDrag(x, y);
      break;
    }
  }
});

canvas.addEventListener('mousemove', e => {
  if (!draggingPiece) return;
  const {x,y} = getCorrectedCoords(e);

  draggingPiece.dragTo(x, y);
  draw();
});


canvas.addEventListener('mouseup', () => {
  if (draggingPiece) {
    draggingPiece.endDrag();
    draggingPiece = null;
  }

  draw(); // 毎回描画

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



canvas.addEventListener('dblclick', e => {
  const {x,y} = getCorrectedCoords(e);

  for (const p of pieces.slice().reverse()) {
    if (p.isHit(x, y)) {
      p.rotate(); // ← ここで回転!
      draw();
      break;
    }
  }
});

draw();


//drawGrid();

// サンプルピース描画(T字型)
//drawPiece(2, 2, [[0, 0], [1, 0], [2, 0], [1, 1]], '#e91e63');

CSS

HTML

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

view-source:https://hi0a.com/demo/-js/js-puzzle-upongo/

ABOUT

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

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

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

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

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

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

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